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#[allow(clippy::too_many_lines)] // one linear procedure, kept in reading order
94pub(crate) fn run_task(
95    path: &Path,
96    feature: Option<&str>,
97    task: &str,
98    state: &Path,
99    provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
100    secret: Option<&str>,
101    options: &RunOptions,
102) -> CommandResult {
103    use speccoder_core::RunReport;
104    // In place: the person's checkout, switched and switched back around the task.
105    if options.in_place && options.session.is_none() {
106        return in_session("run", path, feature, state, options, |o| {
107            run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
108        });
109    }
110    let approver = options.approver.clone();
111    use speccoder_core::snapshot::TaskState;
112
113    let refuse =
114        |status: CommandStatus, m: String| CommandResult::new("run", status, &redact(&m, secret));
115
116    // 1. The feature, as selected in the user's checkout.
117    let s = match setup(path, feature, state, "run") {
118        Ok(s) => s,
119        Err(r) => return *r,
120    };
121    let Some(f) = s.snapshot.feature.as_ref() else {
122        return refuse(
123            CommandStatus::SelectionNeeded,
124            "no feature selected; pass --feature".to_owned(),
125        );
126    };
127    let feature_name = f.feature.name.clone();
128    // A lane keeps its own journal and live state; a person's
129    // decisions are the feature's.
130    let plane_dir = match &options.lane {
131        Some(lane) => s.plane_dir.join("lanes").join(lane),
132        None => s.plane_dir.clone(),
133    };
134    // Live progress: live.json for `speccoder report --live`, and the printer.
135    let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
136        &plane_dir,
137        s.model.prices,
138        options.progress.clone(),
139    ));
140
141    // 2. The worktree.
142    let wt = workspace(path, Some(&feature_name), state, options);
143    let Some(CommandData::Worktree(w)) = wt.data() else {
144        return CommandResult::carried("run", &wt);
145    };
146    if wt.status() != CommandStatus::Ok {
147        return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
148    }
149    let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
150        return refuse(CommandStatus::Error, "worktree without a path".to_owned());
151    };
152    let (wt_path, project_dir) = match &options.lane {
153        None => (wt_path, project_dir),
154        Some(lane) => match lane_worktree(path, state, &feature_name, lane, &wt_path, &project_dir)
155        {
156            Ok(paths) => paths,
157            Err(e) => return refuse(CommandStatus::GitPrecondition, e),
158        },
159    };
160    let project = std::path::PathBuf::from(&project_dir);
161    let wt_root = std::path::PathBuf::from(&wt_path);
162    let tmp = plane_dir.join("tmp");
163
164    // 2a. A task waiting for a person holds the worktree with its attempt:
165    //     another task starts once it is decided and has resumed.
166    if options.lane.is_none()
167        && let Some((other, covers)) =
168            crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task)
169    {
170        return refuse(
171            CommandStatus::NeedsHuman,
172            format!(
173                "{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}`"
174            ),
175        );
176    }
177    // 2a'. So does an attempt held for a ruling on its tests.
178    if options.lane.is_none()
179        && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
180    {
181        return refuse(
182            CommandStatus::NeedsHuman,
183            format!(
184                "{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`"
185            ),
186        )
187        .with_code("TESTS-OVER-STRICT");
188    }
189    // 2b. Leftovers of an interrupted run (or edits by hand) are saved under
190    //     an attempt ref and set aside: a run starts from the branch tip
191    //    . Without per-task commits, uncommitted work is expected.
192    let commits_per_task = verify::find_config(&project)
193        .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
194        .and_then(|t| substrate::run_config::parse(&t).ok())
195        .is_none_or(|c| c.commit_per_task);
196    // an attempt a killed process left behind is resumed, not set
197    // aside — the worktree holds what its finished tool calls wrote.
198    let marker_path = resume::path(&plane_dir, &feature_name, task);
199    // A fresh start for the task's tests: its held (or interrupted) attempt
200    // is set aside below like any leftover, and new tests are written.
201    if options.fresh_acceptance && options.lane.is_none() {
202        let _ = std::fs::remove_file(&marker_path);
203        crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
204    }
205    let head_now = git::Git::at(&wt_root)
206        .line(&["rev-parse", "HEAD"])
207        .unwrap_or_default();
208    let interrupted = resume::load(&marker_path)
209        .filter(|m| m.head == head_now && options.lane.is_none() && !head_now.is_empty());
210    let marker_guard = resume::Guard::new(marker_path.clone());
211    // Where the interrupted attempt was, from the live record it left
212    // (read before this run overwrites it).
213    let resumed_from = interrupted.as_ref().map(|_| {
214        std::fs::read_to_string(plane_dir.join(progress::FILE))
215            .ok()
216            .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
217            .filter(|l| l.task == task)
218            .map_or((None, None), |l| (l.stage, l.last_call))
219    });
220    let mut set_aside = None;
221    // In place, a dirty checkout is the person's: the session guard refuses it.
222    if interrupted.is_none()
223        && commits_per_task
224        && options.session.is_none()
225        && worktree::require_clean(&wt_root).is_err()
226    {
227        match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
228            Ok(reference) => set_aside = Some(reference),
229            Err(e) => return refuse(CommandStatus::GitPrecondition, e),
230        }
231    }
232
233    // 3. Spec, checks and policy as committed in the worktree.
234    let format = crate::format::at(&project);
235    let Some(snap) = format.inspect(&project, Some(&feature_name)) else {
236        return refuse(
237            CommandStatus::NotAProject,
238            format.messages().no_project_in_worktree.to_owned(),
239        );
240    };
241    // The task must exist and be ready in the worktree, where progress
242    // (ticks committed on speccoder/<feature>) lives.
243    let Some(st) = snap
244        .feature
245        .as_ref()
246        .and_then(|f| f.schedule.as_ref())
247        .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
248    else {
249        return refuse(
250            CommandStatus::NotFound,
251            format!("no task {task} in {feature_name}"),
252        );
253    };
254    match st.state {
255        TaskState::Done => {
256            return refuse(
257                CommandStatus::TaskNotReady,
258                format!("{task} is already done"),
259            )
260            .with_code("TASK-DONE");
261        }
262        TaskState::Blocked => {
263            return refuse(
264                CommandStatus::TaskNotReady,
265                format!("{task} is blocked by {}", st.blocked_by.join(", ")),
266            )
267            .with_code("TASK-BLOCKED");
268        }
269        TaskState::Ready => {}
270    }
271    // Unfinished checklists stop the task before any model call (the
272    // committed checklists in the worktree are what counts).
273    let checklists_overridden =
274        match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
275            Ok(overridden) => overridden,
276            Err(stop) => return *stop,
277        };
278    let parsed_task = snap
279        .feature
280        .as_ref()
281        .and_then(|f| f.tasks.as_ref())
282        .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
283    let tasks_file = snap
284        .feature
285        .as_ref()
286        .and_then(|f| f.tasks.as_ref())
287        .map(|t| project.join(&t.path));
288
289    let bundle = match format.bundle(&snap, task) {
290        Ok(b) => b,
291        Err(_) => {
292            return refuse(
293                CommandStatus::NotFound,
294                format!("no task {task} in the worktree's {feature_name}"),
295            );
296        }
297    };
298    let Some(config_root) = verify::find_config(&project) else {
299        return refuse(
300            CommandStatus::ConfigError,
301            "the worktree has no speccoder.toml (is it committed?)".to_owned(),
302        )
303        .with_code("CONFIG-MISSING");
304    };
305    let config_text =
306        std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
307    let run_config = match substrate::run_config::parse(&config_text) {
308        Ok(c) => c,
309        Err(e) => {
310            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
311                .with_code("CONFIG-INVALID");
312        }
313    };
314    // `[review] required`: the spec as committed in the worktree must be
315    // approved, every requirement on its current wording.
316    if run_config.review_required
317        && let Some(f) = snap.feature.as_ref()
318        && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
319        && !t.complete()
320    {
321        return refuse(
322            CommandStatus::NeedsHuman,
323            format!(
324                "{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",
325                t.approved, t.open, t.changes_requested, t.lapsed, t.questions
326            ),
327        )
328        .with_code("REVIEW-PENDING");
329    }
330    let executor = match external::parse(&config_text) {
331        Ok(e) => e,
332        Err(e) => {
333            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
334                .with_code("CONFIG-INVALID");
335        }
336    };
337    // Every command this task starts runs confined to the worktree.
338    let sandbox_config = match sandbox::parse(&config_text) {
339        Ok(c) => c,
340        Err(e) => {
341            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
342                .with_code("CONFIG-INVALID");
343        }
344    };
345    let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
346        Ok(r) => r,
347        Err(e) => return refuse(CommandStatus::ConfigError, e),
348    };
349    let mutation_config = match mutation::parse(&config_text) {
350        Ok(c) => c,
351        Err(e) => {
352            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
353                .with_code("CONFIG-INVALID");
354        }
355    };
356    let check_argvs = verify::check_argvs(&config_root);
357    let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow).rules(format.rules());
358    // In place: the model's view of the checkout confined (git that moves
359    // it denied, ignored files hidden, `.git` read-only to commands).
360    let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
361    {
362        Some(s) => s.confine(policy, runner),
363        None => (policy, runner, None),
364    };
365    let sandbox_notice = sandbox_notice.or(carve_notice);
366    // In place: the checkout as speccoder left it, the intent as the run
367    // started with it — before the task, and again before the tick.
368    if let Some(e) = options.session.as_ref().and_then(|s| {
369        s.lock().ok().and_then(|s| {
370            // A resumed attempt's writes are in the checkout already.
371            if interrupted.is_some() {
372                s.check_before_tick(&policy).err()
373            } else {
374                s.check(&policy).err()
375            }
376        })
377    }) {
378        return e.result("run");
379    }
380
381    let wt_root_canonical = std::fs::canonicalize(&wt_root)
382        .unwrap_or_else(|_| wt_root.clone())
383        .display()
384        .to_string();
385
386    // A person's approvals and rejections for this task.
387    let decisions = match approvals::Decisions::load(&s.plane_dir) {
388        Ok(d) => d.for_task(&feature_name, task),
389        Err(e) => return refuse(CommandStatus::ConfigError, e),
390    };
391
392    if let Some((stage, last_call)) = &resumed_from {
393        live.event(&progress::Event::Resumed {
394            task: task.to_owned(),
395            stage: *stage,
396            last_call: last_call.clone(),
397        });
398    }
399    live.event(&progress::Event::TaskStarted {
400        feature: feature_name.clone(),
401        task: task.to_owned(),
402        title: parsed_task
403            .as_ref()
404            .map(|t| t.title.clone())
405            .unwrap_or_default(),
406    });
407    // From here on, an early end is recorded as the task run's end.
408    let fail = |status: CommandStatus, m: String| {
409        live.event(&progress::Event::TaskFinished {
410            task: task.to_owned(),
411            outcome: "error".to_owned(),
412            message: redact(&m, secret),
413            run: None,
414            input_tokens: 0,
415            output_tokens: 0,
416            cache_read_tokens: 0,
417            cache_write_tokens: 0,
418            usd: None,
419        });
420        refuse(status, m)
421    };
422    // 4. Baseline: no verdict without a judge.
423    let began = std::time::Instant::now();
424    stage(
425        &*live,
426        task,
427        Stage::Baseline,
428        StageState::Started,
429        None,
430        None,
431    );
432    let baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
433        Ok(b) => b,
434        Err(e) => {
435            stage(
436                &*live,
437                task,
438                Stage::Baseline,
439                StageState::Failed,
440                None,
441                Some(began),
442            );
443            return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
444        }
445    };
446    // A check that cannot run on the untouched code — it failed without
447    // running a test: not found, a build or configuration error, a file the
448    // worktree lacks because it was never committed — can never judge the
449    // task. Stop before any model call rather than spend attempts on it.
450    if !options.allow_failing_baseline
451        && let Some((c, why)) = baseline
452            .checks
453            .iter()
454            .find_map(|c| broken(c).map(|w| (c, w)))
455    {
456        stage(
457            &*live,
458            task,
459            Stage::Baseline,
460            StageState::Failed,
461            Some(format!("`{}` cannot run", c.name)),
462            Some(began),
463        );
464        return fail(
465            CommandStatus::ConfigError,
466            format!(
467                "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",
468                c.name
469            ),
470        )
471        .with_code("CHECK-BROKEN");
472    }
473    // Failing tests at the baseline are reported, not blamed: the stage held.
474    let failing = baseline
475        .checks
476        .iter()
477        .filter(|c| c.status.is_failure())
478        .count();
479    stage(
480        &*live,
481        task,
482        Stage::Baseline,
483        StageState::Passed,
484        (failing > 0).then(|| format!("{failing} check(s) already failing")),
485        Some(began),
486    );
487
488    let rt = match tokio_runtime() {
489        Ok(rt) => rt,
490        Err(e) => return fail(CommandStatus::RunFailed, e),
491    };
492    let bundle_value = match serde_json::to_value(&bundle) {
493        Ok(v) => v,
494        Err(e) => return fail(CommandStatus::Error, e.to_string()),
495    };
496    // The repository's instructions for agents, from the tree the
497    // attempt starts in: for speccoder's own calls (the attempt, the test
498    // authors); an external agent reads its own files.
499    let instructions = crate::instructions::collect(
500        &wt_root,
501        &project,
502        &speccoder_core::scope::named_paths(&bundle.task.title),
503        &run_config.instructions,
504    );
505    let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
506        &instructions
507    } else {
508        &[]
509    };
510
511    // MCP servers, started in the worktree for this task's attempts.
512    let servers = match mcp::parse(&config_text) {
513        Ok(s) => s,
514        Err(e) => {
515            return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
516                .with_code("CONFIG-INVALID");
517        }
518    };
519    let mut connected = if servers.is_empty() {
520        mcp::Connected::default()
521    } else {
522        live.event(&progress::Event::Notice {
523            text: format!("starting {} MCP server(s)", servers.len()),
524        });
525        rt.block_on(mcp::connect(&servers, &project, &runner))
526    };
527    // An allowed tool its server changed since review needs approval again.
528    let approved_tools: Vec<String> = decisions
529        .approved
530        .iter()
531        .filter_map(|g| match g {
532            tools::policy::Grant::Tool { name } => Some(name.clone()),
533            _ => None,
534        })
535        .collect();
536    let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
537    connected.notices.extend(pinned);
538
539    // One plane for the whole task. Its tool server enforces denials; the
540    // frozen-test approvals are the skill's, from its journaled input.
541    let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
542        Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
543        Err(e) => return fail(CommandStatus::Error, e.to_string()),
544    };
545    // Calls past their deadline are denied as expired before anything resumes.
546    let _ = crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc());
547    // What a person is shown about the sandbox, for every call they decide.
548    let present = crate::approvals::PresentContext {
549        root: None,
550        sandbox: runner.is_sandboxed().then(|| {
551            if cfg!(target_os = "macos") {
552                "Seatbelt".to_owned()
553            } else {
554                "Landlock+seccomp".to_owned()
555            }
556        }),
557        network: match sandbox_config.network {
558            sandbox::Network::Off => "none",
559            sandbox::Network::Loopback => "loopback",
560            sandbox::Network::On => "unrestricted",
561        }
562        .to_owned(),
563        file_text: None,
564    };
565    let plane = match rt.block_on(async {
566        substrate::Plane::open_with(
567            &plane_dir,
568            &s.model,
569            provider,
570            Some(tools_server),
571            substrate::Hooks {
572                progress: Some(Arc::clone(&live)),
573                mcp: connected.clients.clone(),
574                mcp_tools: connected.tools.clone(),
575                format: Some(format),
576                present: present.clone(),
577            },
578        )
579    }) {
580        Ok(p) => p,
581        Err(e) => return fail(CommandStatus::RunFailed, e.0),
582    };
583    let mut notices: Vec<String> = plane
584        .replaced_journal
585        .iter()
586        .map(|p| {
587            format!(
588                "the journal could not be read by this build and was moved to {}; past runs stay in git",
589                p.display()
590            )
591        })
592        .chain(connected.notices.iter().cloned())
593        .chain(sandbox_notice.clone())
594        .chain(resumed_from.as_ref().map(|(stage, last_call)| {
595            format!(
596                "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
597                stage.map_or("a run", speccoder_core::stage::Stage::name),
598                last_call
599                    .as_ref()
600                    .map(|c| format!(" ({c})"))
601                    .unwrap_or_default()
602            )
603        }))
604        .collect();
605
606    // A Convergence task is the converge model's claim that a gap exists;
607    // it changes no code until a failing test demonstrates the gap.
608    let demand_red = run_config.acceptance && format.is_convergence(&bundle.task.phase);
609    // Where this run started: an attempt that does not pass is undone to here.
610    // Where an attempt that does not pass is undone to: the tree the task
611    // started from. A resumed attempt (interrupted, or waiting for a
612    // person) left its own writes in the tree, so its start is the branch
613    // tip it began at — a task always starts from a clean tip.
614    let start = match &interrupted {
615        Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
616            Ok(tree) => worktree::Checkpoint(tree),
617            Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
618        },
619        None => match worktree::checkpoint(&wt_root, &tmp) {
620            Ok(c) => c,
621            Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
622        },
623    };
624
625    let began = std::time::Instant::now();
626    // 4b. Acceptance tests derived from the spec, written before the
627    //     attempt so they are frozen, and required to be red now.
628    let scenarios = format.scenarios(&snap, task);
629    let mut acceptance = if let Some(prepared) = interrupted
630        .as_ref()
631        .and_then(|m| m.acceptance.clone())
632        .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
633    {
634        // The interrupted attempt's tests, already in the worktree.
635        Some(prepared)
636    } else if run_config.acceptance && format.acceptance_testable(&bundle) {
637        stage(&*live, task, Stage::Red, StageState::Started, None, None);
638        Some(prepare_acceptance(&AcceptanceSetup {
639            rt: &rt,
640            plane: &plane,
641            project: &project,
642            worktree: &wt_root,
643            tmp: &tmp,
644            policy: &policy,
645            feature: &feature_name,
646            task,
647            bundle: bundle_value.clone(),
648            scenarios: &scenarios,
649            instructions: &instructions,
650            runner: &runner,
651        }))
652    } else {
653        None
654    };
655    // 4c. The held-out set: written after a red visible set, kept in
656    //     the state directory, never shown to the implementer.
657    let held_out_store = crate::held_out::dir(&s.plane_dir, &feature_name, task);
658    if let Some(a) = acceptance
659        .as_mut()
660        .filter(|a| a.report.get("held_out").is_none())
661    {
662        if !run_config.held_out {
663            a.report["held_out"] = serde_json::json!({ "state": "off" });
664        } else if a.argv.is_none() {
665            a.report["held_out"] =
666                serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
667        } else if !a.red {
668            a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
669        } else if scenarios.is_empty() {
670            a.report["held_out"] =
671                serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
672        } else {
673            let (held, sha) = prepare_held_out(
674                &AcceptanceSetup {
675                    rt: &rt,
676                    plane: &plane,
677                    project: &project,
678                    worktree: &wt_root,
679                    tmp: &tmp,
680                    policy: &policy,
681                    feature: &feature_name,
682                    task,
683                    bundle: bundle_value.clone(),
684                    scenarios: &scenarios,
685                    instructions: &instructions,
686                    runner: &runner,
687                },
688                a,
689                &scenarios,
690                &held_out_store,
691                story_checkpoint(
692                    snap.feature.as_ref().and_then(|f| f.tasks.as_ref()),
693                    task,
694                    &scenarios,
695                ),
696            );
697            a.report["held_out"] = held;
698            a.held_out = sha;
699        }
700    }
701    if run_config.held_out && !run_config.acceptance {
702        notices.push(
703            "[acceptance] held_out has no effect while generate = false: no held-out tests"
704                .to_owned(),
705        );
706    }
707    match &acceptance {
708        None => stage(
709            &*live,
710            task,
711            Stage::Red,
712            StageState::Skipped,
713            Some("no acceptance examples".to_owned()),
714            None,
715        ),
716        Some(a) if a.red => stage(
717            &*live,
718            task,
719            Stage::Red,
720            StageState::Passed,
721            a.red_class.map(class_word),
722            Some(began),
723        ),
724        Some(a) => stage(
725            &*live,
726            task,
727            Stage::Red,
728            StageState::Failed,
729            Some(a.red_class.map_or_else(
730                || "not demonstrated".to_owned(),
731                |c| format!("not demonstrated ({})", class_word(c)),
732            )),
733            Some(began),
734        ),
735    }
736
737    // The author call itself failed: without its tests the task could only
738    // be ticked on the regression checks, which would look like "the spec
739    // has no examples". A person decides; nothing was attempted.
740    if let Some(why) = acceptance
741        .as_ref()
742        .and_then(|a| a.generation_failed.clone())
743    {
744        let reason = format!(
745            "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"
746        );
747        let _ = worktree::restore(&wt_root, &tmp, &start);
748        let report = RunReport {
749            reason: Some(reason.clone()),
750            acceptance: acceptance.as_ref().map(|a| a.report.clone()),
751            set_aside,
752            notices: notices.clone(),
753            ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
754        };
755        live.event(&finished_event(
756            task,
757            &report,
758            "acceptance_failed",
759            false,
760            redact(&reason, secret),
761        ));
762        return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
763            .with_code("ACCEPTANCE-UNWRITTEN")
764            .with_data(CommandData::Run(Box::new(report)));
765    }
766
767    if demand_red && !acceptance.as_ref().is_some_and(|a| a.red) {
768        let why = undemonstrated_why(acceptance.as_ref());
769        let reason = format!(
770            "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
771        );
772        // The undemonstrated tests are kept under an attempt ref, not in the worktree.
773        let saved = attempts::save(
774            &wt_root,
775            &tmp,
776            &feature_name,
777            task,
778            &attempts::Record {
779                run: None,
780                outcome: "not_demonstrated",
781                reason: &reason,
782                pending: None,
783                usage: None,
784            },
785        )
786        .ok()
787        .map(|s| s.reference);
788        let _ = worktree::restore(&wt_root, &tmp, &start);
789        let report = RunReport {
790            reason: Some(reason.clone()),
791            acceptance: acceptance.as_ref().map(|a| a.report.clone()),
792            saved_attempt: saved,
793            set_aside,
794            notices: notices.clone(),
795            ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
796        };
797        live.event(&finished_event(
798            task,
799            &report,
800            "not_demonstrated",
801            false,
802            redact(&reason, secret),
803        ));
804        return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
805            .with_data(CommandData::Run(Box::new(report)));
806    }
807
808    // 5. Checkpoint, attempt, checkpoint — again after a failed verdict,
809    //    from the same checkpoint, with the verifier's digest.
810    let before = match &interrupted {
811        Some(m) => worktree::Checkpoint(m.before.clone()),
812        None => match worktree::checkpoint(&wt_root, &tmp) {
813            Ok(c) => c,
814            Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
815        },
816    };
817    let acceptance_saved = acceptance
818        .as_ref()
819        .and_then(|a| serde_json::to_value(a).ok());
820    // Tests that exist now are frozen for this attempt.
821    let frozen: Vec<String> = worktree::files(&wt_root, &before)
822        .unwrap_or_default()
823        .into_iter()
824        .filter(|p| tools::paths::is_test_path(p))
825        .collect();
826    let mut usage: Option<serde_json::Value> = None;
827    // Best-of-n: independent samples from the same checkpoint, each
828    // with its own retries; the best by evidence is kept.
829    let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
830    // Each judged attempt's held-out outcome, across samples and retries.
831    let mut held_out_log: Vec<serde_json::Value> = Vec::new();
832    let (mut report, attempt_status) = loop {
833        let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
834        let mut digest: Option<serde_json::Value> = None;
835        // A resumed attempt continues at its own number: starting over at 1
836        // would admit a fresh run and repeat the model's work.
837        let mut attempt_no = interrupted
838            .as_ref()
839            .filter(|m| m.sample == samples.len())
840            .map_or(1, |m| m.attempt.max(1));
841        let (mut report, attempt_status) = loop {
842            // In place: what git ignored at the session's start.
843            let hidden: Vec<String> = options
844                .session
845                .as_ref()
846                .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
847                .unwrap_or_default();
848            // This run's grants as they stand now: journaled with the input.
849            let run_approved: Vec<crate::tools::policy::Grant> = options
850                .run_grants
851                .lock()
852                .map(|g| g.clone())
853                .unwrap_or_default();
854            // Everything the attempt's decisions depend on is in its journaled input.
855            let input = substrate::attempt::input(
856                bundle_value.clone(),
857                &substrate::attempt::Settings {
858                    checks: &check_argvs,
859                    allow: &run_config.allow,
860                    max_turns: run_config.max_turns,
861                    max_tool_calls: run_config.max_tool_calls,
862                    frozen: &frozen,
863                    approved: &decisions.approved,
864                    rejected: &decisions.rejected,
865                    run_approved: &run_approved,
866                    nudges: &decisions.nudges,
867                    mcp: &connected.tools,
868                    root: &wt_root_canonical,
869                    frozen_additions: run_config.frozen_additions,
870                    // Lanes stop (their worktree is merged or dropped with
871                    // the group); every other attempt waits for a person.
872                    asks: if options.lane.is_some() {
873                        "stop"
874                    } else {
875                        "tasks"
876                    },
877                    wait_secs: run_config.approval_wait.as_secs(),
878                    network_allow: &sandbox_config.network_allow,
879                    in_place: options.session.is_some(),
880                    hidden: &hidden,
881                },
882                digest.as_ref(),
883                attempt_instructions,
884            );
885            let attempt_began = std::time::Instant::now();
886            let attempt_detail = if run_config.best_of > 1 {
887                format!(
888                    "{attempt_no}/{} · sample {}/{}",
889                    run_config.max_attempts,
890                    samples.len() + 1,
891                    run_config.best_of
892                )
893            } else {
894                format!("{attempt_no}/{}", run_config.max_attempts)
895            };
896            stage(
897                &*live,
898                task,
899                Stage::Attempt,
900                StageState::Started,
901                Some(attempt_detail.clone()),
902                None,
903            );
904            // Judge inputs as the attempt finds them, git-ignored ones
905            // included (an agent loads `.claude/settings.local.json` too).
906            let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
907            // The attempt itself: speccoder's governed loop, or an external
908            // agent whose result is judged from the diff alike.
909            let (run_id, succeeded, mut output) = if let Some(exec) = &executor {
910                let allow: Vec<Vec<String>> = check_argvs
911                    .iter()
912                    .chain(&run_config.allow)
913                    .cloned()
914                    .collect();
915                let prompt = external::prompt_in(format.wording(), &bundle_value, digest.as_ref());
916                let withheld = acceptance
917                    .as_ref()
918                    .and_then(|a| a.held_out.as_ref())
919                    .map(|_| held_out_store.as_path());
920                if withheld.is_some()
921                    && !notices
922                        .iter()
923                        .any(|n| n.starts_with("held-out set withheld"))
924                {
925                    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());
926                }
927                let o = external::run(exec, &wt_root, &prompt, &allow, &tmp, withheld);
928                (o.run_id, true, o.output)
929            } else {
930                // The same key as an interrupted attempt resumes it.
931                let key = match &interrupted {
932                    Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
933                        m.key.clone()
934                    }
935                    _ => resume::new_key(&feature_name, task),
936                };
937                let _ = resume::save(
938                    &marker_path,
939                    &resume::Marker {
940                        key: key.clone(),
941                        before: before.0.clone(),
942                        head: head_now.clone(),
943                        attempt: attempt_no,
944                        sample: samples.len(),
945                        acceptance: acceptance_saved.clone(),
946                    },
947                );
948                marker_guard.arm();
949                let outcome = match rt.block_on(plane.attempt_keyed(
950                    &feature_name,
951                    task,
952                    input,
953                    &key,
954                )) {
955                    Ok((o, resumed)) => {
956                        if resumed {
957                            notices.push(format!(
958                                    "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
959                                    o.run_id
960                                ));
961                        }
962                        o
963                    }
964                    Err(e) => return fail(CommandStatus::RunFailed, e.0),
965                };
966                // A call waits for a person. With one here they
967                // decide now, recorded with who they are, and the attempt
968                // continues; "later" or nobody leaves it waiting.
969                let context = crate::approvals::PresentContext {
970                    root: Some(wt_root.clone()),
971                    ..present.clone()
972                };
973                let (outcome, waiting) = match rt.block_on(plane.decide_inline(
974                    outcome,
975                    approver.as_deref(),
976                    &context,
977                )) {
978                    Ok(x) => x,
979                    Err(e) => return fail(CommandStatus::RunFailed, e.0),
980                };
981                if let Some(task_row) = waiting {
982                    // The attempt waits as it is: its tree and its resume
983                    // marker stay, so the next run continues it.
984                    marker_guard.keep();
985                    let action = &task_row.justification.proposed_action;
986                    let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
987                    report.run_id = outcome.run_id.to_string();
988                    report.notices = notices;
989                    report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
990                    // The waiting call itself, as the stop recorded it before.
991                    report.tool_calls.push(serde_json::json!({
992                        "tool": action["tool"],
993                        "arguments": action["arguments"],
994                        "decision": "approval",
995                        "reason": action["request"]["reason"],
996                        "waiting": true,
997                        "call": task_row.id.to_hex(),
998                    }));
999                    report.pending = Some(serde_json::json!({
1000                        "tool": action["tool"],
1001                        "arguments": action["arguments"],
1002                        "reason": action["request"]["reason"],
1003                        "call": task_row.id.to_hex(),
1004                        "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
1005                        "waiting": true,
1006                    }));
1007                    report.reason = Some(format!(
1008                        "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
1009                        task_row.justification.summary.peek()
1010                    ));
1011                    let (status, message) = status_and_message(&report, "needs_approval", false);
1012                    live.event(&finished_event(
1013                        task,
1014                        &report,
1015                        "needs_approval",
1016                        false,
1017                        redact(&message, secret),
1018                    ));
1019                    return CommandResult::new("run", status, &redact(&message, secret))
1020                        .with_data(CommandData::Run(Box::new(report)));
1021                }
1022                let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
1023                let mut output = outcome
1024                    .output
1025                    .as_ref()
1026                    .map(|o| o.peek().clone())
1027                    .unwrap_or(serde_json::Value::Null);
1028                if !succeeded && output["reason"].as_str().is_none() {
1029                    if !output.is_object() {
1030                        output = serde_json::json!({});
1031                    }
1032                    output["reason"] =
1033                        serde_json::Value::String(redact(&format!("{:?}", outcome.status), secret));
1034                }
1035                (outcome.run_id.to_string(), succeeded, output)
1036            };
1037            // The configured formatter, run by speccoder (not the model) on the
1038            // attempt's result; its changes are part of the attempt and audited.
1039            if let Some(argv) = run_config.format.as_ref().filter(|_| {
1040                succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
1041            }) {
1042                use verify::process::CommandRunner as _;
1043                let ran = runner.run(&verify::process::Invocation {
1044                    argv: argv.clone(),
1045                    cwd: project.clone(),
1046                    env: verify::process::scrubbed_env(std::env::vars()),
1047                    timeout: std::time::Duration::from_secs(300),
1048                    output_cap: verify::process::OUTPUT_CAP,
1049                });
1050                if ran.exit_code != Some(0) {
1051                    notices.push(format!(
1052                        "the formatter (`{}`) did not succeed",
1053                        argv.join(" ")
1054                    ));
1055                }
1056            }
1057            let after = match worktree::checkpoint(&wt_root, &tmp) {
1058                Ok(c) => c,
1059                Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1060            };
1061            let raw_changes = worktree::diff(&wt_root, &before, &after).unwrap_or_default();
1062            // A frozen test a person approved writing (for the task, or inline)
1063            // is not a violation; the audit still flags removed tests and skip
1064            // markers in it — approval permits editing a test, not weakening it.
1065            let approved_writes = approved_writes(&decisions.approved, &output["tool_calls"]);
1066            let findings = attempt_findings(&Audited {
1067                wt_root: &wt_root,
1068                policy: &policy,
1069                judged_before: &judged_before,
1070                raw_changes: &raw_changes,
1071                patch: &worktree::patch(&wt_root, &before, &after).unwrap_or_default(),
1072                frozen: &frozen,
1073                frozen_additions: run_config.frozen_additions,
1074                approved_writes: &approved_writes,
1075                command_drift: &output["integrity"],
1076            });
1077            let changes = changes_of(raw_changes);
1078
1079            // 6. The attempt's own account.
1080            // A resumed attempt that was killed while a command ran: nobody
1081            // can know whether the command finished, so agentplane did not
1082            // run it again. The attempt is saved and undone below.
1083            let quarantined = !succeeded
1084                && output["reason"]
1085                    .as_str()
1086                    .is_some_and(|r| r.starts_with("Quarantined("));
1087            if quarantined {
1088                output["reason"] = serde_json::Value::String(
1089                    "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(),
1090                );
1091            }
1092            let mut attempt_status = if succeeded {
1093                output["status"].as_str().unwrap_or("failed").to_owned()
1094            } else if quarantined {
1095                "interrupted".to_owned()
1096            } else {
1097                "failed".to_owned()
1098            };
1099            let mut report = RunReport {
1100                run_id: run_id.clone(),
1101                model_report: output.get("report").cloned(),
1102                reason: output["reason"].as_str().map(str::to_owned),
1103                pending: output.get("pending").cloned(),
1104                tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
1105                changes,
1106                usage: output.get("usage").cloned(),
1107                integrity: findings.clone(),
1108                acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1109                set_aside: set_aside.clone(),
1110                notices: notices.clone(),
1111                ..bare_report(&feature_name, task, &wt_path, &attempt_status)
1112            };
1113            record_inline_approvals(
1114                &s.plane_dir,
1115                &feature_name,
1116                task,
1117                &report.tool_calls,
1118                &options.run_grants,
1119            );
1120            // A person approving an MCP call reviewed the tool as it is now.
1121            for c in report
1122                .tool_calls
1123                .iter()
1124                .filter(|c| c["approved_by"] == "person")
1125            {
1126                if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
1127                    mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
1128                }
1129            }
1130
1131            stage(
1132                &*live,
1133                task,
1134                Stage::Attempt,
1135                if attempt_status == "completed" {
1136                    StageState::Passed
1137                } else {
1138                    StageState::Failed
1139                },
1140                Some(if attempt_status == "completed" {
1141                    attempt_detail
1142                } else {
1143                    format!("{attempt_detail} {attempt_status}")
1144                }),
1145                Some(attempt_began),
1146            );
1147            // A command changed a judge input: the attempt is over and fails,
1148            // not retried (integrity findings never are).
1149            if attempt_status == "integrity" {
1150                report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1151                    reasons: findings.clone(),
1152                });
1153            }
1154            // 7. The verdict: only the verifier decides.
1155            let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
1156            if judged {
1157                let began = std::time::Instant::now();
1158                stage(&*live, task, Stage::Verify, StageState::Started, None, None);
1159                if let Err(e) = judge_checks(
1160                    &project,
1161                    &runner,
1162                    &baseline,
1163                    acceptance.as_ref(),
1164                    &findings,
1165                    &mut report,
1166                ) {
1167                    stage(
1168                        &*live,
1169                        task,
1170                        Stage::Verify,
1171                        StageState::Failed,
1172                        None,
1173                        Some(began),
1174                    );
1175                    return fail(CommandStatus::ConfigError, format!("verification: {e}"));
1176                }
1177                let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
1178                stage_with_tail(
1179                    &*live,
1180                    task,
1181                    Stage::Verify,
1182                    if checks_held {
1183                        StageState::Passed
1184                    } else {
1185                        StageState::Failed
1186                    },
1187                    None,
1188                    Some(began),
1189                    report
1190                        .verification
1191                        .as_ref()
1192                        .map(failing_tail)
1193                        .unwrap_or_default(),
1194                );
1195            }
1196            if judged || attempt_status == "integrity" {
1197                stage(
1198                    &*live,
1199                    task,
1200                    Stage::Audit,
1201                    if findings.is_empty() {
1202                        StageState::Passed
1203                    } else {
1204                        StageState::Failed
1205                    },
1206                    findings
1207                        .first()
1208                        .map(|f| f.trim_start_matches("integrity: ").to_owned()),
1209                    None,
1210                );
1211            }
1212
1213            // 7.5 The held-out tests, on an attempt that passed visibly with a
1214            //     clean audit: a failure fails the verdict naming the scenarios
1215            //     only; tampering or a leak stops for a person.
1216            let visibly_passed = judged
1217                && findings.is_empty()
1218                && attempt_status == "completed"
1219                && matches!(
1220                    report.verdict,
1221                    Some(
1222                        speccoder_core::verify::Verdict::Verified
1223                            | speccoder_core::verify::Verdict::RegressionOnly
1224                    )
1225                );
1226            if let Some(sha) = acceptance
1227                .as_ref()
1228                .and_then(|a| a.held_out.clone())
1229                .filter(|_| visibly_passed)
1230            {
1231                let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1232                let mut known = format!(
1233                    "{}\n{}\n{}",
1234                    bundle_value["story"].as_str().unwrap_or_default(),
1235                    bundle_value["plan"].as_str().unwrap_or_default(),
1236                    bundle_value["constitution"].as_str().unwrap_or_default()
1237                );
1238                for p in report
1239                    .acceptance
1240                    .as_ref()
1241                    .and_then(|a| a["files"].as_array().cloned())
1242                    .unwrap_or_default()
1243                {
1244                    if let Some(p) = p.as_str() {
1245                        known.push_str(
1246                            &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
1247                        );
1248                    }
1249                }
1250                match judge_held_out(
1251                    &held_out_store,
1252                    &sha,
1253                    &scenarios,
1254                    &known,
1255                    &wt_root,
1256                    &project,
1257                    &tmp,
1258                    &before,
1259                    &patch,
1260                    &runner,
1261                    &policy,
1262                ) {
1263                    Ok(j) => {
1264                        let failed_ids: Vec<String> =
1265                            j.failed.iter().map(|s| s.id.clone()).collect();
1266                        held_out_log.push(serde_json::json!({
1267                            "attempt": held_out_log.len() + 1,
1268                            "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
1269                            "scenarios": failed_ids,
1270                        }));
1271                        if let Some(v) = report.verdict.take() {
1272                            let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
1273                            if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
1274                                && !j.failed.is_empty()
1275                                && let Some(last) = reasons.last_mut()
1276                            {
1277                                *last = speccoder_core::retry::held_out_reason(&j.failed);
1278                            }
1279                            report.verdict = Some(v);
1280                        }
1281                    }
1282                    Err((outcome, why)) => {
1283                        held_out_log.push(serde_json::json!({
1284                            "attempt": held_out_log.len() + 1,
1285                            "outcome": outcome,
1286                        }));
1287                        report.verdict = None;
1288                        report.reason = Some(why);
1289                        attempt_status = "held_out".to_owned();
1290                    }
1291                }
1292            }
1293            if let Some(h) = report
1294                .acceptance
1295                .as_mut()
1296                .and_then(|a| a.get_mut("held_out"))
1297                .filter(|h| h["state"] == "on")
1298            {
1299                let failed = held_out_log
1300                    .iter()
1301                    .filter(|o| o["outcome"] == "failed")
1302                    .count();
1303                h["attempts"] = serde_json::json!(held_out_log);
1304                h["gap"] = serde_json::json!(failed > 0);
1305            }
1306
1307            // 7a. Mutation testing of a passing attempt's changes.
1308            let passing = attempt_status == "completed"
1309                && matches!(
1310                    report.verdict,
1311                    Some(
1312                        speccoder_core::verify::Verdict::Verified
1313                            | speccoder_core::verify::Verdict::RegressionOnly
1314                    )
1315                );
1316            if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
1317                stage(
1318                    &*live,
1319                    task,
1320                    Stage::Mutation,
1321                    StageState::Skipped,
1322                    Some(if mutation_config.is_none() {
1323                        "not configured".to_owned()
1324                    } else {
1325                        "suite = false: for acceptance strength only".to_owned()
1326                    }),
1327                    None,
1328                );
1329            }
1330            if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
1331                let began = std::time::Instant::now();
1332                stage(
1333                    &*live,
1334                    task,
1335                    Stage::Mutation,
1336                    StageState::Started,
1337                    None,
1338                    None,
1339                );
1340                let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1341                let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
1342                if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
1343                    if m.mode == mutation::Mode::Require
1344                        && let Some(reason) = mutation::failure(&result)
1345                    {
1346                        report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1347                            reasons: vec![reason],
1348                        });
1349                    }
1350                    report.mutation = Some(result);
1351                }
1352                let held = !matches!(
1353                    report.verdict,
1354                    Some(speccoder_core::verify::Verdict::Failed { .. })
1355                );
1356                stage(
1357                    &*live,
1358                    task,
1359                    Stage::Mutation,
1360                    if held {
1361                        StageState::Passed
1362                    } else {
1363                        StageState::Failed
1364                    },
1365                    report
1366                        .mutation
1367                        .as_ref()
1368                        .and_then(crate::mutation::trailer)
1369                        .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
1370                    Some(began),
1371                );
1372            }
1373
1374            usage = add_usage(usage.as_ref(), report.usage.as_ref());
1375            // retry a completed attempt the verifier failed, when it can help.
1376            let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
1377                (report.verdict.clone(), report.verification.as_ref())
1378            else {
1379                break (report, attempt_status);
1380            };
1381            if attempt_status != "completed" {
1382                break (report, attempt_status);
1383            }
1384            let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
1385            let d = speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
1386            let fingerprint = speccoder_core::retry::fingerprint(&d);
1387            let stop = retry_stop(
1388                &findings,
1389                verification,
1390                earlier.last().map(|e| e.fingerprint.as_str()),
1391                &fingerprint,
1392                attempt_no,
1393                run_config.max_attempts,
1394            );
1395            if let Some(stop) = stop {
1396                report.retry_stop = Some(stop);
1397                break (report, attempt_status);
1398            }
1399            if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
1400                report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
1401                break (report, attempt_status);
1402            }
1403            earlier.push(speccoder_core::EarlierAttempt {
1404                run_id: report.run_id.clone(),
1405                attempt_status: attempt_status.clone(),
1406                verdict: report.verdict.clone(),
1407                fingerprint,
1408                digest: d.clone(),
1409                usage: report.usage.clone(),
1410            });
1411            digest = serde_json::to_value(&d).ok();
1412            attempt_no += 1;
1413        };
1414        report.earlier = earlier;
1415        if run_config.best_of <= 1 {
1416            break (report, attempt_status);
1417        }
1418        let end = match worktree::checkpoint(&wt_root, &tmp) {
1419            Ok(c) => c,
1420            Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1421        };
1422        let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
1423        let summary = sample_summary(&report, &attempt_status, &patch);
1424        samples.push((report, attempt_status, end, summary));
1425        if samples.len() >= run_config.best_of as usize {
1426            let best = best_sample(&samples);
1427            let summaries: Vec<serde_json::Value> = samples
1428                .iter()
1429                .enumerate()
1430                .map(|(i, s)| {
1431                    let mut v = s.3.clone();
1432                    v["kept"] = serde_json::json!(i == best);
1433                    v
1434                })
1435                .collect();
1436            let (mut chosen, status, end, _) = samples.swap_remove(best);
1437            if best != samples.len() {
1438                // Not the sample the worktree holds: put the chosen one back.
1439                if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
1440                    return fail(CommandStatus::GitPrecondition, e.0);
1441                }
1442            }
1443            chosen.samples = summaries;
1444            break (chosen, status);
1445        }
1446        if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
1447            return fail(CommandStatus::GitPrecondition, e.0);
1448        }
1449    };
1450    report.usage = usage;
1451    // Scope: what the attempt changed beyond the paths its task names.
1452    report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
1453
1454    // 7c. Acceptance strength: the set a verified verdict
1455    //     rests on, run against wrong and permitted candidates. Judge-side;
1456    //     the implementer never sees a variant, a survivor or a finding.
1457    let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
1458    let mut attempt_status = attempt_status;
1459    let verified = attempt_status == "completed"
1460        && matches!(
1461            report.verdict,
1462            Some(speccoder_core::verify::Verdict::Verified)
1463        );
1464    let visible_argv = acceptance
1465        .as_ref()
1466        .filter(|a| a.red)
1467        .and_then(|a| a.argv.clone());
1468    if verified && !run_config.strength.enabled {
1469        stage(
1470            &*live,
1471            task,
1472            Stage::Strength,
1473            StageState::Skipped,
1474            Some("strength = false".to_owned()),
1475            None,
1476        );
1477    }
1478    if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
1479        use speccoder_core::strength::{Gate, gate, over_strict};
1480        let began = std::time::Instant::now();
1481        stage(
1482            &*live,
1483            task,
1484            Stage::Strength,
1485            StageState::Started,
1486            None,
1487            None,
1488        );
1489        let fin = worktree::checkpoint(&wt_root, &tmp);
1490        let patch = fin
1491            .as_ref()
1492            .ok()
1493            .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
1494            .unwrap_or_default();
1495        let mut notes = Vec::new();
1496        let held: Vec<(crate::held_out::HeldOutFile, String)> =
1497            match acceptance.as_ref().and_then(|a| a.held_out.clone()) {
1498                None => Vec::new(),
1499                Some(sha) => match crate::held_out::load_verified(&held_out_store, &sha) {
1500                    Ok((manifest, contents)) => manifest
1501                        .files
1502                        .into_iter()
1503                        .zip(contents.into_iter().map(|(_, c)| c))
1504                        .collect(),
1505                    Err(t) => {
1506                        notes.push(format!("held-out set not overlaid: {}", t.0));
1507                        Vec::new()
1508                    }
1509                },
1510            };
1511        let rulings = crate::strength::rulings(&strength_dir);
1512        let mut commands = vec![argv];
1513        let measure = |commands: &[Vec<String>]| {
1514            crate::strength::measure(
1515                &crate::strength::Setup {
1516                    wt_root: &wt_root,
1517                    project: &project,
1518                    tmp: &tmp,
1519                    runner: &runner,
1520                    policy: &policy,
1521                    acceptance: commands,
1522                    held_out: &held,
1523                    mutation: mutation_config.as_ref(),
1524                    patch: &patch,
1525                    config: &run_config.strength,
1526                    keep: &strength_dir,
1527                    rulings: &rulings,
1528                },
1529                |changed| {
1530                    // Source files only: the author never sees a test.
1531                    let input = substrate::near_miss::input(
1532                        &bundle_value,
1533                        changed,
1534                        &crate::mutation::source_patch(&patch),
1535                        run_config.strength.near_miss,
1536                        run_config.strength.permitted,
1537                    );
1538                    match rt.block_on(plane.near_miss(&feature_name, task, input)) {
1539                        Ok(o) => {
1540                            let out = o
1541                                .output
1542                                .as_ref()
1543                                .map(|v| v.peek().clone())
1544                                .unwrap_or_default();
1545                            let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
1546                                && !out["answer"].is_null();
1547                            crate::strength::Authored {
1548                                answer: if ok {
1549                                    Ok(out["answer"].clone())
1550                                } else {
1551                                    Err(format!(
1552                                        "the variant author did not answer ({})",
1553                                        o.status.as_str()
1554                                    ))
1555                                },
1556                                run_id: Some(o.run_id.to_string()),
1557                                usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
1558                            }
1559                        }
1560                        Err(e) => crate::strength::Authored {
1561                            answer: Err(redact(&e.0, secret)),
1562                            run_id: None,
1563                            usage: None,
1564                        },
1565                    }
1566                },
1567            )
1568        };
1569        let mut measured = measure(&commands);
1570        // The gate: one regeneration, told what got through; then it holds
1571        // or the set is too weak for a tick.
1572        let min = run_config.strength.min;
1573        if let (Ok(a), Ok(fin)) = (&measured, &fin)
1574            && gate(min, &a.combined, false) == Gate::Failed
1575            && over_strict(&a.permitted).is_empty()
1576        {
1577            let visible_tests: Vec<(String, String)> = report
1578                .acceptance
1579                .as_ref()
1580                .and_then(|a| a["files"].as_array().cloned())
1581                .unwrap_or_default()
1582                .iter()
1583                .filter_map(|p| p.as_str())
1584                .map(|p| {
1585                    (
1586                        p.to_owned(),
1587                        std::fs::read_to_string(project.join(p)).unwrap_or_default(),
1588                    )
1589                })
1590                .collect();
1591            let setup = AcceptanceSetup {
1592                rt: &rt,
1593                plane: &plane,
1594                project: &project,
1595                worktree: &wt_root,
1596                tmp: &tmp,
1597                policy: &policy,
1598                feature: &feature_name,
1599                task,
1600                bundle: bundle_value.clone(),
1601                scenarios: &scenarios,
1602                instructions: &instructions,
1603                runner: &runner,
1604            };
1605            let weak = crate::strength::weak(a);
1606            match strengthen(&setup, &visible_tests, weak, &before, fin) {
1607                Ok(added) => {
1608                    if let Some(acc) = report.acceptance.as_mut() {
1609                        if let Some(files) = acc["files"].as_array_mut() {
1610                            files.extend(added.files.iter().map(|f| serde_json::json!(f)));
1611                        }
1612                        acc["strengthened"] = serde_json::json!({
1613                            "files": added.files,
1614                            "argv": added.argv,
1615                            "run_id": added.report["run_id"],
1616                            "usage": added.report["usage"],
1617                        });
1618                    }
1619                    report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
1620                    commands.push(added.argv);
1621                    let mut again = measure(&commands);
1622                    if let Ok(b) = again.as_mut() {
1623                        b.gate = gate(min, &b.combined, true);
1624                        b.notes.insert(
1625                            0,
1626                            "strengthened once with added acceptance tests".to_owned(),
1627                        );
1628                    }
1629                    measured = again;
1630                }
1631                Err(why) => {
1632                    if let Ok(a) = measured.as_mut() {
1633                        a.notes
1634                            .insert(0, format!("could not strengthen the set: {why}"));
1635                    }
1636                }
1637            }
1638        }
1639        match measured {
1640            Ok(mut a) => {
1641                if a.gate == Gate::Off {
1642                    a.gate = gate(min, &a.combined, false);
1643                }
1644                a.notes.extend(notes);
1645                let findings = over_strict(&a.permitted);
1646                if !findings.is_empty() {
1647                    attempt_status = "over_strict".to_owned();
1648                    report.reason = Some(format!(
1649                        "an acceptance test rejects an implementation the spec appears to allow: {}",
1650                        findings
1651                            .iter()
1652                            .map(|p| format!("{} ({})", p.scenario, p.rationale))
1653                            .collect::<Vec<_>>()
1654                            .join("; ")
1655                    ));
1656                } else if a.gate == Gate::Failed {
1657                    attempt_status = "acceptance_weak".to_owned();
1658                    report.reason = Some(format!(
1659                        "acceptance set too weak: strength {} is below min_strength {}{}",
1660                        speccoder_core::strength::summary(&a.combined, None),
1661                        min.unwrap_or_default(),
1662                        a.notes
1663                            .first()
1664                            .map(|n| format!(" ({n})"))
1665                            .unwrap_or_default()
1666                    ));
1667                } else {
1668                    crate::strength::release(&strength_dir);
1669                }
1670                // The variant author's tokens count with the task's.
1671                report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
1672                report.strength = Some(a);
1673            }
1674            Err(e) => {
1675                report.integrity.push(e.clone());
1676                report.reason = Some(e);
1677                report.verdict = None;
1678                attempt_status = "integrity".to_owned();
1679            }
1680        }
1681        report.attempt_status.clone_from(&attempt_status);
1682        let held_up = attempt_status == "completed";
1683        stage(
1684            &*live,
1685            task,
1686            Stage::Strength,
1687            if held_up {
1688                StageState::Passed
1689            } else {
1690                StageState::Failed
1691            },
1692            report.strength.as_ref().map(|a| {
1693                speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
1694            }),
1695            Some(began),
1696        );
1697    }
1698
1699    // 7b. The gaming monitor: reasoning-blind, judge-side, add-only.
1700    //     It sees the task, the diff and the tool calls — not the model's
1701    //     account — and a suspicion only stops the tick for a person.
1702    let verdict_passed = attempt_status == "completed"
1703        && matches!(
1704            report.verdict,
1705            Some(
1706                speccoder_core::verify::Verdict::Verified
1707                    | speccoder_core::verify::Verdict::RegressionOnly
1708            )
1709        );
1710    let mut suspected = false;
1711    if !run_config.monitor && verdict_passed {
1712        stage(
1713            &*live,
1714            task,
1715            Stage::Monitor,
1716            StageState::Skipped,
1717            Some("not enabled".to_owned()),
1718            None,
1719        );
1720    }
1721    if run_config.monitor && verdict_passed {
1722        let began = std::time::Instant::now();
1723        stage(
1724            &*live,
1725            task,
1726            Stage::Monitor,
1727            StageState::Started,
1728            None,
1729            None,
1730        );
1731        let patch = worktree::checkpoint(&wt_root, &tmp)
1732            .and_then(|now| worktree::patch(&wt_root, &start, &now))
1733            .unwrap_or_default();
1734        let calls: Vec<serde_json::Value> = report
1735            .tool_calls
1736            .iter()
1737            .map(|c| {
1738                let tool = c["tool"].as_str().unwrap_or_default();
1739                serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
1740            })
1741            .collect();
1742        let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
1743        let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
1744        report.monitor = Some(monitor);
1745        suspected = suspicion;
1746        stage(
1747            &*live,
1748            task,
1749            Stage::Monitor,
1750            if suspected {
1751                StageState::Failed
1752            } else {
1753                StageState::Passed
1754            },
1755            suspected.then(|| "suspects gaming".to_owned()),
1756            Some(began),
1757        );
1758    }
1759
1760    // 8. A passed task is ticked by the engine and committed. In place, the
1761    //    spec and the checkout must still be as the run found them.
1762    let in_place_stop = options
1763        .session
1764        .as_ref()
1765        .filter(|_| verdict_passed && !suspected)
1766        .and_then(|s| {
1767            s.lock()
1768                .ok()
1769                .and_then(|s| s.check_before_tick(&policy).err())
1770        });
1771    if let Some(e) = &in_place_stop {
1772        report.reason = Some(e.line());
1773        report.verdict = None;
1774        // The saved attempt names the cause: `intent_changed`, `judge_changed`, …
1775        attempt_status = e.code.to_lowercase().replace('-', "_");
1776        report.attempt_status.clone_from(&attempt_status);
1777    }
1778    let passed = verdict_passed && !suspected && in_place_stop.is_none();
1779    if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
1780        let began = std::time::Instant::now();
1781        stage(&*live, task, Stage::Commit, StageState::Started, None, None);
1782        let message = tick_message(&TickFacts {
1783            feature: &feature_name,
1784            task,
1785            title: &t.title,
1786            report: &report,
1787            red: acceptance
1788                .as_ref()
1789                .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
1790            checklists_overridden,
1791        });
1792        match &options.session {
1793            // Worktree: tick, then commit the tick with the evidence.
1794            None => {
1795                match format.tick(file, task, &t.line_hash) {
1796                    Ok(()) => report.ticked = true,
1797                    Err(e) => report.integrity.push(format!("tick refused: {e}")),
1798                }
1799                if report.ticked && run_config.commit_per_task {
1800                    match commit_attempt(&wt_root, &message) {
1801                        Ok(id) => report.commit = Some(id),
1802                        Err(e) => report.integrity.push(format!("commit failed: {e}")),
1803                    }
1804                }
1805            }
1806            // In place: evidence first, then the tick; a git-ignored
1807            // tasks.md is never in the commit, so its tick is local.
1808            Some(session) => {
1809                let digest =
1810                    crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
1811                if let Ok(mut s) = session.lock() {
1812                    let done = s.commit_and_tick(
1813                        &plane_dir,
1814                        file,
1815                        &t.line_hash,
1816                        &message,
1817                        &digest,
1818                        || format.tick(file, task, &t.line_hash),
1819                    );
1820                    report.ticked = done.ticked;
1821                    report.commit = done.commit;
1822                    report.integrity.extend(done.problems);
1823                }
1824            }
1825        }
1826        report.instructions = attempt_instructions
1827            .iter()
1828            .map(|i| speccoder_core::InstructionFile {
1829                path: i.path.clone(),
1830                sha256: i.sha256.clone(),
1831                bytes: i.bytes,
1832                cut: i.cut,
1833            })
1834            .collect();
1835        // Signed, portable evidence of the tick: never undoes it.
1836        if let Some(commit) = report
1837            .commit
1838            .clone()
1839            .filter(|_| report.ticked && run_config.attest.enabled)
1840        {
1841            let verdict = report
1842                .verdict
1843                .as_ref()
1844                .map(super::outcome::verdict_word)
1845                .unwrap_or_default();
1846            let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
1847            let feature_dir = snap
1848                .feature
1849                .as_ref()
1850                .map(|f| f.feature.dir.clone())
1851                .unwrap_or_default();
1852            let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
1853            let tick = crate::attest::Tick {
1854                repo: &wt_root,
1855                config: &config_text,
1856                attest: &run_config.attest,
1857                format: &format!("{:?}", format.kind()).to_lowercase(),
1858                feature: &feature_name,
1859                feature_dir: &feature_dir,
1860                task,
1861                title: &t.title,
1862                line_hash: &t.line_hash,
1863                section: t.story.as_deref(),
1864                section_text: bundle_value["story"].as_str().unwrap_or_default(),
1865                requirements: &requirements,
1866                commit: &commit,
1867                verdict: &verdict,
1868                sandbox: serde_json::json!({
1869                    "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
1870                    "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
1871                    "network": format!("{:?}", sandbox_config.network).to_lowercase(),
1872                }),
1873                executor: serde_json::json!({
1874                    "kind": executor.as_ref().map_or_else(|| "native".to_owned(), |e| match &e.kind {
1875                        external::Kind::Claude => "claude".to_owned(),
1876                        external::Kind::Codex => "codex".to_owned(),
1877                        external::Kind::Command(_) => "command".to_owned(),
1878                    }),
1879                }),
1880                model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
1881            };
1882            report.attestation = Some(crate::attest::record_tick(&tick, &report));
1883        }
1884        let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
1885        stage(
1886            &*live,
1887            task,
1888            Stage::Commit,
1889            if committed {
1890                StageState::Passed
1891            } else {
1892                StageState::Failed
1893            },
1894            report.commit.as_ref().map(|c| c.chars().take(7).collect()),
1895            Some(began),
1896        );
1897    }
1898
1899    // 9. An attempt whose tests reject what the spec appears to allow is
1900    //    held for a person, as a waiting call is: its tree and resume marker
1901    //    stay, and after a ruling the next run judges it again from the
1902    //    journal, repeating no model call.
1903    if attempt_status == "over_strict" {
1904        let findings = report
1905            .strength
1906            .as_ref()
1907            .map(|a| speccoder_core::strength::over_strict(&a.permitted))
1908            .unwrap_or_default();
1909        if let Err(e) = crate::strength::hold(&strength_dir, &findings) {
1910            report
1911                .integrity
1912                .push(format!("holding the attempt failed: {e}"));
1913        }
1914        marker_guard.keep();
1915    }
1916    // 9. An attempt that did not pass is saved under an attempt ref and
1917    //    undone: the next attempt starts where this one did.
1918    if !passed && attempt_status != "over_strict" {
1919        let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
1920        let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
1921        let reason = redact(&reason, secret);
1922        let record = attempts::Record {
1923            run,
1924            outcome: &outcome,
1925            reason: &reason,
1926            pending: report.pending.as_ref(),
1927            usage: report.usage.as_ref(),
1928        };
1929        match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
1930            .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
1931        {
1932            Ok(saved) => report.saved_attempt = Some(saved.reference),
1933            Err(e) => report
1934                .integrity
1935                .push(format!("saving the attempt failed: {}", e.0)),
1936        }
1937    }
1938
1939    let (status, message) = status_and_message(&report, &attempt_status, suspected);
1940    live.event(&finished_event(
1941        task,
1942        &report,
1943        &attempt_status,
1944        suspected,
1945        redact(&message, secret),
1946    ));
1947    CommandResult::new("run", status, &redact(&message, secret))
1948        .with_data(CommandData::Run(Box::new(report)))
1949}
1950
1951/// Why a failing baseline check is broken rather than failing tests: it
1952/// could not start, or it failed without any test result to show for it.
1953/// `None`: it passed, or its tests ran and some failed.
1954#[must_use]
1955pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
1956    use speccoder_core::verify::CheckStatus;
1957    if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
1958        return None;
1959    }
1960    if let Some(note) = &c.note {
1961        return Some(note.clone());
1962    }
1963    if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
1964        return None;
1965    }
1966    let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
1967    // Only what is unambiguous: a command that cannot start, a build that
1968    // fails, or a test runner that ran no test. A script or `grep` that
1969    // fails until the task is done is a failing check, not a broken one.
1970    let runner = c
1971        .argv
1972        .first()
1973        .map(|a| a.rsplit('/').next().unwrap_or(a))
1974        .is_some_and(|a| {
1975            matches!(
1976                a,
1977                "cargo"
1978                    | "pytest"
1979                    | "python"
1980                    | "python3"
1981                    | "uv"
1982                    | "npm"
1983                    | "npx"
1984                    | "pnpm"
1985                    | "yarn"
1986                    | "bun"
1987                    | "go"
1988                    | "jest"
1989                    | "vitest"
1990                    | "mvn"
1991                    | "gradle"
1992                    | "dotnet"
1993                    | "mix"
1994                    | "swift"
1995            )
1996        });
1997    let cannot_start = matches!(c.exit_code, Some(126 | 127));
1998    let is_broken = match &summary {
1999        _ if cannot_start => true,
2000        Some(s) if s.build_failed => true,
2001        Some(s) => s.passed + s.failed + s.errors == 0,
2002        None => runner,
2003    };
2004    match is_broken {
2005        false => None,
2006        true => {
2007            let first = c
2008                .stderr_tail
2009                .lines()
2010                .chain(c.stdout_tail.lines())
2011                .map(str::trim)
2012                .find(|l| l.to_lowercase().contains("error"))
2013                .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
2014                .unwrap_or("it failed without running a test")
2015                .chars()
2016                .take(160)
2017                .collect::<String>();
2018            Some(first)
2019        }
2020    }
2021}