Skip to main content

amont_agent_process/
plan_review.rs

1//! Every plan passes its review panel before the person sees it
2//! (ADR-0022, `work.plan-review-panel`).
3//!
4//! The panel follows what the plan touches: an expert in each language and a
5//! staff backend engineer always; a platform engineer for ops; Unix and TUI
6//! experts for a command line; a product owner and an architect for a large
7//! repository; four interface roles for a UI. The skill `/plan-review` runs
8//! it. This module is the check at `ExitPlanMode` that it did.
9//!
10//! ## What is compared
11//!
12//! * **The canonical body** ([`canonical`]): the plan without its review
13//!   section, its full reviews and its machine comment. Writing the review's
14//!   results into the plan therefore never makes the review stale.
15//! * **The areas**, read from each repository named in the machine comment
16//!   (`repos=`) on its default tree — never from keywords in the plan, which
17//!   matched 74% of plans for a UI regex against about 8 real UI plans — plus
18//!   what the plan declares it adds (`adds=`).
19//! * **The bindings** ([`bindings`]): each `plan-review-<role>` agent this
20//!   session launched with a review block for this plan (`<<<PLAN path=…
21//!   sha=…>>>`, printed by `amont-agent plan-sha --block`) and that
22//!   COMPLETED, read only from the structured fields Claude Code writes. The
23//!   text of a notification echoed inside a tool result is never read: that
24//!   is a forgery path, and this repository's author found four in one
25//!   session.
26//!
27//! The declared sha stops the forgetful model — a review skipped, or run on
28//! an older body. It does not stop a model set on declaring a sha for a body
29//! it did not have reviewed; the person accepted that (2026-09-29).
30//!
31//! ## Who is answered
32//!
33//! A cause the model controls (no `repos=`, no review section, missing
34//! reviews) is refused, and counted: after two refusals of the same plan the
35//! call goes to the PERSON as `ask`, so a model cannot loop and cannot slip
36//! through. A cause outside its control (no transcript, a git read that
37//! failed) goes to the person at once. A pass is remembered as the plan's
38//! baseline, so a change the person asks for needs only its delta: the
39//! backend reviewer plus the reviewers of any area that is new.
40
41use std::collections::{BTreeSet, HashMap};
42use std::io::BufRead;
43use std::path::{Path, PathBuf};
44
45pub(crate) use crate::plan_text::is_machine_comment;
46use crate::plan_text::{canonical, shas, Fence, Shas, REVIEW_SECTION};
47use amont_agent_guard::decision::{self, Decision};
48use amont_agent_guard::journal;
49use amont_agent_guard::payload::PlanExit;
50use amont_agent_guard::rules::{plan_review_panel::RULE, Stance};
51use amont_agent_guard::sha256::{hex, sha256};
52
53/// Opens a review block in a reviewer's prompt.
54pub const BLOCK_OPEN: &str = "<<<PLAN ";
55const BLOCK_CLOSE: &str = ">>>";
56pub(crate) const AGENT_PREFIX: &str = "plan-review-";
57/// Refusals of one plan before the call goes to the person instead.
58const REFUSALS_BEFORE_ASK: u32 = 2;
59/// Repository size, in files or in own commits, that adds a product owner
60/// and an architect.
61const LARGE: usize = 500;
62/// Blobs read to classify one repository.
63const MAX_BLOBS: usize = 200;
64/// Where `repos=` names resolve: `git config amont.agent.plan-review.<name>.path`
65/// for a repository kept elsewhere (dotfiles under `~/.local/share/chezmoi`),
66/// else `$AMONT_AGENT_PLAN_ROOT/<name>`, else `~/Developer/Perso/<name>`.
67pub const ENV_ROOT: &str = "AMONT_AGENT_PLAN_ROOT";
68
69/// Whether a review or a baseline at `seen` is of this body.
70fn of_body(seen: &str, sha: &str, legacy: &str) -> bool {
71    seen == sha || seen == legacy
72}
73
74/// The plan's H1 outside any fence, which names it across sessions: the
75/// same plan presented from a new session gets a new random file name.
76fn title(text: &str) -> Option<String> {
77    let mut fence = Fence::default();
78    canonical(text).lines().find_map(|l| {
79        fence.h2(l);
80        if fence.open.is_some() {
81            return None;
82        }
83        l.strip_prefix("# ").map(|t| t.trim().to_string())
84    })
85}
86
87/// The review block a reviewer's prompt carries.
88pub fn block(path: &Path, sha: &str, lang: Option<&str>) -> String {
89    let lang = lang.map(|l| format!(" lang={l}")).unwrap_or_default();
90    format!(
91        "{BLOCK_OPEN}path={} sha={sha}{lang}{BLOCK_CLOSE}",
92        path.display()
93    )
94}
95
96/// The canonical path of a plan: symlinks resolved (`~/.claude` is often a
97/// dotfiles link), or the path as given when it cannot be resolved.
98pub fn real(path: &Path) -> PathBuf {
99    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
100}
101
102// ---------------------------------------------------------------- the plan's own claims
103
104/// What the machine comment says.
105#[derive(Debug, Default, PartialEq)]
106pub struct Meta {
107    pub repos: Vec<String>,
108    pub adds: Vec<String>,
109}
110
111/// The machine comment on the plan's last non-empty line. `Err` names what
112/// is wrong with it, in words the model can act on.
113pub fn meta(text: &str) -> Result<Meta, String> {
114    let line = text
115        .lines()
116        .rev()
117        .find(|l| !l.trim().is_empty())
118        .filter(|l| is_machine_comment(l))
119        .ok_or(
120            "the plan's last line is not its machine comment (`<!-- panel: repos=<repo>,… -->`)",
121        )?;
122    let inner = line
123        .trim()
124        .trim_start_matches("<!-- panel:")
125        .trim_end_matches("-->");
126    let mut m = Meta::default();
127    for tok in inner.split_whitespace() {
128        let list = |v: &str| -> Vec<String> {
129            v.split(',')
130                .map(str::trim)
131                .filter(|s| !s.is_empty())
132                .map(str::to_string)
133                .collect()
134        };
135        if let Some(v) = tok.strip_prefix("repos=") {
136            m.repos = list(v);
137        } else if let Some(v) = tok.strip_prefix("adds=") {
138            m.adds = list(v);
139        }
140    }
141    if m.repos.is_empty() {
142        return Err("the machine comment names no repository (`repos=<repo>,…`)".into());
143    }
144    if let Some(bad) = m.repos.iter().find(|n| !valid_name(n)) {
145        return Err(format!(
146            "`repos=` names `{bad}`, which is not a repository name"
147        ));
148    }
149    if let Some(bad) = m.adds.iter().find(|a| !valid_class(a)) {
150        return Err(format!(
151            "`adds=` names `{bad}`; it takes cli, ui, ops, large or lang:<language>"
152        ));
153    }
154    Ok(m)
155}
156
157/// A directory name under the root, and nothing that could leave it.
158pub(crate) fn valid_name(n: &str) -> bool {
159    !n.is_empty()
160        && n != "."
161        && !n.contains("..")
162        && !n.starts_with('-')
163        && !n.contains(['/', '\\', '\0', '\n', '\r'])
164}
165
166fn valid_class(c: &str) -> bool {
167    match c.strip_prefix("lang:") {
168        Some(l) => {
169            !l.is_empty()
170                && l.chars()
171                    .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || "+#-".contains(ch))
172        }
173        None => matches!(c, "cli" | "ui" | "ops" | "large"),
174    }
175}
176
177/// The review section: is it there, and does it carry a skip the person
178/// asked for (`⚠ unreviewed: …`)?
179#[derive(Debug, PartialEq)]
180pub struct Section {
181    pub present: bool,
182    pub unreviewed: bool,
183}
184
185pub fn section(text: &str) -> Section {
186    let mut fence = Fence::default();
187    let mut inside = false;
188    let mut s = Section {
189        present: false,
190        unreviewed: false,
191    };
192    for line in text.lines() {
193        if let Some(title) = fence.h2(line) {
194            inside = title == REVIEW_SECTION;
195            s.present |= inside;
196            continue;
197        }
198        if inside {
199            let t = line.trim_start().trim_start_matches(['-', '*', ' ']);
200            if t.starts_with('⚠') && t.to_lowercase().contains("unreviewed") {
201                s.unreviewed = true;
202            }
203        }
204    }
205    s
206}
207
208// ---------------------------------------------------------------- areas and roles
209
210/// The reviewers an area set requires. Always the staff backend engineer.
211pub fn roles(classes: &BTreeSet<String>) -> BTreeSet<String> {
212    let mut r = BTreeSet::from(["backend".to_string()]);
213    for c in classes {
214        let add: &[&str] = match c.as_str() {
215            "ops" => &["platform"],
216            "cli" => &["unix", "tui"],
217            "large" => &["po", "architect"],
218            "ui" => &["react", "ui-design", "ux-research", "game-ux"],
219            _ => &[],
220        };
221        r.extend(add.iter().map(|s| s.to_string()));
222        if c.starts_with("lang:") {
223            r.insert(c.clone());
224        }
225    }
226    r
227}
228
229/// The areas that make the panel ask for `role`: [`roles`] read backwards.
230/// `backend` is always asked, so it answers for every area (`*`). The
231/// outcome ledger records it so a later reader can tell a reviewer that
232/// had nothing to say about an area from one that was never asked.
233pub fn remit(role: &str) -> BTreeSet<String> {
234    let area: &[&str] = match role {
235        "unix" | "tui" => &["cli"],
236        "platform" => &["ops"],
237        "po" | "architect" => &["large"],
238        "react" | "ui-design" | "ux-research" | "game-ux" => &["ui"],
239        "backend" => &["*"],
240        r if r.starts_with("lang:") => &[r],
241        _ => &[],
242    };
243    area.iter().map(|s| s.to_string()).collect()
244}
245
246/// Where one `repos=` name lives. The config key's subsection keeps the
247/// name's case, so any valid name can be configured.
248pub fn locate(root: &Path, name: &str) -> PathBuf {
249    let key = format!("amont.agent.plan-review.{name}.path");
250    match amont_agent_guard::git::output(&["config", "--get", "--type=path", &key]) {
251        Some(o) if o.code == 0 && !o.stdout.is_empty() => PathBuf::from(o.stdout),
252        _ => root.join(name),
253    }
254}
255
256/// The directory `repos=` names resolve under.
257pub fn root() -> Option<PathBuf> {
258    if let Some(r) = std::env::var_os(ENV_ROOT).filter(|r| !r.is_empty()) {
259        return Some(PathBuf::from(r));
260    }
261    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
262    Some(PathBuf::from(home).join("Developer").join("Perso"))
263}
264
265/// One repository's areas. `Ok(None)`: no repository there yet — a plan
266/// that creates one says what it adds. `Err`: git could not be asked.
267pub fn repo_areas(dir: &Path) -> Result<Option<BTreeSet<String>>, String> {
268    use amont_agent_guard::git;
269    let io = |e: std::io::Error| format!("git could not be run in {}: {e}", dir.display());
270    if !dir.is_dir() {
271        return Ok(None);
272    }
273    // Fails outside a repository, so it answers both questions in one
274    // process: each git call costs 30–120 ms on a machine running agents.
275    let Some(shallow) =
276        git::bytes_in(dir, &["rev-parse", "--is-shallow-repository"], None).map_err(io)?
277    else {
278        return Ok(None);
279    };
280    let shallow = String::from_utf8_lossy(&shallow).trim() == "true";
281    // The default branch as last fetched, whichever checkout the session
282    // sits in. Never a fetch: this runs while the person waits.
283    let mut rev = None;
284    for r in [
285        "refs/remotes/origin/HEAD",
286        "origin/main",
287        "origin/master",
288        "HEAD",
289    ] {
290        let spec = format!("{r}^{{tree}}");
291        if let Some(out) = git::bytes_in(
292            dir,
293            &[
294                "rev-parse",
295                "--verify",
296                "--quiet",
297                "--end-of-options",
298                &spec,
299            ],
300            None,
301        )
302        .map_err(io)?
303        {
304            rev = Some((r, String::from_utf8_lossy(&out).trim().to_string()));
305            break;
306        }
307    }
308    let Some((commit, tree)) = rev else {
309        // Unborn: an empty tree, whose only area is what the plan adds.
310        return Ok(Some(BTreeSet::new()));
311    };
312    let paths: Vec<String> = git::ls_tree_z(dir, &tree)
313        .map_err(io)?
314        .ok_or_else(|| format!("git ls-tree failed in {}", dir.display()))?
315        .into_iter()
316        .map(|p| String::from_utf8_lossy(&p).into_owned())
317        .collect();
318
319    let wanted = candidates(&paths);
320    let names: Vec<String> = wanted.iter().map(|p| format!("{tree}:{p}")).collect();
321    let blobs: HashMap<String, String> = git::cat_file_batch(dir, &names)
322        .map_err(io)?
323        .ok_or_else(|| format!("git cat-file failed in {}", dir.display()))?
324        .into_iter()
325        .map(|(n, b)| {
326            let p = n.split_once(':').map(|(_, p)| p.to_string()).unwrap_or(n);
327            (p, String::from_utf8_lossy(&b).into_owned())
328        })
329        .collect();
330
331    let commits = if shallow {
332        None
333    } else {
334        own_commits(dir, commit)
335    };
336    Ok(Some(classify(&paths, &blobs, commits)))
337}
338
339/// Commits of this repository's own (a shallow clone's are not counted at
340/// all). A fork's are counted past its upstream, whose history it inherits.
341fn own_commits(dir: &Path, rev: &str) -> Option<usize> {
342    let ask = |args: &[&str]| {
343        amont_agent_guard::git::bytes_in(dir, args, None)
344            .ok()
345            .flatten()
346            .map(|b| String::from_utf8_lossy(&b).trim().to_string())
347    };
348    let refs = ask(&[
349        "for-each-ref",
350        "--format=%(refname)",
351        "refs/remotes/upstream/",
352    ])
353    .unwrap_or_default();
354    let upstream = [
355        "refs/remotes/upstream/HEAD",
356        "refs/remotes/upstream/main",
357        "refs/remotes/upstream/master",
358    ]
359    .into_iter()
360    .find(|u| refs.lines().any(|l| l == *u));
361    let mut args = vec!["rev-list", "--count", rev];
362    if let Some(u) = upstream {
363        args.extend(["--not", u]);
364    }
365    ask(&args)?.parse().ok()
366}
367
368/// Vendored, generated and lock files: not the repository's own size, and
369/// not evidence of its languages.
370fn denied(path: &str) -> bool {
371    let dirs = ["vendor", "node_modules", "third_party", "dist"];
372    let mut parts = path.split('/').peekable();
373    while let Some(p) = parts.next() {
374        if parts.peek().is_some() && dirs.contains(&p) {
375            return true;
376        }
377    }
378    let base = basename(path);
379    base.ends_with(".lock") || base.contains("-lock.")
380}
381
382fn basename(path: &str) -> &str {
383    path.rsplit('/').next().unwrap_or(path)
384}
385
386/// The files worth opening, most telling first, at most [`MAX_BLOBS`].
387fn candidates(paths: &[String]) -> Vec<String> {
388    let own: Vec<&String> = paths.iter().filter(|p| !denied(p)).collect();
389    let named = |n: &str| -> Vec<String> {
390        own.iter()
391            .filter(|p| basename(p) == n)
392            .map(|p| (*p).clone())
393            .collect()
394    };
395    let mut out: Vec<String> = Vec::new();
396    out.extend(
397        own.iter()
398            .filter(|p| p.as_str() == ".adr.yaml")
399            .map(|p| (*p).clone()),
400    );
401    out.extend(named("package.json"));
402    out.extend(named("Cargo.toml"));
403    let ops_by_name = own.iter().any(|p| ops_name(p));
404    if own.iter().any(|p| basename(p) == "go.mod") {
405        let mut go: Vec<String> = own
406            .iter()
407            .filter(|p| p.ends_with(".go") && !p.ends_with("_test.go"))
408            .map(|p| (*p).clone())
409            .collect();
410        go.sort_by_key(|p| (basename(p) != "main.go", !p.contains("cmd/")));
411        out.extend(go);
412    }
413    if !ops_by_name {
414        out.extend(
415            own.iter()
416                .filter(|p| p.ends_with(".yaml") || p.ends_with(".yml"))
417                .map(|p| (*p).clone()),
418        );
419    }
420    out.truncate(MAX_BLOBS);
421    out
422}
423
424fn ops_name(path: &str) -> bool {
425    matches!(
426        basename(path),
427        "kustomization.yaml" | "kustomization.yml" | "Kustomization" | "Chart.yaml"
428    ) || path.ends_with(".tf")
429}
430
431/// The areas of one repository, from its tree and the files read from it.
432/// Pure, so every signal is tested without a repository.
433pub fn classify(
434    paths: &[String],
435    blobs: &HashMap<String, String>,
436    commits: Option<usize>,
437) -> BTreeSet<String> {
438    let own: Vec<&String> = paths.iter().filter(|p| !denied(p)).collect();
439    let mut out = BTreeSet::new();
440    for p in &own {
441        let lang = match basename(p) {
442            "Cargo.toml" => "rust",
443            "go.mod" => "go",
444            "package.json" | "tsconfig.json" => "typescript",
445            "pyproject.toml" => "python",
446            _ => continue,
447        };
448        out.insert(format!("lang:{lang}"));
449    }
450    let traits = blobs
451        .get(".adr.yaml")
452        .map(|t| adr_traits(t))
453        .unwrap_or_default();
454    let blob = |p: &str| blobs.get(p).map(String::as_str);
455
456    let ui = traits.contains("ui")
457        || own
458            .iter()
459            .filter(|p| basename(p) == "package.json")
460            .filter_map(|p| blob(p))
461            .any(crate::plan_text::package_is_ui);
462    let ops = own.iter().any(|p| ops_name(p))
463        || own
464            .iter()
465            .filter(|p| p.ends_with(".yaml") || p.ends_with(".yml"))
466            .filter_map(|p| blob(p))
467            .any(|t| {
468                t.lines()
469                    .any(|l| matches!(l.trim(), "kind: HelmRelease" | "kind: Kustomization"))
470            });
471    let cli = traits.contains("cli")
472        || own
473            .iter()
474            .any(|p| p.as_str() == "src/main.rs" || p.ends_with("/src/main.rs"))
475        || own.iter().any(|p| {
476            let t = blob(p).unwrap_or("");
477            match basename(p) {
478                "Cargo.toml" => t.contains("[[bin]]"),
479                "package.json" => serde_json::from_str::<serde_json::Value>(t)
480                    .ok()
481                    .is_some_and(|v| v.get("bin").is_some()),
482                b if b.ends_with(".go") => t.lines().any(|l| l.trim() == "package main"),
483                _ => false,
484            }
485        });
486    let large = own.len() >= LARGE || commits.is_some_and(|c| c >= LARGE);
487    for (on, class) in [(ui, "ui"), (ops, "ops"), (cli, "cli"), (large, "large")] {
488        if on {
489            out.insert(class.to_string());
490        }
491    }
492    out
493}
494
495/// The traits `.adr.yaml` declares under `areas:` (`"glob": [cli, ui]`).
496fn adr_traits(text: &str) -> BTreeSet<String> {
497    let mut inside = false;
498    let mut out = BTreeSet::new();
499    for line in text.lines() {
500        if line.trim().is_empty() || line.trim_start().starts_with('#') {
501            continue;
502        }
503        if !line.starts_with([' ', '\t']) {
504            inside = line.trim_end() == "areas:";
505            continue;
506        }
507        if !inside {
508            continue;
509        }
510        if let (Some(a), Some(b)) = (line.rfind('['), line.rfind(']')) {
511            if a < b {
512                for t in line[a + 1..b].split(',') {
513                    let t = t.trim().trim_matches(['"', '\'']);
514                    if !t.is_empty() {
515                        out.insert(t.to_string());
516                    }
517                }
518            }
519        }
520    }
521    out
522}
523
524// ---------------------------------------------------------------- the transcript
525
526/// One completed review of one body.
527#[derive(Debug, Clone, PartialEq)]
528pub struct Binding {
529    pub role: String,
530    pub path: String,
531    pub sha: String,
532}
533
534/// The completed reviews in a transcript, streamed: the review blocks of
535/// every `plan-review-*` launch that completed. See [`completed_agents`].
536pub fn bindings(reader: impl BufRead) -> Vec<Binding> {
537    completed_agents(reader, AGENT_PREFIX)
538        .into_iter()
539        .flat_map(|c| parse_blocks(&c.agent, &c.prompt))
540        .collect()
541}
542
543/// One agent launch that Claude Code recorded as completed: the agent type,
544/// the prompt it was given, and the text of its result (a foreground
545/// result's text parts; a background notification's `<result>` body, or the
546/// whole notification when it has none).
547pub struct Completed {
548    pub agent: String,
549    pub prompt: String,
550    pub result: String,
551    /// The agent's id, from the launch's result or its notification's
552    /// `<task-id>`. What the outcome ledger dedupes on.
553    pub agent_id: Option<String>,
554    /// `toolUseResult.resolvedModel` of the launch.
555    pub model: Option<String>,
556    /// Tokens the agent used: `totalTokens`, or `<subagent_tokens>`.
557    pub tokens: Option<u64>,
558    /// Wall time: `totalDurationMs`, or `<duration_ms>`.
559    pub duration_ms: Option<u64>,
560}
561
562/// What a launch's result and notification say about the agent itself.
563#[derive(Default, Clone)]
564struct Reported {
565    agent_id: Option<String>,
566    model: Option<String>,
567    tokens: Option<u64>,
568    duration_ms: Option<u64>,
569}
570
571/// The completed launches of agents whose type starts with `prefix`, in the
572/// order they were launched.
573///
574/// A line is parsed only when it could matter: it names such an agent, or
575/// it is a task notification. A launch counts only when it completed, read
576/// from structured fields: a foreground result's `toolUseResult` (`status:
577/// completed`, `agentType` the agent launched), or a background
578/// notification Claude Code itself queued (a `user` entry with
579/// `origin.kind: task-notification`, or an `attachment` whose
580/// `queued_command` has `commandMode: task-notification`). Nothing inside a
581/// tool result or assistant text is read as a notification.
582pub fn completed_agents(reader: impl BufRead, prefix: &str) -> Vec<Completed> {
583    scan(reader, prefix, false)
584}
585
586/// [`completed_agents`], plus every round a launched agent was RESUMED for
587/// with `SendMessage`, each as a completion of its own: the agent launched,
588/// the message as its prompt, the round's notification as its result.
589///
590/// The shape, from a real session (2026-10-08,
591/// `tests/fixtures/sendmessage-resume.jsonl`): the launch's result carries
592/// `toolUseResult.agentId`; a `SendMessage` whose `input.to` is that id is
593/// answered with `toolUseResult.resumedAgentId` = the same id; and the
594/// round's task notification carries `<tool-use-id>` = THAT SendMessage's id
595/// and `<task-id>` = the agent id, so the launch's own result is never
596/// overwritten. A round counts only when all three agree, and only on a
597/// structured notification, as a launch does.
598///
599/// Only the implementation review reads this: the plan-review panel counts
600/// fresh launches alone.
601pub fn completed_agents_resumable(reader: impl BufRead, prefix: &str) -> Vec<Completed> {
602    scan(reader, prefix, true)
603}
604
605fn scan(reader: impl BufRead, prefix: &str, resumable: bool) -> Vec<Completed> {
606    // id → (agent, prompt), and the launch order, since a HashMap has none.
607    // A resumed round is keyed by its SendMessage's id.
608    let mut launched: HashMap<String, (String, String)> = HashMap::new();
609    let mut order: Vec<String> = Vec::new();
610    let mut done: HashMap<String, String> = HashMap::new();
611    // launch id → what the agent reported about itself.
612    let mut facts: HashMap<String, Reported> = HashMap::new();
613    // agentId → the agent type it was launched as.
614    let mut agents: HashMap<String, String> = HashMap::new();
615    // SendMessage id → (to, message), until its result says it resumed.
616    let mut sends: HashMap<String, (String, String, usize)> = HashMap::new();
617    // A resumed round's id → the agentId its notification must carry.
618    let mut rounds: HashMap<String, String> = HashMap::new();
619    // Launch or round id → the transcript line it started on.
620    let mut starts: HashMap<String, usize> = HashMap::new();
621    // (agentId, line, report) of every hand-back (see `handed_back`).
622    let mut handbacks: Vec<(String, usize, String)> = Vec::new();
623    for (at, line) in reader.split(b'\n').enumerate() {
624        let Ok(raw) = line else { break };
625        let Ok(text) = std::str::from_utf8(&raw) else {
626            continue;
627        };
628        let review = text.contains(prefix)
629            // A background launch's result names its agent by `agentId`
630            // alone, and the outcome ledger needs that id and model.
631            || text.contains("agentId")
632            || (resumable && (text.contains("SendMessage") || text.contains("AgentId")));
633        let notice = text.contains("task-notification");
634        let handback = text.contains("\"handback\"");
635        if !review && !notice && !handback {
636            continue;
637        }
638        // A truncated last line is a session being written right now.
639        let Ok(e) = serde_json::from_str::<serde_json::Value>(text) else {
640            continue;
641        };
642        let content = e
643            .get("message")
644            .and_then(|m| m.get("content"))
645            .and_then(|c| c.as_array());
646        let mut notified: Vec<Notification> = Vec::new();
647        match e.get("type").and_then(|t| t.as_str()) {
648            Some("assistant") if review => {
649                for c in content.into_iter().flatten() {
650                    if c.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
651                        continue;
652                    }
653                    let Some(id) = c.get("id").and_then(|i| i.as_str()) else {
654                        continue;
655                    };
656                    let input = c.get("input");
657                    let field = |k: &str| input.and_then(|i| i.get(k)).and_then(|v| v.as_str());
658                    match c.get("name").and_then(|n| n.as_str()) {
659                        Some("Agent" | "Task") => {
660                            let Some(agent) =
661                                field("subagent_type").filter(|s| s.starts_with(prefix))
662                            else {
663                                continue;
664                            };
665                            let prompt = field("prompt").unwrap_or("");
666                            if !launched.contains_key(id) {
667                                order.push(id.to_string());
668                                starts.insert(id.to_string(), at);
669                                launched.insert(
670                                    id.to_string(),
671                                    (agent.to_string(), prompt.to_string()),
672                                );
673                            }
674                        }
675                        Some("SendMessage") if resumable => {
676                            if let (Some(to), Some(message)) = (field("to"), field("message")) {
677                                sends.insert(
678                                    id.to_string(),
679                                    (to.to_string(), message.to_string(), at),
680                                );
681                            }
682                        }
683                        _ => {}
684                    }
685                }
686            }
687            Some("user") => {
688                if review {
689                    let result = e.get("toolUseResult");
690                    let field = |k: &str| result.and_then(|r| r.get(k)).and_then(|s| s.as_str());
691                    let completed = field("status") == Some("completed");
692                    let agent_type = field("agentType");
693                    let number = |k: &str| result.and_then(|r| r.get(k)).and_then(|v| v.as_u64());
694                    for c in content.into_iter().flatten() {
695                        if c.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
696                            continue;
697                        }
698                        let Some(id) = c.get("tool_use_id").and_then(|i| i.as_str()) else {
699                            continue;
700                        };
701                        if let Some((agent, _)) = launched.get(id) {
702                            let f = facts.entry(id.to_string()).or_default();
703                            if let Some(v) = field("agentId") {
704                                f.agent_id = Some(v.to_string());
705                            }
706                            if let Some(v) = field("resolvedModel") {
707                                f.model = Some(v.to_string());
708                            }
709                            f.tokens = number("totalTokens").or(f.tokens);
710                            f.duration_ms = number("totalDurationMs").or(f.duration_ms);
711                            if completed && agent_type == Some(agent.as_str()) {
712                                done.insert(id.to_string(), text_of(c.get("content")));
713                            }
714                            if resumable {
715                                if let Some(agent_id) = field("agentId") {
716                                    agents.insert(agent_id.to_string(), agent.clone());
717                                }
718                            }
719                        } else if let Some((to, message, sent_at)) = sends.remove(id) {
720                            let Some(agent) = agents.get(&to) else {
721                                continue;
722                            };
723                            if field("resumedAgentId") != Some(to.as_str()) {
724                                continue;
725                            }
726                            order.push(id.to_string());
727                            starts.insert(id.to_string(), sent_at);
728                            launched.insert(id.to_string(), (agent.clone(), message));
729                            rounds.insert(id.to_string(), to);
730                        }
731                    }
732                }
733                if let Some((agent_id, body)) = handed_back(&e) {
734                    handbacks.push((agent_id, at, body));
735                }
736                let origin = e
737                    .get("origin")
738                    .and_then(|o| o.get("kind"))
739                    .and_then(|k| k.as_str());
740                if notice && origin == Some("task-notification") {
741                    let body = match e.get("message").and_then(|m| m.get("content")) {
742                        Some(serde_json::Value::String(s)) => s.clone(),
743                        Some(serde_json::Value::Array(a)) => a
744                            .iter()
745                            .filter_map(|x| x.get("text").and_then(|t| t.as_str()))
746                            .collect::<Vec<_>>()
747                            .join("\n"),
748                        _ => String::new(),
749                    };
750                    notified = notifications(&body);
751                }
752            }
753            Some("attachment") if notice => {
754                let a = e.get("attachment");
755                let field = |k: &str| a.and_then(|a| a.get(k)).and_then(|v| v.as_str());
756                if field("type") == Some("queued_command")
757                    && field("commandMode") == Some("task-notification")
758                {
759                    notified = notifications(field("prompt").unwrap_or(""));
760                }
761            }
762            _ => {}
763        }
764        for n in notified {
765            // A resumed round completes only on its own agent's notification.
766            if rounds
767                .get(&n.tool_use_id)
768                .is_some_and(|agent_id| *agent_id != n.task_id)
769            {
770                continue;
771            }
772            let f = facts.entry(n.tool_use_id.clone()).or_default();
773            if !n.task_id.is_empty() {
774                f.agent_id.get_or_insert(n.task_id);
775            }
776            f.tokens = n.tokens.or(f.tokens);
777            f.duration_ms = n.duration_ms.or(f.duration_ms);
778            done.insert(n.tool_use_id, n.result);
779        }
780    }
781    // Each launch or round's agent, and where it started: a hand-back belongs
782    // to the round of its agent that started last before it.
783    let agent_of: HashMap<String, String> = order
784        .iter()
785        .filter_map(|id| {
786            let a = rounds
787                .get(id)
788                .cloned()
789                .or_else(|| facts.get(id).and_then(|f| f.agent_id.clone()))?;
790            Some((id.clone(), a))
791        })
792        .collect();
793    let report_of = |id: &str| -> Option<String> {
794        let agent = agent_of.get(id)?;
795        let start = *starts.get(id)?;
796        let next = agent_of
797            .iter()
798            .filter(|(other, a)| *a == agent && other.as_str() != id)
799            .filter_map(|(other, _)| starts.get(other).copied())
800            .filter(|&s| s > start)
801            .min()
802            .unwrap_or(usize::MAX);
803        handbacks
804            .iter()
805            .filter(|(a, at, _)| a == agent && *at > start && *at < next)
806            .map(|(_, _, body)| body.clone())
807            .next_back()
808    };
809    order
810        .into_iter()
811        .filter_map(|id| {
812            let mut result = done.remove(&id)?;
813            // The completion only points at the report: take the hand-back
814            // of this round. None found leaves the pointer, which carries no
815            // verdict.
816            if result.contains(HANDED_BACK) {
817                if let Some(report) = report_of(&id) {
818                    result = report;
819                }
820            }
821            let (agent, prompt) = launched.remove(&id)?;
822            let f = facts.remove(&id).unwrap_or_default();
823            Some(Completed {
824                agent,
825                prompt,
826                result,
827                agent_id: rounds.remove(&id).or(f.agent_id),
828                model: f.model,
829                tokens: f.tokens,
830                duration_ms: f.duration_ms,
831            })
832        })
833        .collect()
834}
835
836/// What a completion says when its agent handed its report back as a
837/// message instead of returning it.
838const HANDED_BACK: &str = "report was delivered to you as a message";
839
840/// A subagent's hand-back, as Claude Code records it: a `user` entry whose
841/// `origin` it wrote itself (`kind: peer`, `handback: true`), with the agent's
842/// id in `senderTaskId` and its report in `body`. Read from those structured
843/// fields only, as a notification is; the message text is never parsed.
844fn handed_back(e: &serde_json::Value) -> Option<(String, String)> {
845    let o = e.get("origin")?;
846    let s = |k: &str| o.get(k).and_then(|v| v.as_str());
847    if s("kind") != Some("peer") || o.get("handback").and_then(|v| v.as_bool()) != Some(true) {
848        return None;
849    }
850    Some((s("senderTaskId")?.to_string(), s("body")?.to_string()))
851}
852
853/// The text of a tool result's content: a string, or its text parts joined.
854fn text_of(content: Option<&serde_json::Value>) -> String {
855    match content {
856        Some(serde_json::Value::String(s)) => s.clone(),
857        Some(serde_json::Value::Array(a)) => a
858            .iter()
859            .filter_map(|x| x.get("text").and_then(|t| t.as_str()))
860            .collect::<Vec<_>>()
861            .join("\n"),
862        _ => String::new(),
863    }
864}
865
866/// The tool-use ids a task notification reports `completed`.
867#[cfg(test)]
868fn completed_ids(text: &str) -> Vec<String> {
869    completed_results(text)
870        .into_iter()
871        .map(|(id, _)| id)
872        .collect()
873}
874
875/// One `completed` entry of a task notification.
876pub struct Notification {
877    /// `<tool-use-id>`: the launch, or the `SendMessage` that resumed it.
878    pub tool_use_id: String,
879    /// `<task-id>`: the agent's id; empty when the notification has none.
880    pub task_id: String,
881    /// The `<result>` text (the whole chunk when it carries none).
882    pub result: String,
883    /// `<subagent_tokens>` of the `<usage>` block, when it has one.
884    pub tokens: Option<u64>,
885    /// `<duration_ms>` of the `<usage>` block, when it has one.
886    pub duration_ms: Option<u64>,
887}
888
889/// The `completed` entries of a task notification.
890pub fn notifications(text: &str) -> Vec<Notification> {
891    let tag = |chunk: &str, name: &str| -> Option<String> {
892        let open = format!("<{name}>");
893        let close = format!("</{name}>");
894        let a = chunk.find(&open)? + open.len();
895        let b = chunk[a..].find(&close)? + a;
896        Some(chunk[a..b].trim().to_string())
897    };
898    text.split("<task-notification>")
899        .skip(1)
900        .filter(|c| tag(c, "status").as_deref() == Some("completed"))
901        .filter_map(|c| {
902            Some(Notification {
903                tool_use_id: tag(c, "tool-use-id")?,
904                task_id: tag(c, "task-id").unwrap_or_default(),
905                result: tag(c, "result").unwrap_or_else(|| c.trim().to_string()),
906                tokens: tag(c, "subagent_tokens").and_then(|v| v.parse().ok()),
907                duration_ms: tag(c, "duration_ms").and_then(|v| v.parse().ok()),
908            })
909        })
910        .collect()
911}
912
913/// The tool-use ids a task notification reports `completed`, each with the
914/// text of its `<result>` (the whole chunk when it carries none).
915#[cfg(test)]
916fn completed_results(text: &str) -> Vec<(String, String)> {
917    notifications(text)
918        .into_iter()
919        .map(|n| (n.tool_use_id, n.result))
920        .collect()
921}
922
923/// The review blocks in one reviewer's prompt, as bindings of its role.
924pub(crate) fn parse_blocks(agent: &str, prompt: &str) -> Vec<Binding> {
925    let role = agent.trim_start_matches(AGENT_PREFIX);
926    let mut out = Vec::new();
927    let mut rest = prompt;
928    while let Some(at) = rest.find(BLOCK_OPEN) {
929        rest = &rest[at + BLOCK_OPEN.len()..];
930        let Some(end) = rest.find(BLOCK_CLOSE) else {
931            break;
932        };
933        let inner = &rest[..end];
934        rest = &rest[end..];
935        let Some(body) = inner.strip_prefix("path=") else {
936            continue;
937        };
938        let Some(split) = body.rfind(" sha=") else {
939            continue;
940        };
941        let path = body[..split].to_string();
942        let mut tail = body[split + 5..].split_whitespace();
943        let Some(sha) = tail.next().filter(|s| s.len() == 64) else {
944            continue;
945        };
946        let lang = tail.find_map(|t| t.strip_prefix("lang="));
947        let role = match (role, lang) {
948            ("language", Some(l)) => format!("lang:{l}"),
949            ("language", None) => continue,
950            (r, _) => r.to_string(),
951        };
952        out.push(Binding {
953            role,
954            path,
955            sha: sha.to_ascii_lowercase(),
956        });
957    }
958    out
959}
960
961// ---------------------------------------------------------------- the judgement
962
963/// What the plan was last accepted with.
964#[derive(Debug, Clone, PartialEq, Default)]
965pub struct Baseline {
966    pub sha: String,
967    pub roles: BTreeSet<String>,
968    pub classes: BTreeSet<String>,
969}
970
971pub struct Facts<'a> {
972    pub sha: &'a str,
973    /// LEGACY: the byte sha, accepted alongside `sha`.
974    pub legacy: &'a str,
975    pub path: &'a str,
976    pub classes: &'a BTreeSet<String>,
977    pub bindings: &'a [Binding],
978    pub baseline: Option<&'a Baseline>,
979}
980
981#[derive(Debug, PartialEq)]
982pub enum Outcome {
983    /// Every required review bound; the roles that did.
984    Pass(BTreeSet<String>),
985    /// Roles never reviewed for this plan, and roles whose review is of an
986    /// older body.
987    Short {
988        missing: BTreeSet<String>,
989        stale: BTreeSet<String>,
990    },
991}
992
993/// Pure: whether the reviews in hand cover this plan.
994///
995/// * No baseline: the whole panel, each role bound to this plan's path, and
996///   backend bound to THIS body — round-1 edits change the body, so backend
997///   always re-runs on the final one.
998/// * A baseline with this body, and no area it lacks: a re-presented plan or
999///   a metadata-only write. Passes.
1000/// * Otherwise a delta: backend, plus every role of an area new since the
1001///   baseline, bound to this body. The floor is computed here, never taken
1002///   from the plan's own listing.
1003pub fn judge(f: &Facts) -> Outcome {
1004    let of_plan = |r: &str| -> Vec<&Binding> {
1005        f.bindings
1006            .iter()
1007            .filter(|b| b.role == r && b.path == f.path)
1008            .collect()
1009    };
1010    let now = |r: &str| of_plan(r).iter().any(|b| of_body(&b.sha, f.sha, f.legacy));
1011    let ever = |r: &str| !of_plan(r).is_empty();
1012    let need_now: BTreeSet<String>;
1013    let need_ever: BTreeSet<String>;
1014    match f.baseline {
1015        None => {
1016            need_ever = roles(f.classes);
1017            need_now = BTreeSet::from(["backend".to_string()]);
1018        }
1019        Some(b) if is_current(b, f.sha, f.legacy, f.classes) => {
1020            return Outcome::Pass(b.roles.clone());
1021        }
1022        Some(b) => {
1023            let new: BTreeSet<String> = f.classes.difference(&b.classes).cloned().collect();
1024            let mut need = roles(&new);
1025            need.insert("backend".into());
1026            need_now = need;
1027            need_ever = BTreeSet::new();
1028        }
1029    }
1030    let mut missing = BTreeSet::new();
1031    let mut stale = BTreeSet::new();
1032    for r in need_ever.iter().chain(need_now.iter()) {
1033        if !ever(r) {
1034            missing.insert(r.clone());
1035        } else if need_now.contains(r) && !now(r) {
1036            stale.insert(r.clone());
1037        }
1038    }
1039    if missing.is_empty() && stale.is_empty() {
1040        let mut bound: BTreeSet<String> = need_ever.union(&need_now).cloned().collect();
1041        if let Some(b) = f.baseline {
1042            bound.extend(b.roles.iter().cloned());
1043        }
1044        Outcome::Pass(bound)
1045    } else {
1046        Outcome::Short { missing, stale }
1047    }
1048}
1049
1050/// A baseline of this body with no area it lacks: nothing to review. Shared
1051/// by [`judge`] and [`panel`], so `plan-panel` and the hook never disagree.
1052fn is_current(b: &Baseline, sha: &str, legacy: &str, classes: &BTreeSet<String>) -> bool {
1053    of_body(&b.sha, sha, legacy) && classes.is_subset(&b.classes)
1054}
1055
1056// ---------------------------------------------------------------- the store
1057
1058fn store() -> Option<PathBuf> {
1059    Some(journal::dir()?.join("plan-review"))
1060}
1061
1062fn key(text: &str) -> String {
1063    hex(&sha256(text.as_bytes()))
1064}
1065
1066fn load(file: &Path) -> Option<Baseline> {
1067    let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(file).ok()?).ok()?;
1068    let set = |k: &str| -> BTreeSet<String> {
1069        v.get(k)
1070            .and_then(|a| a.as_array())
1071            .map(|a| {
1072                a.iter()
1073                    .filter_map(|x| x.as_str().map(str::to_string))
1074                    .collect()
1075            })
1076            .unwrap_or_default()
1077    };
1078    Some(Baseline {
1079        sha: v.get("sha")?.as_str()?.to_string(),
1080        roles: set("roles"),
1081        classes: set("classes"),
1082    })
1083}
1084
1085/// The key that names a plan across sessions: its H1 and its repositories.
1086fn title_key(text: &str, repos: &[String]) -> Option<String> {
1087    let mut repos = repos.to_vec();
1088    repos.sort();
1089    Some(key(&format!("{}\n{}", title(text)?, repos.join(","))))
1090}
1091
1092/// The plan's baseline, most specific first: by its path at this body; by
1093/// this body under any path (the same plan from a new session gets a new
1094/// random file name); then, for a delta, by its path at an older body, and
1095/// by its title at an older body. An older baseline is what [`judge`] needs
1096/// to ask for backend plus the reviewers of new areas instead of the whole
1097/// panel. Two plans with the same H1 and repositories share a title
1098/// baseline; backend still reviews the body.
1099pub fn baseline(path: &str, sha: &str, legacy: &str, title: Option<&str>) -> Option<Baseline> {
1100    let s = store()?;
1101    let by_path = load(&s.join("by-path").join(format!("{}.json", key(path))));
1102    let by_body = |b: &str| load(&s.join("by-body").join(format!("{b}.json")));
1103    by_path
1104        .clone()
1105        .filter(|b| of_body(&b.sha, sha, legacy))
1106        .or_else(|| by_body(sha))
1107        .or_else(|| by_body(legacy)) // LEGACY
1108        .or(by_path)
1109        .or_else(|| load(&s.join("by-title").join(format!("{}.json", title?))))
1110}
1111
1112pub(crate) fn private_dir(dir: &Path) -> Option<()> {
1113    std::fs::create_dir_all(dir).ok()?;
1114    journal::private(dir, 0o700);
1115    Some(())
1116}
1117
1118/// Written only on a verified pass: after an `ask` the hook cannot see what
1119/// the person answered.
1120fn save(path: &str, title: Option<&str>, b: &Baseline) -> Option<()> {
1121    let s = store()?;
1122    private_dir(&s)?;
1123    let body = serde_json::json!({
1124        "sha": b.sha,
1125        "roles": b.roles,
1126        "classes": b.classes,
1127        "path": path,
1128    })
1129    .to_string();
1130    let mut places = vec![
1131        ("by-path", format!("{}.json", key(path))),
1132        ("by-body", format!("{}.json", b.sha)),
1133    ];
1134    if let Some(t) = title {
1135        places.push(("by-title", format!("{t}.json")));
1136    }
1137    for (dir, name) in places {
1138        let d = s.join(dir);
1139        private_dir(&d)?;
1140        let f = d.join(name);
1141        amont_agent_guard::atomic::write_atomic(&f, &body).ok()?;
1142        journal::private(&f, 0o600);
1143    }
1144    Some(())
1145}
1146
1147fn refusals_file(path: &str) -> Option<PathBuf> {
1148    Some(store()?.join("refusals").join(key(path)))
1149}
1150
1151fn refusals(path: &str) -> u32 {
1152    refusals_file(path)
1153        .and_then(|f| std::fs::read_to_string(f).ok())
1154        .and_then(|t| t.trim().parse().ok())
1155        .unwrap_or(0)
1156}
1157
1158fn set_refusals(path: &str, n: u32) {
1159    let Some(f) = refusals_file(path) else { return };
1160    if n == 0 {
1161        let _ = std::fs::remove_file(f);
1162        return;
1163    }
1164    if let Some(d) = f.parent() {
1165        if private_dir(d).is_none() {
1166            return;
1167        }
1168    }
1169    let _ = amont_agent_guard::atomic::write_atomic(&f, &format!("{n}\n"));
1170    journal::private(&f, 0o600);
1171}
1172
1173// ---------------------------------------------------------------- the hook
1174
1175/// What the hook says, before the stance is applied.
1176enum Said {
1177    Pass,
1178    /// The model can fix it: refused, and counted.
1179    Refuse(String),
1180    /// Only the person can: asked, and not counted.
1181    Ask(String),
1182}
1183
1184const REMEDY: &str = "run /plan-review then ExitPlanMode again";
1185
1186pub fn on_plan_exit(ev: &PlanExit, stance: Stance) -> Decision {
1187    let (said, repos, excerpt) = examine(ev);
1188    let path_key = ev
1189        .plan_file
1190        .as_deref()
1191        .map(|p| real(p).display().to_string());
1192
1193    // The anti-trap. A refusal the model could not fix twice is the
1194    // person's to decide, and the model cannot answer an `ask`.
1195    let said = match (said, &path_key) {
1196        (Said::Refuse(why), Some(p)) => {
1197            let before = refusals(p);
1198            set_refusals(p, before + 1);
1199            if before >= REFUSALS_BEFORE_ASK {
1200                Said::Ask(format!(
1201                    "UNREVIEWED: refused {before}×; {why} Approve only to accept an unreviewed plan."
1202                ))
1203            } else {
1204                Said::Refuse(why)
1205            }
1206        }
1207        (Said::Pass, Some(p)) => {
1208            set_refusals(p, 0);
1209            Said::Pass
1210        }
1211        (other, _) => other,
1212    };
1213
1214    let outcome = match (&said, stance) {
1215        (_, Stance::Observe) => "watched",
1216        (Said::Pass, _) => "passed",
1217        (Said::Refuse(_), Stance::Deny) => "denied",
1218        (Said::Ask(_), Stance::Deny) => "asked",
1219        (_, Stance::Advise) => "advised",
1220    };
1221    journal::record(&journal::Entry {
1222        rule: RULE.id,
1223        stance: stance.as_str(),
1224        outcome,
1225        session: &ev.session,
1226        repo: &repos,
1227        mode: &ev.permission_mode,
1228        excerpt: &excerpt,
1229    });
1230    let phrase = |why: &str, remedy: &str| decision::phrase(RULE.id, why, remedy);
1231    match (said, stance) {
1232        (_, Stance::Observe) | (Said::Pass, _) => Decision::Silent,
1233        (Said::Refuse(why), Stance::Deny) => Decision::Deny(phrase(&why, REMEDY)),
1234        (Said::Ask(why), Stance::Deny) => Decision::Ask(phrase(&why, "")),
1235        (Said::Refuse(why) | Said::Ask(why), Stance::Advise) => Decision::Advise(phrase(&why, "")),
1236    }
1237}
1238
1239/// The verdict, the repository names for the journal, and an excerpt.
1240fn examine(ev: &PlanExit) -> (Said, String, String) {
1241    let Some(plan_file) = ev.plan_file.as_deref() else {
1242        return (
1243            Said::Ask(
1244                "ExitPlanMode carried no planFilePath, so the plan's review cannot be checked."
1245                    .into(),
1246            ),
1247            "-".into(),
1248            "no planFilePath".into(),
1249        );
1250    };
1251    let text = match std::fs::read_to_string(plan_file) {
1252        Ok(t) => t,
1253        Err(e) => {
1254            return (
1255                Said::Ask(format!(
1256                    "the plan file {} cannot be read ({e}), so its review cannot be checked.",
1257                    plan_file.display()
1258                )),
1259                "-".into(),
1260                "plan unreadable".into(),
1261            )
1262        }
1263    };
1264    let path = real(plan_file).display().to_string();
1265    let Shas { sha, legacy } = match shas(&text) {
1266        Ok(s) => s,
1267        Err(why) => {
1268            return (
1269                Said::Ask(format!(
1270                    "{why}; approve only to accept the plan unreviewed."
1271                )),
1272                "-".into(),
1273                "sha unavailable".into(),
1274            )
1275        }
1276    };
1277    let short = &sha[..12];
1278
1279    let m = match meta(&text) {
1280        Ok(m) => m,
1281        Err(why) => {
1282            return (
1283                Said::Refuse(format!("{why};")),
1284                "-".into(),
1285                format!("{short} no meta"),
1286            )
1287        }
1288    };
1289    let repos = m.repos.join(",");
1290    let sec = section(&text);
1291    if !sec.present {
1292        return (
1293            Said::Refuse(format!(
1294                "the plan has no `## Review panel` section for repos={repos};"
1295            )),
1296            repos,
1297            format!("{short} no section"),
1298        );
1299    }
1300
1301    let Some(transcript) = ev.transcript.as_deref() else {
1302        return (
1303            Said::Ask(
1304                "the hook payload carried no transcript_path, so the reviews cannot be checked."
1305                    .into(),
1306            ),
1307            repos,
1308            format!("{short} no transcript"),
1309        );
1310    };
1311    let found = match std::fs::File::open(transcript) {
1312        Ok(f) => bindings(std::io::BufReader::new(f)),
1313        Err(e) => {
1314            return (
1315                Said::Ask(format!(
1316                "the session transcript cannot be read ({e}), so the reviews cannot be checked."
1317            )),
1318                repos,
1319                format!("{short} transcript unreadable"),
1320            )
1321        }
1322    };
1323
1324    let classes = match classes_of(&m) {
1325        Ok(c) => c,
1326        Err(said) => {
1327            let what = match &said {
1328                Said::Ask(_) => "cannot resolve",
1329                _ => "unknown repo",
1330            };
1331            return (said, repos, format!("{short} {what}"));
1332        }
1333    };
1334
1335    let title = title_key(&text, &m.repos);
1336    let base = baseline(&path, &sha, &legacy, title.as_deref());
1337    let facts = Facts {
1338        sha: &sha,
1339        legacy: &legacy,
1340        path: &path,
1341        classes: &classes,
1342        bindings: &found,
1343        baseline: base.as_ref(),
1344    };
1345    match judge(&facts) {
1346        Outcome::Pass(bound) => {
1347            if sec.unreviewed {
1348                return (
1349                    Said::Ask(format!(
1350                        "the plan for repos={repos} is marked unreviewed at the person's request (body {short}); approve only if you asked to skip the review."
1351                    )),
1352                    repos,
1353                    format!("{short} unreviewed"),
1354                );
1355            }
1356            // LEGACY: whether the pass needed the byte sha — the same facts
1357            // without it fall short. Leads the excerpt, which is cut at
1358            // MAX_EXCERPT, so it is never the part that is lost.
1359            let on_legacy = !matches!(
1360                judge(&Facts {
1361                    legacy: "-",
1362                    ..facts
1363                }),
1364                Outcome::Pass(_)
1365            );
1366            let matched = if on_legacy { "legacy" } else { "new" };
1367            let _ = save(
1368                &path,
1369                title.as_deref(),
1370                &Baseline {
1371                    sha: sha.clone(),
1372                    roles: bound.clone(),
1373                    classes: classes
1374                        .union(&base.map(|b| b.classes).unwrap_or_default())
1375                        .cloned()
1376                        .collect(),
1377                },
1378            );
1379            let listed = bound.into_iter().collect::<Vec<_>>().join(",");
1380            (
1381                Said::Pass,
1382                repos,
1383                format!("match={matched} {short} {listed}"),
1384            )
1385        }
1386        Outcome::Short { missing, stale } => {
1387            if sec.unreviewed {
1388                return (
1389                    Said::Ask(format!(
1390                        "the plan for repos={repos} is marked unreviewed at the person's request; missing {} for body {short}. Approve only if you asked to skip the review.",
1391                        list(&missing.union(&stale).cloned().collect())
1392                    )),
1393                    repos,
1394                    format!("{short} unreviewed"),
1395                );
1396            }
1397            let mut parts = Vec::new();
1398            if !missing.is_empty() {
1399                parts.push(format!("missing for repos={repos}: {}", list(&missing)));
1400            }
1401            if !stale.is_empty() {
1402                parts.push(format!(
1403                    "stale (reviewed an older body) for repos={repos}: {}",
1404                    list(&stale)
1405                ));
1406            }
1407            let excerpt = format!(
1408                "{short} short {}",
1409                list(&missing.union(&stale).cloned().collect())
1410            );
1411            (
1412                Said::Refuse(format!("{} (body {short});", parts.join("; "))),
1413                repos,
1414                excerpt,
1415            )
1416        }
1417    }
1418}
1419
1420/// [`classes_of`] for a reader that has no answer to give: `None` when the
1421/// areas cannot be resolved (the outcome ledger records none, not a guess).
1422pub fn areas_of(m: &Meta) -> Option<BTreeSet<String>> {
1423    classes_of(m).ok()
1424}
1425
1426fn list(s: &BTreeSet<String>) -> String {
1427    s.iter().cloned().collect::<Vec<_>>().join(", ")
1428}
1429
1430/// The areas of every repository the plan names, plus what it declares.
1431/// `Err` carries the answer the hook gives instead.
1432fn classes_of(m: &Meta) -> Result<BTreeSet<String>, Said> {
1433    let Some(root) = root() else {
1434        return Err(Said::Ask(
1435            "no home directory, so repos= cannot be resolved.".into(),
1436        ));
1437    };
1438    let mut classes: BTreeSet<String> = m.adds.iter().cloned().collect();
1439    for name in &m.repos {
1440        match repo_areas(&locate(&root, name)) {
1441            Ok(Some(a)) => classes.extend(a),
1442            Ok(None) if m.adds.iter().any(|a| a.starts_with("lang:")) => {}
1443            Ok(None) => {
1444                return Err(Said::Refuse(format!(
1445                    "repos= names `{name}`, which is not a repository at {} (set `git config --global amont.agent.plan-review.{name}.path <dir>` for one kept elsewhere); a plan that creates one declares its language in adds=lang:<language>;",
1446                    locate(&root, name).display()
1447                )))
1448            }
1449            Err(why) => {
1450                return Err(Said::Ask(format!(
1451                    "{why}, so the panel cannot be computed."
1452                )))
1453            }
1454        }
1455    }
1456    Ok(classes)
1457}
1458
1459/// What `amont-agent plan-panel` reports: the reviews this plan still
1460/// needs, computed exactly as the hook will.
1461pub struct Panel {
1462    pub repos: String,
1463    pub classes: BTreeSet<String>,
1464    pub sha: String,
1465    /// `full` (no baseline), `delta` (the body or the areas changed since
1466    /// the last pass) or `current` (nothing to review).
1467    pub mode: &'static str,
1468    pub needed: BTreeSet<String>,
1469}
1470
1471pub fn panel(plan_file: &Path) -> Result<Panel, String> {
1472    let text = std::fs::read_to_string(plan_file)
1473        .map_err(|e| format!("cannot read {}: {e}", plan_file.display()))?;
1474    let m = meta(&text)?;
1475    let classes = classes_of(&m).map_err(|said| match said {
1476        Said::Refuse(w) | Said::Ask(w) => w.trim_end_matches(';').to_string(),
1477        Said::Pass => String::new(),
1478    })?;
1479    let path = real(plan_file).display().to_string();
1480    let Shas { sha, legacy } = shas(&text).map_err(|e| e.to_string())?;
1481    let title = title_key(&text, &m.repos);
1482    let (mode, needed) = match baseline(&path, &sha, &legacy, title.as_deref()) {
1483        None => ("full", roles(&classes)),
1484        Some(b) if is_current(&b, &sha, &legacy, &classes) => ("current", BTreeSet::new()),
1485        Some(b) => {
1486            let new: BTreeSet<String> = classes.difference(&b.classes).cloned().collect();
1487            let mut need = roles(&new);
1488            need.insert("backend".into());
1489            ("delta", need)
1490        }
1491    };
1492    Ok(Panel {
1493        repos: m.repos.join(","),
1494        classes,
1495        sha,
1496        mode,
1497        needed,
1498    })
1499}
1500
1501/// The agent to launch for a role, and the `--lang` its block takes.
1502pub fn agent_for(role: &str) -> (String, Option<&str>) {
1503    match role.strip_prefix("lang:") {
1504        Some(l) => (format!("{AGENT_PREFIX}language"), Some(l)),
1505        None => (format!("{AGENT_PREFIX}{role}"), None),
1506    }
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511    use super::*;
1512    use crate::plan_text::{body_sha, guarded, legacy_sha, ShaError};
1513
1514    /// `remit` reads `roles` backwards, for every class the panel knows.
1515    #[test]
1516    fn remit_is_the_inverse_of_roles() {
1517        for c in ["cli", "ops", "large", "ui", "lang:rust", "lang:go"] {
1518            let class = BTreeSet::from([c.to_string()]);
1519            for r in roles(&class).into_iter().filter(|r| r != "backend") {
1520                assert!(remit(&r).contains(c), "{c} not in remit({r})");
1521            }
1522        }
1523        assert_eq!(remit("backend"), BTreeSet::from(["*".to_string()]));
1524    }
1525
1526    fn set(items: &[&str]) -> BTreeSet<String> {
1527        items.iter().map(|s| s.to_string()).collect()
1528    }
1529
1530    const PLAN: &str = "# Title\n\n## Review panel\n\n👉 Decide: none\n\n## Context\n\nBody.  \n\n```md\n## Review panel\nkept\n```\n\n## Full reviews (reference)\n\n- rust: ok\n\n<!-- panel: repos=amont-agent adds=cli body-sha=abc -->\n";
1531
1532    #[test]
1533    fn the_canonical_body_drops_review_metadata_and_nothing_else() {
1534        let c = canonical(PLAN);
1535        assert_eq!(
1536            c,
1537            "# Title\n\n## Context\n\nBody.\n\n```md\n## Review panel\nkept\n```\n"
1538        );
1539    }
1540
1541    #[test]
1542    fn canonicalising_is_idempotent_and_line_endings_do_not_count() {
1543        let once = canonical(PLAN);
1544        assert_eq!(canonical(&once), once);
1545        assert_eq!(body_sha(&PLAN.replace('\n', "\r\n")), body_sha(PLAN));
1546    }
1547
1548    #[test]
1549    fn front_matter_added_on_landing_keeps_the_sha() {
1550        let landed = format!(
1551            "---\nstatus: active\nbranch: feat/x\nrepos: [x]\n---\n\n{}",
1552            PLAN.replace(
1553                "👉 Decide: none",
1554                "👉 Decide: none\n📄 Full reviews: [x](x.reviews.md)"
1555            )
1556            .split("\n## Full reviews")
1557            .next()
1558            .unwrap()
1559        ) + "\n<!-- panel: repos=amont-agent -->\n";
1560        assert_eq!(body_sha(&landed), body_sha(PLAN));
1561        assert_eq!(canonical(&canonical(&landed)), canonical(&landed));
1562        // Not front matter: no closing fence, or not on the first line.
1563        // (Its `---` is then a thematic break, which says nothing, so the
1564        // sha over the CommonMark reading does not see it; the bytes do.)
1565        let open = format!("---\n{PLAN}");
1566        assert_ne!(legacy_sha(&open), legacy_sha(PLAN));
1567        let later = PLAN.replace("Body.", "Body.\n\n---\n\nMore.\n\n---");
1568        assert!(canonical(&later).contains("---\n\nMore."));
1569    }
1570
1571    #[test]
1572    fn writing_review_metadata_keeps_the_sha() {
1573        let edited = PLAN
1574            .replace("👉 Decide: none", "👉 Decide: none left\n📍 amont-agent")
1575            .replace("- rust: ok", "- rust: ok\n- backend: ok")
1576            .replace("body-sha=abc", "body-sha=def reviewers=backend");
1577        assert_eq!(body_sha(&edited), body_sha(PLAN));
1578        assert_ne!(body_sha(&PLAN.replace("Body.", "Body!")), body_sha(PLAN));
1579    }
1580
1581    /// Both values are pinned: a `pulldown-cmark` bump that re-hashes plans
1582    /// fails here, and the legacy value is what 2.24.0's `plan-sha` printed
1583    /// for this plan, so every review bound before 2.25 still matches.
1584    #[test]
1585    fn the_shas_of_a_fixed_plan_do_not_move() {
1586        assert_eq!(
1587            body_sha(PLAN),
1588            "ab25150b6b847cc4ed344aad0d38ae282422b111a37f8f48c14863dff74ed788"
1589        );
1590        assert_eq!(
1591            legacy_sha(PLAN),
1592            "db6b774afd48bd8c7bd4edb3b8758a4417beae2c3ce26d4245cba7bc36e396dd"
1593        );
1594    }
1595
1596    #[test]
1597    fn formatting_the_plan_keeps_the_sha_and_the_legacy_sha_does_not() {
1598        let formatted = PLAN.replace("## Context\n\nBody.", "## Context\n\n\nBody.");
1599        assert_eq!(body_sha(&formatted), body_sha(PLAN));
1600        assert_ne!(legacy_sha(&formatted), legacy_sha(PLAN));
1601    }
1602
1603    #[test]
1604    fn a_panic_computing_the_sha_is_an_error_not_silence() {
1605        assert_eq!(
1606            guarded(|| panic!("parser bug")),
1607            Err(ShaError::ParserPanicked)
1608        );
1609        assert!(shas(PLAN).is_ok());
1610    }
1611
1612    #[test]
1613    fn a_parser_panic_in_the_hook_goes_to_the_person() {
1614        let dir =
1615            std::env::temp_dir().join(format!("amont-agent-sha-panic-{}", std::process::id()));
1616        std::fs::create_dir_all(&dir).unwrap();
1617        let plan = dir.join("p.md");
1618        std::fs::write(&plan, PLAN).unwrap();
1619        let ev = PlanExit {
1620            session: "s".into(),
1621            permission_mode: "plan".into(),
1622            transcript: None,
1623            plan_file: Some(plan),
1624        };
1625        crate::plan_text::PARSER_PANICS.with(|p| p.set(true));
1626        let (said, _, excerpt) = examine(&ev);
1627        crate::plan_text::PARSER_PANICS.with(|p| p.set(false));
1628        let _ = std::fs::remove_dir_all(&dir);
1629        assert!(
1630            matches!(said, Said::Ask(ref why) if why.contains("cannot be computed")),
1631            "{excerpt}"
1632        );
1633        assert_eq!(excerpt, "sha unavailable");
1634    }
1635
1636    #[test]
1637    fn the_title_is_the_first_h1_outside_a_fence() {
1638        assert_eq!(title(PLAN).as_deref(), Some("Title"));
1639        let fenced = "```md\n# Not this\n```\n\n# This one\n";
1640        assert_eq!(title(fenced).as_deref(), Some("This one"));
1641        assert_eq!(title("no heading\n"), None);
1642    }
1643
1644    #[test]
1645    fn the_machine_comment_is_read_and_checked() {
1646        let m = meta(PLAN).unwrap();
1647        assert_eq!(m.repos, vec!["amont-agent"]);
1648        assert_eq!(m.adds, vec!["cli"]);
1649        assert!(meta("# t\n").is_err());
1650        assert!(meta("<!-- panel: adds=ui -->").is_err());
1651        for bad in ["..", "a/b", "-x", "."] {
1652            assert!(
1653                meta(&format!("<!-- panel: repos={bad} -->")).is_err(),
1654                "{bad}"
1655            );
1656        }
1657        assert!(meta("<!-- panel: repos=x adds=gui -->").is_err());
1658        assert!(meta("<!-- panel: repos=x adds=lang:rust,ops -->").is_ok());
1659    }
1660
1661    #[test]
1662    fn the_review_section_and_a_requested_skip_are_found() {
1663        assert_eq!(
1664            section(PLAN),
1665            Section {
1666                present: true,
1667                unreviewed: false
1668            }
1669        );
1670        let skipped = PLAN.replace("👉 Decide: none", "⚠ unreviewed: the person asked to skip");
1671        assert!(section(&skipped).unreviewed);
1672        assert!(!section("# t\n```\n## Review panel\n```\n").present);
1673    }
1674
1675    #[test]
1676    fn areas_come_from_the_tree_and_the_files_read_from_it() {
1677        let paths: Vec<String> = [
1678            "Cargo.toml",
1679            "src/main.rs",
1680            "web/package.json",
1681            "deploy/kustomization.yaml",
1682            "vendor/x/go.mod",
1683            "Cargo.lock",
1684        ]
1685        .iter()
1686        .map(|s| s.to_string())
1687        .collect();
1688        let mut blobs = HashMap::new();
1689        blobs.insert(
1690            "web/package.json".to_string(),
1691            r#"{"scripts":{"dev":"vite"}}"#.to_string(),
1692        );
1693        let got = classify(&paths, &blobs, Some(10));
1694        assert_eq!(
1695            got,
1696            set(&["cli", "lang:rust", "lang:typescript", "ops", "ui"])
1697        );
1698        assert!(classify(&paths, &blobs, Some(500)).contains("large"));
1699        assert!(
1700            !classify(&paths, &blobs, None).contains("large"),
1701            "shallow: files only"
1702        );
1703    }
1704
1705    #[test]
1706    fn adr_areas_declare_traits() {
1707        let t = "dir: docs\nareas:\n  # ui: x\n  \"apps/a/**\": [ui]\n  bootstrap/**: [cli, ui]\ndisclaims:\n  x/**: [ops]\n";
1708        assert_eq!(adr_traits(t), set(&["cli", "ui"]));
1709    }
1710
1711    #[test]
1712    fn the_panel_follows_the_areas() {
1713        assert_eq!(roles(&set(&[])), set(&["backend"]));
1714        assert_eq!(
1715            roles(&set(&["lang:rust", "cli"])),
1716            set(&["backend", "lang:rust", "unix", "tui"])
1717        );
1718        assert_eq!(roles(&set(&["ui"])).len(), 5);
1719    }
1720
1721    fn b(role: &str, sha: &str) -> Binding {
1722        Binding {
1723            role: role.into(),
1724            path: "/p.md".into(),
1725            sha: sha.into(),
1726        }
1727    }
1728
1729    #[test]
1730    fn a_first_panel_needs_every_role_and_backend_on_the_final_body() {
1731        let classes = set(&["lang:rust", "cli"]);
1732        let old = "1".repeat(64);
1733        let new = "2".repeat(64);
1734        let bs = vec![
1735            b("lang:rust", &old),
1736            b("unix", &old),
1737            b("tui", &old),
1738            b("backend", &old),
1739        ];
1740        let f = Facts {
1741            sha: &new,
1742            legacy: "-",
1743            path: "/p.md",
1744            classes: &classes,
1745            bindings: &bs,
1746            baseline: None,
1747        };
1748        assert_eq!(
1749            judge(&f),
1750            Outcome::Short {
1751                missing: set(&[]),
1752                stale: set(&["backend"])
1753            }
1754        );
1755        let mut bs2 = bs.clone();
1756        bs2.push(b("backend", &new));
1757        let f = Facts {
1758            bindings: &bs2,
1759            ..f
1760        };
1761        assert!(matches!(judge(&f), Outcome::Pass(_)));
1762    }
1763
1764    #[test]
1765    fn a_review_of_another_plan_does_not_bind() {
1766        let classes = set(&[]);
1767        let sha = "1".repeat(64);
1768        let mut other = b("backend", &sha);
1769        other.path = "/other.md".into();
1770        let bs = [other];
1771        let f = Facts {
1772            sha: &sha,
1773            legacy: "-",
1774            path: "/p.md",
1775            classes: &classes,
1776            bindings: &bs,
1777            baseline: None,
1778        };
1779        assert_eq!(
1780            judge(&f),
1781            Outcome::Short {
1782                missing: set(&["backend"]),
1783                stale: set(&[])
1784            }
1785        );
1786    }
1787
1788    #[test]
1789    fn a_delta_needs_backend_and_the_new_areas_only() {
1790        let base = Baseline {
1791            sha: "1".repeat(64),
1792            roles: set(&["backend", "lang:rust"]),
1793            classes: set(&["lang:rust"]),
1794        };
1795        let new = "2".repeat(64);
1796        let classes = set(&["lang:rust", "ui"]);
1797        let bs = vec![b("backend", &new)];
1798        let f = Facts {
1799            sha: &new,
1800            legacy: "-",
1801            path: "/p.md",
1802            classes: &classes,
1803            bindings: &bs,
1804            baseline: Some(&base),
1805        };
1806        assert_eq!(
1807            judge(&f),
1808            Outcome::Short {
1809                missing: set(&["react", "ui-design", "ux-research", "game-ux"]),
1810                stale: set(&[])
1811            }
1812        );
1813        // Same body, no new area: passes with nothing new bound.
1814        let same = base.sha.clone();
1815        let old_classes = set(&["lang:rust"]);
1816        let f = Facts {
1817            sha: &same,
1818            classes: &old_classes,
1819            bindings: &[],
1820            ..f
1821        };
1822        assert!(matches!(judge(&f), Outcome::Pass(_)));
1823    }
1824
1825    #[test]
1826    fn a_review_bound_to_the_legacy_sha_still_counts() {
1827        let classes = set(&["lang:rust"]);
1828        let (new, old) = ("2".repeat(64), "1".repeat(64));
1829        let bs = vec![b("lang:rust", &old), b("backend", &old)];
1830        let f = Facts {
1831            sha: &new,
1832            legacy: &old,
1833            path: "/p.md",
1834            classes: &classes,
1835            bindings: &bs,
1836            baseline: None,
1837        };
1838        assert!(
1839            matches!(judge(&f), Outcome::Pass(_)),
1840            "backend at the legacy sha is current"
1841        );
1842        let f = Facts { legacy: "-", ..f };
1843        assert!(
1844            matches!(judge(&f), Outcome::Short { ref stale, .. } if stale.contains("backend")),
1845            "without the legacy sha it is an older body"
1846        );
1847    }
1848
1849    #[test]
1850    fn a_baseline_at_the_legacy_sha_is_current_for_judge_and_panel_alike() {
1851        let classes = set(&["lang:rust"]);
1852        let (new, old) = ("2".repeat(64), "1".repeat(64));
1853        let base = Baseline {
1854            sha: old.clone(),
1855            roles: set(&["backend", "lang:rust"]),
1856            classes: classes.clone(),
1857        };
1858        assert!(is_current(&base, &new, &old, &classes));
1859        assert!(!is_current(&base, &new, "-", &classes));
1860        assert!(!is_current(&base, &new, &old, &set(&["lang:rust", "ui"])));
1861        let f = Facts {
1862            sha: &new,
1863            legacy: &old,
1864            path: "/p.md",
1865            classes: &classes,
1866            bindings: &[],
1867            baseline: Some(&base),
1868        };
1869        assert!(matches!(judge(&f), Outcome::Pass(_)));
1870    }
1871
1872    #[test]
1873    fn blocks_are_read_from_a_reviewer_prompt() {
1874        let sha = "a".repeat(64);
1875        let p = format!("Review this.\n<<<PLAN path=/x/my plan.md sha={sha} lang=rust>>>\nthanks");
1876        assert_eq!(
1877            parse_blocks("plan-review-language", &p),
1878            vec![Binding {
1879                role: "lang:rust".into(),
1880                path: "/x/my plan.md".into(),
1881                sha: sha.clone()
1882            }]
1883        );
1884        assert!(parse_blocks("plan-review-language", &p.replace(" lang=rust", "")).is_empty());
1885        assert_eq!(parse_blocks("plan-review-backend", &p)[0].role, "backend");
1886        assert!(parse_blocks("plan-review-backend", "<<<PLAN path=/x sha=short>>>").is_empty());
1887    }
1888
1889    /// The plan-review panel reads launches through the same scan, so a
1890    /// panelist that handed its report back is read too.
1891    #[test]
1892    fn a_launch_that_handed_back_completes_with_its_report() {
1893        let launch: Vec<&str> = include_str!("../../../tests/fixtures/sendmessage-resume.jsonl")
1894            .lines()
1895            .take(3)
1896            .collect();
1897        let pointer = "This agent's report was delivered to you as a message from aa339dbce3244a139 (its SubagentHandback call).";
1898        let notice = launch[2].replace(
1899            "Verdict: approve-with-changes\\nFindings:\\n1. … (trimmed)",
1900            pointer,
1901        );
1902        assert_ne!(notice, launch[2]);
1903        let handback = include_str!("../../../tests/fixtures/handback-entry.jsonl").trim_end();
1904        let text = format!("{}\n{}\n{handback}\n{notice}\n", launch[0], launch[1]);
1905        let done = completed_agents(std::io::Cursor::new(text.clone()), "implementation-review");
1906        assert_eq!(done.len(), 1);
1907        assert!(
1908            done[0].result.contains("Verdict: approve"),
1909            "{}",
1910            done[0].result
1911        );
1912        // Without the hand-back, the pointer stays and carries no verdict.
1913        let alone = format!("{}\n{}\n{notice}\n", launch[0], launch[1]);
1914        let done = completed_agents(std::io::Cursor::new(alone), "implementation-review");
1915        assert!(!done[0].result.contains("Verdict:"), "{}", done[0].result);
1916    }
1917
1918    #[test]
1919    fn a_notification_names_what_completed() {
1920        let t = "<task-notification>\n<task-id>a</task-id>\n<tool-use-id>toolu_1</tool-use-id>\n<status>completed</status>\n</task-notification><task-notification><tool-use-id>toolu_2</tool-use-id><status>failed</status></task-notification>";
1921        assert_eq!(completed_ids(t), vec!["toolu_1"]);
1922    }
1923}