Skip to main content

speccoder_runtime/pipeline/
task.rs

1//! `speccoder run --task`: one task from baseline to tick.
2
3use 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/// `speccoder run`: one attempt at a ready task in its worktree, judged by
16/// the verifier. The provider comes from `[model]` and its key variable.
17#[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
41/// [`run`] with an explicit state directory and model driver.
42pub 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/// [`run_with`] with explicit [`RunOptions`].
62#[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/// [`run_with`] asking `approver` inline when a call needs approval.
76#[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/// Runs a bug of the format's own bug structure: a unit no task list
94/// names, its task `bug:<slug>`, ticked nowhere.
95#[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
111#[allow(clippy::too_many_lines)] // one linear procedure, kept in reading order
112pub(crate) fn run_task(
113    path: &Path,
114    feature: Option<&str>,
115    task: &str,
116    state: &Path,
117    provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
118    secret: Option<&str>,
119    options: &RunOptions,
120) -> CommandResult {
121    use speccoder_core::RunReport;
122    // In place: the person's checkout, switched and switched back around the task.
123    if options.in_place && options.session.is_none() {
124        return in_session("run", path, feature, state, options, |o| {
125            run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
126        });
127    }
128    let approver = options.approver.clone();
129    use speccoder_core::snapshot::TaskState;
130
131    let refuse =
132        |status: CommandStatus, m: String| CommandResult::new("run", status, &redact(&m, secret));
133
134    // 1. The feature, as selected in the user's checkout.
135    let s = match setup(path, feature, state, "run") {
136        Ok(s) => s,
137        Err(r) => return *r,
138    };
139    // A bug in the format's own bug structure: a unit no task list names.
140    let native = options.bug_home.clone();
141    let feature_name = match (&native, s.snapshot.feature.as_ref()) {
142        (Some(n), _) => n.unit.clone(),
143        (None, Some(f)) => f.feature.name.clone(),
144        (None, None) => {
145            return refuse(
146                CommandStatus::SelectionNeeded,
147                "no feature selected; pass --feature".to_owned(),
148            );
149        }
150    };
151    // A lane keeps its own journal and live state; a person's
152    // decisions are the feature's.
153    let plane_dir = match &options.lane {
154        Some(lane) => s.plane_dir.join("lanes").join(lane),
155        None => s.plane_dir.clone(),
156    };
157    // Live progress: live.json for `speccoder report --live`, and the printer.
158    let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
159        &plane_dir,
160        s.model.prices,
161        options.progress.clone(),
162    ));
163
164    // 2. The worktree.
165    let wt = match &native {
166        Some(n) => crate::commands::worktree_of(&s.snapshot, &n.unit, &n.dir, state),
167        None => workspace(path, Some(&feature_name), state, options),
168    };
169    let Some(CommandData::Worktree(w)) = wt.data() else {
170        return CommandResult::carried("run", &wt);
171    };
172    if wt.status() != CommandStatus::Ok {
173        return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
174    }
175    let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
176        return refuse(CommandStatus::Error, "worktree without a path".to_owned());
177    };
178    let (wt_path, project_dir) = match &options.lane {
179        None => (wt_path, project_dir),
180        Some(lane) => match lane_worktree(path, state, &feature_name, lane, &wt_path, &project_dir)
181        {
182            Ok(paths) => paths,
183            Err(e) => return refuse(CommandStatus::GitPrecondition, e),
184        },
185    };
186    let project = std::path::PathBuf::from(&project_dir);
187    let wt_root = std::path::PathBuf::from(&wt_path);
188    // Scratch worktrees a crashed bisection left behind go first.
189    super::bisect::prune(&wt_root, &s.plane_dir.join("tmp"));
190    let tmp = plane_dir.join("tmp");
191
192    // 2a. A task waiting for a person holds the worktree with its attempt:
193    //     another task starts once it is decided and has resumed.
194    //     Read through the journal's writer, which repairs one a killed run
195    //     left unclosed; calls that cannot be read refuse the run, never
196    //     pass for none waiting.
197    let elsewhere = if options.lane.is_none() {
198        match crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task) {
199            Ok(e) => e,
200            Err(e) => {
201                return refuse(
202                    CommandStatus::Error,
203                    format!(
204                        "the calls waiting for a person could not be read, so whether another task's attempt holds the worktree is unknown: {e}"
205                    ),
206                );
207            }
208        }
209    } else {
210        crate::approvals::Elsewhere::default()
211    };
212    if let Some((other, covers)) = elsewhere.waiting {
213        return refuse(
214            CommandStatus::NeedsHuman,
215            format!(
216                "{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}`"
217            ),
218        );
219    }
220    // 2a'. So does an attempt held for a ruling on its tests.
221    if options.lane.is_none()
222        && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
223    {
224        return refuse(
225            CommandStatus::NeedsHuman,
226            format!(
227                "{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`"
228            ),
229        )
230        .with_code("TESTS-OVER-STRICT");
231    }
232    // 2b. Leftovers of an interrupted run (or edits by hand) are saved under
233    //     an attempt ref and set aside: a run starts from the branch tip
234    //    . Without per-task commits, uncommitted work is expected.
235    let commits_per_task = verify::find_config(&project)
236        .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
237        .and_then(|t| substrate::run_config::parse(&t).ok())
238        .is_none_or(|c| c.commit_per_task);
239    // an attempt a killed process left behind is resumed, not set
240    // aside — the worktree holds what its finished tool calls wrote.
241    let marker_path = resume::path(&plane_dir, &feature_name, task);
242    // A fresh start for the task's tests: its held (or interrupted) attempt
243    // is set aside below like any leftover, and new tests are written.
244    if options.fresh_acceptance && options.lane.is_none() {
245        let _ = std::fs::remove_file(&marker_path);
246        crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
247    }
248    let head_now = git::Git::at(&wt_root)
249        .line(&["rev-parse", "HEAD"])
250        .unwrap_or_default();
251    // An autopilot run has exactly the grants ticked on its sheet:
252    // it resumes no interrupted attempt — whose journal holds an earlier
253    // run's grants and answers — but sets it aside like any leftover, and
254    // adopts no answer kept for the task.
255    let autopilot = options.mode == speccoder_core::oracle::RunMode::Autopilot;
256    let interrupted = resume::load(&marker_path).filter(|m| {
257        m.head == head_now && options.lane.is_none() && !head_now.is_empty() && !autopilot
258    });
259    // A resumed attempt continues its run: this invocation adopts the
260    // run's id, its asking count and its grants.
261    if let Some(m) = &interrupted {
262        if let (Some(id), Ok(mut run)) = (&m.run_id, options.run_id.lock()) {
263            *run = crate::pipeline::RunId(id.clone());
264        }
265        if let Ok(mut asked) = options.asked.lock() {
266            *asked = (*asked).max(m.asked);
267        }
268        if let Ok(mut g) = options.run_grants.lock() {
269            for o in &m.run_grants {
270                if !g.contains(o) {
271                    g.push(o.clone());
272                }
273            }
274        }
275    }
276    let marker_guard = resume::Guard::new(marker_path.clone());
277    // Where the interrupted attempt was, from the live record it left
278    // (read before this run overwrites it).
279    let resumed_from = interrupted.as_ref().map(|_| {
280        std::fs::read_to_string(plane_dir.join(progress::FILE))
281            .ok()
282            .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
283            .filter(|l| l.task == task)
284            .map_or((None, None), |l| (l.stage, l.last_call))
285    });
286    let mut set_aside = None;
287    // In place, a dirty checkout is the person's: the session guard refuses it.
288    if interrupted.is_none()
289        && commits_per_task
290        && options.session.is_none()
291        && worktree::require_clean(&wt_root).is_err()
292    {
293        // What the checks leave behind — build output not ignored, a
294        // lockfile never committed — is no one's edit: setting it aside
295        // would store it in git on every run. The setup is fixed once.
296        let left = worktree::leftovers(&wt_root);
297        if !left.is_empty() {
298            let mut what = Vec::new();
299            if !left.commit.is_empty() {
300                what.push(format!("{} is not committed", left.commit.join(", ")));
301            }
302            if !left.ignore.is_empty() {
303                what.push(format!("{} is not git-ignored", left.ignore.join(", ")));
304            }
305            let checkout = std::path::Path::new(&s.snapshot.root);
306            let remedy = crate::code::setup_remedy(checkout, Some(&feature_name));
307            let command = remedy
308                .as_ref()
309                .map_or_else(|| left.fix(checkout, &wt_root, &[]), |r| r.command.clone());
310            return refuse(
311                CommandStatus::GitPrecondition,
312                format!(
313                    "{} — the checks write it on every run, so each run would find it in the worktree and set it aside",
314                    what.join(" and "),
315                ),
316            )
317            .with_code("SETUP-INCOMPLETE")
318            .with_fix(vec!["speccoder fix".to_owned(), command])
319            .with_remedy(remedy);
320        }
321        match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
322            Ok(reference) => set_aside = Some(reference),
323            Err(e) => return refuse(CommandStatus::GitPrecondition, e),
324        }
325    }
326
327    // 3. Spec, checks and policy as committed in the worktree.
328    let format = crate::format::at(&project);
329    let Some(snap) = format.inspect(
330        &project,
331        if native.is_some() {
332            None
333        } else {
334            Some(&feature_name)
335        },
336    ) else {
337        return refuse(
338            CommandStatus::NotAProject,
339            format.messages().no_project_in_worktree.to_owned(),
340        );
341    };
342    if native.is_some()
343        && crate::bug::state::load(&s.plane_dir, &feature_name, task)
344            .is_some_and(|b| b.outcome == speccoder_core::bug::Outcome::Fixed)
345    {
346        return refuse(
347            CommandStatus::TaskNotReady,
348            format!("{task} is already fixed"),
349        )
350        .with_code("TASK-DONE");
351    }
352    // The task must exist and be ready in the worktree, where progress
353    // (ticks committed on speccoder/<feature>) lives.
354    if native.is_none() {
355        let Some(st) = snap
356            .feature
357            .as_ref()
358            .and_then(|f| f.schedule.as_ref())
359            .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
360        else {
361            return refuse(
362                CommandStatus::NotFound,
363                format!("no task {task} in {feature_name}"),
364            );
365        };
366        match st.state {
367            TaskState::Done => {
368                return refuse(
369                    CommandStatus::TaskNotReady,
370                    format!("{task} is already done"),
371                )
372                .with_code("TASK-DONE");
373            }
374            TaskState::Blocked => {
375                return refuse(
376                    CommandStatus::TaskNotReady,
377                    format!("{task} is blocked by {}", st.blocked_by.join(", ")),
378                )
379                .with_code("TASK-BLOCKED");
380            }
381            TaskState::Ready => {}
382        }
383    }
384    // Unfinished checklists stop the task before any model call (the
385    // committed checklists in the worktree are what counts).
386    let checklists_overridden = if native.is_some() {
387        false
388    } else {
389        match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
390            Ok(overridden) => overridden,
391            Err(stop) => return *stop,
392        }
393    };
394    let parsed_task = snap
395        .feature
396        .as_ref()
397        .and_then(|f| f.tasks.as_ref())
398        .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
399    let tasks_file = snap
400        .feature
401        .as_ref()
402        .and_then(|f| f.tasks.as_ref())
403        .map(|t| project.join(&t.path));
404
405    let bundle = match native
406        .as_ref()
407        .map(|n| n.bundle(&project))
408        .map_or_else(|| format.bundle(&snap, task), Ok)
409    {
410        Ok(b) => b,
411        Err(_) => {
412            return refuse(
413                CommandStatus::NotFound,
414                format!("no task {task} in the worktree's {feature_name}"),
415            );
416        }
417    };
418    // A person's decision: this Setup or Foundational task writes
419    // a story's acceptance tests — its author gets the story's scenarios.
420    let acceptance_story = native
421        .is_none()
422        .then(|| crate::acceptance_at::story_at(&s.plane_dir, &feature_name, task))
423        .flatten();
424    let story_task: Option<String> = acceptance_story.as_ref().and_then(|st| {
425        snap.feature
426            .as_ref()
427            .and_then(|f| f.tasks.as_ref())
428            .and_then(|t| {
429                t.tasks
430                    .iter()
431                    .find(|t| t.story.as_deref() == Some(st.as_str()))
432            })
433            .map(|t| t.id.clone())
434    });
435    let bundle = match (&acceptance_story, &story_task) {
436        (Some(st), Some(other)) => {
437            let mut b = bundle;
438            if let Ok(of) = format.bundle(&snap, other) {
439                b.story = of.story;
440            }
441            b.task.story.clone_from(st);
442            b
443        }
444        _ => bundle,
445    };
446    let Some(config_root) = verify::find_config(&project) else {
447        return refuse(
448            CommandStatus::ConfigError,
449            "the worktree has no speccoder.toml (is it committed?)".to_owned(),
450        )
451        .with_code("CONFIG-MISSING");
452    };
453    let config_text =
454        std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
455    let run_config = match substrate::run_config::parse(&config_text) {
456        Ok(c) => c,
457        Err(e) => {
458            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
459                .with_code("CONFIG-INVALID");
460        }
461    };
462    // `[review] required`: the spec as committed in the worktree must be
463    // approved, every requirement on its current wording.
464    if run_config.review_required
465        && let Some(f) = snap.feature.as_ref()
466        && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
467        && !t.complete()
468    {
469        return refuse(
470            CommandStatus::NeedsHuman,
471            format!(
472                "{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",
473                t.approved, t.open, t.changes_requested, t.lapsed, t.questions
474            ),
475        )
476        .with_code("REVIEW-PENDING");
477    }
478    // Two test authors read a requirement two ways: the person says which
479    // is meant before a gated run.
480    if run_config.review_required {
481        let ambiguous = match crate::oracle::open_ambiguities(
482            &s.plane_dir,
483            &feature_name,
484            &format.oracle_requirements(&snap),
485        ) {
486            Ok(a) => a,
487            Err(t) => {
488                return refuse(
489                    CommandStatus::NeedsHuman,
490                    format!(
491                        "{}: whether it is read two ways is unknown and [review] required is on",
492                        t.0
493                    ),
494                )
495                .with_code("ORACLE-TAMPERED");
496            }
497        };
498        if let Some((req, scenario)) = ambiguous.first() {
499            return refuse(
500                CommandStatus::NeedsHuman,
501                format!(
502                    "{req} is read two ways ({scenario}) and [review] required is on: choose a reading or reword it first"
503                ),
504            )
505            .with_code("REVIEW-AMBIGUOUS")
506            .with_fix(vec![
507                format!("speccoder review tests {req} --reading A {scenario}"),
508                format!("speccoder review tests {req} --reading B {scenario}"),
509            ]);
510        }
511    }
512    let executor = match external::parse(&config_text) {
513        Ok(e) => e,
514        Err(e) => {
515            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
516                .with_code("CONFIG-INVALID");
517        }
518    };
519    // Every command this task starts runs confined to the worktree.
520    let sandbox_config = match sandbox::parse(&config_text) {
521        Ok(c) => c,
522        Err(e) => {
523            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
524                .with_code("CONFIG-INVALID");
525        }
526    };
527    let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
528        Ok(r) => r,
529        Err(e) => return refuse(CommandStatus::ConfigError, e),
530    };
531    // Autopilot asks nobody: it runs only where the OS sandbox confines it.
532    if options.mode == speccoder_core::oracle::RunMode::Autopilot && !runner.is_sandboxed() {
533        return refuse(
534            CommandStatus::ConfigError,
535            "autopilot needs the OS sandbox, and commands here would run unsandboxed".to_owned(),
536        )
537        .with_code("SANDBOX-UNAVAILABLE");
538    }
539    let mutation_config = match mutation::parse(&config_text) {
540        Ok(c) => c,
541        Err(e) => {
542            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
543                .with_code("CONFIG-INVALID");
544        }
545    };
546    let worktree_config = match crate::prepare::parse(&config_text) {
547        Ok(c) => c,
548        Err(e) => {
549            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
550                .with_code("CONFIG-INVALID");
551        }
552    };
553    let check_argvs = verify::check_argvs(&config_root);
554    let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow)
555        .with_prefixes(run_config.allow_prefix.clone())
556        .rules(format.rules());
557    // In place: the model's view of the checkout confined (git that moves
558    // it denied, ignored files hidden, `.git` read-only to commands).
559    let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
560    {
561        Some(s) => s.confine(policy, runner),
562        None => (policy, runner, None),
563    };
564    let sandbox_notice = sandbox_notice.or(carve_notice);
565    // In place: the checkout as speccoder left it, the intent as the run
566    // started with it — before the task, and again before the tick.
567    if let Some(e) = options.session.as_ref().and_then(|s| {
568        s.lock().ok().and_then(|s| {
569            // A resumed attempt's writes are in the checkout already.
570            if interrupted.is_some() {
571                s.check_before_tick(&policy).err()
572            } else {
573                s.check(&policy).err()
574            }
575        })
576    }) {
577        return e.result("run");
578    }
579
580    let wt_root_canonical = std::fs::canonicalize(&wt_root)
581        .unwrap_or_else(|_| wt_root.clone())
582        .display()
583        .to_string();
584
585    // A person's approvals (of this run only: a grant ends with its run)
586    // and rejections for this task.
587    let this_run = options.run();
588    let (decisions, task_grants) = match approvals::Decisions::load(&s.plane_dir) {
589        Ok(mut d) => {
590            // Decisions on a call a lane stopped belong to the run that
591            // now attempts the task (an autopilot run claims none).
592            if !autopilot
593                && d.claim(&feature_name, task, this_run.as_str())
594                && let Err(e) = d.save(&s.plane_dir)
595            {
596                return refuse(CommandStatus::Error, e);
597            }
598            (
599                d.for_task(&feature_name, task),
600                if autopilot {
601                    Vec::new()
602                } else {
603                    d.for_run(&feature_name, task, this_run.as_str())
604                },
605            )
606        }
607        Err(e) => return refuse(CommandStatus::ConfigError, e),
608    };
609
610    if let Some((stage, last_call)) = &resumed_from {
611        live.event(&progress::Event::Resumed {
612            task: task.to_owned(),
613            stage: *stage,
614            last_call: last_call.clone(),
615        });
616    }
617    live.event(&progress::Event::TaskStarted {
618        feature: feature_name.clone(),
619        task: task.to_owned(),
620        title: parsed_task
621            .as_ref()
622            .map(|t| t.title.clone())
623            .unwrap_or_default(),
624    });
625    // From here on, an early end is recorded as the task run's end.
626    let fail = |status: CommandStatus, m: String| {
627        live.event(&progress::Event::TaskFinished {
628            task: task.to_owned(),
629            outcome: "error".to_owned(),
630            message: redact(&m, secret),
631            run: None,
632            input_tokens: 0,
633            output_tokens: 0,
634            cache_read_tokens: 0,
635            cache_write_tokens: 0,
636            usd: None,
637            regression: None,
638        });
639        refuse(status, m)
640    };
641    // 4. Prepare: the git-ignored files `[worktree] include` names copied
642    //    from the checkout, and the worktree's dependency folders (Node,
643    //    uv) installed, once per lockfile. In place, the checkout has its own.
644    let mut prepare_notes: Vec<String> = Vec::new();
645    if options.session.is_none() {
646        match crate::include::copy(
647            &wt_root,
648            &project,
649            Path::new(&s.snapshot.root),
650            &worktree_config.include,
651        ) {
652            Ok(c) => {
653                if !c.now.is_empty() {
654                    prepare_notes.push(format!(
655                        "copied from your checkout ([worktree] include): {}",
656                        c.now.join(", ")
657                    ));
658                }
659                prepare_notes.extend(
660                    c.skipped
661                        .iter()
662                        .map(|n| format!("not copied ([worktree] include): {n}")),
663                );
664                for n in &prepare_notes {
665                    live.event(&progress::Event::Notice { text: n.clone() });
666                }
667            }
668            Err(e) => {
669                return fail(
670                    CommandStatus::ConfigError,
671                    format!("the files [worktree] include names could not be copied: {e}"),
672                )
673                .with_code("PREPARE-FAILED");
674            }
675        }
676    }
677    let prepares =
678        options.session.is_none() && worktree_config.prepare && crate::prepare::needed(&project);
679    if prepares {
680        let began = std::time::Instant::now();
681        stage(
682            &*live,
683            task,
684            Stage::Prepare,
685            StageState::Started,
686            None,
687            None,
688        );
689        let prepared = crate::prepare::prepare(&crate::prepare::Prepare {
690            worktree: &wt_root,
691            project: &project,
692            config: &worktree_config,
693            runner: &runner,
694            state: Some(state),
695        });
696        match prepared {
697            Ok(None) => {}
698            Ok(Some(p)) => {
699                stage(
700                    &*live,
701                    task,
702                    Stage::Prepare,
703                    if p.done.is_empty() {
704                        StageState::Skipped
705                    } else {
706                        StageState::Passed
707                    },
708                    Some(if p.done.is_empty() {
709                        "reused: the lockfiles are unchanged".to_owned()
710                    } else {
711                        p.done.join(", ")
712                    }),
713                    Some(began),
714                );
715            }
716            Err(f) => {
717                stage(
718                    &*live,
719                    task,
720                    Stage::Prepare,
721                    StageState::Failed,
722                    Some(f.message.clone()),
723                    Some(began),
724                );
725                return fail(CommandStatus::ConfigError, f.message)
726                    .with_code("PREPARE-FAILED")
727                    .with_fix(f.fix);
728            }
729        }
730    } else if options.session.is_none() {
731        // Nothing to prepare (any more): the next prepare starts over.
732        crate::prepare::forget(&wt_root);
733    }
734
735    // 4'. Baseline: no verdict without a judge.
736    let began = std::time::Instant::now();
737    stage(
738        &*live,
739        task,
740        Stage::Baseline,
741        StageState::Started,
742        None,
743        None,
744    );
745    let baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
746        Ok(b) => b,
747        Err(e) => {
748            stage(
749                &*live,
750                task,
751                Stage::Baseline,
752                StageState::Failed,
753                None,
754                Some(began),
755            );
756            return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
757        }
758    };
759    // A check that cannot run on the untouched code — it failed without
760    // running a test: not found, a build or configuration error, a file the
761    // worktree lacks because it was never committed — can never judge the
762    // task. Stop before any model call rather than spend attempts on it.
763    if !options.allow_failing_baseline
764        && let Some((c, why)) = baseline
765            .checks
766            .iter()
767            .find_map(|c| broken(c).map(|w| (c, w)))
768    {
769        stage(
770            &*live,
771            task,
772            Stage::Baseline,
773            StageState::Failed,
774            Some(format!("`{}` cannot run", c.name)),
775            Some(began),
776        );
777        // Configuration the checkout never committed is the likely cause,
778        // and one speccoder can fix.
779        let checkout = std::path::Path::new(&s.snapshot.root);
780        if let Some(remedy) = crate::code::setup_remedy(checkout, Some(&feature_name)) {
781            return fail(
782                CommandStatus::GitPrecondition,
783                format!(
784                    "check `{}` cannot run in the worktree: your checkout has not committed its setup — {why}",
785                    c.name
786                ),
787            )
788            .with_code("SETUP-INCOMPLETE")
789            .with_fix(vec!["speccoder fix".to_owned(), remedy.command.clone()])
790            .with_remedy(Some(remedy));
791        }
792        return fail(
793            CommandStatus::ConfigError,
794            // A cause speccoder recognised comes with its fix; else the
795            // general one.
796            if why.contains("git add") || why.contains("--allow-failing-baseline") {
797                format!(
798                    "check `{}` cannot run on the untouched code, so it could never judge {task}: {why}",
799                    c.name
800                )
801            } else {
802                format!(
803                    "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",
804                    c.name
805                )
806            },
807        )
808        .with_code("CHECK-BROKEN");
809    }
810    // Failing tests at the baseline are reported, not blamed: the stage held.
811    let failing = baseline
812        .checks
813        .iter()
814        .filter(|c| c.status.is_failure())
815        .count();
816    stage(
817        &*live,
818        task,
819        Stage::Baseline,
820        StageState::Passed,
821        (failing > 0).then(|| format!("{failing} check(s) already failing")),
822        Some(began),
823    );
824
825    let rt = match tokio_runtime() {
826        Ok(rt) => rt,
827        Err(e) => return fail(CommandStatus::RunFailed, e),
828    };
829    let bundle_value = match serde_json::to_value(&bundle) {
830        Ok(v) => v,
831        Err(e) => return fail(CommandStatus::Error, e.to_string()),
832    };
833    // The repository's instructions for agents, from the tree the
834    // attempt starts in: for speccoder's own calls (the attempt, the test
835    // authors); an external agent reads its own files.
836    let instructions = crate::instructions::collect(
837        &wt_root,
838        &project,
839        &speccoder_core::scope::named_paths(&bundle.task.title),
840        &run_config.instructions,
841    );
842    let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
843        &instructions
844    } else {
845        &[]
846    };
847
848    // MCP servers, started in the worktree for this task's attempts.
849    let servers = match mcp::parse(&config_text) {
850        Ok(s) => s,
851        Err(e) => {
852            return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
853                .with_code("CONFIG-INVALID");
854        }
855    };
856    let mut connected = if servers.is_empty() {
857        mcp::Connected::default()
858    } else {
859        live.event(&progress::Event::Notice {
860            text: format!("starting {} MCP server(s)", servers.len()),
861        });
862        rt.block_on(mcp::connect(&servers, &project, &runner))
863    };
864    // An allowed tool its server changed since review needs approval again.
865    let approved_tools: Vec<String> = task_grants
866        .iter()
867        .filter_map(|g| g.tool_name().map(str::to_owned))
868        .collect();
869    let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
870    connected.notices.extend(pinned);
871
872    // One plane for the whole task. Its tool server enforces denials; the
873    // frozen-test approvals are the skill's, from its journaled input.
874    let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
875        Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
876        Err(e) => return fail(CommandStatus::Error, e.to_string()),
877    };
878    // Calls past their deadline are denied as expired before anything
879    // resumes; a run that cannot tell does not start.
880    let swept =
881        match crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc()) {
882            Ok(p) => p,
883            Err(e) => {
884                return fail(
885                    CommandStatus::RunFailed,
886                    format!("the calls past their deadline could not be denied: {e}"),
887                );
888            }
889        };
890    // What a person is shown about the sandbox, for every call they decide.
891    let present = crate::approvals::PresentContext {
892        root: None,
893        sandbox: runner.is_sandboxed().then(|| {
894            if cfg!(target_os = "macos") {
895                "Seatbelt".to_owned()
896            } else {
897                "Landlock+seccomp".to_owned()
898            }
899        }),
900        network: match sandbox_config.network {
901            sandbox::Network::Off => "none",
902            sandbox::Network::Loopback => "loopback",
903            sandbox::Network::On => "unrestricted",
904        }
905        .to_owned(),
906        file_text: None,
907    };
908    let plane = match rt.block_on(async {
909        substrate::Plane::open_with(
910            &plane_dir,
911            &s.model,
912            provider,
913            Some(tools_server),
914            substrate::Hooks {
915                progress: Some(Arc::clone(&live)),
916                mcp: connected.clients.clone(),
917                mcp_tools: connected.tools.clone(),
918                format: Some(format),
919                present: present.clone(),
920            },
921        )
922    }) {
923        Ok(p) => p,
924        Err(e) => return fail(CommandStatus::RunFailed, e.0),
925    };
926    let mut notices: Vec<String> = elsewhere
927        .replaced_journal
928        .iter()
929        .chain(swept.iter())
930        .chain(plane.replaced_journal.iter())
931        .map(|p| {
932            format!(
933                "the journal could not be read by this build and was moved to {}; past runs stay in git",
934                p.display()
935            )
936        })
937        .chain(connected.notices.iter().cloned())
938        .chain(sandbox_notice.clone())
939        .chain(prepare_notes.iter().cloned())
940        .chain(resumed_from.as_ref().map(|(stage, last_call)| {
941            format!(
942                "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
943                stage.map_or("a run", speccoder_core::stage::Stage::name),
944                last_call
945                    .as_ref()
946                    .map(|c| format!(" ({c})"))
947                    .unwrap_or_default()
948            )
949        }))
950        .collect();
951
952    // A Convergence task is the converge model's claim that a gap exists;
953    // it changes no code until a failing test demonstrates the gap.
954    let demand_red = run_config.acceptance && format.is_convergence(&bundle.task.phase);
955    // Where this run started: an attempt that does not pass is undone to here.
956    // Where an attempt that does not pass is undone to: the tree the task
957    // started from. A resumed attempt (interrupted, or waiting for a
958    // person) left its own writes in the tree, so its start is the branch
959    // tip it began at — a task always starts from a clean tip.
960    let start = match &interrupted {
961        Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
962            Ok(tree) => worktree::Checkpoint(tree),
963            Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
964        },
965        None => match worktree::checkpoint(&wt_root, &tmp) {
966            Ok(c) => c,
967            Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
968        },
969    };
970
971    let began = std::time::Instant::now();
972    // 4b. Acceptance tests derived from the spec, written before the
973    //     attempt so they are frozen, and required to be red now.
974    // 4a. A bug task: reproduce (and the person's confirmation) before
975    //     anything else; once confirmed, its reproductions are the visible set.
976    let bug_ctx = super::bug::Ctx {
977        rt: &rt,
978        plane: &plane,
979        project: &project,
980        wt_root: &wt_root,
981        wt_path: &wt_path,
982        tmp: &tmp,
983        policy: &policy,
984        runner: &runner,
985        state: &s.plane_dir,
986        feature: &feature_name,
987        task,
988        bundle: &bundle_value,
989        instructions: &instructions,
990        live: &*live,
991        approver: approver.as_deref(),
992        held_out: run_config.held_out,
993        max_tries: run_config.reproduce_tries,
994        bug_test: run_config.bug_test.as_deref(),
995        start: &start,
996    };
997    let mut bug_fix = match super::bug::phase(&s.plane_dir, &feature_name, task, &project, format) {
998        Err(e) => return fail(CommandStatus::NeedsHuman, e),
999        Ok(None) => None,
1000        Ok(Some(super::bug::Phase::Fix(f))) => Some(*f),
1001        Ok(Some(super::bug::Phase::Reproduce(b))) if interrupted.is_none() => {
1002            match super::bug::reproduce(&bug_ctx, *b) {
1003                Ok(f) => Some(f),
1004                Err(stop) => return *stop,
1005            }
1006        }
1007        Ok(Some(super::bug::Phase::Reproduce(_))) => None,
1008    };
1009    let bug_candidate = bug_fix.as_ref().and_then(|f| {
1010        f.bug.candidate.clone().and_then(|c| {
1011            crate::bug::state::extra(&s.plane_dir, &f.bug.unit, &f.bug.task)
1012                .candidate_patch
1013                .map(|d| (d, c))
1014        })
1015    });
1016    // The test layout, detected from the checkout: a fact for the
1017    // implementer and every test author, never a judge input.
1018    let test_layout = crate::layout::detect(&project, &check_argvs);
1019    let test_layout_text = test_layout.text();
1020    let story_files = super::commit::story_files(&wt_root, &feature_name);
1021    let scenarios = match &bug_fix {
1022        Some(f) => super::bug::scenarios(&f.bug),
1023        None => format.scenarios(&snap, story_task.as_deref().unwrap_or(task)),
1024    };
1025    // A requirement's oracle frozen at review: reused by the
1026    // first task citing its scenarios while its wording is unchanged.
1027    let mut review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)> = None;
1028    let mut review_reuse = None;
1029    if bug_fix.is_none() && run_config.acceptance && !options.fresh_acceptance {
1030        match crate::oracle::for_task(&s.plane_dir, &project, &wt_root, format, &snap, &scenarios) {
1031            Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
1032            Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
1033                "{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
1034            )),
1035            Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
1036                notices.push(format!("{req}: frozen oracle not present here — authoring"))
1037            }
1038            Some(crate::oracle::TaskReuse::Lapsed {
1039                req,
1040                frozen,
1041                current,
1042            }) => {
1043                if crate::oracle::note_lapse(&s.plane_dir, &feature_name, &req, &frozen) {
1044                    live.event(&progress::Event::Oracle {
1045                        step: progress::OracleEvent::Lapsed {
1046                            unit: feature_name.clone(),
1047                            requirement: req.clone(),
1048                            frozen_wording_sha256: frozen,
1049                            current_wording_sha256: current,
1050                        },
1051                    });
1052                }
1053                notices.push(format!(
1054                    "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
1055                ));
1056            }
1057            Some(crate::oracle::TaskReuse::Tampered(why)) => {
1058                return fail(
1059                    CommandStatus::NeedsHuman,
1060                    format!("{why}: the task stops for a person, nothing is ticked"),
1061                )
1062                .with_code("ORACLE-TAMPERED");
1063            }
1064            Some(crate::oracle::TaskReuse::Unreadable(why)) => {
1065                return fail(
1066                    CommandStatus::NeedsHuman,
1067                    format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
1068                )
1069                .with_code("REVIEW-UNREADABLE");
1070            }
1071            None => {}
1072        }
1073    }
1074    let mut acceptance = if let Some(prepared) = interrupted
1075        .as_ref()
1076        .and_then(|m| m.acceptance.clone())
1077        .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
1078    {
1079        // The interrupted attempt's tests, already in the worktree. A bug
1080        // task's held-out set is the one its pre-frozen record seals now: a
1081        // special case's regeneration re-sealed it after the marker was kept.
1082        let mut prepared = prepared;
1083        if let Some(sealed) = bug_fix
1084            .as_ref()
1085            .and_then(|f| f.record.held_out_sealed.as_ref())
1086        {
1087            prepared.held_out = Some(sealed.manifest_sha256.clone());
1088            prepared.report["held_out"] = serde_json::json!({
1089                "state": "on",
1090                "manifest": format!("sha256:{}", sealed.manifest_sha256),
1091                "kept": sealed.count,
1092            });
1093        }
1094        Some(prepared)
1095    } else if let Some(f) = &bug_fix {
1096        stage(&*live, task, Stage::Red, StageState::Started, None, None);
1097        match super::bug::reuse(&bug_ctx, f) {
1098            Ok(a) => Some(a),
1099            Err(e) => {
1100                let _ = worktree::restore(&wt_root, &tmp, &start);
1101                stage(
1102                    &*live,
1103                    task,
1104                    Stage::Red,
1105                    StageState::Failed,
1106                    Some(e.clone()),
1107                    Some(began),
1108                );
1109                return fail(CommandStatus::NeedsHuman, e);
1110            }
1111        }
1112    } else if let Some(reuse) = review_reuse.take() {
1113        // A frozen oracle: its tests at this checkpoint, red by
1114        // the run-time rule, its sealed held-out set red too — no author call.
1115        stage(&*live, task, Stage::Red, StageState::Started, None, None);
1116        let crate::oracle::TaskReuse::Ready {
1117            record,
1118            files,
1119            tests,
1120            manifest,
1121        } = reuse
1122        else {
1123            return fail(CommandStatus::Error, "no frozen oracle".to_owned());
1124        };
1125        match reuse_pre_frozen(
1126            &project,
1127            &record,
1128            &files,
1129            &tests,
1130            &runner,
1131            RedRule::RunTime,
1132            Some(&SealedCheck {
1133                state: &s.plane_dir,
1134                unit: &feature_name,
1135                wt_root: &wt_root,
1136                tmp: &tmp,
1137                policy: &policy,
1138            }),
1139        ) {
1140            Ok(mut a) => {
1141                a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
1142                notices.push(format!(
1143                    "acceptance tests frozen at review by {} ({}) for {}: no author call",
1144                    record.decided.by, record.decided.via, record.requirement
1145                ));
1146                review_frozen = Some((*record, manifest));
1147                Some(a)
1148            }
1149            Err(e) => {
1150                let _ = worktree::restore(&wt_root, &tmp, &start);
1151                stage(
1152                    &*live,
1153                    task,
1154                    Stage::Red,
1155                    StageState::Failed,
1156                    Some(e.clone()),
1157                    Some(began),
1158                );
1159                return fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED");
1160            }
1161        }
1162    } else if run_config.acceptance && format.acceptance_testable(&bundle) {
1163        stage(&*live, task, Stage::Red, StageState::Started, None, None);
1164        Some(prepare_acceptance(&AcceptanceSetup {
1165            rt: &rt,
1166            plane: &plane,
1167            project: &project,
1168            worktree: &wt_root,
1169            tmp: &tmp,
1170            policy: &policy,
1171            feature: &feature_name,
1172            task,
1173            bundle: bundle_value.clone(),
1174            scenarios: &scenarios,
1175            instructions: &instructions,
1176            runner: &runner,
1177            layout: Some(&test_layout),
1178            story_files: &story_files,
1179        }))
1180    } else {
1181        None
1182    };
1183    if review_frozen.is_none()
1184        && acceptance
1185            .as_ref()
1186            .is_some_and(|a| a.report["source"] == "review")
1187        && let Some(crate::oracle::TaskReuse::Ready {
1188            record, manifest, ..
1189        }) = review_reuse.take()
1190    {
1191        review_frozen = Some((*record, manifest));
1192    }
1193    // 4c. The held-out set: written after a red visible set, kept in
1194    //     the state directory, never shown to the implementer.
1195    let held_out_store = match (&bug_fix, &review_frozen) {
1196        (Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
1197        (None, Some((r, _))) => {
1198            crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &r.requirement)
1199        }
1200        (None, None) => crate::held_out::dir(&s.plane_dir, &feature_name, task),
1201    };
1202    if let Some(a) = acceptance
1203        .as_mut()
1204        .filter(|a| a.report.get("held_out").is_none())
1205    {
1206        if !run_config.held_out {
1207            a.report["held_out"] = serde_json::json!({ "state": "off" });
1208        } else if a.argv.is_none() {
1209            a.report["held_out"] =
1210                serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
1211        } else if !a.red {
1212            a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
1213        } else if scenarios.is_empty() {
1214            a.report["held_out"] =
1215                serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
1216        } else {
1217            let (held, sha) = prepare_held_out(
1218                &AcceptanceSetup {
1219                    rt: &rt,
1220                    plane: &plane,
1221                    project: &project,
1222                    worktree: &wt_root,
1223                    tmp: &tmp,
1224                    policy: &policy,
1225                    feature: &feature_name,
1226                    task,
1227                    bundle: bundle_value.clone(),
1228                    scenarios: &scenarios,
1229                    instructions: &instructions,
1230                    runner: &runner,
1231                    layout: Some(&test_layout),
1232                    story_files: &story_files,
1233                },
1234                a,
1235                &scenarios,
1236                &held_out_store,
1237                story_checkpoint(
1238                    snap.feature.as_ref().and_then(|f| f.tasks.as_ref()),
1239                    task,
1240                    &scenarios,
1241                ),
1242            );
1243            a.report["held_out"] = held;
1244            a.held_out = sha;
1245        }
1246    }
1247    if run_config.held_out && !run_config.acceptance {
1248        notices.push(
1249            "[acceptance] held_out has no effect while generate = false: no held-out tests"
1250                .to_owned(),
1251        );
1252    }
1253    match &acceptance {
1254        None => stage(
1255            &*live,
1256            task,
1257            Stage::Red,
1258            StageState::Skipped,
1259            Some("no acceptance examples".to_owned()),
1260            None,
1261        ),
1262        Some(a) if a.red => stage(
1263            &*live,
1264            task,
1265            Stage::Red,
1266            StageState::Passed,
1267            a.red_class.map(class_word),
1268            Some(began),
1269        ),
1270        Some(a) => stage(
1271            &*live,
1272            task,
1273            Stage::Red,
1274            StageState::Failed,
1275            Some(a.red_class.map_or_else(
1276                || "not demonstrated".to_owned(),
1277                |c| format!("not demonstrated ({})", class_word(c)),
1278            )),
1279            Some(began),
1280        ),
1281    }
1282
1283    // The author call itself failed: without its tests the task could only
1284    // be ticked on the regression checks, which would look like "the spec
1285    // has no examples". A person decides; nothing was attempted.
1286    if let Some(why) = acceptance
1287        .as_ref()
1288        .and_then(|a| a.generation_failed.clone())
1289    {
1290        let reason = format!(
1291            "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"
1292        );
1293        let _ = worktree::restore(&wt_root, &tmp, &start);
1294        let report = RunReport {
1295            reason: Some(reason.clone()),
1296            acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1297            set_aside,
1298            notices: notices.clone(),
1299            ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1300        };
1301        live.event(&finished_event(
1302            task,
1303            &report,
1304            "acceptance_failed",
1305            false,
1306            redact(&reason, secret),
1307        ));
1308        return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1309            .with_code("ACCEPTANCE-UNWRITTEN")
1310            .with_data(CommandData::Run(Box::new(report)));
1311    }
1312
1313    if demand_red && !acceptance.as_ref().is_some_and(|a| a.red) {
1314        let why = undemonstrated_why(acceptance.as_ref());
1315        let reason = format!(
1316            "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
1317        );
1318        // The undemonstrated tests are kept under an attempt ref, not in the worktree.
1319        let saved = attempts::save(
1320            &wt_root,
1321            &tmp,
1322            &feature_name,
1323            task,
1324            &attempts::Record {
1325                run: None,
1326                outcome: "not_demonstrated",
1327                reason: &reason,
1328                pending: None,
1329                usage: None,
1330            },
1331        )
1332        .ok()
1333        .map(|s| s.reference);
1334        let _ = worktree::restore(&wt_root, &tmp, &start);
1335        let report = RunReport {
1336            reason: Some(reason.clone()),
1337            acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1338            saved_attempt: saved,
1339            set_aside,
1340            notices: notices.clone(),
1341            ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
1342        };
1343        live.event(&finished_event(
1344            task,
1345            &report,
1346            "not_demonstrated",
1347            false,
1348            redact(&reason, secret),
1349        ));
1350        return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1351            .with_data(CommandData::Run(Box::new(report)));
1352    }
1353
1354    // 5. Checkpoint, attempt, checkpoint — again after a failed verdict,
1355    //    from the same checkpoint, with the verifier's digest.
1356    let before = match &interrupted {
1357        Some(m) => worktree::Checkpoint(m.before.clone()),
1358        None => match worktree::checkpoint(&wt_root, &tmp) {
1359            Ok(c) => c,
1360            Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1361        },
1362    };
1363    let acceptance_saved = acceptance
1364        .as_ref()
1365        .and_then(|a| serde_json::to_value(a).ok());
1366    // Tests that exist now are frozen for this attempt.
1367    let frozen: Vec<String> = worktree::files(&wt_root, &before)
1368        .unwrap_or_default()
1369        .into_iter()
1370        .filter(|p| tools::paths::is_test_path(p))
1371        .collect();
1372    let mut usage: Option<serde_json::Value> = None;
1373    // Best-of-n: independent samples from the same checkpoint, each
1374    // with its own retries; the best by evidence is kept.
1375    let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
1376    // Each judged attempt's held-out outcome, across samples and retries.
1377    let mut held_out_log: Vec<serde_json::Value> = Vec::new();
1378    // A special case that survived a bug task's reproductions.
1379    let mut bug_weak: Option<speccoder_core::strength::Permitted> = None;
1380    // The provider refused the account (its code says so): no retry helps.
1381    let mut refused_account = false;
1382    let (mut report, attempt_status) = loop {
1383        let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
1384        let mut digest: Option<serde_json::Value> = None;
1385        // A resumed attempt continues at its own number: starting over at 1
1386        // would admit a fresh run and repeat the model's work.
1387        let mut attempt_no = interrupted
1388            .as_ref()
1389            .filter(|m| m.sample == samples.len())
1390            .map_or(1, |m| m.attempt.max(1));
1391        let (mut report, attempt_status) = loop {
1392            // In place: what git ignored at the session's start.
1393            let hidden: Vec<String> = options
1394                .session
1395                .as_ref()
1396                .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
1397                .unwrap_or_default();
1398            // The task's grants of this run as they stand now (an earlier
1399            // attempt's "for the task" answers included), and the calls a
1400            // person approved once, which this attempt alone is given:
1401            // journaled. Decisions that cannot be read stop the task — the
1402            // grants read when it began may hold one rejected since.
1403            let (task_grants, approved_once) = match approvals::Decisions::load(&s.plane_dir) {
1404                // Autopilot: only the ticked grants (`run_grants`) — no
1405                // answer kept for the task, no once-grant is taken.
1406                Ok(_) if autopilot => (Vec::new(), Vec::new()),
1407                Ok(mut d) => {
1408                    let once = d.take_once(&feature_name, task, this_run.as_str());
1409                    if !once.is_empty()
1410                        && let Err(e) = d.save(&s.plane_dir)
1411                    {
1412                        return fail(CommandStatus::Error, e);
1413                    }
1414                    (d.for_run(&feature_name, task, this_run.as_str()), once)
1415                }
1416                Err(e) => return fail(CommandStatus::ConfigError, e),
1417            };
1418            // This run's grants as they stand now: journaled with the input.
1419            let run_approved: Vec<crate::tools::policy::Object> = options
1420                .run_grants
1421                .lock()
1422                .map(|g| g.clone())
1423                .unwrap_or_default();
1424            // Everything the attempt's decisions depend on is in its journaled input.
1425            let input = substrate::attempt::input(
1426                bundle_value.clone(),
1427                &substrate::attempt::Settings {
1428                    checks: &check_argvs,
1429                    allow: &run_config.allow,
1430                    allow_prefix: &run_config.allow_prefix,
1431                    max_turns: run_config.max_turns,
1432                    max_tool_calls: run_config.max_tool_calls,
1433                    reasoning: s.model.reasoning_summary,
1434                    frozen: &frozen,
1435                    approved: &task_grants,
1436                    rejected: &decisions.rejected,
1437                    run_approved: &run_approved,
1438                    approved_once: &approved_once,
1439                    nudges: &decisions.nudges,
1440                    mcp: &connected.tools,
1441                    root: &wt_root_canonical,
1442                    frozen_additions: run_config.frozen_additions,
1443                    // Lanes stop (their worktree is merged or dropped with
1444                    // the group); every other attempt waits for a person.
1445                    asks: if options.lane.is_some() {
1446                        "stop"
1447                    } else {
1448                        "tasks"
1449                    },
1450                    wait_secs: run_config.approval_wait.as_secs(),
1451                    network_allow: &sandbox_config.network_allow,
1452                    network_allow_prefix: &sandbox_config.network_allow_prefix,
1453                    run_id: this_run.as_str(),
1454                    asked: options.asked_now(),
1455                    budget: run_config.approval_budget,
1456                    prompts: approver.is_some() && options.lane.is_none(),
1457                    in_place: options.session.is_some(),
1458                    hidden: &hidden,
1459                    autopilot: options.mode == speccoder_core::oracle::RunMode::Autopilot,
1460                },
1461                digest.as_ref(),
1462                attempt_instructions,
1463                // No runner found: nothing to say (and nothing of the worktree's).
1464                (!test_layout.runners.is_empty()).then_some(test_layout_text.as_str()),
1465            );
1466            let attempt_began = std::time::Instant::now();
1467            let attempt_detail = if run_config.best_of > 1 {
1468                format!(
1469                    "{attempt_no}/{} · sample {}/{}",
1470                    run_config.max_attempts,
1471                    samples.len() + 1,
1472                    run_config.best_of
1473                )
1474            } else {
1475                format!("{attempt_no}/{}", run_config.max_attempts)
1476            };
1477            stage(
1478                &*live,
1479                task,
1480                Stage::Attempt,
1481                StageState::Started,
1482                Some(attempt_detail.clone()),
1483                None,
1484            );
1485            // Judge inputs as the attempt finds them, git-ignored ones
1486            // included (an agent loads `.claude/settings.local.json` too).
1487            let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
1488            // The attempt itself: speccoder's governed loop, or an external
1489            // agent whose result is judged from the diff alike.
1490            let (run_id, succeeded, mut output) = if let Some((diff, cand)) = &bug_candidate {
1491                let held: Vec<String> = bug_fix
1492                    .as_ref()
1493                    .and_then(|f| {
1494                        f.bug
1495                            .held_out_sealed
1496                            .clone()
1497                            .map(|h| (f.bug.unit.clone(), h))
1498                    })
1499                    .and_then(|(unit, h)| {
1500                        crate::held_out::open_sealed(&s.plane_dir, &unit, &h).ok()
1501                    })
1502                    .map(|(m, _)| m.files.into_iter().map(|f| f.path).collect())
1503                    .unwrap_or_default();
1504                match super::bug::apply_candidate(&bug_ctx, diff, cand, &frozen, &held) {
1505                    Ok(x) => x,
1506                    Err(stop) => return *stop,
1507                }
1508            } else if let Some(exec) = &executor {
1509                let allow: Vec<Vec<String>> = check_argvs
1510                    .iter()
1511                    .chain(&run_config.allow)
1512                    .cloned()
1513                    .collect();
1514                let prompt = external::prompt_in(format.wording(), &bundle_value, digest.as_ref());
1515                let withheld = acceptance
1516                    .as_ref()
1517                    .and_then(|a| a.held_out.as_ref())
1518                    .map(|_| held_out_store.as_path());
1519                if withheld.is_some()
1520                    && !notices
1521                        .iter()
1522                        .any(|n| n.starts_with("held-out set withheld"))
1523                {
1524                    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());
1525                }
1526                let o = external::run(exec, &wt_root, &prompt, &allow, &tmp, withheld);
1527                (o.run_id, true, o.output)
1528            } else {
1529                // The same key as an interrupted attempt resumes it.
1530                let key = match &interrupted {
1531                    Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
1532                        m.key.clone()
1533                    }
1534                    _ => resume::new_key(&feature_name, task),
1535                };
1536                let _ = resume::save(
1537                    &marker_path,
1538                    &resume::Marker {
1539                        key: key.clone(),
1540                        before: before.0.clone(),
1541                        head: head_now.clone(),
1542                        attempt: attempt_no,
1543                        sample: samples.len(),
1544                        acceptance: acceptance_saved.clone(),
1545                        run_id: Some(this_run.to_string()),
1546                        asked: options.asked_now(),
1547                        run_grants: run_approved.clone(),
1548                    },
1549                );
1550                marker_guard.arm();
1551                let outcome = match rt.block_on(plane.attempt_keyed(
1552                    &feature_name,
1553                    task,
1554                    input,
1555                    &key,
1556                )) {
1557                    Ok((o, resumed)) => {
1558                        if resumed {
1559                            notices.push(format!(
1560                                    "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
1561                                    o.run_id
1562                                ));
1563                        }
1564                        o
1565                    }
1566                    Err(e) => return fail(CommandStatus::RunFailed, e.0),
1567                };
1568                // A call waits for a person. With one here they
1569                // decide now, recorded with who they are, and the attempt
1570                // continues; "later" or nobody leaves it waiting.
1571                let context = crate::approvals::PresentContext {
1572                    root: Some(wt_root.clone()),
1573                    ..present.clone()
1574                };
1575                let (outcome, waiting) = match rt.block_on(plane.decide_inline(
1576                    outcome,
1577                    approver.as_deref(),
1578                    &context,
1579                )) {
1580                    Ok(x) => {
1581                        // Always Allow: the call ran once; its exact argv
1582                        // joins the allow list through the one action.
1583                        notices.extend(crate::actions::allow_always_inline(
1584                            path,
1585                            &feature_name,
1586                            &x.always,
1587                            state,
1588                        ));
1589                        (x.outcome, x.waiting)
1590                    }
1591                    Err(e) => return fail(CommandStatus::RunFailed, e.0),
1592                };
1593                if let Some(task_row) = waiting {
1594                    // The attempt waits as it is: its tree and its resume
1595                    // marker stay, so the next run continues it.
1596                    marker_guard.keep();
1597                    let action = &task_row.justification.proposed_action;
1598                    let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
1599                    report.run_id = outcome.run_id.to_string();
1600                    report.notices = notices;
1601                    report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
1602                    // The waiting call itself, as the stop recorded it before.
1603                    report.tool_calls.push(serde_json::json!({
1604                        "tool": action["tool"],
1605                        "arguments": action["arguments"],
1606                        "decision": "approval",
1607                        "reason": action["request"]["reason"],
1608                        "object": action["request"]["object"],
1609                        "waiting": true,
1610                        "call": task_row.id.to_hex(),
1611                    }));
1612                    report.pending = Some(serde_json::json!({
1613                        "tool": action["tool"],
1614                        "arguments": action["arguments"],
1615                        "reason": action["request"]["reason"],
1616                        "object": action["request"]["object"],
1617                        "request": action["request"],
1618                        "call": task_row.id.to_hex(),
1619                        "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
1620                        "waiting": true,
1621                        "batched": action["request"]["batched"],
1622                    }));
1623                    // The prompts the attempt showed before it waits count
1624                    // for the run.
1625                    if let (Some(n), Ok(mut asked)) = (
1626                        action["request"]["asked"]
1627                            .as_u64()
1628                            .and_then(|n| u32::try_from(n).ok()),
1629                        options.asked.lock(),
1630                    ) {
1631                        *asked = (*asked).max(n);
1632                    }
1633                    report.reason = Some(format!(
1634                        "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
1635                        task_row.justification.summary.peek()
1636                    ));
1637                    let (status, message) = status_and_message(&report, "needs_approval", false);
1638                    live.event(&finished_event(
1639                        task,
1640                        &report,
1641                        "needs_approval",
1642                        false,
1643                        redact(&message, secret),
1644                    ));
1645                    return CommandResult::new("run", status, &redact(&message, secret))
1646                        .with_data(CommandData::Run(Box::new(report)));
1647                }
1648                let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
1649                refused_account = outcome
1650                    .failed_effect
1651                    .as_ref()
1652                    .is_some_and(agentplane::journal::FailedEffect::refuses_the_account);
1653                let mut output = outcome
1654                    .output
1655                    .as_ref()
1656                    .map(|o| o.peek().clone())
1657                    .unwrap_or(serde_json::Value::Null);
1658                if !succeeded && output["reason"].as_str().is_none() {
1659                    if !output.is_object() {
1660                        output = serde_json::json!({});
1661                    }
1662                    // A decision an earlier release recorded is named, with
1663                    // the fix, not left as a decoding error.
1664                    let reason = if rt.block_on(plane.holds_a_pre_054_decision(outcome.run_id)) {
1665                        substrate::PRE_054_DECISION.to_owned()
1666                    } else {
1667                        format!("{:?}", outcome.status)
1668                    };
1669                    output["reason"] = serde_json::Value::String(redact(&reason, secret));
1670                }
1671                (outcome.run_id.to_string(), succeeded, output)
1672            };
1673            if bug_candidate.is_some()
1674                && let Some(c) = bug_fix.as_mut().and_then(|f| f.bug.candidate.as_mut())
1675            {
1676                c.applied = true;
1677            }
1678            // The configured formatter, run by speccoder (not the model) on the
1679            // attempt's result; its changes are part of the attempt and audited.
1680            if let Some(argv) = run_config.format.as_ref().filter(|_| {
1681                succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
1682            }) {
1683                use verify::process::CommandRunner as _;
1684                let ran = runner.run(&verify::process::Invocation {
1685                    argv: argv.clone(),
1686                    cwd: project.clone(),
1687                    env: verify::process::scrubbed_env(std::env::vars()),
1688                    timeout: std::time::Duration::from_secs(300),
1689                    output_cap: verify::process::OUTPUT_CAP,
1690                });
1691                if ran.exit_code != Some(0) {
1692                    notices.push(format!(
1693                        "the formatter (`{}`) did not succeed",
1694                        argv.join(" ")
1695                    ));
1696                }
1697            }
1698            // A lockfile the attempt changed: its root installed again for
1699            // it before the checks, so they — and the tick's evidence — are
1700            // about the lockfile committed.
1701            if prepares {
1702                let began = std::time::Instant::now();
1703                match crate::prepare::recheck(&crate::prepare::Prepare {
1704                    worktree: &wt_root,
1705                    project: &project,
1706                    config: &worktree_config,
1707                    runner: &runner,
1708                    state: Some(state),
1709                }) {
1710                    Ok(done) if done.is_empty() => {}
1711                    Ok(done) => {
1712                        stage(
1713                            &*live,
1714                            task,
1715                            Stage::Prepare,
1716                            StageState::Passed,
1717                            Some(format!(
1718                                "{}: the attempt changed its lockfile",
1719                                done.join(", ")
1720                            )),
1721                            Some(began),
1722                        );
1723                    }
1724                    Err(f) => {
1725                        stage(
1726                            &*live,
1727                            task,
1728                            Stage::Prepare,
1729                            StageState::Failed,
1730                            Some(f.message.clone()),
1731                            Some(began),
1732                        );
1733                        // The attempt is saved and undone like any failed
1734                        // one: the next run starts from the lockfile that
1735                        // installs.
1736                        let reason = redact(&f.message, secret);
1737                        let saved = attempts::save(
1738                            &wt_root,
1739                            &tmp,
1740                            &feature_name,
1741                            task,
1742                            &attempts::Record {
1743                                run: (!run_id.is_empty()).then_some(run_id.as_str()),
1744                                outcome: "prepare_failed",
1745                                reason: &reason,
1746                                pending: None,
1747                                usage: output.get("usage"),
1748                            },
1749                        )
1750                        .and_then(|saved| {
1751                            worktree::restore(&wt_root, &tmp, &before).map(|_| saved)
1752                        });
1753                        let message = match saved {
1754                            Ok(saved) => format!(
1755                                "{} — the attempt is saved as {} and undone",
1756                                f.message, saved.reference
1757                            ),
1758                            Err(e) => format!("{} — saving the attempt failed: {}", f.message, e.0),
1759                        };
1760                        return fail(CommandStatus::ConfigError, message)
1761                            .with_code("PREPARE-FAILED")
1762                            .with_fix(f.fix);
1763                    }
1764                }
1765            }
1766            let after = match worktree::checkpoint(&wt_root, &tmp) {
1767                Ok(c) => c,
1768                Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1769            };
1770            let raw_changes = worktree::diff(&wt_root, &before, &after).unwrap_or_default();
1771            // A frozen test a person approved writing (for the task, or inline)
1772            // is not a violation; the audit still flags removed tests and skip
1773            // markers in it — approval permits editing a test, not weakening it.
1774            // The prompts the attempt showed count for the run.
1775            if let (Some(n), Ok(mut asked)) = (
1776                output["asked"].as_u64().and_then(|n| u32::try_from(n).ok()),
1777                options.asked.lock(),
1778            ) {
1779                *asked = n;
1780            }
1781            let approved_writes = approved_writes(&task_grants, &output["tool_calls"]);
1782            let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1783            // A value copied from the person's secret files, written into a
1784            // file git would commit: undone, never judged — named, never shown.
1785            if let Some(name) = crate::include::added_secret(&patch) {
1786                let _ = worktree::restore(&wt_root, &tmp, &before);
1787                return fail(
1788                    CommandStatus::NeedsHuman,
1789                    format!(
1790                        "the attempt's change adds the value of {name}, copied from your checkout ([worktree] include): undone, nothing judged or committed"
1791                    ),
1792                )
1793                .with_code("SECRET-IN-DIFF");
1794            }
1795            let findings = attempt_findings(&Audited {
1796                wt_root: &wt_root,
1797                policy: &policy,
1798                judged_before: &judged_before,
1799                raw_changes: &raw_changes,
1800                patch: &patch,
1801                frozen: &frozen,
1802                frozen_additions: run_config.frozen_additions,
1803                approved_writes: &approved_writes,
1804                command_drift: &output["integrity"],
1805            });
1806            let changes = changes_of(raw_changes);
1807
1808            // 6. The attempt's own account.
1809            // A resumed attempt that was killed while a command ran: nobody
1810            // can know whether the command finished, so agentplane did not
1811            // run it again. The attempt is saved and undone below.
1812            let quarantined = !succeeded
1813                && output["reason"]
1814                    .as_str()
1815                    .is_some_and(|r| r.starts_with("Quarantined("));
1816            if quarantined {
1817                output["reason"] = serde_json::Value::String(
1818                    "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(),
1819                );
1820            }
1821            let mut attempt_status = if succeeded {
1822                output["status"].as_str().unwrap_or("failed").to_owned()
1823            } else if quarantined {
1824                "interrupted".to_owned()
1825            } else {
1826                "failed".to_owned()
1827            };
1828            let mut report = RunReport {
1829                run_id: run_id.clone(),
1830                model_report: output.get("report").cloned(),
1831                reason: output["reason"].as_str().map(str::to_owned),
1832                pending: output.get("pending").cloned(),
1833                tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
1834                changes,
1835                usage: output.get("usage").cloned(),
1836                integrity: findings.clone(),
1837                acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1838                set_aside: set_aside.clone(),
1839                notices: notices.clone(),
1840                ..bare_report(&feature_name, task, &wt_path, &attempt_status)
1841            };
1842            if let Err(e) = record_inline_approvals(
1843                &s.plane_dir,
1844                &feature_name,
1845                task,
1846                &report.tool_calls,
1847                &options.run_grants,
1848                this_run.as_str(),
1849            ) {
1850                report.notices.push(format!(
1851                    "the decisions made in this attempt were not recorded ({e}): later attempts ask again"
1852                ));
1853            }
1854            // A person approving an MCP call reviewed the tool as it is now.
1855            for c in report
1856                .tool_calls
1857                .iter()
1858                .filter(|c| c["approved_by"] == "person")
1859            {
1860                if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
1861                    mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
1862                }
1863            }
1864
1865            stage(
1866                &*live,
1867                task,
1868                Stage::Attempt,
1869                if attempt_status == "completed" {
1870                    StageState::Passed
1871                } else {
1872                    StageState::Failed
1873                },
1874                Some(if attempt_status == "completed" {
1875                    attempt_detail
1876                } else {
1877                    format!("{attempt_detail} {attempt_status}")
1878                }),
1879                Some(attempt_began),
1880            );
1881            // A command changed a judge input: the attempt is over and fails,
1882            // not retried (integrity findings never are).
1883            if attempt_status == "integrity" {
1884                report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1885                    reasons: findings.clone(),
1886                });
1887            }
1888            // 7. The verdict: only the verifier decides.
1889            let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
1890            if judged {
1891                let began = std::time::Instant::now();
1892                stage(&*live, task, Stage::Verify, StageState::Started, None, None);
1893                if let Err(e) = judge_checks(
1894                    &project,
1895                    &runner,
1896                    &baseline,
1897                    acceptance.as_ref(),
1898                    &findings,
1899                    &mut report,
1900                ) {
1901                    stage(
1902                        &*live,
1903                        task,
1904                        Stage::Verify,
1905                        StageState::Failed,
1906                        None,
1907                        Some(began),
1908                    );
1909                    return fail(CommandStatus::ConfigError, format!("verification: {e}"));
1910                }
1911                // 7'. A test file the attempt wrote that the run never
1912                //     collected is evidence of nothing: it fails the verdict;
1913                //     one speccoder cannot tell about stops for a person.
1914                let otherwise_passing = attempt_status == "completed"
1915                    && findings.is_empty()
1916                    && matches!(
1917                        report.verdict,
1918                        Some(
1919                            speccoder_core::verify::Verdict::Verified
1920                                | speccoder_core::verify::Verdict::RegressionOnly
1921                        )
1922                    );
1923                match judge_collection(
1924                    (&project, &wt_root, &tmp),
1925                    &runner,
1926                    otherwise_passing,
1927                    &mut report,
1928                    &*live,
1929                    task,
1930                ) {
1931                    Some(Collected::Unknown(why)) => {
1932                        attempt_status = "collection_unknown".to_owned();
1933                        report.verdict = None;
1934                        report.reason = Some(why);
1935                    }
1936                    // A listing changed the tree: never committed, never retried.
1937                    Some(Collected::Changed(finding)) => {
1938                        report.integrity.push(finding.clone());
1939                        report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1940                            reasons: vec![finding],
1941                        });
1942                        attempt_status = "integrity".to_owned();
1943                    }
1944                    None => {}
1945                }
1946                let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
1947                stage_with_tail(
1948                    &*live,
1949                    task,
1950                    Stage::Verify,
1951                    if checks_held {
1952                        StageState::Passed
1953                    } else {
1954                        StageState::Failed
1955                    },
1956                    None,
1957                    Some(began),
1958                    report
1959                        .verification
1960                        .as_ref()
1961                        .map(failing_tail)
1962                        .unwrap_or_default(),
1963                );
1964            }
1965            if judged || attempt_status == "integrity" {
1966                stage(
1967                    &*live,
1968                    task,
1969                    Stage::Audit,
1970                    if findings.is_empty() {
1971                        StageState::Passed
1972                    } else {
1973                        StageState::Failed
1974                    },
1975                    findings
1976                        .first()
1977                        .map(|f| f.trim_start_matches("integrity: ").to_owned()),
1978                    None,
1979                );
1980            }
1981
1982            // 7.5 The held-out tests, on an attempt that passed visibly with a
1983            //     clean audit: a failure fails the verdict naming the scenarios
1984            //     only; tampering or a leak stops for a person.
1985            let visibly_passed = judged
1986                && findings.is_empty()
1987                && attempt_status == "completed"
1988                && matches!(
1989                    report.verdict,
1990                    Some(
1991                        speccoder_core::verify::Verdict::Verified
1992                            | speccoder_core::verify::Verdict::RegressionOnly
1993                    )
1994                );
1995            if let Some(sha) = acceptance
1996                .as_ref()
1997                .and_then(|a| a.held_out.clone())
1998                .filter(|_| visibly_passed)
1999            {
2000                let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2001                let mut known = format!(
2002                    "{}\n{}\n{}",
2003                    bundle_value["story"].as_str().unwrap_or_default(),
2004                    bundle_value["plan"].as_str().unwrap_or_default(),
2005                    bundle_value["constitution"].as_str().unwrap_or_default()
2006                );
2007                for p in report
2008                    .acceptance
2009                    .as_ref()
2010                    .and_then(|a| a["files"].as_array().cloned())
2011                    .unwrap_or_default()
2012                {
2013                    if let Some(p) = p.as_str() {
2014                        known.push_str(
2015                            &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2016                        );
2017                    }
2018                }
2019                match judge_held_out(
2020                    &held_out_store,
2021                    &sha,
2022                    &scenarios,
2023                    &known,
2024                    &wt_root,
2025                    &project,
2026                    &tmp,
2027                    &before,
2028                    &patch,
2029                    &runner,
2030                    &policy,
2031                ) {
2032                    Ok(j) => {
2033                        let failed_ids: Vec<String> =
2034                            j.failed.iter().map(|s| s.id.clone()).collect();
2035                        held_out_log.push(serde_json::json!({
2036                            "attempt": held_out_log.len() + 1,
2037                            "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
2038                            "scenarios": failed_ids,
2039                        }));
2040                        if let Some(v) = report.verdict.take() {
2041                            let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
2042                            if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
2043                                && !j.failed.is_empty()
2044                                && let Some(last) = reasons.last_mut()
2045                            {
2046                                *last = speccoder_core::retry::held_out_reason(&j.failed);
2047                            }
2048                            report.verdict = Some(v);
2049                        }
2050                    }
2051                    Err((outcome, why)) => {
2052                        held_out_log.push(serde_json::json!({
2053                            "attempt": held_out_log.len() + 1,
2054                            "outcome": outcome,
2055                        }));
2056                        report.verdict = None;
2057                        report.reason = Some(why);
2058                        attempt_status = "held_out".to_owned();
2059                    }
2060                }
2061            }
2062            // 7.6 A bug task: red → green per reproduction, the pass-to-pass
2063            //     set, the revert and the special cases.
2064            if let Some(fix) = bug_fix.as_mut().filter(|_| {
2065                attempt_status == "completed"
2066                    && findings.is_empty()
2067                    && matches!(
2068                        report.verdict,
2069                        Some(
2070                            speccoder_core::verify::Verdict::Verified
2071                                | speccoder_core::verify::Verdict::RegressionOnly
2072                        )
2073                    )
2074            }) {
2075                let keep = crate::strength::dir(&s.plane_dir, &feature_name, task);
2076                let judging = super::bug::Judging {
2077                    before: &before,
2078                    baseline: &baseline,
2079                    verification: report.verification.as_ref(),
2080                    base: report
2081                        .verdict
2082                        .clone()
2083                        .unwrap_or(speccoder_core::verify::Verdict::RegressionOnly),
2084                    keep: &keep,
2085                };
2086                match super::bug::judge(&bug_ctx, fix, &judging) {
2087                    Ok(j) => {
2088                        report.verdict = Some(j.verdict);
2089                        if let Some(weak) = j.weak {
2090                            report.reason = Some(format!(
2091                                "reproduction too weak: a fix that special-cases {}'s input passes ({}); `speccoder task rule {task} --tests-right --reason …` if the tests are right",
2092                                weak.scenario, weak.rationale
2093                            ));
2094                            bug_weak = Some(weak);
2095                            attempt_status = "repro_weak".to_owned();
2096                        }
2097                    }
2098                    Err(e) => {
2099                        report.integrity.push(e.clone());
2100                        report.reason = Some(e);
2101                        report.verdict = None;
2102                        attempt_status = "integrity".to_owned();
2103                    }
2104                }
2105            }
2106            if let Some(h) = report
2107                .acceptance
2108                .as_mut()
2109                .and_then(|a| a.get_mut("held_out"))
2110                .filter(|h| h["state"] == "on")
2111            {
2112                let failed = held_out_log
2113                    .iter()
2114                    .filter(|o| o["outcome"] == "failed")
2115                    .count();
2116                h["attempts"] = serde_json::json!(held_out_log);
2117                h["gap"] = serde_json::json!(failed > 0);
2118            }
2119
2120            // 7a. Mutation testing of a passing attempt's changes.
2121            let passing = attempt_status == "completed"
2122                && matches!(
2123                    report.verdict,
2124                    Some(
2125                        speccoder_core::verify::Verdict::Verified
2126                            | speccoder_core::verify::Verdict::RegressionOnly
2127                    )
2128                );
2129            if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
2130                stage(
2131                    &*live,
2132                    task,
2133                    Stage::Mutation,
2134                    StageState::Skipped,
2135                    Some(if mutation_config.is_none() {
2136                        "not configured".to_owned()
2137                    } else {
2138                        "suite = false: for acceptance strength only".to_owned()
2139                    }),
2140                    None,
2141                );
2142            }
2143            if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
2144                let began = std::time::Instant::now();
2145                stage(
2146                    &*live,
2147                    task,
2148                    Stage::Mutation,
2149                    StageState::Started,
2150                    None,
2151                    None,
2152                );
2153                let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2154                let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
2155                if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
2156                    if m.mode == mutation::Mode::Require
2157                        && let Some(reason) = mutation::failure(&result)
2158                    {
2159                        report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2160                            reasons: vec![reason],
2161                        });
2162                    }
2163                    report.mutation = Some(result);
2164                }
2165                let held = !matches!(
2166                    report.verdict,
2167                    Some(speccoder_core::verify::Verdict::Failed { .. })
2168                );
2169                stage(
2170                    &*live,
2171                    task,
2172                    Stage::Mutation,
2173                    if held {
2174                        StageState::Passed
2175                    } else {
2176                        StageState::Failed
2177                    },
2178                    report
2179                        .mutation
2180                        .as_ref()
2181                        .and_then(crate::mutation::trailer)
2182                        .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
2183                    Some(began),
2184                );
2185            }
2186
2187            usage = add_usage(usage.as_ref(), report.usage.as_ref());
2188            // retry a completed attempt the verifier failed, when it can help.
2189            let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
2190                (report.verdict.clone(), report.verification.as_ref())
2191            else {
2192                break (report, attempt_status);
2193            };
2194            if attempt_status != "completed" {
2195                break (report, attempt_status);
2196            }
2197            let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
2198            let mut d =
2199                speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
2200            d.uncollected = report
2201                .collection
2202                .as_ref()
2203                .map(speccoder_core::collection::Collection::retry_lines)
2204                .unwrap_or_default();
2205            let fingerprint = speccoder_core::retry::fingerprint(&d);
2206            let stop = if bug_candidate.is_some() {
2207                Some("a candidate is judged, never retried".to_owned())
2208            } else {
2209                retry_stop(
2210                    &findings,
2211                    verification,
2212                    earlier.last().map(|e| e.fingerprint.as_str()),
2213                    &fingerprint,
2214                    attempt_no,
2215                    run_config.max_attempts,
2216                )
2217            };
2218            if let Some(stop) = stop {
2219                report.retry_stop = Some(stop);
2220                break (report, attempt_status);
2221            }
2222            if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2223                report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
2224                break (report, attempt_status);
2225            }
2226            earlier.push(speccoder_core::EarlierAttempt {
2227                run_id: report.run_id.clone(),
2228                attempt_status: attempt_status.clone(),
2229                verdict: report.verdict.clone(),
2230                fingerprint,
2231                digest: d.clone(),
2232                usage: report.usage.clone(),
2233            });
2234            digest = serde_json::to_value(&d).ok();
2235            attempt_no += 1;
2236        };
2237        report.earlier = earlier;
2238        if run_config.best_of <= 1 {
2239            break (report, attempt_status);
2240        }
2241        let end = match worktree::checkpoint(&wt_root, &tmp) {
2242            Ok(c) => c,
2243            Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2244        };
2245        let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
2246        let summary = sample_summary(&report, &attempt_status, &patch);
2247        samples.push((report, attempt_status, end, summary));
2248        if samples.len() >= run_config.best_of as usize {
2249            let best = best_sample(&samples);
2250            let summaries: Vec<serde_json::Value> = samples
2251                .iter()
2252                .enumerate()
2253                .map(|(i, s)| {
2254                    let mut v = s.3.clone();
2255                    v["kept"] = serde_json::json!(i == best);
2256                    v
2257                })
2258                .collect();
2259            let (mut chosen, status, end, _) = samples.swap_remove(best);
2260            if best != samples.len() {
2261                // Not the sample the worktree holds: put the chosen one back.
2262                if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
2263                    return fail(CommandStatus::GitPrecondition, e.0);
2264                }
2265            }
2266            chosen.samples = summaries;
2267            break (chosen, status);
2268        }
2269        if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2270            return fail(CommandStatus::GitPrecondition, e.0);
2271        }
2272    };
2273    report.usage = usage;
2274    // What reached the implementer, by digest: the bundle's parts and the
2275    // test layout (constitution VIII).
2276    {
2277        use speccoder_core::context::ContextKind as K;
2278        let part = |k: &str| {
2279            bundle_value[k]
2280                .as_str()
2281                .unwrap_or_default()
2282                .as_bytes()
2283                .to_vec()
2284        };
2285        let mut m = crate::context::Manifest::new();
2286        m.push(
2287            K::Constitution,
2288            "worktree",
2289            "/bundle/constitution",
2290            &part("constitution"),
2291        )
2292        .push(K::Plan, "worktree", "/bundle/plan", &part("plan"))
2293        .push(K::Story, "worktree", "/bundle/story", &part("story"))
2294        .push(
2295            K::Task,
2296            "worktree",
2297            "/bundle/task",
2298            serde_json::to_string(&bundle_value["task"])
2299                .unwrap_or_default()
2300                .as_bytes(),
2301        )
2302        .push(
2303            K::TestLayout,
2304            "worktree",
2305            "/test_layout",
2306            test_layout_text.as_bytes(),
2307        );
2308        report.context = Some(m.finish());
2309    }
2310    // Scope: what the attempt changed beyond the paths its task names.
2311    report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
2312
2313    // 7c. Acceptance strength: the set a verified verdict
2314    //     rests on, run against wrong and permitted candidates. Judge-side;
2315    //     the implementer never sees a variant, a survivor or a finding.
2316    let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
2317    let mut attempt_status = attempt_status;
2318    let verified = attempt_status == "completed"
2319        && matches!(
2320            report.verdict,
2321            Some(speccoder_core::verify::Verdict::Verified)
2322        );
2323    let visible_argv = acceptance
2324        .as_ref()
2325        .filter(|a| a.red)
2326        .and_then(|a| a.argv.clone());
2327    if verified && !run_config.strength.enabled {
2328        stage(
2329            &*live,
2330            task,
2331            Stage::Strength,
2332            StageState::Skipped,
2333            Some("strength = false".to_owned()),
2334            None,
2335        );
2336    }
2337    if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
2338        use speccoder_core::strength::{Gate, gate, over_strict};
2339        let began = std::time::Instant::now();
2340        stage(
2341            &*live,
2342            task,
2343            Stage::Strength,
2344            StageState::Started,
2345            None,
2346            None,
2347        );
2348        let fin = worktree::checkpoint(&wt_root, &tmp);
2349        let patch = fin
2350            .as_ref()
2351            .ok()
2352            .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
2353            .unwrap_or_default();
2354        let mut notes = Vec::new();
2355        let held: Vec<(crate::held_out::HeldOutFile, String)> =
2356            match acceptance.as_ref().and_then(|a| a.held_out.clone()) {
2357                None => Vec::new(),
2358                Some(sha) => match crate::held_out::load_verified(&held_out_store, &sha) {
2359                    Ok((manifest, contents)) => manifest
2360                        .files
2361                        .into_iter()
2362                        .zip(contents.into_iter().map(|(_, c)| c))
2363                        .collect(),
2364                    Err(t) => {
2365                        notes.push(format!("held-out set not overlaid: {}", t.0));
2366                        Vec::new()
2367                    }
2368                },
2369            };
2370        let rulings = crate::strength::rulings(&strength_dir);
2371        let mut commands = vec![argv];
2372        let measure = |commands: &[Vec<String>]| {
2373            crate::strength::measure(
2374                &crate::strength::Setup {
2375                    wt_root: &wt_root,
2376                    project: &project,
2377                    tmp: &tmp,
2378                    runner: &runner,
2379                    policy: &policy,
2380                    acceptance: commands,
2381                    held_out: &held,
2382                    mutation: mutation_config.as_ref(),
2383                    patch: &patch,
2384                    config: &run_config.strength,
2385                    keep: &strength_dir,
2386                    rulings: &rulings,
2387                },
2388                |changed| {
2389                    // Source files only: the author never sees a test.
2390                    let input = substrate::near_miss::input(
2391                        &bundle_value,
2392                        changed,
2393                        &crate::mutation::source_patch(&patch),
2394                        run_config.strength.near_miss,
2395                        run_config.strength.permitted,
2396                    );
2397                    match rt.block_on(plane.near_miss(&feature_name, task, input)) {
2398                        Ok(o) => {
2399                            let out = o
2400                                .output
2401                                .as_ref()
2402                                .map(|v| v.peek().clone())
2403                                .unwrap_or_default();
2404                            let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
2405                                && !out["answer"].is_null();
2406                            crate::strength::Authored {
2407                                answer: if ok {
2408                                    Ok(out["answer"].clone())
2409                                } else {
2410                                    Err(format!(
2411                                        "the variant author did not answer ({})",
2412                                        o.status.as_str()
2413                                    ))
2414                                },
2415                                run_id: Some(o.run_id.to_string()),
2416                                usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
2417                            }
2418                        }
2419                        Err(e) => crate::strength::Authored {
2420                            answer: Err(redact(&e.0, secret)),
2421                            run_id: None,
2422                            usage: None,
2423                        },
2424                    }
2425                },
2426            )
2427        };
2428        let mut measured = measure(&commands);
2429        // The gate: one regeneration, told what got through; then it holds
2430        // or the set is too weak for a tick.
2431        let min = run_config.strength.min;
2432        if let (Ok(a), Ok(fin)) = (&measured, &fin)
2433            && gate(min, &a.combined, false) == Gate::Failed
2434            && over_strict(&a.permitted).is_empty()
2435        {
2436            let visible_tests: Vec<(String, String)> = report
2437                .acceptance
2438                .as_ref()
2439                .and_then(|a| a["files"].as_array().cloned())
2440                .unwrap_or_default()
2441                .iter()
2442                .filter_map(|p| p.as_str())
2443                .map(|p| {
2444                    (
2445                        p.to_owned(),
2446                        std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2447                    )
2448                })
2449                .collect();
2450            let setup = AcceptanceSetup {
2451                rt: &rt,
2452                plane: &plane,
2453                project: &project,
2454                worktree: &wt_root,
2455                tmp: &tmp,
2456                policy: &policy,
2457                feature: &feature_name,
2458                task,
2459                bundle: bundle_value.clone(),
2460                scenarios: &scenarios,
2461                instructions: &instructions,
2462                runner: &runner,
2463                layout: Some(&test_layout),
2464                story_files: &story_files,
2465            };
2466            let weak = crate::strength::weak(a);
2467            match strengthen(&setup, &visible_tests, weak, &before, fin) {
2468                Ok(added) => {
2469                    if let Some(acc) = report.acceptance.as_mut() {
2470                        if let Some(files) = acc["files"].as_array_mut() {
2471                            files.extend(added.files.iter().map(|f| serde_json::json!(f)));
2472                        }
2473                        acc["strengthened"] = serde_json::json!({
2474                            "files": added.files,
2475                            "argv": added.argv,
2476                            "run_id": added.report["run_id"],
2477                            "usage": added.report["usage"],
2478                        });
2479                    }
2480                    report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
2481                    commands.push(added.argv);
2482                    let mut again = measure(&commands);
2483                    if let Ok(b) = again.as_mut() {
2484                        b.gate = gate(min, &b.combined, true);
2485                        b.notes.insert(
2486                            0,
2487                            "strengthened once with added acceptance tests".to_owned(),
2488                        );
2489                    }
2490                    measured = again;
2491                }
2492                Err(why) => {
2493                    if let Ok(a) = measured.as_mut() {
2494                        a.notes
2495                            .insert(0, format!("could not strengthen the set: {why}"));
2496                    }
2497                }
2498            }
2499        }
2500        match measured {
2501            Ok(mut a) => {
2502                if a.gate == Gate::Off {
2503                    a.gate = gate(min, &a.combined, false);
2504                }
2505                a.notes.extend(notes);
2506                let findings = over_strict(&a.permitted);
2507                if !findings.is_empty() {
2508                    attempt_status = "over_strict".to_owned();
2509                    report.reason = Some(format!(
2510                        "an acceptance test rejects an implementation the spec appears to allow: {}",
2511                        findings
2512                            .iter()
2513                            .map(|p| format!("{} ({})", p.scenario, p.rationale))
2514                            .collect::<Vec<_>>()
2515                            .join("; ")
2516                    ));
2517                } else if a.gate == Gate::Failed {
2518                    attempt_status = "acceptance_weak".to_owned();
2519                    report.reason = Some(format!(
2520                        "acceptance set too weak: strength {} is below min_strength {}{}",
2521                        speccoder_core::strength::summary(&a.combined, None),
2522                        min.unwrap_or_default(),
2523                        a.notes
2524                            .first()
2525                            .map(|n| format!(" ({n})"))
2526                            .unwrap_or_default()
2527                    ));
2528                } else {
2529                    crate::strength::release(&strength_dir);
2530                }
2531                // The variant author's tokens count with the task's.
2532                report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
2533                report.strength = Some(a);
2534            }
2535            Err(e) => {
2536                report.integrity.push(e.clone());
2537                report.reason = Some(e);
2538                report.verdict = None;
2539                attempt_status = "integrity".to_owned();
2540            }
2541        }
2542        report.attempt_status.clone_from(&attempt_status);
2543        let held_up = attempt_status == "completed";
2544        stage(
2545            &*live,
2546            task,
2547            Stage::Strength,
2548            if held_up {
2549                StageState::Passed
2550            } else {
2551                StageState::Failed
2552            },
2553            report.strength.as_ref().map(|a| {
2554                speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
2555            }),
2556            Some(began),
2557        );
2558    }
2559
2560    // 7b. The gaming monitor: reasoning-blind, judge-side, add-only.
2561    //     It sees the task, the diff and the tool calls — not the model's
2562    //     account — and a suspicion only stops the tick for a person.
2563    let verdict_passed = attempt_status == "completed"
2564        && matches!(
2565            report.verdict,
2566            Some(
2567                speccoder_core::verify::Verdict::Verified
2568                    | speccoder_core::verify::Verdict::RegressionOnly
2569            )
2570        );
2571    let mut suspected = false;
2572    if !run_config.monitor && verdict_passed {
2573        stage(
2574            &*live,
2575            task,
2576            Stage::Monitor,
2577            StageState::Skipped,
2578            Some("not enabled".to_owned()),
2579            None,
2580        );
2581    }
2582    if run_config.monitor && verdict_passed {
2583        let began = std::time::Instant::now();
2584        stage(
2585            &*live,
2586            task,
2587            Stage::Monitor,
2588            StageState::Started,
2589            None,
2590            None,
2591        );
2592        let patch = worktree::checkpoint(&wt_root, &tmp)
2593            .and_then(|now| worktree::patch(&wt_root, &start, &now))
2594            .unwrap_or_default();
2595        let calls: Vec<serde_json::Value> = report
2596            .tool_calls
2597            .iter()
2598            .map(|c| {
2599                let tool = c["tool"].as_str().unwrap_or_default();
2600                serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
2601            })
2602            .collect();
2603        let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
2604        let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
2605        report.monitor = Some(monitor);
2606        suspected = suspicion;
2607        stage(
2608            &*live,
2609            task,
2610            Stage::Monitor,
2611            if suspected {
2612                StageState::Failed
2613            } else {
2614                StageState::Passed
2615            },
2616            suspected.then(|| "suspects gaming".to_owned()),
2617            Some(began),
2618        );
2619    }
2620
2621    // 8. A passed task is ticked by the engine and committed. In place, the
2622    //    spec and the checkout must still be as the run found them.
2623    let in_place_stop = options
2624        .session
2625        .as_ref()
2626        .filter(|_| verdict_passed && !suspected)
2627        .and_then(|s| {
2628            s.lock()
2629                .ok()
2630                .and_then(|s| s.check_before_tick(&policy).err())
2631        });
2632    if let Some(e) = &in_place_stop {
2633        report.reason = Some(e.line());
2634        report.verdict = None;
2635        // The saved attempt names the cause: `intent_changed`, `judge_changed`, …
2636        attempt_status = e.code.to_lowercase().replace('-', "_");
2637        report.attempt_status.clone_from(&attempt_status);
2638    }
2639    let passed = verdict_passed && !suspected && in_place_stop.is_none();
2640    // 8a. Why a regression-only tick is one: decided by code from the run's
2641    //     facts; already-passing tests are searched for the tick that made
2642    //     them pass (never changing the verdict, never a model call).
2643    if passed
2644        && matches!(
2645            report.verdict,
2646            Some(speccoder_core::verify::Verdict::RegressionOnly)
2647        )
2648    {
2649        use speccoder_core::regression::{Regression, RegressionFacts, RegressionReason, decide};
2650        let facts = RegressionFacts {
2651            acceptance_on: run_config.acceptance,
2652            // A bug's requirement is its story.
2653            story: acceptance_story
2654                .clone()
2655                .or_else(|| bug_fix.as_ref().map(|f| f.bug.requirement.clone()))
2656                .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone())),
2657            scenarios: scenarios.len(),
2658            skip: acceptance.as_ref().and_then(|a| a.skip_cause.clone()),
2659            // Red first but green only on a re-run: the evidence was flaky.
2660            red_class: acceptance.as_ref().and_then(|a| {
2661                if a.red
2662                    && report
2663                        .acceptance
2664                        .as_ref()
2665                        .is_some_and(|r| r["green_flaky"] == true)
2666                {
2667                    Some(speccoder_core::verify::RedClass::Flaky)
2668                } else {
2669                    a.red_class
2670                }
2671            }),
2672        };
2673        let mut reason = decide(&facts);
2674        // A bug's reproductions passing already is the bug lane's own
2675        // outcome (already satisfied): no tick to search for.
2676        if matches!(reason, RegressionReason::AlreadyPassing { .. })
2677            && bug_fix.is_none()
2678            && let Some(a) = acceptance.as_ref()
2679            && let Some(argv) = a.argv.as_ref()
2680        {
2681            let mut files: Vec<(String, String)> = a.report["files"]
2682                .as_array()
2683                .into_iter()
2684                .flatten()
2685                .filter_map(|f| f.as_str())
2686                .filter_map(|f| {
2687                    std::fs::read_to_string(project.join(f))
2688                        .ok()
2689                        .map(|c| (f.to_owned(), c))
2690                })
2691                .collect();
2692            let prefix = project
2693                .strip_prefix(&wt_root)
2694                .map(|p| p.to_string_lossy().replace('\\', "/"))
2695                .unwrap_or_default();
2696            // A section added to the story's file: only that section, with
2697            // the file's imports, and only its tests run.
2698            let mut command = Some(argv.clone());
2699            if a.report["appended"] == true
2700                && let Some(story) = a.report["story_file"].as_str()
2701            {
2702                let at = if prefix.is_empty() {
2703                    story.to_owned()
2704                } else {
2705                    format!("{prefix}/{story}")
2706                };
2707                let old = crate::git::Git::at(&wt_root)
2708                    .run(&["show", &format!("HEAD:{at}")])
2709                    .map(|b| String::from_utf8_lossy(&b).into_owned())
2710                    .unwrap_or_default();
2711                command = None;
2712                if let Some((_, content)) = files.iter_mut().find(|(f, _)| f == story)
2713                    && let Some(added) = content.strip_prefix(old.as_str()).map(str::to_owned)
2714                {
2715                    command = super::bisect::section_command(argv, story, &added);
2716                    let imports: String = old
2717                        .lines()
2718                        .filter(|l| {
2719                            let t = l.trim_start();
2720                            ["use ", "import ", "from ", "package ", "extern crate "]
2721                                .iter()
2722                                .any(|k| t.starts_with(k))
2723                        })
2724                        .map(|l| format!("{l}\n"))
2725                        .collect();
2726                    *content = format!("{imports}{added}");
2727                }
2728            }
2729            let timeout = std::fs::read_to_string(project.join(crate::verify::config::FILE))
2730                .ok()
2731                .and_then(|t| crate::verify::config::parse(&t).ok())
2732                .and_then(|cs| {
2733                    cs.into_iter()
2734                        .filter(|c| c.tier == crate::verify::config::Tier::Fast)
2735                        .map(|c| c.timeout)
2736                        .max()
2737                })
2738                .unwrap_or(std::time::Duration::from_secs(600));
2739            let found = match &command {
2740                Some(argv) => super::bisect::search(&super::bisect::Search {
2741                    repo: &wt_root,
2742                    prefix: &prefix,
2743                    feature: &feature_name,
2744                    task,
2745                    files: &files,
2746                    argv,
2747                    scratch: &s.plane_dir.join("tmp"),
2748                    sandbox: &sandbox_config,
2749                    timeout,
2750                    worktree: &worktree_config,
2751                    checkout: Path::new(&s.snapshot.root),
2752                    state: Some(state),
2753                }),
2754                // No way to run only the section: no answer, never a guess.
2755                None => super::bisect::Found {
2756                    implemented_by: None,
2757                    runs: 0,
2758                },
2759            };
2760            live.event(&progress::Event::RegressionBisected {
2761                task: task.to_owned(),
2762                runs: found.runs,
2763                result: found.implemented_by.as_ref().map(|i| match i {
2764                    speccoder_core::regression::ImplementedBy::Task { task, .. } => task.clone(),
2765                    speccoder_core::regression::ImplementedBy::BeforeFeature { .. } => {
2766                        "before_feature".to_owned()
2767                    }
2768                }),
2769            });
2770            reason = RegressionReason::AlreadyPassing {
2771                implemented_by: found.implemented_by,
2772            };
2773        }
2774        report.regression = Some(Regression::of(reason));
2775    }
2776    if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
2777        let began = std::time::Instant::now();
2778        stage(&*live, task, Stage::Commit, StageState::Started, None, None);
2779        let message = tick_message(&TickFacts {
2780            feature: &feature_name,
2781            task,
2782            title: &t.title,
2783            report: &report,
2784            red: acceptance
2785                .as_ref()
2786                .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
2787            checklists_overridden,
2788            extra: bug_fix
2789                .as_ref()
2790                .map(|f| {
2791                    super::bug::trailers(
2792                        f,
2793                        &report
2794                            .verdict
2795                            .as_ref()
2796                            .map(super::outcome::verdict_word)
2797                            .unwrap_or_default(),
2798                        &crate::strength::rulings(&strength_dir),
2799                    )
2800                })
2801                // A set frozen at review: the one pre-frozen trailer form.
2802                .or_else(|| review_frozen.as_ref().map(|(r, m)| r.trailer(m)))
2803                .unwrap_or_default(),
2804        });
2805        match &options.session {
2806            // Worktree: tick, then commit the tick with the evidence.
2807            None => {
2808                match format.tick(file, task, &t.line_hash) {
2809                    Ok(()) => report.ticked = true,
2810                    Err(e) => report.integrity.push(format!("tick refused: {e}")),
2811                }
2812                if report.ticked && run_config.commit_per_task {
2813                    match commit_attempt(&wt_root, &message) {
2814                        Ok(id) => report.commit = Some(id),
2815                        Err(e) => report.integrity.push(format!("commit failed: {e}")),
2816                    }
2817                }
2818            }
2819            // In place: evidence first, then the tick; a git-ignored
2820            // tasks.md is never in the commit, so its tick is local.
2821            Some(session) => {
2822                let digest =
2823                    crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
2824                if let Ok(mut s) = session.lock() {
2825                    let done = s.commit_and_tick(
2826                        &plane_dir,
2827                        file,
2828                        &t.line_hash,
2829                        &message,
2830                        &digest,
2831                        || format.tick(file, task, &t.line_hash),
2832                    );
2833                    report.ticked = done.ticked;
2834                    report.commit = done.commit;
2835                    report.integrity.extend(done.problems);
2836                }
2837            }
2838        }
2839        report.instructions = attempt_instructions
2840            .iter()
2841            .map(|i| speccoder_core::InstructionFile {
2842                path: i.path.clone(),
2843                sha256: i.sha256.clone(),
2844                bytes: i.bytes,
2845                cut: i.cut,
2846            })
2847            .collect();
2848        // Signed, portable evidence of the tick: never undoes it.
2849        if let Some(commit) = report
2850            .commit
2851            .clone()
2852            .filter(|_| report.ticked && run_config.attest.enabled)
2853        {
2854            let verdict = report
2855                .verdict
2856                .as_ref()
2857                .map(super::outcome::verdict_word)
2858                .unwrap_or_default();
2859            let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
2860            let feature_dir = snap
2861                .feature
2862                .as_ref()
2863                .map(|f| f.feature.dir.clone())
2864                .unwrap_or_default();
2865            let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
2866            let tick = crate::attest::Tick {
2867                repo: &wt_root,
2868                config: &config_text,
2869                attest: &run_config.attest,
2870                format: &format!("{:?}", format.kind()).to_lowercase(),
2871                feature: &feature_name,
2872                feature_dir: &feature_dir,
2873                task,
2874                title: &t.title,
2875                line_hash: &t.line_hash,
2876                section: t.story.as_deref(),
2877                section_text: bundle_value["story"].as_str().unwrap_or_default(),
2878                requirements: &requirements,
2879                commit: &commit,
2880                verdict: &verdict,
2881                sandbox: serde_json::json!({
2882                    "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
2883                    "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
2884                    "network": format!("{:?}", sandbox_config.network).to_lowercase(),
2885                }),
2886                executor: serde_json::json!({
2887                    "kind": if bug_candidate.is_some() { "candidate".to_owned() } else { executor.as_ref().map_or_else(|| "native".to_owned(), |e| match &e.kind {
2888                        external::Kind::Claude => "claude".to_owned(),
2889                        external::Kind::Codex => "codex".to_owned(),
2890                        external::Kind::Command(_) => "command".to_owned(),
2891                    }) },
2892                    "candidate": bug_candidate.as_ref().map(|(_, c)| serde_json::json!({ "origin": c.origin, "digest": c.digest, "head_sha": c.head_sha })),
2893                }),
2894                model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
2895                bug: bug_fix
2896                    .as_ref()
2897                    .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
2898            };
2899            report.attestation = Some(crate::attest::record_tick(&tick, &report));
2900        }
2901        let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
2902        stage(
2903            &*live,
2904            task,
2905            Stage::Commit,
2906            if committed {
2907                StageState::Passed
2908            } else {
2909                StageState::Failed
2910            },
2911            report.commit.as_ref().map(|c| c.chars().take(7).collect()),
2912            Some(began),
2913        );
2914    }
2915    // 8'. A bug in the format's own bug structure: nothing is ticked; its
2916    //     verdict commit carries the evidence (a trailer-only task).
2917    if passed && let Some(n) = &native {
2918        let began = std::time::Instant::now();
2919        stage(&*live, task, Stage::Commit, StageState::Started, None, None);
2920        let verdict = report
2921            .verdict
2922            .as_ref()
2923            .map(super::outcome::verdict_word)
2924            .unwrap_or_default();
2925        let message = tick_message(&TickFacts {
2926            feature: &feature_name,
2927            task,
2928            title: &n.title,
2929            report: &report,
2930            red: acceptance
2931                .as_ref()
2932                .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
2933            checklists_overridden: false,
2934            extra: bug_fix
2935                .as_ref()
2936                .map(|f| {
2937                    super::bug::trailers(f, &verdict, &crate::strength::rulings(&strength_dir))
2938                })
2939                .unwrap_or_default(),
2940        });
2941        match commit_attempt(&wt_root, &message) {
2942            Ok(id) => report.commit = Some(id),
2943            Err(e) => report.integrity.push(format!("commit failed: {e}")),
2944        }
2945        if let Some(commit) = report.commit.clone().filter(|_| run_config.attest.enabled) {
2946            let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
2947            let tick = crate::attest::Tick {
2948                repo: &wt_root,
2949                config: &config_text,
2950                attest: &run_config.attest,
2951                format: &format!("{:?}", format.kind()).to_lowercase(),
2952                feature: &feature_name,
2953                feature_dir: &n.dir,
2954                task,
2955                title: &n.title,
2956                line_hash: "",
2957                section: None,
2958                section_text: &n.story,
2959                requirements: &[],
2960                commit: &commit,
2961                verdict: &verdict,
2962                sandbox: serde_json::json!({
2963                    "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
2964                    "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
2965                    "network": format!("{:?}", sandbox_config.network).to_lowercase(),
2966                }),
2967                executor: serde_json::json!({
2968                    "kind": if bug_candidate.is_some() { "candidate" } else if executor.is_some() { "external" } else { "native" },
2969                }),
2970                model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
2971                bug: bug_fix
2972                    .as_ref()
2973                    .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
2974            };
2975            report.attestation = Some(crate::attest::record_tick(&tick, &report));
2976        }
2977        stage(
2978            &*live,
2979            task,
2980            Stage::Commit,
2981            if report.commit.is_some() {
2982                StageState::Passed
2983            } else {
2984                StageState::Failed
2985            },
2986            report.commit.as_ref().map(|c| c.chars().take(7).collect()),
2987            Some(began),
2988        );
2989    }
2990
2991    // 9. An attempt whose tests reject what the spec appears to allow is
2992    //    held for a person, as a waiting call is: its tree and resume marker
2993    //    stay, and after a ruling the next run judges it again from the
2994    //    journal, repeating no model call.
2995    if attempt_status == "repro_weak"
2996        && let Some(weak) = &bug_weak
2997        && let Err(e) = crate::strength::hold(&strength_dir, &[weak])
2998    {
2999        report
3000            .integrity
3001            .push(format!("holding the attempt failed: {e}"));
3002    }
3003    if attempt_status == "over_strict" || attempt_status == "repro_weak" {
3004        let findings = report
3005            .strength
3006            .as_ref()
3007            .map(|a| speccoder_core::strength::over_strict(&a.permitted))
3008            .unwrap_or_default();
3009        if attempt_status == "over_strict"
3010            && let Err(e) = crate::strength::hold(&strength_dir, &findings)
3011        {
3012            report
3013                .integrity
3014                .push(format!("holding the attempt failed: {e}"));
3015        }
3016        marker_guard.keep();
3017    }
3018    // 9. An attempt that did not pass is saved under an attempt ref and
3019    //    undone: the next attempt starts where this one did.
3020    if !passed && attempt_status != "over_strict" && attempt_status != "repro_weak" {
3021        let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
3022        let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
3023        let reason = redact(&reason, secret);
3024        let record = attempts::Record {
3025            run,
3026            outcome: &outcome,
3027            reason: &reason,
3028            pending: report.pending.as_ref(),
3029            usage: report.usage.as_ref(),
3030        };
3031        match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
3032            .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
3033        {
3034            Ok(saved) => report.saved_attempt = Some(saved.reference),
3035            Err(e) => report
3036                .integrity
3037                .push(format!("saving the attempt failed: {}", e.0)),
3038        }
3039    }
3040
3041    let (status, mut message) = status_and_message(&report, &attempt_status, suspected);
3042    // Bedrock's refusals name where the call went: a model the region does
3043    // not offer reads the same as one the account may not use.
3044    if status == CommandStatus::RunFailed
3045        && let Some(region) = s.model.region.as_deref()
3046        && report
3047            .reason
3048            .as_deref()
3049            .and_then(substrate::provider_refusal)
3050            .is_some()
3051    {
3052        message.push_str(&format!(
3053            " — Amazon Bedrock, region {region}, model {}",
3054            s.model.model
3055        ));
3056    }
3057    // The provider refused the account: no retry helps, and for Bedrock the
3058    // first fix renews the credentials the calls were signed with.
3059    let refusal = |result: CommandResult| {
3060        if !result.has_explicit_code()
3061            && refused_account
3062            && result.status() == CommandStatus::RunFailed
3063        {
3064            result.with_code("PROVIDER-REFUSED").with_fix(
3065                substrate::model_config::provider_refused_fix(
3066                    &s.model,
3067                    crate::aws::detect_here().as_ref(),
3068                    feature,
3069                ),
3070            )
3071        } else {
3072            result
3073        }
3074    };
3075    if let Some(fix) = bug_fix.as_mut() {
3076        return refusal(super::bug::finished(
3077            &bug_ctx,
3078            fix,
3079            report,
3080            &attempt_status,
3081            passed,
3082            status,
3083            redact(&message, secret),
3084        ));
3085    }
3086    live.event(&finished_event(
3087        task,
3088        &report,
3089        &attempt_status,
3090        suspected,
3091        redact(&message, secret),
3092    ));
3093    let code = collection_code(&report, &attempt_status);
3094    let result = CommandResult::new("run", status, &redact(&message, secret));
3095    match code {
3096        Some(c) => result.with_code(c),
3097        None => refusal(result),
3098    }
3099    .with_data(CommandData::Run(Box::new(report)))
3100}
3101
3102/// The code of a run the collection check decided: a file that never ran
3103/// (`TESTS-NOT-COLLECTED`), or one speccoder cannot tell about.
3104fn collection_code(
3105    report: &speccoder_core::RunReport,
3106    attempt_status: &str,
3107) -> Option<&'static str> {
3108    if attempt_status == "collection_unknown" {
3109        return Some("TESTS-COLLECTION-UNKNOWN");
3110    }
3111    match &report.verdict {
3112        Some(speccoder_core::verify::Verdict::Failed { reasons })
3113            if reasons
3114                .first()
3115                .is_some_and(|r| r.starts_with(speccoder_core::collection::REASON_PREFIX)) =>
3116        {
3117            Some("TESTS-NOT-COLLECTED")
3118        }
3119        _ => None,
3120    }
3121}
3122
3123/// Judges the attempt's changed test files against what the run collected:
3124/// a file no test ran from fails the verdict (its reason
3125/// first); for an otherwise passing attempt, a file speccoder cannot tell
3126/// about returns the stop's message. Listings run only then.
3127/// What the collection check came to beyond the verdict's reasons.
3128enum Collected {
3129    /// A file speccoder cannot tell about: the stop's message.
3130    Unknown(String),
3131    /// A listing changed the worktree: the integrity finding.
3132    Changed(String),
3133}
3134
3135fn judge_collection(
3136    dirs: (&Path, &Path, &Path),
3137    runner: &sandbox::Runner,
3138    otherwise_passing: bool,
3139    report: &mut speccoder_core::RunReport,
3140    live: &dyn progress::Progress,
3141    task: &str,
3142) -> Option<Collected> {
3143    let (project, wt_root, tmp) = dirs;
3144    // Listings run the project's code: the tree they leave must be the one judged.
3145    let before = worktree::checkpoint(wt_root, tmp).ok();
3146    let checks = std::fs::read_to_string(project.join(crate::verify::config::FILE))
3147        .ok()
3148        .and_then(|t| crate::verify::config::parse(&t).ok())
3149        .unwrap_or_default()
3150        .into_iter()
3151        .filter(|c| c.tier == crate::verify::config::Tier::Fast)
3152        .collect::<Vec<_>>();
3153    let results = report
3154        .verification
3155        .as_ref()
3156        .map(|v| v.checks.clone())
3157        .unwrap_or_default();
3158    let c = crate::collection::judge(&crate::collection::Input {
3159        root: project,
3160        changes: &report.changes,
3161        checks: &checks,
3162        results: &results,
3163        runner,
3164        listings: otherwise_passing,
3165    })?;
3166    if !c.listing.is_empty()
3167        && let Some(before) = &before
3168    {
3169        let changed: Vec<String> = worktree::checkpoint(wt_root, tmp)
3170            .and_then(|after| worktree::diff(wt_root, before, &after))
3171            .map(|d| {
3172                d.into_iter()
3173                    .map(|c| c.path)
3174                    .filter(|p| !speccoder_core::collection::is_build_or_dependency(p))
3175                    .collect()
3176            })
3177            .unwrap_or_else(|e| vec![format!("(the tree could not be read: {})", e.0)]);
3178        if !changed.is_empty() {
3179            report.collection = Some(c);
3180            return Some(Collected::Changed(format!(
3181                "integrity: a test listing changed the worktree: {}",
3182                changed.join(", ")
3183            )));
3184        }
3185    }
3186    live.event(&progress::Event::CollectionChecked {
3187        task: task.to_owned(),
3188        files: c
3189            .files
3190            .iter()
3191            .map(|f| progress::CollectionFile {
3192                path: f.path.clone(),
3193                state: f.state,
3194                decided_by: f.decided_by,
3195            })
3196            .collect(),
3197    });
3198    let never = c.reasons();
3199    let unknown: Vec<String> = c
3200        .unknown()
3201        .map(|f| {
3202            format!(
3203                "speccoder cannot tell whether the runner ran `{}` ({})",
3204                f.path,
3205                f.why.as_deref().unwrap_or("no report or listing names it")
3206            )
3207        })
3208        .collect();
3209    report.collection = Some(c);
3210    if !never.is_empty() {
3211        let mut reasons = never;
3212        if let Some(speccoder_core::verify::Verdict::Failed { reasons: more }) =
3213            report.verdict.take()
3214        {
3215            reasons.extend(more);
3216        }
3217        report.verdict = Some(speccoder_core::verify::Verdict::Failed { reasons });
3218        return None;
3219    }
3220    (otherwise_passing && !unknown.is_empty()).then(|| Collected::Unknown(unknown.join("; ")))
3221}
3222
3223/// `note`, with what the check itself said last (its own reason) and, for
3224/// the causes that are known, the fix.
3225fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
3226    let said = c
3227        .stderr_tail
3228        .lines()
3229        .chain(c.stdout_tail.lines())
3230        .map(str::trim)
3231        .find(|l| l.starts_with("error"))
3232        .map(|l| l.chars().take(200).collect::<String>());
3233    let fix = match said.as_deref() {
3234        Some(l) if l.contains("profile `speccoder` not found") => Some(
3235            "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
3236        ),
3237        Some(l) if l.contains("no tests to run") => Some(
3238            "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",
3239        ),
3240        Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
3241            "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
3242        ),
3243        _ => None,
3244    };
3245    match (said, fix) {
3246        (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
3247        (Some(s), None) => format!("{note} ({s})"),
3248        _ => note.to_owned(),
3249    }
3250}
3251
3252/// Why a failing baseline check is broken rather than failing tests: it
3253/// could not start, or it failed without any test result to show for it.
3254/// `None`: it passed, or its tests ran and some failed.
3255#[must_use]
3256pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
3257    use speccoder_core::verify::CheckStatus;
3258    if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
3259        return None;
3260    }
3261    if let Some(note) = &c.note {
3262        return Some(with_cause(note, c));
3263    }
3264    if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
3265        return None;
3266    }
3267    let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
3268    // Only what is unambiguous: a command that cannot start, a build that
3269    // fails, or a test runner that ran no test. A script or `grep` that
3270    // fails until the task is done is a failing check, not a broken one.
3271    let runner = c
3272        .argv
3273        .first()
3274        .map(|a| a.rsplit('/').next().unwrap_or(a))
3275        .is_some_and(|a| {
3276            matches!(
3277                a,
3278                "cargo"
3279                    | "pytest"
3280                    | "python"
3281                    | "python3"
3282                    | "uv"
3283                    | "npm"
3284                    | "npx"
3285                    | "pnpm"
3286                    | "yarn"
3287                    | "bun"
3288                    | "go"
3289                    | "jest"
3290                    | "vitest"
3291                    | "mvn"
3292                    | "gradle"
3293                    | "dotnet"
3294                    | "mix"
3295                    | "swift"
3296            )
3297        });
3298    let cannot_start = matches!(c.exit_code, Some(126 | 127));
3299    let is_broken = match &summary {
3300        _ if cannot_start => true,
3301        Some(s) if s.build_failed => true,
3302        Some(s) => s.passed + s.failed + s.errors == 0,
3303        None => runner,
3304    };
3305    match is_broken {
3306        false => None,
3307        true => {
3308            let first = c
3309                .stderr_tail
3310                .lines()
3311                .chain(c.stdout_tail.lines())
3312                .map(str::trim)
3313                .find(|l| l.to_lowercase().contains("error"))
3314                .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
3315                .unwrap_or("it failed without running a test")
3316                .chars()
3317                .take(160)
3318                .collect::<String>();
3319            Some(first)
3320        }
3321    }
3322}