Skip to main content

amont_agent_process/
review_outcomes.rs

1//! `amont-agent plan-review record`: what each plan-review reviewer did,
2//! kept so the panel can later be judged on its own record
3//! (docs/plans/2026-10-11-plan-review-learns-model.md).
4//!
5//! The ledger is `<journal dir>/plan-review/outcomes.jsonl`, one JSON object
6//! per completed reviewer, deduped by agent id. The plan body each round
7//! reviewed is kept once under `plan-review/bodies/<sha>.md`, because the
8//! plan file keeps changing and the ledger holds only the sha.
9
10use std::collections::BTreeSet;
11use std::ffi::OsString;
12use std::io::{BufReader, Read, Write};
13use std::path::{Path, PathBuf};
14use std::process::ExitCode;
15
16use crate::plan_review::{self, Completed, AGENT_PREFIX};
17
18pub const USAGE: &str = "\
19usage: amont-agent plan-review record <plan.md> [--transcript <path>]
20       amont-agent plan-review stats [--json] [--model <id>]
21
22Append one row per completed plan-review reviewer of the plan to
23~/.claude/amont-agent/plan-review/outcomes.jsonl, skipping agents already
24recorded (by agent id), and save each reviewed plan body once under
25plan-review/bodies/.
26
27The reviewers come from the session transcript: --transcript, or else, of
28the transcripts under ~/.claude/projects modified in the last 7 days that
29reviewed this plan, the one that reviewed its current body, then the newest.
30
31stdin: a JSON array of findings (empty stdin is `[]`):
32  {\"role\": str, \"severity\": \"blocking\"|\"high\"|\"medium\"|\"low\",
33   \"disposition\": \"accepted\"|\"rejected\"|\"decision\",
34   \"n\": number (optional), \"section\": str (optional), \"area\": str (optional)}
35They are attached to the rows written by this call for the highest round.
36
37stats: one row per (role, model) of the plan-review and implementation-review
38ledgers: reviews, findings by severity, accepted findings by severity (plan
39review `accepted`, implementation review `fixed`), unresolved findings and
40accepted-plus-unresolved high-or-blocking findings per review. A parsed
41finding is `accepted` when a merge row of its body applied `role#n`, else
42`unresolved`; a later disposition row wins. A row with 20 or more reviews and
43under 0.05 per review is marked `retire-candidate` (ADR-0042). Stats also list
44reviews that predate finding parsing, seats whose verdict has no parsed
45finding, and plans whose hook record was skipped. --json prints one array;
46--model keeps one model.
47
48Exit 0 when the ledger is up to date, 1 when a reviewer has no agent id, no
49transcript names the plan or the ledger cannot be written, 2 on a usage error
50(including malformed findings).
51";
52
53const SCHEMA: &str = "amont-agent-review-outcome-v1";
54const MERGE_SCHEMA: &str = "amont-agent-review-merge-v1";
55const DISPOSITION_SCHEMA: &str = "amont-agent-review-disposition-v1";
56pub(crate) const SEVERITIES: [&str; 4] = ["blocking", "high", "medium", "low"];
57const DISPOSITIONS: [&str; 3] = ["accepted", "rejected", "decision"];
58const LOCK_FILE: &str = "outcomes.jsonl.lock";
59const WEEK: u64 = 7 * 86_400;
60const BODY_TTL: u64 = 90 * 86_400;
61
62/// Runs the verb and returns what it says on stdout, for `main` to print:
63/// stdout is written only from there (`only_the_emitter_writes_to_stdout`).
64pub fn run(args: &[OsString]) -> (String, ExitCode) {
65    let args: Vec<String> = args
66        .iter()
67        .map(|a| a.to_string_lossy().into_owned())
68        .collect();
69    if args.iter().any(|a| a == "--help" || a == "-h") {
70        return (USAGE.to_string(), ExitCode::SUCCESS);
71    }
72    let usage = |why: &str| {
73        eprint!("amont-agent: {why}\n\n{USAGE}");
74        (String::new(), ExitCode::from(2))
75    };
76    let mut it = args.iter();
77    match it.next().map(String::as_str) {
78        Some("record") => {}
79        Some("stats") => return run_stats(&args[1..]),
80        _ => return usage("plan-review takes one verb: record or stats"),
81    }
82    let mut plan: Option<&String> = None;
83    // The last `--transcript` wins, in either form.
84    let mut transcript: Option<String> = None;
85    let mut operands_only = false;
86    while let Some(a) = it.next() {
87        if !operands_only && a == "--" {
88            operands_only = true;
89        } else if !operands_only && a == "--transcript" {
90            let Some(v) = it.next() else {
91                return usage("--transcript needs a path");
92            };
93            transcript = Some(v.clone());
94        } else if let Some(v) = a.strip_prefix("--transcript=").filter(|_| !operands_only) {
95            transcript = Some(v.to_string());
96        } else if (!operands_only && a.starts_with('-') && a != "-") || plan.is_some() {
97            return usage(&format!("unexpected argument `{a}`"));
98        } else {
99            plan = Some(a);
100        }
101    }
102    let Some(plan) = plan else {
103        return usage("record needs a plan file");
104    };
105    let mut stdin = String::new();
106    if let Err(e) = std::io::stdin().read_to_string(&mut stdin) {
107        return usage(&format!("stdin cannot be read: {e}"));
108    }
109    let findings = match parse_findings(&stdin) {
110        Ok(f) => f,
111        Err(why) => return usage(&why),
112    };
113    match record(Path::new(plan), transcript.map(PathBuf::from), &findings) {
114        // A status line, not data: stderr (cli.basics.stdout-is-data-stderr-is-talk).
115        Ok((said, code)) => {
116            eprint!("{said}");
117            (String::new(), ExitCode::from(code))
118        }
119        Err(why) => {
120            eprintln!("amont-agent: {why}");
121            (String::new(), ExitCode::from(1))
122        }
123    }
124}
125
126/// The findings on stdin, checked: an unknown severity would poison every
127/// later aggregate, so it is refused here rather than stored.
128fn parse_findings(text: &str) -> Result<Vec<serde_json::Value>, String> {
129    if text.trim().is_empty() {
130        return Ok(Vec::new());
131    }
132    let v: serde_json::Value =
133        serde_json::from_str(text).map_err(|e| format!("stdin is not JSON: {e}"))?;
134    let Some(list) = v.as_array() else {
135        return Err("stdin must be a JSON array of findings".into());
136    };
137    for (i, f) in list.iter().enumerate() {
138        let s = |k: &str| f.get(k).and_then(|v| v.as_str());
139        if s("role").is_none() {
140            return Err(format!("finding {i}: `role` must be a string"));
141        }
142        for (key, allowed) in [
143            ("severity", &SEVERITIES[..]),
144            ("disposition", &DISPOSITIONS[..]),
145        ] {
146            match s(key) {
147                Some(v) if allowed.contains(&v) => {}
148                other => {
149                    return Err(format!(
150                        "finding {i}: unknown {key} {}; one of {}",
151                        other.unwrap_or("(missing)"),
152                        allowed.join("|")
153                    ))
154                }
155            }
156        }
157        if f.get("n").is_some_and(|v| v.as_u64().is_none()) {
158            return Err(format!("finding {i}: `n` must be a number"));
159        }
160        for key in ["section", "area"] {
161            if f.get(key).is_some_and(|v| !v.is_string() && !v.is_null()) {
162                return Err(format!("finding {i}: `{key}` must be a string"));
163            }
164        }
165    }
166    Ok(list.clone())
167}
168
169/// One completed reviewer of the plan, with the body it reviewed.
170pub(crate) struct Launch {
171    pub(crate) role: String,
172    pub(crate) sha: String,
173    pub(crate) done: Completed,
174}
175
176/// The completed launches of `transcript` that reviewed `plan`, in launch
177/// order: a launch whose prompt carries several blocks for the plan is one
178/// reviewer. The rounds a reviewer was resumed for by `SendMessage` count as
179/// launches of their own: `plan-apply` reads a round-2 reviewer's patches
180/// from its resume, and `record` writes a row per (agent, body).
181pub(crate) fn launches_resumable(transcript: &Path, plan: &str) -> std::io::Result<Vec<Launch>> {
182    collect(transcript, plan, |r, p| {
183        plan_review::completed_agents_resumable(r, p)
184    })
185}
186
187type Reader = fn(BufReader<std::fs::File>, &str) -> Vec<Completed>;
188
189fn collect(transcript: &Path, plan: &str, completed: Reader) -> std::io::Result<Vec<Launch>> {
190    let reader = BufReader::new(std::fs::File::open(transcript)?);
191    let mut out = Vec::new();
192    for done in completed(reader, AGENT_PREFIX) {
193        let found = plan_review::parse_blocks(&done.agent, &done.prompt)
194            .into_iter()
195            .find(|b| b.path == plan);
196        if let Some(b) = found {
197            out.push(Launch {
198                role: b.role,
199                sha: b.sha,
200                done,
201            });
202        }
203    }
204    Ok(out)
205}
206
207/// The transcript to read when none is named: of those touched in the last
208/// week that reviewed this plan, the one that reviewed its CURRENT body,
209/// then the most recently written. The body comes first because a
210/// long-lived session still being appended to can have reviewed an older
211/// body days ago; its mtime says nothing about when the review ran.
212pub(crate) fn discover(
213    plan: &str,
214    current: &str,
215    load: fn(&Path, &str) -> std::io::Result<Vec<Launch>>,
216) -> Option<(PathBuf, Vec<Launch>)> {
217    let projects = amont_agent_guard::settings::config_dir()?.join("projects");
218    let now = std::time::SystemTime::now();
219    let mut best: Option<((bool, std::time::SystemTime), PathBuf, Vec<Launch>)> = None;
220    let dirs = match std::fs::read_dir(&projects) {
221        Ok(d) => d,
222        Err(e) => {
223            eprintln!("amont-agent: cannot read {}: {e}", projects.display());
224            return None;
225        }
226    };
227    for dir in dirs.flatten() {
228        let files = match std::fs::read_dir(dir.path()) {
229            Ok(f) => f,
230            Err(e) => {
231                eprintln!("amont-agent: skipped {}: {e}", dir.path().display());
232                continue;
233            }
234        };
235        for f in files.flatten() {
236            let path = f.path();
237            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
238                continue;
239            }
240            let Some(modified) = f.metadata().ok().and_then(|m| m.modified().ok()) else {
241                continue;
242            };
243            let age = now.duration_since(modified).map_or(0, |d| d.as_secs());
244            // Nothing can outrank a best that reviewed the current body and
245            // is newer: skip the read.
246            if age > WEEK
247                || best
248                    .as_ref()
249                    .is_some_and(|(k, _, _)| *k >= (true, modified))
250            {
251                continue;
252            }
253            let found = match load(&path, plan) {
254                Ok(f) => Ok(f),
255                Err(e) => {
256                    eprintln!("amont-agent: skipped {}: {e}", path.display());
257                    Err(e)
258                }
259            };
260            if let Ok(found) = found {
261                let key = (found.iter().any(|l| l.sha == current), modified);
262                if !found.is_empty() && best.as_ref().is_none_or(|(k, _, _)| key > *k) {
263                    best = Some((key, path, found));
264                }
265            }
266        }
267    }
268    best.map(|(_, p, l)| (p, l))
269}
270
271/// The verdict a reviewer closed with: the first of the three words after
272/// `Verdict`. `approve-with-changes` is tried before `approve`, which it
273/// starts with.
274fn verdict(result: &str) -> Option<&'static str> {
275    let lower = result.to_lowercase();
276    let after = &lower[lower.find("verdict")? + "verdict".len()..];
277    (0..after.len())
278        .filter(|&i| after.is_char_boundary(i))
279        .find_map(|i| {
280            ["rework", "approve-with-changes", "approve"]
281                .into_iter()
282                .find(|w| after[i..].starts_with(w))
283        })
284}
285
286pub(crate) fn now() -> u64 {
287    std::time::SystemTime::now()
288        .duration_since(std::time::UNIX_EPOCH)
289        .map(|d| d.as_secs())
290        .unwrap_or(0)
291}
292
293/// How serious a reviewer called a finding.
294#[derive(Clone, Copy, Debug, PartialEq, Eq)]
295pub(crate) enum Severity {
296    Blocking,
297    High,
298    Medium,
299    Low,
300}
301
302impl Severity {
303    pub(crate) fn as_str(self) -> &'static str {
304        match self {
305            Severity::Blocking => "blocking",
306            Severity::High => "high",
307            Severity::Medium => "medium",
308            Severity::Low => "low",
309        }
310    }
311}
312
313/// The numbered findings of a reviewer's result: each `N. [severity]` line
314/// between `Findings:` and `What I would measure:` / `Patches:` (or the end),
315/// a leading `**` or spaces tolerated, with the count of numbered lines that
316/// did not match. A line is skipped, never guessed.
317pub(crate) fn findings_of(result: &str) -> (Vec<(u32, Severity)>, usize) {
318    let heading = |l: &str| l.trim().trim_matches('*').trim().to_string();
319    let mut found = Vec::new();
320    let mut unparsed = 0;
321    let mut inside = false;
322    for line in result.lines() {
323        let h = heading(line);
324        if !inside {
325            inside = h.starts_with("Findings:");
326            continue;
327        }
328        if h.starts_with("What I would measure:") || h.starts_with("Patches:") {
329            break;
330        }
331        let body = line.trim_start().trim_start_matches('*').trim_start();
332        let digits = body.chars().take_while(char::is_ascii_digit).count();
333        if digits == 0 || !body[digits..].starts_with('.') {
334            continue;
335        }
336        let parsed = body[..digits].parse::<u32>().ok().zip(
337            body[digits + 1..]
338                .trim_start()
339                // `1. **[high]** …`: bold after the number, as reviewers write it.
340                .trim_start_matches('*')
341                .strip_prefix('[')
342                .and_then(|r| r.split_once(']'))
343                .and_then(|(w, _)| match w.trim().to_lowercase().as_str() {
344                    "blocking" => Some(Severity::Blocking),
345                    "high" => Some(Severity::High),
346                    "medium" => Some(Severity::Medium),
347                    "low" => Some(Severity::Low),
348                    _ => None,
349                }),
350        );
351        match parsed {
352            Some(f) => found.push(f),
353            None => unparsed += 1,
354        }
355    }
356    (found, unparsed)
357}
358
359fn json_list<'a>(items: impl IntoIterator<Item = &'a String>) -> serde_json::Value {
360    serde_json::Value::Array(items.into_iter().map(|s| s.as_str().into()).collect())
361}
362
363/// An exclusive lock on the ledger's lock file, held until dropped. Two
364/// `record` calls on one transcript would otherwise both read "not yet
365/// recorded" and append the same reviewers twice.
366pub(crate) struct Lock {
367    _held: std::fs::File,
368}
369
370#[cfg(unix)]
371pub(crate) fn lock(path: &Path) -> std::io::Result<Lock> {
372    use std::os::unix::fs::OpenOptionsExt;
373    use std::os::unix::io::AsRawFd;
374    extern "C" {
375        fn flock(fd: i32, op: i32) -> i32;
376    }
377    const LOCK_EX: i32 = 2;
378    let f = std::fs::OpenOptions::new()
379        .create(true)
380        .append(true)
381        .mode(0o600)
382        .open(path)?;
383    amont_agent_guard::journal::private(path, 0o600);
384    if unsafe { flock(f.as_raw_fd(), LOCK_EX) } != 0 {
385        return Err(std::io::Error::last_os_error());
386    }
387    Ok(Lock { _held: f })
388}
389
390// holds-until: two `record` calls run at once on Windows. No lock is taken
391// there: `File::lock` needs Rust 1.89, above the 1.85 floor, and plan
392// reviews run on unix machines. Ends when the floor reaches 1.89.
393#[cfg(not(unix))]
394pub(crate) fn lock(path: &Path) -> std::io::Result<Lock> {
395    let f = std::fs::OpenOptions::new()
396        .create(true)
397        .append(true)
398        .open(path)?;
399    Ok(Lock { _held: f })
400}
401
402/// The longest the PostToolUse hook waits for the ledger lock.
403const HOOK_LOCK_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
404/// Pause between two non-blocking lock attempts.
405const HOOK_LOCK_RETRY: std::time::Duration = std::time::Duration::from_millis(25);
406
407/// Like [`lock`], but takes the lock with `LOCK_NB` and retries for at most
408/// `budget`; `TimedOut` when another process still holds it.
409#[cfg(unix)]
410pub(crate) fn lock_within(path: &Path, budget: std::time::Duration) -> std::io::Result<Lock> {
411    use std::os::unix::fs::OpenOptionsExt;
412    use std::os::unix::io::AsRawFd;
413    extern "C" {
414        fn flock(fd: i32, op: i32) -> i32;
415    }
416    const LOCK_EX_NB: i32 = 2 | 4;
417    let f = std::fs::OpenOptions::new()
418        .create(true)
419        .append(true)
420        .mode(0o600)
421        .open(path)?;
422    amont_agent_guard::journal::private(path, 0o600);
423    let deadline = std::time::Instant::now() + budget;
424    loop {
425        if unsafe { flock(f.as_raw_fd(), LOCK_EX_NB) } == 0 {
426            return Ok(Lock { _held: f });
427        }
428        let e = std::io::Error::last_os_error();
429        if e.kind() != std::io::ErrorKind::WouldBlock {
430            return Err(e);
431        }
432        if std::time::Instant::now() >= deadline {
433            return Err(std::io::ErrorKind::TimedOut.into());
434        }
435        std::thread::sleep(HOOK_LOCK_RETRY);
436    }
437}
438
439// holds-until: see `lock`; no lock is taken off unix, so there is nothing to wait for.
440#[cfg(not(unix))]
441pub(crate) fn lock_within(path: &Path, _budget: std::time::Duration) -> std::io::Result<Lock> {
442    lock(path)
443}
444
445const HOOK_RULE: &str = "plan-review-record";
446
447fn hook_note(outcome: &str, session: &str, excerpt: &str) {
448    amont_agent_guard::journal::record(&amont_agent_guard::journal::Entry {
449        rule: HOOK_RULE,
450        stance: "-",
451        outcome,
452        session,
453        repo: "-",
454        mode: "-",
455        excerpt,
456    });
457}
458
459/// PostToolUse on `ExitPlanMode`, approved or rejected: record the plan's
460/// reviewers. It never fails the call: an error is journalled (`error`), and
461/// a ledger lock still held after 2 s is journalled as one `skipped`, with
462/// the body sha and path `plan-review stats` lists until a row for that body
463/// exists (a manual `plan-review record` catches up).
464pub fn on_plan_answered(ev: &amont_agent_guard::payload::PlanAnswered) {
465    let Some(plan) = &ev.plan_file else { return };
466    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
467        let store = prepare_store()?;
468        let held = match lock_within(&store.join(LOCK_FILE), HOOK_LOCK_BUDGET) {
469            Ok(l) => l,
470            Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {
471                let sha = std::fs::read_to_string(plan)
472                    .map(|t| crate::plan_text::body_sha(&t))
473                    .unwrap_or_default();
474                let real = plan_review::real(plan);
475                hook_note(
476                    "skipped",
477                    &ev.session,
478                    &format!("{} {}", sha.get(..12).unwrap_or("-"), real.display()),
479                );
480                return Ok(());
481            }
482            Err(e) => return Err(format!("cannot lock the ledger: {e}")),
483        };
484        let done = record_locked(&store, plan, ev.transcript.clone(), &[]).map(|_| ());
485        drop(held);
486        done
487    }));
488    match outcome {
489        Ok(Ok(())) => {}
490        Ok(Err(why)) => hook_note("error", &ev.session, &why),
491        Err(_) => hook_note("error", &ev.session, "record panicked"),
492    }
493}
494
495/// Options for a file only its owner reads: the ledger and the bodies hold
496/// what reviewers said about private plans.
497pub(crate) fn private_options() -> std::fs::OpenOptions {
498    let o = std::fs::OpenOptions::new();
499    #[cfg(unix)]
500    let o = {
501        use std::os::unix::fs::OpenOptionsExt;
502        let mut o = o;
503        o.mode(0o600);
504        o
505    };
506    o
507}
508
509/// The ledger, opened for appending; created when missing.
510pub(crate) fn open_ledger(path: &Path) -> std::io::Result<std::fs::File> {
511    let f = private_options().append(true).create(true).open(path)?;
512    amont_agent_guard::journal::private(path, 0o600);
513    Ok(f)
514}
515
516/// A saved body, created once: `AlreadyExists` when an earlier call wrote it.
517fn create_body(path: &Path) -> std::io::Result<std::fs::File> {
518    private_options().write(true).create_new(true).open(path)
519}
520
521/// The rows of a ledger, in file order; a missing file is no rows. A torn
522/// line (a crash mid-append) is skipped, not fatal. The one reader of every
523/// outcomes ledger.
524pub fn read_rows(path: &Path) -> std::io::Result<Vec<serde_json::Value>> {
525    let text = match std::fs::read(path) {
526        Ok(b) => String::from_utf8_lossy(&b).into_owned(),
527        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
528        Err(e) => return Err(e),
529    };
530    Ok(text
531        .lines()
532        .filter_map(|l| serde_json::from_str(l).ok())
533        .collect())
534}
535
536type Recorded = (BTreeSet<(String, String)>, Option<String>);
537
538/// What the ledger already holds: the (review agent id, body sha) pairs on
539/// record, and the round-1 body an earlier call recorded for `plan`, if any.
540/// A torn line (a crash mid-append) is skipped, not fatal.
541fn recorded(path: &Path, plan: &str) -> std::io::Result<Recorded> {
542    let rows = read_rows(path)?;
543    // Only review rows: a merge row (`plan-apply`) has an `agent_id` too, and
544    // must not make a reviewer look recorded.
545    let ids = rows
546        .iter()
547        .filter(|v| v.get("schema").and_then(|s| s.as_str()) == Some(SCHEMA))
548        .filter_map(|v| {
549            Some((
550                v.get("agent_id")?.as_str()?.to_string(),
551                v.get("body_sha")?.as_str()?.to_string(),
552            ))
553        })
554        .collect();
555    let first = rows
556        .iter()
557        .filter(|v| v.get("plan_path").and_then(|p| p.as_str()) == Some(plan))
558        .find_map(|v| v.get("first_round_sha")?.as_str().map(str::to_string));
559    Ok((ids, first))
560}
561
562/// The plan-review store under the journal directory, with its `bodies`
563/// directory, created private.
564fn prepare_store() -> Result<PathBuf, String> {
565    let store = amont_agent_guard::journal::dir()
566        .ok_or("no home directory, so the ledger cannot be located")?
567        .join("plan-review");
568    let bodies = store.join("bodies");
569    plan_review::private_dir(&store)
570        .and_then(|()| plan_review::private_dir(&bodies))
571        .ok_or_else(|| format!("cannot create {}", bodies.display()))?;
572    Ok(store)
573}
574
575fn record(
576    plan: &Path,
577    transcript: Option<PathBuf>,
578    findings: &[serde_json::Value],
579) -> Result<(String, u8), String> {
580    let store = prepare_store()?;
581    let _lock = lock(&store.join(LOCK_FILE)).map_err(|e| format!("cannot lock the ledger: {e}"))?;
582    record_locked(&store, plan, transcript, findings)
583}
584
585/// `record` with the ledger lock already held by the caller.
586fn record_locked(
587    store: &Path,
588    plan: &Path,
589    transcript: Option<PathBuf>,
590    findings: &[serde_json::Value],
591) -> Result<(String, u8), String> {
592    let text = std::fs::read_to_string(plan)
593        .map_err(|e| format!("cannot read {}: {e}", plan.display()))?;
594    let real = plan_review::real(plan);
595    let real = real.to_string_lossy().into_owned();
596    let plan_sha = crate::plan_text::body_sha(&text);
597    let (transcript, found) = match transcript {
598        Some(t) => {
599            let l = launches_resumable(&t, &real)
600                .map_err(|e| format!("cannot read transcript {}: {e}", t.display()))?;
601            (t, l)
602        }
603        None => discover(&real, &plan_sha, launches_resumable).ok_or_else(|| {
604            format!(
605                "no transcript from the last 7 days has a completed plan-review of {real}; name one with --transcript"
606            )
607        })?,
608    };
609    if found.is_empty() {
610        return Err(format!(
611            "{} has no completed plan-review of {real}",
612            transcript.display()
613        ));
614    }
615    // A round is a body: numbered in the order bodies first appear.
616    let mut shas: Vec<&str> = Vec::new();
617    for l in &found {
618        if !shas.contains(&l.sha.as_str()) {
619            shas.push(&l.sha);
620        }
621    }
622    let round_of = |sha: &str| shas.iter().position(|s| *s == sha).map_or(0, |i| i + 1);
623    let top = shas.len();
624
625    let io = |what: &str| {
626        let what = what.to_string();
627        move |e: std::io::Error| format!("{what}: {e}")
628    };
629    let bodies = store.join("bodies");
630    let ledger = store.join("outcomes.jsonl");
631    let (mut seen, first_on_record) =
632        recorded(&ledger, &real).map_err(io(&format!("cannot read {}", ledger.display())))?;
633    // Round 1 is the first body the LEDGER saw for this plan, so a review
634    // that spans sessions keeps one round-1 body to diff against.
635    // holds-until: `round` is still counted within this transcript; a plan
636    // reviewed across sessions gets a second round 1 until rounds are
637    // numbered from the ledger too.
638    let before = seen.clone();
639    let first_round_sha = first_on_record.unwrap_or_else(|| shas[0].to_string());
640
641    // A row describes the body its reviewer read: the current file when it
642    // is that body, else the copy an earlier call saved. A body that was
643    // never saved records null, not a guess from another body.
644    let features = |sha: &str| {
645        let saved = (plan_sha != sha)
646            .then(|| {
647                let file = bodies.join(format!("{sha}.md"));
648                std::fs::read_to_string(&file)
649                    .map_err(|e| {
650                        if e.kind() != std::io::ErrorKind::NotFound {
651                            eprintln!("amont-agent: cannot read {}: {e}", file.display());
652                        }
653                    })
654                    .ok()
655            })
656            .flatten();
657        let body = match &saved {
658            Some(s) => s.as_str(),
659            None if plan_sha == sha => text.as_str(),
660            None => return serde_json::Value::Null,
661        };
662        let meta = plan_review::meta(body);
663        serde_json::json!({
664            "areas": meta.as_ref().ok().and_then(plan_review::areas_of).map(|a| json_list(&a)).unwrap_or_else(|| serde_json::json!([])),
665            "repos": meta.as_ref().map(|m| json_list(&m.repos)).unwrap_or_else(|_| serde_json::json!([])),
666            "body_lines": crate::plan_text::canonical(body).lines().count(),
667        })
668    };
669
670    let at = now();
671    let mut buffer = String::new();
672    let mut written: BTreeSet<usize> = BTreeSet::new();
673    let mut written_roles: BTreeSet<&str> = BTreeSet::new();
674    let mut count = 0;
675    // The findings on stdin are this call's merge: they belong to the
676    // highest round it writes, which is not always the transcript's top.
677    let attach_round = found
678        .iter()
679        .filter(|l| {
680            l.done
681                .agent_id
682                .as_ref()
683                .is_some_and(|id| !seen.contains(&(id.clone(), l.sha.clone())))
684        })
685        .map(|l| round_of(&l.sha))
686        .max();
687    for l in &found {
688        let Some(id) = l.done.agent_id.as_ref() else {
689            continue;
690        };
691        if !seen.insert((id.clone(), l.sha.clone())) {
692            continue;
693        }
694        let round = round_of(&l.sha);
695        let attach = Some(round) == attach_round;
696        let mine: Vec<&serde_json::Value> = findings
697            .iter()
698            .filter(|f| attach && f.get("role").and_then(|r| r.as_str()) == Some(&l.role))
699            .collect();
700        // Stdin wins for a role it names; else what the reviewer wrote.
701        let (listed, unparsed) = if mine.is_empty() {
702            let (parsed, unparsed) = findings_of(&l.done.result);
703            let rows: Vec<serde_json::Value> = parsed
704                .iter()
705                .map(|(n, sev)| serde_json::json!({"n": n, "severity": sev.as_str()}))
706                .collect();
707            (rows, Some(unparsed))
708        } else {
709            (mine.iter().map(|f| (*f).clone()).collect(), None)
710        };
711        let row = serde_json::json!({
712            "schema": SCHEMA,
713            "recorded_at": at,
714            "role": l.role,
715            "agent_id": id,
716            "model": l.done.model,
717            "tokens": l.done.tokens,
718            "seconds": l.done.duration_ms.map(|ms| ms as f64 / 1000.0),
719            "round": round,
720            "body_sha": l.sha,
721            "first_round_sha": first_round_sha,
722            "plan_path": real,
723            "plan": features(&l.sha),
724            "verdict": verdict(&l.done.result),
725            "remit": json_list(&plan_review::remit(&l.role)),
726            "findings": listed,
727        });
728        let mut row = row;
729        if let Some(u) = unparsed {
730            row["findings_unparsed"] = u.into();
731        }
732        buffer.push_str(&row.to_string());
733        buffer.push('\n');
734        written.insert(round);
735        if attach {
736            written_roles.insert(l.role.as_str());
737        }
738        count += 1;
739    }
740    if count > 0 {
741        let mut f =
742            open_ledger(&ledger).map_err(io(&format!("cannot open {}", ledger.display())))?;
743        f.write_all(buffer.as_bytes())
744            .and_then(|()| f.flush())
745            .map_err(io(&format!("cannot write {}", ledger.display())))?;
746    }
747
748    // A stdin finding no new row took is a disposition for a reviewer already
749    // on record: appended, never deduped.
750    let mut dispositions = 0;
751    if !findings.is_empty() {
752        let mut buffer = String::new();
753        for f in findings {
754            let Some(role) = f.get("role").and_then(|r| r.as_str()) else {
755                continue;
756            };
757            let taken = written_roles.contains(role);
758            let target = found
759                .iter()
760                .filter(|l| l.role == role && l.done.agent_id.is_some())
761                .filter(|l| {
762                    let id = l.done.agent_id.clone().unwrap_or_default();
763                    before.contains(&(id, l.sha.clone()))
764                })
765                .max_by_key(|l| round_of(&l.sha));
766            let (false, Some(l)) = (taken, target) else {
767                continue;
768            };
769            let row = serde_json::json!({
770                "schema": DISPOSITION_SCHEMA,
771                "recorded_at": at,
772                "agent_id": l.done.agent_id,
773                "body_sha": l.sha,
774                "role": role,
775                "n": f.get("n").cloned().unwrap_or(serde_json::Value::Null),
776                "disposition": f.get("disposition"),
777            });
778            buffer.push_str(&row.to_string());
779            buffer.push('\n');
780            dispositions += 1;
781        }
782        if dispositions > 0 {
783            let mut f =
784                open_ledger(&ledger).map_err(io(&format!("cannot open {}", ledger.display())))?;
785            f.write_all(buffer.as_bytes())
786                .and_then(|()| f.flush())
787                .map_err(io(&format!("cannot write {}", ledger.display())))?;
788        }
789    }
790
791    for &round in &written {
792        let sha = shas[round - 1];
793        if plan_sha != sha {
794            eprintln!("body of round {round} not saved: the plan changed since it was reviewed");
795            continue;
796        }
797        match create_body(&bodies.join(format!("{sha}.md"))) {
798            Ok(mut f) => {
799                f.write_all(text.as_bytes())
800                    .map_err(io("cannot save the plan body"))?;
801            }
802            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
803            Err(e) => return Err(format!("cannot save the plan body: {e}")),
804        }
805    }
806    prune(&bodies);
807
808    // Reported after the write: the rows that resolved are already safe.
809    let mut missing = false;
810    for l in found.iter().filter(|l| l.done.agent_id.is_none()) {
811        eprintln!(
812            "amont-agent: no agent id for {} in {}",
813            l.role,
814            transcript.display()
815        );
816        missing = true;
817    }
818
819    let (round, sha) = match written.iter().next_back() {
820        Some(&r) => (r, shas[r - 1]),
821        None => (top, shas[top - 1]),
822    };
823    let mut said = if count > 0 {
824        format!("wrote {count} rows (round {round}, plan {})\n", &sha[..12])
825    } else if missing {
826        format!("no rows written for round {round} of {}\n", &sha[..12])
827    } else {
828        format!(
829            "already recorded round {round} of {}; nothing written\n",
830            &sha[..12]
831        )
832    };
833    if dispositions > 0 {
834        said.push_str(&format!("wrote {dispositions} disposition rows\n"));
835    }
836    Ok((said, u8::from(missing)))
837}
838
839/// Saved bodies are for looking back at a recent review, not an archive.
840/// A failure is said on stderr and never fails the call: the rows are
841/// already written, and a body left over is harmless.
842fn prune(dir: &Path) {
843    let entries = match std::fs::read_dir(dir) {
844        Ok(e) => e,
845        Err(e) => {
846            eprintln!("amont-agent: bodies in {} not pruned: {e}", dir.display());
847            return;
848        }
849    };
850    let now = std::time::SystemTime::now();
851    for e in entries.flatten() {
852        let old = e
853            .metadata()
854            .and_then(|m| m.modified())
855            .ok()
856            .and_then(|t| now.duration_since(t).ok())
857            .is_some_and(|d| d.as_secs() > BODY_TTL);
858        if old {
859            if let Err(why) = std::fs::remove_file(e.path()) {
860                eprintln!("amont-agent: body {} not pruned: {why}", e.path().display());
861            }
862        }
863    }
864}
865
866pub(crate) const IMPL_SCHEMA: &str = "amont-agent-impl-review-outcome-v1";
867const IMPL_ROLE: &str = "implementation-review";
868const RETIRE_MIN_REVIEWS: u64 = 20;
869const RETIRE_BELOW: f64 = 0.05;
870
871/// One (role, model) line of `plan-review stats`.
872#[derive(Default, Debug, PartialEq)]
873pub struct Stat {
874    pub reviews: u64,
875    pub found: [u64; 4],
876    pub accepted: [u64; 4],
877    /// Parsed findings no disposition row and no merge row settled.
878    pub unresolved: [u64; 4],
879    /// Reviews recorded before findings were parsed: `findings` empty and no
880    /// `findings_unparsed`.
881    pub predating: u64,
882    /// Reviews that closed approve-with-changes or rework with no parsed
883    /// finding, though the parser ran.
884    pub verdict_without_findings: u64,
885}
886
887impl Stat {
888    /// Accepted blocking and high findings per review.
889    pub fn accepted_per_review(&self) -> f64 {
890        if self.reviews == 0 {
891            return 0.0;
892        }
893        (self.accepted[0] + self.accepted[1]) as f64 / self.reviews as f64
894    }
895
896    /// Accepted plus unresolved blocking and high findings per review: an
897    /// unresolved finding is not evidence that the seat caught nothing.
898    pub fn serious_per_review(&self) -> f64 {
899        if self.reviews == 0 {
900            return 0.0;
901        }
902        (self.accepted[0] + self.accepted[1] + self.unresolved[0] + self.unresolved[1]) as f64
903            / self.reviews as f64
904    }
905
906    pub fn retire_candidate(&self) -> bool {
907        self.reviews >= RETIRE_MIN_REVIEWS && self.serious_per_review() < RETIRE_BELOW
908    }
909}
910
911/// What settles a parsed finding, read from the whole ledger so a merge row
912/// or a disposition row appended after the review rows still counts.
913struct Settled<'a> {
914    /// The latest disposition row per (agent id, body sha, n).
915    dispositions: std::collections::BTreeMap<(&'a str, &'a str, u64), &'a str>,
916    /// The `role#n` labels merged into each body sha, over every merge row.
917    applied: std::collections::BTreeMap<&'a str, BTreeSet<&'a str>>,
918}
919
920impl<'a> Settled<'a> {
921    fn of(rows: &'a [serde_json::Value]) -> Settled<'a> {
922        let mut out = Settled {
923            dispositions: Default::default(),
924            applied: Default::default(),
925        };
926        for r in rows {
927            let text = |k: &str| r.get(k).and_then(|v| v.as_str());
928            match text("schema") {
929                Some(DISPOSITION_SCHEMA) => {
930                    if let (Some(id), Some(sha), Some(n), Some(d)) = (
931                        text("agent_id"),
932                        text("body_sha"),
933                        r.get("n").and_then(|n| n.as_u64()),
934                        text("disposition"),
935                    ) {
936                        out.dispositions.insert((id, sha, n), d);
937                    }
938                }
939                Some(MERGE_SCHEMA) => {
940                    if let Some(sha) = text("body_sha_before") {
941                        let labels = r
942                            .get("applied_labels")
943                            .and_then(|l| l.as_array())
944                            .into_iter()
945                            .flatten()
946                            .filter_map(|l| l.as_str());
947                        out.applied.entry(sha).or_default().extend(labels);
948                    }
949                }
950                _ => {}
951            }
952        }
953        out
954    }
955
956    /// The disposition of finding `f` of review row `r`: the one it was
957    /// given (stdin), else the latest disposition row, else `accepted` when a
958    /// merge of this row's body applied `role#n`, else `unresolved`.
959    fn of_finding<'b>(&self, r: &'b serde_json::Value, f: &'b serde_json::Value) -> &'b str
960    where
961        'a: 'b,
962    {
963        if let Some(given) = f.get("disposition").and_then(|d| d.as_str()) {
964            return given;
965        }
966        let text = |k: &str| r.get(k).and_then(|v| v.as_str()).unwrap_or("");
967        let Some(n) = f.get("n").and_then(|n| n.as_u64()) else {
968            return "unresolved";
969        };
970        if let Some(d) = self
971            .dispositions
972            .get(&(text("agent_id"), text("body_sha"), n))
973        {
974            return d;
975        }
976        let label = format!("{}#{n}", text("role"));
977        if self
978            .applied
979            .get(text("body_sha"))
980            .is_some_and(|l| l.contains(label.as_str()))
981        {
982            "accepted"
983        } else {
984            "unresolved"
985        }
986    }
987}
988
989/// The rows of both ledgers folded to one [`Stat`] per (role, model), in key
990/// order. Rows of another schema (merge rows) are not reviews.
991pub fn tally(rows: &[serde_json::Value]) -> std::collections::BTreeMap<(String, String), Stat> {
992    let settled = Settled::of(rows);
993    let mut out: std::collections::BTreeMap<(String, String), Stat> = Default::default();
994    for r in rows {
995        let text = |k: &str| r.get(k).and_then(|v| v.as_str()).unwrap_or("");
996        let (role, accepted_word, plan_review) = match text("schema") {
997            SCHEMA => (text("role").to_string(), "accepted", true),
998            IMPL_SCHEMA => (IMPL_ROLE.to_string(), "fixed", false),
999            _ => continue,
1000        };
1001        let stat = out.entry((role, text("model").to_string())).or_default();
1002        stat.reviews += 1;
1003        let list = r.get("findings").and_then(|f| f.as_array());
1004        if plan_review && list.is_none_or(|l| l.is_empty()) {
1005            if r.get("findings_unparsed").is_none() {
1006                stat.predating += 1;
1007            } else if ["approve-with-changes", "rework"].contains(&text("verdict")) {
1008                stat.verdict_without_findings += 1;
1009            }
1010        }
1011        for f in list.into_iter().flatten() {
1012            let Some(i) = f
1013                .get("severity")
1014                .and_then(|s| s.as_str())
1015                .and_then(|s| SEVERITIES.iter().position(|x| *x == s))
1016            else {
1017                continue;
1018            };
1019            stat.found[i] += 1;
1020            let disposition = if plan_review {
1021                settled.of_finding(r, f)
1022            } else {
1023                f.get("disposition").and_then(|d| d.as_str()).unwrap_or("")
1024            };
1025            if disposition == accepted_word {
1026                stat.accepted[i] += 1;
1027            } else if disposition == "unresolved" {
1028                stat.unresolved[i] += 1;
1029            }
1030        }
1031    }
1032    out
1033}
1034
1035/// A plan whose `PostToolUse` record was skipped (ledger lock busy), read
1036/// from the journal.
1037#[derive(Debug, PartialEq, Eq, Clone)]
1038pub struct Skip {
1039    pub ts: u64,
1040    /// The first 12 hex digits of the plan's body sha when it was skipped.
1041    pub sha: String,
1042    pub plan: String,
1043}
1044
1045/// The journalled skips in `journal` text (`F ts rule stance outcome session
1046/// repo mode arm excerpt`), in file order.
1047pub fn skips_in(journal: &str) -> Vec<Skip> {
1048    journal
1049        .lines()
1050        .filter_map(|line| {
1051            let mut f = line.strip_prefix("F ")?.splitn(9, ' ');
1052            let ts = f.next()?.parse::<u64>().ok()?;
1053            let (rule, _stance, outcome) = (f.next()?, f.next()?, f.next()?);
1054            if rule != HOOK_RULE || outcome != "skipped" {
1055                return None;
1056            }
1057            let (_session, _repo, _mode, _arm) = (f.next()?, f.next()?, f.next()?, f.next()?);
1058            // The excerpt is `<sha12> <plan path>`.
1059            let (sha, plan) = f.next()?.split_once(' ')?;
1060            Some(Skip {
1061                ts,
1062                sha: sha.to_string(),
1063                plan: plan.trim().to_string(),
1064            })
1065        })
1066        .collect()
1067}
1068
1069/// The skips no later `record` made up for: no review row for the plan holds
1070/// that body, and none was recorded for the plan since the skip. One per
1071/// (plan, body), the latest.
1072pub fn unrecorded_skips(rows: &[serde_json::Value], skips: &[Skip]) -> Vec<Skip> {
1073    let mut out: std::collections::BTreeMap<(String, String), Skip> = Default::default();
1074    for s in skips {
1075        let caught_up = rows.iter().any(|r| {
1076            let text = |k: &str| r.get(k).and_then(|v| v.as_str()).unwrap_or("");
1077            text("schema") == SCHEMA
1078                && text("plan_path") == s.plan
1079                && (text("body_sha").starts_with(&s.sha)
1080                    || r.get("recorded_at").and_then(|t| t.as_u64()).unwrap_or(0) >= s.ts)
1081        });
1082        if !caught_up {
1083            out.insert((s.plan.clone(), s.sha.clone()), s.clone());
1084        }
1085    }
1086    out.into_values().collect()
1087}
1088
1089/// How `plan-review stats` prints.
1090#[derive(Clone, Copy, PartialEq, Eq)]
1091pub enum Format {
1092    Text,
1093    Json,
1094}
1095
1096/// `plan-review stats`: what the panel's seats caught, per role and model.
1097pub fn stats_report(rows: &[serde_json::Value], model: Option<&str>, format: Format) -> String {
1098    stats_report_with(rows, &[], model, format)
1099}
1100
1101/// [`stats_report`] with the journalled skips, for the plans a record missed.
1102pub fn stats_report_with(
1103    rows: &[serde_json::Value],
1104    skips: &[Skip],
1105    model: Option<&str>,
1106    format: Format,
1107) -> String {
1108    let tallied = tally(rows);
1109    let shown: Vec<_> = tallied
1110        .iter()
1111        .filter(|((_, m), _)| model.is_none_or(|want| want == m))
1112        .collect();
1113    let missed = unrecorded_skips(rows, skips);
1114    if format == Format::Json {
1115        let mut list: Vec<serde_json::Value> = shown
1116            .iter()
1117            .map(|((role, model), s)| {
1118                let by = |a: &[u64; 4]| {
1119                    serde_json::json!({
1120                        "blocking": a[0], "high": a[1], "medium": a[2], "low": a[3]
1121                    })
1122                };
1123                serde_json::json!({
1124                    "role": role,
1125                    "model": model,
1126                    "reviews": s.reviews,
1127                    "findings": by(&s.found),
1128                    "accepted": by(&s.accepted),
1129                    "unresolved": by(&s.unresolved),
1130                    "accepted_high_or_blocking_per_review": s.accepted_per_review(),
1131                    "serious_per_review": s.serious_per_review(),
1132                    "predate_change": s.predating,
1133                    "verdict_without_findings": s.verdict_without_findings,
1134                    "retire_candidate": s.retire_candidate(),
1135                })
1136            })
1137            .collect();
1138        list.extend(missed.iter().map(|s| {
1139            serde_json::json!({"record_skipped": s.plan, "body_sha": s.sha, "skipped_at": s.ts})
1140        }));
1141        return format!("{}\n", serde_json::Value::Array(list));
1142    }
1143    let mut out = String::new();
1144    let sev = |a: &[u64; 4]| format!("{}/{}/{}/{}", a[0], a[1], a[2], a[3]);
1145    for ((role, model), s) in &shown {
1146        let model = if model.is_empty() { "-" } else { model };
1147        out.push_str(&format!(
1148            "{role}\t{model}\treviews={}\tfound={}\taccepted={}",
1149            s.reviews,
1150            sev(&s.found),
1151            sev(&s.accepted),
1152        ));
1153        if s.unresolved != [0; 4] {
1154            out.push_str(&format!("\tunresolved={}", sev(&s.unresolved)));
1155        }
1156        out.push_str(&format!("\tserious/review={:.2}", s.serious_per_review()));
1157        if s.predating > 0 {
1158            out.push_str(&format!("\tpredate-change={}", s.predating));
1159        }
1160        if s.retire_candidate() {
1161            out.push_str("\tretire-candidate");
1162        }
1163        out.push('\n');
1164    }
1165    for ((role, model), s) in &shown {
1166        if s.verdict_without_findings > 0 {
1167            let model = if model.is_empty() { "-" } else { model };
1168            out.push_str(&format!(
1169                "no-parsed-findings\t{role}\t{model}\t{} reviews closed approve-with-changes or rework with 0 parsed findings\n",
1170                s.verdict_without_findings
1171            ));
1172        }
1173    }
1174    for s in &missed {
1175        out.push_str(&format!(
1176            "record-skipped\t{}\tbody={}\tcatch up: amont-agent plan-review record {}\n",
1177            s.plan, s.sha, s.plan
1178        ));
1179    }
1180    out
1181}
1182
1183/// The ledgers' path under the journal directory, for `store`.
1184fn ledger_path(store: &str) -> Result<PathBuf, String> {
1185    Ok(amont_agent_guard::journal::dir()
1186        .ok_or("no home directory, so the ledger cannot be located")?
1187        .join(store)
1188        .join("outcomes.jsonl"))
1189}
1190
1191fn run_stats(args: &[String]) -> (String, ExitCode) {
1192    let usage = |why: &str| {
1193        eprint!("amont-agent: {why}\n\n{USAGE}");
1194        (String::new(), ExitCode::from(2))
1195    };
1196    let (mut json, mut model) = (false, None::<String>);
1197    let mut it = args.iter();
1198    while let Some(a) = it.next() {
1199        if a == "--json" {
1200            json = true;
1201        } else if a == "--model" {
1202            let Some(v) = it.next() else {
1203                return usage("--model needs a model id");
1204            };
1205            model = Some(v.clone());
1206        } else if let Some(v) = a.strip_prefix("--model=") {
1207            model = Some(v.to_string());
1208        } else {
1209            return usage(&format!("unexpected argument `{a}`"));
1210        }
1211    }
1212    let mut rows = Vec::new();
1213    for store in ["plan-review", crate::impl_review_outcomes::STORE] {
1214        let read = ledger_path(store)
1215            .and_then(|p| read_rows(&p).map_err(|e| format!("cannot read {}: {e}", p.display())));
1216        match read {
1217            Ok(mut r) => rows.append(&mut r),
1218            Err(why) => {
1219                eprintln!("amont-agent: {why}");
1220                return (String::new(), ExitCode::from(1));
1221            }
1222        }
1223    }
1224    let journal: String = amont_agent_guard::journal::paths()
1225        .iter()
1226        .filter_map(|p| std::fs::read_to_string(p).ok())
1227        .collect();
1228    (
1229        stats_report_with(
1230            &rows,
1231            &skips_in(&journal),
1232            model.as_deref(),
1233            if json { Format::Json } else { Format::Text },
1234        ),
1235        ExitCode::SUCCESS,
1236    )
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    #[test]
1244    fn the_output_contract_example_parses() {
1245        let r = "Verdict: approve-with-changes\n\n**Findings:**\n1. [blocking] Section A: x. Edit: y.\n   evidence 2. [low] not a finding\n**2. [high]** B\n3. [low] C\n\n**Patches:**\n````json\n[]\n````\n4. [high] after\n";
1246        assert_eq!(
1247            findings_of(r),
1248            (
1249                vec![
1250                    (1, Severity::Blocking),
1251                    (2, Severity::High),
1252                    (3, Severity::Low)
1253                ],
1254                0
1255            )
1256        );
1257    }
1258
1259    #[test]
1260    fn a_malformed_numbered_line_is_counted_not_parsed() {
1261        let r = "Findings:\n1. [high] ok\n2. [severe] bad\n3. no severity\n";
1262        assert_eq!(findings_of(r), (vec![(1, Severity::High)], 2));
1263    }
1264
1265    /// A reviewer on 2026-10-22 wrote its findings as `1. **[high] text**`:
1266    /// bold after the number. The live ledger counted both as unparsed.
1267    #[test]
1268    fn bold_after_the_number_parses() {
1269        let r = "Verdict: approve-with-changes\n\n**Findings:**\n\n\
1270                 1. **[high] A delta round that resumes the reviewer is never recorded.**\n\
1271                 2. **[high] The disposition is fixed at write time.**\n\n\
1272                 **What I would measure:**\n";
1273        assert_eq!(
1274            findings_of(r),
1275            (vec![(1, Severity::High), (2, Severity::High)], 0)
1276        );
1277    }
1278
1279    #[test]
1280    fn none_gives_no_findings() {
1281        assert_eq!(
1282            findings_of("Verdict: approve\nFindings:\nNone.\n"),
1283            (vec![], 0)
1284        );
1285        assert_eq!(findings_of("no list at all"), (vec![], 0));
1286    }
1287
1288    #[test]
1289    fn a_numbered_line_after_the_measure_heading_is_ignored() {
1290        let r = "Findings:\n1. [medium] a\nWhat I would measure:\n2. [high] b\n3. junk\n";
1291        assert_eq!(findings_of(r), (vec![(1, Severity::Medium)], 0));
1292    }
1293}