1use std::path::Path;
4use std::sync::Arc;
5
6use speccoder_core::stage::{Stage, StageState};
7use speccoder_core::{CommandData, CommandResult, CommandStatus};
8
9use crate::pipeline::*;
10use crate::{
11 approvals, attempts, external, git, integrity, mcp, mutation, progress, resume, sandbox,
12 substrate, tools, verify, worktree,
13};
14
15#[must_use]
18pub fn run(path: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
19 let Some(state) = worktree::state_dir() else {
20 return CommandResult::new(
21 "run",
22 CommandStatus::ConfigError,
23 "no state directory (set SPECCODER_STATE_DIR or HOME)",
24 )
25 .with_code("NO-STATE-DIR");
26 };
27 let model = match setup(path, feature, &state, "run") {
28 Ok(s) => s.model,
29 Err(r) => return *r,
30 };
31 let (driver, key) = match substrate::model_config::open_driver(&model) {
32 Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
33 Err(e) => return substrate::model_config::refused("run", &e),
34 };
35 match driver {
36 Ok(provider) => run_task(path, feature, task, &state, provider, Some(&key), options),
37 Err(e) => CommandResult::new("run", CommandStatus::ConfigError, &redact(&e, Some(&key))),
38 }
39}
40
41pub fn run_with(
43 path: &Path,
44 feature: Option<&str>,
45 task: &str,
46 state: &Path,
47 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
48 secret: Option<&str>,
49) -> CommandResult {
50 run_task(
51 path,
52 feature,
53 task,
54 state,
55 provider,
56 secret,
57 &RunOptions::default(),
58 )
59}
60
61#[must_use]
63pub fn run_with_options(
64 path: &Path,
65 feature: Option<&str>,
66 task: &str,
67 state: &Path,
68 provider: Arc<dyn agentplane::model::ModelProvider>,
69 secret: Option<&str>,
70 options: &RunOptions,
71) -> CommandResult {
72 run_task(path, feature, task, state, provider, secret, options)
73}
74
75#[must_use]
77pub fn run_with_approver(
78 path: &Path,
79 feature: Option<&str>,
80 task: &str,
81 state: &Path,
82 provider: Arc<dyn agentplane::model::ModelProvider>,
83 secret: Option<&str>,
84 approver: Arc<dyn approvals::Approver>,
85) -> CommandResult {
86 let options = RunOptions {
87 approver: Some(approver),
88 ..RunOptions::default()
89 };
90 run_task(path, feature, task, state, provider, secret, &options)
91}
92
93#[must_use]
96pub fn run_native_bug(
97 root: &Path,
98 bug: &speccoder_core::bug::BugReport,
99 state: &Path,
100 provider: Arc<dyn agentplane::model::ModelProvider>,
101 secret: Option<&str>,
102 options: &RunOptions,
103) -> CommandResult {
104 let options = RunOptions {
105 bug_home: Some(Arc::new(super::bug::NativeBug::of(bug))),
106 ..options.clone()
107 };
108 run_task(root, None, &bug.task, state, provider, secret, &options)
109}
110
111pub(crate) fn run_task(
115 path: &Path,
116 feature: Option<&str>,
117 task: &str,
118 state: &Path,
119 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
120 secret: Option<&str>,
121 options: &RunOptions,
122) -> CommandResult {
123 let first = run_task_once(
124 path,
125 feature,
126 task,
127 state,
128 Arc::clone(&provider),
129 secret,
130 options,
131 );
132 match first.data() {
133 Some(CommandData::Run(r)) if r.attempt_status == CHANGED_CODE => {
134 if let Some(p) = &options.progress {
135 p.event(&progress::Event::Notice {
136 text: format!("{task} changed code, so it runs as code"),
137 });
138 }
139 run_task_once(path, feature, task, state, provider, secret, options)
140 }
141 _ => first,
142 }
143}
144
145pub const CHANGED_CODE: &str = "planning_changed_code";
148
149#[allow(clippy::too_many_lines)] fn run_task_once(
151 path: &Path,
152 feature: Option<&str>,
153 task: &str,
154 state: &Path,
155 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
156 secret: Option<&str>,
157 options: &RunOptions,
158) -> CommandResult {
159 use speccoder_core::RunReport;
160 if options.in_place && options.session.is_none() {
162 return in_session("run", path, feature, state, options, |o| {
163 run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
164 });
165 }
166 let approver = options.approver.clone();
167 use speccoder_core::snapshot::TaskState;
168
169 let refuse =
170 |status: CommandStatus, m: String| CommandResult::new("run", status, &redact(&m, secret));
171
172 let s = match setup(path, feature, state, "run") {
174 Ok(s) => s,
175 Err(r) => return *r,
176 };
177 let native = options.bug_home.clone();
179 let feature_name = match (&native, s.snapshot.feature.as_ref()) {
180 (Some(n), _) => n.unit.clone(),
181 (None, Some(f)) => f.feature.name.clone(),
182 (None, None) => {
183 return refuse(
184 CommandStatus::SelectionNeeded,
185 "no feature selected; pass --feature".to_owned(),
186 );
187 }
188 };
189 let plane_dir = match &options.lane {
192 Some(lane) => s.plane_dir.join("lanes").join(lane),
193 None => s.plane_dir.clone(),
194 };
195 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
197 &plane_dir,
198 s.model.prices,
199 options.progress.clone(),
200 ));
201
202 let wt = match &native {
204 Some(n) => crate::commands::worktree_of(&s.snapshot, &n.unit, &n.dir, state),
205 None => workspace(path, Some(&feature_name), state, options),
206 };
207 let Some(CommandData::Worktree(w)) = wt.data() else {
208 return CommandResult::carried("run", &wt);
209 };
210 if wt.status() != CommandStatus::Ok {
211 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
212 }
213 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
214 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
215 };
216 let (wt_path, project_dir) = match &options.lane {
217 None => (wt_path, project_dir),
218 Some(lane) => match lane_worktree(path, state, &feature_name, lane, &wt_path, &project_dir)
219 {
220 Ok(paths) => paths,
221 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
222 },
223 };
224 let project = std::path::PathBuf::from(&project_dir);
225 let wt_root = std::path::PathBuf::from(&wt_path);
226 let tmp = plane_dir.join("tmp");
227
228 let elsewhere = if options.lane.is_none() {
234 match crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task) {
235 Ok(e) => e,
236 Err(e) => {
237 return refuse(
238 CommandStatus::Error,
239 format!(
240 "the calls waiting for a person could not be read, so whether another task's attempt holds the worktree is unknown: {e}"
241 ),
242 );
243 }
244 }
245 } else {
246 crate::approvals::Elsewhere::default()
247 };
248 if let Some((other, covers)) = elsewhere.waiting {
249 return refuse(
250 CommandStatus::NeedsHuman,
251 format!(
252 "{other} waits for a person ({covers}) and its attempt holds the worktree: decide it (`speccoder task approve {other}` or `reject`), then `speccoder run --task {other}`"
253 ),
254 );
255 }
256 if options.lane.is_none()
258 && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
259 {
260 return refuse(
261 CommandStatus::NeedsHuman,
262 format!(
263 "{other}'s acceptance tests reject an implementation the spec appears to allow, and its attempt holds the worktree: `speccoder task rule {other} --tests-right --reason …` or `speccoder run --task {other} --fresh-acceptance`"
264 ),
265 )
266 .with_code("TESTS-OVER-STRICT");
267 }
268 let commits_per_task = verify::find_config(&project)
272 .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
273 .and_then(|t| substrate::run_config::parse(&t).ok())
274 .is_none_or(|c| c.commit_per_task);
275 let marker_path = resume::path(&plane_dir, &feature_name, task);
278 if options.fresh_acceptance && options.lane.is_none() {
281 let _ = std::fs::remove_file(&marker_path);
282 crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
283 }
284 let mut head_now = git::Git::at(&wt_root)
285 .line(&["rev-parse", "HEAD"])
286 .unwrap_or_default();
287 let autopilot = options.mode == speccoder_core::oracle::RunMode::Autopilot;
292 let interrupted = resume::load(&marker_path).filter(|m| {
293 m.head == head_now && options.lane.is_none() && !head_now.is_empty() && !autopilot
294 });
295 if let Some(m) = &interrupted {
298 if let (Some(id), Ok(mut run)) = (&m.run_id, options.run_id.lock()) {
299 *run = crate::pipeline::RunId(id.clone());
300 }
301 if let Ok(mut asked) = options.asked.lock() {
302 *asked = (*asked).max(m.asked);
303 }
304 if let Ok(mut g) = options.run_grants.lock() {
305 for o in &m.run_grants {
306 if !g.contains(o) {
307 g.push(o.clone());
308 }
309 }
310 }
311 }
312 let marker_guard = resume::Guard::new(marker_path.clone());
313 let resumed_from = interrupted.as_ref().map(|_| {
316 std::fs::read_to_string(plane_dir.join(progress::FILE))
317 .ok()
318 .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
319 .filter(|l| l.task == task)
320 .map_or((None, None), |l| (l.stage, l.last_call))
321 });
322 let mut set_aside = None;
323 if interrupted.is_none()
325 && commits_per_task
326 && options.session.is_none()
327 && worktree::require_clean(&wt_root).is_err()
328 {
329 let left = worktree::leftovers(&wt_root);
333 if !left.is_empty() {
334 let mut what = Vec::new();
335 if !left.commit.is_empty() {
336 what.push(format!("{} is not committed", left.commit.join(", ")));
337 }
338 if !left.ignore.is_empty() {
339 what.push(format!("{} is not git-ignored", left.ignore.join(", ")));
340 }
341 let checkout = std::path::Path::new(&s.snapshot.root);
342 let remedy = crate::code::setup_remedy(checkout, Some(&feature_name));
343 let command = remedy
344 .as_ref()
345 .map_or_else(|| left.fix(checkout, &wt_root, &[]), |r| r.command.clone());
346 return refuse(
347 CommandStatus::GitPrecondition,
348 format!(
349 "{} — the checks write it on every run, so each run would find it in the worktree and set it aside",
350 what.join(" and "),
351 ),
352 )
353 .with_code("SETUP-INCOMPLETE")
354 .with_fix(vec!["speccoder fix".to_owned(), command])
355 .with_remedy(remedy);
356 }
357 match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
358 Ok(reference) => set_aside = Some(reference),
359 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
360 }
361 }
362
363 let format = crate::format::at(&project);
365 let Some(snap) = format.inspect(
366 &project,
367 if native.is_some() {
368 None
369 } else {
370 Some(&feature_name)
371 },
372 ) else {
373 return refuse(
374 CommandStatus::NotAProject,
375 format.messages().no_project_in_worktree.to_owned(),
376 );
377 };
378 if native.is_some()
379 && crate::bug::state::load(&s.plane_dir, &feature_name, task)
380 .is_some_and(|b| b.outcome == speccoder_core::bug::Outcome::Fixed)
381 {
382 return refuse(
383 CommandStatus::TaskNotReady,
384 format!("{task} is already fixed"),
385 )
386 .with_code("TASK-DONE");
387 }
388 if native.is_none() {
391 let Some(st) = snap
392 .feature
393 .as_ref()
394 .and_then(|f| f.schedule.as_ref())
395 .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
396 else {
397 return refuse(
398 CommandStatus::NotFound,
399 format!("no task {task} in {feature_name}"),
400 );
401 };
402 match st.state {
403 TaskState::Done => {
404 return refuse(
405 CommandStatus::TaskNotReady,
406 format!("{task} is already done"),
407 )
408 .with_code("TASK-DONE");
409 }
410 TaskState::Blocked => {
411 return refuse(
412 CommandStatus::TaskNotReady,
413 format!("{task} is blocked by {}", st.blocked_by.join(", ")),
414 )
415 .with_code("TASK-BLOCKED");
416 }
417 TaskState::Ready => {}
418 }
419 }
420 let checklists_overridden = if native.is_some() {
423 false
424 } else {
425 match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
426 Ok(overridden) => overridden,
427 Err(stop) => return *stop,
428 }
429 };
430 let parsed_task = snap
431 .feature
432 .as_ref()
433 .and_then(|f| f.tasks.as_ref())
434 .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
435 let tasks_file = snap
436 .feature
437 .as_ref()
438 .and_then(|f| f.tasks.as_ref())
439 .map(|t| project.join(&t.path));
440
441 let bundle = match native
442 .as_ref()
443 .map(|n| n.bundle(&project))
444 .map_or_else(|| format.bundle(&snap, task), Ok)
445 {
446 Ok(b) => b,
447 Err(_) => {
448 return refuse(
449 CommandStatus::NotFound,
450 format!("no task {task} in the worktree's {feature_name}"),
451 );
452 }
453 };
454 let acceptance_story = native
457 .is_none()
458 .then(|| crate::acceptance_at::story_at(&s.plane_dir, &feature_name, task))
459 .flatten();
460 let story_task: Option<String> = acceptance_story.as_ref().and_then(|st| {
461 snap.feature
462 .as_ref()
463 .and_then(|f| f.tasks.as_ref())
464 .and_then(|t| {
465 t.tasks
466 .iter()
467 .find(|t| t.story.as_deref() == Some(st.as_str()))
468 })
469 .map(|t| t.id.clone())
470 });
471 let bundle = match (&acceptance_story, &story_task) {
472 (Some(st), Some(other)) => {
473 let mut b = bundle;
474 if let Ok(of) = format.bundle(&snap, other) {
475 b.story = of.story;
476 }
477 b.task.story.clone_from(st);
478 b
479 }
480 _ => bundle,
481 };
482 let Some(config_root) = verify::find_config(&project) else {
483 return refuse(
484 CommandStatus::ConfigError,
485 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
486 )
487 .with_code("CONFIG-MISSING");
488 };
489 let config_text =
490 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
491 let run_config = match substrate::run_config::parse(&config_text) {
492 Ok(c) => c,
493 Err(e) => {
494 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
495 .with_code("CONFIG-INVALID");
496 }
497 };
498 if run_config.review_required
501 && let Some(f) = snap.feature.as_ref()
502 && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
503 && !t.complete()
504 {
505 return refuse(
506 CommandStatus::NeedsHuman,
507 format!(
508 "{feature_name}'s spec is not approved yet ({} approved; {} to review, {} with changes requested, {} changed since, {} open question(s); committed review only) and [review] required is on",
509 t.approved, t.open, t.changes_requested, t.lapsed, t.questions
510 ),
511 )
512 .with_code("REVIEW-PENDING");
513 }
514 if run_config.review_required {
517 let ambiguous = match crate::oracle::open_ambiguities(
518 &s.plane_dir,
519 &feature_name,
520 &format.oracle_requirements(&snap),
521 ) {
522 Ok(a) => a,
523 Err(t) => {
524 return refuse(
525 CommandStatus::NeedsHuman,
526 format!(
527 "{}: whether it is read two ways is unknown and [review] required is on",
528 t.0
529 ),
530 )
531 .with_code("ORACLE-TAMPERED");
532 }
533 };
534 if let Some((req, scenario)) = ambiguous.first() {
535 return refuse(
536 CommandStatus::NeedsHuman,
537 format!(
538 "{req} is read two ways ({scenario}) and [review] required is on: choose a reading or reword it first"
539 ),
540 )
541 .with_code("REVIEW-AMBIGUOUS")
542 .with_fix(vec![
543 format!("speccoder review tests {req} --reading A {scenario}"),
544 format!("speccoder review tests {req} --reading B {scenario}"),
545 ]);
546 }
547 }
548 let executor = match external::parse(&config_text) {
549 Ok(e) => e,
550 Err(e) => {
551 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
552 .with_code("CONFIG-INVALID");
553 }
554 };
555 let executor = match options.agent.as_deref() {
557 None => executor,
558 Some("native") => None,
559 Some(id) => match external::for_agent(id, executor.as_ref()) {
560 Ok(e) => Some(e),
561 Err(e) => return refuse(CommandStatus::UsageError, e),
562 },
563 };
564 let sandbox_config = match sandbox::parse(&config_text) {
566 Ok(c) => c,
567 Err(e) => {
568 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
569 .with_code("CONFIG-INVALID");
570 }
571 };
572 let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
573 Ok(r) => r,
574 Err(e) => return refuse(CommandStatus::ConfigError, e),
575 };
576 if options.mode == speccoder_core::oracle::RunMode::Autopilot && !runner.is_sandboxed() {
578 return refuse(
579 CommandStatus::ConfigError,
580 "autopilot needs the OS sandbox, and commands here would run unsandboxed".to_owned(),
581 )
582 .with_code("SANDBOX-UNAVAILABLE");
583 }
584 let mutation_config = match mutation::parse(&config_text) {
585 Ok(c) => c,
586 Err(e) => {
587 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
588 .with_code("CONFIG-INVALID");
589 }
590 };
591 let worktree_config = match crate::prepare::parse(&config_text) {
592 Ok(c) => c,
593 Err(e) => {
594 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
595 .with_code("CONFIG-INVALID");
596 }
597 };
598 let check_argvs = verify::check_argvs(&config_root);
599 let unit_dir = snap
603 .feature
604 .as_ref()
605 .map(|f| f.feature.dir.clone())
606 .unwrap_or_default();
607 let changed_code = crate::attempts::list(&wt_root, &feature_name)
608 .iter()
609 .any(|a| a.task == task && a.outcome == CHANGED_CODE);
610 let planning: Option<String> = parsed_task
611 .as_ref()
612 .filter(|t| {
613 native.is_none()
614 && !changed_code
615 && speccoder_core::lane::by_paths(&t.title, &unit_dir, &format.rules().judged)
616 == speccoder_core::lane::Lane::Planning
617 })
618 .map(|_| {
619 let prefix = project
620 .strip_prefix(&wt_root)
621 .map(|p| p.to_string_lossy().replace('\\', "/"))
622 .unwrap_or_default();
623 if prefix.is_empty() {
624 unit_dir.trim_end_matches('/').to_owned()
625 } else {
626 format!("{prefix}/{}", unit_dir.trim_end_matches('/'))
627 }
628 });
629 let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow)
630 .with_prefixes(run_config.allow_prefix.clone())
631 .rules(format.rules());
632 let policy = match &planning {
633 Some(dir) => policy.planning(dir),
634 None => policy,
635 };
636 let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
639 {
640 Some(s) => s.confine(policy, runner),
641 None => (policy, runner, None),
642 };
643 let sandbox_notice = sandbox_notice.or(carve_notice);
644 let contained = runner.confines().filter(|_| run_config.contained);
647 let contained_notice = (run_config.contained && contained.is_none()).then(|| {
648 format!(
649 "contained calls ask: {}",
650 match &runner {
651 sandbox::Runner::Plain => "no sandbox is enforced",
652 sandbox::Runner::Sandboxed(s) if s.profile.network == sandbox::Network::On => {
653 "the sandbox gives the network"
654 }
655 sandbox::Runner::Sandboxed(_) =>
656 "the sandbox cannot confine signals or `.git` here",
657 }
658 )
659 });
660 if let Some(e) = options.session.as_ref().and_then(|s| {
663 s.lock().ok().and_then(|s| {
664 if interrupted.is_some() {
666 s.check_before_tick(&policy).err()
667 } else {
668 s.check(&policy).err()
669 }
670 })
671 }) {
672 return e.result("run");
673 }
674
675 let wt_root_canonical = std::fs::canonicalize(&wt_root)
676 .unwrap_or_else(|_| wt_root.clone())
677 .display()
678 .to_string();
679
680 let this_run = options.run();
683 let (decisions, task_grants) = match approvals::Decisions::load(&s.plane_dir) {
684 Ok(mut d) => {
685 if !autopilot
688 && d.claim(&feature_name, task, this_run.as_str())
689 && let Err(e) = d.save(&s.plane_dir)
690 {
691 return refuse(CommandStatus::Error, e);
692 }
693 (
694 d.for_task(&feature_name, task),
695 if autopilot {
696 Vec::new()
697 } else {
698 d.for_run(&feature_name, task, this_run.as_str())
699 },
700 )
701 }
702 Err(e) => return refuse(CommandStatus::ConfigError, e),
703 };
704
705 if let Some((stage, last_call)) = &resumed_from {
706 live.event(&progress::Event::Resumed {
707 task: task.to_owned(),
708 stage: *stage,
709 last_call: last_call.clone(),
710 });
711 }
712 live.event(&progress::Event::TaskStarted {
713 feature: feature_name.clone(),
714 task: task.to_owned(),
715 title: parsed_task
716 .as_ref()
717 .map(|t| t.title.clone())
718 .unwrap_or_default(),
719 });
720 if planning.is_some()
721 && let Some(t) = &parsed_task
722 {
723 live.event(&progress::Event::Notice {
724 text: format!(
725 "📐 planning · writes {}",
726 speccoder_core::lane::task_paths(&t.title, &unit_dir).join(", ")
727 ),
728 });
729 }
730 let fail = |status: CommandStatus, m: String| {
732 live.event(&progress::Event::TaskFinished {
733 task: task.to_owned(),
734 outcome: "error".to_owned(),
735 message: redact(&m, secret),
736 run: None,
737 input_tokens: 0,
738 output_tokens: 0,
739 cache_read_tokens: 0,
740 cache_write_tokens: 0,
741 usd: None,
742 regression: None,
743 });
744 refuse(status, m)
745 };
746 let mut prepare_notes: Vec<String> = Vec::new();
750 let mut copied_files: Vec<String> = Vec::new();
753 if options.session.is_none() {
754 match crate::include::copy(
755 &wt_root,
756 &project,
757 Path::new(&s.snapshot.root),
758 &worktree_config.include,
759 ) {
760 Ok(c) => {
761 copied_files.clone_from(&c.files);
762 if !c.now.is_empty() {
763 prepare_notes.push(format!(
764 "copied from your checkout ([worktree] include): {}",
765 c.now.join(", ")
766 ));
767 }
768 prepare_notes.extend(
769 c.skipped
770 .iter()
771 .map(|n| format!("not copied ([worktree] include): {n}")),
772 );
773 for n in &prepare_notes {
774 live.event(&progress::Event::Notice { text: n.clone() });
775 }
776 }
777 Err(e) => {
778 return fail(
779 CommandStatus::ConfigError,
780 format!("the files [worktree] include names could not be copied: {e}"),
781 )
782 .with_code("PREPARE-FAILED");
783 }
784 }
785 }
786 let prepares =
787 options.session.is_none() && worktree_config.prepare && crate::prepare::needed(&project);
788 if prepares {
789 let began = std::time::Instant::now();
790 stage(
791 &*live,
792 task,
793 Stage::Prepare,
794 StageState::Started,
795 None,
796 None,
797 );
798 let prepared = crate::prepare::prepare(&crate::prepare::Prepare {
799 worktree: &wt_root,
800 project: &project,
801 config: &worktree_config,
802 runner: &runner,
803 state: Some(state),
804 });
805 match prepared {
806 Ok(None) => {}
807 Ok(Some(p)) => {
808 stage(
809 &*live,
810 task,
811 Stage::Prepare,
812 if p.done.is_empty() {
813 StageState::Skipped
814 } else {
815 StageState::Passed
816 },
817 Some(if p.done.is_empty() {
818 "reused: the lockfiles are unchanged".to_owned()
819 } else {
820 p.done.join(", ")
821 }),
822 Some(began),
823 );
824 }
825 Err(f) => {
826 stage(
827 &*live,
828 task,
829 Stage::Prepare,
830 StageState::Failed,
831 Some(f.message.clone()),
832 Some(began),
833 );
834 return fail(CommandStatus::ConfigError, f.message)
835 .with_code("PREPARE-FAILED")
836 .with_fix(f.fix)
837 .with_commands(f.commands);
838 }
839 }
840 } else if options.session.is_none() {
841 crate::prepare::forget(&wt_root);
843 }
844
845 let began = std::time::Instant::now();
847 stage(
848 &*live,
849 task,
850 Stage::Baseline,
851 StageState::Started,
852 None,
853 None,
854 );
855 let mut baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner)
856 {
857 Ok(b) => b,
858 Err(e) => {
859 stage(
860 &*live,
861 task,
862 Stage::Baseline,
863 StageState::Failed,
864 None,
865 Some(began),
866 );
867 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
868 }
869 };
870 if !options.allow_failing_baseline
876 && !super::story::open_oracle(&wt_root, &snap, format)
877 && let Some((c, why)) = baseline
878 .checks
879 .iter()
880 .find_map(|c| broken(c).map(|w| (c, w)))
881 {
882 stage(
883 &*live,
884 task,
885 Stage::Baseline,
886 StageState::Failed,
887 Some(format!("`{}` cannot run", c.name)),
888 Some(began),
889 );
890 let checkout = std::path::Path::new(&s.snapshot.root);
893 if let Some(remedy) = crate::code::setup_remedy(checkout, Some(&feature_name)) {
894 return fail(
895 CommandStatus::GitPrecondition,
896 format!(
897 "check `{}` cannot run in the worktree: your checkout has not committed its setup — {why}",
898 c.name
899 ),
900 )
901 .with_code("SETUP-INCOMPLETE")
902 .with_fix(vec!["speccoder fix".to_owned(), remedy.command.clone()])
903 .with_remedy(Some(remedy));
904 }
905 return fail(
906 CommandStatus::ConfigError,
907 if why.contains("git add") || why.contains("--allow-failing-baseline") {
910 format!(
911 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why}",
912 c.name
913 )
914 } else {
915 format!(
916 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why} — fix the check (a file it needs may be uncommitted: `git status`), or run with --allow-failing-baseline",
917 c.name
918 )
919 },
920 )
921 .with_code("CHECK-BROKEN");
922 }
923 let failing = baseline
925 .checks
926 .iter()
927 .filter(|c| c.status.is_failure())
928 .count();
929 stage(
930 &*live,
931 task,
932 Stage::Baseline,
933 StageState::Passed,
934 (failing > 0).then(|| format!("{failing} check(s) already failing")),
935 Some(began),
936 );
937
938 let rt = match tokio_runtime() {
939 Ok(rt) => rt,
940 Err(e) => return fail(CommandStatus::RunFailed, e),
941 };
942 let bundle_value = match serde_json::to_value(&bundle) {
943 Ok(v) => v,
944 Err(e) => return fail(CommandStatus::Error, e.to_string()),
945 };
946 let instructions = crate::instructions::collect(
950 &wt_root,
951 &project,
952 &speccoder_core::scope::named_paths(&bundle.task.title),
953 &run_config.instructions,
954 );
955 let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
956 &instructions
957 } else {
958 &[]
959 };
960
961 let servers = match mcp::parse(&config_text) {
963 Ok(s) => s,
964 Err(e) => {
965 return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
966 .with_code("CONFIG-INVALID");
967 }
968 };
969 let mut connected = if servers.is_empty() {
970 mcp::Connected::default()
971 } else {
972 live.event(&progress::Event::Notice {
973 text: format!("starting {} MCP server(s)", servers.len()),
974 });
975 rt.block_on(mcp::connect(&servers, &project, &runner))
976 };
977 let approved_tools: Vec<String> = task_grants
979 .iter()
980 .filter_map(|g| g.tool_name().map(str::to_owned))
981 .collect();
982 let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
983 connected.notices.extend(pinned);
984
985 let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
988 Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
989 Err(e) => return fail(CommandStatus::Error, e.to_string()),
990 };
991 let swept =
994 match crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc()) {
995 Ok(p) => p,
996 Err(e) => {
997 return fail(
998 CommandStatus::RunFailed,
999 format!("the calls past their deadline could not be denied: {e}"),
1000 );
1001 }
1002 };
1003 let present = crate::approvals::PresentContext {
1005 root: None,
1006 sandbox: runner.is_sandboxed().then(|| {
1007 if cfg!(target_os = "macos") {
1008 "Seatbelt".to_owned()
1009 } else {
1010 "Landlock+seccomp".to_owned()
1011 }
1012 }),
1013 network: match sandbox_config.network {
1014 sandbox::Network::Off => "none",
1015 sandbox::Network::Loopback => "loopback",
1016 sandbox::Network::On => "unrestricted",
1017 }
1018 .to_owned(),
1019 file_text: None,
1020 };
1021 let plane = match rt.block_on(async {
1022 substrate::Plane::open_with(
1023 &plane_dir,
1024 &s.model,
1025 provider,
1026 Some(tools_server),
1027 substrate::Hooks {
1028 progress: Some(Arc::clone(&live)),
1029 mcp: connected.clients.clone(),
1030 mcp_tools: connected.tools.clone(),
1031 format: Some(format),
1032 present: present.clone(),
1033 },
1034 )
1035 }) {
1036 Ok(p) => p,
1037 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1038 };
1039 let mut notices: Vec<String> = elsewhere
1040 .replaced_journal
1041 .iter()
1042 .chain(swept.iter())
1043 .chain(plane.replaced_journal.iter())
1044 .map(|p| {
1045 format!(
1046 "the journal could not be read by this build and was moved to {}; past runs stay in git",
1047 p.display()
1048 )
1049 })
1050 .chain(connected.notices.iter().cloned())
1051 .chain(sandbox_notice.clone())
1052 .chain(contained_notice.clone())
1053 .chain(prepare_notes.iter().cloned())
1054 .chain(resumed_from.as_ref().map(|(stage, last_call)| {
1055 format!(
1056 "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
1057 stage.map_or("a run", speccoder_core::stage::Stage::name),
1058 last_call
1059 .as_ref()
1060 .map(|c| format!(" ({c})"))
1061 .unwrap_or_default()
1062 )
1063 }))
1064 .collect();
1065
1066 let demand_red =
1069 run_config.acceptance && planning.is_none() && format.is_convergence(&bundle.task.phase);
1070 let start = match &interrupted {
1076 Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
1077 Ok(tree) => worktree::Checkpoint(tree),
1078 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1079 },
1080 None => match worktree::checkpoint(&wt_root, &tmp) {
1081 Ok(c) => c,
1082 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1083 },
1084 };
1085
1086 let began = std::time::Instant::now();
1087 let bug_ctx = super::bug::Ctx {
1092 rt: &rt,
1093 plane: &plane,
1094 project: &project,
1095 wt_root: &wt_root,
1096 wt_path: &wt_path,
1097 tmp: &tmp,
1098 policy: &policy,
1099 runner: &runner,
1100 state: &s.plane_dir,
1101 feature: &feature_name,
1102 task,
1103 bundle: &bundle_value,
1104 instructions: &instructions,
1105 live: &*live,
1106 approver: approver.as_deref(),
1107 held_out: run_config.held_out,
1108 max_tries: run_config.reproduce_tries,
1109 bug_test: run_config.bug_test.as_deref(),
1110 start: &start,
1111 };
1112 let mut bug_fix = match super::bug::phase(&s.plane_dir, &feature_name, task, &project, format) {
1113 Err(e) => return fail(CommandStatus::NeedsHuman, e),
1114 Ok(None) => None,
1115 Ok(Some(super::bug::Phase::Fix(f))) => Some(*f),
1116 Ok(Some(super::bug::Phase::Reproduce(b))) if interrupted.is_none() => {
1117 match super::bug::reproduce(&bug_ctx, *b) {
1118 Ok(f) => Some(f),
1119 Err(stop) => return *stop,
1120 }
1121 }
1122 Ok(Some(super::bug::Phase::Reproduce(_))) => None,
1123 };
1124 let bug_candidate = bug_fix.as_ref().and_then(|f| {
1125 f.bug.candidate.clone().and_then(|c| {
1126 crate::bug::state::extra(&s.plane_dir, &f.bug.unit, &f.bug.task)
1127 .candidate_patch
1128 .map(|d| (d, c))
1129 })
1130 });
1131 let test_layout = crate::layout::detect(&project, &check_argvs);
1134 let test_layout_text = test_layout.text();
1135 let story_scenarios = match &bug_fix {
1136 Some(f) => super::bug::scenarios(&f.bug),
1137 None => format.scenarios(&snap, story_task.as_deref().unwrap_or(task)),
1138 };
1139 let rules = format.rules().judged;
1145 let whole_unit = format.kind() == speccoder_core::spec::FormatKind::Openspec;
1146 let linked_story: Option<(crate::format::OracleRequirement, Vec<String>)> = if bug_fix.is_some()
1150 || acceptance_story.is_some()
1151 || whole_unit
1152 || parsed_task.as_ref().is_none_or(|t| t.story.is_some())
1153 {
1154 None
1155 } else {
1156 parsed_task.as_ref().and_then(|t| {
1157 super::acceptance::linked_story(
1158 t,
1159 &story_scenarios,
1160 &unit_dir,
1161 &rules,
1162 &format.oracle_requirements(&snap),
1163 )
1164 })
1165 };
1166 let story_scenarios = linked_story
1167 .as_ref()
1168 .map_or(story_scenarios, |(r, _)| r.scenarios.clone());
1169 let story_key: Option<String> = if bug_fix.is_some() || story_scenarios.is_empty() {
1172 None
1173 } else {
1174 acceptance_story
1175 .clone()
1176 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone()))
1177 .or_else(|| whole_unit.then(|| feature_name.clone()))
1178 .or_else(|| linked_story.as_ref().map(|(r, _)| r.key.clone()))
1179 };
1180 let parsed_tasks = snap.feature.as_ref().and_then(|f| f.tasks.as_ref());
1181 let story_members: Vec<&speccoder_core::snapshot::Task> = match (&story_key, parsed_tasks) {
1182 (Some(key), Some(pt)) => pt
1183 .tasks
1184 .iter()
1185 .filter(|t| whole_unit || t.story.as_deref() == Some(key.as_str()))
1186 .collect(),
1187 _ => Vec::new(),
1188 };
1189 let judged_by: Option<speccoder_core::story::Judged> = if bug_fix.is_some() {
1190 None
1191 } else if let Some((_, links)) = &linked_story {
1192 Some(speccoder_core::story::Judged::Linked {
1193 scenarios: links.clone(),
1194 })
1195 } else if story_key.is_some() {
1196 let ids: Vec<String> = story_scenarios.iter().map(|s| s.id.clone()).collect();
1197 let st = super::story::story_tasks(&story_members, &unit_dir, &rules);
1198 speccoder_core::story::judged(&st, task, &ids).or_else(|| {
1199 acceptance_story
1200 .is_some()
1201 .then_some(speccoder_core::story::Judged::Progress)
1202 })
1203 } else {
1204 parsed_task
1205 .as_ref()
1206 .and_then(|t| super::acceptance::outside_story(t, &story_scenarios, &unit_dir, &rules))
1207 };
1208 let in_story =
1211 story_key.is_some() && judged_by.is_some() && run_config.acceptance && planning.is_none();
1212 let scenarios: Vec<speccoder_core::spec::ScenarioRef> = match &judged_by {
1213 Some(speccoder_core::story::Judged::Linked { scenarios: l }) if story_key.is_none() => {
1214 story_scenarios
1215 .iter()
1216 .filter(|s| l.contains(&s.id))
1217 .cloned()
1218 .collect()
1219 }
1220 _ => story_scenarios.clone(),
1221 };
1222 let mut review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)> = None;
1225 let mut review_reuse = None;
1226 let mut story_oracle: Option<super::story::StoryOracle> = None;
1231 let mut oracle_note = serde_json::Value::Null;
1233 let mut oracle_empty = false;
1235 let mut before_run = super::story::Run::default();
1236 let mut oracle_spent: Option<serde_json::Value> = None;
1238 if in_story && let Some(key) = story_key.as_deref() {
1239 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1240 match super::story::ensure(&super::story::Ensure {
1241 key,
1242 scenarios: &story_scenarios,
1243 feature: &feature_name,
1244 project: &project,
1245 wt_root: &wt_root,
1246 state,
1247 plane_dir: &s.plane_dir,
1250 runner: &runner,
1251 rt: &rt,
1252 plane: &plane,
1253 options,
1254 format,
1255 snapshot: &snap,
1256 fresh: options.fresh_acceptance,
1257 resuming: interrupted.is_some(),
1258 }) {
1259 Ok((oracle, wrote, said)) => {
1260 notices.extend(said);
1261 oracle_spent.clone_from(&oracle.usage);
1262 if wrote {
1263 notices.push(format!(
1264 "{key}'s oracle: {} test(s) for {} scenario(s), committed {}",
1265 oracle.tests.len(),
1266 oracle.scenarios.len(),
1267 oracle.commit.get(..7).unwrap_or(&oracle.commit)
1268 ));
1269 head_now = git::Git::at(&wt_root)
1272 .line(&["rev-parse", "HEAD"])
1273 .unwrap_or_default();
1274 match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
1277 Ok(b) => baseline = b,
1278 Err(e) => {
1279 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
1280 }
1281 }
1282 }
1283 before_run = interrupted
1286 .as_ref()
1287 .and_then(|m| m.story_before.clone())
1288 .and_then(|v| serde_json::from_value(v).ok())
1289 .unwrap_or_else(|| super::story::run(&project, &oracle, &runner));
1290 if !oracle.tests.is_empty() {
1294 oracle_note = serde_json::json!({
1295 "red": oracle
1296 .scenarios
1297 .iter()
1298 .filter(|s| {
1299 before_run.states.get(&s.id)
1300 != Some(&speccoder_core::story::ScenarioState::Green)
1301 && !oracle.untestable.contains_key(&s.id)
1302 })
1303 .map(|s| serde_json::json!({
1304 "id": s.id,
1305 "contract": oracle.contracts.get(&s.id),
1306 "tests": oracle
1307 .tests
1308 .iter()
1309 .filter(|t| t.scenarios.contains(&s.id))
1310 .map(|t| t.path.clone())
1311 .collect::<Vec<_>>(),
1312 }))
1313 .collect::<Vec<_>>(),
1314 "links": judged_by.as_ref().map(speccoder_core::story::Judged::links).unwrap_or_default(),
1315 "checkpoint": matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint)),
1316 });
1317 }
1318 if let Some((record, manifest)) = oracle.reviewed.clone() {
1320 review_frozen = Some((record, manifest));
1321 }
1322 if let Some((_, links)) = &linked_story
1325 && !oracle.tests.is_empty()
1326 && links.iter().all(|l| {
1327 before_run.states.get(l)
1328 == Some(&speccoder_core::story::ScenarioState::Green)
1329 })
1330 {
1331 stage(
1332 &*live,
1333 task,
1334 Stage::Red,
1335 StageState::Skipped,
1336 Some("already holds".to_owned()),
1337 None,
1338 );
1339 let reason = format!(
1340 "{} already hold{} — {key}'s oracle passes {}: nothing for {task} to do",
1341 links.join(", "),
1342 if links.len() == 1 { "s" } else { "" },
1343 if links.len() == 1 {
1344 "its test"
1345 } else {
1346 "their tests"
1347 },
1348 );
1349 let report = speccoder_core::RunReport {
1350 reason: Some(reason.clone()),
1351 notices: notices.clone(),
1352 usage: oracle.usage.clone(),
1353 ..bare_report(&feature_name, task, &wt_path, "holds")
1354 };
1355 return CommandResult::new("run", CommandStatus::NeedsHuman, &reason)
1356 .with_code("TASK-HOLDS")
1357 .with_data(CommandData::Run(Box::new(report)));
1358 }
1359 if matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
1362 && !oracle.untestable.is_empty()
1363 {
1364 let accepted = super::story::accepted_on_branch(&wt_root, "HEAD");
1365 let open: Vec<(&String, &String)> = oracle
1366 .untestable
1367 .iter()
1368 .filter(|(id, _)| !accepted.contains(id))
1369 .collect();
1370 if let Some((first, _)) = open.first() {
1371 stage(
1372 &*live,
1373 task,
1374 Stage::Red,
1375 StageState::Skipped,
1376 Some("untestable".to_owned()),
1377 None,
1378 );
1379 let said: Vec<String> = open
1380 .iter()
1381 .map(|(id, why)| format!("{id} (\"{why}\")"))
1382 .collect();
1383 let reason = format!(
1384 "{key} closes with {task}, but its test author called {} untestable — a person accepts each, or has its test written again",
1385 said.join(", ")
1386 );
1387 let report = speccoder_core::RunReport {
1388 reason: Some(reason.clone()),
1389 notices: notices.clone(),
1390 usage: oracle.usage.clone(),
1391 ..bare_report(&feature_name, task, &wt_path, "untestable")
1392 };
1393 return CommandResult::new("run", CommandStatus::NeedsHuman, &reason)
1394 .with_code("STORY-UNTESTABLE")
1395 .with_fix(vec![
1396 format!("speccoder story accept {first} --reason \"…\""),
1397 format!("speccoder task rule {task} --tests-wrong --scenario {first} --reason \"…\""),
1398 ])
1399 .with_data(CommandData::Run(Box::new(report)));
1400 }
1401 }
1402 if oracle.tests.is_empty() {
1403 oracle_empty = true;
1404 } else {
1405 story_oracle = Some(oracle);
1406 }
1407 }
1408 Err(r) => {
1409 stage(
1410 &*live,
1411 task,
1412 Stage::Red,
1413 StageState::Failed,
1414 Some(r.message().to_owned()),
1415 None,
1416 );
1417 let reason = format!(
1418 "the acceptance tests could not be written ({}); nothing was attempted",
1419 r.message()
1420 );
1421 let report = speccoder_core::RunReport {
1422 reason: Some(redact(&reason, secret)),
1423 notices: notices.clone(),
1424 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1425 };
1426 let code = r.code().map_or("ORACLE-UNWRITTEN", |c| c.name);
1427 return CommandResult::new(
1428 "run",
1429 CommandStatus::NeedsHuman,
1430 &redact(&reason, secret),
1431 )
1432 .with_code(code)
1433 .with_fix(if r.given_fix().is_empty() {
1435 Vec::new()
1436 } else {
1437 r.given_fix()
1438 .iter()
1439 .cloned()
1440 .chain([format!("speccoder run --task {task}")])
1441 .collect()
1442 })
1443 .with_data(CommandData::Run(Box::new(report)));
1444 }
1445 }
1446 }
1447 let ruled: Vec<crate::acceptance_at::Ruling> = if bug_fix.is_none() && !in_story {
1453 match crate::acceptance_at::rulings_for_task(&s.plane_dir, &feature_name, task) {
1454 Ok(r) => r,
1455 Err(e) => {
1456 return fail(
1457 CommandStatus::Error,
1458 format!("the person's rulings cannot be read: {e}"),
1459 );
1460 }
1461 }
1462 } else {
1463 Vec::new()
1464 };
1465 let told: Vec<String> = ruled
1466 .iter()
1467 .map(|r| format!("{} ruled the tests wrong: {}", r.by, r.reason))
1468 .collect();
1469 let related: Vec<String> = match (parsed_task.as_ref(), parsed_tasks) {
1472 (Some(t), Some(pt)) => super::acceptance::related(t, &pt.tasks),
1473 _ => Vec::new(),
1474 };
1475 if bug_fix.is_none()
1476 && run_config.acceptance
1477 && !options.fresh_acceptance
1478 && planning.is_none()
1479 && !in_story
1480 && ruled.is_empty()
1481 {
1482 match crate::oracle::for_task(&s.plane_dir, &project, &wt_root, format, &snap, &scenarios) {
1483 Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
1484 Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
1485 "{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
1486 )),
1487 Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
1488 notices.push(format!("{req}: frozen oracle not present here — authoring"))
1489 }
1490 Some(crate::oracle::TaskReuse::Lapsed {
1491 req,
1492 frozen,
1493 current,
1494 }) => {
1495 if crate::oracle::note_lapse(&s.plane_dir, &feature_name, &req, &frozen) {
1496 live.event(&progress::Event::Oracle {
1497 step: progress::OracleEvent::Lapsed {
1498 unit: feature_name.clone(),
1499 requirement: req.clone(),
1500 frozen_wording_sha256: frozen,
1501 current_wording_sha256: current,
1502 },
1503 });
1504 }
1505 notices.push(format!(
1506 "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
1507 ));
1508 }
1509 Some(crate::oracle::TaskReuse::Tampered(why)) => {
1510 return fail(
1511 CommandStatus::NeedsHuman,
1512 format!("{why}: the task stops for a person, nothing is ticked"),
1513 )
1514 .with_code("ORACLE-TAMPERED");
1515 }
1516 Some(crate::oracle::TaskReuse::Unreadable(why)) => {
1517 return fail(
1518 CommandStatus::NeedsHuman,
1519 format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
1520 )
1521 .with_code("REVIEW-UNREADABLE");
1522 }
1523 None => {}
1524 }
1525 }
1526 let mut acceptance = if let Some(prepared) = interrupted
1527 .as_ref()
1528 .and_then(|m| m.acceptance.clone())
1529 .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
1530 {
1531 let mut prepared = prepared;
1535 if let Some(sealed) = bug_fix
1536 .as_ref()
1537 .and_then(|f| f.record.held_out_sealed.as_ref())
1538 {
1539 prepared.held_out = Some(sealed.manifest_sha256.clone());
1540 prepared.report["held_out"] = serde_json::json!({
1541 "state": "on",
1542 "manifest": format!("sha256:{}", sealed.manifest_sha256),
1543 "kept": sealed.count,
1544 });
1545 }
1546 Some(prepared)
1547 } else if let Some(f) = &bug_fix {
1548 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1549 match super::bug::reuse(&bug_ctx, f) {
1550 Ok(a) => Some(a),
1551 Err(e) => {
1552 let _ = worktree::restore(&wt_root, &tmp, &start);
1553 stage(
1554 &*live,
1555 task,
1556 Stage::Red,
1557 StageState::Failed,
1558 Some(e.clone()),
1559 Some(began),
1560 );
1561 return fail(CommandStatus::NeedsHuman, e);
1562 }
1563 }
1564 } else if let Some(reuse) = review_reuse.take() {
1565 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1568 let crate::oracle::TaskReuse::Ready {
1569 record,
1570 files,
1571 tests,
1572 manifest,
1573 } = reuse
1574 else {
1575 return fail(CommandStatus::Error, "no frozen oracle".to_owned());
1576 };
1577 match reuse_pre_frozen(
1578 &project,
1579 &record,
1580 &files,
1581 &tests,
1582 &runner,
1583 RedRule::RunTime,
1584 Some(&SealedCheck {
1585 state: &s.plane_dir,
1586 unit: &feature_name,
1587 wt_root: &wt_root,
1588 tmp: &tmp,
1589 policy: &policy,
1590 }),
1591 ) {
1592 Ok(mut a) => {
1593 a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
1594 notices.push(format!(
1595 "acceptance tests frozen at review by {} ({}) for {}: no author call",
1596 record.decided.by, record.decided.via, record.requirement
1597 ));
1598 review_frozen = Some((*record, manifest));
1599 Some(a)
1600 }
1601 Err(e) => {
1602 let _ = worktree::restore(&wt_root, &tmp, &start);
1603 stage(
1604 &*live,
1605 task,
1606 Stage::Red,
1607 StageState::Failed,
1608 Some(e.clone()),
1609 Some(began),
1610 );
1611 return fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED");
1612 }
1613 }
1614 } else if run_config.acceptance
1615 && planning.is_none()
1616 && !in_story
1617 && format.acceptance_testable(&bundle)
1618 {
1619 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1620 Some(prepare_acceptance(&AcceptanceSetup {
1621 rt: &rt,
1622 plane: &plane,
1623 project: &project,
1624 worktree: &wt_root,
1625 tmp: &tmp,
1626 policy: &policy,
1627 feature: &feature_name,
1628 task,
1629 bundle: bundle_value.clone(),
1630 scenarios: &scenarios,
1631 instructions: &instructions,
1632 runner: &runner,
1633 layout: Some(&test_layout),
1634 told: &told,
1635 related: &related,
1636 oracle: false,
1637 }))
1638 } else {
1639 None
1640 };
1641 if review_frozen.is_none()
1642 && acceptance
1643 .as_ref()
1644 .is_some_and(|a| a.report["source"] == "review")
1645 && let Some(crate::oracle::TaskReuse::Ready {
1646 record, manifest, ..
1647 }) = review_reuse.take()
1648 {
1649 review_frozen = Some((*record, manifest));
1650 }
1651 let held_out_store = match (&bug_fix, &review_frozen) {
1654 (Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
1655 (None, Some((r, _))) => {
1656 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &r.requirement)
1657 }
1658 (None, None) => crate::held_out::dir(&s.plane_dir, &feature_name, task),
1659 };
1660 if let Some(a) = acceptance
1661 .as_mut()
1662 .filter(|a| a.report.get("held_out").is_none())
1663 {
1664 if !run_config.held_out {
1665 a.report["held_out"] = serde_json::json!({ "state": "off" });
1666 } else if a.argv.is_none() {
1667 a.report["held_out"] =
1668 serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
1669 } else if !a.red {
1670 a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
1671 } else if scenarios.is_empty() {
1672 a.report["held_out"] =
1673 serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
1674 } else {
1675 let (held, sha) = prepare_held_out(
1676 &AcceptanceSetup {
1677 rt: &rt,
1678 plane: &plane,
1679 project: &project,
1680 worktree: &wt_root,
1681 tmp: &tmp,
1682 policy: &policy,
1683 feature: &feature_name,
1684 task,
1685 bundle: bundle_value.clone(),
1686 scenarios: &scenarios,
1687 instructions: &instructions,
1688 runner: &runner,
1689 layout: Some(&test_layout),
1690 told: &told,
1691 related: &related,
1692 oracle: false,
1693 },
1694 a,
1695 &scenarios,
1696 &held_out_store,
1697 false,
1698 );
1699 a.report["held_out"] = held;
1700 a.held_out = sha;
1701 }
1702 }
1703 if run_config.held_out && !run_config.acceptance {
1704 notices.push(
1705 "[acceptance] held_out has no effect while generate = false: no held-out tests"
1706 .to_owned(),
1707 );
1708 }
1709 match &acceptance {
1710 None => stage(
1711 &*live,
1712 task,
1713 Stage::Red,
1714 StageState::Skipped,
1715 Some(if planning.is_some() {
1716 "planning work: design documents".to_owned()
1717 } else if in_story {
1718 "judged by its story's oracle".to_owned()
1719 } else {
1720 "no acceptance examples".to_owned()
1721 }),
1722 None,
1723 ),
1724 Some(a) if a.red => stage(
1725 &*live,
1726 task,
1727 Stage::Red,
1728 StageState::Passed,
1729 a.red_class.map(class_word),
1730 Some(began),
1731 ),
1732 Some(a) => stage(
1733 &*live,
1734 task,
1735 Stage::Red,
1736 StageState::Failed,
1737 Some(a.red_class.map_or_else(
1738 || "not demonstrated".to_owned(),
1739 |c| format!("not demonstrated ({})", class_word(c)),
1740 )),
1741 Some(began),
1742 ),
1743 }
1744
1745 if let Some(why) = acceptance
1749 .as_ref()
1750 .and_then(|a| a.generation_failed.clone())
1751 {
1752 let reason = format!(
1753 "the acceptance tests could not be written ({why}); nothing was attempted — run again once the model answers, or set [acceptance] generate = false to rely on the checks alone"
1754 );
1755 let _ = worktree::restore(&wt_root, &tmp, &start);
1756 let report = RunReport {
1757 reason: Some(reason.clone()),
1758 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1759 set_aside,
1760 notices: notices.clone(),
1761 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1762 };
1763 live.event(&finished_event(
1764 task,
1765 &report,
1766 "acceptance_failed",
1767 false,
1768 redact(&reason, secret),
1769 ));
1770 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1771 .with_code("ACCEPTANCE-UNWRITTEN")
1772 .with_data(CommandData::Run(Box::new(report)));
1773 }
1774
1775 if demand_red && story_oracle.is_none() && !acceptance.as_ref().is_some_and(|a| a.red) {
1777 let why = undemonstrated_why(acceptance.as_ref());
1778 let reason = format!(
1779 "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
1780 );
1781 let saved = attempts::save(
1783 &wt_root,
1784 &tmp,
1785 &feature_name,
1786 task,
1787 &attempts::Record {
1788 run: None,
1789 outcome: "not_demonstrated",
1790 reason: &reason,
1791 pending: None,
1792 usage: None,
1793 story_open: &[],
1794 own_test: None,
1795 },
1796 )
1797 .ok()
1798 .map(|s| s.reference);
1799 let _ = worktree::restore(&wt_root, &tmp, &start);
1800 let report = RunReport {
1801 reason: Some(reason.clone()),
1802 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1803 saved_attempt: saved,
1804 set_aside,
1805 notices: notices.clone(),
1806 ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
1807 };
1808 live.event(&finished_event(
1809 task,
1810 &report,
1811 "not_demonstrated",
1812 false,
1813 redact(&reason, secret),
1814 ));
1815 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1816 .with_data(CommandData::Run(Box::new(report)));
1817 }
1818
1819 let before = match &interrupted {
1822 Some(m) => worktree::Checkpoint(m.before.clone()),
1823 None => match worktree::checkpoint(&wt_root, &tmp) {
1824 Ok(c) => c,
1825 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1826 },
1827 };
1828 let acceptance_saved = acceptance
1829 .as_ref()
1830 .and_then(|a| serde_json::to_value(a).ok());
1831 let frozen: Vec<String> = match worktree::files(&wt_root, &before) {
1833 Ok(f) => f,
1834 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1835 }
1836 .into_iter()
1837 .filter(|p| tools::paths::is_test_path(p))
1838 .collect();
1839 let mut usage: Option<serde_json::Value> = None;
1840 let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
1843 let mut held_out_log: Vec<serde_json::Value> = Vec::new();
1845 let mut bug_weak: Option<speccoder_core::strength::Permitted> = None;
1847 let mut refused_account = false;
1849 let (mut report, attempt_status) = loop {
1850 let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
1851 let mut digest: Option<serde_json::Value> = None;
1852 let mut attempt_no = interrupted
1855 .as_ref()
1856 .filter(|m| m.sample == samples.len())
1857 .map_or(1, |m| m.attempt.max(1));
1858 let (mut report, attempt_status) = loop {
1859 let hidden: Vec<String> = options
1861 .session
1862 .as_ref()
1863 .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
1864 .unwrap_or_default();
1865 let (task_grants, approved_once) = match approvals::Decisions::load(&s.plane_dir) {
1871 Ok(_) if autopilot => (Vec::new(), Vec::new()),
1874 Ok(mut d) => {
1875 let once = d.take_once(&feature_name, task, this_run.as_str());
1876 if !once.is_empty()
1877 && let Err(e) = d.save(&s.plane_dir)
1878 {
1879 return fail(CommandStatus::Error, e);
1880 }
1881 (d.for_run(&feature_name, task, this_run.as_str()), once)
1882 }
1883 Err(e) => return fail(CommandStatus::ConfigError, e),
1884 };
1885 let run_approved: Vec<crate::tools::policy::Object> = options
1887 .run_grants
1888 .lock()
1889 .map(|g| g.clone())
1890 .unwrap_or_default();
1891 let input = substrate::attempt::input(
1893 with_oracle(&bundle_value, &oracle_note),
1894 &substrate::attempt::Settings {
1895 checks: &check_argvs,
1896 allow: &run_config.allow,
1897 allow_prefix: &run_config.allow_prefix,
1898 max_turns: run_config.max_turns,
1899 max_tool_calls: run_config.max_tool_calls,
1900 reasoning: s.model.reasoning_summary,
1901 frozen: &frozen,
1902 approved: &task_grants,
1903 rejected: &decisions.rejected,
1904 run_approved: &run_approved,
1905 approved_once: &approved_once,
1906 nudges: &decisions.nudges,
1907 mcp: &connected.tools,
1908 root: &wt_root_canonical,
1909 frozen_additions: run_config.frozen_additions,
1910 asks: if options.lane.is_some() {
1913 "stop"
1914 } else {
1915 "tasks"
1916 },
1917 wait_secs: run_config.approval_wait.as_secs(),
1918 network_allow: &sandbox_config.network_allow,
1919 network_allow_prefix: &sandbox_config.network_allow_prefix,
1920 run_id: this_run.as_str(),
1921 asked: options.asked_now(),
1922 budget: run_config.approval_budget,
1923 prompts: approver.is_some() && options.lane.is_none(),
1924 in_place: options.session.is_some(),
1925 hidden: &hidden,
1926 autopilot: options.mode == speccoder_core::oracle::RunMode::Autopilot,
1927 planning: planning.as_deref(),
1928 contained,
1929 left: options.left.map(|cap| {
1932 let share = substrate::attempt::Left {
1933 cap,
1934 prices: s.model.prices,
1935 };
1936 let so_far = add_usage(usage.as_ref(), oracle_spent.as_ref());
1937 substrate::attempt::Left {
1938 cap: cap
1939 .less(share.spent(&substrate::attempt::usage_of(so_far.as_ref()))),
1940 ..share
1941 }
1942 }),
1943 },
1944 digest.as_ref(),
1945 attempt_instructions,
1946 (!test_layout.runners.is_empty()).then_some(test_layout_text.as_str()),
1948 );
1949 let attempt_began = std::time::Instant::now();
1950 let attempt_detail = if run_config.best_of > 1 {
1951 format!(
1952 "{attempt_no}/{} · sample {}/{}",
1953 run_config.max_attempts,
1954 samples.len() + 1,
1955 run_config.best_of
1956 )
1957 } else {
1958 format!("{attempt_no}/{}", run_config.max_attempts)
1959 };
1960 stage(
1961 &*live,
1962 task,
1963 Stage::Attempt,
1964 StageState::Started,
1965 Some(attempt_detail.clone()),
1966 None,
1967 );
1968 let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
1971 let (run_id, succeeded, mut output) = if let Some((diff, cand)) = &bug_candidate {
1974 let held: Vec<String> = bug_fix
1975 .as_ref()
1976 .and_then(|f| {
1977 f.bug
1978 .held_out_sealed
1979 .clone()
1980 .map(|h| (f.bug.unit.clone(), h))
1981 })
1982 .and_then(|(unit, h)| {
1983 crate::held_out::open_sealed(&s.plane_dir, &unit, &h).ok()
1984 })
1985 .map(|(m, _)| m.files.into_iter().map(|f| f.path).collect())
1986 .unwrap_or_default();
1987 match super::bug::apply_candidate(&bug_ctx, diff, cand, &frozen, &held) {
1988 Ok(x) => x,
1989 Err(stop) => return *stop,
1990 }
1991 } else if let Some(exec) = &executor {
1992 let allow: Vec<Vec<String>> = check_argvs
1993 .iter()
1994 .chain(&run_config.allow)
1995 .cloned()
1996 .collect();
1997 let prompt = external::prompt_in(
1998 format.wording(),
1999 &with_oracle(&bundle_value, &oracle_note),
2000 digest.as_ref(),
2001 );
2002 let withheld = acceptance
2003 .as_ref()
2004 .and_then(|a| a.held_out.as_ref())
2005 .map(|_| held_out_store.as_path());
2006 if withheld.is_some()
2007 && !notices
2008 .iter()
2009 .any(|n| n.starts_with("held-out set withheld"))
2010 {
2011 notices.push("held-out set withheld from the external agent, not sealed: it runs with your permissions; a copied held-out literal stops the tick".to_owned());
2012 }
2013 let _ = allow;
2014 let o = match &exec.kind {
2015 external::Kind::Command(argv) => {
2016 external::run(argv, exec.timeout, &wt_root, &prompt, &tmp)
2017 }
2018 external::Kind::Acp(agent) => {
2019 let policy = match substrate::attempt::policy_of(
2022 &input.peek()["settings"],
2023 format.rules(),
2024 ) {
2025 Ok(p) => p,
2026 Err(e) => return fail(CommandStatus::RunFailed, e.to_string()),
2027 };
2028 let tools = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
2029 Ok(t) => t.with_runner(runner.clone()),
2030 Err(e) => {
2031 return fail(
2032 CommandStatus::Error,
2033 format!("{}: {e}", wt_root.display()),
2034 );
2035 }
2036 };
2037 let governor = Arc::new(crate::acp::govern::Governor::new(
2038 crate::acp::govern::Setup {
2039 root: std::fs::canonicalize(&wt_root)
2040 .unwrap_or_else(|_| wt_root.clone()),
2041 tools,
2042 policy,
2043 approver: approver.clone().filter(|_| options.lane.is_none()),
2045 present: present.clone(),
2046 task: format!("{feature_name}/{task}"),
2047 task_title: parsed_task
2048 .as_ref()
2049 .map(|t| t.title.clone())
2050 .unwrap_or_default(),
2051 run: Some(this_run.to_string()),
2052 frozen_additions: run_config.frozen_additions,
2053 run_approved: run_approved.clone(),
2054 approved_once: approved_once.clone(),
2055 progress: Some((task.to_owned(), Arc::clone(&live))),
2056 copied: copied_files.clone(),
2057 agent: agent.id.clone(),
2058 },
2059 ));
2060 let a = external::run_acp(
2061 exec,
2062 agent,
2063 &wt_root,
2064 &prompt,
2065 &runner,
2066 governor,
2067 Some((task.to_owned(), Arc::clone(&live))),
2068 );
2069 if let Some(ways) = &a.sign_in {
2070 return login(fail, &a.agent, agent, ways);
2071 }
2072 for n in a.outcome.output["acp"]["notices"]
2073 .as_array()
2074 .into_iter()
2075 .flatten()
2076 .filter_map(serde_json::Value::as_str)
2077 {
2078 notices.push(format!("{}: {n}", a.agent));
2079 }
2080 a.outcome
2081 }
2082 };
2083 (o.run_id, true, o.output)
2084 } else {
2085 let key = match &interrupted {
2087 Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
2088 m.key.clone()
2089 }
2090 _ => resume::new_key(&feature_name, task),
2091 };
2092 let _ = resume::save(
2093 &marker_path,
2094 &resume::Marker {
2095 key: key.clone(),
2096 before: before.0.clone(),
2097 head: head_now.clone(),
2098 attempt: attempt_no,
2099 sample: samples.len(),
2100 acceptance: acceptance_saved.clone(),
2101 story_before: story_oracle
2102 .as_ref()
2103 .and_then(|_| serde_json::to_value(&before_run).ok()),
2104 run_id: Some(this_run.to_string()),
2105 asked: options.asked_now(),
2106 run_grants: run_approved.clone(),
2107 },
2108 );
2109 marker_guard.arm();
2110 let outcome = match rt.block_on(plane.attempt_keyed(
2111 &feature_name,
2112 task,
2113 input,
2114 &key,
2115 )) {
2116 Ok((o, resumed)) => {
2117 if resumed {
2118 notices.push(format!(
2119 "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
2120 o.run_id
2121 ));
2122 }
2123 o
2124 }
2125 Err(e) => return fail(CommandStatus::RunFailed, e.0),
2126 };
2127 let context = crate::approvals::PresentContext {
2131 root: Some(wt_root.clone()),
2132 ..present.clone()
2133 };
2134 let (outcome, waiting) = match rt.block_on(plane.decide_inline(
2135 outcome,
2136 approver.as_deref(),
2137 &context,
2138 )) {
2139 Ok(x) => {
2140 notices.extend(crate::actions::allow_always_inline(
2143 path,
2144 &feature_name,
2145 &x.always,
2146 state,
2147 ));
2148 (x.outcome, x.waiting)
2149 }
2150 Err(e) => return fail(CommandStatus::RunFailed, e.0),
2151 };
2152 if let Some(task_row) = waiting {
2153 marker_guard.keep();
2156 let action = &task_row.justification.proposed_action;
2157 let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
2158 report.judged.clone_from(&judged_by);
2159 report.run_id = outcome.run_id.to_string();
2160 report.notices = notices;
2161 report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
2162 report.tool_calls.push(serde_json::json!({
2164 "tool": action["tool"],
2165 "arguments": action["arguments"],
2166 "decision": "approval",
2167 "reason": action["request"]["reason"],
2168 "object": action["request"]["object"],
2169 "waiting": true,
2170 "call": task_row.id.to_hex(),
2171 }));
2172 report.pending = Some(serde_json::json!({
2173 "tool": action["tool"],
2174 "arguments": action["arguments"],
2175 "reason": action["request"]["reason"],
2176 "object": action["request"]["object"],
2177 "request": action["request"],
2178 "call": task_row.id.to_hex(),
2179 "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
2180 "waiting": true,
2181 "batched": action["request"]["batched"],
2182 }));
2183 if let (Some(n), Ok(mut asked)) = (
2186 action["request"]["asked"]
2187 .as_u64()
2188 .and_then(|n| u32::try_from(n).ok()),
2189 options.asked.lock(),
2190 ) {
2191 *asked = (*asked).max(n);
2192 }
2193 report.reason = Some(format!(
2194 "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
2195 task_row.justification.summary.peek()
2196 ));
2197 let (status, message) = status_and_message(&report, "needs_approval", false);
2198 live.event(&finished_event(
2199 task,
2200 &report,
2201 "needs_approval",
2202 false,
2203 redact(&message, secret),
2204 ));
2205 return CommandResult::new("run", status, &redact(&message, secret))
2206 .with_data(CommandData::Run(Box::new(report)));
2207 }
2208 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
2209 refused_account = outcome
2210 .failed_effect
2211 .as_ref()
2212 .is_some_and(agentplane::journal::FailedEffect::refuses_the_account);
2213 let mut output = outcome
2214 .output
2215 .as_ref()
2216 .map(|o| o.peek().clone())
2217 .unwrap_or(serde_json::Value::Null);
2218 if !succeeded && output["reason"].as_str().is_none() {
2219 if !output.is_object() {
2220 output = serde_json::json!({});
2221 }
2222 let reason = if rt.block_on(plane.holds_a_pre_054_decision(outcome.run_id)) {
2225 substrate::PRE_054_DECISION.to_owned()
2226 } else if let agentplane::prelude::RunStatus::Exhausted(limit) = &outcome.status
2227 {
2228 output["status"] = serde_json::json!("budget_exhausted");
2230 substrate::exhausted(limit)
2231 } else if let agentplane::prelude::RunStatus::Quarantined(why) = &outcome.status
2232 {
2233 output["status"] = serde_json::json!("quarantined");
2234 why.clone()
2235 } else {
2236 substrate::why_not(&outcome.status)
2237 };
2238 output["reason"] = serde_json::Value::String(redact(&reason, secret));
2239 }
2240 if output.get("usage").is_none_or(serde_json::Value::is_null)
2243 && let Ok(Some(used)) =
2244 rt.block_on(substrate::spent(&s.plane_dir, &outcome.run_id.to_string()))
2245 {
2246 output["usage"] = used;
2247 }
2248 (outcome.run_id.to_string(), succeeded, output)
2249 };
2250 if bug_candidate.is_some()
2251 && let Some(c) = bug_fix.as_mut().and_then(|f| f.bug.candidate.as_mut())
2252 {
2253 c.applied = true;
2254 }
2255 if let Some(argv) = run_config.format.as_ref().filter(|_| {
2258 succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
2259 }) {
2260 use verify::process::CommandRunner as _;
2261 let ran = runner.run(&verify::process::Invocation {
2262 argv: argv.clone(),
2263 cwd: project.clone(),
2264 env: verify::process::scrubbed_env(std::env::vars()),
2265 timeout: std::time::Duration::from_secs(300),
2266 output_cap: verify::process::OUTPUT_CAP,
2267 });
2268 if ran.exit_code != Some(0) {
2269 notices.push(format!(
2270 "the formatter (`{}`) did not succeed",
2271 argv.join(" ")
2272 ));
2273 }
2274 }
2275 if prepares {
2279 let began = std::time::Instant::now();
2280 match crate::prepare::recheck(&crate::prepare::Prepare {
2281 worktree: &wt_root,
2282 project: &project,
2283 config: &worktree_config,
2284 runner: &runner,
2285 state: Some(state),
2286 }) {
2287 Ok(done) if done.is_empty() => {}
2288 Ok(done) => {
2289 stage(
2290 &*live,
2291 task,
2292 Stage::Prepare,
2293 StageState::Passed,
2294 Some(format!(
2295 "{}: the attempt changed its lockfile",
2296 done.join(", ")
2297 )),
2298 Some(began),
2299 );
2300 }
2301 Err(f) => {
2302 stage(
2303 &*live,
2304 task,
2305 Stage::Prepare,
2306 StageState::Failed,
2307 Some(f.message.clone()),
2308 Some(began),
2309 );
2310 let reason = redact(&f.message, secret);
2314 let saved = attempts::save(
2315 &wt_root,
2316 &tmp,
2317 &feature_name,
2318 task,
2319 &attempts::Record {
2320 run: (!run_id.is_empty()).then_some(run_id.as_str()),
2321 outcome: "prepare_failed",
2322 reason: &reason,
2323 pending: None,
2324 usage: output.get("usage"),
2325 story_open: &[],
2326 own_test: None,
2327 },
2328 )
2329 .and_then(|saved| {
2330 worktree::restore(&wt_root, &tmp, &before).map(|_| saved)
2331 });
2332 let message = match saved {
2333 Ok(saved) => format!(
2334 "{} — the attempt is saved as {} and undone",
2335 f.message, saved.reference
2336 ),
2337 Err(e) => format!("{} — saving the attempt failed: {}", f.message, e.0),
2338 };
2339 return fail(CommandStatus::ConfigError, message)
2340 .with_code("PREPARE-FAILED")
2341 .with_fix(f.fix)
2342 .with_commands(f.commands);
2343 }
2344 }
2345 }
2346 let after = match worktree::checkpoint(&wt_root, &tmp) {
2347 Ok(c) => c,
2348 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2349 };
2350 let raw_changes = match worktree::diff(&wt_root, &before, &after) {
2352 Ok(c) => c,
2353 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2354 };
2355 if let (Some(n), Ok(mut asked)) = (
2360 output["asked"].as_u64().and_then(|n| u32::try_from(n).ok()),
2361 options.asked.lock(),
2362 ) {
2363 *asked = n;
2364 }
2365 let approved_writes = approved_writes(&task_grants, &output["tool_calls"]);
2366 let patch = match worktree::patch(&wt_root, &before, &after) {
2367 Ok(p) => p,
2368 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2369 };
2370 if let Some(name) = crate::include::added_secret(&patch) {
2373 let _ = worktree::restore(&wt_root, &tmp, &before);
2374 return fail(
2375 CommandStatus::NeedsHuman,
2376 format!(
2377 "the attempt's change adds the value of {name}, copied from your checkout ([worktree] include): undone, nothing judged or committed"
2378 ),
2379 )
2380 .with_code("SECRET-IN-DIFF");
2381 }
2382 let findings = attempt_findings(&Audited {
2383 wt_root: &wt_root,
2384 policy: &policy,
2385 judged_before: &judged_before,
2386 raw_changes: &raw_changes,
2387 patch: &patch,
2388 frozen: &frozen,
2389 frozen_additions: run_config.frozen_additions,
2390 approved_writes: &approved_writes,
2391 command_drift: &output["integrity"],
2392 });
2393 let changes = changes_of(raw_changes);
2394
2395 let quarantined = !succeeded && output["status"] == "quarantined";
2400 if quarantined {
2401 output["reason"] = serde_json::Value::String(
2402 "the attempt was interrupted while a tool call was running; whether it finished cannot be known, so it was not run again. The attempt is saved and undone; run again to start a fresh attempt".to_owned(),
2403 );
2404 }
2405 let mut attempt_status = if succeeded {
2406 output["status"].as_str().unwrap_or("failed").to_owned()
2407 } else if quarantined {
2408 "interrupted".to_owned()
2409 } else if output["status"] == "budget_exhausted" {
2410 "budget_exhausted".to_owned()
2411 } else {
2412 "failed".to_owned()
2413 };
2414 if attempt_status == "completed" && changes.is_empty() && output.get("acp").is_some() {
2418 attempt_status = "blocked".to_owned();
2419 output["reason"] = serde_json::json!(format!(
2420 "{} ended its turn without changing anything",
2421 output["acp"]["agent"].as_str().unwrap_or("the agent")
2422 ));
2423 }
2424 let mut report = RunReport {
2425 run_id: run_id.clone(),
2426 model_report: output.get("report").cloned(),
2427 reason: output["reason"].as_str().map(str::to_owned),
2428 pending: output.get("pending").cloned(),
2429 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
2430 changes,
2431 usage: output.get("usage").cloned(),
2432 integrity: findings.clone(),
2433 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
2434 set_aside: set_aside.clone(),
2435 notices: notices.clone(),
2436 agent: external::agent_attempt(&output),
2437 judged: judged_by.clone(),
2438 ..bare_report(&feature_name, task, &wt_path, &attempt_status)
2439 };
2440 if let Err(e) = record_inline_approvals(
2441 &s.plane_dir,
2442 &feature_name,
2443 task,
2444 &report.tool_calls,
2445 &options.run_grants,
2446 this_run.as_str(),
2447 ) {
2448 report.notices.push(format!(
2449 "the decisions made in this attempt were not recorded ({e}): later attempts ask again"
2450 ));
2451 }
2452 for c in report
2454 .tool_calls
2455 .iter()
2456 .filter(|c| c["approved_by"] == "person")
2457 {
2458 if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
2459 mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
2460 }
2461 }
2462
2463 stage(
2464 &*live,
2465 task,
2466 Stage::Attempt,
2467 if attempt_status == "completed" {
2468 StageState::Passed
2469 } else {
2470 StageState::Failed
2471 },
2472 Some(if attempt_status == "completed" {
2473 attempt_detail
2474 } else {
2475 format!("{attempt_detail} {attempt_status}")
2476 }),
2477 Some(attempt_began),
2478 );
2479 if attempt_status == "integrity" {
2482 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2483 reasons: findings.clone(),
2484 });
2485 }
2486 let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
2488 if judged {
2489 let began = std::time::Instant::now();
2490 stage(&*live, task, Stage::Verify, StageState::Started, None, None);
2491 let oracle_files: Vec<String> = story_oracle
2492 .iter()
2493 .flat_map(|o| o.tests.iter().map(|t| t.path.clone()))
2494 .collect();
2495 if let Err(e) = judge_checks(
2496 &project,
2497 &runner,
2498 (&baseline, &oracle_files),
2499 acceptance.as_ref(),
2500 &findings,
2501 &mut report,
2502 ) {
2503 stage(
2504 &*live,
2505 task,
2506 Stage::Verify,
2507 StageState::Failed,
2508 None,
2509 Some(began),
2510 );
2511 return fail(CommandStatus::ConfigError, format!("verification: {e}"));
2512 }
2513 if let Some(o) = &story_oracle {
2516 let touched = super::story::changed(&project, o);
2519 if !touched.is_empty() {
2520 let why = format!(
2521 "the story's oracle tests were changed: {}",
2522 touched.join(", ")
2523 );
2524 report.verdict = Some(match report.verdict.take() {
2525 Some(speccoder_core::verify::Verdict::Failed { mut reasons }) => {
2526 reasons.push(why);
2527 speccoder_core::verify::Verdict::Failed { reasons }
2528 }
2529 _ => speccoder_core::verify::Verdict::Failed { reasons: vec![why] },
2530 });
2531 }
2532 let after = super::story::states(&project, o, &runner);
2533 super::story::judge(
2534 o,
2535 &before_run.states,
2536 &after,
2537 judged_by
2538 .as_ref()
2539 .map(speccoder_core::story::Judged::links)
2540 .unwrap_or_default(),
2541 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint)),
2542 &mut report,
2543 );
2544 if let Some(speccoder_core::story::Progress::Verified { turned }) =
2546 &report.progress
2547 {
2548 report.acceptance = Some(super::story::evidence(o, turned, &before_run));
2549 }
2550 }
2551 if let Some(dir) = planning.as_ref().filter(|_| {
2555 matches!(
2556 report.verdict,
2557 Some(
2558 speccoder_core::verify::Verdict::Verified
2559 | speccoder_core::verify::Verdict::RegressionOnly
2560 )
2561 )
2562 }) {
2563 let inside = format!("{dir}/");
2564 let outside: Vec<&str> = report
2565 .changes
2566 .iter()
2567 .filter(|(p, _)| !p.starts_with(&inside))
2568 .map(|(p, _)| p.as_str())
2569 .collect();
2570 let reason = if !outside.is_empty() {
2571 Some(format!(
2572 "a planning task changed code: {}",
2573 outside.join(", ")
2574 ))
2575 } else if !report.changes.iter().any(|(_, kind)| kind != "deleted") {
2576 Some(format!("wrote no planning file under {inside}"))
2577 } else {
2578 None
2579 };
2580 if !outside.is_empty() {
2581 attempt_status = CHANGED_CODE.to_owned();
2583 report.attempt_status = CHANGED_CODE.to_owned();
2584 }
2585 if let Some(r) = reason {
2586 report.verdict =
2587 Some(speccoder_core::verify::Verdict::Failed { reasons: vec![r] });
2588 }
2589 }
2590 let otherwise_passing = attempt_status == "completed"
2594 && findings.is_empty()
2595 && matches!(
2596 report.verdict,
2597 Some(
2598 speccoder_core::verify::Verdict::Verified
2599 | speccoder_core::verify::Verdict::RegressionOnly
2600 )
2601 );
2602 match judge_collection(
2603 (&project, &wt_root, &tmp),
2604 &runner,
2605 otherwise_passing,
2606 &mut report,
2607 &*live,
2608 task,
2609 ) {
2610 Some(Collected::Unknown(why)) => {
2611 attempt_status = "collection_unknown".to_owned();
2612 report.verdict = None;
2613 report.reason = Some(why);
2614 }
2615 Some(Collected::Changed(finding)) => {
2617 report.integrity.push(finding.clone());
2618 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2619 reasons: vec![finding],
2620 });
2621 attempt_status = "integrity".to_owned();
2622 }
2623 None => {}
2624 }
2625 let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
2626 stage_with_tail(
2627 &*live,
2628 task,
2629 Stage::Verify,
2630 if checks_held {
2631 StageState::Passed
2632 } else {
2633 StageState::Failed
2634 },
2635 None,
2636 Some(began),
2637 report
2638 .verification
2639 .as_ref()
2640 .map(failing_tail)
2641 .unwrap_or_default(),
2642 );
2643 }
2644 if judged || attempt_status == "integrity" {
2645 stage(
2646 &*live,
2647 task,
2648 Stage::Audit,
2649 if findings.is_empty() {
2650 StageState::Passed
2651 } else {
2652 StageState::Failed
2653 },
2654 findings
2655 .first()
2656 .map(|f| f.trim_start_matches("integrity: ").to_owned()),
2657 None,
2658 );
2659 }
2660
2661 let visibly_passed = judged
2665 && findings.is_empty()
2666 && attempt_status == "completed"
2667 && matches!(
2668 report.verdict,
2669 Some(
2670 speccoder_core::verify::Verdict::Verified
2671 | speccoder_core::verify::Verdict::RegressionOnly
2672 )
2673 );
2674 let held = acceptance
2677 .as_ref()
2678 .and_then(|a| a.held_out.clone())
2679 .map(|sha| (held_out_store.clone(), sha))
2680 .or_else(|| {
2681 story_oracle
2682 .as_ref()
2683 .filter(|_| {
2684 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
2685 })
2686 .and_then(|o| o.held_out.as_ref())
2687 .map(|h| {
2688 (
2689 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &h.key),
2690 h.manifest_sha256.clone(),
2691 )
2692 })
2693 });
2694 let at_checkpoint =
2697 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint));
2698 if at_checkpoint
2699 && visibly_passed
2700 && held.is_none()
2701 && let Some(summary) = story_oracle
2702 .as_ref()
2703 .and_then(|o| o.held_out_summary.as_ref())
2704 {
2705 let entry = report
2706 .acceptance
2707 .get_or_insert_with(|| serde_json::json!({ "source": "authored" }));
2708 entry["held_out"] = match summary.state.as_str() {
2709 "off" => serde_json::json!({ "state": "off" }),
2710 _ => serde_json::json!({
2711 "state": "absent",
2712 "reason": summary.reason,
2713 "discarded": summary
2714 .discarded
2715 .iter()
2716 .map(|r| serde_json::json!({ "reason": r }))
2717 .collect::<Vec<_>>(),
2718 }),
2719 };
2720 }
2721 if acceptance
2722 .as_ref()
2723 .and_then(|a| a.held_out.as_ref())
2724 .is_none()
2725 && let (Some((_, sha)), Some(o)) = (&held, &story_oracle)
2726 && visibly_passed
2727 {
2728 let entry = report.acceptance.get_or_insert_with(
2729 || serde_json::json!({ "source": "authored", "oracle": o.commit }),
2730 );
2731 entry["held_out"] = serde_json::json!({
2732 "state": "on",
2733 "manifest": format!("sha256:{sha}"),
2734 "kept": o.held_out.as_ref().map_or(0, |h| h.count),
2735 });
2736 }
2737 if let Some((store, sha)) = held.filter(|_| visibly_passed) {
2738 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2739 let mut known = format!(
2740 "{}\n{}\n{}",
2741 bundle_value["story"].as_str().unwrap_or_default(),
2742 bundle_value["plan"].as_str().unwrap_or_default(),
2743 bundle_value["constitution"].as_str().unwrap_or_default()
2744 );
2745 for p in report
2746 .acceptance
2747 .as_ref()
2748 .and_then(|a| a["files"].as_array().cloned())
2749 .unwrap_or_default()
2750 {
2751 if let Some(p) = p.as_str() {
2752 known.push_str(
2753 &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2754 );
2755 }
2756 }
2757 match judge_held_out(
2758 &store, &sha, &scenarios, &known, &wt_root, &project, &tmp, &before, &patch,
2759 &runner, &policy,
2760 ) {
2761 Ok(j) => {
2762 let failed_ids: Vec<String> =
2763 j.failed.iter().map(|s| s.id.clone()).collect();
2764 held_out_log.push(serde_json::json!({
2765 "attempt": held_out_log.len() + 1,
2766 "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
2767 "scenarios": failed_ids,
2768 }));
2769 if let Some(v) = report.verdict.take() {
2770 let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
2771 if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
2772 && !j.failed.is_empty()
2773 && let Some(last) = reasons.last_mut()
2774 {
2775 *last = speccoder_core::retry::held_out_reason(&j.failed);
2776 }
2777 report.verdict = Some(v);
2778 }
2779 }
2780 Err((outcome, why)) => {
2781 held_out_log.push(serde_json::json!({
2782 "attempt": held_out_log.len() + 1,
2783 "outcome": outcome,
2784 }));
2785 report.verdict = None;
2786 report.reason = Some(why);
2787 attempt_status = "held_out".to_owned();
2788 }
2789 }
2790 }
2791 if let Some(fix) = bug_fix.as_mut().filter(|_| {
2794 attempt_status == "completed"
2795 && findings.is_empty()
2796 && matches!(
2797 report.verdict,
2798 Some(
2799 speccoder_core::verify::Verdict::Verified
2800 | speccoder_core::verify::Verdict::RegressionOnly
2801 )
2802 )
2803 }) {
2804 let keep = crate::strength::dir(&s.plane_dir, &feature_name, task);
2805 let judging = super::bug::Judging {
2806 before: &before,
2807 baseline: &baseline,
2808 verification: report.verification.as_ref(),
2809 base: report
2810 .verdict
2811 .clone()
2812 .unwrap_or(speccoder_core::verify::Verdict::RegressionOnly),
2813 keep: &keep,
2814 };
2815 match super::bug::judge(&bug_ctx, fix, &judging) {
2816 Ok(j) => {
2817 report.verdict = Some(j.verdict);
2818 if let Some(weak) = j.weak {
2819 report.reason = Some(format!(
2820 "reproduction too weak: a fix that special-cases {}'s input passes ({}); `speccoder task rule {task} --tests-right --reason …` if the tests are right",
2821 weak.scenario, weak.rationale
2822 ));
2823 bug_weak = Some(weak);
2824 attempt_status = "repro_weak".to_owned();
2825 }
2826 }
2827 Err(e) => {
2828 report.integrity.push(e.clone());
2829 report.reason = Some(e);
2830 report.verdict = None;
2831 attempt_status = "integrity".to_owned();
2832 }
2833 }
2834 }
2835 if let Some(h) = report
2836 .acceptance
2837 .as_mut()
2838 .and_then(|a| a.get_mut("held_out"))
2839 .filter(|h| h["state"] == "on")
2840 {
2841 let failed = held_out_log
2842 .iter()
2843 .filter(|o| o["outcome"] == "failed")
2844 .count();
2845 h["attempts"] = serde_json::json!(held_out_log);
2846 h["gap"] = serde_json::json!(failed > 0);
2847 }
2848
2849 let passing = attempt_status == "completed"
2851 && matches!(
2852 report.verdict,
2853 Some(
2854 speccoder_core::verify::Verdict::Verified
2855 | speccoder_core::verify::Verdict::RegressionOnly
2856 )
2857 );
2858 if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
2859 stage(
2860 &*live,
2861 task,
2862 Stage::Mutation,
2863 StageState::Skipped,
2864 Some(if mutation_config.is_none() {
2865 "not configured".to_owned()
2866 } else {
2867 "suite = false: for acceptance strength only".to_owned()
2868 }),
2869 None,
2870 );
2871 }
2872 if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
2873 let began = std::time::Instant::now();
2874 stage(
2875 &*live,
2876 task,
2877 Stage::Mutation,
2878 StageState::Started,
2879 None,
2880 None,
2881 );
2882 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2883 let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
2884 if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
2885 if m.mode == mutation::Mode::Require
2886 && let Some(reason) = mutation::failure(&result)
2887 {
2888 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2889 reasons: vec![reason],
2890 });
2891 }
2892 report.mutation = Some(result);
2893 }
2894 let held = !matches!(
2895 report.verdict,
2896 Some(speccoder_core::verify::Verdict::Failed { .. })
2897 );
2898 stage(
2899 &*live,
2900 task,
2901 Stage::Mutation,
2902 if held {
2903 StageState::Passed
2904 } else {
2905 StageState::Failed
2906 },
2907 report
2908 .mutation
2909 .as_ref()
2910 .and_then(crate::mutation::trailer)
2911 .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
2912 Some(began),
2913 );
2914 }
2915
2916 usage = add_usage(usage.as_ref(), report.usage.as_ref());
2917 let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
2919 (report.verdict.clone(), report.verification.as_ref())
2920 else {
2921 break (report, attempt_status);
2922 };
2923 if attempt_status != "completed" {
2924 break (report, attempt_status);
2925 }
2926 let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
2927 let mut d =
2928 speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
2929 d.uncollected = report
2930 .collection
2931 .as_ref()
2932 .map(speccoder_core::collection::Collection::retry_lines)
2933 .unwrap_or_default();
2934 let fingerprint = speccoder_core::retry::fingerprint(&d);
2935 let stop = if bug_candidate.is_some() {
2936 Some("a candidate is judged, never retried".to_owned())
2937 } else {
2938 retry_stop(
2939 &findings,
2940 verification,
2941 earlier.last().map(|e| e.fingerprint.as_str()),
2942 &fingerprint,
2943 attempt_no,
2944 run_config.max_attempts,
2945 )
2946 };
2947 if let Some(stop) = stop {
2948 report.retry_stop = Some(stop);
2949 break (report, attempt_status);
2950 }
2951 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2952 report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
2953 break (report, attempt_status);
2954 }
2955 earlier.push(speccoder_core::EarlierAttempt {
2956 run_id: report.run_id.clone(),
2957 attempt_status: attempt_status.clone(),
2958 verdict: report.verdict.clone(),
2959 fingerprint,
2960 digest: d.clone(),
2961 usage: report.usage.clone(),
2962 });
2963 digest = serde_json::to_value(&d).ok();
2964 attempt_no += 1;
2965 };
2966 report.earlier = earlier;
2967 if run_config.best_of <= 1 {
2968 break (report, attempt_status);
2969 }
2970 let end = match worktree::checkpoint(&wt_root, &tmp) {
2971 Ok(c) => c,
2972 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2973 };
2974 let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
2975 let summary = sample_summary(&report, &attempt_status, &patch);
2976 samples.push((report, attempt_status, end, summary));
2977 if samples.len() >= run_config.best_of as usize {
2978 let best = best_sample(&samples);
2979 let summaries: Vec<serde_json::Value> = samples
2980 .iter()
2981 .enumerate()
2982 .map(|(i, s)| {
2983 let mut v = s.3.clone();
2984 v["kept"] = serde_json::json!(i == best);
2985 v
2986 })
2987 .collect();
2988 let (mut chosen, status, end, _) = samples.swap_remove(best);
2989 if best != samples.len() {
2990 if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
2992 return fail(CommandStatus::GitPrecondition, e.0);
2993 }
2994 }
2995 chosen.samples = summaries;
2996 break (chosen, status);
2997 }
2998 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2999 return fail(CommandStatus::GitPrecondition, e.0);
3000 }
3001 };
3002 report.usage = super::commit::add_usage(usage.as_ref(), oracle_spent.as_ref());
3003 {
3006 use speccoder_core::context::ContextKind as K;
3007 let part = |k: &str| {
3008 bundle_value[k]
3009 .as_str()
3010 .unwrap_or_default()
3011 .as_bytes()
3012 .to_vec()
3013 };
3014 let mut m = crate::context::Manifest::new();
3015 m.push(
3016 K::Constitution,
3017 "worktree",
3018 "/bundle/constitution",
3019 &part("constitution"),
3020 )
3021 .push(K::Plan, "worktree", "/bundle/plan", &part("plan"))
3022 .push(K::Story, "worktree", "/bundle/story", &part("story"))
3023 .push(
3024 K::Task,
3025 "worktree",
3026 "/bundle/task",
3027 serde_json::to_string(&bundle_value["task"])
3028 .unwrap_or_default()
3029 .as_bytes(),
3030 )
3031 .push(
3032 K::TestLayout,
3033 "worktree",
3034 "/test_layout",
3035 test_layout_text.as_bytes(),
3036 );
3037 report.context = Some(m.finish());
3038 }
3039 report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
3041
3042 let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
3046 let mut attempt_status = attempt_status;
3047 let verified = attempt_status == "completed"
3048 && matches!(
3049 report.verdict,
3050 Some(speccoder_core::verify::Verdict::Verified)
3051 );
3052 let visible: Vec<Vec<String>> = match acceptance
3055 .as_ref()
3056 .filter(|a| a.red)
3057 .and_then(|a| a.argv.clone())
3058 {
3059 Some(argv) => vec![argv],
3060 None => report
3061 .acceptance
3062 .as_ref()
3063 .filter(|a| a["oracle"].is_string() && a["red"] == true)
3064 .and_then(|a| a["tests"].as_array().cloned())
3065 .unwrap_or_default()
3066 .iter()
3067 .filter_map(|t| serde_json::from_value::<Vec<String>>(t["argv"].clone()).ok())
3068 .collect(),
3069 };
3070 let visible_argv = visible.first().cloned();
3071 if verified && !run_config.strength.enabled {
3072 stage(
3073 &*live,
3074 task,
3075 Stage::Strength,
3076 StageState::Skipped,
3077 Some("strength = false".to_owned()),
3078 None,
3079 );
3080 }
3081 if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
3082 use speccoder_core::strength::{Gate, gate, over_strict};
3083 let began = std::time::Instant::now();
3084 stage(
3085 &*live,
3086 task,
3087 Stage::Strength,
3088 StageState::Started,
3089 None,
3090 None,
3091 );
3092 let fin = worktree::checkpoint(&wt_root, &tmp);
3093 let patch = fin
3094 .as_ref()
3095 .ok()
3096 .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
3097 .unwrap_or_default();
3098 let mut notes = Vec::new();
3099 let held_set: Option<(std::path::PathBuf, String)> = acceptance
3102 .as_ref()
3103 .and_then(|a| a.held_out.clone())
3104 .map(|sha| (held_out_store.clone(), sha))
3105 .or_else(|| {
3106 story_oracle
3107 .as_ref()
3108 .filter(|_| {
3109 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
3110 })
3111 .and_then(|o| o.held_out.as_ref())
3112 .map(|h| {
3113 (
3114 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &h.key),
3115 h.manifest_sha256.clone(),
3116 )
3117 })
3118 });
3119 let held: Vec<(crate::held_out::HeldOutFile, String)> = match held_set {
3120 None => Vec::new(),
3121 Some((store, sha)) => match crate::held_out::load_verified(&store, &sha) {
3122 Ok((manifest, contents)) => manifest
3123 .files
3124 .into_iter()
3125 .zip(contents.into_iter().map(|(_, c)| c))
3126 .collect(),
3127 Err(t) => {
3128 notes.push(format!("held-out set not overlaid: {}", t.0));
3129 Vec::new()
3130 }
3131 },
3132 };
3133 let rulings = crate::strength::rulings(&strength_dir);
3134 let mut commands = if visible.is_empty() {
3135 vec![argv]
3136 } else {
3137 visible.clone()
3138 };
3139 let measure = |commands: &[Vec<String>]| {
3140 crate::strength::measure(
3141 &crate::strength::Setup {
3142 wt_root: &wt_root,
3143 project: &project,
3144 tmp: &tmp,
3145 runner: &runner,
3146 policy: &policy,
3147 acceptance: commands,
3148 held_out: &held,
3149 mutation: mutation_config.as_ref(),
3150 patch: &patch,
3151 config: &run_config.strength,
3152 keep: &strength_dir,
3153 rulings: &rulings,
3154 },
3155 |changed| {
3156 let input = substrate::near_miss::input(
3158 &bundle_value,
3159 changed,
3160 &crate::mutation::source_patch(&patch),
3161 run_config.strength.near_miss,
3162 run_config.strength.permitted,
3163 );
3164 match rt.block_on(plane.near_miss(&feature_name, task, input)) {
3165 Ok(o) => {
3166 let out = o
3167 .output
3168 .as_ref()
3169 .map(|v| v.peek().clone())
3170 .unwrap_or_default();
3171 let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
3172 && !out["answer"].is_null();
3173 crate::strength::Authored {
3174 answer: if ok {
3175 Ok(out["answer"].clone())
3176 } else {
3177 Err(format!(
3178 "the variant author did not answer ({})",
3179 crate::substrate::why_not(&o.status)
3180 ))
3181 },
3182 run_id: Some(o.run_id.to_string()),
3183 usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
3184 }
3185 }
3186 Err(e) => crate::strength::Authored {
3187 answer: Err(redact(&e.0, secret)),
3188 run_id: None,
3189 usage: None,
3190 },
3191 }
3192 },
3193 )
3194 };
3195 let mut measured = measure(&commands);
3196 let min = run_config.strength.min;
3199 if let (Ok(a), Ok(fin)) = (&measured, &fin)
3200 && gate(min, &a.combined, false) == Gate::Failed
3201 && over_strict(&a.permitted).is_empty()
3202 {
3203 let visible_tests: Vec<(String, String)> = report
3204 .acceptance
3205 .as_ref()
3206 .and_then(|a| a["files"].as_array().cloned())
3207 .unwrap_or_default()
3208 .iter()
3209 .filter_map(|p| p.as_str())
3210 .map(|p| {
3211 (
3212 p.to_owned(),
3213 std::fs::read_to_string(project.join(p)).unwrap_or_default(),
3214 )
3215 })
3216 .collect();
3217 let setup = AcceptanceSetup {
3218 rt: &rt,
3219 plane: &plane,
3220 project: &project,
3221 worktree: &wt_root,
3222 tmp: &tmp,
3223 policy: &policy,
3224 feature: &feature_name,
3225 task,
3226 bundle: bundle_value.clone(),
3227 scenarios: &scenarios,
3228 instructions: &instructions,
3229 runner: &runner,
3230 layout: Some(&test_layout),
3231 told: &told,
3232 related: &related,
3233 oracle: false,
3234 };
3235 let weak = crate::strength::weak(a);
3236 match strengthen(&setup, &visible_tests, weak, &before, fin) {
3237 Ok(added) => {
3238 if let Some(acc) = report.acceptance.as_mut() {
3239 if let Some(files) = acc["files"].as_array_mut() {
3240 files.extend(added.files.iter().map(|f| serde_json::json!(f)));
3241 }
3242 acc["strengthened"] = serde_json::json!({
3243 "files": added.files,
3244 "argv": added.argv,
3245 "run_id": added.report["run_id"],
3246 "usage": added.report["usage"],
3247 });
3248 }
3249 report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
3250 commands.push(added.argv);
3251 let mut again = measure(&commands);
3252 if let Ok(b) = again.as_mut() {
3253 b.gate = gate(min, &b.combined, true);
3254 b.notes.insert(
3255 0,
3256 "strengthened once with added acceptance tests".to_owned(),
3257 );
3258 }
3259 measured = again;
3260 }
3261 Err(why) => {
3262 if let Ok(a) = measured.as_mut() {
3263 a.notes
3264 .insert(0, format!("could not strengthen the set: {why}"));
3265 }
3266 }
3267 }
3268 }
3269 match measured {
3270 Ok(mut a) => {
3271 if a.gate == Gate::Off {
3272 a.gate = gate(min, &a.combined, false);
3273 }
3274 a.notes.extend(notes);
3275 let findings = over_strict(&a.permitted);
3276 if !findings.is_empty() {
3277 attempt_status = "over_strict".to_owned();
3278 report.reason = Some(format!(
3279 "an acceptance test rejects an implementation the spec appears to allow: {}",
3280 findings
3281 .iter()
3282 .map(|p| format!("{} ({})", p.scenario, p.rationale))
3283 .collect::<Vec<_>>()
3284 .join("; ")
3285 ));
3286 } else if a.gate == Gate::Failed {
3287 attempt_status = "acceptance_weak".to_owned();
3288 report.reason = Some(format!(
3289 "acceptance set too weak: strength {} is below min_strength {}{}",
3290 speccoder_core::strength::summary(&a.combined, None),
3291 min.unwrap_or_default(),
3292 a.notes
3293 .first()
3294 .map(|n| format!(" ({n})"))
3295 .unwrap_or_default()
3296 ));
3297 } else {
3298 crate::strength::release(&strength_dir);
3299 }
3300 report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
3302 report.strength = Some(a);
3303 }
3304 Err(e) => {
3305 report.integrity.push(e.clone());
3306 report.reason = Some(e);
3307 report.verdict = None;
3308 attempt_status = "integrity".to_owned();
3309 }
3310 }
3311 report.attempt_status.clone_from(&attempt_status);
3312 let held_up = attempt_status == "completed";
3313 stage(
3314 &*live,
3315 task,
3316 Stage::Strength,
3317 if held_up {
3318 StageState::Passed
3319 } else {
3320 StageState::Failed
3321 },
3322 report.strength.as_ref().map(|a| {
3323 speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
3324 }),
3325 Some(began),
3326 );
3327 }
3328
3329 let verdict_passed = attempt_status == "completed"
3333 && matches!(
3334 report.verdict,
3335 Some(
3336 speccoder_core::verify::Verdict::Verified
3337 | speccoder_core::verify::Verdict::RegressionOnly
3338 )
3339 );
3340 let mut suspected = false;
3341 if !run_config.monitor && verdict_passed {
3342 stage(
3343 &*live,
3344 task,
3345 Stage::Monitor,
3346 StageState::Skipped,
3347 Some("not enabled".to_owned()),
3348 None,
3349 );
3350 }
3351 if run_config.monitor && verdict_passed {
3352 let began = std::time::Instant::now();
3353 stage(
3354 &*live,
3355 task,
3356 Stage::Monitor,
3357 StageState::Started,
3358 None,
3359 None,
3360 );
3361 let patch = worktree::checkpoint(&wt_root, &tmp)
3362 .and_then(|now| worktree::patch(&wt_root, &start, &now))
3363 .unwrap_or_default();
3364 let calls: Vec<serde_json::Value> = report
3365 .tool_calls
3366 .iter()
3367 .map(|c| {
3368 let tool = c["tool"].as_str().unwrap_or_default();
3369 serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
3370 })
3371 .collect();
3372 let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
3373 let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
3374 report.monitor = Some(monitor);
3375 suspected = suspicion;
3376 stage(
3377 &*live,
3378 task,
3379 Stage::Monitor,
3380 if suspected {
3381 StageState::Failed
3382 } else {
3383 StageState::Passed
3384 },
3385 suspected.then(|| "suspects gaming".to_owned()),
3386 Some(began),
3387 );
3388 }
3389
3390 let in_place_stop = options
3393 .session
3394 .as_ref()
3395 .filter(|_| verdict_passed && !suspected)
3396 .and_then(|s| {
3397 s.lock()
3398 .ok()
3399 .and_then(|s| s.check_before_tick(&policy).err())
3400 });
3401 if let Some(e) = &in_place_stop {
3402 report.reason = Some(e.line());
3403 report.verdict = None;
3404 attempt_status = e.code.to_lowercase().replace('-', "_");
3406 report.attempt_status.clone_from(&attempt_status);
3407 }
3408 let passed = verdict_passed && !suspected && in_place_stop.is_none();
3409 if passed
3413 && matches!(
3414 report.verdict,
3415 Some(speccoder_core::verify::Verdict::RegressionOnly)
3416 )
3417 {
3418 use speccoder_core::regression::{Regression, RegressionFacts, RegressionReason, decide};
3419 let facts = RegressionFacts {
3420 acceptance_on: run_config.acceptance,
3421 story: acceptance_story
3423 .clone()
3424 .or_else(|| bug_fix.as_ref().map(|f| f.bug.requirement.clone()))
3425 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone())),
3426 scenarios: scenarios.len(),
3427 skip: acceptance.as_ref().and_then(|a| a.skip_cause.clone()),
3428 red_class: acceptance.as_ref().and_then(|a| {
3430 if a.red
3431 && report
3432 .acceptance
3433 .as_ref()
3434 .is_some_and(|r| r["green_flaky"] == true)
3435 {
3436 Some(speccoder_core::verify::RedClass::Flaky)
3437 } else {
3438 a.red_class
3439 }
3440 }),
3441 };
3442 let reason = if planning.is_some() {
3443 RegressionReason::PlanningWork
3444 } else if in_story && oracle_empty {
3445 RegressionReason::AuthorWroteNone
3446 } else if in_story {
3447 match &report.progress {
3449 Some(
3451 speccoder_core::story::Progress::Closed
3452 | speccoder_core::story::Progress::InProgress,
3453 ) if story_oracle
3454 .as_ref()
3455 .is_some_and(super::story::StoryOracle::green_from_the_start) =>
3456 {
3457 RegressionReason::AlreadyPassing
3458 }
3459 Some(speccoder_core::story::Progress::Closed) => RegressionReason::StoryVerified,
3460 _ if super::story::unchecked(&report) => RegressionReason::RegressionUnchecked,
3461 _ => RegressionReason::InProgress,
3462 }
3463 } else {
3464 decide(&facts)
3465 };
3466 report.regression = Some(Regression::of(reason));
3467 }
3468 let recorded_from = report.integrity.len();
3469 if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
3470 let began = std::time::Instant::now();
3471 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3472 let message = tick_message(&TickFacts {
3473 feature: &feature_name,
3474 task,
3475 title: &t.title,
3476 report: &report,
3477 scenarios: &story_scenarios,
3478 red: acceptance
3479 .as_ref()
3480 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red()))
3481 .or_else(|| story_red(&report)),
3482 checklists_overridden,
3483 extra: bug_fix
3484 .as_ref()
3485 .map(|f| {
3486 super::bug::trailers(
3487 f,
3488 &report
3489 .verdict
3490 .as_ref()
3491 .map(super::outcome::verdict_word)
3492 .unwrap_or_default(),
3493 &crate::strength::rulings(&strength_dir),
3494 )
3495 })
3496 .or_else(|| review_frozen.as_ref().map(|(r, m)| r.trailer(m)))
3498 .unwrap_or_default()
3499 + &ruling_trailers(&ruled),
3500 });
3501 match &options.session {
3502 None => {
3504 match format.tick(file, task, &t.line_hash) {
3505 Ok(()) => report.ticked = true,
3506 Err(e) => report.integrity.push(format!("tick refused: {e}")),
3507 }
3508 if report.ticked && run_config.commit_per_task {
3509 match commit_attempt(&wt_root, &message) {
3510 Ok(id) => report.commit = Some(id),
3511 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3512 }
3513 }
3514 }
3515 Some(session) => {
3518 let digest =
3519 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
3520 if let Ok(mut s) = session.lock() {
3521 let done = s.commit_and_tick(
3522 &plane_dir,
3523 file,
3524 &t.line_hash,
3525 &message,
3526 &digest,
3527 || format.tick(file, task, &t.line_hash),
3528 );
3529 report.ticked = done.ticked;
3530 report.commit = done.commit;
3531 report.integrity.extend(done.problems);
3532 }
3533 }
3534 }
3535 report.instructions = attempt_instructions
3536 .iter()
3537 .map(|i| speccoder_core::InstructionFile {
3538 path: i.path.clone(),
3539 sha256: i.sha256.clone(),
3540 bytes: i.bytes,
3541 cut: i.cut,
3542 })
3543 .collect();
3544 if let Some(commit) = report
3546 .commit
3547 .clone()
3548 .filter(|_| report.ticked && run_config.attest.enabled)
3549 {
3550 let verdict = report
3551 .verdict
3552 .as_ref()
3553 .map(super::outcome::verdict_word)
3554 .unwrap_or_default();
3555 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3556 let feature_dir = snap
3557 .feature
3558 .as_ref()
3559 .map(|f| f.feature.dir.clone())
3560 .unwrap_or_default();
3561 let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
3562 let tick = crate::attest::Tick {
3563 repo: &wt_root,
3564 config: &config_text,
3565 attest: &run_config.attest,
3566 format: &format!("{:?}", format.kind()).to_lowercase(),
3567 feature: &feature_name,
3568 feature_dir: &feature_dir,
3569 task,
3570 title: &t.title,
3571 line_hash: &t.line_hash,
3572 section: t.story.as_deref(),
3573 section_text: bundle_value["story"].as_str().unwrap_or_default(),
3574 requirements: &requirements,
3575 commit: &commit,
3576 verdict: &verdict,
3577 sandbox: serde_json::json!({
3578 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3579 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3580 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3581 }),
3582 executor: serde_json::json!({
3583 "kind": if bug_candidate.is_some() { "candidate".to_owned() } else { executor.as_ref().map_or_else(|| "native".to_owned(), |e| e.kind.name().to_owned()) },
3584 "candidate": bug_candidate.as_ref().map(|(_, c)| serde_json::json!({ "origin": c.origin, "digest": c.digest, "head_sha": c.head_sha })),
3585 }),
3586 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3587 bug: bug_fix
3588 .as_ref()
3589 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3590 };
3591 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3592 }
3593 let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
3594 stage(
3595 &*live,
3596 task,
3597 Stage::Commit,
3598 if committed {
3599 StageState::Passed
3600 } else {
3601 StageState::Failed
3602 },
3603 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3604 Some(began),
3605 );
3606 }
3607 if passed && let Some(n) = &native {
3610 let began = std::time::Instant::now();
3611 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3612 let verdict = report
3613 .verdict
3614 .as_ref()
3615 .map(super::outcome::verdict_word)
3616 .unwrap_or_default();
3617 let message = tick_message(&TickFacts {
3618 feature: &feature_name,
3619 task,
3620 title: &n.title,
3621 report: &report,
3622 scenarios: &story_scenarios,
3623 red: acceptance
3624 .as_ref()
3625 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red()))
3626 .or_else(|| story_red(&report)),
3627 checklists_overridden: false,
3628 extra: bug_fix
3629 .as_ref()
3630 .map(|f| {
3631 super::bug::trailers(f, &verdict, &crate::strength::rulings(&strength_dir))
3632 })
3633 .unwrap_or_default()
3634 + &ruling_trailers(&ruled),
3635 });
3636 match commit_attempt(&wt_root, &message) {
3637 Ok(id) => report.commit = Some(id),
3638 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3639 }
3640 if let Some(commit) = report.commit.clone().filter(|_| run_config.attest.enabled) {
3641 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3642 let tick = crate::attest::Tick {
3643 repo: &wt_root,
3644 config: &config_text,
3645 attest: &run_config.attest,
3646 format: &format!("{:?}", format.kind()).to_lowercase(),
3647 feature: &feature_name,
3648 feature_dir: &n.dir,
3649 task,
3650 title: &n.title,
3651 line_hash: "",
3652 section: None,
3653 section_text: &n.story,
3654 requirements: &[],
3655 commit: &commit,
3656 verdict: &verdict,
3657 sandbox: serde_json::json!({
3658 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3659 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3660 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3661 }),
3662 executor: serde_json::json!({
3663 "kind": if bug_candidate.is_some() { "candidate" } else if executor.is_some() { "external" } else { "native" },
3664 }),
3665 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3666 bug: bug_fix
3667 .as_ref()
3668 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3669 };
3670 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3671 }
3672 stage(
3673 &*live,
3674 task,
3675 Stage::Commit,
3676 if report.commit.is_some() {
3677 StageState::Passed
3678 } else {
3679 StageState::Failed
3680 },
3681 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3682 Some(began),
3683 );
3684 }
3685
3686 let unrecorded = report.integrity[recorded_from..]
3689 .iter()
3690 .find(|m| m.starts_with("tick refused") || m.starts_with("commit failed"))
3691 .cloned();
3692 let passed = passed && unrecorded.is_none();
3693 if let Some(why) = unrecorded {
3694 attempt_status = "tick_failed".to_owned();
3695 report.ticked = false;
3696 report.reason = Some(why);
3697 }
3698 if attempt_status == "repro_weak"
3703 && let Some(weak) = &bug_weak
3704 && let Err(e) = crate::strength::hold(&strength_dir, &[weak])
3705 {
3706 report
3707 .integrity
3708 .push(format!("holding the attempt failed: {e}"));
3709 }
3710 if attempt_status == "over_strict" || attempt_status == "repro_weak" {
3711 let findings = report
3712 .strength
3713 .as_ref()
3714 .map(|a| speccoder_core::strength::over_strict(&a.permitted))
3715 .unwrap_or_default();
3716 if attempt_status == "over_strict"
3717 && let Err(e) = crate::strength::hold(&strength_dir, &findings)
3718 {
3719 report
3720 .integrity
3721 .push(format!("holding the attempt failed: {e}"));
3722 }
3723 marker_guard.keep();
3724 }
3725 if report.ticked && !ruled.is_empty() {
3727 let _ = crate::acceptance_at::acted_on_task(&s.plane_dir, &feature_name, task);
3728 }
3729 let mut conflict: Option<String> = None;
3731 if !passed && attempt_status != "over_strict" && attempt_status != "repro_weak" {
3734 let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
3735 let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
3736 let reason = redact(&reason, secret);
3737 let story_open: Vec<String> = match &report.progress {
3742 Some(speccoder_core::story::Progress::StoryOpen { scenarios }) => scenarios.clone(),
3743 _ if outcome == "blocked" => story_oracle
3744 .as_ref()
3745 .and_then(|o| {
3746 let named = frozen_test_named(report.reason.as_deref()?)?;
3747 o.tests
3748 .iter()
3749 .find(|t| t.path == named)
3750 .map(|t| t.scenarios.clone())
3751 })
3752 .unwrap_or_default(),
3753 _ => Vec::new(),
3754 };
3755 let own_files: Vec<String> = report
3758 .acceptance
3759 .as_ref()
3760 .filter(|_| story_oracle.is_none() && story_key.is_none())
3762 .and_then(|a| a["files"].as_array())
3763 .into_iter()
3764 .flatten()
3765 .filter_map(|p| p.as_str().map(str::to_owned))
3766 .collect();
3767 let own_failed = report.verification.as_ref().is_some_and(|v| {
3768 v.checks
3769 .iter()
3770 .any(|c| c.name == "acceptance" && c.status.is_failure())
3771 });
3772 let named = (outcome == "blocked")
3773 .then(|| frozen_test_named(report.reason.as_deref()?))
3774 .flatten();
3775 let own_test = named
3776 .filter(|n| own_files.contains(n))
3777 .or_else(|| own_failed.then(|| own_files.first().cloned()).flatten());
3778 let spent = super::commit::all_usage(&report);
3780 let record = attempts::Record {
3781 run,
3782 outcome: &outcome,
3783 reason: &reason,
3784 pending: report.pending.as_ref(),
3785 usage: spent.as_ref(),
3786 story_open: &story_open,
3787 own_test: own_test.as_deref(),
3788 };
3789 conflict.clone_from(&own_test);
3790 match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
3791 .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
3792 {
3793 Ok(saved) => report.saved_attempt = Some(saved.reference),
3794 Err(e) => report
3795 .integrity
3796 .push(format!("saving the attempt failed: {}", e.0)),
3797 }
3798 }
3799
3800 let (status, mut message) = status_and_message(&report, &attempt_status, suspected);
3801 if status == CommandStatus::RunFailed
3804 && let Some(region) = s.model.region.as_deref()
3805 && report
3806 .reason
3807 .as_deref()
3808 .and_then(substrate::provider_refusal)
3809 .is_some()
3810 {
3811 message.push_str(&format!(
3812 " — Amazon Bedrock, region {region}, model {}",
3813 s.model.model
3814 ));
3815 }
3816 let refusal = |result: CommandResult| {
3819 if !result.has_explicit_code()
3820 && refused_account
3821 && result.status() == CommandStatus::RunFailed
3822 {
3823 result.with_code("PROVIDER-REFUSED").with_fix(
3824 substrate::model_config::provider_refused_fix(
3825 &s.model,
3826 crate::aws::detect_here().as_ref(),
3827 feature,
3828 ),
3829 )
3830 } else {
3831 result
3832 }
3833 };
3834 let offered = crate::ladder::offer_for(
3836 path,
3837 &worktree::project_state_dir(state, path),
3838 &format!("{feature_name}/{task}"),
3839 )
3840 .map(|o| progress::Event::LadderOffered {
3841 unit: o.unit,
3842 task: o.task,
3843 signals: o.signals.into_iter().map(|s| s.id).collect(),
3844 });
3845 if let Some(fix) = bug_fix.as_mut() {
3846 let done = refusal(super::bug::finished(
3847 &bug_ctx,
3848 fix,
3849 report,
3850 &attempt_status,
3851 passed,
3852 status,
3853 redact(&message, secret),
3854 ));
3855 if let Some(e) = &offered {
3856 live.event(e);
3857 }
3858 return done;
3859 }
3860 live.event(&finished_event(
3861 task,
3862 &report,
3863 &attempt_status,
3864 suspected,
3865 redact(&message, secret),
3866 ));
3867 if let Some(e) = &offered {
3868 live.event(e);
3869 }
3870 let code = collection_code(&report, &attempt_status).or_else(|| {
3872 matches!(
3873 report.progress,
3874 Some(speccoder_core::story::Progress::StoryOpen { .. })
3875 )
3876 .then_some("STORY-OPEN")
3877 });
3878 let never: Vec<(String, &str)> = match &report.progress {
3880 Some(
3881 speccoder_core::story::Progress::StoryOpen { scenarios }
3882 | speccoder_core::story::Progress::LinksOpen { scenarios },
3883 ) => scenarios
3884 .iter()
3885 .filter_map(|id| {
3886 let t = story_oracle
3887 .as_ref()?
3888 .tests
3889 .iter()
3890 .find(|t| t.scenarios.contains(id))?;
3891 let text = std::fs::read_to_string(project.join(&t.path)).ok()?;
3892 crate::integrity::never_passes(&t.path, &text).map(|why| (id.clone(), why))
3893 })
3894 .collect(),
3895 _ => Vec::new(),
3896 };
3897 for (id, why) in &never {
3898 message.push_str(&format!(" — {id}'s test can never pass: {why}"));
3899 }
3900 let rule = |id: &str| {
3901 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
3902 let why = never.iter().find(|(s, _)| s == id).map_or("…", |(_, w)| *w);
3903 format!("speccoder task rule {task}{f} --tests-wrong --scenario {id} --reason \"{why}\"")
3904 };
3905 let nudge = || {
3906 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
3907 format!("speccoder task nudge {task}{f} --note \"…\"")
3908 };
3909 let story_open_fix: Vec<String> = match &report.progress {
3910 Some(speccoder_core::story::Progress::StoryOpen { scenarios }) if !report.ticked => {
3912 scenarios
3913 .iter()
3914 .map(|id| rule(id))
3915 .chain(std::iter::once(nudge()))
3916 .collect()
3917 }
3918 Some(speccoder_core::story::Progress::LinksOpen { scenarios }) if !report.ticked => {
3921 let rules = scenarios.iter().map(|id| rule(id));
3922 if never.is_empty() {
3923 std::iter::once(nudge()).chain(rules).collect()
3924 } else {
3925 rules.chain(std::iter::once(nudge())).collect()
3926 }
3927 }
3928 _ => match &conflict {
3930 Some(path) if !report.ticked => {
3931 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
3932 message.push_str(&format!(
3933 " — its own test `{path}` failed: if it asserts what the spec does not say, rule it wrong"
3934 ));
3935 vec![
3936 format!("speccoder task rule {task}{f} --tests-wrong --reason \"…\""),
3937 format!("speccoder task nudge {task}{f} --note \"…\""),
3938 ]
3939 }
3940 _ => Vec::new(),
3941 },
3942 };
3943 let result = CommandResult::new("run", status, &redact(&message, secret));
3944 let result = if story_open_fix.is_empty() {
3945 result
3946 } else {
3947 result.with_fix(story_open_fix)
3948 };
3949 match code {
3950 Some(c) => result.with_code(c),
3951 None => refusal(result),
3952 }
3953 .with_data(CommandData::Run(Box::new(report)))
3954}
3955
3956fn collection_code(
3959 report: &speccoder_core::RunReport,
3960 attempt_status: &str,
3961) -> Option<&'static str> {
3962 if attempt_status == "collection_unknown" {
3963 return Some("TESTS-COLLECTION-UNKNOWN");
3964 }
3965 match &report.verdict {
3966 Some(speccoder_core::verify::Verdict::Failed { reasons })
3967 if reasons
3968 .first()
3969 .is_some_and(|r| r.starts_with(speccoder_core::collection::REASON_PREFIX)) =>
3970 {
3971 Some("TESTS-NOT-COLLECTED")
3972 }
3973 _ => None,
3974 }
3975}
3976
3977enum Collected {
3983 Unknown(String),
3985 Changed(String),
3987}
3988
3989fn judge_collection(
3990 dirs: (&Path, &Path, &Path),
3991 runner: &sandbox::Runner,
3992 otherwise_passing: bool,
3993 report: &mut speccoder_core::RunReport,
3994 live: &dyn progress::Progress,
3995 task: &str,
3996) -> Option<Collected> {
3997 let (project, wt_root, tmp) = dirs;
3998 let before = worktree::checkpoint(wt_root, tmp).ok();
4000 let checks = std::fs::read_to_string(project.join(crate::verify::config::FILE))
4001 .ok()
4002 .and_then(|t| crate::verify::config::parse(&t).ok())
4003 .unwrap_or_default()
4004 .into_iter()
4005 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
4006 .collect::<Vec<_>>();
4007 let results = report
4008 .verification
4009 .as_ref()
4010 .map(|v| v.checks.clone())
4011 .unwrap_or_default();
4012 let c = crate::collection::judge(&crate::collection::Input {
4013 root: project,
4014 changes: &report.changes,
4015 checks: &checks,
4016 results: &results,
4017 runner,
4018 listings: otherwise_passing,
4019 })?;
4020 if !c.listing.is_empty()
4021 && let Some(before) = &before
4022 {
4023 let changed: Vec<String> = worktree::checkpoint(wt_root, tmp)
4024 .and_then(|after| worktree::diff(wt_root, before, &after))
4025 .map(|d| {
4026 d.into_iter()
4027 .map(|c| c.path)
4028 .filter(|p| !speccoder_core::collection::is_build_or_dependency(p))
4029 .collect()
4030 })
4031 .unwrap_or_else(|e| vec![format!("(the tree could not be read: {})", e.0)]);
4032 if !changed.is_empty() {
4033 report.collection = Some(c);
4034 return Some(Collected::Changed(format!(
4035 "integrity: a test listing changed the worktree: {}",
4036 changed.join(", ")
4037 )));
4038 }
4039 }
4040 live.event(&progress::Event::CollectionChecked {
4041 task: task.to_owned(),
4042 files: c
4043 .files
4044 .iter()
4045 .map(|f| progress::CollectionFile {
4046 path: f.path.clone(),
4047 state: f.state,
4048 decided_by: f.decided_by,
4049 })
4050 .collect(),
4051 });
4052 let never = c.reasons();
4053 let unknown: Vec<String> = c
4054 .unknown()
4055 .map(|f| {
4056 format!(
4057 "speccoder cannot tell whether the runner ran `{}` ({})",
4058 f.path,
4059 f.why.as_deref().unwrap_or("no report or listing names it")
4060 )
4061 })
4062 .collect();
4063 report.collection = Some(c);
4064 if !never.is_empty() {
4065 let mut reasons = never;
4066 if let Some(speccoder_core::verify::Verdict::Failed { reasons: more }) =
4067 report.verdict.take()
4068 {
4069 reasons.extend(more);
4070 }
4071 report.verdict = Some(speccoder_core::verify::Verdict::Failed { reasons });
4072 return None;
4073 }
4074 (otherwise_passing && !unknown.is_empty()).then(|| Collected::Unknown(unknown.join("; ")))
4075}
4076
4077fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
4080 let said = c
4081 .stderr_tail
4082 .lines()
4083 .chain(c.stdout_tail.lines())
4084 .map(str::trim)
4085 .find(|l| l.starts_with("error"))
4086 .map(|l| l.chars().take(200).collect::<String>());
4087 let fix = match said.as_deref() {
4088 Some(l) if l.contains("profile `speccoder` not found") => Some(
4089 "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
4090 ),
4091 Some(l) if l.contains("no tests to run") => Some(
4092 "the project has no tests yet; if the first task writes them, run with --allow-failing-baseline, or add `--no-tests=pass` to the check's argv",
4093 ),
4094 Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
4095 "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
4096 ),
4097 _ => None,
4098 };
4099 match (said, fix) {
4100 (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
4101 (Some(s), None) => format!("{note} ({s})"),
4102 _ => note.to_owned(),
4103 }
4104}
4105
4106#[must_use]
4111pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
4112 let why = broken_why(c)?;
4113 Some(match c.sandbox.denied.first() {
4114 Some(first) => {
4115 let others = c.sandbox.denied.len() - 1 + c.sandbox.more;
4116 let more = if others > 0 {
4117 format!(" (and {others} more)")
4118 } else {
4119 String::new()
4120 };
4121 format!("{why} — the sandbox denied {first}{more}")
4122 }
4123 None => why,
4124 })
4125}
4126
4127fn broken_why(c: &speccoder_core::verify::CheckResult) -> Option<String> {
4128 use speccoder_core::verify::CheckStatus;
4129 if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
4130 return None;
4131 }
4132 if let Some(note) = &c.note {
4133 return Some(with_cause(note, c));
4134 }
4135 if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
4136 return None;
4137 }
4138 let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
4139 let runner = c
4143 .argv
4144 .first()
4145 .map(|a| a.rsplit('/').next().unwrap_or(a))
4146 .is_some_and(|a| {
4147 matches!(
4148 a,
4149 "cargo"
4150 | "pytest"
4151 | "python"
4152 | "python3"
4153 | "uv"
4154 | "npm"
4155 | "npx"
4156 | "pnpm"
4157 | "yarn"
4158 | "bun"
4159 | "go"
4160 | "jest"
4161 | "vitest"
4162 | "mvn"
4163 | "gradle"
4164 | "dotnet"
4165 | "mix"
4166 | "swift"
4167 )
4168 });
4169 let cannot_start = matches!(c.exit_code, Some(126 | 127));
4170 let is_broken = match &summary {
4171 _ if cannot_start => true,
4172 Some(s) if s.build_failed => true,
4173 Some(s) => s.passed + s.failed + s.errors == 0,
4174 None => runner,
4175 };
4176 match is_broken {
4177 false => None,
4178 true => {
4179 let first = c
4180 .stderr_tail
4181 .lines()
4182 .chain(c.stdout_tail.lines())
4183 .map(str::trim)
4184 .find(|l| l.to_lowercase().contains("error"))
4185 .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
4186 .unwrap_or("it failed without running a test")
4187 .chars()
4188 .take(160)
4189 .collect::<String>();
4190 Some(first)
4191 }
4192 }
4193}
4194
4195fn login(
4199 fail: impl FnOnce(CommandStatus, String) -> CommandResult,
4200 shown: &str,
4201 agent: &crate::acp::agent::Agent,
4202 ways: &[crate::acp::session::SignIn],
4203) -> CommandResult {
4204 let words: Vec<String> = ways
4205 .iter()
4206 .filter(|w| w.command.is_none())
4207 .map(|w| {
4208 format!(
4209 "→ {}",
4210 w.description.clone().unwrap_or_else(|| w.name.clone())
4211 )
4212 })
4213 .collect();
4214 let mut commands: Vec<Vec<String>> = ways
4215 .iter()
4216 .filter_map(|w| w.command.as_deref())
4217 .map(|c| c.split_whitespace().map(str::to_owned).collect())
4218 .collect();
4219 if words.is_empty()
4220 && commands.is_empty()
4221 && let Some(p) = agent.preset()
4222 {
4223 commands.push(p.login.split_whitespace().map(str::to_owned).collect());
4224 }
4225 commands.dedup();
4226 let argvs: Vec<speccoder_core::step::Argvs> = commands.into_iter().map(|c| vec![c]).collect();
4227 let mut fix: Vec<String> = argvs
4228 .iter()
4229 .map(|a| speccoder_core::step::render(a))
4230 .collect();
4231 fix.extend(words);
4232 fix.push("speccoder doctor".to_owned());
4233 fail(
4234 CommandStatus::NeedsHuman,
4235 format!("{shown} needs signing in before it can make the attempt; nothing was attempted"),
4236 )
4237 .with_code("EXECUTOR-LOGIN")
4238 .with_fix(fix)
4239 .with_commands(argvs)
4240}
4241
4242fn story_red(report: &speccoder_core::RunReport) -> Option<speccoder_core::verify::RedClass> {
4245 let a = report
4246 .acceptance
4247 .as_ref()
4248 .filter(|a| a["oracle"].is_string())?;
4249 serde_json::from_value::<speccoder_core::verify::RedClass>(a["red_class"].clone())
4250 .ok()
4251 .filter(|c| c.is_red())
4252}
4253
4254fn with_oracle(bundle: &serde_json::Value, oracle: &serde_json::Value) -> serde_json::Value {
4257 let mut b = bundle.clone();
4258 if !oracle.is_null() {
4259 b["oracle"] = oracle.clone();
4260 }
4261 b
4262}
4263
4264fn ruling_trailers(ruled: &[crate::acceptance_at::Ruling]) -> String {
4267 ruled
4268 .iter()
4269 .map(|r| {
4270 format!(
4271 "Ruling: tests-wrong by {} — {}\n",
4272 r.by,
4273 r.reason.replace('\n', " ")
4274 )
4275 })
4276 .collect()
4277}
4278
4279fn frozen_test_named(reason: &str) -> Option<String> {
4282 Some(
4283 reason
4284 .split("frozen test `")
4285 .nth(1)?
4286 .split('`')
4287 .next()?
4288 .trim_start_matches("./")
4289 .to_owned(),
4290 )
4291}