1use 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
62pub 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 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 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
126fn 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
169pub(crate) struct Launch {
171 pub(crate) role: String,
172 pub(crate) sha: String,
173 pub(crate) done: Completed,
174}
175
176pub(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
207pub(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 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
271fn 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#[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
313pub(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 .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
363pub(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#[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
402const HOOK_LOCK_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
404const HOOK_LOCK_RETRY: std::time::Duration = std::time::Duration::from_millis(25);
406
407#[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#[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
459pub 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
495pub(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
509pub(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
516fn create_body(path: &Path) -> std::io::Result<std::fs::File> {
518 private_options().write(true).create_new(true).open(path)
519}
520
521pub 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
538fn recorded(path: &Path, plan: &str) -> std::io::Result<Recorded> {
542 let rows = read_rows(path)?;
543 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
562fn 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
585fn 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 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 let before = seen.clone();
639 let first_round_sha = first_on_record.unwrap_or_else(|| shas[0].to_string());
640
641 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 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 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 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 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
839fn 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#[derive(Default, Debug, PartialEq)]
873pub struct Stat {
874 pub reviews: u64,
875 pub found: [u64; 4],
876 pub accepted: [u64; 4],
877 pub unresolved: [u64; 4],
879 pub predating: u64,
882 pub verdict_without_findings: u64,
885}
886
887impl Stat {
888 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 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
911struct Settled<'a> {
914 dispositions: std::collections::BTreeMap<(&'a str, &'a str, u64), &'a str>,
916 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 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
989pub 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#[derive(Debug, PartialEq, Eq, Clone)]
1038pub struct Skip {
1039 pub ts: u64,
1040 pub sha: String,
1042 pub plan: String,
1043}
1044
1045pub 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 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
1069pub 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#[derive(Clone, Copy, PartialEq, Eq)]
1091pub enum Format {
1092 Text,
1093 Json,
1094}
1095
1096pub fn stats_report(rows: &[serde_json::Value], model: Option<&str>, format: Format) -> String {
1098 stats_report_with(rows, &[], model, format)
1099}
1100
1101pub 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
1183fn 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 #[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}