Skip to main content

speccoder_runtime/
review.rs

1//! `speccoder review`: the person's review of the spec, one
2//! requirement at a time. A decision — approved, or changes requested with a
3//! comment — is bound to the exact line it was made on, and names who made
4//! it; when the line changes, the decision lapses. It is recorded in
5//! `review.json` beside the spec, to commit and share like the spec itself.
6//! The model never writes it ([`crate::tools::paths::REVIEW_FILE`]).
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use speccoder_core::{
13    CommandData, CommandResult, CommandStatus, OpenQuestion, RequirementDrift, ReviewItem,
14    ReviewReport, ReviewTotals, SpecVersion,
15};
16
17use crate::tools::paths::REVIEW_FILE;
18
19/// One decision as recorded.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21struct Decision {
22    /// `approved` or `changes_requested`.
23    verdict: String,
24    /// The line decided on.
25    line: String,
26    /// `sha256:<hex>` of the line, trailing space aside.
27    sha256: String,
28    /// Who (git's `user.name`, else the OS user).
29    by: String,
30    /// `cli`, `web`, `editor <name>`.
31    via: String,
32    /// Unix seconds.
33    at: u64,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    comment: Option<String>,
36}
37
38/// The file.
39#[derive(Debug, Default, Serialize, Deserialize)]
40struct Record {
41    #[serde(default)]
42    requirements: BTreeMap<String, Decision>,
43    /// The frozen oracle, by requirement (`US1`): digests only.
44    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45    oracle: BTreeMap<String, crate::oracle::OracleEntry>,
46    /// The approval that froze it.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    approval: Option<speccoder_core::oracle::ReviewApproval>,
49}
50
51fn digest(line: &str) -> String {
52    format!(
53        "sha256:{}",
54        crate::integrity::sha256_hex(line.trim_end().as_bytes())
55    )
56}
57
58/// `- **FR-001**: Users MUST …` → `Users MUST …`.
59fn wording(line: &str) -> String {
60    let t = line.trim_start().trim_start_matches("- ");
61    match t.strip_prefix("**").and_then(|r| r.split_once("**")) {
62        Some((_, rest)) => rest.trim_start_matches(':').trim().to_owned(),
63        None => t.trim().to_owned(),
64    }
65}
66
67/// What a review is about: the feature, its directory, the project root.
68struct Unit {
69    feature: String,
70    root: PathBuf,
71    dir: String,
72    format: &'static dyn crate::format::Adapter,
73    /// The project as inspected (the requirements with scenarios come
74    /// from it); `None` for the run's gate, which reads decision lines only.
75    snapshot: Option<Box<speccoder_core::snapshot::ProjectSnapshot>>,
76    /// The project's state directory, when there is one.
77    state: Option<PathBuf>,
78}
79
80impl Unit {
81    fn file(&self) -> PathBuf {
82        self.root.join(&self.dir).join(REVIEW_FILE)
83    }
84    fn rel(&self) -> String {
85        format!("{}/{REVIEW_FILE}", self.dir.trim_end_matches('/'))
86    }
87    /// The requirements as the spec in the checkout has them now.
88    fn requirements(&self) -> Vec<(String, String)> {
89        let Some(file) = self.format.requirements_file() else {
90            return Vec::new();
91        };
92        let text =
93            std::fs::read_to_string(self.root.join(&self.dir).join(file)).unwrap_or_default();
94        self.format.requirements_in(&text)
95    }
96    /// The recorded review; none written yet is the empty record, one
97    /// that cannot be read is an `Err` (never read as empty).
98    fn record(&self) -> Result<Record, String> {
99        read_record(&self.file())
100    }
101    /// The requirements with scenarios a review writes tests for.
102    fn oracle_requirements(&self) -> Vec<crate::format::OracleRequirement> {
103        self.snapshot
104            .as_ref()
105            .map(|s| self.format.oracle_requirements(s))
106            .unwrap_or_default()
107            .into_iter()
108            .filter(|r| !r.scenarios.is_empty())
109            .collect()
110    }
111    /// The unit's rung (a quick change's, a bug's, a feature's).
112    fn rung(&self) -> speccoder_core::ladder::Rung {
113        crate::oracle::rung(self.format, &self.root, &self.feature)
114    }
115}
116
117fn unit(command: &str, path: &Path, feature: Option<&str>) -> Result<Unit, Box<CommandResult>> {
118    unit_in(command, path, feature, None)
119}
120
121fn unit_in(
122    command: &str,
123    path: &Path,
124    feature: Option<&str>,
125    state: Option<&Path>,
126) -> Result<Unit, Box<CommandResult>> {
127    let inspected = crate::inspect(path, feature);
128    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
129        return Err(Box::new(CommandResult::carried(command, &inspected)));
130    };
131    let Some(f) = snapshot.feature.as_ref() else {
132        return Err(Box::new(CommandResult::new(
133            command,
134            CommandStatus::SelectionNeeded,
135            inspected.message(),
136        )));
137    };
138    let root = PathBuf::from(&snapshot.root);
139    let state = state
140        .map(Path::to_path_buf)
141        .or_else(crate::worktree::state_dir)
142        .map(|s| crate::worktree::project_state_dir(&s, &root));
143    Ok(Unit {
144        feature: f.feature.name.clone(),
145        root,
146        dir: f.feature.dir.clone(),
147        format: crate::format::of(snapshot),
148        snapshot: Some(snapshot.clone()),
149        state,
150    })
151}
152
153/// Each requirement with its state against the record.
154fn items(requirements: &[(String, String)], record: &Record) -> Vec<ReviewItem> {
155    requirements
156        .iter()
157        .map(|(id, line)| {
158            let d = record.requirements.get(id);
159            let current = d.is_some_and(|d| d.sha256 == digest(line));
160            let state = match d {
161                None => "open",
162                Some(_) if !current => "lapsed",
163                Some(d) if d.verdict == "approved" => "approved",
164                Some(_) => "changes_requested",
165            };
166            ReviewItem {
167                id: id.clone(),
168                text: wording(line),
169                state: state.to_owned(),
170                by: d.map(|d| d.by.clone()),
171                at: d.map(|d| d.at),
172                comment: d.and_then(|d| d.comment.clone()),
173                decided_on: d.filter(|_| !current).map(|d| wording(&d.line)),
174            }
175        })
176        .collect()
177}
178
179fn totals(items: &[ReviewItem], questions: usize) -> ReviewTotals {
180    let n = |s: &str| items.iter().filter(|i| i.state == s).count();
181    ReviewTotals {
182        approved: n("approved"),
183        changes_requested: n("changes_requested"),
184        open: n("open"),
185        lapsed: n("lapsed"),
186        questions,
187        ambiguous: 0,
188    }
189}
190
191/// The marker a skill leaves where it could not decide.
192const MARKER: &str = "[NEEDS CLARIFICATION";
193
194/// The unit's Markdown documents, the requirements file first, then by
195/// name: (project-relative path, text).
196fn documents(u: &Unit) -> Vec<(String, String)> {
197    let dir = u.root.join(&u.dir);
198    let mut names: Vec<String> = std::fs::read_dir(&dir)
199        .into_iter()
200        .flatten()
201        .flatten()
202        .filter(|e| e.path().is_file())
203        .map(|e| e.file_name().to_string_lossy().into_owned())
204        .filter(|n| n.ends_with(".md"))
205        .collect();
206    let first = u.format.requirements_file().unwrap_or_default();
207    names.sort_by_key(|n| (n != first, n.clone()));
208    names
209        .into_iter()
210        .filter_map(|n| {
211            let text = std::fs::read_to_string(dir.join(&n)).ok()?;
212            Some((format!("{}/{n}", u.dir.trim_end_matches('/')), text))
213        })
214        .collect()
215}
216
217/// Every `[NEEDS CLARIFICATION: …]` in `text`: (the marker as written, the
218/// question), in order.
219fn markers(line: &str) -> Vec<(String, String)> {
220    let mut out = Vec::new();
221    let mut rest = line;
222    while let Some(at) = rest.find(MARKER) {
223        let tail = &rest[at..];
224        let Some(end) = tail.find(']') else { break };
225        let raw = &tail[..=end];
226        let question = raw[MARKER.len()..raw.len() - 1]
227            .trim_start_matches(':')
228            .trim()
229            .to_owned();
230        out.push((raw.to_owned(), question));
231        rest = &tail[end + 1..];
232    }
233    out
234}
235
236/// The questions the unit's documents leave open, with their markers.
237fn questions(u: &Unit) -> Vec<(OpenQuestion, String)> {
238    let mut out = Vec::new();
239    for (file, text) in documents(u) {
240        let mut section: Option<String> = None;
241        for (i, line) in text.lines().enumerate() {
242            if let Some(h) = line
243                .strip_prefix("## ")
244                .or_else(|| line.strip_prefix("### "))
245            {
246                section = Some(h.trim().to_owned());
247            }
248            let near = u
249                .format
250                .requirements_in(line)
251                .first()
252                .map(|(id, _)| id.clone())
253                .or_else(|| section.clone());
254            for (raw, text) in markers(line) {
255                out.push((
256                    OpenQuestion {
257                        id: format!("Q{}", out.len() + 1),
258                        text,
259                        file: file.clone(),
260                        line: i + 1,
261                        near: near.clone(),
262                    },
263                    raw,
264                ));
265            }
266        }
267    }
268    out
269}
270
271/// The requirements as `rev` (`HEAD`, a commit) has the requirements file.
272fn requirements_at(u: &Unit, rev: &str) -> Option<Vec<(String, String)>> {
273    let file = u.format.requirements_file()?;
274    let rel = format!("{}/{file}", u.dir.trim_end_matches('/'));
275    let text = crate::git::Git::at(&u.root)
276        .run(&["show", &format!("{rev}:./{rel}")])
277        .ok()?;
278    Some(u.format.requirements_in(&String::from_utf8_lossy(&text)))
279}
280
281/// The requirements changed in the checkout since the spec's last commit,
282/// each with its line number on both sides.
283fn changes_since_head(u: &Unit) -> Vec<speccoder_core::ReviewChange> {
284    let Some(file) = u.format.requirements_file() else {
285        return Vec::new();
286    };
287    let Some(then) = requirements_at(u, "HEAD") else {
288        return Vec::new();
289    };
290    let path = format!("{}/{file}", u.dir.trim_end_matches('/'));
291    let old_text = crate::git::Git::at(&u.root)
292        .run(&["show", &format!("HEAD:./{path}")])
293        .map(|b| String::from_utf8_lossy(&b).into_owned())
294        .unwrap_or_default();
295    let new_text = std::fs::read_to_string(u.root.join(&path)).unwrap_or_default();
296    let line_of = |text: &str, line: &Option<String>| {
297        let line = line.as_deref()?.trim_end();
298        text.lines()
299            .position(|l| l.trim_end() == line)
300            .and_then(|i| u32::try_from(i + 1).ok())
301    };
302    compare(&then, &u.requirements())
303        .into_iter()
304        .map(|d| speccoder_core::ReviewChange {
305            then_line: line_of(&old_text, &d.then),
306            now_line: line_of(&new_text, &d.now),
307            path: path.clone(),
308            id: d.id,
309            then: d.then,
310            now: d.now,
311        })
312        .collect()
313}
314
315/// What changed from `before` to `after`, requirement by requirement.
316fn compare(before: &[(String, String)], after: &[(String, String)]) -> Vec<RequirementDrift> {
317    let mut ids: Vec<&String> = after.iter().map(|(id, _)| id).collect();
318    for (id, _) in before {
319        if !ids.contains(&id) {
320            ids.push(id);
321        }
322    }
323    let line = |v: &[(String, String)], id: &str| {
324        v.iter()
325            .find(|(i, _)| i == id)
326            .map(|(_, l)| l.trim_end().to_owned())
327    };
328    ids.into_iter()
329        .filter_map(|id| {
330            let (then, now) = (line(before, id), line(after, id));
331            (then != now).then(|| RequirementDrift {
332                id: id.clone(),
333                then,
334                now,
335            })
336        })
337        .collect()
338}
339
340/// The spec's last commits and what each changed in its requirements.
341fn history(u: &Unit) -> Vec<SpecVersion> {
342    let Some(file) = u.format.requirements_file() else {
343        return Vec::new();
344    };
345    let rel = format!("{}/{file}", u.dir.trim_end_matches('/'));
346    let git = crate::git::Git::at(&u.root);
347    let log = git
348        .run(&[
349            "log",
350            "-n",
351            "12",
352            "--format=%H%x1f%as%x1f%an%x1f%s",
353            "--",
354            &rel,
355        ])
356        .map(|b| String::from_utf8_lossy(&b).into_owned())
357        .unwrap_or_default();
358    log.lines()
359        .filter_map(|l| {
360            let mut f = l.split('\u{1f}');
361            let (commit, date, author, subject) = (f.next()?, f.next()?, f.next()?, f.next()?);
362            let now = requirements_at(u, commit).unwrap_or_default();
363            let before = requirements_at(u, &format!("{commit}^")).unwrap_or_default();
364            let changes = compare(&before, &now);
365            let pick = |want: fn(&RequirementDrift) -> bool| {
366                changes
367                    .iter()
368                    .filter(|c| want(c))
369                    .map(|c| c.id.clone())
370                    .collect::<Vec<_>>()
371            };
372            Some(SpecVersion {
373                commit: commit.to_owned(),
374                date: date.to_owned(),
375                author: author.to_owned(),
376                subject: subject.to_owned(),
377                added: pick(|c| c.then.is_none()),
378                reworded: pick(|c| c.then.is_some() && c.now.is_some()),
379                removed: pick(|c| c.now.is_none()),
380            })
381        })
382        .collect()
383}
384
385/// The review state of the feature in `dir` (project-relative) under
386/// `root`; `None` when the format has no requirement identifiers or the
387/// spec names none.
388#[must_use]
389pub fn totals_of(
390    root: &Path,
391    format: &'static dyn crate::format::Adapter,
392    feature: &str,
393    dir: &str,
394) -> Option<ReviewTotals> {
395    let u = Unit {
396        feature: feature.to_owned(),
397        root: root.to_path_buf(),
398        dir: dir.to_owned(),
399        format,
400        snapshot: None,
401        state: None,
402    };
403    let requirements = u.requirements();
404    // A record that cannot be read approves nothing: every line counts open.
405    (!requirements.is_empty()).then(|| {
406        totals(
407            &items(&requirements, &u.record().unwrap_or_default()),
408            questions(&u).len(),
409        )
410    })
411}
412
413/// The review; `full` adds what changed since the last commit and the
414/// history (git reads, for the review view — not for the inbox).
415fn report_of(u: &Unit, full: bool) -> ReviewReport {
416    // Shown as it is: a record that cannot be read approves and freezes
417    // nothing, and the report says why.
418    let (record, error) = match u.record() {
419        Ok(r) => (r, None),
420        Err(e) => (Record::default(), Some(e)),
421    };
422    let items = items(&u.requirements(), &record);
423    let skills = crate::skills::discover_with(&u.root, u.format.planning());
424    let installed = |name: &str| {
425        let full = format!("{}{name}", u.format.planning().prefix);
426        skills.iter().any(|s| s.name == full && s.refused.is_none())
427    };
428    let open: Vec<OpenQuestion> = questions(u).into_iter().map(|(q, _)| q).collect();
429    let readiness = readiness_of(u, open.len());
430    let totals = totals(&items, open.len());
431    let revision = (totals.changes_requested > 0).then(|| revision_request(&items));
432    let changes = if full {
433        changes_since_head(u)
434    } else {
435        Vec::new()
436    };
437    let rung = u.rung();
438    let (oracle, tests_notice) = if rung == speccoder_core::ladder::Rung::Feature {
439        let cfg = crate::oracle::ViewConfig::at(&u.root);
440        let views = u
441            .oracle_requirements()
442            .iter()
443            .map(|r| match &u.state {
444                Some(state) => {
445                    crate::oracle::view(state, &u.feature, r, record.oracle.get(&r.key), cfg)
446                }
447                None => crate::oracle::view(
448                    Path::new("/nonexistent-speccoder-state"),
449                    &u.feature,
450                    r,
451                    record.oracle.get(&r.key),
452                    cfg,
453                ),
454            })
455            .collect::<Vec<_>>();
456        (views, None)
457    } else {
458        (
459            Vec::new(),
460            Some("Tests for this unit are written and confirmed by its own pipeline".to_owned()),
461        )
462    };
463    let bug_tests = if rung == speccoder_core::ladder::Rung::Bug {
464        u.state
465            .as_deref()
466            .map(|plane| confirmed_reproductions(plane, &u.feature))
467            .unwrap_or_default()
468    } else {
469        Vec::new()
470    };
471    let mut totals = totals;
472    totals.ambiguous = oracle
473        .iter()
474        .filter(|v| v.open_ambiguities().next().is_some())
475        .count();
476    // The approve sheet is the view's (`report`), never a decision's result.
477    let sheet = None;
478    ReviewReport {
479        feature: u.feature.clone(),
480        file: u.rel(),
481        totals,
482        revision,
483        questions: open,
484        changes,
485        history: if full { history(u) } else { Vec::new() },
486        requirements: items,
487        revise_with: u
488            .format
489            .planning()
490            .revises
491            .filter(|s| installed(s))
492            .map(str::to_owned),
493        oracle,
494        tests_notice,
495        bug_tests,
496        approval: record.approval,
497        sheet,
498        error,
499        readiness: Some(readiness),
500    }
501}
502
503/// The unit's readiness: its requirements, the scenarios over its
504/// stories (or the format's requirements), the open questions, and the
505/// stories or requirements no scenario covers — each counted from the
506/// files through the format adapter.
507fn readiness_of(u: &Unit, open_questions: usize) -> speccoder_core::Readiness {
508    let all = u
509        .snapshot
510        .as_ref()
511        .map(|s| u.format.oracle_requirements(s))
512        .unwrap_or_default();
513    let identifiers = u.format.requirements_file().is_some();
514    let count = |n: usize| u32::try_from(n).unwrap_or(u32::MAX);
515    speccoder_core::Readiness {
516        requirements: if identifiers {
517            count(u.requirements().len())
518        } else {
519            count(all.len())
520        },
521        scenarios: count(all.iter().map(|r| r.scenarios.len()).sum()),
522        open_questions: count(open_questions),
523        untestable: all
524            .iter()
525            .filter(|r| r.scenarios.is_empty())
526            .map(|r| r.key.clone())
527            .collect(),
528        identifiers,
529    }
530}
531
532/// The readiness of `feature` (default: the current one) now; `None` when
533/// there is no such unit.
534#[must_use]
535pub fn readiness(path: &Path, feature: Option<&str>) -> Option<speccoder_core::Readiness> {
536    let u = unit("review", path, feature).ok()?;
537    let open = questions(&u).len();
538    Some(readiness_of(&u, open))
539}
540
541/// The confirmed reproductions of the bugs recorded in bug unit `unit`,
542/// each with the content kept when it was confirmed (digest checked).
543fn confirmed_reproductions(plane: &Path, unit: &str) -> Vec<speccoder_core::oracle::BugTests> {
544    let mut bugs: Vec<speccoder_core::bug::BugReport> = crate::bug::state::all(plane)
545        .into_iter()
546        .filter(|b| b.unit == unit && b.confirmation.is_some())
547        .collect();
548    bugs.sort_by(|a, b| a.task.cmp(&b.task));
549    bugs.into_iter()
550        .filter_map(|b| {
551            let paths: Vec<String> = b.reproductions.iter().map(|r| r.file.clone()).collect();
552            let kept = crate::bug::state::repro(plane, &b.unit, &b.task, &paths);
553            let reproductions = b
554                .reproductions
555                .iter()
556                .map(|r| speccoder_core::oracle::BugTest {
557                    statement: r.statement.clone(),
558                    file: r.file.clone(),
559                    test: r.test.clone(),
560                    red_class: r
561                        .baseline
562                        .last()
563                        .map(|c| crate::pipeline::class_word(*c))
564                        .unwrap_or_default(),
565                    content: kept
566                        .iter()
567                        .find(|(p, _)| *p == r.file)
568                        .filter(|(_, c)| {
569                            format!("sha256:{}", crate::integrity::sha256_hex(c.as_bytes()))
570                                == r.digest
571                        })
572                        .map(|(_, c)| c.clone()),
573                })
574                .collect();
575            Some(speccoder_core::oracle::BugTests {
576                task: b.task.clone(),
577                title: b.title.clone(),
578                confirmation: b.confirmation.clone()?,
579                reproductions,
580            })
581        })
582        .collect()
583}
584
585/// Whether a feature run holds this project now.
586fn run_active(u: &Unit) -> bool {
587    crate::worktree::state_dir()
588        .and_then(|s| crate::progress::read_live(&u.root, &s))
589        .is_some_and(|(live, running)| running && live.finished.is_none())
590}
591
592/// Why autopilot cannot run here: it needs the OS sandbox enforced.
593fn autopilot_blocked(root: &Path) -> Option<String> {
594    let text = crate::verify::find_config(root)
595        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
596        .unwrap_or_default();
597    let mode = crate::sandbox::parse(&text)
598        .map(|c| c.mode)
599        .unwrap_or(crate::sandbox::Mode::Auto);
600    if mode == crate::sandbox::Mode::Off {
601        return Some("autopilot needs the OS sandbox ([sandbox] mode = \"off\")".to_owned());
602    }
603    crate::sandbox::backend()
604        .err()
605        .map(|e| format!("autopilot needs the OS sandbox ({e})"))
606}
607
608/// The sandbox line of the approve sheet.
609fn sandbox_line(root: &Path) -> Option<String> {
610    let text = crate::verify::find_config(root)
611        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
612        .unwrap_or_default();
613    let config = crate::sandbox::parse(&text).ok()?;
614    let backend = crate::sandbox::backend().ok()?;
615    let network = match config.network {
616        crate::sandbox::Network::Off => "network off",
617        crate::sandbox::Network::Loopback => "loopback only",
618        crate::sandbox::Network::On => "network on",
619    };
620    Some(format!("Sandbox: {}, {network}", backend.mechanism()))
621}
622
623/// What *Approve and Run* would approve, what blocks it, and what an
624/// autopilot run may hold.
625fn sheet(
626    u: &Unit,
627    items: &[ReviewItem],
628    questions: &[OpenQuestion],
629    oracle: &[speccoder_core::oracle::OracleView],
630) -> speccoder_core::oracle::ApproveSheet {
631    let mut blocked = Vec::new();
632    if !questions.is_empty() {
633        blocked.push(format!(
634            "{} open question(s): {}",
635            questions.len(),
636            questions
637                .iter()
638                .map(|q| q.near.clone().unwrap_or_else(|| q.id.clone()))
639                .collect::<Vec<_>>()
640                .join(", ")
641        ));
642    }
643    for v in oracle {
644        for a in v.open_ambiguities() {
645            blocked.push(format!(
646                "{}: two readings of {} disagree — choose one, or reword",
647                v.req, a.scenario
648            ));
649        }
650    }
651    if run_active(u) {
652        blocked.push("a run is active".to_owned());
653    }
654    // What the approval would record: the click sends it back, and a
655    // change in between refuses it (REVIEW-CHANGED).
656    let sha256 = match (&u.state, u.record()) {
657        (Some(plane), Ok(record)) => {
658            match crate::oracle::freezable(plane, &u.feature, &u.oracle_requirements()) {
659                Ok(f) => Some(would_approve(&u.requirements(), &record, &f)),
660                Err(t) => {
661                    blocked.push(format!("a test draft cannot be read: {}", t.0));
662                    None
663                }
664            }
665        }
666        (_, Err(e)) => {
667            blocked.push(e);
668            None
669        }
670        (None, Ok(_)) => None,
671    };
672    speccoder_core::oracle::ApproveSheet {
673        sha256,
674        approves: items
675            .iter()
676            .filter(|i| matches!(i.state.as_str(), "open" | "lapsed"))
677            .map(|i| i.id.clone())
678            .collect(),
679        blocked,
680        autopilot_blocked: autopilot_blocked(&u.root),
681        sandbox: sandbox_line(&u.root),
682        grants: u
683            .state
684            .as_ref()
685            .map(|s| crate::approvals::offered_grants(s, &u.feature))
686            .unwrap_or_default(),
687    }
688}
689
690/// The frozen oracle entries and the approval digest `review.json` in
691/// `dir` (project-relative, under `root`) holds.
692///
693/// # Errors
694/// The record exists but cannot be read: never read as "nothing frozen".
695pub fn oracle_entries(
696    root: &Path,
697    dir: &str,
698) -> Result<(BTreeMap<String, crate::oracle::OracleEntry>, Option<String>), String> {
699    let record = read_record(&root.join(dir).join(REVIEW_FILE))?;
700    Ok((record.oracle, record.approval.map(|a| a.sha256)))
701}
702
703/// `review.json` at `file`: absent is the empty record; present and not
704/// valid is an error naming it.
705fn read_record(file: &Path) -> Result<Record, String> {
706    match std::fs::read_to_string(file) {
707        Ok(text) => serde_json::from_str(&text)
708            .map_err(|e| format!("{} is not a valid review record: {e}", file.display())),
709        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Record::default()),
710        Err(e) => Err(format!("{}: {e}", file.display())),
711    }
712}
713
714/// The refusal for a review record that cannot be read.
715fn unreadable(e: &str) -> CommandResult {
716    CommandResult::new("review", CommandStatus::NeedsHuman, e).with_code("REVIEW-UNREADABLE")
717}
718
719fn message(r: &ReviewReport) -> String {
720    let t = r.totals;
721    let n = r.requirements.len();
722    if n == 0 {
723        return format!("{}'s spec names no requirement IDs to review", r.feature);
724    }
725    if t.complete() {
726        return format!("{n}/{n} requirements approved");
727    }
728    let plural =
729        |n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
730    let parts: Vec<String> = [
731        (
732            t.questions,
733            plural(t.questions, "open question", "open questions"),
734        ),
735        (t.open, format!("{} to review", t.open)),
736        (
737            t.changes_requested,
738            format!("{} with changes requested", t.changes_requested),
739        ),
740        (
741            t.lapsed,
742            format!("{} changed since they were decided", t.lapsed),
743        ),
744    ]
745    .into_iter()
746    .filter(|(k, _)| *k > 0)
747    .map(|(_, w)| w)
748    .collect();
749    format!(
750        "{}/{n} requirements approved; {}",
751        t.approved,
752        parts.join(", ")
753    )
754}
755
756/// The review without its git reads (changes, history): what the inbox
757/// needs, per feature, on every refresh.
758#[must_use]
759pub fn summary(path: &Path, feature: Option<&str>) -> Option<ReviewReport> {
760    unit("review", path, feature)
761        .ok()
762        .map(|u| report_of(&u, false))
763}
764
765/// `speccoder review`: every requirement and its review.
766#[must_use]
767pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
768    report_in(path, feature, None)
769}
770
771/// [`report`] with the state directory given (`None`: the environment's).
772#[must_use]
773pub fn report_in(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
774    let u = match unit_in("review", path, feature, state) {
775        Ok(u) => u,
776        Err(r) => return *r,
777    };
778    let mut r = report_of(&u, true);
779    r.sheet = Some(sheet(&u, &r.requirements, &r.questions, &r.oracle));
780    CommandResult::new("review", CommandStatus::Ok, &message(&r))
781        .with_data(CommandData::Review(Box::new(r)))
782}
783
784/// `speccoder review approve|request|clear [ID…]`: records the person's
785/// decision on the requirements named (none: every one not approved on its
786/// current wording) and writes `review.json`. `request` needs a comment.
787#[must_use]
788pub fn decide(
789    path: &Path,
790    feature: Option<&str>,
791    ids: &[String],
792    verdict: &str,
793    comment: Option<&str>,
794    via: speccoder_core::actions::Via,
795) -> CommandResult {
796    decide_in(path, feature, ids, verdict, comment, via, None)
797}
798
799/// [`decide`] with the state directory given (`None`: the environment's).
800#[must_use]
801pub fn decide_in(
802    path: &Path,
803    feature: Option<&str>,
804    ids: &[String],
805    verdict: &str,
806    comment: Option<&str>,
807    via: speccoder_core::actions::Via,
808    state: Option<&Path>,
809) -> CommandResult {
810    let u = match unit_in("review", path, feature, state) {
811        Ok(u) => u,
812        Err(r) => return *r,
813    };
814    let comment = comment.map(str::trim).filter(|c| !c.is_empty());
815    if verdict == "request" && comment.is_none() {
816        return CommandResult::new(
817            "review",
818            CommandStatus::UsageError,
819            "a change request needs a comment: what should the requirement say?",
820        )
821        .with_code("USAGE");
822    }
823    let requirements = u.requirements();
824    let mut record = match u.record() {
825        Ok(r) => r,
826        Err(e) => return unreadable(&e),
827    };
828    let before = items(&requirements, &record);
829    // A wording that still asks a question is not one to sign: approving
830    // it is refused; approving "the rest" passes it by and says so.
831    let asks = |line: &str| line.contains(MARKER);
832    let undecided: Vec<&str> = requirements
833        .iter()
834        .filter(|(_, l)| asks(l))
835        .map(|(id, _)| id.as_str())
836        .collect();
837    if verdict == "approve" && !ids.is_empty() {
838        let blocked: Vec<&str> = ids
839            .iter()
840            .map(String::as_str)
841            .filter(|id| undecided.contains(id))
842            .collect();
843        if !blocked.is_empty() {
844            return CommandResult::new(
845                "review",
846                CommandStatus::UsageError,
847                &format!(
848                    "{} still {} an open question: answer it first (`speccoder review answer …`), or reword it (`speccoder review edit {} \"…\" --approve`)",
849                    blocked.join(", "),
850                    if blocked.len() == 1 { "asks" } else { "ask" },
851                    blocked[0]
852                ),
853            )
854            .with_code("USAGE");
855        }
856    }
857    let chosen: Vec<&(String, String)> = if ids.is_empty() {
858        requirements
859            .iter()
860            .filter(|(id, l)| {
861                before.iter().any(|i| i.id == *id && i.state != "approved")
862                    && !(verdict == "approve" && asks(l))
863            })
864            .collect()
865    } else {
866        let unknown: Vec<&String> = ids
867            .iter()
868            .filter(|id| !requirements.iter().any(|(r, _)| r == *id))
869            .collect();
870        if !unknown.is_empty() {
871            return CommandResult::new(
872                "review",
873                CommandStatus::NotFound,
874                &format!(
875                    "the spec has no {}; it names {}",
876                    unknown
877                        .iter()
878                        .map(|s| s.as_str())
879                        .collect::<Vec<_>>()
880                        .join(", "),
881                    requirements
882                        .iter()
883                        .map(|(r, _)| r.as_str())
884                        .collect::<Vec<_>>()
885                        .join(", ")
886                ),
887            )
888            .with_code("NOT-FOUND")
889            .with_fix(vec!["speccoder review".to_owned()]);
890        }
891        requirements
892            .iter()
893            .filter(|(id, _)| ids.contains(id))
894            .collect()
895    };
896    let who = crate::approvals::Who::local_in(&u.root, via);
897    let now = std::time::SystemTime::now()
898        .duration_since(std::time::UNIX_EPOCH)
899        .map_or(0, |d| d.as_secs());
900    for (id, line) in &chosen {
901        match verdict {
902            "clear" => {
903                record.requirements.remove(id);
904            }
905            "approve" | "request" => {
906                record.requirements.insert(
907                    id.clone(),
908                    Decision {
909                        verdict: if verdict == "approve" {
910                            "approved"
911                        } else {
912                            "changes_requested"
913                        }
914                        .to_owned(),
915                        line: line.trim_end().to_owned(),
916                        sha256: digest(line),
917                        by: who.name.clone(),
918                        via: who.via.clone(),
919                        at: now,
920                        comment: comment.map(str::to_owned),
921                    },
922                );
923            }
924            other => {
925                return CommandResult::new(
926                    "review",
927                    CommandStatus::UsageError,
928                    &format!("`{other}` is not a review verdict: approve, request or clear"),
929                )
930                .with_code("USAGE");
931            }
932        }
933    }
934    // Decisions on requirements the spec no longer names go with them.
935    record
936        .requirements
937        .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
938    let named = u.oracle_requirements();
939    if u.snapshot.is_some() {
940        record
941            .oracle
942            .retain(|key, _| named.iter().any(|r| r.key == *key));
943    }
944    let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
945    if let Err(e) = std::fs::write(u.file(), text) {
946        return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
947    }
948    let r = report_of(&u, true);
949    let word = match verdict {
950        "approve" => "approved",
951        "request" => "changes requested on",
952        _ => "cleared",
953    };
954    let skipped = if verdict == "approve" && ids.is_empty() && !undecided.is_empty() {
955        format!(
956            "; not {} — {} still {} an open question",
957            undecided.join(", "),
958            if undecided.len() == 1 { "it" } else { "they" },
959            if undecided.len() == 1 { "asks" } else { "ask" }
960        )
961    } else {
962        String::new()
963    };
964    let ambiguous: Vec<String> = r
965        .oracle
966        .iter()
967        .filter(|v| v.open_ambiguities().next().is_some())
968        .map(|v| v.req.clone())
969        .collect();
970    let skipped = if verdict == "approve" && !ambiguous.is_empty() {
971        format!(
972            "{skipped}; {} {} ambiguous — Approve and Run stays blocked",
973            ambiguous.join(", "),
974            if ambiguous.len() == 1 { "is" } else { "are" }
975        )
976    } else {
977        skipped
978    };
979    let message = if chosen.is_empty() {
980        format!("nothing to decide{skipped}: {}", message(&r))
981    } else {
982        format!(
983            "{word} {}{skipped}; {} — commit {} to share it",
984            chosen
985                .iter()
986                .map(|(id, _)| id.as_str())
987                .collect::<Vec<_>>()
988                .join(", "),
989            message(&r),
990            r.file
991        )
992    };
993    // Every requirement approved: what waited for it goes on.
994    let artifacts = crate::format::open(&u.root, Some(&u.feature))
995        .ok()
996        .and_then(|o| {
997            o.snapshot
998                .feature
999                .map(|f| (f.artifacts.plan, f.artifacts.tasks))
1000        });
1001    let next = match artifacts {
1002        Some((_, true)) if verdict == "approve" && r.totals.complete() => {
1003            vec![speccoder_core::next::NextStep {
1004                command: format!("speccoder run --feature {}", u.feature),
1005                why: "work through the tasks".to_owned(),
1006            }]
1007        }
1008        Some((plan, false)) if verdict == "approve" && r.totals.complete() => {
1009            let skills = (u.format.planning().toward_tasks)(plan);
1010            vec![speccoder_core::next::NextStep {
1011                command: format!(
1012                    "speccoder spec {} --through {} --feature {}",
1013                    skills.first().copied().unwrap_or("plan"),
1014                    skills.last().copied().unwrap_or("tasks"),
1015                    u.feature
1016                ),
1017                why: "planning waited for this approval".to_owned(),
1018            }]
1019        }
1020        _ => Vec::new(),
1021    };
1022    CommandResult::new("review", CommandStatus::Ok, &message)
1023        .with_data(CommandData::Review(Box::new(r)))
1024        .with_next(next)
1025}
1026
1027/// The approval digest *Approve and Run* records when it runs now: every
1028/// decision line approved after it (each open or lapsed one approved on
1029/// its wording), and the requirements it freezes — what the sheet shows,
1030/// so the click binds to it.
1031fn would_approve(
1032    requirements: &[(String, String)],
1033    record: &Record,
1034    freezable: &[crate::oracle::Freezable],
1035) -> String {
1036    let before = items(requirements, record);
1037    let approved: Vec<(String, String)> = requirements
1038        .iter()
1039        .filter_map(|(id, line)| {
1040            let state = before.iter().find(|i| i.id == *id)?.state.as_str();
1041            match state {
1042                "open" | "lapsed" => Some((id.clone(), digest(line))),
1043                _ => record
1044                    .requirements
1045                    .get(id)
1046                    .filter(|d| d.verdict == "approved")
1047                    .map(|d| (id.clone(), d.sha256.clone())),
1048            }
1049        })
1050        .collect();
1051    approval_digest(
1052        approved.iter().map(|(a, b)| (a.as_str(), b.as_str())),
1053        freezable
1054            .iter()
1055            .map(|f| (f.req.key.as_str(), f.manifest.as_str())),
1056    )
1057}
1058
1059/// The approval digest (research R6): SHA-256 over the approved decision
1060/// lines (`ID=line digest`, sorted) and the frozen oracle entries
1061/// (`REQ=manifest digest`, sorted).
1062#[must_use]
1063pub fn approval_digest<'a>(
1064    decisions: impl IntoIterator<Item = (&'a str, &'a str)>,
1065    oracle: impl IntoIterator<Item = (&'a str, &'a str)>,
1066) -> String {
1067    let mut lines: Vec<String> = decisions
1068        .into_iter()
1069        .map(|(id, sha)| format!("{id}={sha}"))
1070        .collect();
1071    lines.sort();
1072    let mut frozen: Vec<String> = oracle
1073        .into_iter()
1074        .map(|(req, sha)| format!("{req}={sha}"))
1075        .collect();
1076    frozen.sort();
1077    format!(
1078        "sha256:{}",
1079        crate::integrity::sha256_hex(
1080            format!("{}\n{}", lines.join("\n"), frozen.join("\n")).as_bytes()
1081        )
1082    )
1083}
1084
1085/// `speccoder review approve --run ask|autopilot [--grant …]` — *Approve
1086/// and Run*: approves every decision line that is open or lapsed, freezes
1087/// each requirement's red tests (one pre-frozen set each, `review.json`'s
1088/// `oracle` and `approval`), commits `review.json` and starts the
1089/// feature's run. Refused while a question is open, a requirement is read
1090/// two ways, or a run is active.
1091#[must_use]
1092#[allow(clippy::too_many_arguments)]
1093pub fn approve_and_run(
1094    path: &Path,
1095    feature: Option<&str>,
1096    mode: speccoder_core::oracle::RunMode,
1097    grants: &[String],
1098    sheet: Option<&str>,
1099    via: speccoder_core::actions::Via,
1100    state: Option<&Path>,
1101    provider: Option<std::sync::Arc<dyn agentplane::model::ModelProvider>>,
1102    options: &crate::RunOptions,
1103) -> CommandResult {
1104    use speccoder_core::oracle::RunMode;
1105    let u = match unit_in("review", path, feature, state) {
1106        Ok(u) => u,
1107        Err(r) => return *r,
1108    };
1109    let Some(plane) = u.state.clone() else {
1110        return CommandResult::new(
1111            "review",
1112            CommandStatus::ConfigError,
1113            "no state directory (set SPECCODER_STATE_DIR or HOME)",
1114        )
1115        .with_code("NO-STATE-DIR");
1116    };
1117    let refuse =
1118        |status, code: &str, m: String| CommandResult::new("review", status, &m).with_code(code);
1119    if run_active(&u) {
1120        return refuse(
1121            CommandStatus::GitPrecondition,
1122            "RUN-ACTIVE",
1123            format!(
1124                "a run is active for {}: approve and run when it ends",
1125                u.feature
1126            ),
1127        );
1128    }
1129    // Nothing to run: refused before anything is approved or frozen — a
1130    // unit without a task is never done.
1131    if let Some(stop) = u
1132        .snapshot
1133        .as_ref()
1134        .and_then(|s| s.feature.as_ref())
1135        .and_then(|f| crate::pipeline::feature::no_tasks(u.format, f))
1136    {
1137        let mut fix = vec![format!(
1138            "speccoder review approve --feature {}  (approve the spec without a run)",
1139            u.feature
1140        )];
1141        fix.extend(stop.fix);
1142        return refuse(
1143            CommandStatus::TaskNotReady,
1144            "NO-TASKS",
1145            format!("{}; nothing was approved", stop.message),
1146        )
1147        .with_fix(fix)
1148        .with_next(stop.next);
1149    }
1150    // Nothing writes the unit's tests while they are frozen.
1151    let lock = match crate::oracle::UnitLock::take(&plane, &u.feature) {
1152        Ok(l) => l,
1153        Err(e) => return refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", e),
1154    };
1155    let open = questions(&u);
1156    if !open.is_empty() {
1157        return refuse(
1158            CommandStatus::NeedsHuman,
1159            "REVIEW-PENDING",
1160            format!(
1161                "{} open question(s) first: {} — answer (`speccoder review answer Q1 \"…\"`) or reword",
1162                open.len(),
1163                open.iter()
1164                    .map(|(q, _)| q.id.clone())
1165                    .collect::<Vec<_>>()
1166                    .join(", ")
1167            ),
1168        );
1169    }
1170    let named = u.oracle_requirements();
1171    let ambiguous = match crate::oracle::open_ambiguities(&plane, &u.feature, &named) {
1172        Ok(a) => a,
1173        Err(t) => return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", t.0),
1174    };
1175    if !ambiguous.is_empty() {
1176        return refuse(
1177            CommandStatus::NeedsHuman,
1178            "REVIEW-AMBIGUOUS",
1179            format!(
1180                "Approve and Run waits: {} read two ways — choose a reading (`speccoder review tests {} --reading A|B {}`) or reword",
1181                ambiguous
1182                    .iter()
1183                    .map(|(r, s)| format!("{r}/{s}"))
1184                    .collect::<Vec<_>>()
1185                    .join(", "),
1186                ambiguous[0].0,
1187                ambiguous[0].1
1188            ),
1189        );
1190    }
1191    // Autopilot: an enforced sandbox, and only grants offered for this
1192    // feature, by their grant id.
1193    let mut objects = Vec::new();
1194    if mode == RunMode::Autopilot {
1195        if let Some(why) = autopilot_blocked(&u.root) {
1196            return refuse(CommandStatus::ConfigError, "SANDBOX-UNAVAILABLE", why).with_fix(vec![
1197                "speccoder review approve --run ask".to_owned(),
1198                "→ or enable the OS sandbox ([sandbox] mode = \"auto\" on macOS or Linux 6.2+)"
1199                    .to_owned(),
1200            ]);
1201        }
1202        let offered = crate::approvals::offered_objects(&plane, &u.feature);
1203        for id in grants {
1204            match offered.iter().find(|(g, _)| g.id == *id) {
1205                Some((_, o)) => objects.push(o.clone()),
1206                None => {
1207                    return refuse(
1208                        CommandStatus::UsageError,
1209                        "USAGE",
1210                        format!(
1211                            "{id} is not a grant offered for {}; offered: {}",
1212                            u.feature,
1213                            if offered.is_empty() {
1214                                "none".to_owned()
1215                            } else {
1216                                offered
1217                                    .iter()
1218                                    .map(|(g, _)| format!("{} ({})", g.id, g.object))
1219                                    .collect::<Vec<_>>()
1220                                    .join(", ")
1221                            }
1222                        ),
1223                    );
1224                }
1225            }
1226        }
1227    } else if !grants.is_empty() {
1228        return refuse(
1229            CommandStatus::UsageError,
1230            "USAGE",
1231            "--grant is for an autopilot run (--run autopilot)".to_owned(),
1232        );
1233    }
1234    let freezable = match crate::oracle::freezable(&plane, &u.feature, &named) {
1235        Ok(f) => f,
1236        Err(t) => {
1237            return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", t.0);
1238        }
1239    };
1240    // The decision lines: every one open or lapsed is approved now.
1241    let requirements = u.requirements();
1242    let mut record = match u.record() {
1243        Ok(r) => r,
1244        Err(e) => return unreadable(&e),
1245    };
1246    let before = items(&requirements, &record);
1247    let who = crate::approvals::Who::local_in(&u.root, via);
1248    let at = std::time::SystemTime::now()
1249        .duration_since(std::time::UNIX_EPOCH)
1250        .map_or(0, |d| d.as_secs());
1251    let mut approved_now = Vec::new();
1252    for (id, line) in &requirements {
1253        if before
1254            .iter()
1255            .any(|i| i.id == *id && matches!(i.state.as_str(), "open" | "lapsed"))
1256        {
1257            record.requirements.insert(
1258                id.clone(),
1259                Decision {
1260                    verdict: "approved".to_owned(),
1261                    line: line.trim_end().to_owned(),
1262                    sha256: digest(line),
1263                    by: who.name.clone(),
1264                    via: who.via.clone(),
1265                    at,
1266                    comment: None,
1267                },
1268            );
1269            approved_now.push(id.clone());
1270        }
1271    }
1272    record
1273        .requirements
1274        .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
1275    let approved: Vec<(String, String)> = record
1276        .requirements
1277        .iter()
1278        .filter(|(_, d)| d.verdict == "approved")
1279        .map(|(id, d)| (id.clone(), d.sha256.clone()))
1280        .collect();
1281    let sha = approval_digest(
1282        approved.iter().map(|(a, b)| (a.as_str(), b.as_str())),
1283        freezable
1284            .iter()
1285            .map(|f| (f.req.key.as_str(), f.manifest.as_str())),
1286    );
1287    // A click binds to what its sheet showed; a terminal command acts on
1288    // what it reads now.
1289    if sheet.is_none() && via != speccoder_core::actions::Via::Terminal {
1290        return refuse(
1291            CommandStatus::UsageError,
1292            "USAGE",
1293            "Approve and Run names what its sheet showed (the sheet's sha256): open the sheet again".to_owned(),
1294        );
1295    }
1296    if let Some(shown) = sheet
1297        && shown != sha
1298    {
1299        return refuse(
1300            CommandStatus::NeedsHuman,
1301            "REVIEW-CHANGED",
1302            "what Approve and Run would approve and freeze changed since the sheet showed it (another tab, a finished Write Tests): nothing was approved — look at the sheet again".to_owned(),
1303        );
1304    }
1305    record.oracle.clear();
1306    let mut frozen = Vec::new();
1307    for f in &freezable {
1308        let decided = speccoder_core::acceptance::Decided {
1309            by: who.name.clone(),
1310            via,
1311            at,
1312        };
1313        match crate::oracle::freeze(
1314            &plane,
1315            &u.feature,
1316            f,
1317            &sha,
1318            decided,
1319            speccoder_core::acceptance::Producer::Review,
1320        ) {
1321            Ok(rec) => {
1322                record.oracle.insert(
1323                    f.req.key.clone(),
1324                    crate::oracle::OracleEntry {
1325                        requirement_sha256: f.draft.requirement.sha256.clone(),
1326                        manifest_sha256: f.manifest.clone(),
1327                        held_out_sha256: rec
1328                            .held_out_sealed
1329                            .as_ref()
1330                            .map(|h| format!("sha256:{}", h.manifest_sha256)),
1331                        by: who.name.clone(),
1332                        via,
1333                        at,
1334                    },
1335                );
1336                frozen.push((f, rec));
1337            }
1338            Err(e) => return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", e),
1339        }
1340    }
1341    record.approval = Some(speccoder_core::oracle::ReviewApproval {
1342        sha256: sha.clone(),
1343        mode,
1344        by: who.name.clone(),
1345        via,
1346        at,
1347    });
1348    let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
1349    if let Err(e) = std::fs::write(u.file(), text) {
1350        return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
1351    }
1352    // The run reads the committed spec: the approval is committed with it.
1353    let rel = {
1354        let (top_prefix, rel) = (
1355            crate::git::Git::at(&u.root)
1356                .line(&["rev-parse", "--show-prefix"])
1357                .unwrap_or_default(),
1358            u.rel(),
1359        );
1360        format!("{top_prefix}{rel}")
1361    };
1362    let committed = crate::hosting::commit_exactly(
1363        &u.root,
1364        &[rel],
1365        &format!("review: approve {} and freeze its tests", u.feature),
1366    );
1367    if committed.status() != CommandStatus::Ok {
1368        return CommandResult::carried("review", &committed);
1369    }
1370    for (f, rec) in &frozen {
1371        if let Some(p) = &options.progress {
1372            p.event(&crate::progress::Event::Oracle {
1373                step: crate::progress::OracleEvent::Frozen {
1374                    unit: u.feature.clone(),
1375                    requirement: f.req.key.clone(),
1376                    manifest_sha256: f.manifest.clone(),
1377                    approval_sha256: sha.clone(),
1378                    visible_count: rec.visible.len(),
1379                    held_out_count: rec.held_out_sealed.as_ref().map_or(0, |h| h.count),
1380                    by: who.name.clone(),
1381                    via: via.as_str().to_owned(),
1382                },
1383            });
1384        }
1385    }
1386    let said = format!(
1387        "approved {} decision line(s), froze tests for {} requirement(s){}; committed {}",
1388        approved_now.len(),
1389        frozen.len(),
1390        if mode == RunMode::Autopilot {
1391            format!(", autopilot with {} grant(s)", objects.len())
1392        } else {
1393            String::new()
1394        },
1395        u.rel()
1396    );
1397    drop(lock);
1398    let mut run_options = options.clone();
1399    run_options.mode = mode;
1400    if mode == RunMode::Autopilot {
1401        // Nobody is asked: the ticked grants are the run's only grants.
1402        run_options.approver = None;
1403        run_options.run_grants = std::sync::Arc::new(std::sync::Mutex::new(objects));
1404    }
1405    let ran = match (state, provider) {
1406        (Some(state), Some(provider)) => crate::pipeline::run_feature_with(
1407            path,
1408            Some(&u.feature),
1409            state,
1410            &provider,
1411            None,
1412            &run_options,
1413        ),
1414        _ => crate::run_feature(path, Some(&u.feature), &run_options, false),
1415    };
1416    let status = ran.status();
1417    let message = format!("{said} — {}", ran.message());
1418    ran.reworded(status, &message)
1419}
1420
1421/// `speccoder review answer Q1 "…"`: writes the person's answer where the
1422/// question was — the marker replaced by the answer, nothing else touched.
1423#[must_use]
1424pub fn answer(path: &Path, feature: Option<&str>, id: &str, text: &str) -> CommandResult {
1425    let u = match unit("review", path, feature) {
1426        Ok(u) => u,
1427        Err(r) => return *r,
1428    };
1429    let text = text.trim();
1430    if text.is_empty() {
1431        return CommandResult::new("review", CommandStatus::UsageError, "an answer needs words")
1432            .with_code("USAGE");
1433    }
1434    let open = questions(&u);
1435    let Some((q, raw)) = open.iter().find(|(q, _)| q.id.eq_ignore_ascii_case(id)) else {
1436        return CommandResult::new(
1437            "review",
1438            CommandStatus::NotFound,
1439            &format!(
1440                "no open question {id}; open: {}",
1441                if open.is_empty() {
1442                    "none".to_owned()
1443                } else {
1444                    open.iter()
1445                        .map(|(q, _)| q.id.as_str())
1446                        .collect::<Vec<_>>()
1447                        .join(", ")
1448                }
1449            ),
1450        )
1451        .with_code("NOT-FOUND");
1452    };
1453    let file = u.root.join(&q.file);
1454    let Ok(body) = std::fs::read_to_string(&file) else {
1455        return CommandResult::new(
1456            "review",
1457            CommandStatus::Error,
1458            &format!("{} cannot be read", q.file),
1459        );
1460    };
1461    let mut lines: Vec<String> = body.lines().map(str::to_owned).collect();
1462    let Some(line) = lines
1463        .get_mut(q.line - 1)
1464        .filter(|l| l.contains(raw.as_str()))
1465    else {
1466        return CommandResult::new(
1467            "review",
1468            CommandStatus::Error,
1469            &format!("{} changed under the question; look again", q.file),
1470        );
1471    };
1472    *line = line.replacen(raw.as_str(), text, 1);
1473    let mut out = lines.join("\n");
1474    if body.ends_with('\n') {
1475        out.push('\n');
1476    }
1477    if let Err(e) = std::fs::write(&file, out) {
1478        return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", q.file));
1479    }
1480    let r = report_of(&u, true);
1481    CommandResult::ok(
1482        "review",
1483        &format!("answered {} in {} — {}", q.id, q.file, message(&r)),
1484    )
1485    .with_data(CommandData::Review(Box::new(r)))
1486}
1487
1488/// `speccoder review edit FR-003 "…" [--approve]`: the person rewords a
1489/// requirement — its line keeps its identifier, its decision lapses (or is
1490/// approved on the new words with `approve`).
1491#[must_use]
1492pub fn edit(
1493    path: &Path,
1494    feature: Option<&str>,
1495    id: &str,
1496    wording: &str,
1497    approve: bool,
1498    via: speccoder_core::actions::Via,
1499) -> CommandResult {
1500    let u = match unit("review", path, feature) {
1501        Ok(u) => u,
1502        Err(r) => return *r,
1503    };
1504    let wording = wording.trim();
1505    if wording.is_empty() || wording.contains('\n') {
1506        return CommandResult::new(
1507            "review",
1508            CommandStatus::UsageError,
1509            "a requirement's wording is one line of words",
1510        )
1511        .with_code("USAGE");
1512    }
1513    let Some(spec) = u.format.requirements_file() else {
1514        return CommandResult::new(
1515            "review",
1516            CommandStatus::UsageError,
1517            "this format has no requirement identifiers",
1518        )
1519        .with_code("USAGE");
1520    };
1521    let Some((_, old)) = u.requirements().into_iter().find(|(r, _)| r == id) else {
1522        return CommandResult::new(
1523            "review",
1524            CommandStatus::NotFound,
1525            &format!("the spec has no {id}"),
1526        )
1527        .with_code("NOT-FOUND");
1528    };
1529    // `- **FR-003**: …` keeps everything up to the identifier and its colon.
1530    let key = format!("**{id}**");
1531    let Some(at) = old.find(&key) else {
1532        return CommandResult::new(
1533            "review",
1534            CommandStatus::Error,
1535            &format!("{id}'s line has no `{key}`"),
1536        );
1537    };
1538    let new = format!("{}{key}: {wording}", &old[..at]);
1539    let file = u.root.join(&u.dir).join(spec);
1540    let body = std::fs::read_to_string(&file).unwrap_or_default();
1541    if !body.lines().any(|l| l.trim_end() == old.trim_end()) {
1542        return CommandResult::new(
1543            "review",
1544            CommandStatus::Error,
1545            &format!("{spec} changed under {id}; look again"),
1546        );
1547    }
1548    let mut done = false;
1549    let mut out: Vec<String> = Vec::new();
1550    for l in body.lines() {
1551        if !done && l.trim_end() == old.trim_end() {
1552            out.push(new.clone());
1553            done = true;
1554        } else {
1555            out.push(l.to_owned());
1556        }
1557    }
1558    let mut text = out.join("\n");
1559    if body.ends_with('\n') {
1560        text.push('\n');
1561    }
1562    if let Err(e) = std::fs::write(&file, text) {
1563        return CommandResult::new("review", CommandStatus::Error, &format!("{spec}: {e}"));
1564    }
1565    if approve {
1566        let r = decide(path, feature, &[id.to_owned()], "approve", None, via);
1567        let status = r.status();
1568        let message = if status == CommandStatus::Ok {
1569            format!("reworded {id} and approved it; {}", r.message())
1570        } else {
1571            format!("reworded {id}; not approved: {}", r.message())
1572        };
1573        return r.reworded(status, &message);
1574    }
1575    let r = report_of(&u, true);
1576    CommandResult::ok("review", &format!("reworded {id}; {}", message(&r)))
1577        .with_data(CommandData::Review(Box::new(r)))
1578}
1579
1580/// The arguments for the revising skill: each change request with its
1581/// requirement, so the spec is revised to say what the person asked.
1582#[must_use]
1583fn revision_request(items: &[ReviewItem]) -> String {
1584    let lines: Vec<String> = items
1585        .iter()
1586        .filter(|i| i.state == "changes_requested")
1587        .map(|i| {
1588            format!(
1589                "- {} (\"{}\"): {}",
1590                i.id,
1591                i.text,
1592                i.comment.as_deref().unwrap_or_default()
1593            )
1594        })
1595        .collect();
1596    format!(
1597        "Revise the spec per the reviewer's change requests; keep every requirement ID, reword only what is asked:\n{}",
1598        lines.join("\n")
1599    )
1600}
1601
1602/// The review records under `root`'s units, as they are before a hosted
1603/// skill runs: it must not change them, nor leave one in a unit it creates.
1604#[derive(Debug)]
1605pub struct Snapshot {
1606    root: PathBuf,
1607    files: Vec<(PathBuf, Option<Vec<u8>>)>,
1608}
1609
1610/// Every unit's review record under `root` (absent ones as `None`).
1611fn records(root: &Path) -> Vec<(PathBuf, Option<Vec<u8>>)> {
1612    let format = crate::format::at(root);
1613    let Some(snap) = format.inspect(root, None) else {
1614        return Vec::new();
1615    };
1616    snap.features
1617        .iter()
1618        .map(|f| {
1619            let file = Path::new(&snap.root).join(&f.dir).join(REVIEW_FILE);
1620            let bytes = std::fs::read(&file).ok();
1621            (file, bytes)
1622        })
1623        .collect()
1624}
1625
1626/// The records as they are now.
1627#[must_use]
1628pub fn snapshot(root: &Path) -> Snapshot {
1629    Snapshot {
1630        root: root.to_path_buf(),
1631        files: records(root),
1632    }
1633}
1634
1635/// Puts back every record that changed since `before`, and removes one in
1636/// a unit that appeared since; the paths restored.
1637pub fn restore(before: &Snapshot) -> Vec<String> {
1638    let mut restored = Vec::new();
1639    let now = records(&before.root);
1640    for (file, bytes) in &now {
1641        let was = before
1642            .files
1643            .iter()
1644            .find(|(f, _)| f == file)
1645            .and_then(|(_, b)| b.clone());
1646        if *bytes == was {
1647            continue;
1648        }
1649        let ok = match &was {
1650            Some(b) => std::fs::write(file, b).is_ok(),
1651            None => std::fs::remove_file(file).is_ok(),
1652        };
1653        if ok {
1654            restored.push(file.display().to_string());
1655        }
1656    }
1657    restored
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662    use super::*;
1663
1664    #[test]
1665    fn the_wording_is_the_line_without_its_markup() {
1666        assert_eq!(
1667            wording("- **FR-001**: Users MUST log in."),
1668            "Users MUST log in."
1669        );
1670        assert_eq!(digest("a  "), digest("a"));
1671    }
1672}