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