1use std::path::{Path, PathBuf};
40
41use amont_agent_guard::journal;
42use amont_agent_guard::push_target::{self, Kind, Push, Target};
43use amont_agent_guard::rules::Stance;
44
45const REGISTRATION_TTL: u64 = 24 * 3600;
48const APPROVAL_TTL: u64 = 24 * 3600;
50const SCRATCH_TTL: u64 = 24 * 3600;
53
54pub const RULE_ID: &str = "push-preview";
55
56pub const APPROVE: &str = "Approve";
58pub const REQUEST_CHANGES: &str = "Request changes";
59pub const HOLD: &str = "Hold";
60
61fn now() -> u64 {
62 now_ms() / 1000
63}
64
65fn now_ms() -> u64 {
66 std::time::SystemTime::now()
67 .duration_since(std::time::UNIX_EPOCH)
68 .map(|d| d.as_millis() as u64)
69 .unwrap_or(0)
70}
71
72fn latency(asked_ms: Option<u64>, answered_ms: u64, duration_ms: Option<u64>) -> String {
78 let secs = asked_ms
79 .filter(|&a| a > 0 && a <= answered_ms)
80 .map(|a| format!("{}s", (answered_ms - a) / 1000))
81 .unwrap_or_else(|| "-".to_string());
82 let dur = duration_ms
83 .map(|d| format!("{d}ms"))
84 .unwrap_or_else(|| "-".to_string());
85 format!("{secs},dur={dur}")
86}
87
88use amont_agent_guard::push_record::{dir, ensure, Before};
89
90pub fn gated(repo: &Path) -> bool {
98 match amont_agent_guard::git::stdout_in(
99 repo,
100 &["config", "--get", "amont.agent.push-preview.ui"],
101 )
102 .as_deref()
103 {
104 Some("true") => return true,
105 Some("false") => return false,
106 _ => {}
107 }
108 ["package.json", "web/package.json"]
109 .iter()
110 .any(|p| has_dev_script(&repo.join(p)))
111}
112
113fn has_dev_script(path: &Path) -> bool {
114 let Ok(text) = std::fs::read_to_string(path) else {
115 return false;
116 };
117 serde_json::from_str::<serde_json::Value>(&text)
118 .ok()
119 .and_then(|v| v.get("scripts")?.get("dev").cloned())
120 .is_some_and(|d| d.as_str().is_some_and(|s| !s.trim().is_empty()))
121}
122
123const UI_EXTENSIONS: [&str; 4] = [".tsx", ".jsx", ".css", ".html"];
125
126pub fn is_ui_path(path: &str) -> bool {
129 if is_test_path(path) {
130 return false;
131 }
132 let top = ["app/", "src/", "web/"];
133 top.iter()
134 .any(|t| path.starts_with(t) || path.contains(&format!("/{t}")))
135 || UI_EXTENSIONS.iter().any(|e| path.ends_with(e))
136}
137
138fn is_test_path(path: &str) -> bool {
145 let mut segments: Vec<&str> = path.split('/').collect();
146 let base = segments.pop().unwrap_or("");
147 if segments.contains(&"__tests__") || base.contains(".test.") || base.contains(".spec.") {
148 return true;
149 }
150 if UI_EXTENSIONS.iter().any(|e| base.ends_with(e)) {
151 return false;
152 }
153 segments
154 .iter()
155 .any(|s| matches!(*s, "e2e" | "tests" | "test"))
156 || ["vitest", "jest", "playwright", "cypress"]
157 .iter()
158 .any(|t| base.strip_prefix(t).is_some_and(|r| r.starts_with('.')))
159}
160
161struct Published {
164 base: Option<String>,
165 files: Vec<String>,
166}
167
168fn published(repo: &Path, t: &Target) -> Option<Published> {
171 let short = t.dst.strip_prefix("refs/heads/")?;
172 if amont_agent_guard::git::nothing_new(repo, &t.src) {
176 return Some(Published {
177 base: None,
178 files: Vec::new(),
179 });
180 }
181 let tracking = format!("refs/remotes/{}/{}", t.remote, short);
182 let base = if git(repo, &["rev-parse", "--verify", "--quiet", &tracking]).is_some() {
183 Some(tracking)
184 } else {
185 git(
186 repo,
187 &[
188 "merge-base",
189 &t.src,
190 &format!("refs/remotes/{}/HEAD", t.remote),
191 ],
192 )
193 };
194 let out = match &base {
195 Some(b) => amont_agent_guard::git::stdout_in(
196 repo,
197 &["diff", "--name-only", &format!("{b}..{}", t.src)],
198 )?,
199 None => amont_agent_guard::git::stdout_in(
200 repo,
201 &[
202 "log",
203 "--format=",
204 "--name-only",
205 &t.src,
206 "--not",
207 &format!("--remotes={}", t.remote),
208 ],
209 )?,
210 };
211 Some(Published {
212 base,
213 files: out
214 .lines()
215 .map(str::trim)
216 .filter(|l| !l.is_empty())
217 .map(str::to_string)
218 .collect(),
219 })
220}
221
222fn carries_ui(repo: &Path, t: &Target) -> Option<bool> {
225 let p = published(repo, t)?;
226 let mut packages = std::collections::HashMap::new();
227 Some(
228 p.files
229 .iter()
230 .any(|f| counts_as_ui(repo, p.base.as_deref(), t, f, &mut packages)),
231 )
232}
233
234fn counts_as_ui(
242 repo: &Path,
243 base: Option<&str>,
244 t: &Target,
245 file: &str,
246 packages: &mut std::collections::HashMap<String, bool>,
247) -> bool {
248 let commit = t.src.as_str();
249 if !is_ui_path(file) {
250 return false;
251 }
252 if !nearest_package_is_ui(repo, commit, file, packages) {
253 return false;
254 }
255 let commentable = [".ts", ".tsx", ".js", ".jsx", ".css"]
256 .iter()
257 .any(|e| file.ends_with(e));
258 if !commentable {
259 return true;
260 }
261 let range = base.map(|b| format!("{b}..{commit}"));
262 let not_remote = format!("--remotes={}", t.remote);
263 let args: Vec<&str> = match &range {
264 Some(r) => vec!["diff", "--no-ext-diff", "--no-color", "-U0", r, "--", file],
265 None => vec![
268 "log",
269 "-p",
270 "-U0",
271 "--format=",
272 "--no-ext-diff",
273 "--no-color",
274 "--first-parent",
275 "--diff-merges=first-parent",
276 commit,
277 "--not",
278 ¬_remote,
279 "--",
280 file,
281 ],
282 };
283 match amont_agent_guard::git::stdout_in(repo, &args) {
284 Some(diff) => !comment_only(&diff),
285 None => true,
286 }
287}
288
289fn nearest_package_is_ui(
292 repo: &Path,
293 commit: &str,
294 file: &str,
295 cache: &mut std::collections::HashMap<String, bool>,
296) -> bool {
297 let mut dir = Path::new(file).parent();
298 loop {
299 let d = dir
300 .map(|d| d.to_string_lossy().into_owned())
301 .unwrap_or_default();
302 if let Some(&known) = cache.get(&d) {
303 return known;
304 }
305 let manifest = if d.is_empty() {
306 "package.json".to_string()
307 } else {
308 format!("{d}/package.json")
309 };
310 if let Some(text) =
311 amont_agent_guard::git::stdout_in(repo, &["show", &format!("{commit}:{manifest}")])
312 {
313 let ui = crate::plan_text::package_is_ui(&text);
314 cache.insert(d, ui);
315 return ui;
316 }
317 if d.is_empty() {
318 cache.insert(d, true);
319 return true;
320 }
321 dir = dir.and_then(Path::parent);
322 }
323}
324
325pub fn comment_only(diff: &str) -> bool {
330 let mut hunks = false;
331 let mut header = true;
332 for line in diff.lines() {
333 if line.starts_with("diff ") {
334 header = true;
336 continue;
337 }
338 if line.starts_with("@@") {
339 hunks = true;
340 header = false;
341 continue;
342 }
343 if header {
344 continue;
345 }
346 let body = match line.as_bytes().first() {
347 Some(b'+') | Some(b'-') => &line[1..],
348 _ => continue,
349 };
350 if body.trim().is_empty() {
351 continue;
352 }
353 if !is_comment_line(body) {
354 return false;
355 }
356 }
357 hunks
358}
359
360pub fn is_comment_line(line: &str) -> bool {
365 let t = line.trim();
366 if t.starts_with("//") {
367 return true;
368 }
369 if let Some(rest) = t.strip_prefix("{/*") {
370 return match rest.find("*/") {
372 None => !rest.contains('}'),
373 Some(i) => rest[i..] == *"*/}",
374 };
375 }
376 if let Some(rest) = t.strip_prefix("/*") {
377 return match rest.find("*/") {
378 None => true,
379 Some(i) => rest[i..] == *"*/",
380 };
381 }
382 if let Some(rest) = t.strip_prefix("*/") {
383 return rest.is_empty() || rest == "}";
385 }
386 if let Some(rest) = t.strip_prefix('*') {
387 if !(rest.is_empty() || rest.starts_with([' ', '\t']) || rest.starts_with("*/")) {
389 return false;
390 }
391 let body = rest.trim();
392 if let Some(i) = body.find("*/") {
393 return &body[i..] == "*/" && !body.contains("/*");
394 }
395 if body.starts_with('@') {
396 return true;
398 }
399 return !body.ends_with(['{', '}', ';']) && !body.contains('{');
401 }
402 false
403}
404
405fn git(dir: &Path, args: &[&str]) -> Option<String> {
406 amont_agent_guard::git::stdout_in(dir, args).filter(|s| !s.is_empty())
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum Needs {
412 Unapproved { mockup: bool },
415 Unreadable(&'static str),
419}
420
421pub fn needs_preview(
425 ctx: &amont_agent_guard::rules::Context,
426 cmd: &amont_agent_guard::shell::Simple,
427 stance: Stance,
428) -> Result<Needs, &'static str> {
429 match push_target::resolve_for(ctx, cmd) {
430 Push::DryRun => Err("a dry run publishes nothing"),
431 Push::DeleteOnly => Err("a delete publishes nothing"),
432 Push::Refused(_) => Err("amont refuses this land, so it publishes nothing"),
433 Push::Unresolvable { repo, shape } => {
434 if repo.as_deref().is_some_and(gated) && stance == Stance::Deny {
435 Ok(Needs::Unreadable(shape))
438 } else {
439 Err(shape)
440 }
441 }
442 Push::Resolved { repo, targets } => {
443 if !gated(&repo) {
444 return Err("not a repository with a user interface");
445 }
446 let mut any_ui = false;
447 let mut evidenced = false;
448 for t in targets.iter().filter(|t| t.kind == Kind::Branch) {
449 let Some(stands) = standing(&repo, t) else {
450 return if stance == Stance::Deny {
451 Ok(Needs::Unreadable("the published files cannot be listed"))
452 } else {
453 Err("the published files cannot be listed")
454 };
455 };
456 match stands {
457 Standing::NoUi => {}
458 Standing::Evidence(detail) => {
459 any_ui = true;
460 note("evidence", "-", &repo.to_string_lossy(), &detail);
461 evidenced = true;
462 }
463 Standing::Approval { mockup } => {
464 any_ui = true;
465 if !approved(&repo, &t.src) {
466 return Ok(Needs::Unapproved { mockup });
467 }
468 }
469 }
470 }
471 if evidenced {
472 Err("the plan the person approved declares the preview's evidence")
473 } else if any_ui {
474 Err("every interface change is approved")
475 } else {
476 Err("no interface change")
477 }
478 }
479 }
480}
481
482enum Standing {
485 NoUi,
487 Evidence(String),
489 Approval { mockup: bool },
492}
493
494fn standing(repo: &Path, t: &Target) -> Option<Standing> {
498 if !carries_ui(repo, t)? {
499 return Some(Standing::NoUi);
500 }
501 let screens = mockup_screens(repo, &t.src);
502 if matches!(&screens, Ok(s) if s.is_empty()) {
503 if let Some(detail) = evidence_of(repo, &t.src) {
504 return Some(Standing::Evidence(detail));
505 }
506 }
507 Some(Standing::Approval {
508 mockup: screens.map_or(true, |s| !s.is_empty()),
509 })
510}
511
512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520pub enum Outcome {
521 Approved,
522 Evidence,
523 NotRequired,
524}
525
526impl Outcome {
527 pub(crate) fn word(self) -> &'static str {
528 match self {
529 Outcome::Approved => "approved",
530 Outcome::Evidence => "evidence",
531 Outcome::NotRequired => "not-required",
532 }
533 }
534}
535
536pub(crate) fn outcome(repo: &Path, t: &Target) -> Option<Outcome> {
537 if !gated(repo) {
538 return Some(Outcome::NotRequired);
539 }
540 match standing(repo, t)? {
541 Standing::NoUi => Some(Outcome::NotRequired),
542 Standing::Evidence(_) => Some(Outcome::Evidence),
543 Standing::Approval { .. } => approval_of(repo, &t.src).map(|_| Outcome::Approved),
544 }
545}
546
547fn mockups_dir(repo: &Path) -> String {
553 git(repo, &["config", "--get", "amont.agent.preview.mockups"])
554 .filter(|d| !d.trim().is_empty())
555 .map(|d| d.trim().trim_end_matches('/').to_string())
556 .unwrap_or_else(|| "docs/mockups".to_string())
557}
558
559pub fn mockup_screens(repo: &Path, commit: &str) -> Result<Vec<String>, String> {
569 let dir = mockups_dir(repo);
570 let has_artboards = |tree_path: &str| {
571 git(
572 repo,
573 &["ls-tree", "-r", "--name-only", commit, "--", tree_path],
574 )
575 .is_some_and(|l| l.lines().any(|f| f.ends_with(".dc.html")))
576 };
577 if !has_artboards(&dir) {
578 return Ok(Vec::new());
579 }
580 let base = amont_agent_guard::stale::remote_of(repo)
581 .and_then(|r| amont_agent_guard::stale::default_base(repo, &r))
582 .ok_or_else(|| {
583 format!(
584 "{dir} holds artboards, and the commits this preview adds cannot be told apart from the default branch's: no default remote branch (set `checkout.defaultRemote`, or fetch `origin`)"
585 )
586 })?;
587 let from = git(repo, &["merge-base", &base, commit]).ok_or_else(|| {
588 format!(
589 "{dir} holds artboards, and {base} shares no history with {}",
590 short(commit)
591 )
592 })?;
593 let changed = amont_agent_guard::git::stdout_in(
596 repo,
597 &["diff", "--name-only", &format!("{from}..{commit}")],
598 )
599 .ok_or_else(|| format!("`git diff {}..{}` failed", short(&from), short(commit)))?;
600 let prefix = format!("{dir}/");
601 let mut screens: Vec<String> = Vec::new();
602 for f in changed.lines() {
603 let Some(rest) = f.strip_prefix(&prefix) else {
604 continue;
605 };
606 let Some((name, _)) = rest.split_once('/') else {
607 continue;
608 };
609 let screen = format!("{dir}/{name}");
610 if !screens.contains(&screen) && has_artboards(&screen) {
611 screens.push(screen);
612 }
613 }
614 Ok(screens)
615}
616
617#[derive(Debug, Clone, PartialEq, Eq)]
620struct Registration {
621 id: String,
622 session: String,
623 prompt: String,
624 repo: String,
625 commit: String,
626 url: String,
627 guide: String,
629 at: u64,
630 asked: String,
632}
633
634impl Registration {
635 fn line(&self) -> String {
636 [
637 self.id.as_str(),
638 &self.session,
639 &self.prompt,
640 &self.repo,
641 &self.commit,
642 &self.url,
643 &self.guide,
644 &self.at.to_string(),
645 &self.asked,
646 ]
647 .map(clean)
648 .join("\t")
649 }
650 fn parse(line: &str) -> Option<Registration> {
651 let f: Vec<&str> = line.split('\t').collect();
652 if f.len() != 9 {
653 return None;
654 }
655 Some(Registration {
656 id: f[0].into(),
657 session: f[1].into(),
658 prompt: f[2].into(),
659 repo: f[3].into(),
660 commit: f[4].into(),
661 url: f[5].into(),
662 guide: f[6].into(),
663 at: f[7].parse().ok()?,
664 asked: f[8].into(),
665 })
666 }
667 fn label(&self) -> String {
668 let name = Path::new(&self.repo)
669 .file_name()
670 .map(|n| n.to_string_lossy().into_owned())
671 .unwrap_or_default();
672 format!("{name}@{}", short(&self.commit))
673 }
674}
675
676fn checkout_names(repo: &Path) -> Vec<String> {
680 let mut out: Vec<String> = Vec::new();
681 let mut push = |s: &str| {
682 let s = s.trim();
683 if !s.is_empty() && !out.iter().any(|o| o == s) {
684 out.push(s.to_string());
685 }
686 };
687 if let Some(n) = repo.file_name() {
688 push(&n.to_string_lossy());
689 }
690 if let Some(common) = git(
691 repo,
692 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
693 ) {
694 let common = PathBuf::from(common);
695 if common.file_name().is_some_and(|n| n == ".git") {
696 if let Some(n) = common.parent().and_then(Path::file_name) {
697 push(&n.to_string_lossy());
698 }
699 }
700 }
701 if let Some(url) = git(repo, &["remote", "get-url", "origin"]) {
702 let last = url
703 .trim_end_matches('/')
704 .rsplit(['/', ':'])
705 .next()
706 .unwrap_or("");
707 push(last.trim_end_matches(".git"));
708 }
709 out
710}
711
712fn clean(s: &str) -> String {
713 s.replace(['\t', '\n', '\r'], " ")
714}
715
716fn short(sha: &str) -> &str {
717 &sha[..sha.len().min(7)]
718}
719
720fn registrations() -> Vec<Registration> {
723 let Some(path) = dir().map(|d| d.join("registrations")) else {
724 return Vec::new();
725 };
726 let text = std::fs::read_to_string(&path).unwrap_or_default();
727 let all: Vec<Registration> = text.lines().filter_map(Registration::parse).collect();
728 let cutoff = now().saturating_sub(REGISTRATION_TTL);
729 let (live, lapsed): (Vec<_>, Vec<_>) = all.into_iter().partition(|r| r.at >= cutoff);
730 if !lapsed.is_empty() {
731 for r in &lapsed {
732 note(
733 "expired",
734 &r.session,
735 &r.repo,
736 &format!("registration {} {}", r.id, r.label()),
737 );
738 }
739 save_registrations(&live);
740 }
741 live
742}
743
744fn save_registrations(regs: &[Registration]) {
745 let Some(d) = dir() else { return };
746 if ensure(&d).is_none() {
747 return;
748 }
749 let mut body: String = regs.iter().map(|r| r.line() + "\n").collect();
750 if body.is_empty() {
751 body.push('\n');
752 }
753 let path = d.join("registrations");
754 let _ = amont_agent_guard::atomic::write_atomic(&path, &body);
755 journal::private(&path, 0o600);
756}
757
758type Approval = (String, String, u64, String, Option<String>);
762
763fn approvals() -> Vec<Approval> {
764 let Some(path) = dir().map(|d| d.join("approvals")) else {
765 return Vec::new();
766 };
767 let cutoff = now().saturating_sub(APPROVAL_TTL);
768 std::fs::read_to_string(&path)
769 .unwrap_or_default()
770 .lines()
771 .filter_map(|l| {
772 let f: Vec<&str> = l.split('\t').collect();
773 let at: u64 = f.get(2)?.parse().ok()?;
774 Some((
775 f.first()?.to_string(),
776 f.get(1)?.to_string(),
777 at,
778 f.get(3).unwrap_or(&"-").to_string(),
779 f.get(4)
780 .filter(|t| !t.is_empty() && **t != "-")
781 .map(|t| t.to_string()),
782 ))
783 })
784 .filter(|(_, _, at, _, _)| *at >= cutoff)
785 .collect()
786}
787
788pub fn approved(repo: &Path, commit: &str) -> bool {
793 match approval_of(repo, commit) {
794 None => false,
795 Some(ApprovedBy::Exact) => true,
796 Some(ApprovedBy::ByTree(from)) => {
797 note(
798 "approved-by-tree",
799 "-",
800 &repo.to_string_lossy(),
801 &format!("{}\u{2192}{}", short(&from), short(commit)),
802 );
803 true
804 }
805 }
806}
807
808enum ApprovedBy {
810 Exact,
812 ByTree(String),
814}
815
816fn approval_of(repo: &Path, commit: &str) -> Option<ApprovedBy> {
818 let repo_s = repo.to_string_lossy();
819 let mine: Vec<Approval> = approvals()
820 .into_iter()
821 .filter(|a| *a.0 == *repo_s)
822 .collect();
823 if mine.iter().any(|a| a.1 == commit) {
824 return Some(ApprovedBy::Exact);
825 }
826 if !mine.iter().any(|a| a.4.is_some()) {
827 return None;
828 }
829 let tree = crate::content_id::content_id(repo, commit).ok()?;
830 mine.iter()
831 .find(|a| a.4.as_deref() == Some(tree.as_str()))
832 .map(|a| ApprovedBy::ByTree(a.1.clone()))
833}
834
835fn approve(regs: &[Registration], channel: &str) {
836 let Some(d) = dir() else { return };
837 if ensure(&d).is_none() {
838 return;
839 }
840 let mut kept = approvals();
841 for r in regs {
842 kept.retain(|(repo, c, _, _, _)| !(repo == &r.repo && c == &r.commit));
843 let tree = crate::content_id::content_id(Path::new(&r.repo), &r.commit).ok();
844 kept.push((
845 r.repo.clone(),
846 r.commit.clone(),
847 now(),
848 channel.to_string(),
849 tree,
850 ));
851 note(
852 "approved",
853 &r.session,
854 &r.repo,
855 &format!("{} by {channel}", r.label()),
856 );
857 }
858 let body: String = kept
859 .iter()
860 .map(|(r, c, at, ch, tree)| {
861 let mut line = format!("{}\t{}\t{at}\t{}", clean(r), clean(c), clean(ch));
862 if let Some(t) = tree {
863 line.push('\t');
864 line.push_str(&clean(t));
865 }
866 line + "\n"
867 })
868 .collect();
869 let path = d.join("approvals");
870 let _ = amont_agent_guard::atomic::write_atomic(&path, &body);
871 journal::private(&path, 0o600);
872}
873
874fn note(outcome: &str, session: &str, repo: &str, excerpt: &str) {
876 let name = Path::new(repo)
877 .file_name()
878 .map(|n| n.to_string_lossy().into_owned())
879 .unwrap_or_else(|| "-".to_string());
880 journal::record(&journal::Entry {
881 rule: RULE_ID,
882 stance: "-",
883 outcome,
884 session,
885 repo: &name,
886 mode: "-",
887 excerpt,
888 });
889}
890
891#[derive(Debug, Clone, PartialEq, Eq)]
895pub struct Registered {
896 pub id: String,
897 pub repo: String,
898 pub commit: String,
899 pub url: String,
900 pub guide: String,
902 pub page: String,
905 pub page_url: String,
907 pub label: String,
909 pub aliases: Vec<String>,
912 pub question_prefix: String,
916}
917
918impl Registered {
919 pub fn to_json(&self) -> String {
922 amont_agent_guard::json::object(&[
923 amont_agent_guard::json::string_field("id", &self.id),
924 amont_agent_guard::json::string_field("repo", &self.repo),
925 amont_agent_guard::json::string_field("commit", &self.commit),
926 amont_agent_guard::json::string_field("url", &self.url),
927 amont_agent_guard::json::string_field("guide", &self.guide),
928 amont_agent_guard::json::string_field("page", &self.page),
929 amont_agent_guard::json::string_field("page_url", &self.page_url),
930 amont_agent_guard::json::string_field("label", &self.label),
931 amont_agent_guard::json::string_array_field("aliases", &self.aliases),
932 amont_agent_guard::json::string_field("question_prefix", &self.question_prefix),
933 amont_agent_guard::json::string_field("attestation", &self.guide),
934 ])
935 }
936 fn from_json(text: &str) -> Option<Registered> {
939 let v: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
940 let s = |k: &str| v.get(k)?.as_str().map(str::to_string);
941 Some(Registered {
942 id: s("id")?,
943 repo: s("repo")?,
944 commit: s("commit")?,
945 url: s("url")?,
946 guide: s("guide").or_else(|| s("attestation"))?,
947 page: s("page").unwrap_or_default(),
948 page_url: s("page_url").unwrap_or_default(),
949 label: s("label").unwrap_or_default(),
950 aliases: v
951 .get("aliases")
952 .and_then(|a| a.as_array())
953 .map(|a| {
954 a.iter()
955 .filter_map(|x| x.as_str().map(str::to_string))
956 .collect()
957 })
958 .unwrap_or_default(),
959 question_prefix: s("question_prefix").unwrap_or_default(),
960 })
961 }
962}
963
964pub fn register(repo_dir: &Path, url: &str, guide: &Path) -> Result<Registered, String> {
967 if !(url.starts_with("http://") || url.starts_with("https://")) {
968 return Err(format!("`{url}` is not an http(s) URL"));
969 }
970 let repo = push_target::toplevel(repo_dir)
971 .ok_or_else(|| format!("{} is not inside a git repository", repo_dir.display()))?;
972 let commit = git(
973 &repo,
974 &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"],
975 )
976 .ok_or_else(|| "HEAD is not a commit".to_string())?;
977 let status = amont_agent_guard::git::stdout_in(&repo, &["status", "--porcelain"])
978 .ok_or_else(|| "`git status` failed".to_string())?;
979 if !status.trim().is_empty() {
980 return Err(format!(
981 "the worktree is not clean, so the preview is not of commit {}:\n{status}",
982 short(&commit)
983 ));
984 }
985 let record = std::fs::canonicalize(guide)
986 .map_err(|_| format!("the guide {} does not exist", guide.display()))?;
987 let size = std::fs::metadata(&record).map(|m| m.len()).unwrap_or(0);
988 if size == 0 {
989 return Err(format!("the guide {} is empty", record.display()));
990 }
991 if size > crate::guide::MAX_BYTES {
992 return Err(format!(
993 "the guide {} is {size} bytes; a guide is a page, not a log",
994 record.display()
995 ));
996 }
997 let canon_repo = std::fs::canonicalize(&repo).unwrap_or_else(|_| repo.clone());
998 if record.starts_with(&canon_repo) {
999 return Err(format!(
1000 "the guide {} is inside the worktree; writing it there (and its page beside it) dirties the commit it describes. Keep it under {}",
1001 record.display(),
1002 dir()
1003 .and_then(|d| d.parent().map(|p| p.join("attestations")))
1004 .map(|p| p.display().to_string())
1005 .unwrap_or_else(|| "the amont-agent state directory".to_string())
1006 ));
1007 }
1008 let text = std::fs::read_to_string(&record)
1009 .map_err(|_| format!("the guide {} is not UTF-8 text", record.display()))?;
1010 let folder = record.parent().unwrap_or(Path::new("/")).to_path_buf();
1011 let screens = mockup_screens(&repo, &commit)?;
1012 let mut missing = crate::guide::check(&text);
1013 if !screens.is_empty() {
1014 missing.extend(crate::guide::check_mockup(&text, &screens, &folder));
1015 }
1016 if !missing.is_empty() {
1017 let mut sections: Vec<String> = crate::guide::SECTIONS
1018 .iter()
1019 .map(|s| format!("`## {s}`"))
1020 .collect();
1021 let mut mode = String::new();
1022 if !screens.is_empty() {
1023 sections.push(format!("`## {}`", crate::guide::DIFFERENCES));
1024 let screen = &screens[0];
1025 mode = format!(
1026 "mockup mode: this branch commits the picked artboards of {} (handoff.prove-fidelity), so the guide shows the artboard beside the built screen, at one viewport, and says how they differ.\n",
1027 screens.iter().map(|s| format!("`{s}`")).collect::<Vec<_>>().join(", ")
1028 );
1029 let paste = format!(
1030 "\nPaste and fill, with artboard.png and after.png copied beside the guide first (its own step):\n\n## Reference\nArtboards: {screen}\nViewport: 1120px · light\n\n\n\n\n## {}\n- fixed: <a difference found and removed>\n- deliberate: <a difference kept, and why>\n",
1031 crate::guide::DIFFERENCES
1032 );
1033 missing.push(paste.trim_end().to_string());
1034 }
1035 let (paste, items): (Vec<&String>, Vec<&String>) =
1036 missing.iter().partition(|m| m.starts_with("\nPaste"));
1037 return Err(format!(
1038 "the guide {} lacks what the person needs to decide with (work.preview-is-guided):\n{mode}{}\nRequired H2 sections, in order: {}.{}",
1039 record.display(),
1040 items
1041 .iter()
1042 .map(|m| format!(" - {m}"))
1043 .collect::<Vec<_>>()
1044 .join("\n"),
1045 sections.join(", "),
1046 paste.first().map(|s| s.as_str()).unwrap_or("")
1047 ));
1048 }
1049 if !screens.is_empty() {
1050 for w in crate::guide::width_warnings(&text, &folder) {
1051 eprintln!("amont-agent: warning: {w}");
1052 }
1053 }
1054 let names = checkout_names(&repo);
1055 let label = format!(
1056 "{}@{}",
1057 names.first().map(String::as_str).unwrap_or_default(),
1058 short(&commit)
1059 );
1060 let aliases: Vec<String> = names
1061 .iter()
1062 .skip(1)
1063 .map(|n| format!("{n}@{}", short(&commit)))
1064 .collect();
1065 let html = crate::guide::render(
1066 &text,
1067 &crate::guide::Page {
1068 label: &label,
1069 commit: &commit,
1070 url,
1071 },
1072 );
1073 for img in crate::guide::missing_images(&text, &folder) {
1074 eprintln!(
1075 "amont-agent: warning: the guide references {img}, which is not beside it in {}",
1076 folder.display()
1077 );
1078 }
1079 let page = folder.join("index.html");
1080 amont_agent_guard::atomic::write_atomic(&page, &html)
1081 .map_err(|e| format!("the page {} could not be written: {e}", page.display()))?;
1082 let id = format!("{}{:x}", short(&commit), now() & 0xfff_ffff);
1083 Ok(Registered {
1084 question_prefix: question_prefix(&id, &label),
1085 id,
1086 repo: repo.to_string_lossy().into_owned(),
1087 commit,
1088 url: url.to_string(),
1089 guide: record.to_string_lossy().into_owned(),
1090 page_url: crate::guide::file_url(&page),
1091 page: page.to_string_lossy().into_owned(),
1092 label,
1093 aliases,
1094 })
1095}
1096
1097pub fn question_prefix(id: &str, label: &str) -> String {
1100 format!("[preview {id}] {label}")
1101}
1102
1103pub fn stamp_register(
1108 bash: &amont_agent_guard::payload::Bash,
1109 parsed: &amont_agent_guard::shell::Parsed,
1110) {
1111 if bash.tool_use_id.is_empty() || bash.tool_use_id.contains('/') {
1112 return;
1113 }
1114 if !parsed.clauses().iter().any(is_register) {
1115 return;
1116 }
1117 if let Some(d) = dir().map(|d| d.join("registers")) {
1118 if ensure(&d).is_some() {
1119 let _ = amont_agent_guard::atomic::write_atomic(
1120 &d.join(&bash.tool_use_id),
1121 &format!("{}\t{}\n", clean(&bash.session), now_ms()),
1122 );
1123 }
1124 }
1125}
1126
1127fn take_stamp(session: &str, tool_use_id: &str) -> Option<u64> {
1130 if tool_use_id.is_empty() || tool_use_id.contains('/') {
1131 return None;
1132 }
1133 let path = dir()?.join("registers").join(tool_use_id);
1134 let text = std::fs::read_to_string(&path).ok();
1135 let _ = std::fs::remove_file(&path);
1136 let text = text?;
1137 let (who, at) = text.trim_end().split_once('\t')?;
1138 (who == clean(session)).then(|| at.parse().ok()).flatten()
1139}
1140
1141fn printed_json(stdout: &str) -> Option<Registered> {
1145 let line = stdout.lines().rev().find(|l| !l.trim().is_empty())?;
1146 Registered::from_json(line)
1147}
1148
1149fn printed_by_this_call(printed: &Registered, started_ms: u64) -> Result<(), &'static str> {
1153 if !printed.id.starts_with(short(&printed.commit)) {
1154 return Err("its id does not start with the commit it names");
1155 }
1156 if printed.page.is_empty() {
1157 return Err("its output names no page");
1158 }
1159 let page = Path::new(&printed.page);
1160 let Ok(html) = std::fs::read_to_string(page) else {
1161 return Err("the page it names does not exist");
1162 };
1163 if !html.contains(&printed.commit) {
1164 return Err("the page it names is not of that commit");
1165 }
1166 let written_ms = std::fs::metadata(page)
1167 .and_then(|m| m.modified())
1168 .ok()
1169 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1170 .map(|d| d.as_millis() as u64);
1171 let before = |w: u64| match w % 1000 {
1174 0 => w / 1000 < started_ms / 1000,
1175 _ => w < started_ms,
1176 };
1177 if written_ms.is_none_or(before) {
1178 return Err("the page it names was not written by this call");
1179 }
1180 Ok(())
1181}
1182
1183pub fn bind(
1190 bash: &amont_agent_guard::payload::Bash,
1191 parsed: &amont_agent_guard::shell::Parsed,
1192) -> Option<String> {
1193 let clauses = parsed.clauses();
1194 let cmd = clauses.iter().find(|c| is_register(c))?;
1195 let ctx = amont_agent_guard::rules::Context {
1197 platform: Some(amont_agent_guard::rules::Platform::host()),
1198 cwd: &bash.cwd,
1199 parsed,
1200 background: bash.background,
1201 timeout_ms: bash.timeout_ms,
1202 tool_use_id: &bash.tool_use_id,
1203 transcript: bash.transcript.as_deref(),
1204 agent_transcript: bash.agent_transcript.as_deref(),
1205 command: &bash.command,
1206 output: None,
1207 session: &bash.session,
1208 failed: false,
1209 };
1210 let here = ctx.cwd_at(cmd.at);
1211 let dir = match cmd.flag_value("--repo") {
1212 Some(d) if d.starts_with('/') => PathBuf::from(d),
1213 Some(d) => here.join(d),
1214 None => here.clone(),
1215 };
1216 let named = push_target::toplevel(&dir);
1217 let shown = named.clone().unwrap_or_else(|| dir.clone());
1219 let excerpt = "preview register";
1220 let printed = bash.stdout.as_deref().and_then(printed_json);
1221 let started = take_stamp(&bash.session, &bash.tool_use_id);
1223 let again = format!(
1225 "cd {} && {}",
1226 shell_word(&here.to_string_lossy()),
1227 cmd.words
1228 .iter()
1229 .map(|w| shell_word(&w.text))
1230 .collect::<Vec<_>>()
1231 .join(" ")
1232 );
1233 let speaks = amont_agent_guard::stance::resolve(&crate::push_preview::RULE) != Stance::Observe;
1234 let refuse = |why: &str| -> Option<String> {
1235 note(
1236 "unbound",
1237 &bash.session,
1238 &shown.to_string_lossy(),
1239 &format!("{excerpt}: {why}"),
1240 );
1241 speaks.then(|| {
1242 let ask = printed
1243 .as_ref()
1244 .map(|p| {
1245 if p.question_prefix.is_empty() {
1246 question_prefix(&p.id, &p.label)
1247 } else {
1248 p.question_prefix.clone()
1249 }
1250 })
1251 .map(|q| format!("a question starting `{}`", q.trim_end()))
1252 .unwrap_or_else(|| "the `question_prefix` from its output".to_string());
1253 format!(
1254 "`preview register` ran, but the preview is NOT bound to this session ({why}), so no answer to a marked question can approve it. Run it again in the foreground as the LAST command of the line: commands joined by `&&` or `;` may come before it, nothing after it, no pipe, no redirect and no `$(…)` around it:\n {again}\nthen ask the marked question: {ask}."
1255 )
1256 })
1257 };
1258 if bash.background {
1259 return refuse("it ran in the background");
1260 }
1261 if !parsed.fully_read() || !register_is_last(clauses, cmd.at) {
1262 return refuse("it was not the last command of the line");
1263 }
1264 let Some(started) = started else {
1265 return refuse("no record of the call starting: the hook did not see it before it ran");
1266 };
1267 let Some(printed) = printed.clone() else {
1268 return refuse("its output's last line is not the expected JSON");
1269 };
1270 let Some(repo) = named else {
1271 return refuse("the repository it names is gone");
1272 };
1273 let head = git(
1274 &repo,
1275 &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"],
1276 );
1277 if printed.repo != repo.to_string_lossy() || head.as_deref() != Some(printed.commit.as_str()) {
1278 return refuse("its output does not match the repository's HEAD");
1279 }
1280 if let Err(why) = printed_by_this_call(&printed, started) {
1281 return refuse(why);
1282 }
1283 let mut regs = registrations();
1284 let before = regs.len();
1285 regs.retain(|r| !(r.session == bash.session && r.repo == printed.repo));
1286 let outcome = if regs.len() < before {
1287 "replaced"
1288 } else {
1289 "registered"
1290 };
1291 let reg = Registration {
1292 id: printed.id.clone(),
1293 session: bash.session.clone(),
1294 prompt: bash.prompt_id.clone(),
1295 repo: printed.repo.clone(),
1296 commit: printed.commit.clone(),
1297 url: printed.url.clone(),
1298 guide: printed.guide.clone(),
1299 at: now(),
1300 asked: "-".to_string(),
1301 };
1302 note(
1303 outcome,
1304 &bash.session,
1305 ®.repo,
1306 &format!(
1307 "{} {} {} page={}",
1308 reg.id,
1309 reg.label(),
1310 reg.url,
1311 if printed.page.is_empty() {
1312 "-"
1313 } else {
1314 &printed.page
1315 }
1316 ),
1317 );
1318 regs.push(reg);
1319 save_registrations(®s);
1320 None
1321}
1322
1323fn shell_word(w: &str) -> String {
1326 if !w.is_empty()
1327 && w.chars()
1328 .all(|c| c.is_ascii_alphanumeric() || "-_./:=@%+,".contains(c))
1329 {
1330 w.to_string()
1331 } else {
1332 format!("'{}'", w.replace('\'', "'\\''"))
1333 }
1334}
1335
1336fn register_is_last(clauses: &[amont_agent_guard::shell::Simple], at: usize) -> bool {
1342 use amont_agent_guard::shell::Connector;
1343 let line: Vec<&amont_agent_guard::shell::Simple> =
1345 clauses.iter().filter(|c| c.nested.is_none()).collect();
1346 let Some(last) = line.last() else {
1347 return false;
1348 };
1349 if last.at != at || last.next.is_some() || !last.redirects.is_empty() || last.heredoc {
1350 return false;
1351 }
1352 let joined = |c: Option<Connector>| matches!(c, Some(Connector::AndAnd | Connector::Semi));
1353 (last.prev.is_none() || joined(last.prev))
1354 && line[..line.len() - 1].iter().all(|c| joined(c.next))
1355}
1356
1357fn is_register(cmd: &amont_agent_guard::shell::Simple) -> bool {
1358 let is_us = cmd
1359 .program()
1360 .is_some_and(|p| p == "amont-agent" || p.ends_with("/amont-agent"));
1361 let ops: Vec<&str> = cmd.operands().iter().map(|w| w.text.as_str()).collect();
1362 let help = cmd
1366 .words
1367 .iter()
1368 .any(|w| w.text == "--help" || w.text == "-h");
1369 is_us && !help && ops.first() == Some(&"preview") && ops.get(1) == Some(&"register")
1370}
1371
1372pub fn marker(question: &str) -> Option<Vec<String>> {
1376 let start = question.find("[preview ")? + "[preview ".len();
1377 let end = start + question[start..].find(']')?;
1378 let ids: Vec<String> = question[start..end]
1379 .split(',')
1380 .map(|s| s.trim().to_string())
1381 .filter(|s| !s.is_empty())
1382 .collect();
1383 (!ids.is_empty()).then_some(ids)
1384}
1385
1386pub fn on_pre_ask(ask: &amont_agent_guard::payload::Ask, stance: Stance) -> Option<String> {
1389 let marked: Vec<Vec<String>> = ask.questions.iter().filter_map(|q| marker(q)).collect();
1390 if marked.is_empty() {
1391 return None;
1392 }
1393 let ids: Vec<String> = marked.concat();
1394 if let Some(d) = dir().map(|d| d.join("asks")) {
1395 if ensure(&d).is_some() && !ask.tool_use_id.is_empty() && !ask.tool_use_id.contains('/') {
1396 let _ = amont_agent_guard::atomic::write_atomic(
1397 &d.join(&ask.tool_use_id),
1398 &format!(
1399 "{}\t{}\t{}\t{}\t{}\n",
1400 clean(&ask.session),
1401 clean(&ask.prompt_id),
1402 ask.prefilled,
1403 ids.join(","),
1404 now_ms()
1405 ),
1406 );
1407 }
1408 }
1409 if ask.prefilled {
1410 note(
1411 "prefilled",
1412 &ask.session,
1413 &repo_of(&ask.session, &ids),
1414 &format!("marked question {}", ids.join(",")),
1415 );
1416 if stance == Stance::Deny {
1417 return Some(
1418 "A preview question may not arrive with `answers` already filled in: only the person's own selection approves a preview. Ask it again without `answers`."
1419 .to_string(),
1420 );
1421 }
1422 return None;
1423 }
1424 let mut regs = registrations();
1426 let mut changed = false;
1427 for r in regs.iter_mut() {
1428 if r.session == ask.session && r.prompt == ask.prompt_id && ids.contains(&r.id) {
1429 r.asked = ask.tool_use_id.clone();
1430 changed = true;
1431 }
1432 }
1433 if changed {
1434 save_registrations(®s);
1435 }
1436 None
1437}
1438
1439pub fn on_post_ask(ask: &amont_agent_guard::payload::Ask) {
1443 let recorded = dir()
1444 .map(|d| d.join("asks").join(&ask.tool_use_id))
1445 .filter(|_| !ask.tool_use_id.is_empty() && !ask.tool_use_id.contains('/'))
1446 .and_then(|p| {
1447 let t = std::fs::read_to_string(&p).ok();
1448 let _ = std::fs::remove_file(&p);
1449 t
1450 });
1451 let Some(recorded) = recorded else {
1452 if ask.questions.iter().any(|q| marker(q).is_some()) {
1455 let ids: Vec<String> = ask
1456 .questions
1457 .iter()
1458 .filter_map(|q| marker(q))
1459 .flatten()
1460 .collect();
1461 note(
1462 "unrecorded",
1463 &ask.session,
1464 &repo_of(&ask.session, &ids),
1465 "marked question with no PreToolUse record",
1466 );
1467 }
1468 return;
1469 };
1470 let answered_ms = now_ms();
1471 let f: Vec<&str> = recorded.trim_end().split('\t').collect();
1472 if !(f.len() == 4 || f.len() == 5) || f[0] != ask.session || f[2] != "false" {
1474 return;
1475 }
1476 let asked_ms = f.get(4).and_then(|t| t.parse::<u64>().ok());
1477 for question in &ask.questions {
1478 let Some(ids) = marker(question) else {
1479 continue;
1480 };
1481 let label = ask
1482 .answers
1483 .iter()
1484 .find(|(q, _)| q == question)
1485 .map(|(_, l)| l.as_str())
1486 .unwrap_or("");
1487 let latency = latency(asked_ms, answered_ms, ask.duration_ms);
1488 let mut regs = registrations();
1489 let (mine, rest): (Vec<Registration>, Vec<Registration>) = regs.drain(..).partition(|r| {
1490 r.session == ask.session && r.asked == ask.tool_use_id && ids.contains(&r.id)
1491 });
1492 if mine.is_empty() {
1493 continue;
1494 }
1495 match label {
1496 APPROVE => {
1497 approve(&mine, &format!("option,{latency}"));
1501 save_registrations(&rest);
1502 }
1503 REQUEST_CHANGES | HOLD => {
1504 for r in &mine {
1505 note(
1506 "dropped",
1507 &r.session,
1508 &r.repo,
1509 &format!("{} {label} after {latency}", r.label()),
1510 );
1511 }
1512 save_registrations(&rest);
1513 }
1514 _ => {
1515 for r in &mine {
1518 note(
1519 "unanswered",
1520 &r.session,
1521 &r.repo,
1522 &format!("{} after {latency}", r.label()),
1523 );
1524 }
1525 let mut back = rest;
1526 back.extend(mine);
1527 save_registrations(&back);
1528 }
1529 }
1530 }
1531}
1532
1533fn repo_of(session: &str, ids: &[String]) -> String {
1536 let mut repos: Vec<String> = registrations()
1537 .into_iter()
1538 .filter(|r| r.session == session && ids.contains(&r.id))
1539 .map(|r| r.repo)
1540 .collect();
1541 repos.sort();
1542 repos.dedup();
1543 match repos.as_slice() {
1544 [one] => one.clone(),
1545 _ => "-".to_string(),
1546 }
1547}
1548
1549pub fn is_typed_approval(text: &str) -> bool {
1551 let t = text
1552 .trim()
1553 .trim_end_matches(['.', '!', ' '])
1554 .to_ascii_lowercase();
1555 let t = t.strip_suffix(" it").unwrap_or(&t);
1556 matches!(t, "approve" | "approved" | "ship" | "lgtm" | "looks good")
1557}
1558
1559pub fn on_prompt(prompt: &amont_agent_guard::payload::Prompt) {
1562 let regs = registrations();
1563 let (older, rest): (Vec<Registration>, Vec<Registration>) = regs
1564 .into_iter()
1565 .partition(|r| r.session == prompt.session && r.prompt != prompt.prompt_id);
1566 if older.is_empty() {
1567 return;
1568 }
1569 let typed = is_typed_approval(&prompt.text);
1570 let (asked, never): (Vec<_>, Vec<_>) = older.into_iter().partition(|r| r.asked != "-");
1571 for r in &never {
1572 note(
1573 "dropped",
1574 &r.session,
1575 &r.repo,
1576 &format!("{} was never asked about", r.label()),
1577 );
1578 }
1579 if typed {
1580 approve(&asked, "typed");
1581 } else {
1582 for r in &asked {
1583 note(
1584 "dropped",
1585 &r.session,
1586 &r.repo,
1587 &format!("{} the next prompt was not an approval", r.label()),
1588 );
1589 }
1590 }
1591 save_registrations(&rest);
1592}
1593
1594fn plans_approved_dir() -> Option<PathBuf> {
1598 Some(journal::dir()?.join("plan-approved"))
1599}
1600
1601pub fn on_plan_answered(ev: &amont_agent_guard::payload::PlanAnswered) {
1608 if !ev.approved {
1609 return;
1610 }
1611 let Some(file) = &ev.plan_file else { return };
1612 let Ok(text) = std::fs::read_to_string(file) else {
1613 return;
1614 };
1615 let Ok(shas) = crate::plan_text::shas(&text) else {
1616 return;
1617 };
1618 let Some(d) = plans_approved_dir() else {
1619 return;
1620 };
1621 if ensure(&d).is_none() {
1622 return;
1623 }
1624 let path = d.join(&shas.sha);
1625 let _ = amont_agent_guard::atomic::write_atomic(
1626 &path,
1627 &format!(
1628 "{}\t{}\t{}\n",
1629 clean(&ev.session),
1630 clean(&file.to_string_lossy()),
1631 now()
1632 ),
1633 );
1634 journal::private(&path, 0o600);
1635 note(
1636 "plan-approved",
1637 &ev.session,
1638 "-",
1639 &format!("{} {}", &shas.sha[..12], file.display()),
1640 );
1641}
1642
1643fn plan_approved(sha: &str) -> bool {
1645 sha.len() == 64
1646 && sha.bytes().all(|b| b.is_ascii_hexdigit())
1647 && plans_approved_dir().is_some_and(|d| d.join(sha).is_file())
1648}
1649
1650pub fn preview_evidence(text: &str) -> Option<String> {
1656 let body = crate::plan_text::canonical(text);
1657 let mut fence = crate::plan_text::Fence::default();
1658 let mut lines = body.lines();
1659 lines
1660 .by_ref()
1661 .find(|l| fence.h2(l).is_some_and(|t| t == "Preview"))?;
1662 let first = lines.find(|l| !l.trim().is_empty())?;
1663 let reason = first.trim().strip_prefix("evidence:")?.trim();
1664 (!reason.is_empty() && !reason.starts_with('<')).then(|| reason.to_string())
1665}
1666
1667fn first_plan(repo: &Path, from: &str, commit: &str) -> Option<(String, String)> {
1670 let log = git(
1671 repo,
1672 &[
1673 "log",
1674 "--reverse",
1675 "--diff-filter=A",
1676 "--format=%H",
1677 "--name-only",
1678 &format!("{from}..{commit}"),
1679 "--",
1680 "docs/plans/",
1681 ],
1682 )?;
1683 let mut at: Option<&str> = None;
1684 for line in log.lines().map(str::trim).filter(|l| !l.is_empty()) {
1685 if let Some(name) = line.strip_prefix("docs/plans/") {
1686 if let Some(c) = at.filter(|_| crate::plan_phases::is_plan_name(name)) {
1687 return Some((c.to_string(), line.to_string()));
1688 }
1689 } else if line.len() >= 40 && line.bytes().all(|b| b.is_ascii_hexdigit()) {
1690 at = Some(line);
1691 }
1692 }
1693 None
1694}
1695
1696fn evidence_of(repo: &Path, commit: &str) -> Option<String> {
1709 let base = amont_agent_guard::stale::remote_of(repo)
1710 .and_then(|r| amont_agent_guard::stale::default_base(repo, &r))?;
1711 let from = git(repo, &["merge-base", &base, commit])?;
1712 let (added, path) = first_plan(repo, &from, commit)?;
1713 let text = amont_agent_guard::git::stdout_in(repo, &["show", &format!("{added}:{path}")])?;
1714 let (front, _) = crate::plans::split_front_matter(&text);
1715 if crate::plans::field(front, "canonical").is_some_and(|c| !c.is_empty()) {
1716 return None;
1717 }
1718 let Ok(shas) = crate::plan_text::shas(&text) else {
1719 return None;
1720 };
1721 if !plan_approved(&shas.sha) {
1722 return None;
1723 }
1724 let reason = preview_evidence(&text)?;
1725 Some(format!(
1726 "{path} commit={} sha={} evidence: {reason}",
1727 short(commit),
1728 &shas.sha[..12]
1729 ))
1730}
1731
1732pub fn record_before(
1739 bash: &amont_agent_guard::payload::Bash,
1740 parsed: &amont_agent_guard::shell::Parsed,
1741) {
1742 if bash.tool_use_id.is_empty() || bash.tool_use_id.contains('/') {
1743 return;
1744 }
1745 let Some(cmd) = push_target::find(parsed) else {
1746 return;
1747 };
1748 let ctx = amont_agent_guard::rules::Context {
1749 platform: Some(amont_agent_guard::rules::Platform::host()),
1750 cwd: &bash.cwd,
1751 parsed,
1752 background: bash.background,
1753 timeout_ms: bash.timeout_ms,
1754 tool_use_id: &bash.tool_use_id,
1755 transcript: bash.transcript.as_deref(),
1756 agent_transcript: bash.agent_transcript.as_deref(),
1757 command: &bash.command,
1758 output: None,
1759 session: &bash.session,
1760 failed: false,
1761 };
1762 let Push::Resolved { repo, targets } = push_target::resolve_for(&ctx, cmd) else {
1763 return;
1764 };
1765 if amont_agent_guard::land::is_land(cmd) {
1770 if let (Some(t), amont_agent_guard::land::Answer::Would(l)) = (
1771 targets.first(),
1772 amont_agent_guard::land::dry_run(&ctx.cwd_at(cmd.at)),
1773 ) {
1774 amont_agent_guard::push_record::record_land(&bash.tool_use_id, &t.src, &l);
1775 }
1776 }
1777 if !gated(&repo) {
1778 return;
1779 }
1780 let rows: Vec<Before> = targets
1781 .iter()
1782 .filter(|t| t.kind == Kind::Branch)
1783 .map(|t| {
1784 let short = t.dst.trim_start_matches("refs/heads/");
1785 Before {
1786 repo: repo.clone(),
1787 remote: t.remote.clone(),
1788 dst: t.dst.clone(),
1789 before: git(
1790 &repo,
1791 &[
1792 "rev-parse",
1793 "--verify",
1794 "--quiet",
1795 &format!("refs/remotes/{}/{short}", t.remote),
1796 ],
1797 ),
1798 src: t.src.clone(),
1799 ui: carries_ui(&repo, t).unwrap_or(false),
1800 approved: approved(&repo, &t.src),
1801 }
1802 })
1803 .collect();
1804 amont_agent_guard::push_record::write_before(&bash.tool_use_id, &rows);
1805}
1806
1807pub fn sweep() {
1809 let Some(d) = dir() else { return };
1810 for sub in ["pushes", "asks", "registers"] {
1811 let Ok(entries) = std::fs::read_dir(d.join(sub)) else {
1812 continue;
1813 };
1814 for e in entries.flatten() {
1815 let old = e
1816 .metadata()
1817 .and_then(|m| m.modified())
1818 .ok()
1819 .and_then(|t| t.elapsed().ok())
1820 .is_some_and(|age| age.as_secs() > SCRATCH_TTL);
1821 if old {
1822 let _ = std::fs::remove_file(e.path());
1823 }
1824 }
1825 }
1826 let _ = registrations();
1828}
1829
1830#[cfg(test)]
1831mod tests {
1832 use super::*;
1833 use crate::plan_text::package_is_ui;
1834
1835 #[test]
1836 fn a_preview_section_declares_evidence_on_its_first_line() {
1837 let plan =
1838 |section: &str| format!("# P\n\n## Context\n\nx\n\n{section}\n## Phases\n\n1. y\n");
1839 assert_eq!(
1840 preview_evidence(&plan("## Preview\n\nevidence: decided at 28px\n")).as_deref(),
1841 Some("decided at 28px")
1842 );
1843 for held in [
1844 "## Preview\n\nevidence:\n",
1846 "## Preview\n\nevidence: <why the person will have nothing left to judge: the visible change\nis one this plan decides>\n",
1848 "## Preview\n\nThe screenshots.\nevidence: decided\n",
1850 "```md\n## Preview\n\nevidence: an example\n```\n",
1852 "## Previews\n\nevidence: decided\n",
1854 "",
1855 ] {
1856 assert_eq!(preview_evidence(&plan(held)), None, "{held}");
1857 }
1858 assert_eq!(preview_evidence("---\nstatus: active\n---\n# P\n"), None);
1860 }
1861
1862 #[test]
1863 fn a_marker_lists_its_ids() {
1864 assert_eq!(
1865 marker("[preview a1b2, c3] Ship duro-app@abc1234?"),
1866 Some(vec!["a1b2".to_string(), "c3".to_string()])
1867 );
1868 assert_eq!(marker("Ship it?"), None);
1869 assert_eq!(marker("[preview ] empty"), None);
1870 assert_eq!(marker("[preview unterminated"), None);
1871 }
1872
1873 #[test]
1874 fn only_explicit_words_are_a_typed_approval() {
1875 for yes in [
1876 "approve",
1877 "Approve.",
1878 "ship it",
1879 "Ship it!",
1880 "lgtm",
1881 "looks good",
1882 " LGTM ",
1883 ] {
1884 assert!(is_typed_approval(yes), "{yes}");
1885 }
1886 for no in [
1887 "yes",
1888 "go",
1889 "ok",
1890 "continue",
1891 "approve but fix the header",
1892 "ship it later",
1893 "",
1894 ] {
1895 assert!(!is_typed_approval(no), "{no}");
1896 }
1897 }
1898
1899 #[test]
1900 fn ui_paths_are_recognised_and_docs_are_not() {
1901 for ui in [
1902 "app/routes/home.tsx",
1903 "src/main.ts",
1904 "web/src/x.ts",
1905 "packages/ui/src/a.ts",
1906 "styles/site.css",
1907 "public/index.html",
1908 "apps/web/vite.config.ts",
1909 "app/routes/test/index.tsx",
1911 "app/tests/page.tsx",
1912 "src/e2e/Editor.tsx",
1913 "src/vitest.tsx",
1914 "src/testimonials/a.tsx",
1916 "src/latest.ts",
1917 "src/features/e2e-banner.tsx",
1918 ] {
1919 assert!(is_ui_path(ui), "{ui}");
1920 }
1921 for not in [
1922 "docs/plans/x.md",
1923 ".github/workflows/ci.yaml",
1924 "README.md",
1925 "Cargo.toml",
1926 "package.json",
1927 "apps/web/vitest.core.config.ts",
1928 "app/routes/home.test.tsx",
1929 "src/a.spec.ts",
1930 "web/e2e/login.ts",
1931 "src/__tests__/x.tsx",
1932 ] {
1933 assert!(!is_ui_path(not), "{not}");
1934 }
1935 }
1936
1937 const PR_301: &str = "\
1940diff --git a/app/.server/observedPipeline.integration.test.ts b/app/.server/observedPipeline.integration.test.ts
1941index 15750da..2770014 100644
1942--- a/app/.server/observedPipeline.integration.test.ts
1943+++ b/app/.server/observedPipeline.integration.test.ts
1944@@ -8 +8 @@
1945- * docs/plan-e4-e5-cluster-connector.md; it needs an authed Pro-org Playwright
1946+ * docs/plans/2026-07-07-e4-e5-cluster-connector.md; it needs an authed Pro-org Playwright
1947";
1948
1949 #[test]
1950 fn comment_only_diffs_from_real_pushes_are_recognised() {
1951 assert!(comment_only(PR_301));
1952 assert!(comment_only(&format!("{PR_301}{PR_301}")));
1955 for diff in [
1956 "--- a/app/components/board/BoardToolbar.tsx\n+++ b/app/components/board/BoardToolbar.tsx\n\
1957 @@ -88 +88 @@ export interface BoardToolbarProps {\n\
1958 - /** Photo mode (plan-90d A): when set, a camera button joins the controls\n\
1959 + /** Photo mode (90-day-table-stakes-plan A): when set, a camera button joins the controls\n",
1960 "--- a/app/styles/board.css\n+++ b/app/styles/board.css\n@@ -40 +40 @@\n\
1961 -/* ---- PHOTO MODE (plan-90d A) ---- */\n+/* ---- PHOTO MODE (90-day-table-stakes-plan A) ---- */\n",
1962 "--- a/app/routes/import.tsx\n+++ b/app/routes/import.tsx\n@@ -3 +3 @@\n\
1963 -// Import from a spreadsheet (plan-90d D): an onboarding path\n\
1964 +// Import from a spreadsheet (90-day-table-stakes-plan D): an onboarding path\n",
1965 "@@ -12,0 +13,2 @@\n+ {/* the header row */}\n+\n",
1966 "@@ -5,3 +5,3 @@\n- * Old text.\n- * @param {string} name the name\n-*/\n+ * New text.\n+ * @returns {JSX.Element}\n+ */\n",
1967 ] {
1968 assert!(comment_only(diff), "{diff}");
1969 }
1970 }
1971
1972 #[test]
1973 fn any_code_in_the_diff_counts_as_an_interface_change() {
1974 for diff in [
1975 "@@ -20 +20 @@ export function Toolbar() {\n- <Button>Save</Button>\n+ <Button>Save all</Button>\n",
1977 "@@ -1,2 +1,2 @@\n-// old\n+// new\n-const gap = 4\n+const gap = 8\n",
1979 "@@ -1 +1 @@\n-/* a */ foo()\n+/* b */ foo()\n",
1981 "@@ -3 +3 @@\n- */ export const x = 1\n+ */ export const x = 2\n",
1982 "@@ -1 +1 @@\n-* { margin: 0 }\n+* { margin: 4px }\n",
1984 "@@ -1,0 +1 @@\n+* {\n",
1985 "diff --git a/app/logo.png b/app/logo.png\nBinary files a/app/logo.png and b/app/logo.png differ\n",
1987 "",
1988 ] {
1989 assert!(!comment_only(diff), "{diff}");
1990 }
1991 }
1992
1993 #[test]
1994 fn a_package_json_looks_like_an_interface_by_dev_script_or_ui_dependency() {
1995 let cli = r#"{"name":"@duro-app/cli","bin":{"duro":"./dist/bin.js"},
1998 "scripts":{"build":"tsc -p tsconfig.build.json"},
1999 "peerDependencies":{"@duro-app/ui":"workspace:^"},
2000 "peerDependenciesMeta":{"@duro-app/ui":{"optional":true}},
2001 "devDependencies":{"@duro-app/ui":"workspace:^","typescript":"^5.7.0"}}"#;
2002 assert!(!package_is_ui(cli));
2003 assert!(package_is_ui(
2005 r#"{"peerDependencies":{"react":"^19","react-strict-dom":"*"}}"#
2006 ));
2007 assert!(package_is_ui(r#"{"dependencies":{"react-dom":"^19"}}"#));
2009 assert!(package_is_ui(r#"{"dependencies":{"@duro-app/ui":"^3"}}"#));
2010 assert!(package_is_ui(r#"{"scripts":{"dev":"vite"}}"#));
2011 assert!(!package_is_ui(r#"{"scripts":{"dev":" "}}"#));
2012 assert!(!package_is_ui(r#"{"name":"eslint-plugin"}"#));
2013 assert!(package_is_ui("{not json"));
2015 }
2016
2017 #[test]
2018 fn latency_is_measured_by_the_hook_and_duration_ms_rides_along() {
2019 assert_eq!(latency(Some(1_000), 185_400, Some(0)), "184s,dur=0ms");
2020 assert_eq!(latency(None, 185_400, Some(30_997)), "-,dur=30997ms");
2021 assert_eq!(latency(Some(9_000), 5_000, None), "-,dur=-");
2022 }
2023
2024 #[test]
2025 fn a_registration_round_trips_through_its_line() {
2026 let r = Registration {
2027 id: "abc123".into(),
2028 session: "s".into(),
2029 prompt: "p".into(),
2030 repo: "/w/duro-app".into(),
2031 commit: "abcdef0123".into(),
2032 url: "http://localhost:5173/x".into(),
2033 guide: "/h/a.md".into(),
2034 at: 42,
2035 asked: "-".into(),
2036 };
2037 assert_eq!(Registration::parse(&r.line()), Some(r.clone()));
2038 assert_eq!(r.label(), "duro-app@abcdef0");
2039 }
2040
2041 #[test]
2042 fn registered_json_round_trips() {
2043 let r = Registered {
2044 id: "i".into(),
2045 repo: "/r".into(),
2046 commit: "c".into(),
2047 url: "http://localhost:1".into(),
2048 guide: "/g/guide.md".into(),
2049 page: "/g/index.html".into(),
2050 page_url: "file:///g/index.html".into(),
2051 label: "r-wt-x@c".into(),
2052 aliases: vec!["r@c".into(), "repo@c".into()],
2053 question_prefix: "[preview i] r-wt-x@c".into(),
2054 };
2055 assert!(r
2056 .to_json()
2057 .contains(r#""question_prefix":"[preview i] r-wt-x@c""#));
2058 assert_eq!(Registered::from_json(&r.to_json()), Some(r));
2059 }
2060
2061 #[test]
2062 fn a_registration_printed_before_labels_still_reads() {
2063 let old = r#"{"id":"i","repo":"/r","commit":"c","url":"http://l/","guide":"/g.md","page":"","page_url":""}"#;
2064 let r = Registered::from_json(old).expect("the 2.22 shape");
2065 assert_eq!(r.label, "");
2066 assert!(r.aliases.is_empty());
2067 assert_eq!(r.question_prefix, "");
2068 }
2069
2070 #[test]
2071 fn a_word_is_quoted_only_when_it_must_be() {
2072 assert_eq!(shell_word("/tmp/g.md"), "/tmp/g.md");
2073 assert_eq!(
2074 shell_word("http://localhost:5173/"),
2075 "http://localhost:5173/"
2076 );
2077 assert_eq!(shell_word("a b"), "'a b'");
2078 assert_eq!(shell_word("it's"), "'it'\\''s'");
2079 }
2080
2081 #[test]
2082 fn a_registration_printed_before_guides_still_reads() {
2083 let old = r#"{"id":"i","repo":"/r","commit":"c","url":"http://l/","attestation":"/a.md"}"#;
2084 let r = Registered::from_json(old).expect("the 2.20.0 shape");
2085 assert_eq!(r.guide, "/a.md");
2086 assert_eq!(r.page, "");
2087 }
2088}