1use std::collections::{BTreeSet, HashMap};
42use std::io::BufRead;
43use std::path::{Path, PathBuf};
44
45pub(crate) use crate::plan_text::is_machine_comment;
46use crate::plan_text::{canonical, shas, Fence, Shas, REVIEW_SECTION};
47use amont_agent_guard::decision::{self, Decision};
48use amont_agent_guard::journal;
49use amont_agent_guard::payload::PlanExit;
50use amont_agent_guard::rules::{plan_review_panel::RULE, Stance};
51use amont_agent_guard::sha256::{hex, sha256};
52
53pub const BLOCK_OPEN: &str = "<<<PLAN ";
55const BLOCK_CLOSE: &str = ">>>";
56pub(crate) const AGENT_PREFIX: &str = "plan-review-";
57const REFUSALS_BEFORE_ASK: u32 = 2;
59const LARGE: usize = 500;
62const MAX_BLOBS: usize = 200;
64pub const ENV_ROOT: &str = "AMONT_AGENT_PLAN_ROOT";
68
69fn of_body(seen: &str, sha: &str, legacy: &str) -> bool {
71 seen == sha || seen == legacy
72}
73
74fn title(text: &str) -> Option<String> {
77 let mut fence = Fence::default();
78 canonical(text).lines().find_map(|l| {
79 fence.h2(l);
80 if fence.open.is_some() {
81 return None;
82 }
83 l.strip_prefix("# ").map(|t| t.trim().to_string())
84 })
85}
86
87pub fn block(path: &Path, sha: &str, lang: Option<&str>) -> String {
89 let lang = lang.map(|l| format!(" lang={l}")).unwrap_or_default();
90 format!(
91 "{BLOCK_OPEN}path={} sha={sha}{lang}{BLOCK_CLOSE}",
92 path.display()
93 )
94}
95
96pub fn real(path: &Path) -> PathBuf {
99 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
100}
101
102#[derive(Debug, Default, PartialEq)]
106pub struct Meta {
107 pub repos: Vec<String>,
108 pub adds: Vec<String>,
109}
110
111pub fn meta(text: &str) -> Result<Meta, String> {
114 let line = text
115 .lines()
116 .rev()
117 .find(|l| !l.trim().is_empty())
118 .filter(|l| is_machine_comment(l))
119 .ok_or(
120 "the plan's last line is not its machine comment (`<!-- panel: repos=<repo>,… -->`)",
121 )?;
122 let inner = line
123 .trim()
124 .trim_start_matches("<!-- panel:")
125 .trim_end_matches("-->");
126 let mut m = Meta::default();
127 for tok in inner.split_whitespace() {
128 let list = |v: &str| -> Vec<String> {
129 v.split(',')
130 .map(str::trim)
131 .filter(|s| !s.is_empty())
132 .map(str::to_string)
133 .collect()
134 };
135 if let Some(v) = tok.strip_prefix("repos=") {
136 m.repos = list(v);
137 } else if let Some(v) = tok.strip_prefix("adds=") {
138 m.adds = list(v);
139 }
140 }
141 if m.repos.is_empty() {
142 return Err("the machine comment names no repository (`repos=<repo>,…`)".into());
143 }
144 if let Some(bad) = m.repos.iter().find(|n| !valid_name(n)) {
145 return Err(format!(
146 "`repos=` names `{bad}`, which is not a repository name"
147 ));
148 }
149 if let Some(bad) = m.adds.iter().find(|a| !valid_class(a)) {
150 return Err(format!(
151 "`adds=` names `{bad}`; it takes cli, ui, ops, large or lang:<language>"
152 ));
153 }
154 Ok(m)
155}
156
157pub(crate) fn valid_name(n: &str) -> bool {
159 !n.is_empty()
160 && n != "."
161 && !n.contains("..")
162 && !n.starts_with('-')
163 && !n.contains(['/', '\\', '\0', '\n', '\r'])
164}
165
166fn valid_class(c: &str) -> bool {
167 match c.strip_prefix("lang:") {
168 Some(l) => {
169 !l.is_empty()
170 && l.chars()
171 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || "+#-".contains(ch))
172 }
173 None => matches!(c, "cli" | "ui" | "ops" | "large"),
174 }
175}
176
177#[derive(Debug, PartialEq)]
180pub struct Section {
181 pub present: bool,
182 pub unreviewed: bool,
183}
184
185pub fn section(text: &str) -> Section {
186 let mut fence = Fence::default();
187 let mut inside = false;
188 let mut s = Section {
189 present: false,
190 unreviewed: false,
191 };
192 for line in text.lines() {
193 if let Some(title) = fence.h2(line) {
194 inside = title == REVIEW_SECTION;
195 s.present |= inside;
196 continue;
197 }
198 if inside {
199 let t = line.trim_start().trim_start_matches(['-', '*', ' ']);
200 if t.starts_with('⚠') && t.to_lowercase().contains("unreviewed") {
201 s.unreviewed = true;
202 }
203 }
204 }
205 s
206}
207
208pub fn roles(classes: &BTreeSet<String>) -> BTreeSet<String> {
212 let mut r = BTreeSet::from(["backend".to_string()]);
213 for c in classes {
214 let add: &[&str] = match c.as_str() {
215 "ops" => &["platform"],
216 "cli" => &["unix", "tui"],
217 "large" => &["po", "architect"],
218 "ui" => &["react", "ui-design", "ux-research", "game-ux"],
219 _ => &[],
220 };
221 r.extend(add.iter().map(|s| s.to_string()));
222 if c.starts_with("lang:") {
223 r.insert(c.clone());
224 }
225 }
226 r
227}
228
229pub fn remit(role: &str) -> BTreeSet<String> {
234 let area: &[&str] = match role {
235 "unix" | "tui" => &["cli"],
236 "platform" => &["ops"],
237 "po" | "architect" => &["large"],
238 "react" | "ui-design" | "ux-research" | "game-ux" => &["ui"],
239 "backend" => &["*"],
240 r if r.starts_with("lang:") => &[r],
241 _ => &[],
242 };
243 area.iter().map(|s| s.to_string()).collect()
244}
245
246pub fn locate(root: &Path, name: &str) -> PathBuf {
249 let key = format!("amont.agent.plan-review.{name}.path");
250 match amont_agent_guard::git::output(&["config", "--get", "--type=path", &key]) {
251 Some(o) if o.code == 0 && !o.stdout.is_empty() => PathBuf::from(o.stdout),
252 _ => root.join(name),
253 }
254}
255
256pub fn root() -> Option<PathBuf> {
258 if let Some(r) = std::env::var_os(ENV_ROOT).filter(|r| !r.is_empty()) {
259 return Some(PathBuf::from(r));
260 }
261 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
262 Some(PathBuf::from(home).join("Developer").join("Perso"))
263}
264
265pub fn repo_areas(dir: &Path) -> Result<Option<BTreeSet<String>>, String> {
268 use amont_agent_guard::git;
269 let io = |e: std::io::Error| format!("git could not be run in {}: {e}", dir.display());
270 if !dir.is_dir() {
271 return Ok(None);
272 }
273 let Some(shallow) =
276 git::bytes_in(dir, &["rev-parse", "--is-shallow-repository"], None).map_err(io)?
277 else {
278 return Ok(None);
279 };
280 let shallow = String::from_utf8_lossy(&shallow).trim() == "true";
281 let mut rev = None;
284 for r in [
285 "refs/remotes/origin/HEAD",
286 "origin/main",
287 "origin/master",
288 "HEAD",
289 ] {
290 let spec = format!("{r}^{{tree}}");
291 if let Some(out) = git::bytes_in(
292 dir,
293 &[
294 "rev-parse",
295 "--verify",
296 "--quiet",
297 "--end-of-options",
298 &spec,
299 ],
300 None,
301 )
302 .map_err(io)?
303 {
304 rev = Some((r, String::from_utf8_lossy(&out).trim().to_string()));
305 break;
306 }
307 }
308 let Some((commit, tree)) = rev else {
309 return Ok(Some(BTreeSet::new()));
311 };
312 let paths: Vec<String> = git::ls_tree_z(dir, &tree)
313 .map_err(io)?
314 .ok_or_else(|| format!("git ls-tree failed in {}", dir.display()))?
315 .into_iter()
316 .map(|p| String::from_utf8_lossy(&p).into_owned())
317 .collect();
318
319 let wanted = candidates(&paths);
320 let names: Vec<String> = wanted.iter().map(|p| format!("{tree}:{p}")).collect();
321 let blobs: HashMap<String, String> = git::cat_file_batch(dir, &names)
322 .map_err(io)?
323 .ok_or_else(|| format!("git cat-file failed in {}", dir.display()))?
324 .into_iter()
325 .map(|(n, b)| {
326 let p = n.split_once(':').map(|(_, p)| p.to_string()).unwrap_or(n);
327 (p, String::from_utf8_lossy(&b).into_owned())
328 })
329 .collect();
330
331 let commits = if shallow {
332 None
333 } else {
334 own_commits(dir, commit)
335 };
336 Ok(Some(classify(&paths, &blobs, commits)))
337}
338
339fn own_commits(dir: &Path, rev: &str) -> Option<usize> {
342 let ask = |args: &[&str]| {
343 amont_agent_guard::git::bytes_in(dir, args, None)
344 .ok()
345 .flatten()
346 .map(|b| String::from_utf8_lossy(&b).trim().to_string())
347 };
348 let refs = ask(&[
349 "for-each-ref",
350 "--format=%(refname)",
351 "refs/remotes/upstream/",
352 ])
353 .unwrap_or_default();
354 let upstream = [
355 "refs/remotes/upstream/HEAD",
356 "refs/remotes/upstream/main",
357 "refs/remotes/upstream/master",
358 ]
359 .into_iter()
360 .find(|u| refs.lines().any(|l| l == *u));
361 let mut args = vec!["rev-list", "--count", rev];
362 if let Some(u) = upstream {
363 args.extend(["--not", u]);
364 }
365 ask(&args)?.parse().ok()
366}
367
368fn denied(path: &str) -> bool {
371 let dirs = ["vendor", "node_modules", "third_party", "dist"];
372 let mut parts = path.split('/').peekable();
373 while let Some(p) = parts.next() {
374 if parts.peek().is_some() && dirs.contains(&p) {
375 return true;
376 }
377 }
378 let base = basename(path);
379 base.ends_with(".lock") || base.contains("-lock.")
380}
381
382fn basename(path: &str) -> &str {
383 path.rsplit('/').next().unwrap_or(path)
384}
385
386fn candidates(paths: &[String]) -> Vec<String> {
388 let own: Vec<&String> = paths.iter().filter(|p| !denied(p)).collect();
389 let named = |n: &str| -> Vec<String> {
390 own.iter()
391 .filter(|p| basename(p) == n)
392 .map(|p| (*p).clone())
393 .collect()
394 };
395 let mut out: Vec<String> = Vec::new();
396 out.extend(
397 own.iter()
398 .filter(|p| p.as_str() == ".adr.yaml")
399 .map(|p| (*p).clone()),
400 );
401 out.extend(named("package.json"));
402 out.extend(named("Cargo.toml"));
403 let ops_by_name = own.iter().any(|p| ops_name(p));
404 if own.iter().any(|p| basename(p) == "go.mod") {
405 let mut go: Vec<String> = own
406 .iter()
407 .filter(|p| p.ends_with(".go") && !p.ends_with("_test.go"))
408 .map(|p| (*p).clone())
409 .collect();
410 go.sort_by_key(|p| (basename(p) != "main.go", !p.contains("cmd/")));
411 out.extend(go);
412 }
413 if !ops_by_name {
414 out.extend(
415 own.iter()
416 .filter(|p| p.ends_with(".yaml") || p.ends_with(".yml"))
417 .map(|p| (*p).clone()),
418 );
419 }
420 out.truncate(MAX_BLOBS);
421 out
422}
423
424fn ops_name(path: &str) -> bool {
425 matches!(
426 basename(path),
427 "kustomization.yaml" | "kustomization.yml" | "Kustomization" | "Chart.yaml"
428 ) || path.ends_with(".tf")
429}
430
431pub fn classify(
434 paths: &[String],
435 blobs: &HashMap<String, String>,
436 commits: Option<usize>,
437) -> BTreeSet<String> {
438 let own: Vec<&String> = paths.iter().filter(|p| !denied(p)).collect();
439 let mut out = BTreeSet::new();
440 for p in &own {
441 let lang = match basename(p) {
442 "Cargo.toml" => "rust",
443 "go.mod" => "go",
444 "package.json" | "tsconfig.json" => "typescript",
445 "pyproject.toml" => "python",
446 _ => continue,
447 };
448 out.insert(format!("lang:{lang}"));
449 }
450 let traits = blobs
451 .get(".adr.yaml")
452 .map(|t| adr_traits(t))
453 .unwrap_or_default();
454 let blob = |p: &str| blobs.get(p).map(String::as_str);
455
456 let ui = traits.contains("ui")
457 || own
458 .iter()
459 .filter(|p| basename(p) == "package.json")
460 .filter_map(|p| blob(p))
461 .any(crate::plan_text::package_is_ui);
462 let ops = own.iter().any(|p| ops_name(p))
463 || own
464 .iter()
465 .filter(|p| p.ends_with(".yaml") || p.ends_with(".yml"))
466 .filter_map(|p| blob(p))
467 .any(|t| {
468 t.lines()
469 .any(|l| matches!(l.trim(), "kind: HelmRelease" | "kind: Kustomization"))
470 });
471 let cli = traits.contains("cli")
472 || own
473 .iter()
474 .any(|p| p.as_str() == "src/main.rs" || p.ends_with("/src/main.rs"))
475 || own.iter().any(|p| {
476 let t = blob(p).unwrap_or("");
477 match basename(p) {
478 "Cargo.toml" => t.contains("[[bin]]"),
479 "package.json" => serde_json::from_str::<serde_json::Value>(t)
480 .ok()
481 .is_some_and(|v| v.get("bin").is_some()),
482 b if b.ends_with(".go") => t.lines().any(|l| l.trim() == "package main"),
483 _ => false,
484 }
485 });
486 let large = own.len() >= LARGE || commits.is_some_and(|c| c >= LARGE);
487 for (on, class) in [(ui, "ui"), (ops, "ops"), (cli, "cli"), (large, "large")] {
488 if on {
489 out.insert(class.to_string());
490 }
491 }
492 out
493}
494
495fn adr_traits(text: &str) -> BTreeSet<String> {
497 let mut inside = false;
498 let mut out = BTreeSet::new();
499 for line in text.lines() {
500 if line.trim().is_empty() || line.trim_start().starts_with('#') {
501 continue;
502 }
503 if !line.starts_with([' ', '\t']) {
504 inside = line.trim_end() == "areas:";
505 continue;
506 }
507 if !inside {
508 continue;
509 }
510 if let (Some(a), Some(b)) = (line.rfind('['), line.rfind(']')) {
511 if a < b {
512 for t in line[a + 1..b].split(',') {
513 let t = t.trim().trim_matches(['"', '\'']);
514 if !t.is_empty() {
515 out.insert(t.to_string());
516 }
517 }
518 }
519 }
520 }
521 out
522}
523
524#[derive(Debug, Clone, PartialEq)]
528pub struct Binding {
529 pub role: String,
530 pub path: String,
531 pub sha: String,
532}
533
534pub fn bindings(reader: impl BufRead) -> Vec<Binding> {
537 completed_agents(reader, AGENT_PREFIX)
538 .into_iter()
539 .flat_map(|c| parse_blocks(&c.agent, &c.prompt))
540 .collect()
541}
542
543pub struct Completed {
548 pub agent: String,
549 pub prompt: String,
550 pub result: String,
551 pub agent_id: Option<String>,
554 pub model: Option<String>,
556 pub tokens: Option<u64>,
558 pub duration_ms: Option<u64>,
560}
561
562#[derive(Default, Clone)]
564struct Reported {
565 agent_id: Option<String>,
566 model: Option<String>,
567 tokens: Option<u64>,
568 duration_ms: Option<u64>,
569}
570
571pub fn completed_agents(reader: impl BufRead, prefix: &str) -> Vec<Completed> {
583 scan(reader, prefix, false)
584}
585
586pub fn completed_agents_resumable(reader: impl BufRead, prefix: &str) -> Vec<Completed> {
602 scan(reader, prefix, true)
603}
604
605fn scan(reader: impl BufRead, prefix: &str, resumable: bool) -> Vec<Completed> {
606 let mut launched: HashMap<String, (String, String)> = HashMap::new();
609 let mut order: Vec<String> = Vec::new();
610 let mut done: HashMap<String, String> = HashMap::new();
611 let mut facts: HashMap<String, Reported> = HashMap::new();
613 let mut agents: HashMap<String, String> = HashMap::new();
615 let mut sends: HashMap<String, (String, String, usize)> = HashMap::new();
617 let mut rounds: HashMap<String, String> = HashMap::new();
619 let mut starts: HashMap<String, usize> = HashMap::new();
621 let mut handbacks: Vec<(String, usize, String)> = Vec::new();
623 for (at, line) in reader.split(b'\n').enumerate() {
624 let Ok(raw) = line else { break };
625 let Ok(text) = std::str::from_utf8(&raw) else {
626 continue;
627 };
628 let review = text.contains(prefix)
629 || text.contains("agentId")
632 || (resumable && (text.contains("SendMessage") || text.contains("AgentId")));
633 let notice = text.contains("task-notification");
634 let handback = text.contains("\"handback\"");
635 if !review && !notice && !handback {
636 continue;
637 }
638 let Ok(e) = serde_json::from_str::<serde_json::Value>(text) else {
640 continue;
641 };
642 let content = e
643 .get("message")
644 .and_then(|m| m.get("content"))
645 .and_then(|c| c.as_array());
646 let mut notified: Vec<Notification> = Vec::new();
647 match e.get("type").and_then(|t| t.as_str()) {
648 Some("assistant") if review => {
649 for c in content.into_iter().flatten() {
650 if c.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
651 continue;
652 }
653 let Some(id) = c.get("id").and_then(|i| i.as_str()) else {
654 continue;
655 };
656 let input = c.get("input");
657 let field = |k: &str| input.and_then(|i| i.get(k)).and_then(|v| v.as_str());
658 match c.get("name").and_then(|n| n.as_str()) {
659 Some("Agent" | "Task") => {
660 let Some(agent) =
661 field("subagent_type").filter(|s| s.starts_with(prefix))
662 else {
663 continue;
664 };
665 let prompt = field("prompt").unwrap_or("");
666 if !launched.contains_key(id) {
667 order.push(id.to_string());
668 starts.insert(id.to_string(), at);
669 launched.insert(
670 id.to_string(),
671 (agent.to_string(), prompt.to_string()),
672 );
673 }
674 }
675 Some("SendMessage") if resumable => {
676 if let (Some(to), Some(message)) = (field("to"), field("message")) {
677 sends.insert(
678 id.to_string(),
679 (to.to_string(), message.to_string(), at),
680 );
681 }
682 }
683 _ => {}
684 }
685 }
686 }
687 Some("user") => {
688 if review {
689 let result = e.get("toolUseResult");
690 let field = |k: &str| result.and_then(|r| r.get(k)).and_then(|s| s.as_str());
691 let completed = field("status") == Some("completed");
692 let agent_type = field("agentType");
693 let number = |k: &str| result.and_then(|r| r.get(k)).and_then(|v| v.as_u64());
694 for c in content.into_iter().flatten() {
695 if c.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
696 continue;
697 }
698 let Some(id) = c.get("tool_use_id").and_then(|i| i.as_str()) else {
699 continue;
700 };
701 if let Some((agent, _)) = launched.get(id) {
702 let f = facts.entry(id.to_string()).or_default();
703 if let Some(v) = field("agentId") {
704 f.agent_id = Some(v.to_string());
705 }
706 if let Some(v) = field("resolvedModel") {
707 f.model = Some(v.to_string());
708 }
709 f.tokens = number("totalTokens").or(f.tokens);
710 f.duration_ms = number("totalDurationMs").or(f.duration_ms);
711 if completed && agent_type == Some(agent.as_str()) {
712 done.insert(id.to_string(), text_of(c.get("content")));
713 }
714 if resumable {
715 if let Some(agent_id) = field("agentId") {
716 agents.insert(agent_id.to_string(), agent.clone());
717 }
718 }
719 } else if let Some((to, message, sent_at)) = sends.remove(id) {
720 let Some(agent) = agents.get(&to) else {
721 continue;
722 };
723 if field("resumedAgentId") != Some(to.as_str()) {
724 continue;
725 }
726 order.push(id.to_string());
727 starts.insert(id.to_string(), sent_at);
728 launched.insert(id.to_string(), (agent.clone(), message));
729 rounds.insert(id.to_string(), to);
730 }
731 }
732 }
733 if let Some((agent_id, body)) = handed_back(&e) {
734 handbacks.push((agent_id, at, body));
735 }
736 let origin = e
737 .get("origin")
738 .and_then(|o| o.get("kind"))
739 .and_then(|k| k.as_str());
740 if notice && origin == Some("task-notification") {
741 let body = match e.get("message").and_then(|m| m.get("content")) {
742 Some(serde_json::Value::String(s)) => s.clone(),
743 Some(serde_json::Value::Array(a)) => a
744 .iter()
745 .filter_map(|x| x.get("text").and_then(|t| t.as_str()))
746 .collect::<Vec<_>>()
747 .join("\n"),
748 _ => String::new(),
749 };
750 notified = notifications(&body);
751 }
752 }
753 Some("attachment") if notice => {
754 let a = e.get("attachment");
755 let field = |k: &str| a.and_then(|a| a.get(k)).and_then(|v| v.as_str());
756 if field("type") == Some("queued_command")
757 && field("commandMode") == Some("task-notification")
758 {
759 notified = notifications(field("prompt").unwrap_or(""));
760 }
761 }
762 _ => {}
763 }
764 for n in notified {
765 if rounds
767 .get(&n.tool_use_id)
768 .is_some_and(|agent_id| *agent_id != n.task_id)
769 {
770 continue;
771 }
772 let f = facts.entry(n.tool_use_id.clone()).or_default();
773 if !n.task_id.is_empty() {
774 f.agent_id.get_or_insert(n.task_id);
775 }
776 f.tokens = n.tokens.or(f.tokens);
777 f.duration_ms = n.duration_ms.or(f.duration_ms);
778 done.insert(n.tool_use_id, n.result);
779 }
780 }
781 let agent_of: HashMap<String, String> = order
784 .iter()
785 .filter_map(|id| {
786 let a = rounds
787 .get(id)
788 .cloned()
789 .or_else(|| facts.get(id).and_then(|f| f.agent_id.clone()))?;
790 Some((id.clone(), a))
791 })
792 .collect();
793 let report_of = |id: &str| -> Option<String> {
794 let agent = agent_of.get(id)?;
795 let start = *starts.get(id)?;
796 let next = agent_of
797 .iter()
798 .filter(|(other, a)| *a == agent && other.as_str() != id)
799 .filter_map(|(other, _)| starts.get(other).copied())
800 .filter(|&s| s > start)
801 .min()
802 .unwrap_or(usize::MAX);
803 handbacks
804 .iter()
805 .filter(|(a, at, _)| a == agent && *at > start && *at < next)
806 .map(|(_, _, body)| body.clone())
807 .next_back()
808 };
809 order
810 .into_iter()
811 .filter_map(|id| {
812 let mut result = done.remove(&id)?;
813 if result.contains(HANDED_BACK) {
817 if let Some(report) = report_of(&id) {
818 result = report;
819 }
820 }
821 let (agent, prompt) = launched.remove(&id)?;
822 let f = facts.remove(&id).unwrap_or_default();
823 Some(Completed {
824 agent,
825 prompt,
826 result,
827 agent_id: rounds.remove(&id).or(f.agent_id),
828 model: f.model,
829 tokens: f.tokens,
830 duration_ms: f.duration_ms,
831 })
832 })
833 .collect()
834}
835
836const HANDED_BACK: &str = "report was delivered to you as a message";
839
840fn handed_back(e: &serde_json::Value) -> Option<(String, String)> {
845 let o = e.get("origin")?;
846 let s = |k: &str| o.get(k).and_then(|v| v.as_str());
847 if s("kind") != Some("peer") || o.get("handback").and_then(|v| v.as_bool()) != Some(true) {
848 return None;
849 }
850 Some((s("senderTaskId")?.to_string(), s("body")?.to_string()))
851}
852
853fn text_of(content: Option<&serde_json::Value>) -> String {
855 match content {
856 Some(serde_json::Value::String(s)) => s.clone(),
857 Some(serde_json::Value::Array(a)) => a
858 .iter()
859 .filter_map(|x| x.get("text").and_then(|t| t.as_str()))
860 .collect::<Vec<_>>()
861 .join("\n"),
862 _ => String::new(),
863 }
864}
865
866#[cfg(test)]
868fn completed_ids(text: &str) -> Vec<String> {
869 completed_results(text)
870 .into_iter()
871 .map(|(id, _)| id)
872 .collect()
873}
874
875pub struct Notification {
877 pub tool_use_id: String,
879 pub task_id: String,
881 pub result: String,
883 pub tokens: Option<u64>,
885 pub duration_ms: Option<u64>,
887}
888
889pub fn notifications(text: &str) -> Vec<Notification> {
891 let tag = |chunk: &str, name: &str| -> Option<String> {
892 let open = format!("<{name}>");
893 let close = format!("</{name}>");
894 let a = chunk.find(&open)? + open.len();
895 let b = chunk[a..].find(&close)? + a;
896 Some(chunk[a..b].trim().to_string())
897 };
898 text.split("<task-notification>")
899 .skip(1)
900 .filter(|c| tag(c, "status").as_deref() == Some("completed"))
901 .filter_map(|c| {
902 Some(Notification {
903 tool_use_id: tag(c, "tool-use-id")?,
904 task_id: tag(c, "task-id").unwrap_or_default(),
905 result: tag(c, "result").unwrap_or_else(|| c.trim().to_string()),
906 tokens: tag(c, "subagent_tokens").and_then(|v| v.parse().ok()),
907 duration_ms: tag(c, "duration_ms").and_then(|v| v.parse().ok()),
908 })
909 })
910 .collect()
911}
912
913#[cfg(test)]
916fn completed_results(text: &str) -> Vec<(String, String)> {
917 notifications(text)
918 .into_iter()
919 .map(|n| (n.tool_use_id, n.result))
920 .collect()
921}
922
923pub(crate) fn parse_blocks(agent: &str, prompt: &str) -> Vec<Binding> {
925 let role = agent.trim_start_matches(AGENT_PREFIX);
926 let mut out = Vec::new();
927 let mut rest = prompt;
928 while let Some(at) = rest.find(BLOCK_OPEN) {
929 rest = &rest[at + BLOCK_OPEN.len()..];
930 let Some(end) = rest.find(BLOCK_CLOSE) else {
931 break;
932 };
933 let inner = &rest[..end];
934 rest = &rest[end..];
935 let Some(body) = inner.strip_prefix("path=") else {
936 continue;
937 };
938 let Some(split) = body.rfind(" sha=") else {
939 continue;
940 };
941 let path = body[..split].to_string();
942 let mut tail = body[split + 5..].split_whitespace();
943 let Some(sha) = tail.next().filter(|s| s.len() == 64) else {
944 continue;
945 };
946 let lang = tail.find_map(|t| t.strip_prefix("lang="));
947 let role = match (role, lang) {
948 ("language", Some(l)) => format!("lang:{l}"),
949 ("language", None) => continue,
950 (r, _) => r.to_string(),
951 };
952 out.push(Binding {
953 role,
954 path,
955 sha: sha.to_ascii_lowercase(),
956 });
957 }
958 out
959}
960
961#[derive(Debug, Clone, PartialEq, Default)]
965pub struct Baseline {
966 pub sha: String,
967 pub roles: BTreeSet<String>,
968 pub classes: BTreeSet<String>,
969}
970
971pub struct Facts<'a> {
972 pub sha: &'a str,
973 pub legacy: &'a str,
975 pub path: &'a str,
976 pub classes: &'a BTreeSet<String>,
977 pub bindings: &'a [Binding],
978 pub baseline: Option<&'a Baseline>,
979}
980
981#[derive(Debug, PartialEq)]
982pub enum Outcome {
983 Pass(BTreeSet<String>),
985 Short {
988 missing: BTreeSet<String>,
989 stale: BTreeSet<String>,
990 },
991}
992
993pub fn judge(f: &Facts) -> Outcome {
1004 let of_plan = |r: &str| -> Vec<&Binding> {
1005 f.bindings
1006 .iter()
1007 .filter(|b| b.role == r && b.path == f.path)
1008 .collect()
1009 };
1010 let now = |r: &str| of_plan(r).iter().any(|b| of_body(&b.sha, f.sha, f.legacy));
1011 let ever = |r: &str| !of_plan(r).is_empty();
1012 let need_now: BTreeSet<String>;
1013 let need_ever: BTreeSet<String>;
1014 match f.baseline {
1015 None => {
1016 need_ever = roles(f.classes);
1017 need_now = BTreeSet::from(["backend".to_string()]);
1018 }
1019 Some(b) if is_current(b, f.sha, f.legacy, f.classes) => {
1020 return Outcome::Pass(b.roles.clone());
1021 }
1022 Some(b) => {
1023 let new: BTreeSet<String> = f.classes.difference(&b.classes).cloned().collect();
1024 let mut need = roles(&new);
1025 need.insert("backend".into());
1026 need_now = need;
1027 need_ever = BTreeSet::new();
1028 }
1029 }
1030 let mut missing = BTreeSet::new();
1031 let mut stale = BTreeSet::new();
1032 for r in need_ever.iter().chain(need_now.iter()) {
1033 if !ever(r) {
1034 missing.insert(r.clone());
1035 } else if need_now.contains(r) && !now(r) {
1036 stale.insert(r.clone());
1037 }
1038 }
1039 if missing.is_empty() && stale.is_empty() {
1040 let mut bound: BTreeSet<String> = need_ever.union(&need_now).cloned().collect();
1041 if let Some(b) = f.baseline {
1042 bound.extend(b.roles.iter().cloned());
1043 }
1044 Outcome::Pass(bound)
1045 } else {
1046 Outcome::Short { missing, stale }
1047 }
1048}
1049
1050fn is_current(b: &Baseline, sha: &str, legacy: &str, classes: &BTreeSet<String>) -> bool {
1053 of_body(&b.sha, sha, legacy) && classes.is_subset(&b.classes)
1054}
1055
1056fn store() -> Option<PathBuf> {
1059 Some(journal::dir()?.join("plan-review"))
1060}
1061
1062fn key(text: &str) -> String {
1063 hex(&sha256(text.as_bytes()))
1064}
1065
1066fn load(file: &Path) -> Option<Baseline> {
1067 let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(file).ok()?).ok()?;
1068 let set = |k: &str| -> BTreeSet<String> {
1069 v.get(k)
1070 .and_then(|a| a.as_array())
1071 .map(|a| {
1072 a.iter()
1073 .filter_map(|x| x.as_str().map(str::to_string))
1074 .collect()
1075 })
1076 .unwrap_or_default()
1077 };
1078 Some(Baseline {
1079 sha: v.get("sha")?.as_str()?.to_string(),
1080 roles: set("roles"),
1081 classes: set("classes"),
1082 })
1083}
1084
1085fn title_key(text: &str, repos: &[String]) -> Option<String> {
1087 let mut repos = repos.to_vec();
1088 repos.sort();
1089 Some(key(&format!("{}\n{}", title(text)?, repos.join(","))))
1090}
1091
1092pub fn baseline(path: &str, sha: &str, legacy: &str, title: Option<&str>) -> Option<Baseline> {
1100 let s = store()?;
1101 let by_path = load(&s.join("by-path").join(format!("{}.json", key(path))));
1102 let by_body = |b: &str| load(&s.join("by-body").join(format!("{b}.json")));
1103 by_path
1104 .clone()
1105 .filter(|b| of_body(&b.sha, sha, legacy))
1106 .or_else(|| by_body(sha))
1107 .or_else(|| by_body(legacy)) .or(by_path)
1109 .or_else(|| load(&s.join("by-title").join(format!("{}.json", title?))))
1110}
1111
1112pub(crate) fn private_dir(dir: &Path) -> Option<()> {
1113 std::fs::create_dir_all(dir).ok()?;
1114 journal::private(dir, 0o700);
1115 Some(())
1116}
1117
1118fn save(path: &str, title: Option<&str>, b: &Baseline) -> Option<()> {
1121 let s = store()?;
1122 private_dir(&s)?;
1123 let body = serde_json::json!({
1124 "sha": b.sha,
1125 "roles": b.roles,
1126 "classes": b.classes,
1127 "path": path,
1128 })
1129 .to_string();
1130 let mut places = vec![
1131 ("by-path", format!("{}.json", key(path))),
1132 ("by-body", format!("{}.json", b.sha)),
1133 ];
1134 if let Some(t) = title {
1135 places.push(("by-title", format!("{t}.json")));
1136 }
1137 for (dir, name) in places {
1138 let d = s.join(dir);
1139 private_dir(&d)?;
1140 let f = d.join(name);
1141 amont_agent_guard::atomic::write_atomic(&f, &body).ok()?;
1142 journal::private(&f, 0o600);
1143 }
1144 Some(())
1145}
1146
1147fn refusals_file(path: &str) -> Option<PathBuf> {
1148 Some(store()?.join("refusals").join(key(path)))
1149}
1150
1151fn refusals(path: &str) -> u32 {
1152 refusals_file(path)
1153 .and_then(|f| std::fs::read_to_string(f).ok())
1154 .and_then(|t| t.trim().parse().ok())
1155 .unwrap_or(0)
1156}
1157
1158fn set_refusals(path: &str, n: u32) {
1159 let Some(f) = refusals_file(path) else { return };
1160 if n == 0 {
1161 let _ = std::fs::remove_file(f);
1162 return;
1163 }
1164 if let Some(d) = f.parent() {
1165 if private_dir(d).is_none() {
1166 return;
1167 }
1168 }
1169 let _ = amont_agent_guard::atomic::write_atomic(&f, &format!("{n}\n"));
1170 journal::private(&f, 0o600);
1171}
1172
1173enum Said {
1177 Pass,
1178 Refuse(String),
1180 Ask(String),
1182}
1183
1184const REMEDY: &str = "run /plan-review then ExitPlanMode again";
1185
1186pub fn on_plan_exit(ev: &PlanExit, stance: Stance) -> Decision {
1187 let project = std::env::current_dir().unwrap_or_default();
1188 let (said, repos, excerpt) = examine(ev);
1189 let carried_anywhere = crate::agents::carried(crate::agents::Need::PlanPanel, &project)
1192 || root().is_some_and(|root| {
1193 repos
1194 .split(',')
1195 .filter(|name| !name.is_empty() && *name != "-")
1196 .any(|name| {
1197 crate::agents::carried(crate::agents::Need::PlanPanel, &locate(&root, name))
1198 })
1199 });
1200 if matches!(said, Said::Refuse(_)) && !carried_anywhere {
1201 return plan_gap(
1203 ev,
1204 stance,
1205 &crate::agents::gap_line(crate::agents::Need::PlanPanel),
1206 );
1207 }
1208 let path_key = ev
1209 .plan_file
1210 .as_deref()
1211 .map(|p| real(p).display().to_string());
1212
1213 let said = match (said, &path_key) {
1216 (Said::Refuse(why), Some(p)) => {
1217 let before = refusals(p);
1218 set_refusals(p, before + 1);
1219 if before >= REFUSALS_BEFORE_ASK {
1220 Said::Ask(format!(
1221 "UNREVIEWED: refused {before}×; {why} Approve only to accept an unreviewed plan."
1222 ))
1223 } else {
1224 Said::Refuse(why)
1225 }
1226 }
1227 (Said::Pass, Some(p)) => {
1228 set_refusals(p, 0);
1229 Said::Pass
1230 }
1231 (other, _) => other,
1232 };
1233
1234 let outcome = match (&said, stance) {
1235 (_, Stance::Observe) => "watched",
1236 (Said::Pass, _) => "passed",
1237 (Said::Refuse(_), Stance::Deny) => "denied",
1238 (Said::Ask(_), Stance::Deny) => "asked",
1239 (_, Stance::Advise) => "advised",
1240 };
1241 journal::record(&journal::Entry {
1242 rule: RULE.id,
1243 stance: stance.as_str(),
1244 outcome,
1245 session: &ev.session,
1246 repo: &repos,
1247 mode: &ev.permission_mode,
1248 excerpt: &excerpt,
1249 });
1250 let phrase = |why: &str, remedy: &str| decision::phrase(RULE.id, why, remedy);
1251 match (said, stance) {
1252 (_, Stance::Observe) | (Said::Pass, _) => Decision::Silent,
1253 (Said::Refuse(why), Stance::Deny) => Decision::Deny(phrase(&why, REMEDY)),
1254 (Said::Ask(why), Stance::Deny) => Decision::Ask(phrase(&why, "")),
1255 (Said::Refuse(why) | Said::Ask(why), Stance::Advise) => Decision::Advise(phrase(&why, "")),
1256 }
1257}
1258
1259fn plan_gap(ev: &PlanExit, stance: Stance, gap: &str) -> Decision {
1261 journal::record(&journal::Entry {
1262 rule: RULE.id,
1263 stance: stance.as_str(),
1264 outcome: if stance == Stance::Observe {
1265 "watched"
1266 } else {
1267 "advised"
1268 },
1269 session: &ev.session,
1270 repo: "-",
1271 mode: &ev.permission_mode,
1272 excerpt: "panel=gap",
1273 });
1274 if stance == Stance::Observe {
1275 Decision::Silent
1276 } else {
1277 Decision::Advise(decision::phrase(RULE.id, gap, ""))
1278 }
1279}
1280
1281fn examine(ev: &PlanExit) -> (Said, String, String) {
1283 let Some(plan_file) = ev.plan_file.as_deref() else {
1284 return (
1285 Said::Ask(
1286 "ExitPlanMode carried no planFilePath, so the plan's review cannot be checked."
1287 .into(),
1288 ),
1289 "-".into(),
1290 "no planFilePath".into(),
1291 );
1292 };
1293 let text = match std::fs::read_to_string(plan_file) {
1294 Ok(t) => t,
1295 Err(e) => {
1296 return (
1297 Said::Ask(format!(
1298 "the plan file {} cannot be read ({e}), so its review cannot be checked.",
1299 plan_file.display()
1300 )),
1301 "-".into(),
1302 "plan unreadable".into(),
1303 )
1304 }
1305 };
1306 let path = real(plan_file).display().to_string();
1307 let Shas { sha, legacy } = match shas(&text) {
1308 Ok(s) => s,
1309 Err(why) => {
1310 return (
1311 Said::Ask(format!(
1312 "{why}; approve only to accept the plan unreviewed."
1313 )),
1314 "-".into(),
1315 "sha unavailable".into(),
1316 )
1317 }
1318 };
1319 let short = &sha[..12];
1320
1321 let m = match meta(&text) {
1322 Ok(m) => m,
1323 Err(why) => {
1324 return (
1325 Said::Refuse(format!("{why};")),
1326 "-".into(),
1327 format!("{short} no meta"),
1328 )
1329 }
1330 };
1331 let repos = m.repos.join(",");
1332 let sec = section(&text);
1333 if !sec.present {
1334 return (
1335 Said::Refuse(format!(
1336 "the plan has no `## Review panel` section for repos={repos};"
1337 )),
1338 repos,
1339 format!("{short} no section"),
1340 );
1341 }
1342
1343 let Some(transcript) = ev.transcript.as_deref() else {
1344 return (
1345 Said::Ask(
1346 "the hook payload carried no transcript_path, so the reviews cannot be checked."
1347 .into(),
1348 ),
1349 repos,
1350 format!("{short} no transcript"),
1351 );
1352 };
1353 let found = match std::fs::File::open(transcript) {
1354 Ok(f) => bindings(std::io::BufReader::new(f)),
1355 Err(e) => {
1356 return (
1357 Said::Ask(format!(
1358 "the session transcript cannot be read ({e}), so the reviews cannot be checked."
1359 )),
1360 repos,
1361 format!("{short} transcript unreadable"),
1362 )
1363 }
1364 };
1365
1366 let classes = match classes_of(&m) {
1367 Ok(c) => c,
1368 Err(said) => {
1369 let what = match &said {
1370 Said::Ask(_) => "cannot resolve",
1371 _ => "unknown repo",
1372 };
1373 return (said, repos, format!("{short} {what}"));
1374 }
1375 };
1376
1377 let title = title_key(&text, &m.repos);
1378 let base = baseline(&path, &sha, &legacy, title.as_deref());
1379 let facts = Facts {
1380 sha: &sha,
1381 legacy: &legacy,
1382 path: &path,
1383 classes: &classes,
1384 bindings: &found,
1385 baseline: base.as_ref(),
1386 };
1387 match judge(&facts) {
1388 Outcome::Pass(bound) => {
1389 if sec.unreviewed {
1390 return (
1391 Said::Ask(format!(
1392 "the plan for repos={repos} is marked unreviewed at the person's request (body {short}); approve only if you asked to skip the review."
1393 )),
1394 repos,
1395 format!("{short} unreviewed"),
1396 );
1397 }
1398 let on_legacy = !matches!(
1402 judge(&Facts {
1403 legacy: "-",
1404 ..facts
1405 }),
1406 Outcome::Pass(_)
1407 );
1408 let matched = if on_legacy { "legacy" } else { "new" };
1409 let _ = save(
1410 &path,
1411 title.as_deref(),
1412 &Baseline {
1413 sha: sha.clone(),
1414 roles: bound.clone(),
1415 classes: classes
1416 .union(&base.map(|b| b.classes).unwrap_or_default())
1417 .cloned()
1418 .collect(),
1419 },
1420 );
1421 let listed = bound.into_iter().collect::<Vec<_>>().join(",");
1422 (
1423 Said::Pass,
1424 repos,
1425 format!("match={matched} {short} {listed}"),
1426 )
1427 }
1428 Outcome::Short { missing, stale } => {
1429 if sec.unreviewed {
1430 return (
1431 Said::Ask(format!(
1432 "the plan for repos={repos} is marked unreviewed at the person's request; missing {} for body {short}. Approve only if you asked to skip the review.",
1433 list(&missing.union(&stale).cloned().collect())
1434 )),
1435 repos,
1436 format!("{short} unreviewed"),
1437 );
1438 }
1439 let mut parts = Vec::new();
1440 if !missing.is_empty() {
1441 parts.push(format!("missing for repos={repos}: {}", list(&missing)));
1442 }
1443 if !stale.is_empty() {
1444 parts.push(format!(
1445 "stale (reviewed an older body) for repos={repos}: {}",
1446 list(&stale)
1447 ));
1448 }
1449 let excerpt = format!(
1450 "{short} short {}",
1451 list(&missing.union(&stale).cloned().collect())
1452 );
1453 (
1454 Said::Refuse(format!("{} (body {short});", parts.join("; "))),
1455 repos,
1456 excerpt,
1457 )
1458 }
1459 }
1460}
1461
1462pub fn areas_of(m: &Meta) -> Option<BTreeSet<String>> {
1465 classes_of(m).ok()
1466}
1467
1468fn list(s: &BTreeSet<String>) -> String {
1469 s.iter().cloned().collect::<Vec<_>>().join(", ")
1470}
1471
1472fn classes_of(m: &Meta) -> Result<BTreeSet<String>, Said> {
1475 let Some(root) = root() else {
1476 return Err(Said::Ask(
1477 "no home directory, so repos= cannot be resolved.".into(),
1478 ));
1479 };
1480 let mut classes: BTreeSet<String> = m.adds.iter().cloned().collect();
1481 for name in &m.repos {
1482 match repo_areas(&locate(&root, name)) {
1483 Ok(Some(a)) => classes.extend(a),
1484 Ok(None) if m.adds.iter().any(|a| a.starts_with("lang:")) => {}
1485 Ok(None) => {
1486 return Err(Said::Refuse(format!(
1487 "repos= names `{name}`, which is not a repository at {} (set `git config --global amont.agent.plan-review.{name}.path <dir>` for one kept elsewhere); a plan that creates one declares its language in adds=lang:<language>;",
1488 locate(&root, name).display()
1489 )))
1490 }
1491 Err(why) => {
1492 return Err(Said::Ask(format!(
1493 "{why}, so the panel cannot be computed."
1494 )))
1495 }
1496 }
1497 }
1498 Ok(classes)
1499}
1500
1501pub struct Panel {
1504 pub repos: String,
1505 pub classes: BTreeSet<String>,
1506 pub sha: String,
1507 pub mode: &'static str,
1510 pub needed: BTreeSet<String>,
1511}
1512
1513pub fn panel(plan_file: &Path) -> Result<Panel, String> {
1514 let text = std::fs::read_to_string(plan_file)
1515 .map_err(|e| format!("cannot read {}: {e}", plan_file.display()))?;
1516 let m = meta(&text)?;
1517 let classes = classes_of(&m).map_err(|said| match said {
1518 Said::Refuse(w) | Said::Ask(w) => w.trim_end_matches(';').to_string(),
1519 Said::Pass => String::new(),
1520 })?;
1521 let path = real(plan_file).display().to_string();
1522 let Shas { sha, legacy } = shas(&text).map_err(|e| e.to_string())?;
1523 let title = title_key(&text, &m.repos);
1524 let (mode, needed) = match baseline(&path, &sha, &legacy, title.as_deref()) {
1525 None => ("full", roles(&classes)),
1526 Some(b) if is_current(&b, &sha, &legacy, &classes) => ("current", BTreeSet::new()),
1527 Some(b) => {
1528 let new: BTreeSet<String> = classes.difference(&b.classes).cloned().collect();
1529 let mut need = roles(&new);
1530 need.insert("backend".into());
1531 ("delta", need)
1532 }
1533 };
1534 Ok(Panel {
1535 repos: m.repos.join(","),
1536 classes,
1537 sha,
1538 mode,
1539 needed,
1540 })
1541}
1542
1543pub fn agent_for(role: &str) -> (String, Option<&str>) {
1545 match role.strip_prefix("lang:") {
1546 Some(l) => (format!("{AGENT_PREFIX}language"), Some(l)),
1547 None => (format!("{AGENT_PREFIX}{role}"), None),
1548 }
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553 use super::*;
1554 use crate::plan_text::{body_sha, guarded, legacy_sha, ShaError};
1555
1556 #[test]
1558 fn remit_is_the_inverse_of_roles() {
1559 for c in ["cli", "ops", "large", "ui", "lang:rust", "lang:go"] {
1560 let class = BTreeSet::from([c.to_string()]);
1561 for r in roles(&class).into_iter().filter(|r| r != "backend") {
1562 assert!(remit(&r).contains(c), "{c} not in remit({r})");
1563 }
1564 }
1565 assert_eq!(remit("backend"), BTreeSet::from(["*".to_string()]));
1566 }
1567
1568 fn set(items: &[&str]) -> BTreeSet<String> {
1569 items.iter().map(|s| s.to_string()).collect()
1570 }
1571
1572 const PLAN: &str = "# Title\n\n## Review panel\n\n👉 Decide: none\n\n## Context\n\nBody. \n\n```md\n## Review panel\nkept\n```\n\n## Full reviews (reference)\n\n- rust: ok\n\n<!-- panel: repos=amont-agent adds=cli body-sha=abc -->\n";
1573
1574 #[test]
1575 fn the_canonical_body_drops_review_metadata_and_nothing_else() {
1576 let c = canonical(PLAN);
1577 assert_eq!(
1578 c,
1579 "# Title\n\n## Context\n\nBody.\n\n```md\n## Review panel\nkept\n```\n"
1580 );
1581 }
1582
1583 #[test]
1584 fn canonicalising_is_idempotent_and_line_endings_do_not_count() {
1585 let once = canonical(PLAN);
1586 assert_eq!(canonical(&once), once);
1587 assert_eq!(body_sha(&PLAN.replace('\n', "\r\n")), body_sha(PLAN));
1588 }
1589
1590 #[test]
1591 fn front_matter_added_on_landing_keeps_the_sha() {
1592 let landed = format!(
1593 "---\nstatus: active\nbranch: feat/x\nrepos: [x]\n---\n\n{}",
1594 PLAN.replace(
1595 "👉 Decide: none",
1596 "👉 Decide: none\n📄 Full reviews: [x](x.reviews.md)"
1597 )
1598 .split("\n## Full reviews")
1599 .next()
1600 .unwrap()
1601 ) + "\n<!-- panel: repos=amont-agent -->\n";
1602 assert_eq!(body_sha(&landed), body_sha(PLAN));
1603 assert_eq!(canonical(&canonical(&landed)), canonical(&landed));
1604 let open = format!("---\n{PLAN}");
1608 assert_ne!(legacy_sha(&open), legacy_sha(PLAN));
1609 let later = PLAN.replace("Body.", "Body.\n\n---\n\nMore.\n\n---");
1610 assert!(canonical(&later).contains("---\n\nMore."));
1611 }
1612
1613 #[test]
1614 fn writing_review_metadata_keeps_the_sha() {
1615 let edited = PLAN
1616 .replace("👉 Decide: none", "👉 Decide: none left\n📍 amont-agent")
1617 .replace("- rust: ok", "- rust: ok\n- backend: ok")
1618 .replace("body-sha=abc", "body-sha=def reviewers=backend");
1619 assert_eq!(body_sha(&edited), body_sha(PLAN));
1620 assert_ne!(body_sha(&PLAN.replace("Body.", "Body!")), body_sha(PLAN));
1621 }
1622
1623 #[test]
1627 fn the_shas_of_a_fixed_plan_do_not_move() {
1628 assert_eq!(
1629 body_sha(PLAN),
1630 "ab25150b6b847cc4ed344aad0d38ae282422b111a37f8f48c14863dff74ed788"
1631 );
1632 assert_eq!(
1633 legacy_sha(PLAN),
1634 "db6b774afd48bd8c7bd4edb3b8758a4417beae2c3ce26d4245cba7bc36e396dd"
1635 );
1636 }
1637
1638 #[test]
1639 fn formatting_the_plan_keeps_the_sha_and_the_legacy_sha_does_not() {
1640 let formatted = PLAN.replace("## Context\n\nBody.", "## Context\n\n\nBody.");
1641 assert_eq!(body_sha(&formatted), body_sha(PLAN));
1642 assert_ne!(legacy_sha(&formatted), legacy_sha(PLAN));
1643 }
1644
1645 #[test]
1646 fn a_panic_computing_the_sha_is_an_error_not_silence() {
1647 assert_eq!(
1648 guarded(|| panic!("parser bug")),
1649 Err(ShaError::ParserPanicked)
1650 );
1651 assert!(shas(PLAN).is_ok());
1652 }
1653
1654 #[test]
1655 fn a_parser_panic_in_the_hook_goes_to_the_person() {
1656 let dir =
1657 std::env::temp_dir().join(format!("amont-agent-sha-panic-{}", std::process::id()));
1658 std::fs::create_dir_all(&dir).unwrap();
1659 let plan = dir.join("p.md");
1660 std::fs::write(&plan, PLAN).unwrap();
1661 let ev = PlanExit {
1662 session: "s".into(),
1663 permission_mode: "plan".into(),
1664 transcript: None,
1665 plan_file: Some(plan),
1666 };
1667 crate::plan_text::PARSER_PANICS.with(|p| p.set(true));
1668 let (said, _, excerpt) = examine(&ev);
1669 crate::plan_text::PARSER_PANICS.with(|p| p.set(false));
1670 let _ = std::fs::remove_dir_all(&dir);
1671 assert!(
1672 matches!(said, Said::Ask(ref why) if why.contains("cannot be computed")),
1673 "{excerpt}"
1674 );
1675 assert_eq!(excerpt, "sha unavailable");
1676 }
1677
1678 #[test]
1679 fn the_title_is_the_first_h1_outside_a_fence() {
1680 assert_eq!(title(PLAN).as_deref(), Some("Title"));
1681 let fenced = "```md\n# Not this\n```\n\n# This one\n";
1682 assert_eq!(title(fenced).as_deref(), Some("This one"));
1683 assert_eq!(title("no heading\n"), None);
1684 }
1685
1686 #[test]
1687 fn the_machine_comment_is_read_and_checked() {
1688 let m = meta(PLAN).unwrap();
1689 assert_eq!(m.repos, vec!["amont-agent"]);
1690 assert_eq!(m.adds, vec!["cli"]);
1691 assert!(meta("# t\n").is_err());
1692 assert!(meta("<!-- panel: adds=ui -->").is_err());
1693 for bad in ["..", "a/b", "-x", "."] {
1694 assert!(
1695 meta(&format!("<!-- panel: repos={bad} -->")).is_err(),
1696 "{bad}"
1697 );
1698 }
1699 assert!(meta("<!-- panel: repos=x adds=gui -->").is_err());
1700 assert!(meta("<!-- panel: repos=x adds=lang:rust,ops -->").is_ok());
1701 }
1702
1703 #[test]
1704 fn the_review_section_and_a_requested_skip_are_found() {
1705 assert_eq!(
1706 section(PLAN),
1707 Section {
1708 present: true,
1709 unreviewed: false
1710 }
1711 );
1712 let skipped = PLAN.replace("👉 Decide: none", "⚠ unreviewed: the person asked to skip");
1713 assert!(section(&skipped).unreviewed);
1714 assert!(!section("# t\n```\n## Review panel\n```\n").present);
1715 }
1716
1717 #[test]
1718 fn areas_come_from_the_tree_and_the_files_read_from_it() {
1719 let paths: Vec<String> = [
1720 "Cargo.toml",
1721 "src/main.rs",
1722 "web/package.json",
1723 "deploy/kustomization.yaml",
1724 "vendor/x/go.mod",
1725 "Cargo.lock",
1726 ]
1727 .iter()
1728 .map(|s| s.to_string())
1729 .collect();
1730 let mut blobs = HashMap::new();
1731 blobs.insert(
1732 "web/package.json".to_string(),
1733 r#"{"scripts":{"dev":"vite"}}"#.to_string(),
1734 );
1735 let got = classify(&paths, &blobs, Some(10));
1736 assert_eq!(
1737 got,
1738 set(&["cli", "lang:rust", "lang:typescript", "ops", "ui"])
1739 );
1740 assert!(classify(&paths, &blobs, Some(500)).contains("large"));
1741 assert!(
1742 !classify(&paths, &blobs, None).contains("large"),
1743 "shallow: files only"
1744 );
1745 }
1746
1747 #[test]
1748 fn adr_areas_declare_traits() {
1749 let t = "dir: docs\nareas:\n # ui: x\n \"apps/a/**\": [ui]\n bootstrap/**: [cli, ui]\ndisclaims:\n x/**: [ops]\n";
1750 assert_eq!(adr_traits(t), set(&["cli", "ui"]));
1751 }
1752
1753 #[test]
1754 fn the_panel_follows_the_areas() {
1755 assert_eq!(roles(&set(&[])), set(&["backend"]));
1756 assert_eq!(
1757 roles(&set(&["lang:rust", "cli"])),
1758 set(&["backend", "lang:rust", "unix", "tui"])
1759 );
1760 assert_eq!(roles(&set(&["ui"])).len(), 5);
1761 }
1762
1763 fn b(role: &str, sha: &str) -> Binding {
1764 Binding {
1765 role: role.into(),
1766 path: "/p.md".into(),
1767 sha: sha.into(),
1768 }
1769 }
1770
1771 #[test]
1772 fn a_first_panel_needs_every_role_and_backend_on_the_final_body() {
1773 let classes = set(&["lang:rust", "cli"]);
1774 let old = "1".repeat(64);
1775 let new = "2".repeat(64);
1776 let bs = vec![
1777 b("lang:rust", &old),
1778 b("unix", &old),
1779 b("tui", &old),
1780 b("backend", &old),
1781 ];
1782 let f = Facts {
1783 sha: &new,
1784 legacy: "-",
1785 path: "/p.md",
1786 classes: &classes,
1787 bindings: &bs,
1788 baseline: None,
1789 };
1790 assert_eq!(
1791 judge(&f),
1792 Outcome::Short {
1793 missing: set(&[]),
1794 stale: set(&["backend"])
1795 }
1796 );
1797 let mut bs2 = bs.clone();
1798 bs2.push(b("backend", &new));
1799 let f = Facts {
1800 bindings: &bs2,
1801 ..f
1802 };
1803 assert!(matches!(judge(&f), Outcome::Pass(_)));
1804 }
1805
1806 #[test]
1807 fn a_review_of_another_plan_does_not_bind() {
1808 let classes = set(&[]);
1809 let sha = "1".repeat(64);
1810 let mut other = b("backend", &sha);
1811 other.path = "/other.md".into();
1812 let bs = [other];
1813 let f = Facts {
1814 sha: &sha,
1815 legacy: "-",
1816 path: "/p.md",
1817 classes: &classes,
1818 bindings: &bs,
1819 baseline: None,
1820 };
1821 assert_eq!(
1822 judge(&f),
1823 Outcome::Short {
1824 missing: set(&["backend"]),
1825 stale: set(&[])
1826 }
1827 );
1828 }
1829
1830 #[test]
1831 fn a_delta_needs_backend_and_the_new_areas_only() {
1832 let base = Baseline {
1833 sha: "1".repeat(64),
1834 roles: set(&["backend", "lang:rust"]),
1835 classes: set(&["lang:rust"]),
1836 };
1837 let new = "2".repeat(64);
1838 let classes = set(&["lang:rust", "ui"]);
1839 let bs = vec![b("backend", &new)];
1840 let f = Facts {
1841 sha: &new,
1842 legacy: "-",
1843 path: "/p.md",
1844 classes: &classes,
1845 bindings: &bs,
1846 baseline: Some(&base),
1847 };
1848 assert_eq!(
1849 judge(&f),
1850 Outcome::Short {
1851 missing: set(&["react", "ui-design", "ux-research", "game-ux"]),
1852 stale: set(&[])
1853 }
1854 );
1855 let same = base.sha.clone();
1857 let old_classes = set(&["lang:rust"]);
1858 let f = Facts {
1859 sha: &same,
1860 classes: &old_classes,
1861 bindings: &[],
1862 ..f
1863 };
1864 assert!(matches!(judge(&f), Outcome::Pass(_)));
1865 }
1866
1867 #[test]
1868 fn a_review_bound_to_the_legacy_sha_still_counts() {
1869 let classes = set(&["lang:rust"]);
1870 let (new, old) = ("2".repeat(64), "1".repeat(64));
1871 let bs = vec![b("lang:rust", &old), b("backend", &old)];
1872 let f = Facts {
1873 sha: &new,
1874 legacy: &old,
1875 path: "/p.md",
1876 classes: &classes,
1877 bindings: &bs,
1878 baseline: None,
1879 };
1880 assert!(
1881 matches!(judge(&f), Outcome::Pass(_)),
1882 "backend at the legacy sha is current"
1883 );
1884 let f = Facts { legacy: "-", ..f };
1885 assert!(
1886 matches!(judge(&f), Outcome::Short { ref stale, .. } if stale.contains("backend")),
1887 "without the legacy sha it is an older body"
1888 );
1889 }
1890
1891 #[test]
1892 fn a_baseline_at_the_legacy_sha_is_current_for_judge_and_panel_alike() {
1893 let classes = set(&["lang:rust"]);
1894 let (new, old) = ("2".repeat(64), "1".repeat(64));
1895 let base = Baseline {
1896 sha: old.clone(),
1897 roles: set(&["backend", "lang:rust"]),
1898 classes: classes.clone(),
1899 };
1900 assert!(is_current(&base, &new, &old, &classes));
1901 assert!(!is_current(&base, &new, "-", &classes));
1902 assert!(!is_current(&base, &new, &old, &set(&["lang:rust", "ui"])));
1903 let f = Facts {
1904 sha: &new,
1905 legacy: &old,
1906 path: "/p.md",
1907 classes: &classes,
1908 bindings: &[],
1909 baseline: Some(&base),
1910 };
1911 assert!(matches!(judge(&f), Outcome::Pass(_)));
1912 }
1913
1914 #[test]
1915 fn blocks_are_read_from_a_reviewer_prompt() {
1916 let sha = "a".repeat(64);
1917 let p = format!("Review this.\n<<<PLAN path=/x/my plan.md sha={sha} lang=rust>>>\nthanks");
1918 assert_eq!(
1919 parse_blocks("plan-review-language", &p),
1920 vec![Binding {
1921 role: "lang:rust".into(),
1922 path: "/x/my plan.md".into(),
1923 sha: sha.clone()
1924 }]
1925 );
1926 assert!(parse_blocks("plan-review-language", &p.replace(" lang=rust", "")).is_empty());
1927 assert_eq!(parse_blocks("plan-review-backend", &p)[0].role, "backend");
1928 assert!(parse_blocks("plan-review-backend", "<<<PLAN path=/x sha=short>>>").is_empty());
1929 }
1930
1931 #[test]
1934 fn a_launch_that_handed_back_completes_with_its_report() {
1935 let launch: Vec<&str> = include_str!("../../../tests/fixtures/sendmessage-resume.jsonl")
1936 .lines()
1937 .take(3)
1938 .collect();
1939 let pointer = "This agent's report was delivered to you as a message from aa339dbce3244a139 (its SubagentHandback call).";
1940 let notice = launch[2].replace(
1941 "Verdict: approve-with-changes\\nFindings:\\n1. … (trimmed)",
1942 pointer,
1943 );
1944 assert_ne!(notice, launch[2]);
1945 let handback = include_str!("../../../tests/fixtures/handback-entry.jsonl").trim_end();
1946 let text = format!("{}\n{}\n{handback}\n{notice}\n", launch[0], launch[1]);
1947 let done = completed_agents(std::io::Cursor::new(text.clone()), "implementation-review");
1948 assert_eq!(done.len(), 1);
1949 assert!(
1950 done[0].result.contains("Verdict: approve"),
1951 "{}",
1952 done[0].result
1953 );
1954 let alone = format!("{}\n{}\n{notice}\n", launch[0], launch[1]);
1956 let done = completed_agents(std::io::Cursor::new(alone), "implementation-review");
1957 assert!(!done[0].result.contains("Verdict:"), "{}", done[0].result);
1958 }
1959
1960 #[test]
1961 fn a_notification_names_what_completed() {
1962 let t = "<task-notification>\n<task-id>a</task-id>\n<tool-use-id>toolu_1</tool-use-id>\n<status>completed</status>\n</task-notification><task-notification><tool-use-id>toolu_2</tool-use-id><status>failed</status></task-notification>";
1963 assert_eq!(completed_ids(t), vec!["toolu_1"]);
1964 }
1965}