Skip to main content

speccoder_runtime/
oracle.rs

1//! The oracle at review: per requirement, the visible
2//! acceptance tests the run would use, written on the person's request —
3//! two independent author calls whose stated expectations are compared for
4//! ambiguity, the first one's citing tests red-checked on the checkout's
5//! `HEAD` in a scratch worktree, a held-out set sealed beside them — shaped
6//! by the person (reject, ask again, edit, choose a reading), and frozen
7//! with the approval ([`freeze`]). A task whose requirement has a frozen,
8//! current oracle reuses it instead of calling the author.
9//!
10//! Drafts live in the state directory (`<state>/oracle/<unit>/<REQ>/`),
11//! never in the worktree; the model writes none of them. A held-out test's
12//! path or content never leaves its sealed store: the views carry its count
13//! and digest only.
14
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use agentplane::model::ModelProvider;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use speccoder_core::acceptance::{Decided, PreFrozenAcceptance, Producer, SealedRef, VisibleTest};
22use speccoder_core::actions::Via;
23use speccoder_core::context::{ContextEntry, ContextKind};
24use speccoder_core::ladder::Rung;
25use speccoder_core::oracle::{
26    Ambiguity, AuthoredBy, Expectation, HeldOutSummary, OracleDiscarded, OracleOp, OracleRejected,
27    OracleState, OracleTest, OracleView, Reading, Resolution, Untestable,
28};
29use speccoder_core::snapshot::ProjectSnapshot;
30use speccoder_core::verify::{RedClass, classify_red};
31use speccoder_core::{CommandData, CommandResult, CommandStatus};
32
33use crate::format::{Adapter, OracleRequirement};
34use crate::integrity::sha256_hex;
35use crate::{sandbox, substrate, tools, worktree};
36
37/// The store's directory under a project's state directory.
38pub const DIR: &str = "oracle";
39
40/// Visible tests kept per requirement.
41pub const LIMIT: usize = 4;
42
43/// A requirement's draft as the author and the person left it.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Draft {
46    /// The requirement as it read when the tests were written.
47    pub requirement: DraftRequirement,
48    /// Author calls per *Write Tests* (`[review] oracle_samples`).
49    pub samples: u8,
50    /// The kept tests.
51    pub tests: Vec<DraftTest>,
52    /// Tests not kept, and why.
53    pub discarded: Vec<OracleDiscarded>,
54    /// Tests the person rejected.
55    pub rejected: Vec<OracleRejected>,
56    /// What each author stated it asserts.
57    pub expectations: Samples<Expectation>,
58    /// What each author found untestable.
59    pub untestable: Samples<Untestable>,
60    /// Readings that disagree.
61    pub ambiguities: Vec<Ambiguity>,
62    /// The held-out set: count and digest.
63    pub held_out: HeldOutSummary,
64    /// Its sealed reference, when kept.
65    pub sealed: Option<SealedRef>,
66    /// The person's notes to later author calls (comments, readings).
67    pub notes: Vec<String>,
68    /// The journaled author runs.
69    pub runs: Vec<String>,
70    /// What reached each author call (constitution VIII): `{source,
71    /// label, sha256}` in the context manifest's shape.
72    pub context: Vec<ContextEntry>,
73}
74
75/// The requirement a draft was written for.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct DraftRequirement {
78    /// `US1`.
79    pub key: String,
80    /// Its title.
81    pub title: String,
82    /// Its text.
83    pub text: String,
84    /// `sha256:` of `title + "\n" + text`.
85    pub sha256: String,
86}
87
88/// One stored test of a draft.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct DraftTest {
91    /// Project-relative path.
92    pub path: String,
93    /// `sha256:<hex>` of its content.
94    pub sha256: String,
95    /// The scenarios it cites (never empty).
96    pub scenarios: Vec<String>,
97    /// The command that runs only it.
98    pub argv: Vec<String>,
99    /// Its class on the checkout's `HEAD`.
100    pub red_class: RedClass,
101    /// Who wrote it.
102    pub authored_by: AuthoredBy,
103    /// The person who edited it.
104    pub edited_by: Option<String>,
105}
106
107/// Something per author sample.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(bound(deserialize = "T: Deserialize<'de>"))]
110pub struct Samples<T> {
111    /// The first author's (its tests are kept).
112    pub sample1: Vec<T>,
113    /// The second's (compared, then its tests discarded).
114    pub sample2: Vec<T>,
115}
116
117impl<T> Default for Samples<T> {
118    fn default() -> Self {
119        Self {
120            sample1: Vec::new(),
121            sample2: Vec::new(),
122        }
123    }
124}
125
126/// An oracle entry of `review.json`: what the person froze, by digest.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct OracleEntry {
129    /// The requirement's wording digest.
130    pub requirement_sha256: String,
131    /// The visible set's manifest digest.
132    pub manifest_sha256: String,
133    /// The sealed held-out manifest's digest, when one was kept.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub held_out_sha256: Option<String>,
136    /// Who froze it.
137    pub by: String,
138    /// Through what.
139    pub via: Via,
140    /// Unix seconds.
141    pub at: u64,
142}
143
144/// What a freeze keeps beside the pre-frozen record: the commands and the
145/// wording, bound to the manifest digest `review.json` names.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct Frozen {
148    /// The wording digest.
149    pub requirement_sha256: String,
150    /// The wording it was frozen on (title, then text).
151    pub wording: String,
152    /// The manifest digest.
153    pub manifest_sha256: String,
154    /// Each frozen test with the command that runs only it.
155    pub tests: Vec<FrozenTest>,
156}
157
158/// One frozen test.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct FrozenTest {
161    /// Project-relative path.
162    pub path: String,
163    /// `sha256:<hex>`.
164    pub sha256: String,
165    /// Its command.
166    pub argv: Vec<String>,
167    /// The scenarios it cites.
168    pub scenarios: Vec<String>,
169}
170
171/// A draft with its tests' contents (path, text).
172pub type Loaded = (Draft, Vec<(String, String)>);
173
174/// A test to keep: path, content, the scenarios it cites, its command.
175type Kept = (String, String, Vec<String>, Vec<String>);
176
177/// A stored draft that no longer matches its record.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct Tampered(pub String);
180
181fn slug(key: &str) -> String {
182    key.chars()
183        .map(|c| {
184            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
185                c
186            } else {
187                '_'
188            }
189        })
190        .collect()
191}
192
193/// Where a requirement's draft lives.
194#[must_use]
195pub fn dir(project_state: &Path, unit: &str, key: &str) -> PathBuf {
196    project_state.join(DIR).join(unit).join(slug(key))
197}
198
199fn now() -> u64 {
200    std::time::SystemTime::now()
201        .duration_since(std::time::UNIX_EPOCH)
202        .map_or(0, |d| d.as_secs())
203}
204
205fn digest_of(text: &str) -> String {
206    format!("sha256:{}", sha256_hex(text.as_bytes()))
207}
208
209fn write_atomic(file: &Path, text: &str) -> Result<(), String> {
210    if let Some(parent) = file.parent() {
211        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
212    }
213    let tmp = file.with_extension("json.tmp");
214    std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
215    std::fs::rename(&tmp, file).map_err(|e| format!("{}: {e}", file.display()))
216}
217
218/// Stores `draft` with its tests' `contents` (path, text); each content
219/// must hash to its test's digest. Files of tests no longer listed go.
220pub fn store(
221    project_state: &Path,
222    unit: &str,
223    draft: &Draft,
224    contents: &[(String, String)],
225) -> Result<(), String> {
226    let d = dir(project_state, unit, &draft.requirement.key);
227    let _ = std::fs::remove_dir_all(d.join("files"));
228    for t in &draft.tests {
229        let content = contents
230            .iter()
231            .find(|(p, _)| *p == t.path)
232            .map(|(_, c)| c)
233            .ok_or_else(|| format!("{} has no content", t.path))?;
234        if digest_of(content) != t.sha256 {
235            return Err(format!("{} does not match its digest", t.path));
236        }
237        let file = d.join("files").join(&t.path);
238        if let Some(parent) = file.parent() {
239            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
240        }
241        std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
242    }
243    let text = serde_json::to_string_pretty(draft).map_err(|e| e.to_string())?;
244    write_atomic(&d.join("draft.json"), &text)
245}
246
247/// A requirement's draft and its tests' contents, every hash checked;
248/// `Ok(None)`: none was written.
249pub fn load(project_state: &Path, unit: &str, key: &str) -> Result<Option<Loaded>, Tampered> {
250    let d = dir(project_state, unit, key);
251    let Ok(text) = std::fs::read_to_string(d.join("draft.json")) else {
252        return Ok(None);
253    };
254    let draft: Draft = serde_json::from_str(&text)
255        .map_err(|e| Tampered(format!("the oracle draft of {key} is not valid: {e}")))?;
256    let mut contents = Vec::new();
257    for t in &draft.tests {
258        let content = std::fs::read_to_string(d.join("files").join(&t.path))
259            .map_err(|_| Tampered(format!("the test {} of {key} is missing", t.path)))?;
260        if digest_of(&content) != t.sha256 {
261            return Err(Tampered(format!("the test {} of {key} changed", t.path)));
262        }
263        contents.push((t.path.clone(), content));
264    }
265    Ok(Some((draft, contents)))
266}
267
268/// What a freeze kept beside the record, if this machine has it.
269#[must_use]
270pub fn load_frozen(project_state: &Path, unit: &str, key: &str) -> Option<Frozen> {
271    std::fs::read_to_string(dir(project_state, unit, key).join("frozen.json"))
272        .ok()
273        .and_then(|t| serde_json::from_str(&t).ok())
274}
275
276/// The manifest digest of a visible set: `sha256` over its sorted
277/// `path=sha256 argv=[…]` lines — what `review.json` and the tick name.
278#[must_use]
279pub fn manifest_digest<'a>(
280    tests: impl IntoIterator<Item = (&'a str, &'a str, &'a [String])>,
281) -> String {
282    let mut lines: Vec<String> = tests
283        .into_iter()
284        .map(|(path, sha, argv)| {
285            format!(
286                "{path}={sha} argv={}",
287                serde_json::to_string(argv).unwrap_or_default()
288            )
289        })
290        .collect();
291    lines.sort();
292    digest_of(&lines.join("\n"))
293}
294
295impl Draft {
296    /// The tests that can be frozen: red on the checkout's `HEAD`.
297    pub fn red(&self) -> impl Iterator<Item = &DraftTest> {
298        self.tests.iter().filter(|t| t.red_class.is_red())
299    }
300
301    /// The red set's manifest digest; `None` when nothing is red.
302    #[must_use]
303    pub fn manifest(&self) -> Option<String> {
304        let red: Vec<&DraftTest> = self.red().collect();
305        (!red.is_empty()).then(|| {
306            manifest_digest(
307                red.iter()
308                    .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
309            )
310        })
311    }
312
313    /// Whether a scenario's reading is still open.
314    pub fn open_ambiguities(&self) -> impl Iterator<Item = &Ambiguity> {
315        self.ambiguities.iter().filter(|a| a.open())
316    }
317
318    /// Every reason and note a later author call is told.
319    fn all_notes(&self) -> Vec<String> {
320        self.rejected
321            .iter()
322            .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
323            .chain(self.notes.iter().cloned())
324            .collect()
325    }
326}
327
328/// What the views need from the configuration.
329#[derive(Debug, Clone, Copy)]
330pub struct ViewConfig {
331    /// `[review] oracle_samples`.
332    pub samples: u8,
333    /// `[acceptance] min_strength`, as a percentage.
334    pub strength_target: Option<u8>,
335    /// `[acceptance] held_out`.
336    pub held_out: bool,
337    /// `[acceptance] generate`.
338    pub acceptance: bool,
339}
340
341impl ViewConfig {
342    /// From `speccoder.toml` at or above `root` (defaults when absent).
343    #[must_use]
344    pub fn at(root: &Path) -> Self {
345        let rc = crate::verify::find_config(root)
346            .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
347            .and_then(|t| substrate::run_config::parse(&t).ok())
348            .unwrap_or_default();
349        Self::of(&rc)
350    }
351
352    /// From a parsed configuration.
353    #[must_use]
354    pub fn of(rc: &substrate::run_config::RunConfig) -> Self {
355        Self {
356            samples: rc.oracle_samples,
357            strength_target: rc.strength.min.map(|m| {
358                // A share from 0 to 1, as a whole percentage.
359                let percent = (m * 100.0).round().clamp(0.0, 100.0);
360                format!("{percent:.0}").parse().unwrap_or(0)
361            }),
362            held_out: rc.held_out,
363            acceptance: rc.acceptance,
364        }
365    }
366}
367
368fn test_view(t: &DraftTest, content: &str) -> OracleTest {
369    OracleTest {
370        path: t.path.clone(),
371        content: content.to_owned(),
372        scenarios: t.scenarios.clone(),
373        red_class: crate::pipeline::class_word(t.red_class),
374        red: t.red_class.is_red(),
375        authored_by: t.authored_by,
376        edited_by: t.edited_by.clone(),
377    }
378}
379
380/// One requirement's oracle as the review shows it: the draft, the
381/// frozen entry `review.json` holds (`entry`), and whether this machine
382/// has the frozen set.
383#[must_use]
384pub fn view(
385    project_state: &Path,
386    unit: &str,
387    req: &OracleRequirement,
388    entry: Option<&OracleEntry>,
389    cfg: ViewConfig,
390) -> OracleView {
391    let digest = req.digest();
392    let mut v = OracleView {
393        req: req.key.clone(),
394        title: req.title.clone(),
395        scenarios: req.scenarios.clone(),
396        state: OracleState::None,
397        frozen_on: None,
398        frozen_by: None,
399        frozen_at: None,
400        tests: Vec::new(),
401        discarded: Vec::new(),
402        rejected: Vec::new(),
403        uncovered: req.scenarios.iter().map(|s| s.id.clone()).collect(),
404        untestable: Vec::new(),
405        ambiguities: Vec::new(),
406        held_out: HeldOutSummary {
407            state: if cfg.held_out { "absent" } else { "off" }.to_owned(),
408            count: 0,
409            manifest_sha256: None,
410            reason: None,
411            discarded: Vec::new(),
412        },
413        strength_target: cfg.strength_target,
414        calls: cfg.samples,
415        ambiguity_check: cfg.samples >= 2,
416        manifest_sha256: None,
417        error: (!cfg.acceptance)
418            .then(|| "acceptance tests are off ([acceptance] generate = false)".to_owned()),
419    };
420    let loaded = load(project_state, unit, &req.key);
421    let draft = match loaded {
422        Ok(d) => d,
423        Err(t) => {
424            v.state = OracleState::Failed;
425            v.error = Some(t.0);
426            None
427        }
428    };
429    let current = draft
430        .as_ref()
431        .filter(|(d, _)| d.requirement.sha256 == digest);
432    if let Some((d, contents)) = current {
433        let content = |p: &str| {
434            contents
435                .iter()
436                .find(|(q, _)| q == p)
437                .map(|(_, c)| c.as_str())
438                .unwrap_or_default()
439        };
440        v.tests = d
441            .tests
442            .iter()
443            .map(|t| test_view(t, content(&t.path)))
444            .collect();
445        v.discarded.clone_from(&d.discarded);
446        v.rejected.clone_from(&d.rejected);
447        v.untestable.clone_from(&d.untestable.sample1);
448        v.ambiguities.clone_from(&d.ambiguities);
449        v.held_out = if cfg.held_out {
450            d.held_out.clone()
451        } else {
452            HeldOutSummary {
453                state: "off".to_owned(),
454                count: 0,
455                manifest_sha256: None,
456                reason: None,
457                discarded: Vec::new(),
458            }
459        };
460        v.manifest_sha256 = d.manifest();
461        let covered: Vec<&String> = d.red().flat_map(|t| t.scenarios.iter()).collect();
462        v.uncovered.retain(|s| !covered.contains(&s));
463        v.state = if d.open_ambiguities().next().is_some() {
464            OracleState::Ambiguous
465        } else if d.red().next().is_none() {
466            OracleState::NotRed
467        } else {
468            OracleState::Written
469        };
470    } else if draft.is_some() {
471        v.error = Some("written for an earlier wording: write them again".to_owned());
472    }
473    if let Some(e) = entry {
474        if e.requirement_sha256 == digest {
475            v.frozen_by = Some(e.by.clone());
476            v.frozen_at = Some(e.at);
477            v.state = match crate::pre_frozen::load(project_state, unit, &req.key) {
478                Ok(Some((rec, files))) => {
479                    // The card shows what the run uses: the frozen set,
480                    // never a draft changed since (P4).
481                    let drafted = current.map(|(d, _)| d);
482                    v.tests = rec
483                        .visible
484                        .iter()
485                        .map(|t| {
486                            let same = drafted.and_then(|d| {
487                                d.tests
488                                    .iter()
489                                    .find(|x| x.path == t.path && x.sha256 == t.sha256)
490                            });
491                            OracleTest {
492                                path: t.path.clone(),
493                                content: files
494                                    .iter()
495                                    .find(|(p, _)| *p == t.path)
496                                    .map(|(_, c)| c.clone())
497                                    .unwrap_or_default(),
498                                scenarios: t.cites.clone(),
499                                red_class: same.map_or_else(
500                                    || crate::pipeline::class_word(RedClass::Assertion),
501                                    |x| crate::pipeline::class_word(x.red_class),
502                                ),
503                                red: true,
504                                authored_by: same.map_or(AuthoredBy::Model, |x| x.authored_by),
505                                edited_by: same.and_then(|x| x.edited_by.clone()),
506                            }
507                        })
508                        .collect();
509                    v.manifest_sha256 = Some(e.manifest_sha256.clone());
510                    let covered: Vec<&String> =
511                        rec.visible.iter().flat_map(|t| t.cites.iter()).collect();
512                    v.uncovered = req
513                        .scenarios
514                        .iter()
515                        .map(|s| s.id.clone())
516                        .filter(|s| !covered.contains(&s))
517                        .collect();
518                    if cfg.held_out {
519                        v.held_out = match &rec.held_out_sealed {
520                            Some(h) => HeldOutSummary {
521                                state: "kept".to_owned(),
522                                count: h.count,
523                                manifest_sha256: Some(format!("sha256:{}", h.manifest_sha256)),
524                                reason: None,
525                                discarded: Vec::new(),
526                            },
527                            None => HeldOutSummary {
528                                state: "absent".to_owned(),
529                                count: 0,
530                                manifest_sha256: None,
531                                reason: Some("none_kept".to_owned()),
532                                discarded: Vec::new(),
533                            },
534                        };
535                    }
536                    OracleState::Frozen
537                }
538                Ok(None) => OracleState::FrozenElsewhere,
539                Err(t) => {
540                    v.error = Some(format!("the frozen set was tampered with: {}", t.0));
541                    OracleState::Frozen
542                }
543            };
544        } else if current.is_none() {
545            v.state = OracleState::Lapsed;
546            v.frozen_on = load_frozen(project_state, unit, &req.key).map(|f| f.wording);
547            v.frozen_by = Some(e.by.clone());
548            v.frozen_at = Some(e.at);
549        }
550    } else if let Ok(Some((rec, _))) = crate::pre_frozen::load(project_state, unit, &req.key)
551        && rec.producer == Producer::Run
552        && rec.is_current(&digest)
553    {
554        // Written by a run nobody reviewed: the story's oracle, frozen by it.
555        v.state = OracleState::Frozen;
556        v.frozen_by = Some(rec.decided.by.clone());
557        v.frozen_at = Some(rec.decided.at);
558    }
559    v
560}
561
562/// Requirements of the selected unit whose tests two authors read
563/// differently, with the scenario: `(REQ, scenario)`.
564///
565/// # Errors
566/// A stored draft was tampered with or cannot be read: whether it holds an
567/// open reading is unknown, never "none".
568pub fn open_ambiguities(
569    project_state: &Path,
570    unit: &str,
571    requirements: &[OracleRequirement],
572) -> Result<Vec<(String, String)>, Tampered> {
573    let mut out = Vec::new();
574    for r in requirements {
575        let Some((d, _)) = load(project_state, unit, &r.key)? else {
576            continue;
577        };
578        if d.requirement.sha256 != r.digest() {
579            continue;
580        }
581        out.extend(
582            d.open_ambiguities()
583                .map(|a| (d.requirement.key.clone(), a.scenario.clone())),
584        );
585    }
586    Ok(out)
587}
588
589/// The rung of the selected unit, by the format's standing units.
590#[must_use]
591pub fn rung(format: &dyn Adapter, root: &Path, unit: &str) -> Rung {
592    speccoder_core::ladder::rung_of(unit, &format.standing_units(root))
593}
594
595/// What one oracle action is asked.
596#[derive(Debug, Clone)]
597pub struct Request<'a> {
598    /// The requirement.
599    pub req: &'a str,
600    /// What to do.
601    pub op: OracleOp,
602    /// The test (reject, again, edit).
603    pub path: Option<&'a str>,
604    /// The scenario (reading).
605    pub scenario: Option<&'a str>,
606    /// The reading (reading).
607    pub reading: Option<Reading>,
608    /// The reason, comment or content.
609    pub text: Option<&'a str>,
610}
611
612impl Request<'_> {
613    /// Whether it calls the test author.
614    #[must_use]
615    pub fn needs_model(&self) -> bool {
616        matches!(self.op, OracleOp::Write | OracleOp::Again)
617            || (self.op == OracleOp::Reading && self.reading == Some(Reading::B))
618    }
619}
620
621/// Everything an oracle action works with.
622struct Ctx {
623    root: PathBuf,
624    feature: String,
625    dir: String,
626    format: &'static dyn Adapter,
627    snapshot: ProjectSnapshot,
628    plane_dir: PathBuf,
629    config: substrate::run_config::RunConfig,
630    sandbox: sandbox::Config,
631    worktree: crate::prepare::Config,
632    /// speccoder's state directory (the cache `speccoder new` filled).
633    state: PathBuf,
634    policy: tools::policy::Policy,
635    model: Option<substrate::model_config::ModelConfig>,
636}
637
638fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
639    CommandResult::new("review", status, message).with_code(code)
640}
641
642fn open(path: &Path, feature: Option<&str>, state: &Path) -> Result<Ctx, Box<CommandResult>> {
643    let inspected = crate::inspect(path, feature);
644    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
645        return Err(Box::new(CommandResult::carried("review", &inspected)));
646    };
647    let snapshot = (**snapshot).clone();
648    let Some(f) = snapshot.feature.as_ref() else {
649        return Err(Box::new(CommandResult::new(
650            "review",
651            CommandStatus::SelectionNeeded,
652            inspected.message(),
653        )));
654    };
655    let root = PathBuf::from(&snapshot.root);
656    let config_root = crate::verify::find_config(&root).ok_or_else(|| {
657        Box::new(refuse(
658            CommandStatus::ConfigError,
659            "CONFIG-MISSING",
660            "the project has no speccoder.toml",
661        ))
662    })?;
663    let text =
664        std::fs::read_to_string(config_root.join(crate::verify::config::FILE)).unwrap_or_default();
665    let config = substrate::run_config::parse(&text).map_err(|e| {
666        Box::new(refuse(
667            CommandStatus::ConfigError,
668            "CONFIG-INVALID",
669            &format!("speccoder.toml: {e}"),
670        ))
671    })?;
672    let sandbox = sandbox::parse(&text).map_err(|e| {
673        Box::new(refuse(
674            CommandStatus::ConfigError,
675            "CONFIG-INVALID",
676            &format!("speccoder.toml: {e}"),
677        ))
678    })?;
679    let worktree_config = crate::prepare::parse(&text).map_err(|e| {
680        Box::new(refuse(
681            CommandStatus::ConfigError,
682            "CONFIG-INVALID",
683            &format!("speccoder.toml: {e}"),
684        ))
685    })?;
686    let format = crate::format::of(&snapshot);
687    let policy =
688        tools::policy::Policy::new(&crate::verify::check_argvs(&config_root), &config.allow)
689            .with_prefixes(config.allow_prefix.clone())
690            .rules(format.rules());
691    let model = substrate::model_config::parse(&text).ok().flatten();
692    Ok(Ctx {
693        feature: f.feature.name.clone(),
694        dir: f.feature.dir.clone(),
695        plane_dir: worktree::project_state_dir(state, &root),
696        root,
697        format,
698        snapshot,
699        config,
700        sandbox,
701        worktree: worktree_config,
702        state: state.to_path_buf(),
703        policy,
704        model,
705    })
706}
707
708/// The project's directory inside a worktree of its repository.
709fn project_in(root: &Path, worktree_root: &Path) -> PathBuf {
710    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
711    let top = crate::git::Git::at(root)
712        .toplevel()
713        .ok()
714        .flatten()
715        .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
716    match canonical(root).strip_prefix(&top) {
717        Ok(rel) if !rel.as_os_str().is_empty() => worktree_root.join(rel),
718        _ => worktree_root.to_path_buf(),
719    }
720}
721
722/// One writer of a unit's oracle at a time: *Write Tests* and its kin, and
723/// the freeze of *Approve and Run*. A holder elsewhere is refused, never
724/// waited for (a write runs model calls).
725pub(crate) struct UnitLock(PathBuf);
726
727impl UnitLock {
728    pub(crate) fn take(project_state: &Path, unit: &str) -> Result<Self, String> {
729        let path = project_state.join(DIR).join(format!("{unit}.lock"));
730        if let Some(parent) = path.parent() {
731            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
732        }
733        // A lock older than an hour is a crashed writer's.
734        if std::fs::metadata(&path)
735            .ok()
736            .and_then(|m| m.modified().ok())
737            .and_then(|m| m.elapsed().ok())
738            .is_some_and(|age| age.as_secs() > 3600)
739        {
740            let _ = std::fs::remove_file(&path);
741        }
742        std::fs::OpenOptions::new()
743            .write(true)
744            .create_new(true)
745            .open(&path)
746            .map(|_| Self(path))
747            .map_err(|_| {
748                format!("{unit}'s tests are being written or frozen elsewhere right now: look again when that ends")
749            })
750    }
751}
752
753impl Drop for UnitLock {
754    fn drop(&mut self) {
755        let _ = std::fs::remove_file(&self.0);
756    }
757}
758
759/// What a progress sink is told: a stage, by name and count.
760fn stage(options: &crate::RunOptions, req: &str, stage: &str, n: u32, of: u32) {
761    if let Some(p) = &options.progress {
762        p.event(&crate::progress::Event::Oracle {
763            step: crate::progress::OracleEvent::Stage {
764                requirement: req.to_owned(),
765                stage: stage.to_owned(),
766                n,
767                of,
768            },
769        });
770    }
771}
772
773/// `speccoder review tests <REQ> …` with the configured model when the
774/// action calls the test author.
775#[must_use]
776pub fn tests(
777    path: &Path,
778    feature: Option<&str>,
779    request: &Request<'_>,
780    via: Via,
781    state: Option<&Path>,
782    options: &crate::RunOptions,
783) -> CommandResult {
784    let Some(state) = state.map(Path::to_path_buf).or_else(worktree::state_dir) else {
785        return refuse(
786            CommandStatus::ConfigError,
787            "NO-STATE-DIR",
788            "no state directory (set SPECCODER_STATE_DIR or HOME)",
789        );
790    };
791    if !request.needs_model() {
792        return tests_with(path, feature, request, via, &state, None, options);
793    }
794    let ctx = match open(path, feature, &state) {
795        Ok(c) => c,
796        Err(r) => return *r,
797    };
798    if let Err(r) = check(&ctx, request) {
799        return *r;
800    }
801    let Some(model) = ctx.model.as_ref() else {
802        return refuse(
803            CommandStatus::ConfigError,
804            "CONFIG",
805            "speccoder.toml has no [model] section: the test author needs a model",
806        );
807    };
808    match substrate::model_config::open_driver(model) {
809        Ok((provider, _)) => {
810            tests_with(path, feature, request, via, &state, Some(provider), options)
811        }
812        Err(e) => substrate::model_config::refused("review", &e),
813    }
814}
815
816/// [`tests`] against `state`, with `provider` for the author calls.
817#[must_use]
818pub fn tests_with(
819    path: &Path,
820    feature: Option<&str>,
821    request: &Request<'_>,
822    via: Via,
823    state: &Path,
824    provider: Option<Arc<dyn ModelProvider>>,
825    options: &crate::RunOptions,
826) -> CommandResult {
827    let ctx = match open(path, feature, state) {
828        Ok(c) => c,
829        Err(r) => return *r,
830    };
831    let req = match check(&ctx, request) {
832        Ok(r) => r,
833        Err(r) => return *r,
834    };
835    let _lock = match UnitLock::take(&ctx.plane_dir, &ctx.feature) {
836        Ok(l) => l,
837        Err(e) => return refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", &e),
838    };
839    let who = crate::approvals::Who::local_in(&ctx.root, via);
840    let done = match request.op {
841        OracleOp::Write => write(&ctx, &req, provider, options),
842        OracleOp::Reject => reject(&ctx, &req, request, &who.name),
843        OracleOp::Again => again(&ctx, &req, request, &who.name, provider, options),
844        OracleOp::Edit => edit(&ctx, &req, request, &who.name),
845        OracleOp::Reading => reading(&ctx, &req, request, &who.name, provider, options),
846    };
847    match done {
848        Ok(message) => {
849            let r = crate::review::report_in(path, feature, Some(state));
850            let status = r.status();
851            r.reworded(status, &message)
852        }
853        Err(r) => *r,
854    }
855}
856
857/// What refuses an oracle action before anything runs: the unit's rung,
858/// acceptance off, an unknown requirement or one without scenarios.
859fn check(ctx: &Ctx, request: &Request<'_>) -> Result<OracleRequirement, Box<CommandResult>> {
860    let unit_rung = rung(ctx.format, &ctx.root, &ctx.feature);
861    if unit_rung != Rung::Feature {
862        return Err(Box::new(
863            refuse(
864                CommandStatus::UsageError,
865                "REVIEW-TESTS-OWN-PIPELINE",
866                &format!(
867                    "{} is a {} unit: its tests are written and confirmed by its own pipeline",
868                    ctx.feature,
869                    if unit_rung == Rung::Quick {
870                        "quick-change"
871                    } else {
872                        "bug"
873                    }
874                ),
875            )
876            .with_fix(vec![if unit_rung == Rung::Quick {
877                "speccoder do \"…\"".to_owned()
878            } else {
879                format!("speccoder bug confirm {}/<task>", ctx.feature)
880            }]),
881        ));
882    }
883    if !ctx.config.acceptance {
884        return Err(Box::new(refuse(
885            CommandStatus::ConfigError,
886            "ACCEPTANCE-OFF",
887            "acceptance tests are off ([acceptance] generate = false): no tests are written at review",
888        )));
889    }
890    let requirements = ctx.format.oracle_requirements(&ctx.snapshot);
891    let Some(req) = requirements
892        .iter()
893        .find(|r| r.key.eq_ignore_ascii_case(request.req))
894        .cloned()
895    else {
896        return Err(Box::new(refuse(
897            CommandStatus::UsageError,
898            "REVIEW-NO-SCENARIOS",
899            &format!(
900                "the spec has no requirement {}; it names {}",
901                request.req,
902                if requirements.is_empty() {
903                    "none with scenarios".to_owned()
904                } else {
905                    requirements
906                        .iter()
907                        .map(|r| r.key.as_str())
908                        .collect::<Vec<_>>()
909                        .join(", ")
910                }
911            ),
912        )));
913    };
914    if req.scenarios.is_empty() {
915        return Err(Box::new(refuse(
916            CommandStatus::UsageError,
917            "REVIEW-NO-SCENARIOS",
918            &format!(
919                "{} has no acceptance scenarios to write tests from",
920                req.key
921            ),
922        )));
923    }
924    // A frozen set is what the run uses, sealed with its held-out set: it
925    // is not rewritten under the approval that names it (C4).
926    let (entries, _) = crate::review::oracle_entries(&ctx.root, &ctx.dir)
927        .map_err(|e| Box::new(refuse(CommandStatus::NeedsHuman, "REVIEW-UNREADABLE", &e)))?;
928    if let Some(e) = entries
929        .get(&req.key)
930        .filter(|e| e.requirement_sha256 == req.digest())
931    {
932        return Err(Box::new(refuse(
933            CommandStatus::UsageError,
934            "ORACLE-FROZEN",
935            &format!(
936                "{}'s tests were frozen with the approval by {}: the run uses them as frozen — reword {} to lapse the freeze, then write its tests again",
937                req.key, e.by, req.key
938            ),
939        )));
940    }
941    Ok(req)
942}
943
944type Outcome = Result<String, Box<CommandResult>>;
945
946fn failed(message: impl Into<String>) -> Box<CommandResult> {
947    Box::new(refuse(
948        CommandStatus::RunFailed,
949        "ORACLE-AUTHOR-FAILED",
950        &message.into(),
951    ))
952}
953
954fn usage(message: &str) -> Box<CommandResult> {
955    Box::new(refuse(CommandStatus::UsageError, "USAGE", message))
956}
957
958fn existing(ctx: &Ctx, req: &OracleRequirement) -> Result<Loaded, Box<CommandResult>> {
959    match load(&ctx.plane_dir, &ctx.feature, &req.key) {
960        Ok(Some((d, c))) if d.requirement.sha256 == req.digest() => Ok((d, c)),
961        Ok(Some(_)) => Err(usage(&format!(
962            "{}'s tests were written for an earlier wording: write them again (`speccoder review tests {} --write`)",
963            req.key, req.key
964        ))),
965        Ok(None) => Err(usage(&format!(
966            "{} has no tests yet: `speccoder review tests {} --write`",
967            req.key, req.key
968        ))),
969        Err(t) => Err(Box::new(refuse(
970            CommandStatus::NeedsHuman,
971            "ORACLE-TAMPERED",
972            &t.0,
973        ))),
974    }
975}
976
977/// The plane and runtime of the author calls.
978/// Owned at review; lent by the run whose plane already holds the journal.
979enum Held<'a, T> {
980    Own(T),
981    Lent(&'a T),
982}
983
984impl<T> std::ops::Deref for Held<'_, T> {
985    type Target = T;
986    fn deref(&self) -> &T {
987        match self {
988            Self::Own(t) => t,
989            Self::Lent(t) => t,
990        }
991    }
992}
993
994struct Author<'a> {
995    ctx: &'a Ctx,
996    rt: Held<'a, tokio::runtime::Runtime>,
997    plane: Held<'a, substrate::Plane>,
998    bundle: Value,
999    instructions: Vec<crate::instructions::Instruction>,
1000    /// The test layout's text: where the review's files are placed.
1001    test_layout: String,
1002    /// Every call's token usage, summed: what the oracle cost.
1003    spent: std::cell::RefCell<Option<Value>>,
1004}
1005
1006impl<'a> Author<'a> {
1007    fn open(
1008        ctx: &'a Ctx,
1009        req: &OracleRequirement,
1010        provider: Option<Arc<dyn ModelProvider>>,
1011        options: &crate::RunOptions,
1012    ) -> Result<Self, Box<CommandResult>> {
1013        let (Some(provider), Some(model)) = (provider, ctx.model.as_ref()) else {
1014            return Err(Box::new(refuse(
1015                CommandStatus::ConfigError,
1016                "CONFIG",
1017                "the test author needs a model ([model] in speccoder.toml)",
1018            )));
1019        };
1020        let rt = crate::pipeline::tokio_runtime().map_err(failed)?;
1021        let plane = rt
1022            .block_on(async {
1023                substrate::Plane::open_with(
1024                    &ctx.plane_dir,
1025                    model,
1026                    provider,
1027                    None,
1028                    substrate::Hooks {
1029                        format: Some(ctx.format),
1030                        progress: options.progress.clone(),
1031                        ..substrate::Hooks::default()
1032                    },
1033                )
1034            })
1035            .map_err(|e| failed(e.0))?;
1036        Ok(Self::with(ctx, req, Held::Own(rt), Held::Own(plane)))
1037    }
1038
1039    /// The author of a run: the run's runtime and plane, lent.
1040    fn lent(
1041        ctx: &'a Ctx,
1042        req: &OracleRequirement,
1043        rt: &'a tokio::runtime::Runtime,
1044        plane: &'a substrate::Plane,
1045    ) -> Self {
1046        Self::with(ctx, req, Held::Lent(rt), Held::Lent(plane))
1047    }
1048
1049    fn with(
1050        ctx: &'a Ctx,
1051        req: &OracleRequirement,
1052        rt: Held<'a, tokio::runtime::Runtime>,
1053        plane: Held<'a, substrate::Plane>,
1054    ) -> Self {
1055        let unit_dir = ctx.root.join(&ctx.dir);
1056        let [constitution, _, plan, _] = ctx.format.convergence_texts(&ctx.root, &unit_dir);
1057        let bundle = json!({
1058            "feature": ctx.feature,
1059            "constitution": constitution,
1060            "plan": plan,
1061            "story": req.text,
1062            "task": {
1063                "id": format!("req-{}", req.key),
1064                "title": format!("Acceptance tests for {}: {}", req.key, req.title),
1065                "phase": "Review",
1066                "subsection": "",
1067                "story": req.key,
1068                "parallel": false,
1069            },
1070            "dependencies": [],
1071        });
1072        let instructions =
1073            crate::instructions::collect(&ctx.root, &ctx.root, &[], &ctx.config.instructions);
1074        Self {
1075            ctx,
1076            rt,
1077            plane,
1078            bundle,
1079            instructions,
1080            test_layout: crate::layout::text_at(&ctx.root),
1081            spent: std::cell::RefCell::new(None),
1082        }
1083    }
1084
1085    /// The parts a call is handed, as its context manifest lists them.
1086    fn context(
1087        &self,
1088        req: &OracleRequirement,
1089        notes: &[String],
1090    ) -> speccoder_core::context::ContextManifest {
1091        let mut m = crate::context::Manifest::new();
1092        m.push(
1093            ContextKind::Story,
1094            "person",
1095            "/requirement",
1096            req.text.as_bytes(),
1097        );
1098        for s in &req.scenarios {
1099            m.push(
1100                ContextKind::Scenario,
1101                "person",
1102                &format!("/scenarios/{}", s.id),
1103                s.text.as_bytes(),
1104            );
1105        }
1106        m.push(
1107            ContextKind::Constitution,
1108            "worktree",
1109            "/bundle/constitution",
1110            self.bundle["constitution"]
1111                .as_str()
1112                .unwrap_or_default()
1113                .as_bytes(),
1114        );
1115        m.push(
1116            ContextKind::Plan,
1117            "worktree",
1118            "/bundle/plan",
1119            self.bundle["plan"].as_str().unwrap_or_default().as_bytes(),
1120        );
1121        m.push(
1122            ContextKind::TestLayout,
1123            "worktree",
1124            "/test_layout",
1125            self.test_layout.as_bytes(),
1126        );
1127        for (i, n) in notes.iter().enumerate() {
1128            let kind = if n.starts_with("Rejected `") {
1129                ContextKind::Rejection
1130            } else if n.starts_with("Comment ") {
1131                ContextKind::Comment
1132            } else {
1133                ContextKind::Note
1134            };
1135            m.push(kind, "person", &format!("/notes/{i}"), n.as_bytes());
1136        }
1137        m.finish()
1138    }
1139
1140    /// One author call: its answer, run id and context.
1141    #[allow(clippy::too_many_arguments)]
1142    fn call(
1143        &self,
1144        req: &OracleRequirement,
1145        files: &[String],
1146        manifest: &str,
1147        notes: &[String],
1148        refused: &[String],
1149        again: Option<(&str, &str)>,
1150        sample: u8,
1151        places: &[substrate::acceptance::ScenarioPlace],
1152    ) -> Result<(Value, String, Vec<ContextEntry>), String> {
1153        let context = self.context(req, notes);
1154        let suite = crate::pipeline::acceptance::suite(&self.ctx.root, &req.text, &[]);
1155        let interface = crate::pipeline::acceptance::interface(&self.ctx.root, &req.text);
1156        let commands: Vec<String> = if crate::layout::via_command(&req.text) {
1157            crate::layout::commands(&self.ctx.root)
1158                .into_iter()
1159                .map(|c| c.shown)
1160                .collect()
1161        } else {
1162            Vec::new()
1163        };
1164        let input = substrate::acceptance::review_input(&substrate::acceptance::ReviewParts {
1165            bundle: self.bundle.clone(),
1166            requirement: json!({ "key": req.key, "title": req.title, "text": req.text }),
1167            scenarios: &req.scenarios,
1168            files,
1169            manifest,
1170            notes,
1171            again,
1172            instructions: &self.instructions,
1173            context: &context,
1174            sample,
1175            test_layout: Some(&self.test_layout),
1176            suite: &suite,
1177            interface: &interface,
1178            commands: &commands,
1179            refused,
1180            places,
1181        });
1182        let task = format!("req-{}", req.key);
1183        let outcome = self
1184            .rt
1185            .block_on(self.plane.acceptance(&self.ctx.feature, &task, input))
1186            .map_err(|e| format!("the test author failed: {}", e.0))?;
1187        let output = outcome
1188            .output
1189            .as_ref()
1190            .map(|o| o.peek().clone())
1191            .unwrap_or_default();
1192        let answer = output["answer"].clone();
1193        if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
1194        {
1195            let status = format!("{:?}", outcome.status);
1196            return Err(substrate::provider_refusal(&status).map_or_else(
1197                || {
1198                    format!(
1199                        "the test author did not answer: {}",
1200                        substrate::why_not(&outcome.status)
1201                    )
1202                },
1203                |r| format!("the model provider refused the request: {r}"),
1204            ));
1205        }
1206        let total =
1207            crate::pipeline::commit::add_usage(self.spent.borrow().as_ref(), output.get("usage"));
1208        *self.spent.borrow_mut() = total;
1209        Ok((answer, outcome.run_id.to_string(), context.entries))
1210    }
1211}
1212
1213/// What one author states it expects of each scenario, and what it found
1214/// untestable, by the requirement's own scenario ids (case aside). `Err`:
1215/// an answer the comparison cannot read — not parseable, naming a scenario
1216/// the requirement does not have, or saying nothing of any scenario — is
1217/// an author failure, never "no ambiguity".
1218fn expectations(
1219    answer: &Value,
1220    req: &OracleRequirement,
1221) -> Result<(Vec<Expectation>, Vec<Untestable>), String> {
1222    let canonical = |id: &str| {
1223        req.scenarios
1224            .iter()
1225            .find(|s| s.id.eq_ignore_ascii_case(id.trim()))
1226            .map(|s| s.id.clone())
1227            .ok_or_else(|| {
1228                format!(
1229                    "the test author's expectations name `{id}`, which {} does not have",
1230                    req.key
1231                )
1232            })
1233    };
1234    let list = |field: &str| -> Result<Value, String> {
1235        match &answer[field] {
1236            Value::Null => Ok(Value::Array(Vec::new())),
1237            v @ Value::Array(_) => Ok(v.clone()),
1238            _ => Err(format!("the test author's `{field}` is not a list")),
1239        }
1240    };
1241    let mut e: Vec<Expectation> = serde_json::from_value(list("expectations")?)
1242        .map_err(|x| format!("the test author's expectations cannot be read: {x}"))?;
1243    let mut u: Vec<Untestable> = serde_json::from_value(list("untestable")?)
1244        .map_err(|x| format!("the test author's untestable list cannot be read: {x}"))?;
1245    for x in &mut e {
1246        x.scenario = canonical(&x.scenario)?;
1247    }
1248    for x in &mut u {
1249        x.scenario = canonical(&x.scenario)?;
1250    }
1251    if e.is_empty() && u.is_empty() {
1252        return Err(
1253            "the test author stated no expectation for any scenario: the readings cannot be compared"
1254                .to_owned(),
1255        );
1256    }
1257    Ok((e, u))
1258}
1259
1260/// A scratch worktree of the checkout's `HEAD`, its project directory and
1261/// a sandboxed runner in it.
1262struct Bench {
1263    /// The worktree the tests are checked in: a scratch one at review, the
1264    /// task's own at a run (kept alive with it).
1265    root: PathBuf,
1266    _scratch: Option<worktree::Scratch>,
1267    project: PathBuf,
1268    runner: sandbox::Runner,
1269    tmp: PathBuf,
1270}
1271
1272impl Bench {
1273    fn open(ctx: &Ctx) -> Result<Self, Box<CommandResult>> {
1274        let tmp = ctx.plane_dir.join("tmp");
1275        let scratch = worktree::scratch(&ctx.root, &tmp)
1276            .map_err(|e| failed(format!("the scratch worktree could not be made: {}", e.0)))?;
1277        let project = project_in(&ctx.root, &scratch.path);
1278        let (runner, notice) = sandbox::runner(&scratch.path, &ctx.sandbox)
1279            .map_err(|e| Box::new(refuse(CommandStatus::ConfigError, "CONFIG", &e)))?;
1280        // Model-written tests and model-chosen commands run here, before
1281        // anyone approved them: only confined by the OS sandbox.
1282        if !runner.is_sandboxed() {
1283            return Err(Box::new(
1284                refuse(
1285                    CommandStatus::ConfigError,
1286                    "SANDBOX-UNAVAILABLE",
1287                    &format!(
1288                        "the red check runs model-written tests and needs the OS sandbox: {}",
1289                        notice.unwrap_or_else(|| "commands would run unsandboxed".to_owned())
1290                    ),
1291                )
1292                .with_fix(vec![
1293                    "set [sandbox] mode = \"auto\" on a machine with a sandbox backend (speccoder doctor)".to_owned(),
1294                ]),
1295            ));
1296        }
1297        // Its copied files and dependency folders, as a run's worktree gets them.
1298        crate::include::copy(&scratch.path, &project, &ctx.root, &ctx.worktree.include).map_err(
1299            |e| {
1300                Box::new(refuse(
1301                    CommandStatus::ConfigError,
1302                    "PREPARE-FAILED",
1303                    &format!("the files [worktree] include names could not be copied: {e}"),
1304                ))
1305            },
1306        )?;
1307        if ctx.worktree.prepare && crate::prepare::needed(&project) {
1308            crate::prepare::prepare(&crate::prepare::Prepare {
1309                worktree: &scratch.path,
1310                project: &project,
1311                config: &ctx.worktree,
1312                runner: &runner,
1313                state: Some(&ctx.state),
1314            })
1315            .map_err(|f| {
1316                Box::new(
1317                    refuse(CommandStatus::ConfigError, "PREPARE-FAILED", &f.message)
1318                        .with_fix(f.fix)
1319                        .with_commands(f.commands),
1320                )
1321            })?;
1322        }
1323        Ok(Self {
1324            root: scratch.path.clone(),
1325            _scratch: Some(scratch),
1326            project,
1327            runner,
1328            tmp,
1329        })
1330    }
1331
1332    /// The bench of a run: the task's own worktree (`wt_root`), already
1333    /// prepared, at the tree the task starts from, with the task's runner.
1334    fn here(ctx: &Ctx, wt_root: &Path, runner: sandbox::Runner) -> Self {
1335        Self {
1336            root: wt_root.to_path_buf(),
1337            _scratch: None,
1338            project: ctx.root.clone(),
1339            runner,
1340            tmp: ctx.plane_dir.join("tmp"),
1341        }
1342    }
1343
1344    fn files(&self) -> (Vec<String>, String) {
1345        let files: Vec<String> = worktree::checkpoint(&self.root, &self.tmp)
1346            .and_then(|c| worktree::files(&self.root, &c))
1347            .unwrap_or_default()
1348            .into_iter()
1349            .take(400)
1350            .collect();
1351        let manifest = crate::pipeline::MANIFESTS
1352            .iter()
1353            .find_map(|m| {
1354                std::fs::read_to_string(self.project.join(m))
1355                    .ok()
1356                    .map(|t| format!("{m}:\n{t}"))
1357            })
1358            .map(|t| t.chars().take(8192).collect::<String>())
1359            .unwrap_or_default();
1360        (files, manifest)
1361    }
1362
1363    /// Red-checks each test alone: only its own file is in the
1364    /// scratch tree while its command runs — first without it, then twice
1365    /// with it — so a failure is its own, never a neighbour's. A command
1366    /// that is red without the file (a failing suite, a command that runs
1367    /// more than the file) decides nothing about it: `Err("red_without_it")`.
1368    fn red(
1369        &self,
1370        tests: &[(String, String, Vec<String>)],
1371    ) -> Result<Vec<Result<RedClass, &'static str>>, String> {
1372        let mut out = Vec::new();
1373        for (path, content, argv) in tests {
1374            let full = self.project.join(path);
1375            let _ = std::fs::remove_file(&full);
1376            let run = || crate::pipeline::acceptance_run(&self.project, argv, &self.runner);
1377            let (bare, summary) = run();
1378            if classify_red(&bare, summary.as_ref()).is_red() {
1379                out.push(Err("red_without_it"));
1380                continue;
1381            }
1382            if let Some(parent) = full.parent() {
1383                std::fs::create_dir_all(parent).map_err(|e| format!("{path}: {e}"))?;
1384            }
1385            std::fs::write(&full, content).map_err(|e| format!("{path}: {e}"))?;
1386            let (first, summary) = run();
1387            let said = format!("{}\n{}", first.stdout_tail, first.stderr_tail);
1388            if crate::integrity::invalid_escape(&said, &[path.as_str()]).is_some() {
1389                let _ = std::fs::remove_file(&full);
1390                out.push(Err("invalid_escape"));
1391                continue;
1392            }
1393            let class = classify_red(&first, summary.as_ref());
1394            let class = if !class.is_red() {
1395                class
1396            } else {
1397                let (second, summary) = run();
1398                if classify_red(&second, summary.as_ref()).is_red() {
1399                    class
1400                } else {
1401                    RedClass::Flaky
1402                }
1403            };
1404            std::fs::remove_file(&full).map_err(|e| format!("{path}: {e}"))?;
1405            out.push(Ok(class));
1406        }
1407        Ok(out)
1408    }
1409}
1410
1411/// The answer's files as tests to keep, with what was discarded and why.
1412fn keep(
1413    ctx: &Ctx,
1414    bench: &Bench,
1415    answer: &Value,
1416    req: &OracleRequirement,
1417    taken: &[(String, Vec<String>)],
1418) -> (Vec<Kept>, Vec<OracleDiscarded>) {
1419    let (valid, refused) =
1420        crate::pipeline::validate_answer(answer, &bench.project, &ctx.policy, &req.scenarios, None);
1421    let mut discarded: Vec<OracleDiscarded> = refused
1422        .into_iter()
1423        .map(|r| OracleDiscarded {
1424            path: r.path,
1425            why: r.why.to_owned(),
1426        })
1427        .collect();
1428    let set_argv: Vec<String> = crate::pipeline::acceptance::argv_of(&answer["argv"]);
1429    let layout = crate::layout::detect(&bench.project, &crate::verify::check_argvs(&ctx.root));
1430    // The story's interface is the product's command: a test of it runs it.
1431    let commands = crate::layout::commands(&bench.project);
1432    let via_command = !commands.is_empty() && crate::layout::via_command(&req.text);
1433    let mut kept = Vec::new();
1434    for f in valid {
1435        let mut discard = |why: &str| {
1436            discarded.push(OracleDiscarded {
1437                path: f.path.clone(),
1438                why: why.to_owned(),
1439            });
1440        };
1441        if f.cited.is_empty() {
1442            discard("cites_no_scenario");
1443            continue;
1444        }
1445        if via_command && !commands.iter().any(|c| c.run_by(&f.content)) {
1446            discard("no_command");
1447            continue;
1448        }
1449        if taken.iter().any(|(p, _)| *p == f.path)
1450            || kept.iter().any(|(p, ..): &(String, _, _, _)| *p == f.path)
1451        {
1452            discard("exists");
1453            continue;
1454        }
1455        let argv = if f.argv.is_empty() {
1456            set_argv.clone()
1457        } else {
1458            f.argv.clone()
1459        };
1460        // One command per test: a command several tests share cannot say
1461        // whose assertion failed (at the checkpoint they all exist).
1462        if taken.iter().any(|(_, a)| *a == argv)
1463            || kept
1464                .iter()
1465                .any(|(.., a): &(_, _, _, Vec<String>)| *a == argv)
1466        {
1467            discard("command_shared");
1468            continue;
1469        }
1470        if argv.is_empty()
1471            || !crate::pipeline::test_command_allowed(
1472                &ctx.policy,
1473                Some(&layout),
1474                &argv,
1475                &[f.path.as_str()],
1476            )
1477        {
1478            discard("command_not_allowed");
1479            continue;
1480        }
1481        if kept.len() + taken.len() >= LIMIT {
1482            discard("limit");
1483            continue;
1484        }
1485        kept.push((f.path, f.content, f.cited, argv));
1486    }
1487    (kept, discarded)
1488}
1489
1490/// *Write Tests*: the author calls, the comparison, the red check and the
1491/// held-out set; the draft is stored only when the first call answered.
1492fn write(
1493    ctx: &Ctx,
1494    req: &OracleRequirement,
1495    provider: Option<Arc<dyn ModelProvider>>,
1496    options: &crate::RunOptions,
1497) -> Outcome {
1498    let samples = ctx.config.oracle_samples.clamp(1, 2);
1499    let author = Author::open(ctx, req, provider, options)?;
1500    let bench = Bench::open(ctx)?;
1501    // What the person said about earlier tests is told again; a draft
1502    // that cannot be read is not overwritten with its rejections lost.
1503    let earlier = load(&ctx.plane_dir, &ctx.feature, &req.key).map_err(|t| {
1504        Box::new(
1505            refuse(
1506                CommandStatus::NeedsHuman,
1507                "ORACLE-TAMPERED",
1508                &format!(
1509                    "{}: writing anew would lose the rejections and notes it holds",
1510                    t.0
1511                ),
1512            )
1513            .with_fix(vec![format!(
1514                "→ restore it, or remove {} to start {}'s tests over",
1515                dir(&ctx.plane_dir, &ctx.feature, &req.key).display(),
1516                req.key
1517            )]),
1518        )
1519    })?;
1520    let (rejected, mut notes) = earlier
1521        .as_ref()
1522        .filter(|(d, _)| d.requirement.sha256 == req.digest())
1523        .map(|(d, _)| (d.rejected.clone(), d.notes.clone()))
1524        .unwrap_or_default();
1525    notes.dedup();
1526    let told: Vec<String> = rejected
1527        .iter()
1528        .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
1529        .chain(notes.iter().cloned())
1530        .collect();
1531    let (draft, contents) = draft(
1532        ctx,
1533        req,
1534        &author,
1535        &bench,
1536        samples,
1537        &told,
1538        &[],
1539        rejected,
1540        notes,
1541        true,
1542        options,
1543    )?;
1544    store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
1545    let red = draft.red().count();
1546    let open = draft.open_ambiguities().count();
1547    Ok(format!(
1548        "wrote {} test(s) for {}: {red} red{}{}",
1549        draft.tests.len(),
1550        req.key,
1551        if draft.held_out.state == "kept" {
1552            format!(", {} held out", draft.held_out.count)
1553        } else {
1554            String::new()
1555        },
1556        if open > 0 {
1557            format!(
1558                "; {} — {open} scenario(s) read two ways: choose a reading or reword (`speccoder review tests {} --reading A|B <scenario>`)",
1559                req.key, req.key
1560            )
1561        } else {
1562            String::new()
1563        }
1564    ))
1565}
1566
1567/// Each scenario's file, named after it where the runner collects tests
1568/// (`US2-1` → `tests/test_acceptance_us2_1.py`) and free in `project`: a
1569/// file there already (an older task's test) is never the oracle's.
1570fn places(
1571    project: &Path,
1572    root: &Path,
1573    req: &OracleRequirement,
1574) -> Vec<substrate::acceptance::ScenarioPlace> {
1575    let layout = crate::layout::detect(project, &crate::verify::check_argvs(root));
1576    req.scenarios
1577        .iter()
1578        .filter_map(|s| {
1579            let name = s.id.replace('-', "_");
1580            let path = (1..10)
1581                .filter_map(|n| {
1582                    let name = if n == 1 {
1583                        name.clone()
1584                    } else {
1585                        format!("{name}_{n}")
1586                    };
1587                    layout.acceptance_file(&name, None)
1588                })
1589                .find(|p| !project.join(p).exists())?;
1590            Some(substrate::acceptance::ScenarioPlace {
1591                scenario: s.id.clone(),
1592                command: layout.command_for(&path).unwrap_or_default(),
1593                path,
1594            })
1595        })
1596        .collect()
1597}
1598
1599/// One author pass over `req` (`samples` calls), its tests red-checked on
1600/// `bench`, the held-out set sealed: the draft and its tests' contents.
1601#[allow(clippy::too_many_arguments)]
1602fn draft(
1603    ctx: &Ctx,
1604    req: &OracleRequirement,
1605    author: &Author<'_>,
1606    bench: &Bench,
1607    samples: u8,
1608    told: &[String],
1609    refused: &[String],
1610    rejected: Vec<OracleRejected>,
1611    notes: Vec<String>,
1612    with_held_out: bool,
1613    options: &crate::RunOptions,
1614) -> Result<Loaded, Box<CommandResult>> {
1615    let (files, manifest) = bench.files();
1616    let places = places(&bench.project, &ctx.root, req);
1617    let of = u32::from(samples);
1618    stage(options, &req.key, "writing", 1, of);
1619    let (first, run1, mut context) = author
1620        .call(req, &files, &manifest, told, refused, None, 1, &places)
1621        .map_err(failed)?;
1622    // The comparison needs both readings; with one sample they are kept
1623    // as stated, for the card.
1624    let read = |answer: &Value| {
1625        let got = expectations(answer, req);
1626        if samples >= 2 {
1627            got.map_err(failed)
1628        } else {
1629            Ok(got.unwrap_or_default())
1630        }
1631    };
1632    let (e1, u1) = read(&first)?;
1633    let mut runs = vec![run1];
1634    let (mut e2, mut u2) = (Vec::new(), Vec::new());
1635    if samples >= 2 {
1636        stage(options, &req.key, "writing", 2, of);
1637        let (second, run2, more) = author
1638            .call(req, &files, &manifest, told, refused, None, 2, &places)
1639            .map_err(failed)?;
1640        (e2, u2) = read(&second)?;
1641        runs.push(run2);
1642        for c in more {
1643            if !context.contains(&c) {
1644                context.push(c);
1645            }
1646        }
1647        stage(options, &req.key, "comparing", 1, 1);
1648    }
1649    let ambiguities = if samples >= 2 {
1650        speccoder_core::oracle::compare(&e1, &u1, &e2, &u2)
1651    } else {
1652        Vec::new()
1653    };
1654    let (kept, mut discarded) = keep(ctx, bench, &first, req, &[]);
1655    stage(options, &req.key, "checking_red", 1, 1);
1656    let plan: Vec<(String, String, Vec<String>)> = kept
1657        .iter()
1658        .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1659        .collect();
1660    let classes = bench.red(&plan).map_err(failed)?;
1661    let mut tests: Vec<DraftTest> = Vec::new();
1662    let mut contents: Vec<(String, String)> = Vec::new();
1663    for ((path, content, cited, argv), class) in kept.into_iter().zip(classes) {
1664        match class {
1665            Ok(red_class) => {
1666                tests.push(DraftTest {
1667                    path: path.clone(),
1668                    sha256: digest_of(&content),
1669                    scenarios: cited,
1670                    argv,
1671                    red_class,
1672                    authored_by: AuthoredBy::Model,
1673                    edited_by: None,
1674                });
1675                contents.push((path, content));
1676            }
1677            Err(why) => discarded.push(OracleDiscarded {
1678                path,
1679                why: why.to_owned(),
1680            }),
1681        }
1682    }
1683    // An amendment keeps the story's sealed set: none is written for it.
1684    let (held_out, sealed) = if with_held_out {
1685        held_out(ctx, author, bench, req, &tests, options)
1686    } else {
1687        (
1688            HeldOutSummary {
1689                state: "off".to_owned(),
1690                count: 0,
1691                manifest_sha256: None,
1692                reason: None,
1693                discarded: Vec::new(),
1694            },
1695            None,
1696        )
1697    };
1698    let draft = Draft {
1699        requirement: DraftRequirement {
1700            key: req.key.clone(),
1701            title: req.title.clone(),
1702            text: req.text.clone(),
1703            sha256: req.digest(),
1704        },
1705        samples,
1706        tests,
1707        discarded,
1708        rejected,
1709        expectations: Samples {
1710            sample1: e1,
1711            sample2: e2,
1712        },
1713        untestable: Samples {
1714            sample1: u1,
1715            sample2: u2,
1716        },
1717        ambiguities,
1718        held_out,
1719        sealed,
1720        notes,
1721        runs,
1722        context,
1723    };
1724    Ok((draft, contents))
1725}
1726
1727/// The held-out set at review, after a red visible set: authored as at
1728/// run time, red by overlay, sealed under `(unit, REQ)`; only its
1729/// count and digest come back.
1730fn held_out(
1731    ctx: &Ctx,
1732    author: &Author<'_>,
1733    bench: &Bench,
1734    req: &OracleRequirement,
1735    tests: &[DraftTest],
1736    options: &crate::RunOptions,
1737) -> (HeldOutSummary, Option<SealedRef>) {
1738    let summary = |state: &str, reason: Option<&str>| HeldOutSummary {
1739        state: state.to_owned(),
1740        count: 0,
1741        manifest_sha256: None,
1742        reason: reason.map(str::to_owned),
1743        discarded: Vec::new(),
1744    };
1745    if !ctx.config.held_out {
1746        return (summary("off", None), None);
1747    }
1748    let red: Vec<&DraftTest> = tests.iter().filter(|t| t.red_class.is_red()).collect();
1749    if red.is_empty() {
1750        return (summary("absent", Some("not_red")), None);
1751    }
1752    stage(options, &req.key, "held_out", 1, 1);
1753    let visible = crate::pipeline::PreparedAcceptance {
1754        report: json!({ "files": red.iter().map(|t| t.path.clone()).collect::<Vec<_>>() }),
1755        argv: red.first().map(|t| t.argv.clone()),
1756        more_argv: Vec::new(),
1757        red: true,
1758        red_class: Some(RedClass::Assertion),
1759        held_out: None,
1760        generation_failed: None,
1761        skip_cause: None,
1762    };
1763    let task = format!("req-{}", req.key);
1764    let store = crate::held_out::sealed_dir(&ctx.plane_dir, &ctx.feature, &req.key);
1765    let (report, sha) = crate::pipeline::prepare_held_out(
1766        &crate::pipeline::AcceptanceSetup {
1767            rt: &author.rt,
1768            plane: &author.plane,
1769            project: &bench.project,
1770            worktree: &bench.root,
1771            tmp: &bench.tmp,
1772            policy: &ctx.policy,
1773            feature: &ctx.feature,
1774            task: &task,
1775            bundle: author.bundle.clone(),
1776            scenarios: &req.scenarios,
1777            instructions: &author.instructions,
1778            runner: &bench.runner,
1779            layout: None,
1780            told: &[],
1781            related: &[],
1782            oracle: true,
1783        },
1784        &visible,
1785        &req.scenarios,
1786        &store,
1787        false,
1788    );
1789    // The held-out author's call is the oracle's cost too.
1790    let total =
1791        crate::pipeline::commit::add_usage(author.spent.borrow().as_ref(), report.get("usage"));
1792    *author.spent.borrow_mut() = total;
1793    match sha {
1794        Some(sha) => {
1795            let count = report["kept"]
1796                .as_u64()
1797                .and_then(|n| usize::try_from(n).ok())
1798                .unwrap_or(0);
1799            (
1800                HeldOutSummary {
1801                    state: "kept".to_owned(),
1802                    count,
1803                    manifest_sha256: Some(format!("sha256:{sha}")),
1804                    reason: None,
1805                    discarded: Vec::new(),
1806                },
1807                Some(SealedRef {
1808                    key: req.key.clone(),
1809                    manifest_sha256: sha,
1810                    count,
1811                }),
1812            )
1813        }
1814        None => (
1815            HeldOutSummary {
1816                discarded: report["discarded"]
1817                    .as_array()
1818                    .into_iter()
1819                    .flatten()
1820                    .filter_map(|d| d["reason"].as_str().map(str::to_owned))
1821                    .collect(),
1822                ..summary(
1823                    "absent",
1824                    Some(report["reason"].as_str().unwrap_or("none_kept")),
1825                )
1826            },
1827            None,
1828        ),
1829    }
1830}
1831
1832fn reject(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1833    let Some(path) = r.path else {
1834        return Err(usage("name the test to reject (--reject PATH)"));
1835    };
1836    let Some(reason) = r.text.map(str::trim).filter(|t| !t.is_empty()) else {
1837        return Err(usage(
1838            "a rejection needs a reason: why is this test wrong? (--reason \"…\")",
1839        ));
1840    };
1841    let (mut d, contents) = existing(ctx, req)?;
1842    let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1843        return Err(usage(&format!("{} has no test {path}", req.key)));
1844    };
1845    d.tests.remove(at);
1846    d.rejected.push(OracleRejected {
1847        path: path.to_owned(),
1848        reason: reason.to_owned(),
1849        by: by.to_owned(),
1850        at: now(),
1851    });
1852    store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1853    Ok(format!(
1854        "rejected {path} of {}: \"{reason}\" — later author calls for {} are told",
1855        req.key, req.key
1856    ))
1857}
1858
1859fn edit(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1860    let Some(path) = r.path else {
1861        return Err(usage("name the test to edit (--edit PATH)"));
1862    };
1863    let Some(content) = r.text.filter(|t| !t.trim().is_empty()) else {
1864        return Err(usage("an edit needs the test's new content (--from FILE)"));
1865    };
1866    let (mut d, mut contents) = existing(ctx, req)?;
1867    let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1868        return Err(usage(&format!("{} has no test {path}", req.key)));
1869    };
1870    let bench = Bench::open(ctx)?;
1871    let plan = [(
1872        path.to_owned(),
1873        content.to_owned(),
1874        d.tests[at].argv.clone(),
1875    )];
1876    let checked = bench
1877        .red(&plan)
1878        .map_err(failed)?
1879        .pop()
1880        .unwrap_or(Ok(RedClass::Unclassified));
1881    // Red without the test: its command decides nothing about it.
1882    let class = checked.unwrap_or(RedClass::Unclassified);
1883    let t = &mut d.tests[at];
1884    t.sha256 = digest_of(content);
1885    t.red_class = class;
1886    t.authored_by = AuthoredBy::Person;
1887    t.edited_by = Some(by.to_owned());
1888    if let Some(c) = contents.iter_mut().find(|(p, _)| p == path) {
1889        c.1 = content.to_owned();
1890    }
1891    store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1892    Ok(format!(
1893        "edited {path} of {}: {}",
1894        req.key,
1895        if class.is_red() {
1896            format!("red ({})", crate::pipeline::class_word(class))
1897        } else if checked.is_err() {
1898            "its command is red without it — it decides nothing about this test, which cannot be frozen".to_owned()
1899        } else {
1900            format!(
1901                "not red ({}) — no evidence, it cannot be frozen",
1902                crate::pipeline::class_word(class)
1903            )
1904        }
1905    ))
1906}
1907
1908/// Writes tests anew from one author call (`again`, a reading): the
1909/// answer's citing files replace `replaced`, red-checked beside the rest.
1910#[allow(clippy::too_many_arguments)]
1911fn rewrite(
1912    ctx: &Ctx,
1913    req: &OracleRequirement,
1914    mut d: Draft,
1915    mut contents: Vec<(String, String)>,
1916    replaced: &[String],
1917    note: String,
1918    again: Option<(&str, &str)>,
1919    only: Option<&str>,
1920    provider: Option<Arc<dyn ModelProvider>>,
1921    options: &crate::RunOptions,
1922) -> Result<(Draft, Vec<String>), Box<CommandResult>> {
1923    let author = Author::open(ctx, req, provider, options)?;
1924    let bench = Bench::open(ctx)?;
1925    let (files, manifest) = bench.files();
1926    d.notes.push(note);
1927    let told = d.all_notes();
1928    stage(options, &req.key, "writing", 1, 1);
1929    let (answer, run, context) = author
1930        .call(req, &files, &manifest, &told, &[], again, 1, &[])
1931        .map_err(failed)?;
1932    d.runs.push(run);
1933    for c in context {
1934        if !d.context.contains(&c) {
1935            d.context.push(c);
1936        }
1937    }
1938    let keep_paths: Vec<(String, Vec<String>)> = d
1939        .tests
1940        .iter()
1941        .filter(|t| !replaced.contains(&t.path))
1942        .map(|t| (t.path.clone(), t.argv.clone()))
1943        .collect();
1944    // The replaced test's own path may come back.
1945    let mut answer = answer;
1946    if let Some(files) = answer["files"].as_array_mut() {
1947        files.retain(|f| {
1948            only.is_none_or(|s| {
1949                f["scenarios"]
1950                    .as_array()
1951                    .is_some_and(|c| c.iter().any(|v| v.as_str() == Some(s)))
1952            })
1953        });
1954    }
1955    let (new, discarded) = keep(ctx, &bench, &answer, req, &keep_paths);
1956    if new.is_empty() {
1957        return Err(failed(format!(
1958            "the test author wrote no usable test{}",
1959            discarded
1960                .first()
1961                .map(|x| format!(" ({} {})", x.path, x.why))
1962                .unwrap_or_default()
1963        )));
1964    }
1965    d.tests.retain(|t| !replaced.contains(&t.path));
1966    contents.retain(|(p, _)| !replaced.contains(p));
1967    d.discarded.extend(discarded);
1968    stage(options, &req.key, "checking_red", 1, 1);
1969    // Each test is red-checked alone: the kept ones stand as checked.
1970    let plan: Vec<(String, String, Vec<String>)> = new
1971        .iter()
1972        .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1973        .collect();
1974    let classes = bench.red(&plan).map_err(failed)?;
1975    let mut written = Vec::new();
1976    for ((path, content, cited, argv), class) in new.into_iter().zip(classes) {
1977        let red_class = match class {
1978            Ok(c) => c,
1979            Err(why) => {
1980                d.discarded.push(OracleDiscarded {
1981                    path,
1982                    why: why.to_owned(),
1983                });
1984                continue;
1985            }
1986        };
1987        d.tests.push(DraftTest {
1988            sha256: digest_of(&content),
1989            scenarios: cited,
1990            argv,
1991            red_class,
1992            authored_by: AuthoredBy::Model,
1993            edited_by: None,
1994            path: path.clone(),
1995        });
1996        contents.push((path.clone(), content));
1997        written.push(path);
1998    }
1999    store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
2000    Ok((d, written))
2001}
2002
2003fn again(
2004    ctx: &Ctx,
2005    req: &OracleRequirement,
2006    r: &Request<'_>,
2007    _by: &str,
2008    provider: Option<Arc<dyn ModelProvider>>,
2009    options: &crate::RunOptions,
2010) -> Outcome {
2011    let Some(path) = r.path else {
2012        return Err(usage("name the test to write again (--again PATH)"));
2013    };
2014    let comment = r
2015        .text
2016        .map(str::trim)
2017        .filter(|t| !t.is_empty())
2018        .unwrap_or("write it again");
2019    let (d, contents) = existing(ctx, req)?;
2020    let Some(content) = contents
2021        .iter()
2022        .find(|(p, _)| p == path)
2023        .map(|(_, c)| c.clone())
2024    else {
2025        return Err(usage(&format!("{} has no test {path}", req.key)));
2026    };
2027    let (d, written) = rewrite(
2028        ctx,
2029        req,
2030        d,
2031        contents,
2032        &[path.to_owned()],
2033        format!("Comment on `{path}`: {comment}"),
2034        Some((path, &content)),
2035        None,
2036        provider,
2037        options,
2038    )?;
2039    let red = d
2040        .tests
2041        .iter()
2042        .filter(|t| written.contains(&t.path) && t.red_class.is_red())
2043        .count();
2044    Ok(format!(
2045        "wrote {path} of {} again: {}",
2046        req.key,
2047        if written.is_empty() {
2048            "nothing kept — its command is red without it, so it decides nothing".to_owned()
2049        } else if red == written.len() {
2050            "red".to_owned()
2051        } else {
2052            "not red — no evidence, it cannot be frozen".to_owned()
2053        }
2054    ))
2055}
2056
2057fn reading(
2058    ctx: &Ctx,
2059    req: &OracleRequirement,
2060    r: &Request<'_>,
2061    by: &str,
2062    provider: Option<Arc<dyn ModelProvider>>,
2063    options: &crate::RunOptions,
2064) -> Outcome {
2065    let (Some(scenario), Some(choice)) = (r.scenario, r.reading) else {
2066        return Err(usage(
2067            "name the reading and the scenario (--reading A|B US1-2)",
2068        ));
2069    };
2070    let (mut d, contents) = existing(ctx, req)?;
2071    let Some(item) = d.ambiguities.iter_mut().find(|a| a.scenario == scenario) else {
2072        return Err(usage(&format!(
2073            "{} has no ambiguity on {scenario}",
2074            req.key
2075        )));
2076    };
2077    item.resolution = Some(Resolution {
2078        reading: choice,
2079        by: by.to_owned(),
2080        at: now(),
2081    });
2082    let meant = if choice == Reading::A {
2083        item.a.clone()
2084    } else {
2085        item.b.clone()
2086    };
2087    if choice == Reading::A {
2088        d.notes.push(format!(
2089            "For {scenario} the person means reading A: given {}, expect {}",
2090            meant.given, meant.expect
2091        ));
2092        store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
2093        return Ok(format!(
2094            "{scenario} of {}: reading A is meant — the tests stay",
2095            req.key
2096        ));
2097    }
2098    let replaced: Vec<String> = d
2099        .tests
2100        .iter()
2101        .filter(|t| t.scenarios.iter().any(|s| s == scenario))
2102        .map(|t| t.path.clone())
2103        .collect();
2104    let (_, written) = rewrite(
2105        ctx,
2106        req,
2107        d,
2108        contents,
2109        &replaced,
2110        format!(
2111            "For {scenario} the person means reading B: given {}, expect {}",
2112            meant.given, meant.expect
2113        ),
2114        None,
2115        Some(scenario),
2116        provider,
2117        options,
2118    )?;
2119    Ok(format!(
2120        "{scenario} of {}: reading B is meant — wrote {} anew",
2121        req.key,
2122        written.join(", ")
2123    ))
2124}
2125
2126/// A requirement whose tests an approval can freeze: its draft on the
2127/// current wording, no reading open, at least one red test.
2128#[derive(Debug, Clone)]
2129pub struct Freezable {
2130    /// The requirement.
2131    pub req: OracleRequirement,
2132    /// Its draft.
2133    pub draft: Draft,
2134    /// The red tests' contents.
2135    pub contents: Vec<(String, String)>,
2136    /// The red set's manifest digest.
2137    pub manifest: String,
2138}
2139
2140/// The requirements an approval freezes, in the spec's order. `Err`: a
2141/// stored draft was tampered with.
2142pub fn freezable(
2143    project_state: &Path,
2144    unit: &str,
2145    requirements: &[OracleRequirement],
2146) -> Result<Vec<Freezable>, Tampered> {
2147    let mut out = Vec::new();
2148    for r in requirements {
2149        let Some((draft, contents)) = load(project_state, unit, &r.key)? else {
2150            continue;
2151        };
2152        if draft.requirement.sha256 != r.digest() || draft.open_ambiguities().next().is_some() {
2153            continue;
2154        }
2155        let Some(manifest) = draft.manifest() else {
2156            continue;
2157        };
2158        let red: Vec<String> = draft.red().map(|t| t.path.clone()).collect();
2159        out.push(Freezable {
2160            req: r.clone(),
2161            contents: contents
2162                .into_iter()
2163                .filter(|(p, _)| red.contains(p))
2164                .collect(),
2165            draft,
2166            manifest,
2167        });
2168    }
2169    Ok(out)
2170}
2171
2172/// Freezes one requirement's red set with the approval: one
2173/// [`PreFrozenAcceptance`] (producer `review`) in the pre-frozen store,
2174/// its sealed held-out set checked, and the commands and wording beside it.
2175pub fn freeze(
2176    project_state: &Path,
2177    unit: &str,
2178    f: &Freezable,
2179    approval_digest: &str,
2180    decided: Decided,
2181    producer: Producer,
2182) -> Result<PreFrozenAcceptance, String> {
2183    let red: Vec<&DraftTest> = f.draft.red().collect();
2184    freeze_tests(
2185        project_state,
2186        unit,
2187        f,
2188        &red,
2189        approval_digest,
2190        decided,
2191        producer,
2192    )
2193}
2194
2195/// [`freeze`] of `tests` (red, and at a run already passing too).
2196fn freeze_tests(
2197    project_state: &Path,
2198    unit: &str,
2199    f: &Freezable,
2200    red: &[&DraftTest],
2201    approval_digest: &str,
2202    decided: Decided,
2203    producer: Producer,
2204) -> Result<PreFrozenAcceptance, String> {
2205    let sealed = match &f.draft.sealed {
2206        Some(s) => {
2207            crate::held_out::open_sealed(project_state, unit, s)
2208                .map_err(|t| format!("{}'s held-out set was tampered with: {}", f.req.key, t.0))?;
2209            Some(s.clone())
2210        }
2211        None => None,
2212    };
2213    let record = PreFrozenAcceptance {
2214        unit: unit.to_owned(),
2215        requirement: f.req.key.clone(),
2216        wording_digest: f.draft.requirement.sha256.clone(),
2217        approval_digest: approval_digest.to_owned(),
2218        visible: red
2219            .iter()
2220            .map(|t| VisibleTest {
2221                path: t.path.clone(),
2222                sha256: t.sha256.clone(),
2223                cites: t.scenarios.clone(),
2224            })
2225            .collect(),
2226        held_out_sealed: sealed,
2227        producer,
2228        decided,
2229    };
2230    crate::pre_frozen::store(project_state, &record, &f.contents)?;
2231    let frozen = Frozen {
2232        requirement_sha256: f.draft.requirement.sha256.clone(),
2233        wording: format!(
2234            "{}\n{}",
2235            f.draft.requirement.title, f.draft.requirement.text
2236        ),
2237        manifest_sha256: f.manifest.clone(),
2238        tests: red
2239            .iter()
2240            .map(|t| FrozenTest {
2241                path: t.path.clone(),
2242                sha256: t.sha256.clone(),
2243                argv: t.argv.clone(),
2244                scenarios: t.scenarios.clone(),
2245            })
2246            .collect(),
2247    };
2248    let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2249    write_atomic(
2250        &dir(project_state, unit, &f.req.key).join("frozen.json"),
2251        &text,
2252    )?;
2253    Ok(record)
2254}
2255
2256/// Why an oracle test cannot be kept, if it cannot. Kept: red on an
2257/// assertion, already passing, or — in a compiled language, where a test of
2258/// what does not exist yet cannot build — red by an absent symbol (in
2259/// Python or JavaScript an absent import stops the whole run's
2260/// collection). A file of several scenarios, when its story has more, is
2261/// never kept: its scenarios could only turn green together.
2262/// A discarded test's reason, in words (`exists` → "already exists").
2263fn discard_words(why: &str) -> &'static str {
2264    match why {
2265        "cites_no_scenario" => "cites no scenario of the list",
2266        "no_command" => {
2267            "never runs the project's command — the story's interface is the command, not a module behind it"
2268        }
2269        "command_shared" => {
2270            "shares its command with another test — each test needs the command that runs only it"
2271        }
2272        "command_not_allowed" => "gives a command the policy does not allow",
2273        "limit" => "is beyond the number of tests one answer may hold",
2274        "red_without_it" => "fails without its own file — its command runs other, failing tests",
2275        _ => crate::pipeline::acceptance::refusal_words(why),
2276    }
2277}
2278
2279fn not_kept(t: &DraftTest, many: bool) -> Option<String> {
2280    if many && t.scenarios.len() > 1 {
2281        return Some(format!(
2282            "it tests {} together — one file per scenario, so each turns green on its own",
2283            t.scenarios.join(", ")
2284        ));
2285    }
2286    (!usable(t)).then(|| {
2287        format!(
2288            "it did not fail on an assertion ({})",
2289            crate::pipeline::class_word(t.red_class)
2290        )
2291    })
2292}
2293
2294fn usable(t: &DraftTest) -> bool {
2295    match t.red_class {
2296        RedClass::Assertion | RedClass::Passed => true,
2297        RedClass::Absent => [".rs", ".go", ".java", ".kt", ".cs", ".swift"]
2298            .iter()
2299            .any(|e| t.path.ends_with(e)),
2300        _ => false,
2301    }
2302}
2303
2304/// A story's oracle as a run wrote it: the kept tests (red, or already
2305/// passing), their contents, each scenario's contract, and what could not
2306/// be kept.
2307#[derive(Debug)]
2308pub(crate) struct RunOracle {
2309    /// Each kept test with its command and scenarios (none: the author
2310    /// found nothing testable).
2311    pub tests: Vec<FrozenTest>,
2312    /// Their contents (path, text).
2313    pub contents: Vec<(String, String)>,
2314    /// What each scenario must hold, as the author stated it.
2315    pub contracts: Vec<Expectation>,
2316    /// The scenarios the author could not test, and why.
2317    pub untestable: Vec<Untestable>,
2318    /// The token usage of its author calls.
2319    pub usage: Option<Value>,
2320}
2321
2322/// Writes `story`'s oracle for a run nobody reviewed: one author pass on
2323/// the task's own worktree (`wt_root`, its project at `project`, clean at
2324/// the tree the task starts from), every test checked alone; a scenario
2325/// left without a usable test is asked for once more. Tests red or already
2326/// passing are kept and frozen by `speccoder run`; the held-out set is
2327/// sealed beside them. The worktree is left as it was.
2328#[allow(clippy::too_many_arguments)]
2329pub(crate) fn write_for_run(
2330    project: &Path,
2331    wt_root: &Path,
2332    feature: &str,
2333    state: &Path,
2334    story: &str,
2335    runner: sandbox::Runner,
2336    (rt, plane): (&tokio::runtime::Runtime, &substrate::Plane),
2337    (only, told): (&[String], &[String]),
2338    run: &str,
2339    options: &crate::RunOptions,
2340) -> Result<RunOracle, Box<CommandResult>> {
2341    let ctx = open(project, Some(feature), state)?;
2342    let requirements = ctx.format.oracle_requirements(&ctx.snapshot);
2343    // The unit as one story (OpenSpec): every requirement's scenarios.
2344    let whole = (story == ctx.feature && !requirements.iter().any(|r| r.key == story)).then(|| {
2345        OracleRequirement {
2346            key: story.to_owned(),
2347            title: ctx.feature.clone(),
2348            text: requirements
2349                .iter()
2350                .map(|r| format!("{}: {}\n{}", r.key, r.title, r.text))
2351                .collect::<Vec<_>>()
2352                .join("\n\n"),
2353            scenarios: requirements
2354                .iter()
2355                .flat_map(|r| r.scenarios.clone())
2356                .collect(),
2357        }
2358    });
2359    let Some(mut req) = whole.or_else(|| requirements.into_iter().find(|r| r.key == story)) else {
2360        return Err(Box::new(refuse(
2361            CommandStatus::NeedsHuman,
2362            "ORACLE-UNWRITTEN",
2363            &format!("{story} has no scenarios to write tests for"),
2364        )));
2365    };
2366    // An amendment: only the scenarios a person ruled wrong, told why.
2367    if !only.is_empty() {
2368        req.scenarios.retain(|s| only.contains(&s.id));
2369    }
2370    let _lock = UnitLock::take(&ctx.plane_dir, &ctx.feature)
2371        .map_err(|e| Box::new(refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", &e)))?;
2372    let author = Author::lent(&ctx, &req, rt, plane);
2373    let many = req.scenarios.len() > 1;
2374    let bench = Bench::here(&ctx, wt_root, runner);
2375    let kept = |d: &Draft| -> Vec<String> {
2376        let mut covered: Vec<String> = d
2377            .tests
2378            .iter()
2379            .filter(|t| not_kept(t, many).is_none())
2380            .flat_map(|t| t.scenarios.iter().cloned())
2381            .collect();
2382        covered.sort();
2383        covered.dedup();
2384        covered
2385    };
2386    // Two passes at most, neither sealing a held-out set: the kept one is
2387    // sealed once, after it is chosen.
2388    let pass = |refused: &[String]| {
2389        draft(
2390            &ctx,
2391            &req,
2392            &author,
2393            &bench,
2394            1,
2395            told,
2396            refused,
2397            Vec::new(),
2398            told.to_vec(),
2399            false,
2400            options,
2401        )
2402    };
2403    let (mut draft, mut contents) = pass(&[])?;
2404    // Asked once more, told every reason in speccoder's words: a test that
2405    // broke or took a path that exists, nothing kept at all, or every
2406    // scenario called untestable with no test written.
2407    let untestable: Vec<String> = draft
2408        .untestable
2409        .sample1
2410        .iter()
2411        .map(|u| u.scenario.clone())
2412        .collect();
2413    let broke =
2414        !draft.discarded.is_empty() || draft.tests.iter().any(|t| not_kept(t, many).is_some());
2415    let left: Vec<String> = req
2416        .scenarios
2417        .iter()
2418        .map(|s| s.id.clone())
2419        .filter(|id| !kept(&draft).contains(id) && !untestable.contains(id))
2420        .collect();
2421    let opted_out = draft.tests.is_empty() && draft.discarded.is_empty() && !untestable.is_empty();
2422    if (broke && (!left.is_empty() || kept(&draft).is_empty())) || opted_out {
2423        let again: Vec<String> = if left.is_empty() {
2424            req.scenarios.iter().map(|s| s.id.clone()).collect()
2425        } else {
2426            left
2427        };
2428        let refused: Vec<String> = draft
2429            .discarded
2430            .iter()
2431            .map(|d| format!("Not kept `{}`: it {}", d.path, discard_words(&d.why)))
2432            .chain(draft.tests.iter().filter_map(|t| {
2433                not_kept(t, many).map(|why| format!("Not kept `{}`: {why}", t.path))
2434            }))
2435            .chain(opted_out.then(|| {
2436                format!(
2437                    "Called untestable: {} — the story's code not existing yet never makes a scenario untestable; test it through the story's interface",
2438                    untestable.join(", ")
2439                )
2440            }))
2441            .chain(std::iter::once(format!(
2442                "Write tests again for {} — each must build and be collected without the code it tests existing yet: exercise the story through its interface, import the code under test inside each test",
2443                again.join(", ")
2444            )))
2445            .collect();
2446        let (second, more) = pass(&refused)?;
2447        if kept(&second).len() > kept(&draft).len() {
2448            (draft, contents) = (second, more);
2449        }
2450    }
2451    // The kept pass's held-out set (an amendment keeps the story's).
2452    if only.is_empty() {
2453        let keep: Vec<DraftTest> = draft
2454            .tests
2455            .iter()
2456            .filter(|t| not_kept(t, many).is_none())
2457            .cloned()
2458            .collect();
2459        if !keep.is_empty() {
2460            (draft.held_out, draft.sealed) = held_out(&ctx, &author, &bench, &req, &keep, options);
2461        }
2462    }
2463    // An amendment's draft is a part: the story's own draft stays.
2464    if only.is_empty() {
2465        store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
2466    }
2467    let keep: Vec<&DraftTest> = draft
2468        .tests
2469        .iter()
2470        .filter(|t| not_kept(t, many).is_none())
2471        .collect();
2472    // The author found nothing testable (no test written at all): no
2473    // oracle, and no stop — the story is judged by its checks.
2474    if keep.is_empty() && draft.tests.is_empty() && draft.discarded.is_empty() {
2475        return Ok(RunOracle {
2476            tests: Vec::new(),
2477            contents: Vec::new(),
2478            contracts: draft.expectations.sample1.clone(),
2479            untestable: draft.untestable.sample1.clone(),
2480            usage: author.spent.borrow().clone(),
2481        });
2482    }
2483    if keep.is_empty() {
2484        let why: Vec<String> = draft
2485            .discarded
2486            .iter()
2487            .map(|d| format!("`{}` {}", d.path, discard_words(&d.why)))
2488            .chain(
2489                draft
2490                    .tests
2491                    .iter()
2492                    .filter_map(|t| not_kept(t, many).map(|why| format!("`{}`: {why}", t.path))),
2493            )
2494            .collect();
2495        return Err(Box::new(
2496            refuse(
2497                CommandStatus::NeedsHuman,
2498                "ORACLE-UNWRITTEN",
2499                &format!(
2500                    "no test written for {story} could be used, asked twice: {}",
2501                    why.join("; ")
2502                ),
2503            )
2504            .with_fix(vec![format!("speccoder review tests {story} --write")]),
2505        ));
2506    }
2507    let manifest = manifest_digest(
2508        keep.iter()
2509            .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2510    );
2511    let paths: Vec<&str> = keep.iter().map(|t| t.path.as_str()).collect();
2512    let kept_contents: Vec<(String, String)> = contents
2513        .iter()
2514        .filter(|(p, _)| paths.contains(&p.as_str()))
2515        .cloned()
2516        .collect();
2517    let f = Freezable {
2518        req: req.clone(),
2519        draft: draft.clone(),
2520        contents: kept_contents.clone(),
2521        manifest,
2522    };
2523    // An amendment is merged into the frozen oracle by its caller; the
2524    // story's record and sealed set stay.
2525    if only.is_empty() {
2526        freeze_tests(
2527            &ctx.plane_dir,
2528            &ctx.feature,
2529            &f,
2530            &keep,
2531            &format!(
2532                "run:{run}{}",
2533                if only.is_empty() { "" } else { ":amendment" }
2534            ),
2535            Decided {
2536                by: "speccoder run".to_owned(),
2537                via: Via::Terminal,
2538                at: now(),
2539            },
2540            Producer::Run,
2541        )
2542        .map_err(failed)?;
2543    }
2544    Ok(RunOracle {
2545        tests: keep
2546            .iter()
2547            .map(|t| FrozenTest {
2548                path: t.path.clone(),
2549                sha256: t.sha256.clone(),
2550                argv: t.argv.clone(),
2551                scenarios: t.scenarios.clone(),
2552            })
2553            .collect(),
2554        contents: kept_contents,
2555        contracts: draft.expectations.sample1.clone(),
2556        untestable: draft.untestable.sample1.clone(),
2557        usage: author.spent.borrow().clone(),
2558    })
2559}
2560
2561/// Replaces the tests a frozen oracle runs (an amendment: the ruled
2562/// scenarios' tests swapped), its manifest recomputed.
2563///
2564/// # Errors
2565///
2566/// Nothing is frozen for `key`, or it cannot be written.
2567pub(crate) fn refreeze(
2568    project_state: &Path,
2569    unit: &str,
2570    key: &str,
2571    tests: Vec<FrozenTest>,
2572) -> Result<(), String> {
2573    let mut frozen = load_frozen(project_state, unit, key)
2574        .ok_or_else(|| format!("{key} has no frozen oracle here"))?;
2575    frozen.manifest_sha256 = manifest_digest(
2576        tests
2577            .iter()
2578            .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2579    );
2580    frozen.tests = tests;
2581    let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2582    write_atomic(&dir(project_state, unit, key).join("frozen.json"), &text)
2583}
2584
2585/// What a task's checkpoint does with a frozen oracle.
2586#[derive(Debug)]
2587pub(crate) enum TaskReuse {
2588    /// Reuse it: write, red-check, no author call.
2589    Ready {
2590        /// The record.
2591        record: Box<PreFrozenAcceptance>,
2592        /// Its files.
2593        files: Vec<(String, String)>,
2594        /// Each test with its command.
2595        tests: Vec<crate::pipeline::ReusedTest>,
2596        /// The manifest digest the tick names.
2597        manifest: String,
2598    },
2599    /// An earlier task reused it (its tick names the manifest).
2600    Reused {
2601        /// The requirement.
2602        req: String,
2603    },
2604    /// `review.json` names a frozen set this machine does not have.
2605    Elsewhere {
2606        /// The requirement.
2607        req: String,
2608    },
2609    /// The requirement's wording changed since it was frozen.
2610    Lapsed {
2611        /// The requirement.
2612        req: String,
2613        /// The wording it was frozen on.
2614        frozen: String,
2615        /// The wording now.
2616        current: String,
2617    },
2618    /// The stored set no longer matches what was frozen.
2619    Tampered(String),
2620    /// `review.json` cannot be read: whether anything is frozen is unknown.
2621    Unreadable(String),
2622}
2623
2624/// The frozen oracle `task` (citing `scenarios`) reuses, if any: the
2625/// first requirement whose scenarios it cites and whose oracle `review.json`
2626/// (in `project`, the worktree's committed copy) froze — unless an earlier
2627/// task's tick in the worktree's history names its manifest.
2628pub(crate) fn for_task(
2629    project_state: &Path,
2630    project: &Path,
2631    wt_root: &Path,
2632    format: &dyn Adapter,
2633    snapshot: &ProjectSnapshot,
2634    scenarios: &[speccoder_core::spec::ScenarioRef],
2635) -> Option<TaskReuse> {
2636    let f = snapshot.feature.as_ref()?;
2637    let unit = f.feature.name.as_str();
2638    let (entries, approval) = match crate::review::oracle_entries(project, &f.feature.dir) {
2639        Ok(x) => x,
2640        // Unreadable is never "nothing frozen": the task stops for a person.
2641        Err(e) => return Some(TaskReuse::Unreadable(e)),
2642    };
2643    if entries.is_empty() {
2644        return None;
2645    }
2646    let ids: Vec<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
2647    let requirements = format.oracle_requirements(snapshot);
2648    let (req, entry) = requirements.iter().find_map(|r| {
2649        let e = entries.get(&r.key)?;
2650        r.scenarios
2651            .iter()
2652            .any(|s| ids.contains(&s.id.as_str()))
2653            .then_some((r, e))
2654    })?;
2655    let digest = req.digest();
2656    if entry.requirement_sha256 != digest {
2657        return Some(TaskReuse::Lapsed {
2658            req: req.key.clone(),
2659            frozen: entry.requirement_sha256.clone(),
2660            current: digest,
2661        });
2662    }
2663    let (record, files) = match crate::pre_frozen::load(project_state, unit, &req.key) {
2664        Ok(Some(x)) => x,
2665        Ok(None) => {
2666            return Some(TaskReuse::Elsewhere {
2667                req: req.key.clone(),
2668            });
2669        }
2670        Err(t) => return Some(TaskReuse::Tampered(t.0)),
2671    };
2672    // Reused by an earlier task: its tick names the frozen manifest in the
2673    // one `Acceptance:` trailer form, which only speccoder's commit writes —
2674    // never decided by a file the implementer could commit (P2).
2675    let needle = format!("Acceptance: review manifest={} ", entry.manifest_sha256);
2676    let reused = crate::git::Git::at(wt_root)
2677        .run(&[
2678            "log",
2679            "--format=%H",
2680            "--fixed-strings",
2681            &format!("--grep={needle}"),
2682            "HEAD",
2683        ])
2684        .map(|out| !out.is_empty());
2685    match reused {
2686        Ok(true) => {
2687            return Some(TaskReuse::Reused {
2688                req: req.key.clone(),
2689            });
2690        }
2691        Ok(false) => {}
2692        Err(e) => {
2693            return Some(TaskReuse::Unreadable(format!(
2694                "the worktree's history cannot be read: {}",
2695                e.0
2696            )));
2697        }
2698    }
2699    if !record.is_current(&digest) {
2700        return Some(TaskReuse::Lapsed {
2701            req: req.key.clone(),
2702            frozen: record.wording_digest.clone(),
2703            current: digest,
2704        });
2705    }
2706    let Some(frozen) = load_frozen(project_state, unit, &req.key) else {
2707        return Some(TaskReuse::Tampered(format!(
2708            "{}'s frozen commands are missing from the state directory",
2709            req.key
2710        )));
2711    };
2712    let manifest = manifest_digest(
2713        frozen
2714            .tests
2715            .iter()
2716            .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2717    );
2718    let same_files = frozen.tests.len() == record.visible.len()
2719        && frozen.tests.iter().all(|t| {
2720            record
2721                .visible
2722                .iter()
2723                .any(|v| v.path == t.path && v.sha256 == t.sha256)
2724        });
2725    if manifest != entry.manifest_sha256 || manifest != frozen.manifest_sha256 || !same_files {
2726        return Some(TaskReuse::Tampered(format!(
2727            "{}'s frozen tests no longer match the manifest review.json names",
2728            req.key
2729        )));
2730    }
2731    // Bound both ways (P3): the approval review.json records, and the
2732    // held-out set it names — a record that dropped either is not reused.
2733    if approval.as_deref() != Some(record.approval_digest.as_str()) {
2734        return Some(TaskReuse::Tampered(format!(
2735            "{}'s frozen set names another approval than review.json{}",
2736            req.key,
2737            if approval.is_none() {
2738                " (which records none)"
2739            } else {
2740                ""
2741            }
2742        )));
2743    }
2744    let sealed = record
2745        .held_out_sealed
2746        .as_ref()
2747        .map(|h| format!("sha256:{}", h.manifest_sha256));
2748    if sealed != entry.held_out_sha256 {
2749        return Some(TaskReuse::Tampered(format!(
2750            "{}'s frozen held-out set is not the one review.json names",
2751            req.key
2752        )));
2753    }
2754    let tests = frozen
2755        .tests
2756        .iter()
2757        .map(|t| crate::pipeline::ReusedTest {
2758            path: t.path.clone(),
2759            cites: t.scenarios.clone(),
2760            argv: t.argv.clone(),
2761            green: false,
2762        })
2763        .collect();
2764    Some(TaskReuse::Ready {
2765        record: Box::new(record),
2766        files,
2767        tests,
2768        manifest,
2769    })
2770}
2771
2772/// Records that `req`'s oracle lapsed from `frozen` to `current`; `true`
2773/// the first time (the `oracle.lapsed` event is emitted once per lapse).
2774pub(crate) fn note_lapse(project_state: &Path, unit: &str, req: &str, frozen: &str) -> bool {
2775    let file = project_state.join(DIR).join(unit).join("lapsed.json");
2776    let mut seen: Vec<String> = std::fs::read_to_string(&file)
2777        .ok()
2778        .and_then(|t| serde_json::from_str(&t).ok())
2779        .unwrap_or_default();
2780    let key = format!("{req}={frozen}");
2781    if seen.contains(&key) {
2782        return false;
2783    }
2784    seen.push(key);
2785    let _ = write_atomic(&file, &serde_json::to_string(&seen).unwrap_or_default());
2786    true
2787}
2788
2789/// The calls an autopilot run refused, as its tool log recorded them.
2790#[must_use]
2791pub fn autopilot_denials(
2792    task: &str,
2793    tool_calls: &[Value],
2794) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2795    tool_calls
2796        .iter()
2797        .enumerate()
2798        .filter(|(_, c)| {
2799            c["decision"] == "deny" && c["reason"].as_str() == Some(tools::policy::AUTOPILOT_DENIED)
2800        })
2801        .map(|(i, c)| {
2802            let object: Option<tools::policy::Object> =
2803                serde_json::from_value(c["object"].clone()).ok();
2804            speccoder_core::oracle::AutopilotDenial {
2805                task: task.to_owned(),
2806                turn: c["turn"]
2807                    .as_u64()
2808                    .and_then(|n| u32::try_from(n).ok())
2809                    .unwrap_or_else(|| u32::try_from(i + 1).unwrap_or(u32::MAX)),
2810                class: object
2811                    .as_ref()
2812                    .map(|o| o.class().as_str().to_owned())
2813                    .unwrap_or_default(),
2814                object: object.as_ref().map_or_else(
2815                    || {
2816                        c["arguments"]["path"]
2817                            .as_str()
2818                            .map(str::to_owned)
2819                            .or_else(|| {
2820                                c["arguments"]["argv"].as_array().map(|a| {
2821                                    a.iter()
2822                                        .filter_map(Value::as_str)
2823                                        .collect::<Vec<_>>()
2824                                        .join(" ")
2825                                })
2826                            })
2827                            .unwrap_or_else(|| c["tool"].as_str().unwrap_or_default().to_owned())
2828                    },
2829                    tools::policy::Object::describe,
2830                ),
2831                reason: tools::policy::AUTOPILOT_DENIED.to_owned(),
2832            }
2833        })
2834        .collect()
2835}
2836
2837/// The last autopilot run's denials of a unit, for Needs You.
2838#[must_use]
2839pub fn last_denials(
2840    project_state: &Path,
2841    unit: &str,
2842) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2843    std::fs::read_to_string(project_state.join(DIR).join(unit).join("autopilot.json"))
2844        .ok()
2845        .and_then(|t| serde_json::from_str(&t).ok())
2846        .unwrap_or_default()
2847}
2848
2849/// Records a run's denials (none: clears them).
2850pub(crate) fn record_denials(
2851    project_state: &Path,
2852    unit: &str,
2853    denials: &[speccoder_core::oracle::AutopilotDenial],
2854) {
2855    let file = project_state.join(DIR).join(unit).join("autopilot.json");
2856    if denials.is_empty() {
2857        let _ = std::fs::remove_file(file);
2858        return;
2859    }
2860    let _ = write_atomic(
2861        &file,
2862        &serde_json::to_string_pretty(denials).unwrap_or_default(),
2863    );
2864}
2865
2866#[cfg(test)]
2867mod tests {
2868    use super::*;
2869
2870    /// Each scenario gets a file named after it, never one a task wrote
2871    /// before the story had an oracle.
2872    #[test]
2873    fn each_scenario_is_placed_in_a_free_file_of_its_own() {
2874        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2875        for (path, text) in [
2876            ("pytest.ini", "[pytest]\n"),
2877            (
2878                "tests/test_acceptance_us2.py",
2879                "def test_t014():\n    pass\n",
2880            ),
2881            (
2882                "tests/test_acceptance_us2_1.py",
2883                "def test_t017():\n    pass\n",
2884            ),
2885        ] {
2886            std::fs::create_dir_all(d.path().join(path).parent().unwrap()).unwrap();
2887            std::fs::write(d.path().join(path), text).unwrap();
2888        }
2889        let scenario = |id: &str| speccoder_core::spec::ScenarioRef {
2890            id: id.to_owned(),
2891            text: String::new(),
2892        };
2893        let req = OracleRequirement {
2894            key: "US2".to_owned(),
2895            title: "File input".to_owned(),
2896            text: String::new(),
2897            scenarios: vec![scenario("US2-1"), scenario("US2-2")],
2898        };
2899        let got: Vec<(String, String)> = places(d.path(), d.path(), &req)
2900            .into_iter()
2901            .map(|p| (p.scenario, p.path))
2902            .collect();
2903        assert_eq!(
2904            got,
2905            [
2906                (
2907                    "US2-1".to_owned(),
2908                    "tests/test_acceptance_us2_1_2.py".to_owned()
2909                ),
2910                (
2911                    "US2-2".to_owned(),
2912                    "tests/test_acceptance_us2_2.py".to_owned()
2913                ),
2914            ]
2915        );
2916    }
2917
2918    fn draft(content: &str) -> Draft {
2919        Draft {
2920            requirement: DraftRequirement {
2921                key: "US1".to_owned(),
2922                title: "Greet".to_owned(),
2923                text: "### User Story 1 - Greet".to_owned(),
2924                sha256: "sha256:w".to_owned(),
2925            },
2926            samples: 2,
2927            tests: vec![DraftTest {
2928                path: "tests/acceptance_us1.rs".to_owned(),
2929                sha256: digest_of(content),
2930                scenarios: vec!["US1-1".to_owned()],
2931                argv: vec!["cargo".to_owned(), "test".to_owned()],
2932                red_class: RedClass::Assertion,
2933                authored_by: AuthoredBy::Model,
2934                edited_by: None,
2935            }],
2936            discarded: Vec::new(),
2937            rejected: Vec::new(),
2938            expectations: Samples::default(),
2939            untestable: Samples::default(),
2940            ambiguities: Vec::new(),
2941            held_out: HeldOutSummary {
2942                state: "absent".to_owned(),
2943                count: 0,
2944                manifest_sha256: None,
2945                reason: None,
2946                discarded: Vec::new(),
2947            },
2948            sealed: None,
2949            notes: Vec::new(),
2950            runs: Vec::new(),
2951            context: Vec::new(),
2952        }
2953    }
2954
2955    #[test]
2956    fn a_draft_round_trips_and_a_changed_test_is_tampering() {
2957        let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2958        let d = draft("#[test] fn t() {}");
2959        let files = vec![(
2960            "tests/acceptance_us1.rs".to_owned(),
2961            "#[test] fn t() {}".to_owned(),
2962        )];
2963        store(state.path(), "001-greet", &d, &files).unwrap_or_else(|e| panic!("{e}"));
2964        let (back, contents) = load(state.path(), "001-greet", "US1")
2965            .unwrap_or_else(|e| panic!("{e:?}"))
2966            .unwrap_or_else(|| panic!("stored"));
2967        assert_eq!(back, d);
2968        assert_eq!(contents, files);
2969        std::fs::write(
2970            dir(state.path(), "001-greet", "US1").join("files/tests/acceptance_us1.rs"),
2971            "changed",
2972        )
2973        .unwrap_or_else(|e| panic!("{e}"));
2974        assert!(load(state.path(), "001-greet", "US1").is_err());
2975        assert!(store(state.path(), "001-greet", &d, &[]).is_err());
2976        assert!(
2977            load(state.path(), "001-greet", "US2")
2978                .unwrap_or_default()
2979                .is_none()
2980        );
2981    }
2982
2983    #[test]
2984    fn the_manifest_names_paths_contents_and_commands() {
2985        let argv = vec!["cargo".to_owned()];
2986        let other = vec!["make".to_owned()];
2987        let a = manifest_digest([("a", "sha256:1", argv.as_slice())]);
2988        assert_eq!(a, manifest_digest([("a", "sha256:1", argv.as_slice())]));
2989        assert_ne!(a, manifest_digest([("a", "sha256:2", argv.as_slice())]));
2990        assert_ne!(a, manifest_digest([("a", "sha256:1", other.as_slice())]));
2991        let both = manifest_digest([("a", "s", argv.as_slice()), ("b", "s", argv.as_slice())]);
2992        assert_eq!(
2993            both,
2994            manifest_digest([("b", "s", argv.as_slice()), ("a", "s", argv.as_slice())])
2995        );
2996        let mut d = draft("x");
2997        assert!(d.manifest().is_some());
2998        d.tests[0].red_class = RedClass::Passed;
2999        assert!(d.manifest().is_none());
3000        assert_eq!(slug("cap: a / b"), "cap__a___b");
3001    }
3002
3003    #[test]
3004    fn a_lapse_is_noted_once() {
3005        let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
3006        assert!(note_lapse(state.path(), "u", "US1", "sha256:a"));
3007        assert!(!note_lapse(state.path(), "u", "US1", "sha256:a"));
3008        assert!(note_lapse(state.path(), "u", "US1", "sha256:b"));
3009    }
3010}