1use std::path::{Component, Path, PathBuf};
49
50use crate::candidates_block::Reviewed;
51use crate::content_id::content_id;
52use crate::plan_review::{completed_agents_resumable, Completed};
53use crate::provenance::Review as Bound;
54use amont_agent_guard::git;
55use amont_agent_guard::journal;
56use amont_agent_guard::rules::{Confirmed, Context, Stance};
57use amont_agent_guard::sha256::{hex, sha256};
58use amont_agent_guard::shell::Simple;
59
60pub const RULE_ID: &str = "implementation-review";
61const AGENT: &str = "implementation-review";
63pub const BLOCK_OPEN: &str = "<<<TREE ";
65const BLOCK_CLOSE: &str = ">>>";
66const PLANS_PATH: &str = "docs/plans";
68const MARKER: &str = "[implementation-review ";
70const OVERRULE: &str = "Overrule";
71
72const RULE_FILES: &[&[u8]] = &[b"AGENTS.md", b"CLAUDE.md", b".adr.yaml"];
77const RULE_DIRS: &[&[u8]] = &[b".adr/", b".claude/"];
78
79#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct PatchKey {
83 pub patch: String,
84 pub rules: String,
85}
86
87pub fn branch_patch(repo: &Path, merge_base: &str, pushed: &str) -> Result<String, String> {
91 let diff = git::bytes_in(
92 repo,
93 &[
94 "diff",
95 "--binary",
96 "--no-ext-diff",
97 "--no-textconv",
98 "--no-color",
99 merge_base,
100 pushed,
101 "--",
102 ".",
103 &format!(":!{PLANS_PATH}"),
104 ],
105 None,
106 )
107 .map_err(|e| format!("git could not be run: {e}"))?
108 .ok_or("`git diff` against the base failed")?;
109 if diff.is_empty() {
110 return Err("the branch changes nothing outside docs/plans/".into());
111 }
112 let out = git::bytes_in(repo, &["patch-id", "--verbatim"], Some(&diff))
113 .map_err(|e| format!("git could not be run: {e}"))?
114 .ok_or("`git patch-id` failed")?;
115 let out = String::from_utf8_lossy(&out);
116 out.split_whitespace()
117 .next()
118 .filter(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit()))
119 .map(str::to_string)
120 .ok_or_else(|| "`git patch-id` printed no id".into())
121}
122
123pub fn rules_fingerprint(repo: &Path, rev: &str) -> Result<String, String> {
125 let spec = format!("{rev}^{{tree}}");
126 let entries = git::ls_tree_entries(repo, &spec)
127 .map_err(|e| format!("git could not be run in {}: {e}", repo.display()))?
128 .ok_or_else(|| format!("`{rev}` does not name a tree in {}", repo.display()))?;
129 let mut bytes: Vec<u8> = Vec::new();
130 for entry in entries {
131 let path = entry
132 .iter()
133 .position(|c| *c == b'\t')
134 .map(|i| &entry[i + 1..])
135 .unwrap_or(&entry[..]);
136 if RULE_FILES.contains(&path) || RULE_DIRS.iter().any(|d| path.starts_with(d)) {
137 bytes.extend_from_slice(&entry);
138 bytes.push(0);
139 }
140 }
141 Ok(hex(&sha256(&bytes)))
142}
143
144pub fn patch_key(repo: &Path, merge_base: &str, pushed: &str) -> Result<PatchKey, String> {
147 Ok(PatchKey {
148 patch: branch_patch(repo, merge_base, pushed)?,
149 rules: rules_fingerprint(repo, pushed)?,
150 })
151}
152
153pub fn repo_name(repo: &Path) -> Option<String> {
156 let common = git::stdout_in(repo, &["rev-parse", "--git-common-dir"])?;
157 if common.is_empty() {
158 return None;
159 }
160 let common = PathBuf::from(common);
161 let common = if common.is_absolute() {
162 common
163 } else {
164 repo.join(common)
165 };
166 let common = std::fs::canonicalize(&common).unwrap_or(common);
167 common
168 .parent()
169 .and_then(Path::file_name)
170 .map(|n| n.to_string_lossy().into_owned())
171 .filter(|n| !n.is_empty())
172}
173
174pub fn block(repo: &str, sha: &str) -> String {
176 format!("{BLOCK_OPEN}repo={repo} sha={sha}{BLOCK_CLOSE}")
177}
178
179pub fn parse_blocks(prompt: &str) -> Vec<(String, String)> {
182 let mut out = Vec::new();
183 let mut rest = prompt;
184 while let Some(at) = rest.find(BLOCK_OPEN) {
185 rest = &rest[at + BLOCK_OPEN.len()..];
186 let Some(end) = rest.find(BLOCK_CLOSE) else {
187 break;
188 };
189 let inner = &rest[..end];
190 rest = &rest[end..];
191 let Some(body) = inner.strip_prefix("repo=") else {
192 continue;
193 };
194 let Some(split) = body.rfind(" sha=") else {
195 continue;
196 };
197 let repo = body[..split].trim().to_string();
198 let sha = body[split + 5..].trim().to_ascii_lowercase();
199 if repo.is_empty() || !is_sha256(&sha) {
200 continue;
201 }
202 out.push((repo, sha));
203 }
204 out
205}
206
207pub fn is_sha256(s: &str) -> bool {
208 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
209}
210
211fn short(sha: &str) -> &str {
212 &sha[..sha.len().min(12)]
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Verdict {
220 Approve,
221 ApproveWithChanges,
222 Rework,
223}
224
225impl Verdict {
226 pub fn as_str(self) -> &'static str {
227 match self {
228 Verdict::Approve => "approve",
229 Verdict::ApproveWithChanges => "approve-with-changes",
230 Verdict::Rework => "rework",
231 }
232 }
233}
234
235pub fn verdict_of(result: &str) -> Option<Verdict> {
238 let line = result.lines().find(|l| l.contains("Verdict:"))?;
239 let after = line.split_once("Verdict:")?.1;
240 let word: String = after
241 .trim()
242 .trim_start_matches(['*', '`', '_', ' '])
243 .chars()
244 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
245 .collect::<String>()
246 .to_ascii_lowercase();
247 match word.as_str() {
248 "approve-with-changes" => Some(Verdict::ApproveWithChanges),
249 "approve" | "approved" => Some(Verdict::Approve),
250 "rework" => Some(Verdict::Rework),
251 _ => None,
252 }
253}
254
255pub struct Review {
259 pub repo: String,
260 pub sha: String,
261 pub verdict: Option<Verdict>,
262 pub rules_sha: Option<String>,
264 pub candidates: Reviewed,
266}
267
268pub fn reviews(transcript: &Path) -> std::io::Result<Vec<Review>> {
269 let file = std::fs::File::open(transcript)?;
270 let reader = std::io::BufReader::new(file);
271 Ok(completed_agents_resumable(reader, AGENT)
272 .into_iter()
273 .flat_map(|c: Completed| {
274 let verdict = verdict_of(&c.result);
275 let rules_sha = crate::review_brief::parse_rules_sha(&c.prompt);
276 let candidates = Reviewed::read(c.agent_id.as_deref(), &c.prompt, &c.result);
277 parse_blocks(&c.prompt)
278 .into_iter()
279 .map(move |(repo, sha)| Review {
280 repo,
281 sha,
282 verdict,
283 rules_sha: rules_sha.clone(),
284 candidates: candidates.clone(),
285 })
286 })
287 .collect())
288}
289
290fn store() -> Option<PathBuf> {
293 Some(journal::dir()?.join(RULE_ID))
294}
295
296fn private_dir(dir: &Path) -> Option<()> {
297 std::fs::create_dir_all(dir).ok()?;
298 journal::private(dir, 0o700);
299 Some(())
300}
301
302fn pass_file(repo: &str, sha: &str) -> Option<PathBuf> {
303 if repo.contains('/') || repo.contains("..") || !is_sha256(sha) {
304 return None;
305 }
306 Some(
307 store()?
308 .join("by-tree")
309 .join(repo)
310 .join(format!("{sha}.json")),
311 )
312}
313
314pub fn remembered(repo: &str, sha: &str) -> Option<String> {
317 remembered_pass(repo, sha).map(|pass| pass.verdict)
318}
319
320struct Pass {
323 verdict: String,
324 prompt_rules: PromptRules,
325 candidates: Reviewed,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum PromptRules {
331 NotRecorded,
333 Absent,
335 Sha(String),
337}
338
339const RULES_ABSENT_STORED: &str = "";
342
343impl PromptRules {
344 fn of_prompt(sha: Option<&str>) -> PromptRules {
345 sha.map_or(PromptRules::Absent, |sha| PromptRules::Sha(sha.to_string()))
346 }
347
348 fn stored(&self) -> Option<&str> {
351 match self {
352 PromptRules::NotRecorded => None,
353 PromptRules::Absent => Some(RULES_ABSENT_STORED),
354 PromptRules::Sha(sha) => Some(sha),
355 }
356 }
357
358 fn from_stored(stored: Option<&str>) -> PromptRules {
359 match stored {
360 None => PromptRules::NotRecorded,
361 Some(RULES_ABSENT_STORED) => PromptRules::Absent,
362 Some(sha) => PromptRules::Sha(sha.to_string()),
363 }
364 }
365}
366
367fn pass_of(stored_record: &serde_json::Value) -> Option<Pass> {
368 Some(Pass {
369 verdict: stored_record.get("verdict")?.as_str()?.to_string(),
370 prompt_rules: PromptRules::from_stored(
371 stored_record
372 .get("prompt_rules_sha")
373 .and_then(serde_json::Value::as_str),
374 ),
375 candidates: Reviewed::from_json(stored_record.get("candidates")),
376 })
377}
378
379fn remembered_pass(repo: &str, sha: &str) -> Option<Pass> {
380 let f = pass_file(repo, sha)?;
381 pass_of(&serde_json::from_str(&std::fs::read_to_string(f).ok()?).ok()?)
382}
383
384fn remember(
387 repo: &str,
388 sha: &str,
389 verdict: &str,
390 how: &str,
391 prompt_rules: &PromptRules,
392 candidates: &Reviewed,
393) -> Option<()> {
394 let f = pass_file(repo, sha)?;
395 private_dir(f.parent()?)?;
396 let body = serde_json::json!({
397 "repo": repo,
398 "tree": sha,
399 "verdict": verdict,
400 "agent": AGENT,
401 "how": how,
402 "prompt_rules_sha": prompt_rules.stored(),
403 "candidates": candidates.to_json(),
404 })
405 .to_string();
406 amont_agent_guard::atomic::write_atomic(&f, &body).ok()?;
407 journal::private(&f, 0o600);
408 Some(())
409}
410
411fn patch_file(repo: &str, patch: &str) -> Option<PathBuf> {
412 if repo.contains('/')
413 || repo.contains("..")
414 || patch.is_empty()
415 || !patch.bytes().all(|b| b.is_ascii_hexdigit())
416 {
417 return None;
418 }
419 Some(
420 store()?
421 .join("by-patch")
422 .join(repo)
423 .join(format!("{patch}.json")),
424 )
425}
426
427fn remembered_patch(repo: &str, key: &PatchKey) -> Option<(Pass, String)> {
430 let f = patch_file(repo, &key.patch)?;
431 let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(f).ok()?).ok()?;
432 if v.get("rules")?.as_str()? != key.rules {
433 return None;
434 }
435 Some((pass_of(&v)?, v.get("tree")?.as_str()?.to_string()))
436}
437
438fn remember_patch(
440 repo: &str,
441 key: &PatchKey,
442 tree: &str,
443 verdict: &str,
444 how: &str,
445 prompt_rules: &PromptRules,
446 candidates: &Reviewed,
447) -> Option<()> {
448 let f = patch_file(repo, &key.patch)?;
449 private_dir(f.parent()?)?;
450 let body = serde_json::json!({
451 "repo": repo,
452 "patch": key.patch,
453 "rules": key.rules,
454 "tree": tree,
455 "verdict": verdict,
456 "agent": AGENT,
457 "how": how,
458 "prompt_rules_sha": prompt_rules.stored(),
459 "candidates": candidates.to_json(),
460 })
461 .to_string();
462 amont_agent_guard::atomic::write_atomic(&f, &body).ok()?;
463 journal::private(&f, 0o600);
464 Some(())
465}
466
467pub enum Judged {
471 Decline(&'static str),
474 Say { reason: String, excerpt: String },
476}
477
478pub fn judge_push(ctx: &Context, cmd: &Simple, stance: Stance) -> Judged {
480 use amont_agent_guard::push_target::{self, Kind, Push};
481 let (repo, targets) = match push_target::resolve_for(ctx, cmd) {
482 Push::Resolved { repo, targets } => (repo, targets),
483 Push::DryRun => return Judged::Decline("dry-run"),
484 Push::DeleteOnly => return Judged::Decline("delete-only"),
485 Push::Refused(_) => return Judged::Decline("land-refused"),
487 Push::Unresolvable { shape, .. } => {
488 return if stance == Stance::Deny {
491 unknown(None, &format!("the push cannot be read: {shape}"))
492 } else {
493 Judged::Decline("unresolvable")
494 };
495 }
496 };
497 let Some(target) = targets.iter().find(|t| t.kind == Kind::Branch) else {
498 return Judged::Decline("no-branch");
499 };
500 let pushed = target.src.as_str();
501 if git::nothing_new(&repo, pushed) {
505 return Judged::Decline("nothing-new");
506 }
507 let Some(base) = default_base(&repo, &target.remote) else {
508 return unknown(
509 None,
510 &format!(
511 "no default branch of `{}` is known here (no {0}/HEAD, {0}/main or {0}/master)",
512 target.remote
513 ),
514 );
515 };
516 let Some(merge_base) = git::stdout_in(&repo, &["merge-base", pushed, &base]) else {
517 return unknown(
518 None,
519 &format!("`git merge-base {} {base}` failed", short(pushed)),
520 );
521 };
522 let Some(changed) = git::stdout_in(
523 &repo,
524 &["diff", "--name-only", &merge_base, pushed, "--", PLANS_PATH],
525 ) else {
526 return unknown(None, "`git diff` against the base failed");
527 };
528 if changed.trim().is_empty() {
529 return Judged::Decline("no-plan");
530 }
531 let tree = match content_id(&repo, pushed) {
532 Ok(t) => t,
533 Err(why) => return unknown(None, &why),
534 };
535 let Some(name) = repo_name(&repo) else {
536 return unknown(Some(&tree), "the repository has no name");
537 };
538 let key = patch_key(&repo, &merge_base, pushed);
541 let plans: Vec<String> = changed
542 .lines()
543 .map(str::trim)
544 .filter(|l| !l.is_empty())
545 .map(str::to_string)
546 .collect();
547 let unkeyed = key
549 .as_ref()
550 .err()
551 .map(|why| format!(" No earlier review could carry over by patch: {why}."))
552 .unwrap_or_default();
553 let key = key.ok();
554 let push = crate::provenance::Push {
555 repo: &repo,
556 name: &name,
557 pushed,
558 patch: key.as_ref().map(|k| k.patch.as_str()),
559 plans: &plans,
560 target,
561 };
562 if let Some(Pass {
563 verdict,
564 prompt_rules,
565 candidates,
566 }) = remembered_pass(&name, &tree)
567 {
568 if let Some(k) = &key {
569 if remembered_patch(&name, k).is_none() {
570 remember_patch(
571 &name,
572 k,
573 &tree,
574 &verdict,
575 "tree",
576 &prompt_rules,
577 &candidates,
578 );
579 }
580 }
581 let overruled = verdict == "overruled";
582 provenance(
583 &push,
584 if overruled {
585 Bound::Overruled
586 } else {
587 Bound::Reviewed
588 },
589 &tree,
590 );
591 if !overruled {
592 check_remembered_rules(&repo, &name, &tree, &prompt_rules);
593 check_candidates(&name, &tree, &candidates);
594 }
595 return Judged::Decline(if overruled { "overruled" } else { "reviewed" });
596 }
597 if let Some((pass, old_tree)) = key.as_ref().and_then(|k| remembered_patch(&name, k)) {
598 let Pass {
599 verdict,
600 prompt_rules,
601 candidates,
602 } = pass;
603 remember(
604 &name,
605 &tree,
606 &verdict,
607 &format!("patch {}", short(&old_tree)),
608 &prompt_rules,
609 &candidates,
610 );
611 let overruled = verdict == "overruled";
612 provenance(
613 &push,
614 if overruled {
615 Bound::Overruled
616 } else {
617 Bound::ByPatch
618 },
619 &old_tree,
620 );
621 if !overruled {
622 check_remembered_rules(&repo, &name, &tree, &prompt_rules);
623 check_candidates(&name, &tree, &candidates);
624 }
625 return Judged::Decline(if overruled {
626 "overruled-by-patch"
627 } else {
628 "reviewed-by-patch"
629 });
630 }
631 let Some(transcript) = ctx.transcript else {
632 return unknown(Some(&tree), "the payload carried no transcript_path");
633 };
634 let reviews = match reviews(transcript) {
635 Ok(r) => r,
636 Err(e) => {
637 return unknown(
638 Some(&tree),
639 &format!(
640 "the transcript {} could not be read: {e}",
641 transcript.display()
642 ),
643 )
644 }
645 };
646 let mine: Vec<&Review> = reviews.iter().filter(|r| r.repo == name).collect();
647 if let Some(latest) = mine.iter().rev().find(|r| r.sha == tree) {
648 return match latest.verdict {
649 Some(Verdict::Rework) | None => Judged::Say {
650 reason: format!(
651 "the implementation review of {name} tree {} said {}, and no delta followed.",
652 short(&tree),
653 latest
654 .verdict
655 .map(Verdict::as_str)
656 .unwrap_or("no verdict this guard can read")
657 ),
658 excerpt: format!(
659 "tree={} review=rework verdict={}",
660 short(&tree),
661 latest.verdict.map(Verdict::as_str).unwrap_or("-")
662 ),
663 },
664 Some(v) => {
665 let prompt_rules = PromptRules::of_prompt(latest.rules_sha.as_deref());
666 let candidates = &latest.candidates;
667 remember(
668 &name,
669 &tree,
670 v.as_str(),
671 "transcript",
672 &prompt_rules,
673 candidates,
674 );
675 if let Some(k) = &key {
676 remember_patch(
677 &name,
678 k,
679 &tree,
680 v.as_str(),
681 "transcript",
682 &prompt_rules,
683 candidates,
684 );
685 }
686 provenance(&push, Bound::Reviewed, &tree);
687 check_remembered_rules(&repo, &name, &tree, &prompt_rules);
688 check_candidates(&name, &tree, candidates);
689 Judged::Decline("reviewed")
690 }
691 };
692 }
693 if let Some(older) = mine.last() {
694 return Judged::Say {
695 reason: format!(
696 "the implementation review of {name} read tree {}; this push carries tree {} (stale).{unkeyed}",
697 short(&older.sha),
698 short(&tree)
699 ),
700 excerpt: format!("tree={} review=stale verdict=-", short(&tree)),
701 };
702 }
703 Judged::Say {
704 reason: format!(
705 "no implementation review of {name} tree {} is in this session.{unkeyed}",
706 short(&tree)
707 ),
708 excerpt: format!("tree={} review=missing verdict=-", short(&tree)),
709 }
710}
711
712fn check_remembered_rules(repo: &Path, name: &str, tree: &str, prompt_rules: &PromptRules) {
717 let excerpt = match prompt_rules {
718 PromptRules::NotRecorded => "review=rules-unknown",
719 PromptRules::Absent => {
720 crate::review_brief::rules_excerpt(None, &crate::review_brief::rules_sha(repo))
721 }
722 PromptRules::Sha(seen) => {
723 crate::review_brief::rules_excerpt(Some(seen), &crate::review_brief::rules_sha(repo))
724 }
725 };
726 note(
727 "rules-checked",
728 "-",
729 name,
730 &format!("tree={} {excerpt}", short(tree)),
731 );
732}
733
734fn check_candidates(name: &str, tree: &str, candidates: &Reviewed) {
738 note(
739 "candidates-checked",
740 "-",
741 name,
742 &format!("tree={} {}", short(tree), candidates.excerpt()),
743 );
744 if let Err(why) = crate::candidates_block::append_rulings(name, tree, candidates) {
745 note(
746 "rulings-write-failed",
747 "-",
748 name,
749 &format!("tree={} {why}", short(tree)),
750 );
751 }
752}
753
754fn provenance(push: &crate::provenance::Push, review: Bound, review_content: &str) {
757 use crate::provenance::Filing;
758 match crate::provenance::file(push, review, review_content) {
759 Filing::Deferred => note(
760 "provenance-deferred",
761 "-",
762 push.name,
763 "the preview has not stood",
764 ),
765 Filing::Written(file) => note(
766 "provenance-written",
767 "-",
768 push.name,
769 &file.display().to_string(),
770 ),
771 Filing::Kept(file) => note(
772 "provenance-kept",
773 "-",
774 push.name,
775 &file.display().to_string(),
776 ),
777 Filing::Failed(why) => note("provenance-write-failed", "-", push.name, &why),
778 }
779}
780
781fn unknown(tree: Option<&str>, why: &str) -> Judged {
782 Judged::Say {
783 reason: format!("whether this push was reviewed cannot be told: {why}"),
784 excerpt: format!(
785 "tree={} review=unknown verdict=-",
786 tree.map(short).unwrap_or("-")
787 ),
788 }
789}
790
791pub(crate) fn default_base(repo: &Path, remote: &str) -> Option<String> {
795 [
796 format!("refs/remotes/{remote}/HEAD"),
797 format!("refs/remotes/{remote}/main"),
798 format!("refs/remotes/{remote}/master"),
799 ]
800 .into_iter()
801 .find(|r| {
802 git::stdout_in(
803 repo,
804 &[
805 "rev-parse",
806 "--verify",
807 "--quiet",
808 "--end-of-options",
809 &format!("{r}^{{commit}}"),
810 ],
811 )
812 .is_some_and(|s| !s.is_empty())
813 })
814}
815
816pub fn confirm_push(ctx: &Context, cmd: &Simple, stance: Stance) -> Confirmed {
818 match judge_push(ctx, cmd, stance) {
819 Judged::Decline(why) => Confirmed::No(why),
820 Judged::Say { reason, excerpt } => Confirmed::YesSaying {
821 remedy: None,
822 floor: None,
823 ceiling: None,
824 reason,
825 excerpt,
826 },
827 }
828}
829
830pub fn guards_path(path: &Path) -> bool {
836 HOOK_ONLY_DIRS
837 .iter()
838 .filter_map(|d| Some(journal::dir()?.join(d)))
839 .any(|store| guards_store(path, &store))
840}
841
842fn guards_store(path: &Path, store: &Path) -> bool {
843 let here = lexical(path);
844 if here.starts_with(lexical(store)) {
845 return true;
846 }
847 if let Ok(real) = std::fs::canonicalize(store) {
848 if here.starts_with(&real) {
849 return true;
850 }
851 if let Some(parent) = here.parent() {
853 if let Ok(real_parent) = std::fs::canonicalize(parent) {
854 return real_parent.starts_with(&real);
855 }
856 }
857 }
858 false
859}
860
861pub fn lexical(path: &Path) -> PathBuf {
863 let mut out = PathBuf::new();
864 for c in path.components() {
865 match c {
866 Component::CurDir => {}
867 Component::ParentDir => {
868 out.pop();
869 }
870 other => out.push(other.as_os_str()),
871 }
872 }
873 out
874}
875
876const WRITERS: &[&str] = &[
881 "rm", "mv", "cp", "tee", "touch", "install", "truncate", "ln", "chmod", "chown", "mkdir",
882 "rmdir", "dd", "sed", "perl", "ruby", "node", "deno", "python", "python3", "sh", "bash", "zsh",
883 "dash", "fish", "sudo", "xargs", "rsync", "tar", "unzip", "chezmoi", "git",
884];
885
886pub fn store_clause(parsed: &amont_agent_guard::shell::Parsed) -> Option<std::ops::Range<usize>> {
891 parsed
892 .clauses()
893 .iter()
894 .find(|cmd| {
895 let redirected = cmd
896 .redirects
897 .iter()
898 .any(|(op, w)| op.contains('>') && names_store(&w.text));
899 let writer = cmd
900 .program()
901 .map(|p| p.rsplit('/').next().unwrap_or(p))
902 .is_some_and(|p| WRITERS.contains(&p) || p.starts_with("python"));
903 redirected || (writer && cmd.words.iter().any(|w| names_store(&w.text)))
904 })
905 .map(|cmd| cmd.at..cmd.end)
906}
907
908pub fn names_store(word: &str) -> bool {
911 let word = word.replace('\\', "/");
912 HOOK_ONLY_DIRS.iter().any(|d| {
913 word.contains(&format!("amont-agent/{d}/"))
914 || word.contains(&format!("amont-agent/{d}")) && word.ends_with(d)
915 })
916}
917
918const HOOK_ONLY_DIRS: &[&str] = &[RULE_ID, "provenance"];
920
921pub const GUARD_REASON: &str =
922 "files under amont-agent/implementation-review/ and amont-agent/provenance/ are written by the hook alone: the pass files from a completed review in this session's transcript or the person's Overrule, the provenance records from a push the hook passed.";
923pub const GUARD_REMEDY: &str =
924 "Run the review (worktree-task F4b) or ask the person; never write the record by hand.";
925
926pub fn marker(question: &str) -> Option<(String, String)> {
931 let start = question.find(MARKER)? + MARKER.len();
932 let end = start + question[start..].find(']')?;
933 let inner = question[start..end].trim();
934 let at = inner.rfind('@')?;
935 let repo = inner[..at].trim().to_string();
936 let sha = inner[at + 1..].trim().to_ascii_lowercase();
937 (!repo.is_empty() && is_sha256(&sha)).then_some((repo, sha))
938}
939
940fn asks_dir() -> Option<PathBuf> {
941 Some(store()?.join("asks"))
942}
943
944fn now_ms() -> u64 {
945 std::time::SystemTime::now()
946 .duration_since(std::time::UNIX_EPOCH)
947 .map(|d| d.as_millis() as u64)
948 .unwrap_or(0)
949}
950
951pub fn on_pre_ask(ask: &amont_agent_guard::payload::Ask) {
954 let marked: Vec<(String, String)> = ask.questions.iter().filter_map(|q| marker(q)).collect();
955 if marked.is_empty() || ask.tool_use_id.is_empty() || ask.tool_use_id.contains('/') {
956 return;
957 }
958 let Some(d) = asks_dir() else { return };
959 if private_dir(&d).is_none() {
960 return;
961 }
962 let names: Vec<String> = marked.iter().map(|(r, s)| format!("{r}@{s}")).collect();
963 let _ = amont_agent_guard::atomic::write_atomic(
964 &d.join(&ask.tool_use_id),
965 &format!(
966 "{}\t{}\t{}\t{}\n",
967 ask.session.replace(['\t', '\n'], " "),
968 ask.prefilled,
969 names.join(","),
970 now_ms()
971 ),
972 );
973 if ask.prefilled {
974 note("prefilled", &ask.session, &marked[0].0, &names.join(","));
975 }
976}
977
978pub fn on_post_ask(ask: &amont_agent_guard::payload::Ask) -> Option<String> {
982 if !ask.questions.iter().any(|q| marker(q).is_some()) {
983 return None;
984 }
985 let recorded = asks_dir()
986 .map(|d| d.join(&ask.tool_use_id))
987 .filter(|_| !ask.tool_use_id.is_empty() && !ask.tool_use_id.contains('/'))
988 .and_then(|p| {
989 let t = std::fs::read_to_string(&p).ok();
990 let _ = std::fs::remove_file(&p);
991 t
992 });
993 let Some(recorded) = recorded else {
994 note(
995 "unrecorded",
996 &ask.session,
997 "-",
998 "marked question with no PreToolUse record",
999 );
1000 return None;
1001 };
1002 let f: Vec<&str> = recorded.trim_end().split('\t').collect();
1003 if f.len() != 4 || f[0] != ask.session || f[1] != "false" {
1004 return None;
1005 }
1006 let mut said: Vec<String> = Vec::new();
1007 for question in &ask.questions {
1008 let Some((repo, sha)) = marker(question) else {
1009 continue;
1010 };
1011 let label = ask
1012 .answers
1013 .iter()
1014 .find(|(q, _)| q == question)
1015 .map(|(_, l)| l.as_str())
1016 .unwrap_or("");
1017 if label != OVERRULE {
1018 if !label.is_empty() {
1019 note(
1020 "answered",
1021 &ask.session,
1022 &repo,
1023 &format!("tree={} answer={}", short(&sha), label.to_ascii_lowercase()),
1024 );
1025 }
1026 continue;
1027 }
1028 let Some(transcript) = ask.transcript.as_deref() else {
1029 note(
1030 "unknown",
1031 &ask.session,
1032 &repo,
1033 &format!("tree={} overrule without a transcript", short(&sha)),
1034 );
1035 said.push(format!(
1036 "The answer Overrule bound NOTHING for {repo} tree {}: the question's event carried no transcript, so the rework it overrules cannot be seen.",
1037 short(&sha)
1038 ));
1039 continue;
1040 };
1041 let reworked = reviews(transcript)
1042 .map(|rs| {
1043 rs.iter()
1044 .rev()
1045 .find(|r| r.repo == repo && r.sha == sha)
1046 .is_some_and(|r| matches!(r.verdict, Some(Verdict::Rework) | None))
1047 })
1048 .unwrap_or(false);
1049 if !reworked {
1050 note(
1051 "unbound",
1052 &ask.session,
1053 &repo,
1054 &format!("tree={} overrule with no rework to overrule", short(&sha)),
1055 );
1056 said.push(format!(
1057 "The answer Overrule bound NOTHING for {repo} tree {}: no completed implementation review of that tree said rework in this session.",
1058 short(&sha)
1059 ));
1060 continue;
1061 }
1062 remember(
1063 &repo,
1064 &sha,
1065 "overruled",
1066 "person",
1067 &PromptRules::NotRecorded,
1068 &Reviewed::not_recorded(),
1069 );
1070 note(
1071 "overruled",
1072 &ask.session,
1073 &repo,
1074 &format!("tree={} review=overruled verdict=rework", short(&sha)),
1075 );
1076 }
1077 (!said.is_empty()).then(|| said.join("\n\n"))
1078}
1079
1080fn note(outcome: &str, session: &str, repo: &str, excerpt: &str) {
1082 journal::record(&journal::Entry {
1083 rule: RULE_ID,
1084 stance: "-",
1085 outcome,
1086 session,
1087 repo,
1088 mode: "-",
1089 excerpt,
1090 });
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096 use crate::content_id::content_id_of;
1097
1098 const RESUMED: &str = include_str!("../../../tests/fixtures/sendmessage-resume.jsonl");
1101 const LAUNCH_TREE: &str = "6066ad987edfbbb3a514b6d9247b126a77a316db2e1350c226ffd14593a0ba35";
1102 const ROUND_2: &str = "b196a592b3b76cb75155b14491291a45602ae66c6f20ef18e4413a34169556ab";
1103 const ROUND_3: &str = "85f71e67db10c7737c55b27a3c5753b2617c58dec0d58e7ebae5085898cc12d7";
1104
1105 #[test]
1109 fn the_content_id_gives_the_spec_known_answers() {
1110 let o1 = "1".repeat(40);
1111 let o2 = "2".repeat(40);
1112 let id = |recs: &[String]| {
1113 content_id_of(
1114 &recs
1115 .iter()
1116 .map(|r| r.as_bytes().to_vec())
1117 .collect::<Vec<_>>(),
1118 )
1119 };
1120 assert_eq!(
1122 id(&[]),
1123 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1124 );
1125 assert_eq!(
1126 id(&[format!("100644 blob {o1}\tREADME.md")]),
1127 "6f0214fffd79cb8eb5afb2283e46911f5f9022a25b3f339ae8a50a1ee223a0b2"
1128 );
1129 let several = [
1132 format!("100644 blob {o1}\tREADME.md"),
1133 format!("100644 blob {o2}\tdocs/plans/a.md"),
1134 format!("100644 blob {o2}\tdocs/plans-old/a.md"),
1135 format!("120000 blob {o1}\tdocs/plans/link"),
1136 format!("100644 blob {o1}\tx/docs/plans/a.md"),
1137 ];
1138 let several_id = "0fca850e419d8a202129087fd90f00fe285c4b9cc63b42f4db6162e0a2b12eaf";
1139 assert_eq!(id(&several), several_id);
1140 assert_eq!(
1142 id(&[several[0].clone(), several[2].clone(), several[4].clone()]),
1143 several_id
1144 );
1145 assert_eq!(
1147 id(&[
1148 format!("100644 blob {o1}\tdocs/a\nb.md"),
1149 format!("100644 blob {o2}\tdocs/plans/c\nd.md"),
1150 ]),
1151 "88a9c5d4a7b3892be98a8492631b3c436954c2000a038b5d641bd054e76e19e0"
1152 );
1153 }
1154
1155 fn reviews_of(text: &str) -> Vec<(String, Option<Verdict>)> {
1156 completed_agents_resumable(text.as_bytes(), AGENT)
1157 .into_iter()
1158 .flat_map(|c| {
1159 let v = verdict_of(&c.result);
1160 parse_blocks(&c.prompt)
1161 .into_iter()
1162 .map(move |(_, sha)| (sha, v))
1163 })
1164 .collect()
1165 }
1166
1167 #[test]
1168 fn each_resumed_round_is_a_review_of_its_own_tree() {
1169 assert_eq!(
1170 reviews_of(RESUMED),
1171 vec![
1172 (LAUNCH_TREE.to_string(), Some(Verdict::ApproveWithChanges)),
1173 (ROUND_2.to_string(), Some(Verdict::Approve)),
1174 (ROUND_3.to_string(), Some(Verdict::Approve)),
1175 ],
1176 "the launch keeps its own verdict"
1177 );
1178 }
1179
1180 #[test]
1181 fn the_shared_reader_counts_the_launch_alone() {
1182 let launches = crate::plan_review::completed_agents(RESUMED.as_bytes(), AGENT);
1184 assert_eq!(launches.len(), 1);
1185 assert!(launches[0].prompt.contains(LAUNCH_TREE));
1186 assert_eq!(
1187 verdict_of(&launches[0].result),
1188 Some(Verdict::ApproveWithChanges),
1189 "the rounds' notifications do not overwrite the launch's"
1190 );
1191 }
1192
1193 #[test]
1194 fn a_resume_of_an_agent_that_is_not_a_reviewer_does_not_count() {
1195 let other = RESUMED.replace(
1196 "\"subagent_type\":\"implementation-review\"",
1197 "\"subagent_type\":\"general-purpose\"",
1198 );
1199 assert!(reviews_of(&other).is_empty());
1200 let elsewhere = RESUMED.replace(
1202 "\"to\":\"aa339dbce3244a139\"",
1203 "\"to\":\"a0000000000000000\"",
1204 );
1205 assert_eq!(reviews_of(&elsewhere).len(), 1);
1206 }
1207
1208 #[test]
1209 fn a_resumed_round_needs_its_own_agents_structured_notification() {
1210 let lines: Vec<String> = RESUMED
1212 .lines()
1213 .map(|l| {
1214 if l.contains("\"origin\":{\"kind\":\"task-notification\"") {
1215 l.replace(
1216 "<task-id>aa339dbce3244a139</task-id>",
1217 "<task-id>a0000000000000000</task-id>",
1218 )
1219 } else {
1220 l.to_string()
1221 }
1222 })
1223 .collect();
1224 assert_eq!(reviews_of(&lines.join("\n")).len(), 1);
1225
1226 let forged: Vec<String> = RESUMED
1229 .lines()
1230 .map(|l| {
1231 if l.contains("\"origin\":{\"kind\":\"task-notification\"") {
1232 l.replace(
1233 "\"origin\":{\"kind\":\"task-notification\"",
1234 "\"origin\":{\"kind\":\"human\"",
1235 )
1236 } else {
1237 l.to_string()
1238 }
1239 })
1240 .collect();
1241 assert_eq!(reviews_of(&forged.join("\n")).len(), 1);
1242 }
1243
1244 #[test]
1245 fn a_block_round_trips_and_a_short_sha_does_not_bind() {
1246 let sha = "a".repeat(64);
1247 let b = block("amont-agent", &sha);
1248 assert_eq!(b, format!("<<<TREE repo=amont-agent sha={sha}>>>"));
1249 assert_eq!(
1250 parse_blocks(&format!("Round 1.\n{b}\nbrief")),
1251 vec![("amont-agent".to_string(), sha.clone())]
1252 );
1253 assert!(parse_blocks("<<<TREE repo=x sha=abc123>>>").is_empty());
1254 assert!(parse_blocks("<<<TREE sha=abc>>>").is_empty());
1255 assert!(parse_blocks(&format!("<<<TREE repo= sha={sha}>>>")).is_empty());
1256 }
1257
1258 #[test]
1259 fn the_sha_is_lowercased() {
1260 let sha = "A".repeat(64);
1261 let got = parse_blocks(&block("r", &sha));
1262 assert_eq!(got[0].1, "a".repeat(64));
1263 }
1264
1265 #[test]
1266 fn the_verdict_is_the_first_verdict_line() {
1267 assert_eq!(
1268 verdict_of("Verdict: approve\nFindings:"),
1269 Some(Verdict::Approve)
1270 );
1271 assert_eq!(
1272 verdict_of("**Verdict: approve-with-changes**\n"),
1273 Some(Verdict::ApproveWithChanges)
1274 );
1275 assert_eq!(
1276 verdict_of("preamble\nVerdict: rework"),
1277 Some(Verdict::Rework)
1278 );
1279 assert_eq!(verdict_of("no verdict here"), None);
1280 assert_eq!(verdict_of("Verdict: maybe"), None);
1281 }
1282
1283 #[test]
1284 fn the_marker_splits_at_the_last_at_and_wants_64_hex() {
1285 let sha = "b".repeat(64);
1286 assert_eq!(
1287 marker(&format!("[implementation-review my@repo@{sha}] Overrule?")),
1288 Some(("my@repo".to_string(), sha.clone()))
1289 );
1290 assert_eq!(marker("[implementation-review repo@abc] x"), None);
1291 assert_eq!(marker(&format!("[implementation-review @{sha}]")), None);
1292 assert_eq!(marker("[preview a1] x"), None);
1293 }
1294
1295 #[test]
1296 fn lexical_folds_dot_and_dotdot() {
1297 assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
1298 }
1299
1300 #[test]
1301 fn a_read_of_the_store_passes_and_a_write_does_not() {
1302 use amont_agent_guard::shell::lex;
1303 let store = "~/.claude/amont-agent/implementation-review/by-tree/r/x.json";
1304 for read in [
1305 format!("ls {store}"),
1306 format!("cat {store} | head"),
1307 "find ~/.claude/amont-agent/implementation-review -type f".to_string(),
1308 format!("grep verdict {store}"),
1309 ] {
1310 assert!(store_clause(&lex(&read)).is_none(), "{read}");
1311 }
1312 for write in [
1313 format!("echo '{{}}' > {store}"),
1314 format!("printf x >> {store}"),
1315 format!("rm -f {store}"),
1316 format!("cp /tmp/x {store}"),
1317 format!("python3 -c 'open(\"{store}\",\"w\")'"),
1318 format!("tee {store} < /dev/null"),
1319 format!("cat x | sudo tee {store}"),
1320 ] {
1321 assert!(store_clause(&lex(&write)).is_some(), "{write}");
1322 }
1323 }
1324
1325 #[test]
1326 fn a_word_names_the_store_by_its_directory() {
1327 assert!(names_store(
1328 "/Users/me/.claude/amont-agent/implementation-review/by-tree/r/x.json"
1329 ));
1330 assert!(names_store("~/.claude/amont-agent/implementation-review"));
1331 assert!(names_store(
1332 "C:\\Users\\me\\.claude\\amont-agent\\implementation-review\\by-tree\\r\\x.json"
1333 ));
1334 assert!(!names_store("~/.claude/amont-agent/journal.log"));
1335 assert!(!names_store("implementation-review"));
1336 assert!(!names_store(
1338 "~/.claude/amont-agent/implementation-review-outcomes/outcomes.jsonl"
1339 ));
1340 }
1341}