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