Skip to main content

speccoder_runtime/pipeline/
acceptance.rs

1//! Spec-derived acceptance tests: written before the attempt, red then green.
2
3use std::path::Path;
4
5use speccoder_core::verify::{RedClass, classify_red};
6
7use crate::{sandbox, substrate, tools, verify, worktree};
8
9pub(crate) struct AcceptanceSetup<'a> {
10    pub(crate) rt: &'a tokio::runtime::Runtime,
11    pub(crate) plane: &'a substrate::Plane,
12    pub(crate) project: &'a Path,
13    pub(crate) worktree: &'a Path,
14    pub(crate) tmp: &'a Path,
15    pub(crate) policy: &'a tools::policy::Policy,
16    pub(crate) feature: &'a str,
17    pub(crate) task: &'a str,
18    pub(crate) bundle: serde_json::Value,
19    /// The task's scenarios, which each test cites.
20    pub(crate) scenarios: &'a [speccoder_core::spec::ScenarioRef],
21    /// The repository's instructions (test conventions live there too).
22    pub(crate) instructions: &'a [crate::instructions::Instruction],
23    pub(crate) runner: &'a sandbox::Runner,
24}
25
26#[derive(Clone, serde::Serialize, serde::Deserialize)]
27pub(crate) struct PreparedAcceptance {
28    /// What the run report shows.
29    pub(crate) report: serde_json::Value,
30    /// The command, when the tests were written.
31    pub(crate) argv: Option<Vec<String>>,
32    /// They failed before the attempt, on an assertion or for an absent
33    /// symbol.
34    pub(crate) red: bool,
35    /// What the red run showed.
36    #[serde(default)]
37    pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
38    /// The held-out manifest's hash, when a held-out set was kept: a resumed
39    /// attempt reuses it, never re-authors it.
40    #[serde(default)]
41    pub(crate) held_out: Option<String>,
42    /// Why the author call itself failed (the provider refused, the run
43    /// failed) — not "the spec has no examples": the task stops for a person.
44    #[serde(default)]
45    pub(crate) generation_failed: Option<String>,
46}
47
48pub(crate) const MANIFESTS: &[&str] = &[
49    "Cargo.toml",
50    "package.json",
51    "pyproject.toml",
52    "go.mod",
53    "pom.xml",
54];
55
56pub(crate) fn prepare_acceptance(a: &AcceptanceSetup<'_>) -> PreparedAcceptance {
57    use serde_json::json;
58    let skipped = |why: String, report: serde_json::Value| {
59        let mut report = report;
60        report["skipped"] = json!(why);
61        PreparedAcceptance {
62            report,
63            argv: None,
64            red: false,
65            red_class: None,
66            held_out: None,
67            generation_failed: None,
68        }
69    };
70    let failed = |why: String, report: serde_json::Value| PreparedAcceptance {
71        generation_failed: Some(why.clone()),
72        ..skipped(why, report)
73    };
74
75    let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
76        .and_then(|c| worktree::files(a.worktree, &c))
77        .unwrap_or_default()
78        .into_iter()
79        .take(400)
80        .collect();
81    let manifest = MANIFESTS
82        .iter()
83        .find_map(|m| {
84            std::fs::read_to_string(a.project.join(m))
85                .ok()
86                .map(|t| format!("{m}:\n{t}"))
87        })
88        .map(|t| t.chars().take(8192).collect::<String>())
89        .unwrap_or_default();
90    let input = substrate::acceptance::input(
91        a.bundle.clone(),
92        a.scenarios,
93        &files,
94        &manifest,
95        a.instructions,
96    );
97    let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
98    let outcome = match outcome {
99        Ok(o) => o,
100        Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
101    };
102    let output = outcome
103        .output
104        .as_ref()
105        .map(|o| o.peek().clone())
106        .unwrap_or_default();
107    let answer = output["answer"].clone();
108    let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
109    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
110        let status = format!("{:?}", outcome.status);
111        let why = substrate::provider_refusal(&status).map_or_else(
112            || format!("generation did not succeed: {}", outcome.status.as_str()),
113            |r| format!("the model provider refused the request: {r}"),
114        );
115        return failed(why, base);
116    }
117
118    // Validate: new test files only, a command the policy allows.
119    let protected = a.policy.protected();
120    let mut written = Vec::new();
121    let mut planned = Vec::new();
122    // Each test and the scenarios it cites, of those the spec has.
123    let mut tests = Vec::new();
124    for f in answer["files"].as_array().into_iter().flatten() {
125        let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
126            continue;
127        };
128        let rel = match tools::paths::normalise(path) {
129            Ok(r) => r,
130            Err(e) => return skipped(format!("`{path}`: {}", e.0), base),
131        };
132        if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
133            return skipped(format!("`{rel}` is not a new test file"), base);
134        }
135        if a.project.join(&rel).exists() {
136            return skipped(format!("`{rel}` already exists"), base);
137        }
138        let cited: Vec<&str> = f["scenarios"]
139            .as_array()
140            .into_iter()
141            .flatten()
142            .filter_map(|v| v.as_str())
143            .filter(|id| a.scenarios.iter().any(|s| s.id == *id))
144            .collect();
145        tests.push(json!({ "path": rel, "scenarios": cited }));
146        planned.push((rel, content.to_owned()));
147    }
148    if planned.is_empty() {
149        return skipped("no acceptance tests were generated".to_owned(), base);
150    }
151    let argv: Vec<String> = answer["argv"]
152        .as_array()
153        .into_iter()
154        .flatten()
155        .filter_map(|v| v.as_str().map(str::to_owned))
156        .collect();
157    if a.policy.classify("run_command", &json!({ "argv": argv })) != tools::policy::Decision::Allow
158    {
159        return skipped(
160            format!("`{}` is not an allowed command", argv.join(" ")),
161            base,
162        );
163    }
164    for (rel, content) in &planned {
165        let full = a.project.join(rel);
166        if let Some(parent) = full.parent() {
167            let _ = std::fs::create_dir_all(parent);
168        }
169        if let Err(e) = std::fs::write(&full, content) {
170            return skipped(format!("`{rel}`: {e}"), base);
171        }
172        written.push(rel.clone());
173    }
174
175    // Red: they must fail before the task is done — on an assertion, or
176    // because what the task introduces does not exist yet; confirmed by a
177    // second run.
178    let (first, summary) = execute(a.project, &argv, a.runner);
179    let mut class = classify_red(&first, summary.as_ref());
180    if class.is_red() {
181        let (second, summary) = execute(a.project, &argv, a.runner);
182        if !classify_red(&second, summary.as_ref()).is_red() {
183            class = RedClass::Flaky;
184        }
185    }
186    let red = class.is_red();
187    let mut report = base;
188    report["files"] = json!(written);
189    report["tests"] = json!(tests);
190    report["argv"] = json!(argv);
191    report["red"] = json!(red);
192    report["red_class"] = json!(class);
193    if !red {
194        report["note"] = json!(match class {
195            RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
196            RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
197            other => format!(
198                "the acceptance tests did not fail on an assertion ({}): not demonstrated",
199                class_word(other)
200            ),
201        });
202        report["excerpt"] = json!(excerpt(&first));
203    }
204    PreparedAcceptance {
205        report,
206        argv: Some(argv),
207        red,
208        red_class: Some(class),
209        held_out: None,
210        generation_failed: None,
211    }
212}
213
214/// The class as written in reports.
215pub(crate) fn class_word(class: RedClass) -> String {
216    serde_json::to_value(class)
217        .ok()
218        .and_then(|v| v.as_str().map(str::to_owned))
219        .unwrap_or_default()
220}
221
222/// Up to 20 lines from the first error line of a run's output (else its
223/// last 20 lines).
224fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
225    let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
226    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
227    let from = lines
228        .iter()
229        .position(|l| {
230            let l = l.to_lowercase();
231            l.contains("error") || l.contains("fail")
232        })
233        .unwrap_or_else(|| lines.len().saturating_sub(20));
234    lines
235        .iter()
236        .skip(from)
237        .take(20)
238        .copied()
239        .collect::<Vec<_>>()
240        .join("\n")
241}
242
243/// One run of an acceptance command, with the runner's summary.
244pub(crate) fn acceptance_run(
245    project: &Path,
246    argv: &[String],
247    runner: &sandbox::Runner,
248) -> (
249    speccoder_core::verify::CheckResult,
250    Option<speccoder_core::verify::RunnerSummary>,
251) {
252    execute(project, argv, runner)
253}
254
255pub(crate) fn run_acceptance(
256    project: &Path,
257    argv: &[String],
258    runner: &sandbox::Runner,
259) -> speccoder_core::verify::CheckResult {
260    execute(project, argv, runner).0
261}
262
263/// Runs the acceptance tests once; the runner's summary is read from the
264/// whole captured output, before it is cut to tails.
265fn execute(
266    project: &Path,
267    argv: &[String],
268    runner: &sandbox::Runner,
269) -> (
270    speccoder_core::verify::CheckResult,
271    Option<speccoder_core::verify::RunnerSummary>,
272) {
273    use speccoder_core::verify::{CheckResult, CheckStatus};
274    use verify::process::CommandRunner;
275    let out = runner.run(&verify::process::Invocation {
276        argv: argv.to_vec(),
277        cwd: project.to_path_buf(),
278        env: verify::process::scrubbed_env(std::env::vars()),
279        timeout: std::time::Duration::from_secs(600),
280        output_cap: verify::process::OUTPUT_CAP,
281    });
282    let status = if out.spawn_error.is_some() {
283        CheckStatus::Error
284    } else if out.timed_out {
285        CheckStatus::TimedOut
286    } else if out.exit_code == Some(0) {
287        CheckStatus::Passed
288    } else {
289        CheckStatus::Failed
290    };
291    let summary = verify::summary::parse(
292        &String::from_utf8_lossy(&out.stdout.kept),
293        &String::from_utf8_lossy(&out.stderr.kept),
294        out.exit_code,
295    );
296    let tail = |b: &[u8]| {
297        let start = b.len().saturating_sub(4096);
298        String::from_utf8_lossy(&b[start..]).into_owned()
299    };
300    let result = CheckResult {
301        name: "acceptance".to_owned(),
302        argv: argv.to_vec(),
303        status,
304        exit_code: out.exit_code,
305        duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
306        junit: false,
307        tests: None,
308        stdout_tail: tail(&out.stdout.kept),
309        stderr_tail: tail(&out.stderr.kept),
310        truncated: out.stdout.truncated() || out.stderr.truncated(),
311        note: out.spawn_error,
312        flaky: false,
313    };
314    (result, summary)
315}
316
317/// The held-out set's author, store and red check: after a visible
318/// set that was red. Returns the report part (`acceptance.held_out`) and
319/// the stored manifest's hash when files were kept.
320pub(crate) fn prepare_held_out(
321    a: &AcceptanceSetup<'_>,
322    visible: &PreparedAcceptance,
323    scenarios: &[speccoder_core::spec::ScenarioRef],
324    store: &Path,
325    composition: bool,
326) -> (serde_json::Value, Option<String>) {
327    use serde_json::json;
328    let absent = |reason: &str, mut report: serde_json::Value| {
329        report["state"] = json!("absent");
330        report["reason"] = json!(reason);
331        (report, None)
332    };
333    let visible_files: Vec<(String, String)> = visible.report["files"]
334        .as_array()
335        .into_iter()
336        .flatten()
337        .filter_map(|p| p.as_str())
338        .map(|p| {
339            (
340                p.to_owned(),
341                std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
342            )
343        })
344        .collect();
345    let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
346        .and_then(|c| worktree::files(a.worktree, &c))
347        .unwrap_or_default()
348        .into_iter()
349        .take(400)
350        .collect();
351    let manifest_text = MANIFESTS
352        .iter()
353        .find_map(|m| {
354            std::fs::read_to_string(a.project.join(m))
355                .ok()
356                .map(|t| format!("{m}:\n{t}"))
357        })
358        .map(|t| t.chars().take(8192).collect::<String>())
359        .unwrap_or_default();
360    let input = substrate::acceptance::held_out_input(
361        a.bundle.clone(),
362        &files,
363        &manifest_text,
364        scenarios,
365        &visible_files,
366        composition,
367        a.instructions,
368    );
369    let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
370        Ok(o) => o,
371        Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
372    };
373    let output = outcome
374        .output
375        .as_ref()
376        .map(|o| o.peek().clone())
377        .unwrap_or_default();
378    let answer = output["answer"].clone();
379    let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
380    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
381        return absent("generation_failed", base);
382    }
383
384    // Validate: new test paths, known scenarios, an allowed
385    // command each, at most `LIMIT`.
386    let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
387    let protected = a.policy.protected();
388    let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
389    let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
390    let mut discard = |path: &str, reason: &str| {
391        discarded.push(crate::held_out::Discarded {
392            path: path.to_owned(),
393            reason: reason.to_owned(),
394        });
395    };
396    let given = answer["files"].as_array().cloned().unwrap_or_default();
397    if given.is_empty() {
398        return absent("none_generated", base);
399    }
400    for f in &given {
401        let path = f["path"].as_str().unwrap_or_default();
402        let content = f["content"].as_str().unwrap_or_default();
403        let cited: Vec<String> = f["scenarios"]
404            .as_array()
405            .into_iter()
406            .flatten()
407            .filter_map(|s| s.as_str().map(str::to_owned))
408            .collect();
409        let argv: Vec<String> = f["argv"]
410            .as_array()
411            .into_iter()
412            .flatten()
413            .filter_map(|s| s.as_str().map(str::to_owned))
414            .collect();
415        let rel = match tools::paths::normalise(path) {
416            Ok(r) => r,
417            Err(_) => {
418                discard(path, "path");
419                continue;
420            }
421        };
422        let taken =
423            visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
424        if !tools::paths::is_test_path(&rel)
425            || protected.write_class(&rel).is_some()
426            || a.project.join(&rel).exists()
427            || taken
428        {
429            discard(&rel, "path");
430            continue;
431        }
432        if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
433            discard(&rel, "unknown_scenario");
434            continue;
435        }
436        let composed = f["composition"].as_bool().unwrap_or(false);
437        if composed && (!composition || cited.len() < 2) {
438            discard(&rel, "composition");
439            continue;
440        }
441        if a.policy.classify("run_command", &json!({ "argv": argv }))
442            != tools::policy::Decision::Allow
443        {
444            discard(&rel, "command");
445            continue;
446        }
447        if kept.len() >= crate::held_out::LIMIT {
448            discard(&rel, "limit");
449            continue;
450        }
451        kept.push((
452            crate::held_out::HeldOutFile {
453                path: rel,
454                sha256: crate::integrity::sha256_hex(content.as_bytes()),
455                scenarios: cited,
456                argv,
457                composition: composed,
458            },
459            content.to_owned(),
460        ));
461    }
462
463    // Red now, on the tree the attempt starts from.
464    let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
465        .map(|c| c.0)
466        .unwrap_or_default();
467    match crate::held_out::overlay_run(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
468        Ok(runs) => {
469            let mut red = Vec::new();
470            for ((file, content), ran) in kept.into_iter().zip(runs) {
471                match ran.result {
472                    None => discard(&file.path, "collision"),
473                    Some((result, summary)) if classify_red(&result, summary.as_ref()).is_red() => {
474                        red.push((file, content));
475                    }
476                    Some(_) => discard(&file.path, "not_red"),
477                }
478            }
479            kept = red;
480        }
481        Err(e) => {
482            return absent(
483                "generation_failed",
484                json!({ "error": e, "run_id": base["run_id"] }),
485            );
486        }
487    }
488    let mut report = base;
489    report["discarded"] = json!(discarded);
490    if kept.is_empty() {
491        return absent("all_discarded", report);
492    }
493    let manifest = crate::held_out::Manifest {
494        version: 1,
495        feature: a.feature.to_owned(),
496        task: a.task.to_owned(),
497        author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
498        checkpoint,
499        visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
500        files: kept.iter().map(|(f, _)| f.clone()).collect(),
501        discarded,
502    };
503    let contents: Vec<(String, String)> = kept
504        .iter()
505        .map(|(f, c)| (f.path.clone(), c.clone()))
506        .collect();
507    match crate::held_out::store(store, &manifest, &contents) {
508        Ok(sha) => {
509            report["state"] = json!("on");
510            report["manifest"] = json!(format!("sha256:{sha}"));
511            report["kept"] = json!(kept.len());
512            (report, Some(sha))
513        }
514        Err(e) => absent("generation_failed", json!({ "error": e })),
515    }
516}
517
518/// The held-out verdict on the tree an attempt left: `Ok` with what the
519/// tests said, or `Err` with why a person must look (tampering, a leak, a
520/// run that would not leave the tree as it was).
521pub(crate) struct HeldOutJudged {
522    /// The verdict input.
523    pub(crate) held_out: speccoder_core::verify::HeldOut,
524    /// The failed scenarios, with the spec's words, for the retry digest.
525    pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
526}
527
528#[allow(clippy::too_many_arguments)]
529pub(crate) fn judge_held_out(
530    store: &Path,
531    manifest_sha: &str,
532    scenarios: &[speccoder_core::spec::ScenarioRef],
533    known_text: &str,
534    wt_root: &Path,
535    project: &Path,
536    tmp: &Path,
537    start: &worktree::Checkpoint,
538    patch: &str,
539    runner: &sandbox::Runner,
540    policy: &tools::policy::Policy,
541) -> Result<HeldOutJudged, (&'static str, String)> {
542    use speccoder_core::verify::{CheckStatus, HeldOut};
543    let (manifest, contents) =
544        crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
545    let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
546        .files
547        .iter()
548        .cloned()
549        .zip(contents.into_iter().map(|(_, c)| c))
550        .collect();
551    let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
552    if !leaks.is_empty() {
553        return Err((
554            "leak_suspected",
555            format!(
556                "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
557                leaks.len()
558            ),
559        ));
560    }
561    let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
562        .map_err(|e| ("tampered", e))?;
563    let mut failed_ids: Vec<String> = Vec::new();
564    for ((file, _), ran) in files.iter().zip(&runs) {
565        let passed = ran
566            .result
567            .as_ref()
568            .is_some_and(|(r, _)| r.status == CheckStatus::Passed);
569        if !passed {
570            failed_ids.extend(file.scenarios.iter().cloned());
571        }
572    }
573    failed_ids.sort();
574    failed_ids.dedup();
575    let held_out = if failed_ids.is_empty() {
576        HeldOut::Passed { files: files.len() }
577    } else {
578        HeldOut::Failed {
579            scenarios: failed_ids.clone(),
580        }
581    };
582    let failed = failed_ids
583        .iter()
584        .map(|id| {
585            scenarios
586                .iter()
587                .find(|s| &s.id == id)
588                .cloned()
589                .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
590                    id: id.clone(),
591                    text: String::new(),
592                })
593        })
594        .collect();
595    Ok(HeldOutJudged { held_out, failed })
596}
597
598/// Added acceptance tests that strengthen a weak set.
599pub(crate) struct Strengthened {
600    /// The new test files, written into the worktree.
601    pub(crate) files: Vec<String>,
602    /// The command that runs them.
603    pub(crate) argv: Vec<String>,
604    /// The author's run and tokens.
605    pub(crate) report: serde_json::Value,
606}
607
608/// Asks the test author once more, telling it what got through (`weak`),
609/// and keeps its new tests only if they fail on the attempt's starting tree
610/// (`before`) and pass on its final tree (`after`), where they are left.
611/// `Err`: why the set could not be strengthened (no worktree change stays).
612pub(crate) fn strengthen(
613    a: &AcceptanceSetup<'_>,
614    visible: &[(String, String)],
615    weak: serde_json::Value,
616    before: &worktree::Checkpoint,
617    after: &worktree::Checkpoint,
618) -> Result<Strengthened, String> {
619    use serde_json::json;
620    let files: Vec<String> = worktree::files(a.worktree, after)
621        .unwrap_or_default()
622        .into_iter()
623        .take(400)
624        .collect();
625    let manifest = MANIFESTS
626        .iter()
627        .find_map(|m| {
628            std::fs::read_to_string(a.project.join(m))
629                .ok()
630                .map(|t| format!("{m}:\n{t}"))
631        })
632        .map(|t| t.chars().take(8192).collect::<String>())
633        .unwrap_or_default();
634    let input = substrate::acceptance::strengthen_input(
635        a.bundle.clone(),
636        a.scenarios,
637        &files,
638        &manifest,
639        visible,
640        weak,
641        a.instructions,
642    );
643    let outcome =
644        a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
645            .map_err(|e| format!("the test author failed: {}", e.0))?;
646    let output = outcome
647        .output
648        .as_ref()
649        .map(|o| o.peek().clone())
650        .unwrap_or_default();
651    let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
652    let answer = &output["answer"];
653    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
654        return Err(format!(
655            "the test author did not answer ({})",
656            outcome.status.as_str()
657        ));
658    }
659    let protected = a.policy.protected();
660    let mut planned = Vec::new();
661    for f in answer["files"].as_array().into_iter().flatten() {
662        let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
663            continue;
664        };
665        let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
666        if !tools::paths::is_test_path(&rel)
667            || protected.write_class(&rel).is_some()
668            || a.project.join(&rel).exists()
669        {
670            return Err(format!("`{rel}` is not a new test file"));
671        }
672        planned.push((rel, content.to_owned()));
673    }
674    if planned.is_empty() {
675        return Err("the test author added no tests".to_owned());
676    }
677    let argv: Vec<String> = answer["argv"]
678        .as_array()
679        .into_iter()
680        .flatten()
681        .filter_map(|v| v.as_str().map(str::to_owned))
682        .collect();
683    if a.policy.classify("run_command", &json!({ "argv": argv })) != tools::policy::Decision::Allow
684    {
685        return Err(format!("`{}` is not an allowed command", argv.join(" ")));
686    }
687    let write = || -> Result<(), String> {
688        for (rel, content) in &planned {
689            let full = a.project.join(rel);
690            if let Some(parent) = full.parent() {
691                let _ = std::fs::create_dir_all(parent);
692            }
693            std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
694        }
695        Ok(())
696    };
697    let back =
698        |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
699    // Red on the starting tree: they fail without the task's work.
700    back(before)?;
701    let red = write().map(|()| {
702        let (r, summary) = execute(a.project, &argv, a.runner);
703        classify_red(&r, summary.as_ref())
704    });
705    back(after)?;
706    let red = red?;
707    if !red.is_red() {
708        return Err(format!(
709            "the added tests do not fail without the change ({})",
710            class_word(red)
711        ));
712    }
713    // Green on the final tree: the implementation passes them.
714    write()?;
715    let (green, _) = execute(a.project, &argv, a.runner);
716    if green.status != speccoder_core::verify::CheckStatus::Passed {
717        back(after)?;
718        let cited: Vec<String> = answer["files"]
719            .as_array()
720            .into_iter()
721            .flatten()
722            .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
723            .filter_map(|v| v.as_str().map(str::to_owned))
724            .collect();
725        return Err(format!(
726            "the added tests fail the implementation{}",
727            if cited.is_empty() {
728                String::new()
729            } else {
730                format!(" (scenarios {})", cited.join(", "))
731            }
732        ));
733    }
734    Ok(Strengthened {
735        files: planned.into_iter().map(|(p, _)| p).collect(),
736        argv,
737        report,
738    })
739}
740
741/// Whether `task` completes its story: the last task carrying its story
742/// label, in a story with at least two scenarios (US4).
743pub(crate) fn story_checkpoint(
744    tasks: Option<&speccoder_core::snapshot::ParsedTasks>,
745    task: &str,
746    scenarios: &[speccoder_core::spec::ScenarioRef],
747) -> bool {
748    let Some(tasks) = tasks else { return false };
749    let Some(label) = tasks
750        .tasks
751        .iter()
752        .find(|t| t.id == task)
753        .and_then(|t| t.story.as_deref())
754    else {
755        return false;
756    };
757    let last = tasks
758        .tasks
759        .iter()
760        .rfind(|t| t.story.as_deref() == Some(label))
761        .map(|t| t.id.as_str());
762    last == Some(task) && scenarios.len() >= 2
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768
769    #[test]
770    fn the_last_task_of_a_story_with_two_scenarios_composes() {
771        let (tasks, _) = speccoder_speckit::tasks::parse(
772            "tasks.md",
773            "## Phase 3: User Story 1 - Login\n\n- [ ] T005 [US1] a\n- [ ] T006 [US1] b\n\n## Phase 4: User Story 2 - X\n\n- [ ] T007 [US2] c\n",
774        );
775        let two: Vec<speccoder_core::spec::ScenarioRef> = ["US1-1", "US1-2"]
776            .iter()
777            .map(|id| speccoder_core::spec::ScenarioRef {
778                id: (*id).to_owned(),
779                text: String::new(),
780            })
781            .collect();
782        assert!(story_checkpoint(Some(&tasks), "T006", &two));
783        assert!(!story_checkpoint(Some(&tasks), "T005", &two));
784        assert!(!story_checkpoint(Some(&tasks), "T006", &two[..1]));
785        assert!(!story_checkpoint(None, "T006", &two));
786    }
787}