1use 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";
60const AGENT: &str = "implementation-review";
62pub const BLOCK_OPEN: &str = "<<<TREE ";
64const BLOCK_CLOSE: &str = ">>>";
65const PLANS_PATH: &str = "docs/plans";
67const MARKER: &str = "[implementation-review ";
69const OVERRULE: &str = "Overrule";
70
71const RULE_FILES: &[&[u8]] = &[b"AGENTS.md", b"CLAUDE.md", b".adr.yaml"];
76const RULE_DIRS: &[&[u8]] = &[b".adr/", b".claude/"];
77
78#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct PatchKey {
82 pub patch: String,
83 pub rules: String,
84}
85
86pub 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
122pub 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
143pub 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
152pub 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
173pub fn block(repo: &str, sha: &str) -> String {
175 format!("{BLOCK_OPEN}repo={repo} sha={sha}{BLOCK_CLOSE}")
176}
177
178pub 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#[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
234pub 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
254pub struct Review {
258 pub repo: String,
259 pub sha: String,
260 pub verdict: Option<Verdict>,
261}
262
263pub fn reviews(transcript: &Path) -> std::io::Result<Vec<Review>> {
264 let file = std::fs::File::open(transcript)?;
265 let reader = std::io::BufReader::new(file);
266 Ok(completed_agents_resumable(reader, AGENT)
267 .into_iter()
268 .flat_map(|c: Completed| {
269 let verdict = verdict_of(&c.result);
270 parse_blocks(&c.prompt)
271 .into_iter()
272 .map(move |(repo, sha)| Review { repo, sha, verdict })
273 })
274 .collect())
275}
276
277fn store() -> Option<PathBuf> {
280 Some(journal::dir()?.join(RULE_ID))
281}
282
283fn private_dir(dir: &Path) -> Option<()> {
284 std::fs::create_dir_all(dir).ok()?;
285 journal::private(dir, 0o700);
286 Some(())
287}
288
289fn pass_file(repo: &str, sha: &str) -> Option<PathBuf> {
290 if repo.contains('/') || repo.contains("..") || !is_sha256(sha) {
291 return None;
292 }
293 Some(
294 store()?
295 .join("by-tree")
296 .join(repo)
297 .join(format!("{sha}.json")),
298 )
299}
300
301pub fn remembered(repo: &str, sha: &str) -> Option<String> {
304 let f = pass_file(repo, sha)?;
305 let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(f).ok()?).ok()?;
306 v.get("verdict")?.as_str().map(str::to_string)
307}
308
309fn remember(repo: &str, sha: &str, verdict: &str, how: &str) -> Option<()> {
312 let f = pass_file(repo, sha)?;
313 private_dir(f.parent()?)?;
314 let body = serde_json::json!({
315 "repo": repo,
316 "tree": sha,
317 "verdict": verdict,
318 "agent": AGENT,
319 "how": how,
320 })
321 .to_string();
322 amont_agent_guard::atomic::write_atomic(&f, &body).ok()?;
323 journal::private(&f, 0o600);
324 Some(())
325}
326
327fn patch_file(repo: &str, patch: &str) -> Option<PathBuf> {
328 if repo.contains('/')
329 || repo.contains("..")
330 || patch.is_empty()
331 || !patch.bytes().all(|b| b.is_ascii_hexdigit())
332 {
333 return None;
334 }
335 Some(
336 store()?
337 .join("by-patch")
338 .join(repo)
339 .join(format!("{patch}.json")),
340 )
341}
342
343fn remembered_patch(repo: &str, key: &PatchKey) -> Option<(String, String)> {
346 let f = patch_file(repo, &key.patch)?;
347 let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(f).ok()?).ok()?;
348 if v.get("rules")?.as_str()? != key.rules {
349 return None;
350 }
351 Some((
352 v.get("verdict")?.as_str()?.to_string(),
353 v.get("tree")?.as_str()?.to_string(),
354 ))
355}
356
357fn remember_patch(repo: &str, key: &PatchKey, tree: &str, verdict: &str, how: &str) -> Option<()> {
359 let f = patch_file(repo, &key.patch)?;
360 private_dir(f.parent()?)?;
361 let body = serde_json::json!({
362 "repo": repo,
363 "patch": key.patch,
364 "rules": key.rules,
365 "tree": tree,
366 "verdict": verdict,
367 "agent": AGENT,
368 "how": how,
369 })
370 .to_string();
371 amont_agent_guard::atomic::write_atomic(&f, &body).ok()?;
372 journal::private(&f, 0o600);
373 Some(())
374}
375
376pub enum Judged {
380 Decline(&'static str),
383 Say { reason: String, excerpt: String },
385}
386
387pub fn judge_push(ctx: &Context, cmd: &Simple, stance: Stance) -> Judged {
389 use amont_agent_guard::push_target::{self, Kind, Push};
390 let (repo, targets) = match push_target::resolve_for(ctx, cmd) {
391 Push::Resolved { repo, targets } => (repo, targets),
392 Push::DryRun => return Judged::Decline("dry-run"),
393 Push::DeleteOnly => return Judged::Decline("delete-only"),
394 Push::Refused(_) => return Judged::Decline("land-refused"),
396 Push::Unresolvable { shape, .. } => {
397 return if stance == Stance::Deny {
400 unknown(None, &format!("the push cannot be read: {shape}"))
401 } else {
402 Judged::Decline("unresolvable")
403 };
404 }
405 };
406 let Some(target) = targets.iter().find(|t| t.kind == Kind::Branch) else {
407 return Judged::Decline("no-branch");
408 };
409 let pushed = target.src.as_str();
410 if git::nothing_new(&repo, pushed) {
414 return Judged::Decline("nothing-new");
415 }
416 let Some(base) = default_base(&repo, &target.remote) else {
417 return unknown(
418 None,
419 &format!(
420 "no default branch of `{}` is known here (no {0}/HEAD, {0}/main or {0}/master)",
421 target.remote
422 ),
423 );
424 };
425 let Some(merge_base) = git::stdout_in(&repo, &["merge-base", pushed, &base]) else {
426 return unknown(
427 None,
428 &format!("`git merge-base {} {base}` failed", short(pushed)),
429 );
430 };
431 let Some(changed) = git::stdout_in(
432 &repo,
433 &["diff", "--name-only", &merge_base, pushed, "--", PLANS_PATH],
434 ) else {
435 return unknown(None, "`git diff` against the base failed");
436 };
437 if changed.trim().is_empty() {
438 return Judged::Decline("no-plan");
439 }
440 let tree = match content_id(&repo, pushed) {
441 Ok(t) => t,
442 Err(why) => return unknown(None, &why),
443 };
444 let Some(name) = repo_name(&repo) else {
445 return unknown(Some(&tree), "the repository has no name");
446 };
447 let key = patch_key(&repo, &merge_base, pushed);
450 let plans: Vec<String> = changed
451 .lines()
452 .map(str::trim)
453 .filter(|l| !l.is_empty())
454 .map(str::to_string)
455 .collect();
456 let unkeyed = key
458 .as_ref()
459 .err()
460 .map(|why| format!(" No earlier review could carry over by patch: {why}."))
461 .unwrap_or_default();
462 let key = key.ok();
463 let push = crate::provenance::Push {
464 repo: &repo,
465 name: &name,
466 pushed,
467 patch: key.as_ref().map(|k| k.patch.as_str()),
468 plans: &plans,
469 target,
470 };
471 if let Some(verdict) = remembered(&name, &tree) {
472 if let Some(k) = &key {
473 if remembered_patch(&name, k).is_none() {
474 remember_patch(&name, k, &tree, &verdict, "tree");
475 }
476 }
477 let overruled = verdict == "overruled";
478 provenance(
479 &push,
480 if overruled {
481 Bound::Overruled
482 } else {
483 Bound::Reviewed
484 },
485 &tree,
486 );
487 return Judged::Decline(if overruled { "overruled" } else { "reviewed" });
488 }
489 if let Some((verdict, old_tree)) = key.as_ref().and_then(|k| remembered_patch(&name, k)) {
490 remember(
491 &name,
492 &tree,
493 &verdict,
494 &format!("patch {}", short(&old_tree)),
495 );
496 let overruled = verdict == "overruled";
497 provenance(
498 &push,
499 if overruled {
500 Bound::Overruled
501 } else {
502 Bound::ByPatch
503 },
504 &old_tree,
505 );
506 return Judged::Decline(if overruled {
507 "overruled-by-patch"
508 } else {
509 "reviewed-by-patch"
510 });
511 }
512 let Some(transcript) = ctx.transcript else {
513 return unknown(Some(&tree), "the payload carried no transcript_path");
514 };
515 let reviews = match reviews(transcript) {
516 Ok(r) => r,
517 Err(e) => {
518 return unknown(
519 Some(&tree),
520 &format!(
521 "the transcript {} could not be read: {e}",
522 transcript.display()
523 ),
524 )
525 }
526 };
527 let mine: Vec<&Review> = reviews.iter().filter(|r| r.repo == name).collect();
528 if let Some(latest) = mine.iter().rev().find(|r| r.sha == tree) {
529 return match latest.verdict {
530 Some(Verdict::Rework) | None => Judged::Say {
531 reason: format!(
532 "the implementation review of {name} tree {} said {}, and no delta followed.",
533 short(&tree),
534 latest
535 .verdict
536 .map(Verdict::as_str)
537 .unwrap_or("no verdict this guard can read")
538 ),
539 excerpt: format!(
540 "tree={} review=rework verdict={}",
541 short(&tree),
542 latest.verdict.map(Verdict::as_str).unwrap_or("-")
543 ),
544 },
545 Some(v) => {
546 remember(&name, &tree, v.as_str(), "transcript");
547 if let Some(k) = &key {
548 remember_patch(&name, k, &tree, v.as_str(), "transcript");
549 }
550 provenance(&push, Bound::Reviewed, &tree);
551 Judged::Decline("reviewed")
552 }
553 };
554 }
555 if let Some(older) = mine.last() {
556 return Judged::Say {
557 reason: format!(
558 "the implementation review of {name} read tree {}; this push carries tree {} (stale).{unkeyed}",
559 short(&older.sha),
560 short(&tree)
561 ),
562 excerpt: format!("tree={} review=stale verdict=-", short(&tree)),
563 };
564 }
565 Judged::Say {
566 reason: format!(
567 "no implementation review of {name} tree {} is in this session.{unkeyed}",
568 short(&tree)
569 ),
570 excerpt: format!("tree={} review=missing verdict=-", short(&tree)),
571 }
572}
573
574fn provenance(push: &crate::provenance::Push, review: Bound, review_content: &str) {
577 use crate::provenance::Filing;
578 match crate::provenance::file(push, review, review_content) {
579 Filing::Deferred => note(
580 "provenance-deferred",
581 "-",
582 push.name,
583 "the preview has not stood",
584 ),
585 Filing::Written(file) => note(
586 "provenance-written",
587 "-",
588 push.name,
589 &file.display().to_string(),
590 ),
591 Filing::Kept(file) => note(
592 "provenance-kept",
593 "-",
594 push.name,
595 &file.display().to_string(),
596 ),
597 Filing::Failed(why) => note("provenance-write-failed", "-", push.name, &why),
598 }
599}
600
601fn unknown(tree: Option<&str>, why: &str) -> Judged {
602 Judged::Say {
603 reason: format!("whether this push was reviewed cannot be told: {why}"),
604 excerpt: format!(
605 "tree={} review=unknown verdict=-",
606 tree.map(short).unwrap_or("-")
607 ),
608 }
609}
610
611fn default_base(repo: &Path, remote: &str) -> Option<String> {
615 [
616 format!("refs/remotes/{remote}/HEAD"),
617 format!("refs/remotes/{remote}/main"),
618 format!("refs/remotes/{remote}/master"),
619 ]
620 .into_iter()
621 .find(|r| {
622 git::stdout_in(
623 repo,
624 &[
625 "rev-parse",
626 "--verify",
627 "--quiet",
628 "--end-of-options",
629 &format!("{r}^{{commit}}"),
630 ],
631 )
632 .is_some_and(|s| !s.is_empty())
633 })
634}
635
636pub fn confirm_push(ctx: &Context, cmd: &Simple, stance: Stance) -> Confirmed {
638 match judge_push(ctx, cmd, stance) {
639 Judged::Decline(why) => Confirmed::No(why),
640 Judged::Say { reason, excerpt } => Confirmed::YesSaying {
641 remedy: None,
642 floor: None,
643 ceiling: None,
644 reason,
645 excerpt,
646 },
647 }
648}
649
650pub fn guards_path(path: &Path) -> bool {
656 HOOK_ONLY_DIRS
657 .iter()
658 .filter_map(|d| Some(journal::dir()?.join(d)))
659 .any(|store| guards_store(path, &store))
660}
661
662fn guards_store(path: &Path, store: &Path) -> bool {
663 let here = lexical(path);
664 if here.starts_with(lexical(store)) {
665 return true;
666 }
667 if let Ok(real) = std::fs::canonicalize(store) {
668 if here.starts_with(&real) {
669 return true;
670 }
671 if let Some(parent) = here.parent() {
673 if let Ok(real_parent) = std::fs::canonicalize(parent) {
674 return real_parent.starts_with(&real);
675 }
676 }
677 }
678 false
679}
680
681pub fn lexical(path: &Path) -> PathBuf {
683 let mut out = PathBuf::new();
684 for c in path.components() {
685 match c {
686 Component::CurDir => {}
687 Component::ParentDir => {
688 out.pop();
689 }
690 other => out.push(other.as_os_str()),
691 }
692 }
693 out
694}
695
696const WRITERS: &[&str] = &[
701 "rm", "mv", "cp", "tee", "touch", "install", "truncate", "ln", "chmod", "chown", "mkdir",
702 "rmdir", "dd", "sed", "perl", "ruby", "node", "deno", "python", "python3", "sh", "bash", "zsh",
703 "dash", "fish", "sudo", "xargs", "rsync", "tar", "unzip", "chezmoi", "git",
704];
705
706pub fn store_clause(parsed: &amont_agent_guard::shell::Parsed) -> Option<std::ops::Range<usize>> {
711 parsed
712 .clauses()
713 .iter()
714 .find(|cmd| {
715 let redirected = cmd
716 .redirects
717 .iter()
718 .any(|(op, w)| op.contains('>') && names_store(&w.text));
719 let writer = cmd
720 .program()
721 .map(|p| p.rsplit('/').next().unwrap_or(p))
722 .is_some_and(|p| WRITERS.contains(&p) || p.starts_with("python"));
723 redirected || (writer && cmd.words.iter().any(|w| names_store(&w.text)))
724 })
725 .map(|cmd| cmd.at..cmd.end)
726}
727
728pub fn names_store(word: &str) -> bool {
731 let word = word.replace('\\', "/");
732 HOOK_ONLY_DIRS.iter().any(|d| {
733 word.contains(&format!("amont-agent/{d}/"))
734 || word.contains(&format!("amont-agent/{d}")) && word.ends_with(d)
735 })
736}
737
738const HOOK_ONLY_DIRS: &[&str] = &[RULE_ID, "provenance"];
740
741pub const GUARD_REASON: &str =
742 "files under amont-agent/implementation-review/ and amont-agent/provenance/ are written by the hook alone: the pass files from a completed review in this session's transcript or the person's Overrule, the provenance records from a push the hook passed.";
743pub const GUARD_REMEDY: &str =
744 "Run the review (worktree-task F4b) or ask the person; never write the record by hand.";
745
746pub fn marker(question: &str) -> Option<(String, String)> {
751 let start = question.find(MARKER)? + MARKER.len();
752 let end = start + question[start..].find(']')?;
753 let inner = question[start..end].trim();
754 let at = inner.rfind('@')?;
755 let repo = inner[..at].trim().to_string();
756 let sha = inner[at + 1..].trim().to_ascii_lowercase();
757 (!repo.is_empty() && is_sha256(&sha)).then_some((repo, sha))
758}
759
760fn asks_dir() -> Option<PathBuf> {
761 Some(store()?.join("asks"))
762}
763
764fn now_ms() -> u64 {
765 std::time::SystemTime::now()
766 .duration_since(std::time::UNIX_EPOCH)
767 .map(|d| d.as_millis() as u64)
768 .unwrap_or(0)
769}
770
771pub fn on_pre_ask(ask: &amont_agent_guard::payload::Ask) {
774 let marked: Vec<(String, String)> = ask.questions.iter().filter_map(|q| marker(q)).collect();
775 if marked.is_empty() || ask.tool_use_id.is_empty() || ask.tool_use_id.contains('/') {
776 return;
777 }
778 let Some(d) = asks_dir() else { return };
779 if private_dir(&d).is_none() {
780 return;
781 }
782 let names: Vec<String> = marked.iter().map(|(r, s)| format!("{r}@{s}")).collect();
783 let _ = amont_agent_guard::atomic::write_atomic(
784 &d.join(&ask.tool_use_id),
785 &format!(
786 "{}\t{}\t{}\t{}\n",
787 ask.session.replace(['\t', '\n'], " "),
788 ask.prefilled,
789 names.join(","),
790 now_ms()
791 ),
792 );
793 if ask.prefilled {
794 note("prefilled", &ask.session, &marked[0].0, &names.join(","));
795 }
796}
797
798pub fn on_post_ask(ask: &amont_agent_guard::payload::Ask) -> Option<String> {
802 if !ask.questions.iter().any(|q| marker(q).is_some()) {
803 return None;
804 }
805 let recorded = asks_dir()
806 .map(|d| d.join(&ask.tool_use_id))
807 .filter(|_| !ask.tool_use_id.is_empty() && !ask.tool_use_id.contains('/'))
808 .and_then(|p| {
809 let t = std::fs::read_to_string(&p).ok();
810 let _ = std::fs::remove_file(&p);
811 t
812 });
813 let Some(recorded) = recorded else {
814 note(
815 "unrecorded",
816 &ask.session,
817 "-",
818 "marked question with no PreToolUse record",
819 );
820 return None;
821 };
822 let f: Vec<&str> = recorded.trim_end().split('\t').collect();
823 if f.len() != 4 || f[0] != ask.session || f[1] != "false" {
824 return None;
825 }
826 let mut said: Vec<String> = Vec::new();
827 for question in &ask.questions {
828 let Some((repo, sha)) = marker(question) else {
829 continue;
830 };
831 let label = ask
832 .answers
833 .iter()
834 .find(|(q, _)| q == question)
835 .map(|(_, l)| l.as_str())
836 .unwrap_or("");
837 if label != OVERRULE {
838 if !label.is_empty() {
839 note(
840 "answered",
841 &ask.session,
842 &repo,
843 &format!("tree={} answer={}", short(&sha), label.to_ascii_lowercase()),
844 );
845 }
846 continue;
847 }
848 let Some(transcript) = ask.transcript.as_deref() else {
849 note(
850 "unknown",
851 &ask.session,
852 &repo,
853 &format!("tree={} overrule without a transcript", short(&sha)),
854 );
855 said.push(format!(
856 "The answer Overrule bound NOTHING for {repo} tree {}: the question's event carried no transcript, so the rework it overrules cannot be seen.",
857 short(&sha)
858 ));
859 continue;
860 };
861 let reworked = reviews(transcript)
862 .map(|rs| {
863 rs.iter()
864 .rev()
865 .find(|r| r.repo == repo && r.sha == sha)
866 .is_some_and(|r| matches!(r.verdict, Some(Verdict::Rework) | None))
867 })
868 .unwrap_or(false);
869 if !reworked {
870 note(
871 "unbound",
872 &ask.session,
873 &repo,
874 &format!("tree={} overrule with no rework to overrule", short(&sha)),
875 );
876 said.push(format!(
877 "The answer Overrule bound NOTHING for {repo} tree {}: no completed implementation review of that tree said rework in this session.",
878 short(&sha)
879 ));
880 continue;
881 }
882 remember(&repo, &sha, "overruled", "person");
883 note(
884 "overruled",
885 &ask.session,
886 &repo,
887 &format!("tree={} review=overruled verdict=rework", short(&sha)),
888 );
889 }
890 (!said.is_empty()).then(|| said.join("\n\n"))
891}
892
893fn note(outcome: &str, session: &str, repo: &str, excerpt: &str) {
895 journal::record(&journal::Entry {
896 rule: RULE_ID,
897 stance: "-",
898 outcome,
899 session,
900 repo,
901 mode: "-",
902 excerpt,
903 });
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909 use crate::content_id::content_id_of;
910
911 const RESUMED: &str = include_str!("../../../tests/fixtures/sendmessage-resume.jsonl");
914 const LAUNCH_TREE: &str = "6066ad987edfbbb3a514b6d9247b126a77a316db2e1350c226ffd14593a0ba35";
915 const ROUND_2: &str = "b196a592b3b76cb75155b14491291a45602ae66c6f20ef18e4413a34169556ab";
916 const ROUND_3: &str = "85f71e67db10c7737c55b27a3c5753b2617c58dec0d58e7ebae5085898cc12d7";
917
918 #[test]
922 fn the_content_id_gives_the_spec_known_answers() {
923 let o1 = "1".repeat(40);
924 let o2 = "2".repeat(40);
925 let id = |recs: &[String]| {
926 content_id_of(
927 &recs
928 .iter()
929 .map(|r| r.as_bytes().to_vec())
930 .collect::<Vec<_>>(),
931 )
932 };
933 assert_eq!(
935 id(&[]),
936 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
937 );
938 assert_eq!(
939 id(&[format!("100644 blob {o1}\tREADME.md")]),
940 "6f0214fffd79cb8eb5afb2283e46911f5f9022a25b3f339ae8a50a1ee223a0b2"
941 );
942 let several = [
945 format!("100644 blob {o1}\tREADME.md"),
946 format!("100644 blob {o2}\tdocs/plans/a.md"),
947 format!("100644 blob {o2}\tdocs/plans-old/a.md"),
948 format!("120000 blob {o1}\tdocs/plans/link"),
949 format!("100644 blob {o1}\tx/docs/plans/a.md"),
950 ];
951 let several_id = "0fca850e419d8a202129087fd90f00fe285c4b9cc63b42f4db6162e0a2b12eaf";
952 assert_eq!(id(&several), several_id);
953 assert_eq!(
955 id(&[several[0].clone(), several[2].clone(), several[4].clone()]),
956 several_id
957 );
958 assert_eq!(
960 id(&[
961 format!("100644 blob {o1}\tdocs/a\nb.md"),
962 format!("100644 blob {o2}\tdocs/plans/c\nd.md"),
963 ]),
964 "88a9c5d4a7b3892be98a8492631b3c436954c2000a038b5d641bd054e76e19e0"
965 );
966 }
967
968 fn reviews_of(text: &str) -> Vec<(String, Option<Verdict>)> {
969 completed_agents_resumable(text.as_bytes(), AGENT)
970 .into_iter()
971 .flat_map(|c| {
972 let v = verdict_of(&c.result);
973 parse_blocks(&c.prompt)
974 .into_iter()
975 .map(move |(_, sha)| (sha, v))
976 })
977 .collect()
978 }
979
980 #[test]
981 fn each_resumed_round_is_a_review_of_its_own_tree() {
982 assert_eq!(
983 reviews_of(RESUMED),
984 vec![
985 (LAUNCH_TREE.to_string(), Some(Verdict::ApproveWithChanges)),
986 (ROUND_2.to_string(), Some(Verdict::Approve)),
987 (ROUND_3.to_string(), Some(Verdict::Approve)),
988 ],
989 "the launch keeps its own verdict"
990 );
991 }
992
993 #[test]
994 fn the_shared_reader_counts_the_launch_alone() {
995 let launches = crate::plan_review::completed_agents(RESUMED.as_bytes(), AGENT);
997 assert_eq!(launches.len(), 1);
998 assert!(launches[0].prompt.contains(LAUNCH_TREE));
999 assert_eq!(
1000 verdict_of(&launches[0].result),
1001 Some(Verdict::ApproveWithChanges),
1002 "the rounds' notifications do not overwrite the launch's"
1003 );
1004 }
1005
1006 #[test]
1007 fn a_resume_of_an_agent_that_is_not_a_reviewer_does_not_count() {
1008 let other = RESUMED.replace(
1009 "\"subagent_type\":\"implementation-review\"",
1010 "\"subagent_type\":\"general-purpose\"",
1011 );
1012 assert!(reviews_of(&other).is_empty());
1013 let elsewhere = RESUMED.replace(
1015 "\"to\":\"aa339dbce3244a139\"",
1016 "\"to\":\"a0000000000000000\"",
1017 );
1018 assert_eq!(reviews_of(&elsewhere).len(), 1);
1019 }
1020
1021 #[test]
1022 fn a_resumed_round_needs_its_own_agents_structured_notification() {
1023 let lines: Vec<String> = RESUMED
1025 .lines()
1026 .map(|l| {
1027 if l.contains("\"origin\":{\"kind\":\"task-notification\"") {
1028 l.replace(
1029 "<task-id>aa339dbce3244a139</task-id>",
1030 "<task-id>a0000000000000000</task-id>",
1031 )
1032 } else {
1033 l.to_string()
1034 }
1035 })
1036 .collect();
1037 assert_eq!(reviews_of(&lines.join("\n")).len(), 1);
1038
1039 let forged: Vec<String> = RESUMED
1042 .lines()
1043 .map(|l| {
1044 if l.contains("\"origin\":{\"kind\":\"task-notification\"") {
1045 l.replace(
1046 "\"origin\":{\"kind\":\"task-notification\"",
1047 "\"origin\":{\"kind\":\"human\"",
1048 )
1049 } else {
1050 l.to_string()
1051 }
1052 })
1053 .collect();
1054 assert_eq!(reviews_of(&forged.join("\n")).len(), 1);
1055 }
1056
1057 #[test]
1058 fn a_block_round_trips_and_a_short_sha_does_not_bind() {
1059 let sha = "a".repeat(64);
1060 let b = block("amont-agent", &sha);
1061 assert_eq!(b, format!("<<<TREE repo=amont-agent sha={sha}>>>"));
1062 assert_eq!(
1063 parse_blocks(&format!("Round 1.\n{b}\nbrief")),
1064 vec![("amont-agent".to_string(), sha.clone())]
1065 );
1066 assert!(parse_blocks("<<<TREE repo=x sha=abc123>>>").is_empty());
1067 assert!(parse_blocks("<<<TREE sha=abc>>>").is_empty());
1068 assert!(parse_blocks(&format!("<<<TREE repo= sha={sha}>>>")).is_empty());
1069 }
1070
1071 #[test]
1072 fn the_sha_is_lowercased() {
1073 let sha = "A".repeat(64);
1074 let got = parse_blocks(&block("r", &sha));
1075 assert_eq!(got[0].1, "a".repeat(64));
1076 }
1077
1078 #[test]
1079 fn the_verdict_is_the_first_verdict_line() {
1080 assert_eq!(
1081 verdict_of("Verdict: approve\nFindings:"),
1082 Some(Verdict::Approve)
1083 );
1084 assert_eq!(
1085 verdict_of("**Verdict: approve-with-changes**\n"),
1086 Some(Verdict::ApproveWithChanges)
1087 );
1088 assert_eq!(
1089 verdict_of("preamble\nVerdict: rework"),
1090 Some(Verdict::Rework)
1091 );
1092 assert_eq!(verdict_of("no verdict here"), None);
1093 assert_eq!(verdict_of("Verdict: maybe"), None);
1094 }
1095
1096 #[test]
1097 fn the_marker_splits_at_the_last_at_and_wants_64_hex() {
1098 let sha = "b".repeat(64);
1099 assert_eq!(
1100 marker(&format!("[implementation-review my@repo@{sha}] Overrule?")),
1101 Some(("my@repo".to_string(), sha.clone()))
1102 );
1103 assert_eq!(marker("[implementation-review repo@abc] x"), None);
1104 assert_eq!(marker(&format!("[implementation-review @{sha}]")), None);
1105 assert_eq!(marker("[preview a1] x"), None);
1106 }
1107
1108 #[test]
1109 fn lexical_folds_dot_and_dotdot() {
1110 assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
1111 }
1112
1113 #[test]
1114 fn a_read_of_the_store_passes_and_a_write_does_not() {
1115 use amont_agent_guard::shell::lex;
1116 let store = "~/.claude/amont-agent/implementation-review/by-tree/r/x.json";
1117 for read in [
1118 format!("ls {store}"),
1119 format!("cat {store} | head"),
1120 "find ~/.claude/amont-agent/implementation-review -type f".to_string(),
1121 format!("grep verdict {store}"),
1122 ] {
1123 assert!(store_clause(&lex(&read)).is_none(), "{read}");
1124 }
1125 for write in [
1126 format!("echo '{{}}' > {store}"),
1127 format!("printf x >> {store}"),
1128 format!("rm -f {store}"),
1129 format!("cp /tmp/x {store}"),
1130 format!("python3 -c 'open(\"{store}\",\"w\")'"),
1131 format!("tee {store} < /dev/null"),
1132 format!("cat x | sudo tee {store}"),
1133 ] {
1134 assert!(store_clause(&lex(&write)).is_some(), "{write}");
1135 }
1136 }
1137
1138 #[test]
1139 fn a_word_names_the_store_by_its_directory() {
1140 assert!(names_store(
1141 "/Users/me/.claude/amont-agent/implementation-review/by-tree/r/x.json"
1142 ));
1143 assert!(names_store("~/.claude/amont-agent/implementation-review"));
1144 assert!(names_store(
1145 "C:\\Users\\me\\.claude\\amont-agent\\implementation-review\\by-tree\\r\\x.json"
1146 ));
1147 assert!(!names_store("~/.claude/amont-agent/journal.log"));
1148 assert!(!names_store("implementation-review"));
1149 assert!(!names_store(
1151 "~/.claude/amont-agent/implementation-review-outcomes/outcomes.jsonl"
1152 ));
1153 }
1154}