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