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