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        },
412        strength_target: cfg.strength_target,
413        calls: cfg.samples,
414        ambiguity_check: cfg.samples >= 2,
415        manifest_sha256: None,
416        error: (!cfg.acceptance)
417            .then(|| "acceptance tests are off ([acceptance] generate = false)".to_owned()),
418    };
419    let loaded = load(project_state, unit, &req.key);
420    let draft = match loaded {
421        Ok(d) => d,
422        Err(t) => {
423            v.state = OracleState::Failed;
424            v.error = Some(t.0);
425            None
426        }
427    };
428    let current = draft
429        .as_ref()
430        .filter(|(d, _)| d.requirement.sha256 == digest);
431    if let Some((d, contents)) = current {
432        let content = |p: &str| {
433            contents
434                .iter()
435                .find(|(q, _)| q == p)
436                .map(|(_, c)| c.as_str())
437                .unwrap_or_default()
438        };
439        v.tests = d
440            .tests
441            .iter()
442            .map(|t| test_view(t, content(&t.path)))
443            .collect();
444        v.discarded.clone_from(&d.discarded);
445        v.rejected.clone_from(&d.rejected);
446        v.untestable.clone_from(&d.untestable.sample1);
447        v.ambiguities.clone_from(&d.ambiguities);
448        v.held_out = if cfg.held_out {
449            d.held_out.clone()
450        } else {
451            HeldOutSummary {
452                state: "off".to_owned(),
453                count: 0,
454                manifest_sha256: None,
455                reason: None,
456            }
457        };
458        v.manifest_sha256 = d.manifest();
459        let covered: Vec<&String> = d.red().flat_map(|t| t.scenarios.iter()).collect();
460        v.uncovered.retain(|s| !covered.contains(&s));
461        v.state = if d.open_ambiguities().next().is_some() {
462            OracleState::Ambiguous
463        } else if d.red().next().is_none() {
464            OracleState::NotRed
465        } else {
466            OracleState::Written
467        };
468    } else if draft.is_some() {
469        v.error = Some("written for an earlier wording: write them again".to_owned());
470    }
471    if let Some(e) = entry {
472        if e.requirement_sha256 == digest {
473            v.frozen_by = Some(e.by.clone());
474            v.frozen_at = Some(e.at);
475            v.state = match crate::pre_frozen::load(project_state, unit, &req.key) {
476                Ok(Some((rec, files))) => {
477                    // The card shows what the run uses: the frozen set,
478                    // never a draft changed since (P4).
479                    let drafted = current.map(|(d, _)| d);
480                    v.tests = rec
481                        .visible
482                        .iter()
483                        .map(|t| {
484                            let same = drafted.and_then(|d| {
485                                d.tests
486                                    .iter()
487                                    .find(|x| x.path == t.path && x.sha256 == t.sha256)
488                            });
489                            OracleTest {
490                                path: t.path.clone(),
491                                content: files
492                                    .iter()
493                                    .find(|(p, _)| *p == t.path)
494                                    .map(|(_, c)| c.clone())
495                                    .unwrap_or_default(),
496                                scenarios: t.cites.clone(),
497                                red_class: same.map_or_else(
498                                    || crate::pipeline::class_word(RedClass::Assertion),
499                                    |x| crate::pipeline::class_word(x.red_class),
500                                ),
501                                red: true,
502                                authored_by: same.map_or(AuthoredBy::Model, |x| x.authored_by),
503                                edited_by: same.and_then(|x| x.edited_by.clone()),
504                            }
505                        })
506                        .collect();
507                    v.manifest_sha256 = Some(e.manifest_sha256.clone());
508                    let covered: Vec<&String> =
509                        rec.visible.iter().flat_map(|t| t.cites.iter()).collect();
510                    v.uncovered = req
511                        .scenarios
512                        .iter()
513                        .map(|s| s.id.clone())
514                        .filter(|s| !covered.contains(&s))
515                        .collect();
516                    if cfg.held_out {
517                        v.held_out = match &rec.held_out_sealed {
518                            Some(h) => HeldOutSummary {
519                                state: "kept".to_owned(),
520                                count: h.count,
521                                manifest_sha256: Some(format!("sha256:{}", h.manifest_sha256)),
522                                reason: None,
523                            },
524                            None => HeldOutSummary {
525                                state: "absent".to_owned(),
526                                count: 0,
527                                manifest_sha256: None,
528                                reason: Some("none_kept".to_owned()),
529                            },
530                        };
531                    }
532                    OracleState::Frozen
533                }
534                Ok(None) => OracleState::FrozenElsewhere,
535                Err(t) => {
536                    v.error = Some(format!("the frozen set was tampered with: {}", t.0));
537                    OracleState::Frozen
538                }
539            };
540        } else if current.is_none() {
541            v.state = OracleState::Lapsed;
542            v.frozen_on = load_frozen(project_state, unit, &req.key).map(|f| f.wording);
543            v.frozen_by = Some(e.by.clone());
544            v.frozen_at = Some(e.at);
545        }
546    }
547    v
548}
549
550/// Requirements of the selected unit whose tests two authors read
551/// differently, with the scenario: `(REQ, scenario)`.
552///
553/// # Errors
554/// A stored draft was tampered with or cannot be read: whether it holds an
555/// open reading is unknown, never "none".
556pub fn open_ambiguities(
557    project_state: &Path,
558    unit: &str,
559    requirements: &[OracleRequirement],
560) -> Result<Vec<(String, String)>, Tampered> {
561    let mut out = Vec::new();
562    for r in requirements {
563        let Some((d, _)) = load(project_state, unit, &r.key)? else {
564            continue;
565        };
566        if d.requirement.sha256 != r.digest() {
567            continue;
568        }
569        out.extend(
570            d.open_ambiguities()
571                .map(|a| (d.requirement.key.clone(), a.scenario.clone())),
572        );
573    }
574    Ok(out)
575}
576
577/// The rung of the selected unit, by the format's standing units.
578#[must_use]
579pub fn rung(format: &dyn Adapter, root: &Path, unit: &str) -> Rung {
580    speccoder_core::ladder::rung_of(unit, &format.standing_units(root))
581}
582
583/// What one oracle action is asked.
584#[derive(Debug, Clone)]
585pub struct Request<'a> {
586    /// The requirement.
587    pub req: &'a str,
588    /// What to do.
589    pub op: OracleOp,
590    /// The test (reject, again, edit).
591    pub path: Option<&'a str>,
592    /// The scenario (reading).
593    pub scenario: Option<&'a str>,
594    /// The reading (reading).
595    pub reading: Option<Reading>,
596    /// The reason, comment or content.
597    pub text: Option<&'a str>,
598}
599
600impl Request<'_> {
601    /// Whether it calls the test author.
602    #[must_use]
603    pub fn needs_model(&self) -> bool {
604        matches!(self.op, OracleOp::Write | OracleOp::Again)
605            || (self.op == OracleOp::Reading && self.reading == Some(Reading::B))
606    }
607}
608
609/// Everything an oracle action works with.
610struct Ctx {
611    root: PathBuf,
612    feature: String,
613    dir: String,
614    format: &'static dyn Adapter,
615    snapshot: ProjectSnapshot,
616    plane_dir: PathBuf,
617    config: substrate::run_config::RunConfig,
618    sandbox: sandbox::Config,
619    policy: tools::policy::Policy,
620    model: Option<substrate::model_config::ModelConfig>,
621}
622
623fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
624    CommandResult::new("review", status, message).with_code(code)
625}
626
627fn open(path: &Path, feature: Option<&str>, state: &Path) -> Result<Ctx, Box<CommandResult>> {
628    let inspected = crate::inspect(path, feature);
629    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
630        return Err(Box::new(CommandResult::carried("review", &inspected)));
631    };
632    let snapshot = (**snapshot).clone();
633    let Some(f) = snapshot.feature.as_ref() else {
634        return Err(Box::new(CommandResult::new(
635            "review",
636            CommandStatus::SelectionNeeded,
637            inspected.message(),
638        )));
639    };
640    let root = PathBuf::from(&snapshot.root);
641    let config_root = crate::verify::find_config(&root).ok_or_else(|| {
642        Box::new(refuse(
643            CommandStatus::ConfigError,
644            "CONFIG-MISSING",
645            "the project has no speccoder.toml",
646        ))
647    })?;
648    let text =
649        std::fs::read_to_string(config_root.join(crate::verify::config::FILE)).unwrap_or_default();
650    let config = substrate::run_config::parse(&text).map_err(|e| {
651        Box::new(refuse(
652            CommandStatus::ConfigError,
653            "CONFIG-INVALID",
654            &format!("speccoder.toml: {e}"),
655        ))
656    })?;
657    let sandbox = sandbox::parse(&text).map_err(|e| {
658        Box::new(refuse(
659            CommandStatus::ConfigError,
660            "CONFIG-INVALID",
661            &format!("speccoder.toml: {e}"),
662        ))
663    })?;
664    let format = crate::format::of(&snapshot);
665    let policy =
666        tools::policy::Policy::new(&crate::verify::check_argvs(&config_root), &config.allow)
667            .with_prefixes(config.allow_prefix.clone())
668            .rules(format.rules());
669    let model = substrate::model_config::parse(&text).ok().flatten();
670    Ok(Ctx {
671        feature: f.feature.name.clone(),
672        dir: f.feature.dir.clone(),
673        plane_dir: worktree::project_state_dir(state, &root),
674        root,
675        format,
676        snapshot,
677        config,
678        sandbox,
679        policy,
680        model,
681    })
682}
683
684/// The project's directory inside a worktree of its repository.
685fn project_in(root: &Path, worktree_root: &Path) -> PathBuf {
686    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
687    let top = crate::git::Git::at(root)
688        .toplevel()
689        .ok()
690        .flatten()
691        .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
692    match canonical(root).strip_prefix(&top) {
693        Ok(rel) if !rel.as_os_str().is_empty() => worktree_root.join(rel),
694        _ => worktree_root.to_path_buf(),
695    }
696}
697
698/// One writer of a unit's oracle at a time: *Write Tests* and its kin, and
699/// the freeze of *Approve and Run*. A holder elsewhere is refused, never
700/// waited for (a write runs model calls).
701pub(crate) struct UnitLock(PathBuf);
702
703impl UnitLock {
704    pub(crate) fn take(project_state: &Path, unit: &str) -> Result<Self, String> {
705        let path = project_state.join(DIR).join(format!("{unit}.lock"));
706        if let Some(parent) = path.parent() {
707            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
708        }
709        // A lock older than an hour is a crashed writer's.
710        if std::fs::metadata(&path)
711            .ok()
712            .and_then(|m| m.modified().ok())
713            .and_then(|m| m.elapsed().ok())
714            .is_some_and(|age| age.as_secs() > 3600)
715        {
716            let _ = std::fs::remove_file(&path);
717        }
718        std::fs::OpenOptions::new()
719            .write(true)
720            .create_new(true)
721            .open(&path)
722            .map(|_| Self(path))
723            .map_err(|_| {
724                format!("{unit}'s tests are being written or frozen elsewhere right now: look again when that ends")
725            })
726    }
727}
728
729impl Drop for UnitLock {
730    fn drop(&mut self) {
731        let _ = std::fs::remove_file(&self.0);
732    }
733}
734
735/// What a progress sink is told: a stage, by name and count.
736fn stage(options: &crate::RunOptions, req: &str, stage: &str, n: u32, of: u32) {
737    if let Some(p) = &options.progress {
738        p.event(&crate::progress::Event::Oracle {
739            step: crate::progress::OracleEvent::Stage {
740                requirement: req.to_owned(),
741                stage: stage.to_owned(),
742                n,
743                of,
744            },
745        });
746    }
747}
748
749/// `speccoder review tests <REQ> …` with the configured model when the
750/// action calls the test author.
751#[must_use]
752pub fn tests(
753    path: &Path,
754    feature: Option<&str>,
755    request: &Request<'_>,
756    via: Via,
757    state: Option<&Path>,
758    options: &crate::RunOptions,
759) -> CommandResult {
760    let Some(state) = state.map(Path::to_path_buf).or_else(worktree::state_dir) else {
761        return refuse(
762            CommandStatus::ConfigError,
763            "NO-STATE-DIR",
764            "no state directory (set SPECCODER_STATE_DIR or HOME)",
765        );
766    };
767    if !request.needs_model() {
768        return tests_with(path, feature, request, via, &state, None, options);
769    }
770    let ctx = match open(path, feature, &state) {
771        Ok(c) => c,
772        Err(r) => return *r,
773    };
774    if let Err(r) = check(&ctx, request) {
775        return *r;
776    }
777    let Some(model) = ctx.model.as_ref() else {
778        return refuse(
779            CommandStatus::ConfigError,
780            "CONFIG",
781            "speccoder.toml has no [model] section: the test author needs a model",
782        );
783    };
784    match substrate::model_config::open_driver(model) {
785        Ok((provider, _)) => {
786            tests_with(path, feature, request, via, &state, Some(provider), options)
787        }
788        Err(e) => substrate::model_config::refused("review", &e),
789    }
790}
791
792/// [`tests`] against `state`, with `provider` for the author calls.
793#[must_use]
794pub fn tests_with(
795    path: &Path,
796    feature: Option<&str>,
797    request: &Request<'_>,
798    via: Via,
799    state: &Path,
800    provider: Option<Arc<dyn ModelProvider>>,
801    options: &crate::RunOptions,
802) -> CommandResult {
803    let ctx = match open(path, feature, state) {
804        Ok(c) => c,
805        Err(r) => return *r,
806    };
807    let req = match check(&ctx, request) {
808        Ok(r) => r,
809        Err(r) => return *r,
810    };
811    let _lock = match UnitLock::take(&ctx.plane_dir, &ctx.feature) {
812        Ok(l) => l,
813        Err(e) => return refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", &e),
814    };
815    let who = crate::approvals::Who::local_in(&ctx.root, via);
816    let done = match request.op {
817        OracleOp::Write => write(&ctx, &req, provider, options),
818        OracleOp::Reject => reject(&ctx, &req, request, &who.name),
819        OracleOp::Again => again(&ctx, &req, request, &who.name, provider, options),
820        OracleOp::Edit => edit(&ctx, &req, request, &who.name),
821        OracleOp::Reading => reading(&ctx, &req, request, &who.name, provider, options),
822    };
823    match done {
824        Ok(message) => {
825            let r = crate::review::report_in(path, feature, Some(state));
826            let status = r.status();
827            r.reworded(status, &message)
828        }
829        Err(r) => *r,
830    }
831}
832
833/// What refuses an oracle action before anything runs: the unit's rung,
834/// acceptance off, an unknown requirement or one without scenarios.
835fn check(ctx: &Ctx, request: &Request<'_>) -> Result<OracleRequirement, Box<CommandResult>> {
836    let unit_rung = rung(ctx.format, &ctx.root, &ctx.feature);
837    if unit_rung != Rung::Feature {
838        return Err(Box::new(
839            refuse(
840                CommandStatus::UsageError,
841                "REVIEW-TESTS-OWN-PIPELINE",
842                &format!(
843                    "{} is a {} unit: its tests are written and confirmed by its own pipeline",
844                    ctx.feature,
845                    if unit_rung == Rung::Quick {
846                        "quick-change"
847                    } else {
848                        "bug"
849                    }
850                ),
851            )
852            .with_fix(vec![if unit_rung == Rung::Quick {
853                "speccoder do \"…\"".to_owned()
854            } else {
855                format!("speccoder bug confirm {}/<task>", ctx.feature)
856            }]),
857        ));
858    }
859    if !ctx.config.acceptance {
860        return Err(Box::new(refuse(
861            CommandStatus::ConfigError,
862            "ACCEPTANCE-OFF",
863            "acceptance tests are off ([acceptance] generate = false): no tests are written at review",
864        )));
865    }
866    let requirements = ctx.format.oracle_requirements(&ctx.snapshot);
867    let Some(req) = requirements
868        .iter()
869        .find(|r| r.key.eq_ignore_ascii_case(request.req))
870        .cloned()
871    else {
872        return Err(Box::new(refuse(
873            CommandStatus::UsageError,
874            "REVIEW-NO-SCENARIOS",
875            &format!(
876                "the spec has no requirement {}; it names {}",
877                request.req,
878                if requirements.is_empty() {
879                    "none with scenarios".to_owned()
880                } else {
881                    requirements
882                        .iter()
883                        .map(|r| r.key.as_str())
884                        .collect::<Vec<_>>()
885                        .join(", ")
886                }
887            ),
888        )));
889    };
890    if req.scenarios.is_empty() {
891        return Err(Box::new(refuse(
892            CommandStatus::UsageError,
893            "REVIEW-NO-SCENARIOS",
894            &format!(
895                "{} has no acceptance scenarios to write tests from",
896                req.key
897            ),
898        )));
899    }
900    // A frozen set is what the run uses, sealed with its held-out set: it
901    // is not rewritten under the approval that names it (C4).
902    let (entries, _) = crate::review::oracle_entries(&ctx.root, &ctx.dir)
903        .map_err(|e| Box::new(refuse(CommandStatus::NeedsHuman, "REVIEW-UNREADABLE", &e)))?;
904    if let Some(e) = entries
905        .get(&req.key)
906        .filter(|e| e.requirement_sha256 == req.digest())
907    {
908        return Err(Box::new(refuse(
909            CommandStatus::UsageError,
910            "ORACLE-FROZEN",
911            &format!(
912                "{}'s tests were frozen with the approval by {}: the run uses them as frozen — reword {} to lapse the freeze, then write its tests again",
913                req.key, e.by, req.key
914            ),
915        )));
916    }
917    Ok(req)
918}
919
920type Outcome = Result<String, Box<CommandResult>>;
921
922fn failed(message: impl Into<String>) -> Box<CommandResult> {
923    Box::new(refuse(
924        CommandStatus::RunFailed,
925        "ORACLE-AUTHOR-FAILED",
926        &message.into(),
927    ))
928}
929
930fn usage(message: &str) -> Box<CommandResult> {
931    Box::new(refuse(CommandStatus::UsageError, "USAGE", message))
932}
933
934fn existing(ctx: &Ctx, req: &OracleRequirement) -> Result<Loaded, Box<CommandResult>> {
935    match load(&ctx.plane_dir, &ctx.feature, &req.key) {
936        Ok(Some((d, c))) if d.requirement.sha256 == req.digest() => Ok((d, c)),
937        Ok(Some(_)) => Err(usage(&format!(
938            "{}'s tests were written for an earlier wording: write them again (`speccoder review tests {} --write`)",
939            req.key, req.key
940        ))),
941        Ok(None) => Err(usage(&format!(
942            "{} has no tests yet: `speccoder review tests {} --write`",
943            req.key, req.key
944        ))),
945        Err(t) => Err(Box::new(refuse(
946            CommandStatus::NeedsHuman,
947            "ORACLE-TAMPERED",
948            &t.0,
949        ))),
950    }
951}
952
953/// The plane and runtime of the author calls.
954struct Author<'a> {
955    ctx: &'a Ctx,
956    rt: tokio::runtime::Runtime,
957    plane: substrate::Plane,
958    bundle: Value,
959    instructions: Vec<crate::instructions::Instruction>,
960    /// The test layout's text: where the review's files are placed.
961    test_layout: String,
962}
963
964impl<'a> Author<'a> {
965    fn open(
966        ctx: &'a Ctx,
967        req: &OracleRequirement,
968        provider: Option<Arc<dyn ModelProvider>>,
969        options: &crate::RunOptions,
970    ) -> Result<Self, Box<CommandResult>> {
971        let (Some(provider), Some(model)) = (provider, ctx.model.as_ref()) else {
972            return Err(Box::new(refuse(
973                CommandStatus::ConfigError,
974                "CONFIG",
975                "the test author needs a model ([model] in speccoder.toml)",
976            )));
977        };
978        let rt = crate::pipeline::tokio_runtime().map_err(failed)?;
979        let plane = rt
980            .block_on(async {
981                substrate::Plane::open_with(
982                    &ctx.plane_dir,
983                    model,
984                    provider,
985                    None,
986                    substrate::Hooks {
987                        format: Some(ctx.format),
988                        progress: options.progress.clone(),
989                        ..substrate::Hooks::default()
990                    },
991                )
992            })
993            .map_err(|e| failed(e.0))?;
994        let unit_dir = ctx.root.join(&ctx.dir);
995        let [constitution, _, plan, _] = ctx.format.convergence_texts(&ctx.root, &unit_dir);
996        let bundle = json!({
997            "feature": ctx.feature,
998            "constitution": constitution,
999            "plan": plan,
1000            "story": req.text,
1001            "task": {
1002                "id": format!("req-{}", req.key),
1003                "title": format!("Acceptance tests for {}: {}", req.key, req.title),
1004                "phase": "Review",
1005                "subsection": "",
1006                "story": req.key,
1007                "parallel": false,
1008            },
1009            "dependencies": [],
1010        });
1011        let instructions =
1012            crate::instructions::collect(&ctx.root, &ctx.root, &[], &ctx.config.instructions);
1013        Ok(Self {
1014            ctx,
1015            rt,
1016            plane,
1017            bundle,
1018            instructions,
1019            test_layout: crate::layout::text_at(&ctx.root),
1020        })
1021    }
1022
1023    /// The parts a call is handed, as its context manifest lists them.
1024    fn context(
1025        &self,
1026        req: &OracleRequirement,
1027        notes: &[String],
1028    ) -> speccoder_core::context::ContextManifest {
1029        let mut m = crate::context::Manifest::new();
1030        m.push(
1031            ContextKind::Story,
1032            "person",
1033            "/requirement",
1034            req.text.as_bytes(),
1035        );
1036        for s in &req.scenarios {
1037            m.push(
1038                ContextKind::Scenario,
1039                "person",
1040                &format!("/scenarios/{}", s.id),
1041                s.text.as_bytes(),
1042            );
1043        }
1044        m.push(
1045            ContextKind::Constitution,
1046            "worktree",
1047            "/bundle/constitution",
1048            self.bundle["constitution"]
1049                .as_str()
1050                .unwrap_or_default()
1051                .as_bytes(),
1052        );
1053        m.push(
1054            ContextKind::Plan,
1055            "worktree",
1056            "/bundle/plan",
1057            self.bundle["plan"].as_str().unwrap_or_default().as_bytes(),
1058        );
1059        m.push(
1060            ContextKind::TestLayout,
1061            "worktree",
1062            "/test_layout",
1063            self.test_layout.as_bytes(),
1064        );
1065        for (i, n) in notes.iter().enumerate() {
1066            let kind = if n.starts_with("Rejected `") {
1067                ContextKind::Rejection
1068            } else if n.starts_with("Comment ") {
1069                ContextKind::Comment
1070            } else {
1071                ContextKind::Note
1072            };
1073            m.push(kind, "person", &format!("/notes/{i}"), n.as_bytes());
1074        }
1075        m.finish()
1076    }
1077
1078    /// One author call: its answer, run id and context.
1079    #[allow(clippy::too_many_arguments)]
1080    fn call(
1081        &self,
1082        req: &OracleRequirement,
1083        files: &[String],
1084        manifest: &str,
1085        notes: &[String],
1086        again: Option<(&str, &str)>,
1087        sample: u8,
1088    ) -> Result<(Value, String, Vec<ContextEntry>), String> {
1089        let context = self.context(req, notes);
1090        let input = substrate::acceptance::review_input(&substrate::acceptance::ReviewParts {
1091            bundle: self.bundle.clone(),
1092            requirement: json!({ "key": req.key, "title": req.title, "text": req.text }),
1093            scenarios: &req.scenarios,
1094            files,
1095            manifest,
1096            notes,
1097            again,
1098            instructions: &self.instructions,
1099            context: &context,
1100            sample,
1101            test_layout: Some(&self.test_layout),
1102        });
1103        let task = format!("req-{}", req.key);
1104        let outcome = self
1105            .rt
1106            .block_on(self.plane.acceptance(&self.ctx.feature, &task, input))
1107            .map_err(|e| format!("the test author failed: {}", e.0))?;
1108        let output = outcome
1109            .output
1110            .as_ref()
1111            .map(|o| o.peek().clone())
1112            .unwrap_or_default();
1113        let answer = output["answer"].clone();
1114        if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
1115        {
1116            let status = format!("{:?}", outcome.status);
1117            return Err(substrate::provider_refusal(&status).map_or_else(
1118                || {
1119                    format!(
1120                        "the test author did not answer: {}",
1121                        outcome.status.as_str()
1122                    )
1123                },
1124                |r| format!("the model provider refused the request: {r}"),
1125            ));
1126        }
1127        Ok((answer, outcome.run_id.to_string(), context.entries))
1128    }
1129}
1130
1131/// What one author states it expects of each scenario, and what it found
1132/// untestable, by the requirement's own scenario ids (case aside). `Err`:
1133/// an answer the comparison cannot read — not parseable, naming a scenario
1134/// the requirement does not have, or saying nothing of any scenario — is
1135/// an author failure, never "no ambiguity".
1136fn expectations(
1137    answer: &Value,
1138    req: &OracleRequirement,
1139) -> Result<(Vec<Expectation>, Vec<Untestable>), String> {
1140    let canonical = |id: &str| {
1141        req.scenarios
1142            .iter()
1143            .find(|s| s.id.eq_ignore_ascii_case(id.trim()))
1144            .map(|s| s.id.clone())
1145            .ok_or_else(|| {
1146                format!(
1147                    "the test author's expectations name `{id}`, which {} does not have",
1148                    req.key
1149                )
1150            })
1151    };
1152    let list = |field: &str| -> Result<Value, String> {
1153        match &answer[field] {
1154            Value::Null => Ok(Value::Array(Vec::new())),
1155            v @ Value::Array(_) => Ok(v.clone()),
1156            _ => Err(format!("the test author's `{field}` is not a list")),
1157        }
1158    };
1159    let mut e: Vec<Expectation> = serde_json::from_value(list("expectations")?)
1160        .map_err(|x| format!("the test author's expectations cannot be read: {x}"))?;
1161    let mut u: Vec<Untestable> = serde_json::from_value(list("untestable")?)
1162        .map_err(|x| format!("the test author's untestable list cannot be read: {x}"))?;
1163    for x in &mut e {
1164        x.scenario = canonical(&x.scenario)?;
1165    }
1166    for x in &mut u {
1167        x.scenario = canonical(&x.scenario)?;
1168    }
1169    if e.is_empty() && u.is_empty() {
1170        return Err(
1171            "the test author stated no expectation for any scenario: the readings cannot be compared"
1172                .to_owned(),
1173        );
1174    }
1175    Ok((e, u))
1176}
1177
1178/// A scratch worktree of the checkout's `HEAD`, its project directory and
1179/// a sandboxed runner in it.
1180struct Bench {
1181    scratch: worktree::Scratch,
1182    project: PathBuf,
1183    runner: sandbox::Runner,
1184    tmp: PathBuf,
1185}
1186
1187impl Bench {
1188    fn open(ctx: &Ctx) -> Result<Self, Box<CommandResult>> {
1189        let tmp = ctx.plane_dir.join("tmp");
1190        let scratch = worktree::scratch(&ctx.root, &tmp)
1191            .map_err(|e| failed(format!("the scratch worktree could not be made: {}", e.0)))?;
1192        let project = project_in(&ctx.root, &scratch.path);
1193        let (runner, notice) = sandbox::runner(&scratch.path, &ctx.sandbox)
1194            .map_err(|e| Box::new(refuse(CommandStatus::ConfigError, "CONFIG", &e)))?;
1195        // Model-written tests and model-chosen commands run here, before
1196        // anyone approved them: only confined by the OS sandbox.
1197        if !runner.is_sandboxed() {
1198            return Err(Box::new(
1199                refuse(
1200                    CommandStatus::ConfigError,
1201                    "SANDBOX-UNAVAILABLE",
1202                    &format!(
1203                        "the red check runs model-written tests and needs the OS sandbox: {}",
1204                        notice.unwrap_or_else(|| "commands would run unsandboxed".to_owned())
1205                    ),
1206                )
1207                .with_fix(vec![
1208                    "set [sandbox] mode = \"auto\" on a machine with a sandbox backend (speccoder doctor)".to_owned(),
1209                ]),
1210            ));
1211        }
1212        Ok(Self {
1213            scratch,
1214            project,
1215            runner,
1216            tmp,
1217        })
1218    }
1219
1220    fn files(&self) -> (Vec<String>, String) {
1221        let files: Vec<String> = worktree::checkpoint(&self.scratch.path, &self.tmp)
1222            .and_then(|c| worktree::files(&self.scratch.path, &c))
1223            .unwrap_or_default()
1224            .into_iter()
1225            .take(400)
1226            .collect();
1227        let manifest = crate::pipeline::MANIFESTS
1228            .iter()
1229            .find_map(|m| {
1230                std::fs::read_to_string(self.project.join(m))
1231                    .ok()
1232                    .map(|t| format!("{m}:\n{t}"))
1233            })
1234            .map(|t| t.chars().take(8192).collect::<String>())
1235            .unwrap_or_default();
1236        (files, manifest)
1237    }
1238
1239    /// Red-checks each test alone: only its own file is in the
1240    /// scratch tree while its command runs — first without it, then twice
1241    /// with it — so a failure is its own, never a neighbour's. A command
1242    /// that is red without the file (a failing suite, a command that runs
1243    /// more than the file) decides nothing about it: `Err("red_without_it")`.
1244    fn red(
1245        &self,
1246        tests: &[(String, String, Vec<String>)],
1247    ) -> Result<Vec<Result<RedClass, &'static str>>, String> {
1248        let mut out = Vec::new();
1249        for (path, content, argv) in tests {
1250            let full = self.project.join(path);
1251            let _ = std::fs::remove_file(&full);
1252            let run = || crate::pipeline::acceptance_run(&self.project, argv, &self.runner);
1253            let (bare, summary) = run();
1254            if classify_red(&bare, summary.as_ref()).is_red() {
1255                out.push(Err("red_without_it"));
1256                continue;
1257            }
1258            if let Some(parent) = full.parent() {
1259                std::fs::create_dir_all(parent).map_err(|e| format!("{path}: {e}"))?;
1260            }
1261            std::fs::write(&full, content).map_err(|e| format!("{path}: {e}"))?;
1262            let (first, summary) = run();
1263            let class = classify_red(&first, summary.as_ref());
1264            let class = if !class.is_red() {
1265                class
1266            } else {
1267                let (second, summary) = run();
1268                if classify_red(&second, summary.as_ref()).is_red() {
1269                    class
1270                } else {
1271                    RedClass::Flaky
1272                }
1273            };
1274            std::fs::remove_file(&full).map_err(|e| format!("{path}: {e}"))?;
1275            out.push(Ok(class));
1276        }
1277        Ok(out)
1278    }
1279}
1280
1281/// The answer's files as tests to keep, with what was discarded and why.
1282fn keep(
1283    ctx: &Ctx,
1284    bench: &Bench,
1285    answer: &Value,
1286    req: &OracleRequirement,
1287    taken: &[(String, Vec<String>)],
1288) -> (Vec<Kept>, Vec<OracleDiscarded>) {
1289    let (valid, refused) =
1290        crate::pipeline::validate_answer(answer, &bench.project, &ctx.policy, &req.scenarios);
1291    let mut discarded: Vec<OracleDiscarded> = refused
1292        .into_iter()
1293        .map(|r| OracleDiscarded {
1294            path: r.path,
1295            why: r.why.to_owned(),
1296        })
1297        .collect();
1298    let set_argv: Vec<String> = answer["argv"]
1299        .as_array()
1300        .into_iter()
1301        .flatten()
1302        .filter_map(|v| v.as_str().map(str::to_owned))
1303        .collect();
1304    let layout = crate::layout::detect(&bench.project, &crate::verify::check_argvs(&ctx.root));
1305    let mut kept = Vec::new();
1306    for f in valid {
1307        let mut discard = |why: &str| {
1308            discarded.push(OracleDiscarded {
1309                path: f.path.clone(),
1310                why: why.to_owned(),
1311            });
1312        };
1313        if f.cited.is_empty() {
1314            discard("cites_no_scenario");
1315            continue;
1316        }
1317        if taken.iter().any(|(p, _)| *p == f.path)
1318            || kept.iter().any(|(p, ..): &(String, _, _, _)| *p == f.path)
1319        {
1320            discard("exists");
1321            continue;
1322        }
1323        let argv = if f.argv.is_empty() {
1324            set_argv.clone()
1325        } else {
1326            f.argv.clone()
1327        };
1328        // One command per test: a command several tests share cannot say
1329        // whose assertion failed (at the checkpoint they all exist).
1330        if taken.iter().any(|(_, a)| *a == argv)
1331            || kept
1332                .iter()
1333                .any(|(.., a): &(_, _, _, Vec<String>)| *a == argv)
1334        {
1335            discard("command_shared");
1336            continue;
1337        }
1338        if argv.is_empty()
1339            || !crate::pipeline::test_command_allowed(
1340                &ctx.policy,
1341                Some(&layout),
1342                &argv,
1343                &[f.path.as_str()],
1344            )
1345        {
1346            discard("command_not_allowed");
1347            continue;
1348        }
1349        if kept.len() + taken.len() >= LIMIT {
1350            discard("limit");
1351            continue;
1352        }
1353        kept.push((f.path, f.content, f.cited, argv));
1354    }
1355    (kept, discarded)
1356}
1357
1358/// *Write Tests*: the author calls, the comparison, the red check and the
1359/// held-out set; the draft is stored only when the first call answered.
1360fn write(
1361    ctx: &Ctx,
1362    req: &OracleRequirement,
1363    provider: Option<Arc<dyn ModelProvider>>,
1364    options: &crate::RunOptions,
1365) -> Outcome {
1366    let samples = ctx.config.oracle_samples.clamp(1, 2);
1367    let author = Author::open(ctx, req, provider, options)?;
1368    let bench = Bench::open(ctx)?;
1369    let (files, manifest) = bench.files();
1370    // What the person said about earlier tests is told again; a draft
1371    // that cannot be read is not overwritten with its rejections lost.
1372    let earlier = load(&ctx.plane_dir, &ctx.feature, &req.key).map_err(|t| {
1373        Box::new(
1374            refuse(
1375                CommandStatus::NeedsHuman,
1376                "ORACLE-TAMPERED",
1377                &format!(
1378                    "{}: writing anew would lose the rejections and notes it holds",
1379                    t.0
1380                ),
1381            )
1382            .with_fix(vec![format!(
1383                "→ restore it, or remove {} to start {}'s tests over",
1384                dir(&ctx.plane_dir, &ctx.feature, &req.key).display(),
1385                req.key
1386            )]),
1387        )
1388    })?;
1389    let (rejected, mut notes) = earlier
1390        .as_ref()
1391        .filter(|(d, _)| d.requirement.sha256 == req.digest())
1392        .map(|(d, _)| (d.rejected.clone(), d.notes.clone()))
1393        .unwrap_or_default();
1394    notes.dedup();
1395    let told: Vec<String> = rejected
1396        .iter()
1397        .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
1398        .chain(notes.iter().cloned())
1399        .collect();
1400    let of = u32::from(samples);
1401    stage(options, &req.key, "writing", 1, of);
1402    let (first, run1, mut context) = author
1403        .call(req, &files, &manifest, &told, None, 1)
1404        .map_err(failed)?;
1405    // The comparison needs both readings; with one sample they are kept
1406    // as stated, for the card.
1407    let read = |answer: &Value| {
1408        let got = expectations(answer, req);
1409        if samples >= 2 {
1410            got.map_err(failed)
1411        } else {
1412            Ok(got.unwrap_or_default())
1413        }
1414    };
1415    let (e1, u1) = read(&first)?;
1416    let mut runs = vec![run1];
1417    let (mut e2, mut u2) = (Vec::new(), Vec::new());
1418    if samples >= 2 {
1419        stage(options, &req.key, "writing", 2, of);
1420        let (second, run2, more) = author
1421            .call(req, &files, &manifest, &told, None, 2)
1422            .map_err(failed)?;
1423        (e2, u2) = read(&second)?;
1424        runs.push(run2);
1425        for c in more {
1426            if !context.contains(&c) {
1427                context.push(c);
1428            }
1429        }
1430        stage(options, &req.key, "comparing", 1, 1);
1431    }
1432    let ambiguities = if samples >= 2 {
1433        speccoder_core::oracle::compare(&e1, &u1, &e2, &u2)
1434    } else {
1435        Vec::new()
1436    };
1437    let (kept, mut discarded) = keep(ctx, &bench, &first, req, &[]);
1438    stage(options, &req.key, "checking_red", 1, 1);
1439    let plan: Vec<(String, String, Vec<String>)> = kept
1440        .iter()
1441        .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1442        .collect();
1443    let classes = bench.red(&plan).map_err(failed)?;
1444    let mut tests: Vec<DraftTest> = Vec::new();
1445    let mut contents: Vec<(String, String)> = Vec::new();
1446    for ((path, content, cited, argv), class) in kept.into_iter().zip(classes) {
1447        match class {
1448            Ok(red_class) => {
1449                tests.push(DraftTest {
1450                    path: path.clone(),
1451                    sha256: digest_of(&content),
1452                    scenarios: cited,
1453                    argv,
1454                    red_class,
1455                    authored_by: AuthoredBy::Model,
1456                    edited_by: None,
1457                });
1458                contents.push((path, content));
1459            }
1460            Err(why) => discarded.push(OracleDiscarded {
1461                path,
1462                why: why.to_owned(),
1463            }),
1464        }
1465    }
1466    let (held_out, sealed) = held_out(ctx, &author, &bench, req, &tests, options);
1467    let draft = Draft {
1468        requirement: DraftRequirement {
1469            key: req.key.clone(),
1470            title: req.title.clone(),
1471            text: req.text.clone(),
1472            sha256: req.digest(),
1473        },
1474        samples,
1475        tests,
1476        discarded,
1477        rejected,
1478        expectations: Samples {
1479            sample1: e1,
1480            sample2: e2,
1481        },
1482        untestable: Samples {
1483            sample1: u1,
1484            sample2: u2,
1485        },
1486        ambiguities,
1487        held_out,
1488        sealed,
1489        notes,
1490        runs,
1491        context,
1492    };
1493    store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
1494    let red = draft.red().count();
1495    let open = draft.open_ambiguities().count();
1496    Ok(format!(
1497        "wrote {} test(s) for {}: {red} red{}{}",
1498        draft.tests.len(),
1499        req.key,
1500        if draft.held_out.state == "kept" {
1501            format!(", {} held out", draft.held_out.count)
1502        } else {
1503            String::new()
1504        },
1505        if open > 0 {
1506            format!(
1507                "; {} — {open} scenario(s) read two ways: choose a reading or reword (`speccoder review tests {} --reading A|B <scenario>`)",
1508                req.key, req.key
1509            )
1510        } else {
1511            String::new()
1512        }
1513    ))
1514}
1515
1516/// The held-out set at review, after a red visible set: authored as at
1517/// run time, red by overlay, sealed under `(unit, REQ)`; only its
1518/// count and digest come back.
1519fn held_out(
1520    ctx: &Ctx,
1521    author: &Author<'_>,
1522    bench: &Bench,
1523    req: &OracleRequirement,
1524    tests: &[DraftTest],
1525    options: &crate::RunOptions,
1526) -> (HeldOutSummary, Option<SealedRef>) {
1527    let summary = |state: &str, reason: Option<&str>| HeldOutSummary {
1528        state: state.to_owned(),
1529        count: 0,
1530        manifest_sha256: None,
1531        reason: reason.map(str::to_owned),
1532    };
1533    if !ctx.config.held_out {
1534        return (summary("off", None), None);
1535    }
1536    let red: Vec<&DraftTest> = tests.iter().filter(|t| t.red_class.is_red()).collect();
1537    if red.is_empty() {
1538        return (summary("absent", Some("not_red")), None);
1539    }
1540    stage(options, &req.key, "held_out", 1, 1);
1541    let visible = crate::pipeline::PreparedAcceptance {
1542        report: json!({ "files": red.iter().map(|t| t.path.clone()).collect::<Vec<_>>() }),
1543        argv: red.first().map(|t| t.argv.clone()),
1544        more_argv: Vec::new(),
1545        red: true,
1546        red_class: Some(RedClass::Assertion),
1547        held_out: None,
1548        generation_failed: None,
1549        skip_cause: None,
1550    };
1551    let task = format!("req-{}", req.key);
1552    let store = crate::held_out::sealed_dir(&ctx.plane_dir, &ctx.feature, &req.key);
1553    let (report, sha) = crate::pipeline::prepare_held_out(
1554        &crate::pipeline::AcceptanceSetup {
1555            rt: &author.rt,
1556            plane: &author.plane,
1557            project: &bench.project,
1558            worktree: &bench.scratch.path,
1559            tmp: &bench.tmp,
1560            policy: &ctx.policy,
1561            feature: &ctx.feature,
1562            task: &task,
1563            bundle: author.bundle.clone(),
1564            scenarios: &req.scenarios,
1565            instructions: &author.instructions,
1566            runner: &bench.runner,
1567            layout: None,
1568            story_files: &[],
1569        },
1570        &visible,
1571        &req.scenarios,
1572        &store,
1573        false,
1574    );
1575    match sha {
1576        Some(sha) => {
1577            let count = report["kept"]
1578                .as_u64()
1579                .and_then(|n| usize::try_from(n).ok())
1580                .unwrap_or(0);
1581            (
1582                HeldOutSummary {
1583                    state: "kept".to_owned(),
1584                    count,
1585                    manifest_sha256: Some(format!("sha256:{sha}")),
1586                    reason: None,
1587                },
1588                Some(SealedRef {
1589                    key: req.key.clone(),
1590                    manifest_sha256: sha,
1591                    count,
1592                }),
1593            )
1594        }
1595        None => (
1596            summary(
1597                "absent",
1598                Some(report["reason"].as_str().unwrap_or("none_kept")),
1599            ),
1600            None,
1601        ),
1602    }
1603}
1604
1605fn reject(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1606    let Some(path) = r.path else {
1607        return Err(usage("name the test to reject (--reject PATH)"));
1608    };
1609    let Some(reason) = r.text.map(str::trim).filter(|t| !t.is_empty()) else {
1610        return Err(usage(
1611            "a rejection needs a reason: why is this test wrong? (--reason \"…\")",
1612        ));
1613    };
1614    let (mut d, contents) = existing(ctx, req)?;
1615    let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1616        return Err(usage(&format!("{} has no test {path}", req.key)));
1617    };
1618    d.tests.remove(at);
1619    d.rejected.push(OracleRejected {
1620        path: path.to_owned(),
1621        reason: reason.to_owned(),
1622        by: by.to_owned(),
1623        at: now(),
1624    });
1625    store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1626    Ok(format!(
1627        "rejected {path} of {}: \"{reason}\" — later author calls for {} are told",
1628        req.key, req.key
1629    ))
1630}
1631
1632fn edit(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1633    let Some(path) = r.path else {
1634        return Err(usage("name the test to edit (--edit PATH)"));
1635    };
1636    let Some(content) = r.text.filter(|t| !t.trim().is_empty()) else {
1637        return Err(usage("an edit needs the test's new content (--from FILE)"));
1638    };
1639    let (mut d, mut contents) = existing(ctx, req)?;
1640    let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1641        return Err(usage(&format!("{} has no test {path}", req.key)));
1642    };
1643    let bench = Bench::open(ctx)?;
1644    let plan = [(
1645        path.to_owned(),
1646        content.to_owned(),
1647        d.tests[at].argv.clone(),
1648    )];
1649    let checked = bench
1650        .red(&plan)
1651        .map_err(failed)?
1652        .pop()
1653        .unwrap_or(Ok(RedClass::Unclassified));
1654    // Red without the test: its command decides nothing about it.
1655    let class = checked.unwrap_or(RedClass::Unclassified);
1656    let t = &mut d.tests[at];
1657    t.sha256 = digest_of(content);
1658    t.red_class = class;
1659    t.authored_by = AuthoredBy::Person;
1660    t.edited_by = Some(by.to_owned());
1661    if let Some(c) = contents.iter_mut().find(|(p, _)| p == path) {
1662        c.1 = content.to_owned();
1663    }
1664    store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1665    Ok(format!(
1666        "edited {path} of {}: {}",
1667        req.key,
1668        if class.is_red() {
1669            format!("red ({})", crate::pipeline::class_word(class))
1670        } else if checked.is_err() {
1671            "its command is red without it — it decides nothing about this test, which cannot be frozen".to_owned()
1672        } else {
1673            format!(
1674                "not red ({}) — no evidence, it cannot be frozen",
1675                crate::pipeline::class_word(class)
1676            )
1677        }
1678    ))
1679}
1680
1681/// Writes tests anew from one author call (`again`, a reading): the
1682/// answer's citing files replace `replaced`, red-checked beside the rest.
1683#[allow(clippy::too_many_arguments)]
1684fn rewrite(
1685    ctx: &Ctx,
1686    req: &OracleRequirement,
1687    mut d: Draft,
1688    mut contents: Vec<(String, String)>,
1689    replaced: &[String],
1690    note: String,
1691    again: Option<(&str, &str)>,
1692    only: Option<&str>,
1693    provider: Option<Arc<dyn ModelProvider>>,
1694    options: &crate::RunOptions,
1695) -> Result<(Draft, Vec<String>), Box<CommandResult>> {
1696    let author = Author::open(ctx, req, provider, options)?;
1697    let bench = Bench::open(ctx)?;
1698    let (files, manifest) = bench.files();
1699    d.notes.push(note);
1700    let told = d.all_notes();
1701    stage(options, &req.key, "writing", 1, 1);
1702    let (answer, run, context) = author
1703        .call(req, &files, &manifest, &told, again, 1)
1704        .map_err(failed)?;
1705    d.runs.push(run);
1706    for c in context {
1707        if !d.context.contains(&c) {
1708            d.context.push(c);
1709        }
1710    }
1711    let keep_paths: Vec<(String, Vec<String>)> = d
1712        .tests
1713        .iter()
1714        .filter(|t| !replaced.contains(&t.path))
1715        .map(|t| (t.path.clone(), t.argv.clone()))
1716        .collect();
1717    // The replaced test's own path may come back.
1718    let mut answer = answer;
1719    if let Some(files) = answer["files"].as_array_mut() {
1720        files.retain(|f| {
1721            only.is_none_or(|s| {
1722                f["scenarios"]
1723                    .as_array()
1724                    .is_some_and(|c| c.iter().any(|v| v.as_str() == Some(s)))
1725            })
1726        });
1727    }
1728    let (new, discarded) = keep(ctx, &bench, &answer, req, &keep_paths);
1729    if new.is_empty() {
1730        return Err(failed(format!(
1731            "the test author wrote no usable test{}",
1732            discarded
1733                .first()
1734                .map(|x| format!(" ({} {})", x.path, x.why))
1735                .unwrap_or_default()
1736        )));
1737    }
1738    d.tests.retain(|t| !replaced.contains(&t.path));
1739    contents.retain(|(p, _)| !replaced.contains(p));
1740    d.discarded.extend(discarded);
1741    stage(options, &req.key, "checking_red", 1, 1);
1742    // Each test is red-checked alone: the kept ones stand as checked.
1743    let plan: Vec<(String, String, Vec<String>)> = new
1744        .iter()
1745        .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1746        .collect();
1747    let classes = bench.red(&plan).map_err(failed)?;
1748    let mut written = Vec::new();
1749    for ((path, content, cited, argv), class) in new.into_iter().zip(classes) {
1750        let red_class = match class {
1751            Ok(c) => c,
1752            Err(why) => {
1753                d.discarded.push(OracleDiscarded {
1754                    path,
1755                    why: why.to_owned(),
1756                });
1757                continue;
1758            }
1759        };
1760        d.tests.push(DraftTest {
1761            sha256: digest_of(&content),
1762            scenarios: cited,
1763            argv,
1764            red_class,
1765            authored_by: AuthoredBy::Model,
1766            edited_by: None,
1767            path: path.clone(),
1768        });
1769        contents.push((path.clone(), content));
1770        written.push(path);
1771    }
1772    store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1773    Ok((d, written))
1774}
1775
1776fn again(
1777    ctx: &Ctx,
1778    req: &OracleRequirement,
1779    r: &Request<'_>,
1780    _by: &str,
1781    provider: Option<Arc<dyn ModelProvider>>,
1782    options: &crate::RunOptions,
1783) -> Outcome {
1784    let Some(path) = r.path else {
1785        return Err(usage("name the test to write again (--again PATH)"));
1786    };
1787    let comment = r
1788        .text
1789        .map(str::trim)
1790        .filter(|t| !t.is_empty())
1791        .unwrap_or("write it again");
1792    let (d, contents) = existing(ctx, req)?;
1793    let Some(content) = contents
1794        .iter()
1795        .find(|(p, _)| p == path)
1796        .map(|(_, c)| c.clone())
1797    else {
1798        return Err(usage(&format!("{} has no test {path}", req.key)));
1799    };
1800    let (d, written) = rewrite(
1801        ctx,
1802        req,
1803        d,
1804        contents,
1805        &[path.to_owned()],
1806        format!("Comment on `{path}`: {comment}"),
1807        Some((path, &content)),
1808        None,
1809        provider,
1810        options,
1811    )?;
1812    let red = d
1813        .tests
1814        .iter()
1815        .filter(|t| written.contains(&t.path) && t.red_class.is_red())
1816        .count();
1817    Ok(format!(
1818        "wrote {path} of {} again: {}",
1819        req.key,
1820        if written.is_empty() {
1821            "nothing kept — its command is red without it, so it decides nothing".to_owned()
1822        } else if red == written.len() {
1823            "red".to_owned()
1824        } else {
1825            "not red — no evidence, it cannot be frozen".to_owned()
1826        }
1827    ))
1828}
1829
1830fn reading(
1831    ctx: &Ctx,
1832    req: &OracleRequirement,
1833    r: &Request<'_>,
1834    by: &str,
1835    provider: Option<Arc<dyn ModelProvider>>,
1836    options: &crate::RunOptions,
1837) -> Outcome {
1838    let (Some(scenario), Some(choice)) = (r.scenario, r.reading) else {
1839        return Err(usage(
1840            "name the reading and the scenario (--reading A|B US1-2)",
1841        ));
1842    };
1843    let (mut d, contents) = existing(ctx, req)?;
1844    let Some(item) = d.ambiguities.iter_mut().find(|a| a.scenario == scenario) else {
1845        return Err(usage(&format!(
1846            "{} has no ambiguity on {scenario}",
1847            req.key
1848        )));
1849    };
1850    item.resolution = Some(Resolution {
1851        reading: choice,
1852        by: by.to_owned(),
1853        at: now(),
1854    });
1855    let meant = if choice == Reading::A {
1856        item.a.clone()
1857    } else {
1858        item.b.clone()
1859    };
1860    if choice == Reading::A {
1861        d.notes.push(format!(
1862            "For {scenario} the person means reading A: given {}, expect {}",
1863            meant.given, meant.expect
1864        ));
1865        store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1866        return Ok(format!(
1867            "{scenario} of {}: reading A is meant — the tests stay",
1868            req.key
1869        ));
1870    }
1871    let replaced: Vec<String> = d
1872        .tests
1873        .iter()
1874        .filter(|t| t.scenarios.iter().any(|s| s == scenario))
1875        .map(|t| t.path.clone())
1876        .collect();
1877    let (_, written) = rewrite(
1878        ctx,
1879        req,
1880        d,
1881        contents,
1882        &replaced,
1883        format!(
1884            "For {scenario} the person means reading B: given {}, expect {}",
1885            meant.given, meant.expect
1886        ),
1887        None,
1888        Some(scenario),
1889        provider,
1890        options,
1891    )?;
1892    Ok(format!(
1893        "{scenario} of {}: reading B is meant — wrote {} anew",
1894        req.key,
1895        written.join(", ")
1896    ))
1897}
1898
1899/// A requirement whose tests an approval can freeze: its draft on the
1900/// current wording, no reading open, at least one red test.
1901#[derive(Debug, Clone)]
1902pub struct Freezable {
1903    /// The requirement.
1904    pub req: OracleRequirement,
1905    /// Its draft.
1906    pub draft: Draft,
1907    /// The red tests' contents.
1908    pub contents: Vec<(String, String)>,
1909    /// The red set's manifest digest.
1910    pub manifest: String,
1911}
1912
1913/// The requirements an approval freezes, in the spec's order. `Err`: a
1914/// stored draft was tampered with.
1915pub fn freezable(
1916    project_state: &Path,
1917    unit: &str,
1918    requirements: &[OracleRequirement],
1919) -> Result<Vec<Freezable>, Tampered> {
1920    let mut out = Vec::new();
1921    for r in requirements {
1922        let Some((draft, contents)) = load(project_state, unit, &r.key)? else {
1923            continue;
1924        };
1925        if draft.requirement.sha256 != r.digest() || draft.open_ambiguities().next().is_some() {
1926            continue;
1927        }
1928        let Some(manifest) = draft.manifest() else {
1929            continue;
1930        };
1931        let red: Vec<String> = draft.red().map(|t| t.path.clone()).collect();
1932        out.push(Freezable {
1933            req: r.clone(),
1934            contents: contents
1935                .into_iter()
1936                .filter(|(p, _)| red.contains(p))
1937                .collect(),
1938            draft,
1939            manifest,
1940        });
1941    }
1942    Ok(out)
1943}
1944
1945/// Freezes one requirement's red set with the approval: one
1946/// [`PreFrozenAcceptance`] (producer `review`) in the pre-frozen store,
1947/// its sealed held-out set checked, and the commands and wording beside it.
1948pub fn freeze(
1949    project_state: &Path,
1950    unit: &str,
1951    f: &Freezable,
1952    approval_digest: &str,
1953    decided: Decided,
1954) -> Result<PreFrozenAcceptance, String> {
1955    let sealed = match &f.draft.sealed {
1956        Some(s) => {
1957            crate::held_out::open_sealed(project_state, unit, s)
1958                .map_err(|t| format!("{}'s held-out set was tampered with: {}", f.req.key, t.0))?;
1959            Some(s.clone())
1960        }
1961        None => None,
1962    };
1963    let red: Vec<&DraftTest> = f.draft.red().collect();
1964    let record = PreFrozenAcceptance {
1965        unit: unit.to_owned(),
1966        requirement: f.req.key.clone(),
1967        wording_digest: f.draft.requirement.sha256.clone(),
1968        approval_digest: approval_digest.to_owned(),
1969        visible: red
1970            .iter()
1971            .map(|t| VisibleTest {
1972                path: t.path.clone(),
1973                sha256: t.sha256.clone(),
1974                cites: t.scenarios.clone(),
1975            })
1976            .collect(),
1977        held_out_sealed: sealed,
1978        producer: Producer::Review,
1979        decided,
1980    };
1981    crate::pre_frozen::store(project_state, &record, &f.contents)?;
1982    let frozen = Frozen {
1983        requirement_sha256: f.draft.requirement.sha256.clone(),
1984        wording: format!(
1985            "{}\n{}",
1986            f.draft.requirement.title, f.draft.requirement.text
1987        ),
1988        manifest_sha256: f.manifest.clone(),
1989        tests: red
1990            .iter()
1991            .map(|t| FrozenTest {
1992                path: t.path.clone(),
1993                sha256: t.sha256.clone(),
1994                argv: t.argv.clone(),
1995                scenarios: t.scenarios.clone(),
1996            })
1997            .collect(),
1998    };
1999    let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2000    write_atomic(
2001        &dir(project_state, unit, &f.req.key).join("frozen.json"),
2002        &text,
2003    )?;
2004    Ok(record)
2005}
2006
2007/// What a task's checkpoint does with a frozen oracle.
2008#[derive(Debug)]
2009pub(crate) enum TaskReuse {
2010    /// Reuse it: write, red-check, no author call.
2011    Ready {
2012        /// The record.
2013        record: Box<PreFrozenAcceptance>,
2014        /// Its files.
2015        files: Vec<(String, String)>,
2016        /// Each test with its command.
2017        tests: Vec<crate::pipeline::ReusedTest>,
2018        /// The manifest digest the tick names.
2019        manifest: String,
2020    },
2021    /// An earlier task reused it (its tick names the manifest).
2022    Reused {
2023        /// The requirement.
2024        req: String,
2025    },
2026    /// `review.json` names a frozen set this machine does not have.
2027    Elsewhere {
2028        /// The requirement.
2029        req: String,
2030    },
2031    /// The requirement's wording changed since it was frozen.
2032    Lapsed {
2033        /// The requirement.
2034        req: String,
2035        /// The wording it was frozen on.
2036        frozen: String,
2037        /// The wording now.
2038        current: String,
2039    },
2040    /// The stored set no longer matches what was frozen.
2041    Tampered(String),
2042    /// `review.json` cannot be read: whether anything is frozen is unknown.
2043    Unreadable(String),
2044}
2045
2046/// The frozen oracle `task` (citing `scenarios`) reuses, if any: the
2047/// first requirement whose scenarios it cites and whose oracle `review.json`
2048/// (in `project`, the worktree's committed copy) froze — unless an earlier
2049/// task's tick in the worktree's history names its manifest.
2050pub(crate) fn for_task(
2051    project_state: &Path,
2052    project: &Path,
2053    wt_root: &Path,
2054    format: &dyn Adapter,
2055    snapshot: &ProjectSnapshot,
2056    scenarios: &[speccoder_core::spec::ScenarioRef],
2057) -> Option<TaskReuse> {
2058    let f = snapshot.feature.as_ref()?;
2059    let unit = f.feature.name.as_str();
2060    let (entries, approval) = match crate::review::oracle_entries(project, &f.feature.dir) {
2061        Ok(x) => x,
2062        // Unreadable is never "nothing frozen": the task stops for a person.
2063        Err(e) => return Some(TaskReuse::Unreadable(e)),
2064    };
2065    if entries.is_empty() {
2066        return None;
2067    }
2068    let ids: Vec<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
2069    let requirements = format.oracle_requirements(snapshot);
2070    let (req, entry) = requirements.iter().find_map(|r| {
2071        let e = entries.get(&r.key)?;
2072        r.scenarios
2073            .iter()
2074            .any(|s| ids.contains(&s.id.as_str()))
2075            .then_some((r, e))
2076    })?;
2077    let digest = req.digest();
2078    if entry.requirement_sha256 != digest {
2079        return Some(TaskReuse::Lapsed {
2080            req: req.key.clone(),
2081            frozen: entry.requirement_sha256.clone(),
2082            current: digest,
2083        });
2084    }
2085    let (record, files) = match crate::pre_frozen::load(project_state, unit, &req.key) {
2086        Ok(Some(x)) => x,
2087        Ok(None) => {
2088            return Some(TaskReuse::Elsewhere {
2089                req: req.key.clone(),
2090            });
2091        }
2092        Err(t) => return Some(TaskReuse::Tampered(t.0)),
2093    };
2094    // Reused by an earlier task: its tick names the frozen manifest in the
2095    // one `Acceptance:` trailer form, which only speccoder's commit writes —
2096    // never decided by a file the implementer could commit (P2).
2097    let needle = format!("Acceptance: review manifest={} ", entry.manifest_sha256);
2098    let reused = crate::git::Git::at(wt_root)
2099        .run(&[
2100            "log",
2101            "--format=%H",
2102            "--fixed-strings",
2103            &format!("--grep={needle}"),
2104            "HEAD",
2105        ])
2106        .map(|out| !out.is_empty());
2107    match reused {
2108        Ok(true) => {
2109            return Some(TaskReuse::Reused {
2110                req: req.key.clone(),
2111            });
2112        }
2113        Ok(false) => {}
2114        Err(e) => {
2115            return Some(TaskReuse::Unreadable(format!(
2116                "the worktree's history cannot be read: {}",
2117                e.0
2118            )));
2119        }
2120    }
2121    if !record.is_current(&digest) {
2122        return Some(TaskReuse::Lapsed {
2123            req: req.key.clone(),
2124            frozen: record.wording_digest.clone(),
2125            current: digest,
2126        });
2127    }
2128    let Some(frozen) = load_frozen(project_state, unit, &req.key) else {
2129        return Some(TaskReuse::Tampered(format!(
2130            "{}'s frozen commands are missing from the state directory",
2131            req.key
2132        )));
2133    };
2134    let manifest = manifest_digest(
2135        frozen
2136            .tests
2137            .iter()
2138            .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2139    );
2140    let same_files = frozen.tests.len() == record.visible.len()
2141        && frozen.tests.iter().all(|t| {
2142            record
2143                .visible
2144                .iter()
2145                .any(|v| v.path == t.path && v.sha256 == t.sha256)
2146        });
2147    if manifest != entry.manifest_sha256 || manifest != frozen.manifest_sha256 || !same_files {
2148        return Some(TaskReuse::Tampered(format!(
2149            "{}'s frozen tests no longer match the manifest review.json names",
2150            req.key
2151        )));
2152    }
2153    // Bound both ways (P3): the approval review.json records, and the
2154    // held-out set it names — a record that dropped either is not reused.
2155    if approval.as_deref() != Some(record.approval_digest.as_str()) {
2156        return Some(TaskReuse::Tampered(format!(
2157            "{}'s frozen set names another approval than review.json{}",
2158            req.key,
2159            if approval.is_none() {
2160                " (which records none)"
2161            } else {
2162                ""
2163            }
2164        )));
2165    }
2166    let sealed = record
2167        .held_out_sealed
2168        .as_ref()
2169        .map(|h| format!("sha256:{}", h.manifest_sha256));
2170    if sealed != entry.held_out_sha256 {
2171        return Some(TaskReuse::Tampered(format!(
2172            "{}'s frozen held-out set is not the one review.json names",
2173            req.key
2174        )));
2175    }
2176    let tests = frozen
2177        .tests
2178        .iter()
2179        .map(|t| crate::pipeline::ReusedTest {
2180            path: t.path.clone(),
2181            cites: t.scenarios.clone(),
2182            argv: t.argv.clone(),
2183            green: false,
2184        })
2185        .collect();
2186    Some(TaskReuse::Ready {
2187        record: Box::new(record),
2188        files,
2189        tests,
2190        manifest,
2191    })
2192}
2193
2194/// Records that `req`'s oracle lapsed from `frozen` to `current`; `true`
2195/// the first time (the `oracle.lapsed` event is emitted once per lapse).
2196pub(crate) fn note_lapse(project_state: &Path, unit: &str, req: &str, frozen: &str) -> bool {
2197    let file = project_state.join(DIR).join(unit).join("lapsed.json");
2198    let mut seen: Vec<String> = std::fs::read_to_string(&file)
2199        .ok()
2200        .and_then(|t| serde_json::from_str(&t).ok())
2201        .unwrap_or_default();
2202    let key = format!("{req}={frozen}");
2203    if seen.contains(&key) {
2204        return false;
2205    }
2206    seen.push(key);
2207    let _ = write_atomic(&file, &serde_json::to_string(&seen).unwrap_or_default());
2208    true
2209}
2210
2211/// The calls an autopilot run refused, as its tool log recorded them.
2212#[must_use]
2213pub fn autopilot_denials(
2214    task: &str,
2215    tool_calls: &[Value],
2216) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2217    tool_calls
2218        .iter()
2219        .enumerate()
2220        .filter(|(_, c)| {
2221            c["decision"] == "deny" && c["reason"].as_str() == Some(tools::policy::AUTOPILOT_DENIED)
2222        })
2223        .map(|(i, c)| {
2224            let object: Option<tools::policy::Object> =
2225                serde_json::from_value(c["object"].clone()).ok();
2226            speccoder_core::oracle::AutopilotDenial {
2227                task: task.to_owned(),
2228                turn: c["turn"]
2229                    .as_u64()
2230                    .and_then(|n| u32::try_from(n).ok())
2231                    .unwrap_or_else(|| u32::try_from(i + 1).unwrap_or(u32::MAX)),
2232                class: object
2233                    .as_ref()
2234                    .map(|o| o.class().as_str().to_owned())
2235                    .unwrap_or_default(),
2236                object: object.as_ref().map_or_else(
2237                    || {
2238                        c["arguments"]["path"]
2239                            .as_str()
2240                            .map(str::to_owned)
2241                            .or_else(|| {
2242                                c["arguments"]["argv"].as_array().map(|a| {
2243                                    a.iter()
2244                                        .filter_map(Value::as_str)
2245                                        .collect::<Vec<_>>()
2246                                        .join(" ")
2247                                })
2248                            })
2249                            .unwrap_or_else(|| c["tool"].as_str().unwrap_or_default().to_owned())
2250                    },
2251                    tools::policy::Object::describe,
2252                ),
2253                reason: tools::policy::AUTOPILOT_DENIED.to_owned(),
2254            }
2255        })
2256        .collect()
2257}
2258
2259/// The last autopilot run's denials of a unit, for Needs You.
2260#[must_use]
2261pub fn last_denials(
2262    project_state: &Path,
2263    unit: &str,
2264) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2265    std::fs::read_to_string(project_state.join(DIR).join(unit).join("autopilot.json"))
2266        .ok()
2267        .and_then(|t| serde_json::from_str(&t).ok())
2268        .unwrap_or_default()
2269}
2270
2271/// Records a run's denials (none: clears them).
2272pub(crate) fn record_denials(
2273    project_state: &Path,
2274    unit: &str,
2275    denials: &[speccoder_core::oracle::AutopilotDenial],
2276) {
2277    let file = project_state.join(DIR).join(unit).join("autopilot.json");
2278    if denials.is_empty() {
2279        let _ = std::fs::remove_file(file);
2280        return;
2281    }
2282    let _ = write_atomic(
2283        &file,
2284        &serde_json::to_string_pretty(denials).unwrap_or_default(),
2285    );
2286}
2287
2288#[cfg(test)]
2289mod tests {
2290    use super::*;
2291
2292    fn draft(content: &str) -> Draft {
2293        Draft {
2294            requirement: DraftRequirement {
2295                key: "US1".to_owned(),
2296                title: "Greet".to_owned(),
2297                text: "### User Story 1 - Greet".to_owned(),
2298                sha256: "sha256:w".to_owned(),
2299            },
2300            samples: 2,
2301            tests: vec![DraftTest {
2302                path: "tests/acceptance_us1.rs".to_owned(),
2303                sha256: digest_of(content),
2304                scenarios: vec!["US1-1".to_owned()],
2305                argv: vec!["cargo".to_owned(), "test".to_owned()],
2306                red_class: RedClass::Assertion,
2307                authored_by: AuthoredBy::Model,
2308                edited_by: None,
2309            }],
2310            discarded: Vec::new(),
2311            rejected: Vec::new(),
2312            expectations: Samples::default(),
2313            untestable: Samples::default(),
2314            ambiguities: Vec::new(),
2315            held_out: HeldOutSummary {
2316                state: "absent".to_owned(),
2317                count: 0,
2318                manifest_sha256: None,
2319                reason: None,
2320            },
2321            sealed: None,
2322            notes: Vec::new(),
2323            runs: Vec::new(),
2324            context: Vec::new(),
2325        }
2326    }
2327
2328    #[test]
2329    fn a_draft_round_trips_and_a_changed_test_is_tampering() {
2330        let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2331        let d = draft("#[test] fn t() {}");
2332        let files = vec![(
2333            "tests/acceptance_us1.rs".to_owned(),
2334            "#[test] fn t() {}".to_owned(),
2335        )];
2336        store(state.path(), "001-greet", &d, &files).unwrap_or_else(|e| panic!("{e}"));
2337        let (back, contents) = load(state.path(), "001-greet", "US1")
2338            .unwrap_or_else(|e| panic!("{e:?}"))
2339            .unwrap_or_else(|| panic!("stored"));
2340        assert_eq!(back, d);
2341        assert_eq!(contents, files);
2342        std::fs::write(
2343            dir(state.path(), "001-greet", "US1").join("files/tests/acceptance_us1.rs"),
2344            "changed",
2345        )
2346        .unwrap_or_else(|e| panic!("{e}"));
2347        assert!(load(state.path(), "001-greet", "US1").is_err());
2348        assert!(store(state.path(), "001-greet", &d, &[]).is_err());
2349        assert!(
2350            load(state.path(), "001-greet", "US2")
2351                .unwrap_or_default()
2352                .is_none()
2353        );
2354    }
2355
2356    #[test]
2357    fn the_manifest_names_paths_contents_and_commands() {
2358        let argv = vec!["cargo".to_owned()];
2359        let other = vec!["make".to_owned()];
2360        let a = manifest_digest([("a", "sha256:1", argv.as_slice())]);
2361        assert_eq!(a, manifest_digest([("a", "sha256:1", argv.as_slice())]));
2362        assert_ne!(a, manifest_digest([("a", "sha256:2", argv.as_slice())]));
2363        assert_ne!(a, manifest_digest([("a", "sha256:1", other.as_slice())]));
2364        let both = manifest_digest([("a", "s", argv.as_slice()), ("b", "s", argv.as_slice())]);
2365        assert_eq!(
2366            both,
2367            manifest_digest([("b", "s", argv.as_slice()), ("a", "s", argv.as_slice())])
2368        );
2369        let mut d = draft("x");
2370        assert!(d.manifest().is_some());
2371        d.tests[0].red_class = RedClass::Passed;
2372        assert!(d.manifest().is_none());
2373        assert_eq!(slug("cap: a / b"), "cap__a___b");
2374    }
2375
2376    #[test]
2377    fn a_lapse_is_noted_once() {
2378        let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2379        assert!(note_lapse(state.path(), "u", "US1", "sha256:a"));
2380        assert!(!note_lapse(state.path(), "u", "US1", "sha256:a"));
2381        assert!(note_lapse(state.path(), "u", "US1", "sha256:b"));
2382    }
2383}