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