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