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}
477
478/// Judge the push in `cmd`, whose clause runs in `cwd`.
479pub fn judge_push(ctx: &Context, cmd: &Simple, stance: Stance) -> Judged {
480    use amont_agent_guard::push_target::{self, Kind, Push};
481    let (repo, targets) = match push_target::resolve_for(ctx, cmd) {
482        Push::Resolved { repo, targets } => (repo, targets),
483        Push::DryRun => return Judged::Decline("dry-run"),
484        Push::DeleteOnly => return Judged::Decline("delete-only"),
485        // amont refuses this land itself: nothing is published.
486        Push::Refused(_) => return Judged::Decline("land-refused"),
487        Push::Unresolvable { shape, .. } => {
488            // Under `deny` an unreadable shape is held, so `--all` is not the
489            // way around the gate; otherwise it is journalled and passes.
490            return if stance == Stance::Deny {
491                unknown(None, &format!("the push cannot be read: {shape}"))
492            } else {
493                Judged::Decline("unresolvable")
494            };
495        }
496    };
497    let Some(target) = targets.iter().find(|t| t.kind == Kind::Branch) else {
498        return Judged::Decline("no-branch");
499    };
500    let pushed = target.src.as_str();
501    // Commits some remote already has are not this push's work: seeding a
502    // fork reaches here with no default branch on the fork, and used to ask
503    // for a review of upstream's history.
504    if git::nothing_new(&repo, pushed) {
505        return Judged::Decline("nothing-new");
506    }
507    let Some(base) = default_base(&repo, &target.remote) else {
508        return unknown(
509            None,
510            &format!(
511                "no default branch of `{}` is known here (no {0}/HEAD, {0}/main or {0}/master)",
512                target.remote
513            ),
514        );
515    };
516    let Some(merge_base) = git::stdout_in(&repo, &["merge-base", pushed, &base]) else {
517        return unknown(
518            None,
519            &format!("`git merge-base {} {base}` failed", short(pushed)),
520        );
521    };
522    let Some(changed) = git::stdout_in(
523        &repo,
524        &["diff", "--name-only", &merge_base, pushed, "--", PLANS_PATH],
525    ) else {
526        return unknown(None, "`git diff` against the base failed");
527    };
528    if changed.trim().is_empty() {
529        return Judged::Decline("no-plan");
530    }
531    let tree = match content_id(&repo, pushed) {
532        Ok(t) => t,
533        Err(why) => return unknown(None, &why),
534    };
535    let Some(name) = repo_name(&repo) else {
536        return unknown(Some(&tree), "the repository has no name");
537    };
538    // The branch patch, computed once: a pass found here or below is filed
539    // under it, and a miss by tree is looked up by it.
540    let key = patch_key(&repo, &merge_base, pushed);
541    let plans: Vec<String> = changed
542        .lines()
543        .map(str::trim)
544        .filter(|l| !l.is_empty())
545        .map(str::to_string)
546        .collect();
547    // Said when the push is held: no earlier review could carry over.
548    let unkeyed = key
549        .as_ref()
550        .err()
551        .map(|why| format!(" No earlier review could carry over by patch: {why}."))
552        .unwrap_or_default();
553    let key = key.ok();
554    let push = crate::provenance::Push {
555        repo: &repo,
556        name: &name,
557        pushed,
558        patch: key.as_ref().map(|k| k.patch.as_str()),
559        plans: &plans,
560        target,
561    };
562    if let Some(Pass {
563        verdict,
564        prompt_rules,
565        candidates,
566    }) = remembered_pass(&name, &tree)
567    {
568        if let Some(k) = &key {
569            if remembered_patch(&name, k).is_none() {
570                remember_patch(
571                    &name,
572                    k,
573                    &tree,
574                    &verdict,
575                    "tree",
576                    &prompt_rules,
577                    &candidates,
578                );
579            }
580        }
581        let overruled = verdict == "overruled";
582        provenance(
583            &push,
584            if overruled {
585                Bound::Overruled
586            } else {
587                Bound::Reviewed
588            },
589            &tree,
590        );
591        if !overruled {
592            check_remembered_rules(&repo, &name, &tree, &prompt_rules);
593            check_candidates(&name, &tree, &candidates);
594        }
595        return Judged::Decline(if overruled { "overruled" } else { "reviewed" });
596    }
597    if let Some((pass, old_tree)) = key.as_ref().and_then(|k| remembered_patch(&name, k)) {
598        let Pass {
599            verdict,
600            prompt_rules,
601            candidates,
602        } = pass;
603        remember(
604            &name,
605            &tree,
606            &verdict,
607            &format!("patch {}", short(&old_tree)),
608            &prompt_rules,
609            &candidates,
610        );
611        let overruled = verdict == "overruled";
612        provenance(
613            &push,
614            if overruled {
615                Bound::Overruled
616            } else {
617                Bound::ByPatch
618            },
619            &old_tree,
620        );
621        if !overruled {
622            check_remembered_rules(&repo, &name, &tree, &prompt_rules);
623            check_candidates(&name, &tree, &candidates);
624        }
625        return Judged::Decline(if overruled {
626            "overruled-by-patch"
627        } else {
628            "reviewed-by-patch"
629        });
630    }
631    let Some(transcript) = ctx.transcript else {
632        return unknown(Some(&tree), "the payload carried no transcript_path");
633    };
634    let reviews = match reviews(transcript) {
635        Ok(r) => r,
636        Err(e) => {
637            return unknown(
638                Some(&tree),
639                &format!(
640                    "the transcript {} could not be read: {e}",
641                    transcript.display()
642                ),
643            )
644        }
645    };
646    let mine: Vec<&Review> = reviews.iter().filter(|r| r.repo == name).collect();
647    if let Some(latest) = mine.iter().rev().find(|r| r.sha == tree) {
648        return match latest.verdict {
649            Some(Verdict::Rework) | None => Judged::Say {
650                reason: format!(
651                    "the implementation review of {name} tree {} said {}, and no delta followed.",
652                    short(&tree),
653                    latest
654                        .verdict
655                        .map(Verdict::as_str)
656                        .unwrap_or("no verdict this guard can read")
657                ),
658                excerpt: format!(
659                    "tree={} review=rework verdict={}",
660                    short(&tree),
661                    latest.verdict.map(Verdict::as_str).unwrap_or("-")
662                ),
663            },
664            Some(v) => {
665                let prompt_rules = PromptRules::of_prompt(latest.rules_sha.as_deref());
666                let candidates = &latest.candidates;
667                remember(
668                    &name,
669                    &tree,
670                    v.as_str(),
671                    "transcript",
672                    &prompt_rules,
673                    candidates,
674                );
675                if let Some(k) = &key {
676                    remember_patch(
677                        &name,
678                        k,
679                        &tree,
680                        v.as_str(),
681                        "transcript",
682                        &prompt_rules,
683                        candidates,
684                    );
685                }
686                provenance(&push, Bound::Reviewed, &tree);
687                check_remembered_rules(&repo, &name, &tree, &prompt_rules);
688                check_candidates(&name, &tree, candidates);
689                Judged::Decline("reviewed")
690            }
691        };
692    }
693    if let Some(older) = mine.last() {
694        return Judged::Say {
695            reason: format!(
696                "the implementation review of {name} read tree {}; this push carries tree {} (stale).{unkeyed}",
697                short(&older.sha),
698                short(&tree)
699            ),
700            excerpt: format!("tree={} review=stale verdict=-", short(&tree)),
701        };
702    }
703    Judged::Say {
704        reason: format!(
705            "no implementation review of {name} tree {} is in this session.{unkeyed}",
706            short(&tree)
707        ),
708        excerpt: format!("tree={} review=missing verdict=-", short(&tree)),
709    }
710}
711
712/// The advise-only comparison of the rules a passing review read with the
713/// rules now: journalled, never holding the push. `prompt_rules` is what the
714/// pass remembered: `NotRecorded` (from before the line was stored) journals
715/// `rules-unknown`; `Absent` and `Sha` are compared with `rules_sha` now.
716fn check_remembered_rules(repo: &Path, name: &str, tree: &str, prompt_rules: &PromptRules) {
717    let excerpt = match prompt_rules {
718        PromptRules::NotRecorded => "review=rules-unknown",
719        PromptRules::Absent => {
720            crate::review_brief::rules_excerpt(None, &crate::review_brief::rules_sha(repo))
721        }
722        PromptRules::Sha(seen) => {
723            crate::review_brief::rules_excerpt(Some(seen), &crate::review_brief::rules_sha(repo))
724        }
725    };
726    note(
727        "rules-checked",
728        "-",
729        name,
730        &format!("tree={} {excerpt}", short(tree)),
731    );
732}
733
734/// What a passing review did with the candidates adr-lint listed: one
735/// `candidates-checked` note, and one ruling record per ruled candidate.
736/// Never holds the push: a ruling that cannot be written is journalled.
737fn check_candidates(name: &str, tree: &str, candidates: &Reviewed) {
738    note(
739        "candidates-checked",
740        "-",
741        name,
742        &format!("tree={} {}", short(tree), candidates.excerpt()),
743    );
744    if let Err(why) = crate::candidates_block::append_rulings(name, tree, candidates) {
745        note(
746            "rulings-write-failed",
747            "-",
748            name,
749            &format!("tree={} {why}", short(tree)),
750        );
751    }
752}
753
754/// Files the provenance record of a pass and journals what came of it. A
755/// failure never changes the judgment.
756fn provenance(push: &crate::provenance::Push, review: Bound, review_content: &str) {
757    use crate::provenance::Filing;
758    match crate::provenance::file(push, review, review_content) {
759        Filing::Deferred => note(
760            "provenance-deferred",
761            "-",
762            push.name,
763            "the preview has not stood",
764        ),
765        Filing::Written(file) => note(
766            "provenance-written",
767            "-",
768            push.name,
769            &file.display().to_string(),
770        ),
771        Filing::Kept(file) => note(
772            "provenance-kept",
773            "-",
774            push.name,
775            &file.display().to_string(),
776        ),
777        Filing::Failed(why) => note("provenance-write-failed", "-", push.name, &why),
778    }
779}
780
781fn unknown(tree: Option<&str>, why: &str) -> Judged {
782    Judged::Say {
783        reason: format!("whether this push was reviewed cannot be told: {why}"),
784        excerpt: format!(
785            "tree={} review=unknown verdict=-",
786            tree.map(short).unwrap_or("-")
787        ),
788    }
789}
790
791/// The remote's default branch as last fetched: `<remote>/HEAD`, else
792/// `<remote>/main`, else `<remote>/master`. Never `HEAD`: with the pushed
793/// commit as its own base every push would show no plan.
794pub(crate) fn default_base(repo: &Path, remote: &str) -> Option<String> {
795    [
796        format!("refs/remotes/{remote}/HEAD"),
797        format!("refs/remotes/{remote}/main"),
798        format!("refs/remotes/{remote}/master"),
799    ]
800    .into_iter()
801    .find(|r| {
802        git::stdout_in(
803            repo,
804            &[
805                "rev-parse",
806                "--verify",
807                "--quiet",
808                "--end-of-options",
809                &format!("{r}^{{commit}}"),
810            ],
811        )
812        .is_some_and(|s| !s.is_empty())
813    })
814}
815
816/// The rule's `confirm`, for a push: what to say, or why not.
817pub fn confirm_push(ctx: &Context, cmd: &Simple, stance: Stance) -> Confirmed {
818    match judge_push(ctx, cmd, stance) {
819        Judged::Decline(why) => Confirmed::No(why),
820        Judged::Say { reason, excerpt } => Confirmed::YesSaying {
821            remedy: None,
822            floor: None,
823            ceiling: None,
824            reason,
825            excerpt,
826        },
827    }
828}
829
830// ---------------------------------------------------------------- the guards
831
832/// Whether a path is inside the pass-file store, compared lexically (`.`
833/// and `..` folded, no symlink followed) and, where the store exists, by
834/// its canonical form too. Only the hook writes there.
835pub fn guards_path(path: &Path) -> bool {
836    HOOK_ONLY_DIRS
837        .iter()
838        .filter_map(|d| Some(journal::dir()?.join(d)))
839        .any(|store| guards_store(path, &store))
840}
841
842fn guards_store(path: &Path, store: &Path) -> bool {
843    let here = lexical(path);
844    if here.starts_with(lexical(store)) {
845        return true;
846    }
847    if let Ok(real) = std::fs::canonicalize(store) {
848        if here.starts_with(&real) {
849            return true;
850        }
851        // The path's own existing ancestor, resolved.
852        if let Some(parent) = here.parent() {
853            if let Ok(real_parent) = std::fs::canonicalize(parent) {
854                return real_parent.starts_with(&real);
855            }
856        }
857    }
858    false
859}
860
861/// A path with `.` and `..` folded lexically.
862pub fn lexical(path: &Path) -> PathBuf {
863    let mut out = PathBuf::new();
864    for c in path.components() {
865        match c {
866            Component::CurDir => {}
867            Component::ParentDir => {
868                out.pop();
869            }
870            other => out.push(other.as_os_str()),
871        }
872    }
873    out
874}
875
876/// Programs whose operand naming the store is taken as a write. A reader
877/// (`ls`, `cat`, `find`, `grep`, `stat`, …) is not on this list, so looking
878/// at the record is allowed; the list is the Bash half of a best-effort
879/// guard, since a parser cannot see inside `python -c` beyond its words.
880const WRITERS: &[&str] = &[
881    "rm", "mv", "cp", "tee", "touch", "install", "truncate", "ln", "chmod", "chown", "mkdir",
882    "rmdir", "dd", "sed", "perl", "ruby", "node", "deno", "python", "python3", "sh", "bash", "zsh",
883    "dash", "fish", "sudo", "xargs", "rsync", "tar", "unzip", "chezmoi", "git",
884];
885
886/// The first clause of a command that would WRITE into the store: a redirect
887/// into it (`> …/by-tree/…`), or a program on [`WRITERS`] with a word naming
888/// it. A read — `ls`, `cat`, `find` of the pass files — passes: the record is
889/// there to be looked at. Returns the clause's span.
890pub fn store_clause(parsed: &amont_agent_guard::shell::Parsed) -> Option<std::ops::Range<usize>> {
891    parsed
892        .clauses()
893        .iter()
894        .find(|cmd| {
895            let redirected = cmd
896                .redirects
897                .iter()
898                .any(|(op, w)| op.contains('>') && names_store(&w.text));
899            let writer = cmd
900                .program()
901                .map(|p| p.rsplit('/').next().unwrap_or(p))
902                .is_some_and(|p| WRITERS.contains(&p) || p.starts_with("python"));
903            redirected || (writer && cmd.words.iter().any(|w| names_store(&w.text)))
904        })
905        .map(|cmd| cmd.at..cmd.end)
906}
907
908/// Whether a shell word names the store, spelled with either separator:
909/// Windows writes the same directory with backslashes.
910pub fn names_store(word: &str) -> bool {
911    let word = word.replace('\\', "/");
912    HOOK_ONLY_DIRS.iter().any(|d| {
913        word.contains(&format!("amont-agent/{d}/"))
914            || word.contains(&format!("amont-agent/{d}")) && word.ends_with(d)
915    })
916}
917
918/// The directories under the config dir that only the hook writes.
919const HOOK_ONLY_DIRS: &[&str] = &[RULE_ID, "provenance"];
920
921pub const GUARD_REASON: &str =
922    "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.";
923pub const GUARD_REMEDY: &str =
924    "Run the review (worktree-task F4b) or ask the person; never write the record by hand.";
925
926// ---------------------------------------------------------------- the person
927
928/// The `(repo, sha)` a marked question names:
929/// `[implementation-review <repo>@<sha64>]`, split at the last `@`.
930pub fn marker(question: &str) -> Option<(String, String)> {
931    let start = question.find(MARKER)? + MARKER.len();
932    let end = start + question[start..].find(']')?;
933    let inner = question[start..end].trim();
934    let at = inner.rfind('@')?;
935    let repo = inner[..at].trim().to_string();
936    let sha = inner[at + 1..].trim().to_ascii_lowercase();
937    (!repo.is_empty() && is_sha256(&sha)).then_some((repo, sha))
938}
939
940fn asks_dir() -> Option<PathBuf> {
941    Some(store()?.join("asks"))
942}
943
944fn now_ms() -> u64 {
945    std::time::SystemTime::now()
946        .duration_since(std::time::UNIX_EPOCH)
947        .map(|d| d.as_millis() as u64)
948        .unwrap_or(0)
949}
950
951/// Before an `AskUserQuestion` runs: remember a marked question, and whether
952/// it arrived pre-answered, so the answer can be trusted or not.
953pub fn on_pre_ask(ask: &amont_agent_guard::payload::Ask) {
954    let marked: Vec<(String, String)> = ask.questions.iter().filter_map(|q| marker(q)).collect();
955    if marked.is_empty() || ask.tool_use_id.is_empty() || ask.tool_use_id.contains('/') {
956        return;
957    }
958    let Some(d) = asks_dir() else { return };
959    if private_dir(&d).is_none() {
960        return;
961    }
962    let names: Vec<String> = marked.iter().map(|(r, s)| format!("{r}@{s}")).collect();
963    let _ = amont_agent_guard::atomic::write_atomic(
964        &d.join(&ask.tool_use_id),
965        &format!(
966            "{}\t{}\t{}\t{}\n",
967            ask.session.replace(['\t', '\n'], " "),
968            ask.prefilled,
969            names.join(","),
970            now_ms()
971        ),
972    );
973    if ask.prefilled {
974        note("prefilled", &ask.session, &marked[0].0, &names.join(","));
975    }
976}
977
978/// After an `AskUserQuestion`: an `Overrule` of a `rework` this session's
979/// transcript shows is remembered as a pass; anything else changes nothing.
980/// Returns what the session must hear when an overrule bound nothing.
981pub fn on_post_ask(ask: &amont_agent_guard::payload::Ask) -> Option<String> {
982    if !ask.questions.iter().any(|q| marker(q).is_some()) {
983        return None;
984    }
985    let recorded = asks_dir()
986        .map(|d| d.join(&ask.tool_use_id))
987        .filter(|_| !ask.tool_use_id.is_empty() && !ask.tool_use_id.contains('/'))
988        .and_then(|p| {
989            let t = std::fs::read_to_string(&p).ok();
990            let _ = std::fs::remove_file(&p);
991            t
992        });
993    let Some(recorded) = recorded else {
994        note(
995            "unrecorded",
996            &ask.session,
997            "-",
998            "marked question with no PreToolUse record",
999        );
1000        return None;
1001    };
1002    let f: Vec<&str> = recorded.trim_end().split('\t').collect();
1003    if f.len() != 4 || f[0] != ask.session || f[1] != "false" {
1004        return None;
1005    }
1006    let mut said: Vec<String> = Vec::new();
1007    for question in &ask.questions {
1008        let Some((repo, sha)) = marker(question) else {
1009            continue;
1010        };
1011        let label = ask
1012            .answers
1013            .iter()
1014            .find(|(q, _)| q == question)
1015            .map(|(_, l)| l.as_str())
1016            .unwrap_or("");
1017        if label != OVERRULE {
1018            if !label.is_empty() {
1019                note(
1020                    "answered",
1021                    &ask.session,
1022                    &repo,
1023                    &format!("tree={} answer={}", short(&sha), label.to_ascii_lowercase()),
1024                );
1025            }
1026            continue;
1027        }
1028        let Some(transcript) = ask.transcript.as_deref() else {
1029            note(
1030                "unknown",
1031                &ask.session,
1032                &repo,
1033                &format!("tree={} overrule without a transcript", short(&sha)),
1034            );
1035            said.push(format!(
1036                "The answer Overrule bound NOTHING for {repo} tree {}: the question's event carried no transcript, so the rework it overrules cannot be seen.",
1037                short(&sha)
1038            ));
1039            continue;
1040        };
1041        let reworked = reviews(transcript)
1042            .map(|rs| {
1043                rs.iter()
1044                    .rev()
1045                    .find(|r| r.repo == repo && r.sha == sha)
1046                    .is_some_and(|r| matches!(r.verdict, Some(Verdict::Rework) | None))
1047            })
1048            .unwrap_or(false);
1049        if !reworked {
1050            note(
1051                "unbound",
1052                &ask.session,
1053                &repo,
1054                &format!("tree={} overrule with no rework to overrule", short(&sha)),
1055            );
1056            said.push(format!(
1057                "The answer Overrule bound NOTHING for {repo} tree {}: no completed implementation review of that tree said rework in this session.",
1058                short(&sha)
1059            ));
1060            continue;
1061        }
1062        remember(
1063            &repo,
1064            &sha,
1065            "overruled",
1066            "person",
1067            &PromptRules::NotRecorded,
1068            &Reviewed::not_recorded(),
1069        );
1070        note(
1071            "overruled",
1072            &ask.session,
1073            &repo,
1074            &format!("tree={} review=overruled verdict=rework", short(&sha)),
1075        );
1076    }
1077    (!said.is_empty()).then(|| said.join("\n\n"))
1078}
1079
1080/// One journal line under the rule's id, for what happens away from a push.
1081fn note(outcome: &str, session: &str, repo: &str, excerpt: &str) {
1082    journal::record(&journal::Entry {
1083        rule: RULE_ID,
1084        stance: "-",
1085        outcome,
1086        session,
1087        repo,
1088        mode: "-",
1089        excerpt,
1090    });
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096    use crate::content_id::content_id_of;
1097
1098    /// A real session's launch of an implementation reviewer, resumed twice
1099    /// with `SendMessage` (2026-10-08), anonymised.
1100    const RESUMED: &str = include_str!("../../../tests/fixtures/sendmessage-resume.jsonl");
1101    const LAUNCH_TREE: &str = "6066ad987edfbbb3a514b6d9247b126a77a316db2e1350c226ffd14593a0ba35";
1102    const ROUND_2: &str = "b196a592b3b76cb75155b14491291a45602ae66c6f20ef18e4413a34169556ab";
1103    const ROUND_3: &str = "85f71e67db10c7737c55b27a3c5753b2617c58dec0d58e7ebae5085898cc12d7";
1104
1105    /// The known answers attest's SPEC quotes under "Content id". Changing
1106    /// one is changing the definition: every stored review and approval
1107    /// would stop matching.
1108    #[test]
1109    fn the_content_id_gives_the_spec_known_answers() {
1110        let o1 = "1".repeat(40);
1111        let o2 = "2".repeat(40);
1112        let id = |recs: &[String]| {
1113            content_id_of(
1114                &recs
1115                    .iter()
1116                    .map(|r| r.as_bytes().to_vec())
1117                    .collect::<Vec<_>>(),
1118            )
1119        };
1120        // no records: the sha256 of the empty string
1121        assert_eq!(
1122            id(&[]),
1123            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1124        );
1125        assert_eq!(
1126            id(&[format!("100644 blob {o1}\tREADME.md")]),
1127            "6f0214fffd79cb8eb5afb2283e46911f5f9022a25b3f339ae8a50a1ee223a0b2"
1128        );
1129        // every record under the top-level docs/plans/ is left out, a
1130        // symlink too; a near name and a nested docs/plans/ are kept
1131        let several = [
1132            format!("100644 blob {o1}\tREADME.md"),
1133            format!("100644 blob {o2}\tdocs/plans/a.md"),
1134            format!("100644 blob {o2}\tdocs/plans-old/a.md"),
1135            format!("120000 blob {o1}\tdocs/plans/link"),
1136            format!("100644 blob {o1}\tx/docs/plans/a.md"),
1137        ];
1138        let several_id = "0fca850e419d8a202129087fd90f00fe285c4b9cc63b42f4db6162e0a2b12eaf";
1139        assert_eq!(id(&several), several_id);
1140        // so a plan's existence does not count, unlike attest's canon(T)
1141        assert_eq!(
1142            id(&[several[0].clone(), several[2].clone(), several[4].clone()]),
1143            several_id
1144        );
1145        // a raw path with a newline: no quoting, no decoding
1146        assert_eq!(
1147            id(&[
1148                format!("100644 blob {o1}\tdocs/a\nb.md"),
1149                format!("100644 blob {o2}\tdocs/plans/c\nd.md"),
1150            ]),
1151            "88a9c5d4a7b3892be98a8492631b3c436954c2000a038b5d641bd054e76e19e0"
1152        );
1153    }
1154
1155    fn reviews_of(text: &str) -> Vec<(String, Option<Verdict>)> {
1156        completed_agents_resumable(text.as_bytes(), AGENT)
1157            .into_iter()
1158            .flat_map(|c| {
1159                let v = verdict_of(&c.result);
1160                parse_blocks(&c.prompt)
1161                    .into_iter()
1162                    .map(move |(_, sha)| (sha, v))
1163            })
1164            .collect()
1165    }
1166
1167    #[test]
1168    fn each_resumed_round_is_a_review_of_its_own_tree() {
1169        assert_eq!(
1170            reviews_of(RESUMED),
1171            vec![
1172                (LAUNCH_TREE.to_string(), Some(Verdict::ApproveWithChanges)),
1173                (ROUND_2.to_string(), Some(Verdict::Approve)),
1174                (ROUND_3.to_string(), Some(Verdict::Approve)),
1175            ],
1176            "the launch keeps its own verdict"
1177        );
1178    }
1179
1180    #[test]
1181    fn the_shared_reader_counts_the_launch_alone() {
1182        // What the plan-review panel reads: a resume is not a launch.
1183        let launches = crate::plan_review::completed_agents(RESUMED.as_bytes(), AGENT);
1184        assert_eq!(launches.len(), 1);
1185        assert!(launches[0].prompt.contains(LAUNCH_TREE));
1186        assert_eq!(
1187            verdict_of(&launches[0].result),
1188            Some(Verdict::ApproveWithChanges),
1189            "the rounds' notifications do not overwrite the launch's"
1190        );
1191    }
1192
1193    #[test]
1194    fn a_resume_of_an_agent_that_is_not_a_reviewer_does_not_count() {
1195        let other = RESUMED.replace(
1196            "\"subagent_type\":\"implementation-review\"",
1197            "\"subagent_type\":\"general-purpose\"",
1198        );
1199        assert!(reviews_of(&other).is_empty());
1200        // The message went to another agent than the one that resumed.
1201        let elsewhere = RESUMED.replace(
1202            "\"to\":\"aa339dbce3244a139\"",
1203            "\"to\":\"a0000000000000000\"",
1204        );
1205        assert_eq!(reviews_of(&elsewhere).len(), 1);
1206    }
1207
1208    #[test]
1209    fn a_resumed_round_needs_its_own_agents_structured_notification() {
1210        // Another task's id on the round's notifications.
1211        let lines: Vec<String> = RESUMED
1212            .lines()
1213            .map(|l| {
1214                if l.contains("\"origin\":{\"kind\":\"task-notification\"") {
1215                    l.replace(
1216                        "<task-id>aa339dbce3244a139</task-id>",
1217                        "<task-id>a0000000000000000</task-id>",
1218                    )
1219                } else {
1220                    l.to_string()
1221                }
1222            })
1223            .collect();
1224        assert_eq!(reviews_of(&lines.join("\n")).len(), 1);
1225
1226        // The round's notifications echoed in a tool result and typed by a
1227        // person: neither was queued by Claude Code.
1228        let forged: Vec<String> = RESUMED
1229            .lines()
1230            .map(|l| {
1231                if l.contains("\"origin\":{\"kind\":\"task-notification\"") {
1232                    l.replace(
1233                        "\"origin\":{\"kind\":\"task-notification\"",
1234                        "\"origin\":{\"kind\":\"human\"",
1235                    )
1236                } else {
1237                    l.to_string()
1238                }
1239            })
1240            .collect();
1241        assert_eq!(reviews_of(&forged.join("\n")).len(), 1);
1242    }
1243
1244    #[test]
1245    fn a_block_round_trips_and_a_short_sha_does_not_bind() {
1246        let sha = "a".repeat(64);
1247        let b = block("amont-agent", &sha);
1248        assert_eq!(b, format!("<<<TREE repo=amont-agent sha={sha}>>>"));
1249        assert_eq!(
1250            parse_blocks(&format!("Round 1.\n{b}\nbrief")),
1251            vec![("amont-agent".to_string(), sha.clone())]
1252        );
1253        assert!(parse_blocks("<<<TREE repo=x sha=abc123>>>").is_empty());
1254        assert!(parse_blocks("<<<TREE sha=abc>>>").is_empty());
1255        assert!(parse_blocks(&format!("<<<TREE repo= sha={sha}>>>")).is_empty());
1256    }
1257
1258    #[test]
1259    fn the_sha_is_lowercased() {
1260        let sha = "A".repeat(64);
1261        let got = parse_blocks(&block("r", &sha));
1262        assert_eq!(got[0].1, "a".repeat(64));
1263    }
1264
1265    #[test]
1266    fn the_verdict_is_the_first_verdict_line() {
1267        assert_eq!(
1268            verdict_of("Verdict: approve\nFindings:"),
1269            Some(Verdict::Approve)
1270        );
1271        assert_eq!(
1272            verdict_of("**Verdict: approve-with-changes**\n"),
1273            Some(Verdict::ApproveWithChanges)
1274        );
1275        assert_eq!(
1276            verdict_of("preamble\nVerdict: rework"),
1277            Some(Verdict::Rework)
1278        );
1279        assert_eq!(verdict_of("no verdict here"), None);
1280        assert_eq!(verdict_of("Verdict: maybe"), None);
1281    }
1282
1283    #[test]
1284    fn the_marker_splits_at_the_last_at_and_wants_64_hex() {
1285        let sha = "b".repeat(64);
1286        assert_eq!(
1287            marker(&format!("[implementation-review my@repo@{sha}] Overrule?")),
1288            Some(("my@repo".to_string(), sha.clone()))
1289        );
1290        assert_eq!(marker("[implementation-review repo@abc] x"), None);
1291        assert_eq!(marker(&format!("[implementation-review @{sha}]")), None);
1292        assert_eq!(marker("[preview a1] x"), None);
1293    }
1294
1295    #[test]
1296    fn lexical_folds_dot_and_dotdot() {
1297        assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
1298    }
1299
1300    #[test]
1301    fn a_read_of_the_store_passes_and_a_write_does_not() {
1302        use amont_agent_guard::shell::lex;
1303        let store = "~/.claude/amont-agent/implementation-review/by-tree/r/x.json";
1304        for read in [
1305            format!("ls {store}"),
1306            format!("cat {store} | head"),
1307            "find ~/.claude/amont-agent/implementation-review -type f".to_string(),
1308            format!("grep verdict {store}"),
1309        ] {
1310            assert!(store_clause(&lex(&read)).is_none(), "{read}");
1311        }
1312        for write in [
1313            format!("echo '{{}}' > {store}"),
1314            format!("printf x >> {store}"),
1315            format!("rm -f {store}"),
1316            format!("cp /tmp/x {store}"),
1317            format!("python3 -c 'open(\"{store}\",\"w\")'"),
1318            format!("tee {store} < /dev/null"),
1319            format!("cat x | sudo tee {store}"),
1320        ] {
1321            assert!(store_clause(&lex(&write)).is_some(), "{write}");
1322        }
1323    }
1324
1325    #[test]
1326    fn a_word_names_the_store_by_its_directory() {
1327        assert!(names_store(
1328            "/Users/me/.claude/amont-agent/implementation-review/by-tree/r/x.json"
1329        ));
1330        assert!(names_store("~/.claude/amont-agent/implementation-review"));
1331        assert!(names_store(
1332            "C:\\Users\\me\\.claude\\amont-agent\\implementation-review\\by-tree\\r\\x.json"
1333        ));
1334        assert!(!names_store("~/.claude/amont-agent/journal.log"));
1335        assert!(!names_store("implementation-review"));
1336        // The outcome ledger lives beside the store, not in it.
1337        assert!(!names_store(
1338            "~/.claude/amont-agent/implementation-review-outcomes/outcomes.jsonl"
1339        ));
1340    }
1341}