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