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 (said, repos, excerpt) = examine(ev);
1188 let path_key = ev
1189 .plan_file
1190 .as_deref()
1191 .map(|p| real(p).display().to_string());
1192
1193 let said = match (said, &path_key) {
1196 (Said::Refuse(why), Some(p)) => {
1197 let before = refusals(p);
1198 set_refusals(p, before + 1);
1199 if before >= REFUSALS_BEFORE_ASK {
1200 Said::Ask(format!(
1201 "UNREVIEWED: refused {before}×; {why} Approve only to accept an unreviewed plan."
1202 ))
1203 } else {
1204 Said::Refuse(why)
1205 }
1206 }
1207 (Said::Pass, Some(p)) => {
1208 set_refusals(p, 0);
1209 Said::Pass
1210 }
1211 (other, _) => other,
1212 };
1213
1214 let outcome = match (&said, stance) {
1215 (_, Stance::Observe) => "watched",
1216 (Said::Pass, _) => "passed",
1217 (Said::Refuse(_), Stance::Deny) => "denied",
1218 (Said::Ask(_), Stance::Deny) => "asked",
1219 (_, Stance::Advise) => "advised",
1220 };
1221 journal::record(&journal::Entry {
1222 rule: RULE.id,
1223 stance: stance.as_str(),
1224 outcome,
1225 session: &ev.session,
1226 repo: &repos,
1227 mode: &ev.permission_mode,
1228 excerpt: &excerpt,
1229 });
1230 let phrase = |why: &str, remedy: &str| decision::phrase(RULE.id, why, remedy);
1231 match (said, stance) {
1232 (_, Stance::Observe) | (Said::Pass, _) => Decision::Silent,
1233 (Said::Refuse(why), Stance::Deny) => Decision::Deny(phrase(&why, REMEDY)),
1234 (Said::Ask(why), Stance::Deny) => Decision::Ask(phrase(&why, "")),
1235 (Said::Refuse(why) | Said::Ask(why), Stance::Advise) => Decision::Advise(phrase(&why, "")),
1236 }
1237}
1238
1239fn examine(ev: &PlanExit) -> (Said, String, String) {
1241 let Some(plan_file) = ev.plan_file.as_deref() else {
1242 return (
1243 Said::Ask(
1244 "ExitPlanMode carried no planFilePath, so the plan's review cannot be checked."
1245 .into(),
1246 ),
1247 "-".into(),
1248 "no planFilePath".into(),
1249 );
1250 };
1251 let text = match std::fs::read_to_string(plan_file) {
1252 Ok(t) => t,
1253 Err(e) => {
1254 return (
1255 Said::Ask(format!(
1256 "the plan file {} cannot be read ({e}), so its review cannot be checked.",
1257 plan_file.display()
1258 )),
1259 "-".into(),
1260 "plan unreadable".into(),
1261 )
1262 }
1263 };
1264 let path = real(plan_file).display().to_string();
1265 let Shas { sha, legacy } = match shas(&text) {
1266 Ok(s) => s,
1267 Err(why) => {
1268 return (
1269 Said::Ask(format!(
1270 "{why}; approve only to accept the plan unreviewed."
1271 )),
1272 "-".into(),
1273 "sha unavailable".into(),
1274 )
1275 }
1276 };
1277 let short = &sha[..12];
1278
1279 let m = match meta(&text) {
1280 Ok(m) => m,
1281 Err(why) => {
1282 return (
1283 Said::Refuse(format!("{why};")),
1284 "-".into(),
1285 format!("{short} no meta"),
1286 )
1287 }
1288 };
1289 let repos = m.repos.join(",");
1290 let sec = section(&text);
1291 if !sec.present {
1292 return (
1293 Said::Refuse(format!(
1294 "the plan has no `## Review panel` section for repos={repos};"
1295 )),
1296 repos,
1297 format!("{short} no section"),
1298 );
1299 }
1300
1301 let Some(transcript) = ev.transcript.as_deref() else {
1302 return (
1303 Said::Ask(
1304 "the hook payload carried no transcript_path, so the reviews cannot be checked."
1305 .into(),
1306 ),
1307 repos,
1308 format!("{short} no transcript"),
1309 );
1310 };
1311 let found = match std::fs::File::open(transcript) {
1312 Ok(f) => bindings(std::io::BufReader::new(f)),
1313 Err(e) => {
1314 return (
1315 Said::Ask(format!(
1316 "the session transcript cannot be read ({e}), so the reviews cannot be checked."
1317 )),
1318 repos,
1319 format!("{short} transcript unreadable"),
1320 )
1321 }
1322 };
1323
1324 let classes = match classes_of(&m) {
1325 Ok(c) => c,
1326 Err(said) => {
1327 let what = match &said {
1328 Said::Ask(_) => "cannot resolve",
1329 _ => "unknown repo",
1330 };
1331 return (said, repos, format!("{short} {what}"));
1332 }
1333 };
1334
1335 let title = title_key(&text, &m.repos);
1336 let base = baseline(&path, &sha, &legacy, title.as_deref());
1337 let facts = Facts {
1338 sha: &sha,
1339 legacy: &legacy,
1340 path: &path,
1341 classes: &classes,
1342 bindings: &found,
1343 baseline: base.as_ref(),
1344 };
1345 match judge(&facts) {
1346 Outcome::Pass(bound) => {
1347 if sec.unreviewed {
1348 return (
1349 Said::Ask(format!(
1350 "the plan for repos={repos} is marked unreviewed at the person's request (body {short}); approve only if you asked to skip the review."
1351 )),
1352 repos,
1353 format!("{short} unreviewed"),
1354 );
1355 }
1356 let on_legacy = !matches!(
1360 judge(&Facts {
1361 legacy: "-",
1362 ..facts
1363 }),
1364 Outcome::Pass(_)
1365 );
1366 let matched = if on_legacy { "legacy" } else { "new" };
1367 let _ = save(
1368 &path,
1369 title.as_deref(),
1370 &Baseline {
1371 sha: sha.clone(),
1372 roles: bound.clone(),
1373 classes: classes
1374 .union(&base.map(|b| b.classes).unwrap_or_default())
1375 .cloned()
1376 .collect(),
1377 },
1378 );
1379 let listed = bound.into_iter().collect::<Vec<_>>().join(",");
1380 (
1381 Said::Pass,
1382 repos,
1383 format!("match={matched} {short} {listed}"),
1384 )
1385 }
1386 Outcome::Short { missing, stale } => {
1387 if sec.unreviewed {
1388 return (
1389 Said::Ask(format!(
1390 "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.",
1391 list(&missing.union(&stale).cloned().collect())
1392 )),
1393 repos,
1394 format!("{short} unreviewed"),
1395 );
1396 }
1397 let mut parts = Vec::new();
1398 if !missing.is_empty() {
1399 parts.push(format!("missing for repos={repos}: {}", list(&missing)));
1400 }
1401 if !stale.is_empty() {
1402 parts.push(format!(
1403 "stale (reviewed an older body) for repos={repos}: {}",
1404 list(&stale)
1405 ));
1406 }
1407 let excerpt = format!(
1408 "{short} short {}",
1409 list(&missing.union(&stale).cloned().collect())
1410 );
1411 (
1412 Said::Refuse(format!("{} (body {short});", parts.join("; "))),
1413 repos,
1414 excerpt,
1415 )
1416 }
1417 }
1418}
1419
1420pub fn areas_of(m: &Meta) -> Option<BTreeSet<String>> {
1423 classes_of(m).ok()
1424}
1425
1426fn list(s: &BTreeSet<String>) -> String {
1427 s.iter().cloned().collect::<Vec<_>>().join(", ")
1428}
1429
1430fn classes_of(m: &Meta) -> Result<BTreeSet<String>, Said> {
1433 let Some(root) = root() else {
1434 return Err(Said::Ask(
1435 "no home directory, so repos= cannot be resolved.".into(),
1436 ));
1437 };
1438 let mut classes: BTreeSet<String> = m.adds.iter().cloned().collect();
1439 for name in &m.repos {
1440 match repo_areas(&locate(&root, name)) {
1441 Ok(Some(a)) => classes.extend(a),
1442 Ok(None) if m.adds.iter().any(|a| a.starts_with("lang:")) => {}
1443 Ok(None) => {
1444 return Err(Said::Refuse(format!(
1445 "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>;",
1446 locate(&root, name).display()
1447 )))
1448 }
1449 Err(why) => {
1450 return Err(Said::Ask(format!(
1451 "{why}, so the panel cannot be computed."
1452 )))
1453 }
1454 }
1455 }
1456 Ok(classes)
1457}
1458
1459pub struct Panel {
1462 pub repos: String,
1463 pub classes: BTreeSet<String>,
1464 pub sha: String,
1465 pub mode: &'static str,
1468 pub needed: BTreeSet<String>,
1469}
1470
1471pub fn panel(plan_file: &Path) -> Result<Panel, String> {
1472 let text = std::fs::read_to_string(plan_file)
1473 .map_err(|e| format!("cannot read {}: {e}", plan_file.display()))?;
1474 let m = meta(&text)?;
1475 let classes = classes_of(&m).map_err(|said| match said {
1476 Said::Refuse(w) | Said::Ask(w) => w.trim_end_matches(';').to_string(),
1477 Said::Pass => String::new(),
1478 })?;
1479 let path = real(plan_file).display().to_string();
1480 let Shas { sha, legacy } = shas(&text).map_err(|e| e.to_string())?;
1481 let title = title_key(&text, &m.repos);
1482 let (mode, needed) = match baseline(&path, &sha, &legacy, title.as_deref()) {
1483 None => ("full", roles(&classes)),
1484 Some(b) if is_current(&b, &sha, &legacy, &classes) => ("current", BTreeSet::new()),
1485 Some(b) => {
1486 let new: BTreeSet<String> = classes.difference(&b.classes).cloned().collect();
1487 let mut need = roles(&new);
1488 need.insert("backend".into());
1489 ("delta", need)
1490 }
1491 };
1492 Ok(Panel {
1493 repos: m.repos.join(","),
1494 classes,
1495 sha,
1496 mode,
1497 needed,
1498 })
1499}
1500
1501pub fn agent_for(role: &str) -> (String, Option<&str>) {
1503 match role.strip_prefix("lang:") {
1504 Some(l) => (format!("{AGENT_PREFIX}language"), Some(l)),
1505 None => (format!("{AGENT_PREFIX}{role}"), None),
1506 }
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511 use super::*;
1512 use crate::plan_text::{body_sha, guarded, legacy_sha, ShaError};
1513
1514 #[test]
1516 fn remit_is_the_inverse_of_roles() {
1517 for c in ["cli", "ops", "large", "ui", "lang:rust", "lang:go"] {
1518 let class = BTreeSet::from([c.to_string()]);
1519 for r in roles(&class).into_iter().filter(|r| r != "backend") {
1520 assert!(remit(&r).contains(c), "{c} not in remit({r})");
1521 }
1522 }
1523 assert_eq!(remit("backend"), BTreeSet::from(["*".to_string()]));
1524 }
1525
1526 fn set(items: &[&str]) -> BTreeSet<String> {
1527 items.iter().map(|s| s.to_string()).collect()
1528 }
1529
1530 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";
1531
1532 #[test]
1533 fn the_canonical_body_drops_review_metadata_and_nothing_else() {
1534 let c = canonical(PLAN);
1535 assert_eq!(
1536 c,
1537 "# Title\n\n## Context\n\nBody.\n\n```md\n## Review panel\nkept\n```\n"
1538 );
1539 }
1540
1541 #[test]
1542 fn canonicalising_is_idempotent_and_line_endings_do_not_count() {
1543 let once = canonical(PLAN);
1544 assert_eq!(canonical(&once), once);
1545 assert_eq!(body_sha(&PLAN.replace('\n', "\r\n")), body_sha(PLAN));
1546 }
1547
1548 #[test]
1549 fn front_matter_added_on_landing_keeps_the_sha() {
1550 let landed = format!(
1551 "---\nstatus: active\nbranch: feat/x\nrepos: [x]\n---\n\n{}",
1552 PLAN.replace(
1553 "👉 Decide: none",
1554 "👉 Decide: none\n📄 Full reviews: [x](x.reviews.md)"
1555 )
1556 .split("\n## Full reviews")
1557 .next()
1558 .unwrap()
1559 ) + "\n<!-- panel: repos=amont-agent -->\n";
1560 assert_eq!(body_sha(&landed), body_sha(PLAN));
1561 assert_eq!(canonical(&canonical(&landed)), canonical(&landed));
1562 let open = format!("---\n{PLAN}");
1566 assert_ne!(legacy_sha(&open), legacy_sha(PLAN));
1567 let later = PLAN.replace("Body.", "Body.\n\n---\n\nMore.\n\n---");
1568 assert!(canonical(&later).contains("---\n\nMore."));
1569 }
1570
1571 #[test]
1572 fn writing_review_metadata_keeps_the_sha() {
1573 let edited = PLAN
1574 .replace("👉 Decide: none", "👉 Decide: none left\n📍 amont-agent")
1575 .replace("- rust: ok", "- rust: ok\n- backend: ok")
1576 .replace("body-sha=abc", "body-sha=def reviewers=backend");
1577 assert_eq!(body_sha(&edited), body_sha(PLAN));
1578 assert_ne!(body_sha(&PLAN.replace("Body.", "Body!")), body_sha(PLAN));
1579 }
1580
1581 #[test]
1585 fn the_shas_of_a_fixed_plan_do_not_move() {
1586 assert_eq!(
1587 body_sha(PLAN),
1588 "ab25150b6b847cc4ed344aad0d38ae282422b111a37f8f48c14863dff74ed788"
1589 );
1590 assert_eq!(
1591 legacy_sha(PLAN),
1592 "db6b774afd48bd8c7bd4edb3b8758a4417beae2c3ce26d4245cba7bc36e396dd"
1593 );
1594 }
1595
1596 #[test]
1597 fn formatting_the_plan_keeps_the_sha_and_the_legacy_sha_does_not() {
1598 let formatted = PLAN.replace("## Context\n\nBody.", "## Context\n\n\nBody.");
1599 assert_eq!(body_sha(&formatted), body_sha(PLAN));
1600 assert_ne!(legacy_sha(&formatted), legacy_sha(PLAN));
1601 }
1602
1603 #[test]
1604 fn a_panic_computing_the_sha_is_an_error_not_silence() {
1605 assert_eq!(
1606 guarded(|| panic!("parser bug")),
1607 Err(ShaError::ParserPanicked)
1608 );
1609 assert!(shas(PLAN).is_ok());
1610 }
1611
1612 #[test]
1613 fn a_parser_panic_in_the_hook_goes_to_the_person() {
1614 let dir =
1615 std::env::temp_dir().join(format!("amont-agent-sha-panic-{}", std::process::id()));
1616 std::fs::create_dir_all(&dir).unwrap();
1617 let plan = dir.join("p.md");
1618 std::fs::write(&plan, PLAN).unwrap();
1619 let ev = PlanExit {
1620 session: "s".into(),
1621 permission_mode: "plan".into(),
1622 transcript: None,
1623 plan_file: Some(plan),
1624 };
1625 crate::plan_text::PARSER_PANICS.with(|p| p.set(true));
1626 let (said, _, excerpt) = examine(&ev);
1627 crate::plan_text::PARSER_PANICS.with(|p| p.set(false));
1628 let _ = std::fs::remove_dir_all(&dir);
1629 assert!(
1630 matches!(said, Said::Ask(ref why) if why.contains("cannot be computed")),
1631 "{excerpt}"
1632 );
1633 assert_eq!(excerpt, "sha unavailable");
1634 }
1635
1636 #[test]
1637 fn the_title_is_the_first_h1_outside_a_fence() {
1638 assert_eq!(title(PLAN).as_deref(), Some("Title"));
1639 let fenced = "```md\n# Not this\n```\n\n# This one\n";
1640 assert_eq!(title(fenced).as_deref(), Some("This one"));
1641 assert_eq!(title("no heading\n"), None);
1642 }
1643
1644 #[test]
1645 fn the_machine_comment_is_read_and_checked() {
1646 let m = meta(PLAN).unwrap();
1647 assert_eq!(m.repos, vec!["amont-agent"]);
1648 assert_eq!(m.adds, vec!["cli"]);
1649 assert!(meta("# t\n").is_err());
1650 assert!(meta("<!-- panel: adds=ui -->").is_err());
1651 for bad in ["..", "a/b", "-x", "."] {
1652 assert!(
1653 meta(&format!("<!-- panel: repos={bad} -->")).is_err(),
1654 "{bad}"
1655 );
1656 }
1657 assert!(meta("<!-- panel: repos=x adds=gui -->").is_err());
1658 assert!(meta("<!-- panel: repos=x adds=lang:rust,ops -->").is_ok());
1659 }
1660
1661 #[test]
1662 fn the_review_section_and_a_requested_skip_are_found() {
1663 assert_eq!(
1664 section(PLAN),
1665 Section {
1666 present: true,
1667 unreviewed: false
1668 }
1669 );
1670 let skipped = PLAN.replace("👉 Decide: none", "⚠ unreviewed: the person asked to skip");
1671 assert!(section(&skipped).unreviewed);
1672 assert!(!section("# t\n```\n## Review panel\n```\n").present);
1673 }
1674
1675 #[test]
1676 fn areas_come_from_the_tree_and_the_files_read_from_it() {
1677 let paths: Vec<String> = [
1678 "Cargo.toml",
1679 "src/main.rs",
1680 "web/package.json",
1681 "deploy/kustomization.yaml",
1682 "vendor/x/go.mod",
1683 "Cargo.lock",
1684 ]
1685 .iter()
1686 .map(|s| s.to_string())
1687 .collect();
1688 let mut blobs = HashMap::new();
1689 blobs.insert(
1690 "web/package.json".to_string(),
1691 r#"{"scripts":{"dev":"vite"}}"#.to_string(),
1692 );
1693 let got = classify(&paths, &blobs, Some(10));
1694 assert_eq!(
1695 got,
1696 set(&["cli", "lang:rust", "lang:typescript", "ops", "ui"])
1697 );
1698 assert!(classify(&paths, &blobs, Some(500)).contains("large"));
1699 assert!(
1700 !classify(&paths, &blobs, None).contains("large"),
1701 "shallow: files only"
1702 );
1703 }
1704
1705 #[test]
1706 fn adr_areas_declare_traits() {
1707 let t = "dir: docs\nareas:\n # ui: x\n \"apps/a/**\": [ui]\n bootstrap/**: [cli, ui]\ndisclaims:\n x/**: [ops]\n";
1708 assert_eq!(adr_traits(t), set(&["cli", "ui"]));
1709 }
1710
1711 #[test]
1712 fn the_panel_follows_the_areas() {
1713 assert_eq!(roles(&set(&[])), set(&["backend"]));
1714 assert_eq!(
1715 roles(&set(&["lang:rust", "cli"])),
1716 set(&["backend", "lang:rust", "unix", "tui"])
1717 );
1718 assert_eq!(roles(&set(&["ui"])).len(), 5);
1719 }
1720
1721 fn b(role: &str, sha: &str) -> Binding {
1722 Binding {
1723 role: role.into(),
1724 path: "/p.md".into(),
1725 sha: sha.into(),
1726 }
1727 }
1728
1729 #[test]
1730 fn a_first_panel_needs_every_role_and_backend_on_the_final_body() {
1731 let classes = set(&["lang:rust", "cli"]);
1732 let old = "1".repeat(64);
1733 let new = "2".repeat(64);
1734 let bs = vec![
1735 b("lang:rust", &old),
1736 b("unix", &old),
1737 b("tui", &old),
1738 b("backend", &old),
1739 ];
1740 let f = Facts {
1741 sha: &new,
1742 legacy: "-",
1743 path: "/p.md",
1744 classes: &classes,
1745 bindings: &bs,
1746 baseline: None,
1747 };
1748 assert_eq!(
1749 judge(&f),
1750 Outcome::Short {
1751 missing: set(&[]),
1752 stale: set(&["backend"])
1753 }
1754 );
1755 let mut bs2 = bs.clone();
1756 bs2.push(b("backend", &new));
1757 let f = Facts {
1758 bindings: &bs2,
1759 ..f
1760 };
1761 assert!(matches!(judge(&f), Outcome::Pass(_)));
1762 }
1763
1764 #[test]
1765 fn a_review_of_another_plan_does_not_bind() {
1766 let classes = set(&[]);
1767 let sha = "1".repeat(64);
1768 let mut other = b("backend", &sha);
1769 other.path = "/other.md".into();
1770 let bs = [other];
1771 let f = Facts {
1772 sha: &sha,
1773 legacy: "-",
1774 path: "/p.md",
1775 classes: &classes,
1776 bindings: &bs,
1777 baseline: None,
1778 };
1779 assert_eq!(
1780 judge(&f),
1781 Outcome::Short {
1782 missing: set(&["backend"]),
1783 stale: set(&[])
1784 }
1785 );
1786 }
1787
1788 #[test]
1789 fn a_delta_needs_backend_and_the_new_areas_only() {
1790 let base = Baseline {
1791 sha: "1".repeat(64),
1792 roles: set(&["backend", "lang:rust"]),
1793 classes: set(&["lang:rust"]),
1794 };
1795 let new = "2".repeat(64);
1796 let classes = set(&["lang:rust", "ui"]);
1797 let bs = vec![b("backend", &new)];
1798 let f = Facts {
1799 sha: &new,
1800 legacy: "-",
1801 path: "/p.md",
1802 classes: &classes,
1803 bindings: &bs,
1804 baseline: Some(&base),
1805 };
1806 assert_eq!(
1807 judge(&f),
1808 Outcome::Short {
1809 missing: set(&["react", "ui-design", "ux-research", "game-ux"]),
1810 stale: set(&[])
1811 }
1812 );
1813 let same = base.sha.clone();
1815 let old_classes = set(&["lang:rust"]);
1816 let f = Facts {
1817 sha: &same,
1818 classes: &old_classes,
1819 bindings: &[],
1820 ..f
1821 };
1822 assert!(matches!(judge(&f), Outcome::Pass(_)));
1823 }
1824
1825 #[test]
1826 fn a_review_bound_to_the_legacy_sha_still_counts() {
1827 let classes = set(&["lang:rust"]);
1828 let (new, old) = ("2".repeat(64), "1".repeat(64));
1829 let bs = vec![b("lang:rust", &old), b("backend", &old)];
1830 let f = Facts {
1831 sha: &new,
1832 legacy: &old,
1833 path: "/p.md",
1834 classes: &classes,
1835 bindings: &bs,
1836 baseline: None,
1837 };
1838 assert!(
1839 matches!(judge(&f), Outcome::Pass(_)),
1840 "backend at the legacy sha is current"
1841 );
1842 let f = Facts { legacy: "-", ..f };
1843 assert!(
1844 matches!(judge(&f), Outcome::Short { ref stale, .. } if stale.contains("backend")),
1845 "without the legacy sha it is an older body"
1846 );
1847 }
1848
1849 #[test]
1850 fn a_baseline_at_the_legacy_sha_is_current_for_judge_and_panel_alike() {
1851 let classes = set(&["lang:rust"]);
1852 let (new, old) = ("2".repeat(64), "1".repeat(64));
1853 let base = Baseline {
1854 sha: old.clone(),
1855 roles: set(&["backend", "lang:rust"]),
1856 classes: classes.clone(),
1857 };
1858 assert!(is_current(&base, &new, &old, &classes));
1859 assert!(!is_current(&base, &new, "-", &classes));
1860 assert!(!is_current(&base, &new, &old, &set(&["lang:rust", "ui"])));
1861 let f = Facts {
1862 sha: &new,
1863 legacy: &old,
1864 path: "/p.md",
1865 classes: &classes,
1866 bindings: &[],
1867 baseline: Some(&base),
1868 };
1869 assert!(matches!(judge(&f), Outcome::Pass(_)));
1870 }
1871
1872 #[test]
1873 fn blocks_are_read_from_a_reviewer_prompt() {
1874 let sha = "a".repeat(64);
1875 let p = format!("Review this.\n<<<PLAN path=/x/my plan.md sha={sha} lang=rust>>>\nthanks");
1876 assert_eq!(
1877 parse_blocks("plan-review-language", &p),
1878 vec![Binding {
1879 role: "lang:rust".into(),
1880 path: "/x/my plan.md".into(),
1881 sha: sha.clone()
1882 }]
1883 );
1884 assert!(parse_blocks("plan-review-language", &p.replace(" lang=rust", "")).is_empty());
1885 assert_eq!(parse_blocks("plan-review-backend", &p)[0].role, "backend");
1886 assert!(parse_blocks("plan-review-backend", "<<<PLAN path=/x sha=short>>>").is_empty());
1887 }
1888
1889 #[test]
1892 fn a_launch_that_handed_back_completes_with_its_report() {
1893 let launch: Vec<&str> = include_str!("../../../tests/fixtures/sendmessage-resume.jsonl")
1894 .lines()
1895 .take(3)
1896 .collect();
1897 let pointer = "This agent's report was delivered to you as a message from aa339dbce3244a139 (its SubagentHandback call).";
1898 let notice = launch[2].replace(
1899 "Verdict: approve-with-changes\\nFindings:\\n1. … (trimmed)",
1900 pointer,
1901 );
1902 assert_ne!(notice, launch[2]);
1903 let handback = include_str!("../../../tests/fixtures/handback-entry.jsonl").trim_end();
1904 let text = format!("{}\n{}\n{handback}\n{notice}\n", launch[0], launch[1]);
1905 let done = completed_agents(std::io::Cursor::new(text.clone()), "implementation-review");
1906 assert_eq!(done.len(), 1);
1907 assert!(
1908 done[0].result.contains("Verdict: approve"),
1909 "{}",
1910 done[0].result
1911 );
1912 let alone = format!("{}\n{}\n{notice}\n", launch[0], launch[1]);
1914 let done = completed_agents(std::io::Cursor::new(alone), "implementation-review");
1915 assert!(!done[0].result.contains("Verdict:"), "{}", done[0].result);
1916 }
1917
1918 #[test]
1919 fn a_notification_names_what_completed() {
1920 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>";
1921 assert_eq!(completed_ids(t), vec!["toolu_1"]);
1922 }
1923}