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