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