Skip to main content

amont_agent_process/
implementation_review.rs

1//! The implementation review (ADR-0022, `work.implementation-review`):
2//! before the push of a branch that carries a plan, one independent
3//! reviewer has read the diff against the plan and the active rules, and
4//! its verdict is bound to the tree it read.
5//!
6//! A review is bound to the **content id** of the commit it read (attest
7//! SPEC, "Content id"; not attest's canonical tree, which counts whether a
8//! plan exists): the sha256 of `git ls-tree -r -z --full-tree <rev>`'s entries — mode,
9//! type, object id and path, in git's own order — with every entry under
10//! `docs/plans/` left out. So recording the review in the plan never makes
11//! it stale, a later code change does, and the id is 64 hex whatever the
12//! repository's object format. It is computed read-only: no index, no
13//! object written, nothing to clean up if the hook is killed.
14//!
15//! The repository in a block is named by the basename of the parent of
16//! `git rev-parse --git-common-dir`, so a worktree checked out as
17//! `amont-agent-wt-x` still says `amont-agent`, and `tree-sha` and the
18//! rule cannot disagree on the name.
19//!
20//! ## What the hook reads
21//!
22//! A push is judged by `confirm` (the rule is `rules::implementation_review`):
23//! the pushed commit, the base it grew from (the remote's default branch,
24//! never the branch's own tracking ref — once a branch is published its
25//! plan commit is on the remote too, and a later push compared to the
26//! tracking ref would show no `docs/plans/` change and skip the rule for
27//! good), whether `docs/plans/` changed since that base, the content id,
28//! and then a binding: a completed `implementation-review` agent in this
29//! session's transcript whose block names this repository and tree, or a
30//! pass remembered under `<state>/implementation-review/by-tree/`.
31//!
32//! A pass also holds for another tree with the same **branch patch** when
33//! the repository's rule files are unchanged (ADR-0034,
34//! `work.implementation-review-binding`): a conflict-free rebase onto a
35//! moved `main` changes the tree, not the diff the reviewer read. The patch
36//! is `git diff --binary <merge-base> <pushed> -- . ':!docs/plans'` hashed by
37//! `git patch-id --verbatim`; the rules are `AGENTS.md`, `CLAUDE.md`,
38//! `.adr.yaml`, `.adr/` and `.claude/` in the pushed tree. A pass is filed
39//! under `by-patch/` too, and a hit there is journalled `reviewed-by-patch`.
40//!
41//! The verdict is read, not assumed: a review that said `rework` and got
42//! no later delta on the same tree is not a pass. Only the hook writes a
43//! pass file — from a transcript binding, or from the person's `Overrule`
44//! on a marked question — and a Bash, Write or Edit under that directory
45//! is refused by the hook itself, outside any stance, because under `deny`
46//! a pass file is a trust boundary.
47
48use std::path::{Component, Path, PathBuf};
49
50use crate::candidates_block::Reviewed;
51use crate::content_id::content_id;
52use crate::plan_review::{completed_agents_resumable, Completed};
53use crate::provenance::Review as Bound;
54use amont_agent_guard::git;
55use amont_agent_guard::journal;
56use amont_agent_guard::rules::{Confirmed, Context, Stance};
57use amont_agent_guard::sha256::{hex, sha256};
58use amont_agent_guard::shell::Simple;
59
60pub const RULE_ID: &str = "implementation-review";
61/// The agent type the reviewer is launched as; a prefix, like the panel's.
62const AGENT: &str = "implementation-review";
63/// Opens a review block in the reviewer's prompt.
64pub const BLOCK_OPEN: &str = "<<<TREE ";
65const BLOCK_CLOSE: &str = ">>>";
66/// The directory a review does not bind to: the plan's own record.
67const PLANS_PATH: &str = "docs/plans";
68/// The marker a person's overrule question carries.
69const MARKER: &str = "[implementation-review ";
70const OVERRULE: &str = "Overrule";
71
72// ---------------------------------------------------------------- the tree
73
74/// The files whose content a review was read against, besides the diff:
75/// the repository's active rules.
76const RULE_FILES: &[&[u8]] = &[b"AGENTS.md", b"CLAUDE.md", b".adr.yaml"];
77const RULE_DIRS: &[&[u8]] = &[b".adr/", b".claude/"];
78
79/// What a pass carries over to another tree: the branch patch and the
80/// fingerprint of the rule files. Both must match.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct PatchKey {
83    pub patch: String,
84    pub rules: String,
85}
86
87/// The `git patch-id --verbatim` of the branch's diff since `merge_base`,
88/// `docs/plans/` left out. `Err` on an empty diff or any failure: a key
89/// that cannot be computed carries nothing over.
90pub fn branch_patch(repo: &Path, merge_base: &str, pushed: &str) -> Result<String, String> {
91    let diff = git::bytes_in(
92        repo,
93        &[
94            "diff",
95            "--binary",
96            "--no-ext-diff",
97            "--no-textconv",
98            "--no-color",
99            merge_base,
100            pushed,
101            "--",
102            ".",
103            &format!(":!{PLANS_PATH}"),
104        ],
105        None,
106    )
107    .map_err(|e| format!("git could not be run: {e}"))?
108    .ok_or("`git diff` against the base failed")?;
109    if diff.is_empty() {
110        return Err("the branch changes nothing outside docs/plans/".into());
111    }
112    let out = git::bytes_in(repo, &["patch-id", "--verbatim"], Some(&diff))
113        .map_err(|e| format!("git could not be run: {e}"))?
114        .ok_or("`git patch-id` failed")?;
115    let out = String::from_utf8_lossy(&out);
116    out.split_whitespace()
117        .next()
118        .filter(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit()))
119        .map(str::to_string)
120        .ok_or_else(|| "`git patch-id` printed no id".into())
121}
122
123/// The sha256 of the rule files' entries in `rev`'s tree.
124pub fn rules_fingerprint(repo: &Path, rev: &str) -> Result<String, String> {
125    let spec = format!("{rev}^{{tree}}");
126    let entries = git::ls_tree_entries(repo, &spec)
127        .map_err(|e| format!("git could not be run in {}: {e}", repo.display()))?
128        .ok_or_else(|| format!("`{rev}` does not name a tree in {}", repo.display()))?;
129    let mut bytes: Vec<u8> = Vec::new();
130    for entry in entries {
131        let path = entry
132            .iter()
133            .position(|c| *c == b'\t')
134            .map(|i| &entry[i + 1..])
135            .unwrap_or(&entry[..]);
136        if RULE_FILES.contains(&path) || RULE_DIRS.iter().any(|d| path.starts_with(d)) {
137            bytes.extend_from_slice(&entry);
138            bytes.push(0);
139        }
140    }
141    Ok(hex(&sha256(&bytes)))
142}
143
144/// Both halves of the key for `pushed`, or why either cannot be computed:
145/// a miss, never a pass, and the caller says the reason when it holds.
146pub fn patch_key(repo: &Path, merge_base: &str, pushed: &str) -> Result<PatchKey, String> {
147    Ok(PatchKey {
148        patch: branch_patch(repo, merge_base, pushed)?,
149        rules: rules_fingerprint(repo, pushed)?,
150    })
151}
152
153/// The repository's name for a block: the basename of the directory that
154/// holds its common `.git`, whichever worktree `repo` is.
155pub fn repo_name(repo: &Path) -> Option<String> {
156    let common = git::stdout_in(repo, &["rev-parse", "--git-common-dir"])?;
157    if common.is_empty() {
158        return None;
159    }
160    let common = PathBuf::from(common);
161    let common = if common.is_absolute() {
162        common
163    } else {
164        repo.join(common)
165    };
166    let common = std::fs::canonicalize(&common).unwrap_or(common);
167    common
168        .parent()
169        .and_then(Path::file_name)
170        .map(|n| n.to_string_lossy().into_owned())
171        .filter(|n| !n.is_empty())
172}
173
174/// The review block a reviewer's prompt carries.
175pub fn block(repo: &str, sha: &str) -> String {
176    format!("{BLOCK_OPEN}repo={repo} sha={sha}{BLOCK_CLOSE}")
177}
178
179/// The `(repo, sha)` of every review block in a prompt. A sha that is not
180/// 64 hex is not a binding.
181pub fn parse_blocks(prompt: &str) -> Vec<(String, String)> {
182    let mut out = Vec::new();
183    let mut rest = prompt;
184    while let Some(at) = rest.find(BLOCK_OPEN) {
185        rest = &rest[at + BLOCK_OPEN.len()..];
186        let Some(end) = rest.find(BLOCK_CLOSE) else {
187            break;
188        };
189        let inner = &rest[..end];
190        rest = &rest[end..];
191        let Some(body) = inner.strip_prefix("repo=") else {
192            continue;
193        };
194        let Some(split) = body.rfind(" sha=") else {
195            continue;
196        };
197        let repo = body[..split].trim().to_string();
198        let sha = body[split + 5..].trim().to_ascii_lowercase();
199        if repo.is_empty() || !is_sha256(&sha) {
200            continue;
201        }
202        out.push((repo, sha));
203    }
204    out
205}
206
207pub fn is_sha256(s: &str) -> bool {
208    s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
209}
210
211fn short(sha: &str) -> &str {
212    &sha[..sha.len().min(12)]
213}
214
215// ---------------------------------------------------------------- the verdict
216
217/// What a reviewer's result said, read from its first `Verdict:` line.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Verdict {
220    Approve,
221    ApproveWithChanges,
222    Rework,
223}
224
225impl Verdict {
226    pub fn as_str(self) -> &'static str {
227        match self {
228            Verdict::Approve => "approve",
229            Verdict::ApproveWithChanges => "approve-with-changes",
230            Verdict::Rework => "rework",
231        }
232    }
233}
234
235/// The verdict a result text states, or `None` when it states none the
236/// contract knows — which is not a pass.
237pub fn verdict_of(result: &str) -> Option<Verdict> {
238    let line = result.lines().find(|l| l.contains("Verdict:"))?;
239    let after = line.split_once("Verdict:")?.1;
240    let word: String = after
241        .trim()
242        .trim_start_matches(['*', '`', '_', ' '])
243        .chars()
244        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
245        .collect::<String>()
246        .to_ascii_lowercase();
247    match word.as_str() {
248        "approve-with-changes" => Some(Verdict::ApproveWithChanges),
249        "approve" | "approved" => Some(Verdict::Approve),
250        "rework" => Some(Verdict::Rework),
251        _ => None,
252    }
253}
254
255/// The reviews this session completed, in launch order: what each bound
256/// and what it said. A reviewer resumed with `SendMessage` on a new block is
257/// a review of its own ([`completed_agents_resumable`]).
258pub struct Review {
259    pub repo: String,
260    pub sha: String,
261    pub verdict: Option<Verdict>,
262    /// The sha of the prompt's `<<<RULES …>>>` line, when it has a valid one.
263    pub rules_sha: Option<String>,
264    /// The candidates the prompt listed and the result ruled on.
265    pub candidates: Reviewed,
266}
267
268pub fn reviews(transcript: &Path) -> std::io::Result<Vec<Review>> {
269    let file = std::fs::File::open(transcript)?;
270    let reader = std::io::BufReader::new(file);
271    Ok(completed_agents_resumable(reader, AGENT)
272        .into_iter()
273        .flat_map(|c: Completed| {
274            let verdict = verdict_of(&c.result);
275            let rules_sha = crate::review_brief::parse_rules_sha(&c.prompt);
276            let candidates = Reviewed::read(c.agent_id.as_deref(), &c.prompt, &c.result);
277            parse_blocks(&c.prompt)
278                .into_iter()
279                .map(move |(repo, sha)| Review {
280                    repo,
281                    sha,
282                    verdict,
283                    rules_sha: rules_sha.clone(),
284                    candidates: candidates.clone(),
285                })
286        })
287        .collect())
288}
289
290// ---------------------------------------------------------------- the store
291
292fn store() -> Option<PathBuf> {
293    Some(journal::dir()?.join(RULE_ID))
294}
295
296fn private_dir(dir: &Path) -> Option<()> {
297    std::fs::create_dir_all(dir).ok()?;
298    journal::private(dir, 0o700);
299    Some(())
300}
301
302fn pass_file(repo: &str, sha: &str) -> Option<PathBuf> {
303    if repo.contains('/') || repo.contains("..") || !is_sha256(sha) {
304        return None;
305    }
306    Some(
307        store()?
308            .join("by-tree")
309            .join(repo)
310            .join(format!("{sha}.json")),
311    )
312}
313
314/// The verdict a remembered pass carries (`approve`, `approve-with-changes`
315/// or `overruled`), if this repository and tree have one.
316pub fn remembered(repo: &str, sha: &str) -> Option<String> {
317    remembered_pass(repo, sha).map(|pass| pass.verdict)
318}
319
320/// A remembered verdict and the rules line the passing review's prompt
321/// carried.
322struct Pass {
323    verdict: String,
324    prompt_rules: PromptRules,
325    candidates: Reviewed,
326}
327
328/// What a passing review's prompt said about the rules it was given.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum PromptRules {
331    /// The pass was remembered before the rules line was stored.
332    NotRecorded,
333    /// The prompt carried no rules line.
334    Absent,
335    /// The prompt's rules line, 64 hex.
336    Sha(String),
337}
338
339/// How [`PromptRules::Absent`] is stored: the field is kept, and empty, so
340/// it reads apart from a pass that predates the field.
341const RULES_ABSENT_STORED: &str = "";
342
343impl PromptRules {
344    fn of_prompt(sha: Option<&str>) -> PromptRules {
345        sha.map_or(PromptRules::Absent, |sha| PromptRules::Sha(sha.to_string()))
346    }
347
348    /// The stored `prompt_rules_sha`: `null` (not recorded), `""` (absent)
349    /// or the sha.
350    fn stored(&self) -> Option<&str> {
351        match self {
352            PromptRules::NotRecorded => None,
353            PromptRules::Absent => Some(RULES_ABSENT_STORED),
354            PromptRules::Sha(sha) => Some(sha),
355        }
356    }
357
358    fn from_stored(stored: Option<&str>) -> PromptRules {
359        match stored {
360            None => PromptRules::NotRecorded,
361            Some(RULES_ABSENT_STORED) => PromptRules::Absent,
362            Some(sha) => PromptRules::Sha(sha.to_string()),
363        }
364    }
365}
366
367fn pass_of(stored_record: &serde_json::Value) -> Option<Pass> {
368    Some(Pass {
369        verdict: stored_record.get("verdict")?.as_str()?.to_string(),
370        prompt_rules: PromptRules::from_stored(
371            stored_record
372                .get("prompt_rules_sha")
373                .and_then(serde_json::Value::as_str),
374        ),
375        candidates: Reviewed::from_json(stored_record.get("candidates")),
376    })
377}
378
379fn remembered_pass(repo: &str, sha: &str) -> Option<Pass> {
380    let f = pass_file(repo, sha)?;
381    pass_of(&serde_json::from_str(&std::fs::read_to_string(f).ok()?).ok()?)
382}
383
384/// Written only by the hook: from a transcript binding whose verdict is a
385/// pass, or from the person's `Overrule` of a `rework`.
386fn remember(
387    repo: &str,
388    sha: &str,
389    verdict: &str,
390    how: &str,
391    prompt_rules: &PromptRules,
392    candidates: &Reviewed,
393) -> Option<()> {
394    let f = pass_file(repo, sha)?;
395    private_dir(f.parent()?)?;
396    let body = serde_json::json!({
397        "repo": repo,
398        "tree": sha,
399        "verdict": verdict,
400        "agent": AGENT,
401        "how": how,
402        "prompt_rules_sha": prompt_rules.stored(),
403        "candidates": candidates.to_json(),
404    })
405    .to_string();
406    amont_agent_guard::atomic::write_atomic(&f, &body).ok()?;
407    journal::private(&f, 0o600);
408    Some(())
409}
410
411fn patch_file(repo: &str, patch: &str) -> Option<PathBuf> {
412    if repo.contains('/')
413        || repo.contains("..")
414        || patch.is_empty()
415        || !patch.bytes().all(|b| b.is_ascii_hexdigit())
416    {
417        return None;
418    }
419    Some(
420        store()?
421            .join("by-patch")
422            .join(repo)
423            .join(format!("{patch}.json")),
424    )
425}
426
427/// The pass remembered for this patch, when its rules match: the verdict
428/// and the tree it was given for.
429fn remembered_patch(repo: &str, key: &PatchKey) -> Option<(Pass, String)> {
430    let f = patch_file(repo, &key.patch)?;
431    let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(f).ok()?).ok()?;
432    if v.get("rules")?.as_str()? != key.rules {
433        return None;
434    }
435    Some((pass_of(&v)?, v.get("tree")?.as_str()?.to_string()))
436}
437
438/// Files a pass under its patch too, so a conflict-free rebase keeps it.
439fn remember_patch(
440    repo: &str,
441    key: &PatchKey,
442    tree: &str,
443    verdict: &str,
444    how: &str,
445    prompt_rules: &PromptRules,
446    candidates: &Reviewed,
447) -> Option<()> {
448    let f = patch_file(repo, &key.patch)?;
449    private_dir(f.parent()?)?;
450    let body = serde_json::json!({
451        "repo": repo,
452        "patch": key.patch,
453        "rules": key.rules,
454        "tree": tree,
455        "verdict": verdict,
456        "agent": AGENT,
457        "how": how,
458        "prompt_rules_sha": prompt_rules.stored(),
459        "candidates": candidates.to_json(),
460    })
461    .to_string();
462    amont_agent_guard::atomic::write_atomic(&f, &body).ok()?;
463    journal::private(&f, 0o600);
464    Some(())
465}
466
467// ---------------------------------------------------------------- the push
468
469/// What the hook found out about a push.
470pub enum Judged {
471    /// The rule has nothing to say: the reason is the journal's
472    /// `unconfirmed` reason.
473    Decline(&'static str),
474    /// The rule speaks: the reason to say, and the journal excerpt.
475    Say { reason: String, excerpt: String },
476    /// The agent the rule needs is not installed: said, never held.
477    Gap { reason: String, excerpt: String },
478}
479
480/// Judge the push in `cmd`, whose clause runs in `cwd`.
481pub fn judge_push(ctx: &Context, cmd: &Simple, stance: Stance) -> Judged {
482    use amont_agent_guard::push_target::{self, Kind, Push};
483    let (repo, targets) = match push_target::resolve_for(ctx, cmd) {
484        Push::Resolved { repo, targets } => (repo, targets),
485        Push::DryRun => return Judged::Decline("dry-run"),
486        Push::DeleteOnly => return Judged::Decline("delete-only"),
487        // amont refuses this land itself: nothing is published.
488        Push::Refused(_) => return Judged::Decline("land-refused"),
489        Push::Unresolvable { shape, .. } => {
490            // Under `deny` an unreadable shape is held, so `--all` is not the
491            // way around the gate; otherwise it is journalled and passes.
492            return if stance == Stance::Deny {
493                unknown(None, &format!("the push cannot be read: {shape}"))
494            } else {
495                Judged::Decline("unresolvable")
496            };
497        }
498    };
499    let Some(target) = targets.iter().find(|t| t.kind == Kind::Branch) else {
500        return Judged::Decline("no-branch");
501    };
502    let pushed = target.src.as_str();
503    // Commits some remote already has are not this push's work: seeding a
504    // fork reaches here with no default branch on the fork, and used to ask
505    // for a review of upstream's history.
506    if git::nothing_new(&repo, pushed) {
507        return Judged::Decline("nothing-new");
508    }
509    let Some(base) = default_base(&repo, &target.remote) else {
510        return unknown(
511            None,
512            &format!(
513                "no default branch of `{}` is known here (no {0}/HEAD, {0}/main or {0}/master)",
514                target.remote
515            ),
516        );
517    };
518    let Some(merge_base) = git::stdout_in(&repo, &["merge-base", pushed, &base]) else {
519        return unknown(
520            None,
521            &format!("`git merge-base {} {base}` failed", short(pushed)),
522        );
523    };
524    let Some(changed) = git::stdout_in(
525        &repo,
526        &["diff", "--name-only", &merge_base, pushed, "--", PLANS_PATH],
527    ) else {
528        return unknown(None, "`git diff` against the base failed");
529    };
530    if changed.trim().is_empty() {
531        return Judged::Decline("no-plan");
532    }
533    let tree = match content_id(&repo, pushed) {
534        Ok(t) => t,
535        Err(why) => return unknown(None, &why),
536    };
537    let Some(name) = repo_name(&repo) else {
538        return unknown(Some(&tree), "the repository has no name");
539    };
540    // The branch patch, computed once: a pass found here or below is filed
541    // under it, and a miss by tree is looked up by it.
542    let key = patch_key(&repo, &merge_base, pushed);
543    let plans: Vec<String> = changed
544        .lines()
545        .map(str::trim)
546        .filter(|l| !l.is_empty())
547        .map(str::to_string)
548        .collect();
549    // Said when the push is held: no earlier review could carry over.
550    let unkeyed = key
551        .as_ref()
552        .err()
553        .map(|why| format!(" No earlier review could carry over by patch: {why}."))
554        .unwrap_or_default();
555    let key = key.ok();
556    let push = crate::provenance::Push {
557        repo: &repo,
558        name: &name,
559        pushed,
560        patch: key.as_ref().map(|k| k.patch.as_str()),
561        plans: &plans,
562        target,
563    };
564    if let Some(Pass {
565        verdict,
566        prompt_rules,
567        candidates,
568    }) = remembered_pass(&name, &tree)
569    {
570        if let Some(k) = &key {
571            if remembered_patch(&name, k).is_none() {
572                remember_patch(
573                    &name,
574                    k,
575                    &tree,
576                    &verdict,
577                    "tree",
578                    &prompt_rules,
579                    &candidates,
580                );
581            }
582        }
583        let overruled = verdict == "overruled";
584        provenance(
585            &push,
586            if overruled {
587                Bound::Overruled
588            } else {
589                Bound::Reviewed
590            },
591            &tree,
592        );
593        if !overruled {
594            check_remembered_rules(&repo, &name, &tree, &prompt_rules);
595            check_candidates(&name, &tree, &candidates);
596        }
597        return Judged::Decline(if overruled { "overruled" } else { "reviewed" });
598    }
599    if let Some((pass, old_tree)) = key.as_ref().and_then(|k| remembered_patch(&name, k)) {
600        let Pass {
601            verdict,
602            prompt_rules,
603            candidates,
604        } = pass;
605        remember(
606            &name,
607            &tree,
608            &verdict,
609            &format!("patch {}", short(&old_tree)),
610            &prompt_rules,
611            &candidates,
612        );
613        let overruled = verdict == "overruled";
614        provenance(
615            &push,
616            if overruled {
617                Bound::Overruled
618            } else {
619                Bound::ByPatch
620            },
621            &old_tree,
622        );
623        if !overruled {
624            check_remembered_rules(&repo, &name, &tree, &prompt_rules);
625            check_candidates(&name, &tree, &candidates);
626        }
627        return Judged::Decline(if overruled {
628            "overruled-by-patch"
629        } else {
630            "reviewed-by-patch"
631        });
632    }
633    let Some(transcript) = ctx.transcript else {
634        return unknown(Some(&tree), "the payload carried no transcript_path");
635    };
636    let reviews = match reviews(transcript) {
637        Ok(r) => r,
638        Err(e) => {
639            return unknown(
640                Some(&tree),
641                &format!(
642                    "the transcript {} could not be read: {e}",
643                    transcript.display()
644                ),
645            )
646        }
647    };
648    let mine: Vec<&Review> = reviews.iter().filter(|r| r.repo == name).collect();
649    if let Some(latest) = mine.iter().rev().find(|r| r.sha == tree) {
650        return match latest.verdict {
651            Some(Verdict::Rework) | None => Judged::Say {
652                reason: format!(
653                    "the implementation review of {name} tree {} said {}, and no delta followed.",
654                    short(&tree),
655                    latest
656                        .verdict
657                        .map(Verdict::as_str)
658                        .unwrap_or("no verdict this guard can read")
659                ),
660                excerpt: format!(
661                    "tree={} review=rework verdict={}",
662                    short(&tree),
663                    latest.verdict.map(Verdict::as_str).unwrap_or("-")
664                ),
665            },
666            Some(v) => {
667                let prompt_rules = PromptRules::of_prompt(latest.rules_sha.as_deref());
668                let candidates = &latest.candidates;
669                remember(
670                    &name,
671                    &tree,
672                    v.as_str(),
673                    "transcript",
674                    &prompt_rules,
675                    candidates,
676                );
677                if let Some(k) = &key {
678                    remember_patch(
679                        &name,
680                        k,
681                        &tree,
682                        v.as_str(),
683                        "transcript",
684                        &prompt_rules,
685                        candidates,
686                    );
687                }
688                provenance(&push, Bound::Reviewed, &tree);
689                check_remembered_rules(&repo, &name, &tree, &prompt_rules);
690                check_candidates(&name, &tree, candidates);
691                Judged::Decline("reviewed")
692            }
693        };
694    }
695    if let Some(older) = mine.last() {
696        return Judged::Say {
697            reason: format!(
698                "the implementation review of {name} read tree {}; this push carries tree {} (stale).{unkeyed}",
699                short(&older.sha),
700                short(&tree)
701            ),
702            excerpt: format!("tree={} review=stale verdict=-", short(&tree)),
703        };
704    }
705    if !crate::agents::carried(crate::agents::Need::ImplementationReview, &repo)
706        && !std::env::current_dir().is_ok_and(|cwd| {
707            crate::agents::carried(crate::agents::Need::ImplementationReview, &cwd)
708        })
709    {
710        return Judged::Gap {
711            reason: crate::agents::gap_line(crate::agents::Need::ImplementationReview),
712            excerpt: format!("tree={} review=gap verdict=-", short(&tree)),
713        };
714    }
715    Judged::Say {
716        reason: format!(
717            "no implementation review of {name} tree {} is in this session.{unkeyed}",
718            short(&tree)
719        ),
720        excerpt: format!("tree={} review=missing verdict=-", short(&tree)),
721    }
722}
723
724/// The advise-only comparison of the rules a passing review read with the
725/// rules now: journalled, never holding the push. `prompt_rules` is what the
726/// pass remembered: `NotRecorded` (from before the line was stored) journals
727/// `rules-unknown`; `Absent` and `Sha` are compared with `rules_sha` now.
728fn check_remembered_rules(repo: &Path, name: &str, tree: &str, prompt_rules: &PromptRules) {
729    let excerpt = match prompt_rules {
730        PromptRules::NotRecorded => "review=rules-unknown",
731        PromptRules::Absent => {
732            crate::review_brief::rules_excerpt(None, &crate::review_brief::rules_sha(repo))
733        }
734        PromptRules::Sha(seen) => {
735            crate::review_brief::rules_excerpt(Some(seen), &crate::review_brief::rules_sha(repo))
736        }
737    };
738    note(
739        "rules-checked",
740        "-",
741        name,
742        &format!("tree={} {excerpt}", short(tree)),
743    );
744}
745
746/// What a passing review did with the candidates adr-lint listed: one
747/// `candidates-checked` note, and one ruling record per ruled candidate.
748/// Never holds the push: a ruling that cannot be written is journalled.
749fn check_candidates(name: &str, tree: &str, candidates: &Reviewed) {
750    note(
751        "candidates-checked",
752        "-",
753        name,
754        &format!("tree={} {}", short(tree), candidates.excerpt()),
755    );
756    if let Err(why) = crate::candidates_block::append_rulings(name, tree, candidates) {
757        note(
758            "rulings-write-failed",
759            "-",
760            name,
761            &format!("tree={} {why}", short(tree)),
762        );
763    }
764}
765
766/// Files the provenance record of a pass and journals what came of it. A
767/// failure never changes the judgment.
768fn provenance(push: &crate::provenance::Push, review: Bound, review_content: &str) {
769    use crate::provenance::Filing;
770    match crate::provenance::file(push, review, review_content) {
771        Filing::Deferred => note(
772            "provenance-deferred",
773            "-",
774            push.name,
775            "the preview has not stood",
776        ),
777        Filing::Written(file) => note(
778            "provenance-written",
779            "-",
780            push.name,
781            &file.display().to_string(),
782        ),
783        Filing::Kept(file) => note(
784            "provenance-kept",
785            "-",
786            push.name,
787            &file.display().to_string(),
788        ),
789        Filing::Failed(why) => note("provenance-write-failed", "-", push.name, &why),
790    }
791}
792
793fn unknown(tree: Option<&str>, why: &str) -> Judged {
794    Judged::Say {
795        reason: format!("whether this push was reviewed cannot be told: {why}"),
796        excerpt: format!(
797            "tree={} review=unknown verdict=-",
798            tree.map(short).unwrap_or("-")
799        ),
800    }
801}
802
803/// The remote's default branch as last fetched: `<remote>/HEAD`, else
804/// `<remote>/main`, else `<remote>/master`. Never `HEAD`: with the pushed
805/// commit as its own base every push would show no plan.
806pub(crate) fn default_base(repo: &Path, remote: &str) -> Option<String> {
807    [
808        format!("refs/remotes/{remote}/HEAD"),
809        format!("refs/remotes/{remote}/main"),
810        format!("refs/remotes/{remote}/master"),
811    ]
812    .into_iter()
813    .find(|r| {
814        git::stdout_in(
815            repo,
816            &[
817                "rev-parse",
818                "--verify",
819                "--quiet",
820                "--end-of-options",
821                &format!("{r}^{{commit}}"),
822            ],
823        )
824        .is_some_and(|s| !s.is_empty())
825    })
826}
827
828/// The rule's `confirm`, for a push: what to say, or why not.
829pub fn confirm_push(ctx: &Context, cmd: &Simple, stance: Stance) -> Confirmed {
830    match judge_push(ctx, cmd, stance) {
831        Judged::Decline(why) => Confirmed::No(why),
832        Judged::Say { reason, excerpt } => Confirmed::YesSaying {
833            remedy: None,
834            floor: None,
835            ceiling: None,
836            reason,
837            excerpt,
838        },
839        // Said at most as advice: the review cannot be had here, so refusing
840        // the push would hold it for something nobody can supply.
841        Judged::Gap { reason, excerpt } => Confirmed::YesSaying {
842            remedy: Some(String::new()),
843            floor: None,
844            ceiling: Some(Stance::Advise),
845            reason,
846            excerpt,
847        },
848    }
849}
850
851// ---------------------------------------------------------------- the guards
852
853/// Whether a path is inside the pass-file store, compared lexically (`.`
854/// and `..` folded, no symlink followed) and, where the store exists, by
855/// its canonical form too. Only the hook writes there.
856pub fn guards_path(path: &Path) -> bool {
857    HOOK_ONLY_DIRS
858        .iter()
859        .filter_map(|d| Some(journal::dir()?.join(d)))
860        .any(|store| guards_store(path, &store))
861}
862
863fn guards_store(path: &Path, store: &Path) -> bool {
864    let here = lexical(path);
865    if here.starts_with(lexical(store)) {
866        return true;
867    }
868    if let Ok(real) = std::fs::canonicalize(store) {
869        if here.starts_with(&real) {
870            return true;
871        }
872        // The path's own existing ancestor, resolved.
873        if let Some(parent) = here.parent() {
874            if let Ok(real_parent) = std::fs::canonicalize(parent) {
875                return real_parent.starts_with(&real);
876            }
877        }
878    }
879    false
880}
881
882/// A path with `.` and `..` folded lexically.
883pub fn lexical(path: &Path) -> PathBuf {
884    let mut out = PathBuf::new();
885    for c in path.components() {
886        match c {
887            Component::CurDir => {}
888            Component::ParentDir => {
889                out.pop();
890            }
891            other => out.push(other.as_os_str()),
892        }
893    }
894    out
895}
896
897/// Programs whose operand naming the store is taken as a write. A reader
898/// (`ls`, `cat`, `find`, `grep`, `stat`, …) is not on this list, so looking
899/// at the record is allowed; the list is the Bash half of a best-effort
900/// guard, since a parser cannot see inside `python -c` beyond its words.
901const WRITERS: &[&str] = &[
902    "rm", "mv", "cp", "tee", "touch", "install", "truncate", "ln", "chmod", "chown", "mkdir",
903    "rmdir", "dd", "sed", "perl", "ruby", "node", "deno", "python", "python3", "sh", "bash", "zsh",
904    "dash", "fish", "sudo", "xargs", "rsync", "tar", "unzip", "chezmoi", "git",
905];
906
907/// The first clause of a command that would WRITE into the store: a redirect
908/// into it (`> …/by-tree/…`), or a program on [`WRITERS`] with a word naming
909/// it. A read — `ls`, `cat`, `find` of the pass files — passes: the record is
910/// there to be looked at. Returns the clause's span.
911pub fn store_clause(parsed: &amont_agent_guard::shell::Parsed) -> Option<std::ops::Range<usize>> {
912    parsed
913        .clauses()
914        .iter()
915        .find(|cmd| {
916            let redirected = cmd
917                .redirects
918                .iter()
919                .any(|(op, w)| op.contains('>') && names_store(&w.text));
920            let writer = cmd
921                .program()
922                .map(|p| p.rsplit('/').next().unwrap_or(p))
923                .is_some_and(|p| WRITERS.contains(&p) || p.starts_with("python"));
924            redirected || (writer && cmd.words.iter().any(|w| names_store(&w.text)))
925        })
926        .map(|cmd| cmd.at..cmd.end)
927}
928
929/// Whether a shell word names the store, spelled with either separator:
930/// Windows writes the same directory with backslashes.
931pub fn names_store(word: &str) -> bool {
932    let word = word.replace('\\', "/");
933    HOOK_ONLY_DIRS.iter().any(|d| {
934        word.contains(&format!("amont-agent/{d}/"))
935            || word.contains(&format!("amont-agent/{d}")) && word.ends_with(d)
936    })
937}
938
939/// The directories under the config dir that only the hook writes.
940const HOOK_ONLY_DIRS: &[&str] = &[RULE_ID, "provenance"];
941
942pub const GUARD_REASON: &str =
943    "files under amont-agent/implementation-review/ and amont-agent/provenance/ are written by the hook alone: the pass files from a completed review in this session's transcript or the person's Overrule, the provenance records from a push the hook passed.";
944pub const GUARD_REMEDY: &str =
945    "Run the review (worktree-task F4b) or ask the person; never write the record by hand.";
946
947// ---------------------------------------------------------------- the person
948
949/// The `(repo, sha)` a marked question names:
950/// `[implementation-review <repo>@<sha64>]`, split at the last `@`.
951pub fn marker(question: &str) -> Option<(String, String)> {
952    let start = question.find(MARKER)? + MARKER.len();
953    let end = start + question[start..].find(']')?;
954    let inner = question[start..end].trim();
955    let at = inner.rfind('@')?;
956    let repo = inner[..at].trim().to_string();
957    let sha = inner[at + 1..].trim().to_ascii_lowercase();
958    (!repo.is_empty() && is_sha256(&sha)).then_some((repo, sha))
959}
960
961fn asks_dir() -> Option<PathBuf> {
962    Some(store()?.join("asks"))
963}
964
965fn now_ms() -> u64 {
966    std::time::SystemTime::now()
967        .duration_since(std::time::UNIX_EPOCH)
968        .map(|d| d.as_millis() as u64)
969        .unwrap_or(0)
970}
971
972/// Before an `AskUserQuestion` runs: remember a marked question, and whether
973/// it arrived pre-answered, so the answer can be trusted or not.
974pub fn on_pre_ask(ask: &amont_agent_guard::payload::Ask) {
975    let marked: Vec<(String, String)> = ask.questions.iter().filter_map(|q| marker(q)).collect();
976    if marked.is_empty() || ask.tool_use_id.is_empty() || ask.tool_use_id.contains('/') {
977        return;
978    }
979    let Some(d) = asks_dir() else { return };
980    if private_dir(&d).is_none() {
981        return;
982    }
983    let names: Vec<String> = marked.iter().map(|(r, s)| format!("{r}@{s}")).collect();
984    let _ = amont_agent_guard::atomic::write_atomic(
985        &d.join(&ask.tool_use_id),
986        &format!(
987            "{}\t{}\t{}\t{}\n",
988            ask.session.replace(['\t', '\n'], " "),
989            ask.prefilled,
990            names.join(","),
991            now_ms()
992        ),
993    );
994    if ask.prefilled {
995        note("prefilled", &ask.session, &marked[0].0, &names.join(","));
996    }
997}
998
999/// After an `AskUserQuestion`: an `Overrule` of a `rework` this session's
1000/// transcript shows is remembered as a pass; anything else changes nothing.
1001/// Returns what the session must hear when an overrule bound nothing.
1002pub fn on_post_ask(ask: &amont_agent_guard::payload::Ask) -> Option<String> {
1003    if !ask.questions.iter().any(|q| marker(q).is_some()) {
1004        return None;
1005    }
1006    let recorded = asks_dir()
1007        .map(|d| d.join(&ask.tool_use_id))
1008        .filter(|_| !ask.tool_use_id.is_empty() && !ask.tool_use_id.contains('/'))
1009        .and_then(|p| {
1010            let t = std::fs::read_to_string(&p).ok();
1011            let _ = std::fs::remove_file(&p);
1012            t
1013        });
1014    let Some(recorded) = recorded else {
1015        note(
1016            "unrecorded",
1017            &ask.session,
1018            "-",
1019            "marked question with no PreToolUse record",
1020        );
1021        return None;
1022    };
1023    let f: Vec<&str> = recorded.trim_end().split('\t').collect();
1024    if f.len() != 4 || f[0] != ask.session || f[1] != "false" {
1025        return None;
1026    }
1027    let mut said: Vec<String> = Vec::new();
1028    for question in &ask.questions {
1029        let Some((repo, sha)) = marker(question) else {
1030            continue;
1031        };
1032        let label = ask
1033            .answers
1034            .iter()
1035            .find(|(q, _)| q == question)
1036            .map(|(_, l)| l.as_str())
1037            .unwrap_or("");
1038        if label != OVERRULE {
1039            if !label.is_empty() {
1040                note(
1041                    "answered",
1042                    &ask.session,
1043                    &repo,
1044                    &format!("tree={} answer={}", short(&sha), label.to_ascii_lowercase()),
1045                );
1046            }
1047            continue;
1048        }
1049        let Some(transcript) = ask.transcript.as_deref() else {
1050            note(
1051                "unknown",
1052                &ask.session,
1053                &repo,
1054                &format!("tree={} overrule without a transcript", short(&sha)),
1055            );
1056            said.push(format!(
1057                "The answer Overrule bound NOTHING for {repo} tree {}: the question's event carried no transcript, so the rework it overrules cannot be seen.",
1058                short(&sha)
1059            ));
1060            continue;
1061        };
1062        let reworked = reviews(transcript)
1063            .map(|rs| {
1064                rs.iter()
1065                    .rev()
1066                    .find(|r| r.repo == repo && r.sha == sha)
1067                    .is_some_and(|r| matches!(r.verdict, Some(Verdict::Rework) | None))
1068            })
1069            .unwrap_or(false);
1070        if !reworked {
1071            note(
1072                "unbound",
1073                &ask.session,
1074                &repo,
1075                &format!("tree={} overrule with no rework to overrule", short(&sha)),
1076            );
1077            said.push(format!(
1078                "The answer Overrule bound NOTHING for {repo} tree {}: no completed implementation review of that tree said rework in this session.",
1079                short(&sha)
1080            ));
1081            continue;
1082        }
1083        remember(
1084            &repo,
1085            &sha,
1086            "overruled",
1087            "person",
1088            &PromptRules::NotRecorded,
1089            &Reviewed::not_recorded(),
1090        );
1091        note(
1092            "overruled",
1093            &ask.session,
1094            &repo,
1095            &format!("tree={} review=overruled verdict=rework", short(&sha)),
1096        );
1097    }
1098    (!said.is_empty()).then(|| said.join("\n\n"))
1099}
1100
1101/// One journal line under the rule's id, for what happens away from a push.
1102fn note(outcome: &str, session: &str, repo: &str, excerpt: &str) {
1103    journal::record(&journal::Entry {
1104        rule: RULE_ID,
1105        stance: "-",
1106        outcome,
1107        session,
1108        repo,
1109        mode: "-",
1110        excerpt,
1111    });
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117    use crate::content_id::content_id_of;
1118
1119    /// A real session's launch of an implementation reviewer, resumed twice
1120    /// with `SendMessage` (2026-10-08), anonymised.
1121    const RESUMED: &str = include_str!("../../../tests/fixtures/sendmessage-resume.jsonl");
1122    const LAUNCH_TREE: &str = "6066ad987edfbbb3a514b6d9247b126a77a316db2e1350c226ffd14593a0ba35";
1123    const ROUND_2: &str = "b196a592b3b76cb75155b14491291a45602ae66c6f20ef18e4413a34169556ab";
1124    const ROUND_3: &str = "85f71e67db10c7737c55b27a3c5753b2617c58dec0d58e7ebae5085898cc12d7";
1125
1126    /// The known answers attest's SPEC quotes under "Content id". Changing
1127    /// one is changing the definition: every stored review and approval
1128    /// would stop matching.
1129    #[test]
1130    fn the_content_id_gives_the_spec_known_answers() {
1131        let o1 = "1".repeat(40);
1132        let o2 = "2".repeat(40);
1133        let id = |recs: &[String]| {
1134            content_id_of(
1135                &recs
1136                    .iter()
1137                    .map(|r| r.as_bytes().to_vec())
1138                    .collect::<Vec<_>>(),
1139            )
1140        };
1141        // no records: the sha256 of the empty string
1142        assert_eq!(
1143            id(&[]),
1144            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1145        );
1146        assert_eq!(
1147            id(&[format!("100644 blob {o1}\tREADME.md")]),
1148            "6f0214fffd79cb8eb5afb2283e46911f5f9022a25b3f339ae8a50a1ee223a0b2"
1149        );
1150        // every record under the top-level docs/plans/ is left out, a
1151        // symlink too; a near name and a nested docs/plans/ are kept
1152        let several = [
1153            format!("100644 blob {o1}\tREADME.md"),
1154            format!("100644 blob {o2}\tdocs/plans/a.md"),
1155            format!("100644 blob {o2}\tdocs/plans-old/a.md"),
1156            format!("120000 blob {o1}\tdocs/plans/link"),
1157            format!("100644 blob {o1}\tx/docs/plans/a.md"),
1158        ];
1159        let several_id = "0fca850e419d8a202129087fd90f00fe285c4b9cc63b42f4db6162e0a2b12eaf";
1160        assert_eq!(id(&several), several_id);
1161        // so a plan's existence does not count, unlike attest's canon(T)
1162        assert_eq!(
1163            id(&[several[0].clone(), several[2].clone(), several[4].clone()]),
1164            several_id
1165        );
1166        // a raw path with a newline: no quoting, no decoding
1167        assert_eq!(
1168            id(&[
1169                format!("100644 blob {o1}\tdocs/a\nb.md"),
1170                format!("100644 blob {o2}\tdocs/plans/c\nd.md"),
1171            ]),
1172            "88a9c5d4a7b3892be98a8492631b3c436954c2000a038b5d641bd054e76e19e0"
1173        );
1174    }
1175
1176    fn reviews_of(text: &str) -> Vec<(String, Option<Verdict>)> {
1177        completed_agents_resumable(text.as_bytes(), AGENT)
1178            .into_iter()
1179            .flat_map(|c| {
1180                let v = verdict_of(&c.result);
1181                parse_blocks(&c.prompt)
1182                    .into_iter()
1183                    .map(move |(_, sha)| (sha, v))
1184            })
1185            .collect()
1186    }
1187
1188    #[test]
1189    fn each_resumed_round_is_a_review_of_its_own_tree() {
1190        assert_eq!(
1191            reviews_of(RESUMED),
1192            vec![
1193                (LAUNCH_TREE.to_string(), Some(Verdict::ApproveWithChanges)),
1194                (ROUND_2.to_string(), Some(Verdict::Approve)),
1195                (ROUND_3.to_string(), Some(Verdict::Approve)),
1196            ],
1197            "the launch keeps its own verdict"
1198        );
1199    }
1200
1201    #[test]
1202    fn the_shared_reader_counts_the_launch_alone() {
1203        // What the plan-review panel reads: a resume is not a launch.
1204        let launches = crate::plan_review::completed_agents(RESUMED.as_bytes(), AGENT);
1205        assert_eq!(launches.len(), 1);
1206        assert!(launches[0].prompt.contains(LAUNCH_TREE));
1207        assert_eq!(
1208            verdict_of(&launches[0].result),
1209            Some(Verdict::ApproveWithChanges),
1210            "the rounds' notifications do not overwrite the launch's"
1211        );
1212    }
1213
1214    #[test]
1215    fn a_resume_of_an_agent_that_is_not_a_reviewer_does_not_count() {
1216        let other = RESUMED.replace(
1217            "\"subagent_type\":\"implementation-review\"",
1218            "\"subagent_type\":\"general-purpose\"",
1219        );
1220        assert!(reviews_of(&other).is_empty());
1221        // The message went to another agent than the one that resumed.
1222        let elsewhere = RESUMED.replace(
1223            "\"to\":\"aa339dbce3244a139\"",
1224            "\"to\":\"a0000000000000000\"",
1225        );
1226        assert_eq!(reviews_of(&elsewhere).len(), 1);
1227    }
1228
1229    #[test]
1230    fn a_resumed_round_needs_its_own_agents_structured_notification() {
1231        // Another task's id on the round's notifications.
1232        let lines: Vec<String> = RESUMED
1233            .lines()
1234            .map(|l| {
1235                if l.contains("\"origin\":{\"kind\":\"task-notification\"") {
1236                    l.replace(
1237                        "<task-id>aa339dbce3244a139</task-id>",
1238                        "<task-id>a0000000000000000</task-id>",
1239                    )
1240                } else {
1241                    l.to_string()
1242                }
1243            })
1244            .collect();
1245        assert_eq!(reviews_of(&lines.join("\n")).len(), 1);
1246
1247        // The round's notifications echoed in a tool result and typed by a
1248        // person: neither was queued by Claude Code.
1249        let forged: Vec<String> = RESUMED
1250            .lines()
1251            .map(|l| {
1252                if l.contains("\"origin\":{\"kind\":\"task-notification\"") {
1253                    l.replace(
1254                        "\"origin\":{\"kind\":\"task-notification\"",
1255                        "\"origin\":{\"kind\":\"human\"",
1256                    )
1257                } else {
1258                    l.to_string()
1259                }
1260            })
1261            .collect();
1262        assert_eq!(reviews_of(&forged.join("\n")).len(), 1);
1263    }
1264
1265    #[test]
1266    fn a_block_round_trips_and_a_short_sha_does_not_bind() {
1267        let sha = "a".repeat(64);
1268        let b = block("amont-agent", &sha);
1269        assert_eq!(b, format!("<<<TREE repo=amont-agent sha={sha}>>>"));
1270        assert_eq!(
1271            parse_blocks(&format!("Round 1.\n{b}\nbrief")),
1272            vec![("amont-agent".to_string(), sha.clone())]
1273        );
1274        assert!(parse_blocks("<<<TREE repo=x sha=abc123>>>").is_empty());
1275        assert!(parse_blocks("<<<TREE sha=abc>>>").is_empty());
1276        assert!(parse_blocks(&format!("<<<TREE repo= sha={sha}>>>")).is_empty());
1277    }
1278
1279    #[test]
1280    fn the_sha_is_lowercased() {
1281        let sha = "A".repeat(64);
1282        let got = parse_blocks(&block("r", &sha));
1283        assert_eq!(got[0].1, "a".repeat(64));
1284    }
1285
1286    #[test]
1287    fn the_verdict_is_the_first_verdict_line() {
1288        assert_eq!(
1289            verdict_of("Verdict: approve\nFindings:"),
1290            Some(Verdict::Approve)
1291        );
1292        assert_eq!(
1293            verdict_of("**Verdict: approve-with-changes**\n"),
1294            Some(Verdict::ApproveWithChanges)
1295        );
1296        assert_eq!(
1297            verdict_of("preamble\nVerdict: rework"),
1298            Some(Verdict::Rework)
1299        );
1300        assert_eq!(verdict_of("no verdict here"), None);
1301        assert_eq!(verdict_of("Verdict: maybe"), None);
1302    }
1303
1304    #[test]
1305    fn the_marker_splits_at_the_last_at_and_wants_64_hex() {
1306        let sha = "b".repeat(64);
1307        assert_eq!(
1308            marker(&format!("[implementation-review my@repo@{sha}] Overrule?")),
1309            Some(("my@repo".to_string(), sha.clone()))
1310        );
1311        assert_eq!(marker("[implementation-review repo@abc] x"), None);
1312        assert_eq!(marker(&format!("[implementation-review @{sha}]")), None);
1313        assert_eq!(marker("[preview a1] x"), None);
1314    }
1315
1316    #[test]
1317    fn lexical_folds_dot_and_dotdot() {
1318        assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
1319    }
1320
1321    #[test]
1322    fn a_read_of_the_store_passes_and_a_write_does_not() {
1323        use amont_agent_guard::shell::lex;
1324        let store = "~/.claude/amont-agent/implementation-review/by-tree/r/x.json";
1325        for read in [
1326            format!("ls {store}"),
1327            format!("cat {store} | head"),
1328            "find ~/.claude/amont-agent/implementation-review -type f".to_string(),
1329            format!("grep verdict {store}"),
1330        ] {
1331            assert!(store_clause(&lex(&read)).is_none(), "{read}");
1332        }
1333        for write in [
1334            format!("echo '{{}}' > {store}"),
1335            format!("printf x >> {store}"),
1336            format!("rm -f {store}"),
1337            format!("cp /tmp/x {store}"),
1338            format!("python3 -c 'open(\"{store}\",\"w\")'"),
1339            format!("tee {store} < /dev/null"),
1340            format!("cat x | sudo tee {store}"),
1341        ] {
1342            assert!(store_clause(&lex(&write)).is_some(), "{write}");
1343        }
1344    }
1345
1346    #[test]
1347    fn a_word_names_the_store_by_its_directory() {
1348        assert!(names_store(
1349            "/Users/me/.claude/amont-agent/implementation-review/by-tree/r/x.json"
1350        ));
1351        assert!(names_store("~/.claude/amont-agent/implementation-review"));
1352        assert!(names_store(
1353            "C:\\Users\\me\\.claude\\amont-agent\\implementation-review\\by-tree\\r\\x.json"
1354        ));
1355        assert!(!names_store("~/.claude/amont-agent/journal.log"));
1356        assert!(!names_store("implementation-review"));
1357        // The outcome ledger lives beside the store, not in it.
1358        assert!(!names_store(
1359            "~/.claude/amont-agent/implementation-review-outcomes/outcomes.jsonl"
1360        ));
1361    }
1362}