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 };
1207 let here = ctx.cwd_at(cmd.at);
1208 let dir = match cmd.flag_value("--repo") {
1209 Some(d) if d.starts_with('/') => PathBuf::from(d),
1210 Some(d) => here.join(d),
1211 None => here.clone(),
1212 };
1213 let named = push_target::toplevel(&dir);
1214 let shown = named.clone().unwrap_or_else(|| dir.clone());
1216 let excerpt = "preview register";
1217 let printed = bash.stdout.as_deref().and_then(printed_json);
1218 let started = take_stamp(&bash.session, &bash.tool_use_id);
1220 let again = format!(
1222 "cd {} && {}",
1223 shell_word(&here.to_string_lossy()),
1224 cmd.words
1225 .iter()
1226 .map(|w| shell_word(&w.text))
1227 .collect::<Vec<_>>()
1228 .join(" ")
1229 );
1230 let speaks = amont_agent_guard::stance::resolve(&crate::push_preview::RULE) != Stance::Observe;
1231 let refuse = |why: &str| -> Option<String> {
1232 note(
1233 "unbound",
1234 &bash.session,
1235 &shown.to_string_lossy(),
1236 &format!("{excerpt}: {why}"),
1237 );
1238 speaks.then(|| {
1239 let ask = printed
1240 .as_ref()
1241 .map(|p| {
1242 if p.question_prefix.is_empty() {
1243 question_prefix(&p.id, &p.label)
1244 } else {
1245 p.question_prefix.clone()
1246 }
1247 })
1248 .map(|q| format!("a question starting `{}`", q.trim_end()))
1249 .unwrap_or_else(|| "the `question_prefix` from its output".to_string());
1250 format!(
1251 "`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}."
1252 )
1253 })
1254 };
1255 if bash.background {
1256 return refuse("it ran in the background");
1257 }
1258 if !parsed.fully_read() || !register_is_last(clauses, cmd.at) {
1259 return refuse("it was not the last command of the line");
1260 }
1261 let Some(started) = started else {
1262 return refuse("no record of the call starting: the hook did not see it before it ran");
1263 };
1264 let Some(printed) = printed.clone() else {
1265 return refuse("its output's last line is not the expected JSON");
1266 };
1267 let Some(repo) = named else {
1268 return refuse("the repository it names is gone");
1269 };
1270 let head = git(
1271 &repo,
1272 &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"],
1273 );
1274 if printed.repo != repo.to_string_lossy() || head.as_deref() != Some(printed.commit.as_str()) {
1275 return refuse("its output does not match the repository's HEAD");
1276 }
1277 if let Err(why) = printed_by_this_call(&printed, started) {
1278 return refuse(why);
1279 }
1280 let mut regs = registrations();
1281 let before = regs.len();
1282 regs.retain(|r| !(r.session == bash.session && r.repo == printed.repo));
1283 let outcome = if regs.len() < before {
1284 "replaced"
1285 } else {
1286 "registered"
1287 };
1288 let reg = Registration {
1289 id: printed.id.clone(),
1290 session: bash.session.clone(),
1291 prompt: bash.prompt_id.clone(),
1292 repo: printed.repo.clone(),
1293 commit: printed.commit.clone(),
1294 url: printed.url.clone(),
1295 guide: printed.guide.clone(),
1296 at: now(),
1297 asked: "-".to_string(),
1298 };
1299 note(
1300 outcome,
1301 &bash.session,
1302 ®.repo,
1303 &format!(
1304 "{} {} {} page={}",
1305 reg.id,
1306 reg.label(),
1307 reg.url,
1308 if printed.page.is_empty() {
1309 "-"
1310 } else {
1311 &printed.page
1312 }
1313 ),
1314 );
1315 regs.push(reg);
1316 save_registrations(®s);
1317 None
1318}
1319
1320fn shell_word(w: &str) -> String {
1323 if !w.is_empty()
1324 && w.chars()
1325 .all(|c| c.is_ascii_alphanumeric() || "-_./:=@%+,".contains(c))
1326 {
1327 w.to_string()
1328 } else {
1329 format!("'{}'", w.replace('\'', "'\\''"))
1330 }
1331}
1332
1333fn register_is_last(clauses: &[amont_agent_guard::shell::Simple], at: usize) -> bool {
1339 use amont_agent_guard::shell::Connector;
1340 let line: Vec<&amont_agent_guard::shell::Simple> =
1342 clauses.iter().filter(|c| c.nested.is_none()).collect();
1343 let Some(last) = line.last() else {
1344 return false;
1345 };
1346 if last.at != at || last.next.is_some() || !last.redirects.is_empty() || last.heredoc {
1347 return false;
1348 }
1349 let joined = |c: Option<Connector>| matches!(c, Some(Connector::AndAnd | Connector::Semi));
1350 (last.prev.is_none() || joined(last.prev))
1351 && line[..line.len() - 1].iter().all(|c| joined(c.next))
1352}
1353
1354fn is_register(cmd: &amont_agent_guard::shell::Simple) -> bool {
1355 let is_us = cmd
1356 .program()
1357 .is_some_and(|p| p == "amont-agent" || p.ends_with("/amont-agent"));
1358 let ops: Vec<&str> = cmd.operands().iter().map(|w| w.text.as_str()).collect();
1359 let help = cmd
1363 .words
1364 .iter()
1365 .any(|w| w.text == "--help" || w.text == "-h");
1366 is_us && !help && ops.first() == Some(&"preview") && ops.get(1) == Some(&"register")
1367}
1368
1369pub fn marker(question: &str) -> Option<Vec<String>> {
1373 let start = question.find("[preview ")? + "[preview ".len();
1374 let end = start + question[start..].find(']')?;
1375 let ids: Vec<String> = question[start..end]
1376 .split(',')
1377 .map(|s| s.trim().to_string())
1378 .filter(|s| !s.is_empty())
1379 .collect();
1380 (!ids.is_empty()).then_some(ids)
1381}
1382
1383pub fn on_pre_ask(ask: &amont_agent_guard::payload::Ask, stance: Stance) -> Option<String> {
1386 let marked: Vec<Vec<String>> = ask.questions.iter().filter_map(|q| marker(q)).collect();
1387 if marked.is_empty() {
1388 return None;
1389 }
1390 let ids: Vec<String> = marked.concat();
1391 if let Some(d) = dir().map(|d| d.join("asks")) {
1392 if ensure(&d).is_some() && !ask.tool_use_id.is_empty() && !ask.tool_use_id.contains('/') {
1393 let _ = amont_agent_guard::atomic::write_atomic(
1394 &d.join(&ask.tool_use_id),
1395 &format!(
1396 "{}\t{}\t{}\t{}\t{}\n",
1397 clean(&ask.session),
1398 clean(&ask.prompt_id),
1399 ask.prefilled,
1400 ids.join(","),
1401 now_ms()
1402 ),
1403 );
1404 }
1405 }
1406 if ask.prefilled {
1407 note(
1408 "prefilled",
1409 &ask.session,
1410 &repo_of(&ask.session, &ids),
1411 &format!("marked question {}", ids.join(",")),
1412 );
1413 if stance == Stance::Deny {
1414 return Some(
1415 "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`."
1416 .to_string(),
1417 );
1418 }
1419 return None;
1420 }
1421 let mut regs = registrations();
1423 let mut changed = false;
1424 for r in regs.iter_mut() {
1425 if r.session == ask.session && r.prompt == ask.prompt_id && ids.contains(&r.id) {
1426 r.asked = ask.tool_use_id.clone();
1427 changed = true;
1428 }
1429 }
1430 if changed {
1431 save_registrations(®s);
1432 }
1433 None
1434}
1435
1436pub fn on_post_ask(ask: &amont_agent_guard::payload::Ask) {
1440 let recorded = dir()
1441 .map(|d| d.join("asks").join(&ask.tool_use_id))
1442 .filter(|_| !ask.tool_use_id.is_empty() && !ask.tool_use_id.contains('/'))
1443 .and_then(|p| {
1444 let t = std::fs::read_to_string(&p).ok();
1445 let _ = std::fs::remove_file(&p);
1446 t
1447 });
1448 let Some(recorded) = recorded else {
1449 if ask.questions.iter().any(|q| marker(q).is_some()) {
1452 let ids: Vec<String> = ask
1453 .questions
1454 .iter()
1455 .filter_map(|q| marker(q))
1456 .flatten()
1457 .collect();
1458 note(
1459 "unrecorded",
1460 &ask.session,
1461 &repo_of(&ask.session, &ids),
1462 "marked question with no PreToolUse record",
1463 );
1464 }
1465 return;
1466 };
1467 let answered_ms = now_ms();
1468 let f: Vec<&str> = recorded.trim_end().split('\t').collect();
1469 if !(f.len() == 4 || f.len() == 5) || f[0] != ask.session || f[2] != "false" {
1471 return;
1472 }
1473 let asked_ms = f.get(4).and_then(|t| t.parse::<u64>().ok());
1474 for question in &ask.questions {
1475 let Some(ids) = marker(question) else {
1476 continue;
1477 };
1478 let label = ask
1479 .answers
1480 .iter()
1481 .find(|(q, _)| q == question)
1482 .map(|(_, l)| l.as_str())
1483 .unwrap_or("");
1484 let latency = latency(asked_ms, answered_ms, ask.duration_ms);
1485 let mut regs = registrations();
1486 let (mine, rest): (Vec<Registration>, Vec<Registration>) = regs.drain(..).partition(|r| {
1487 r.session == ask.session && r.asked == ask.tool_use_id && ids.contains(&r.id)
1488 });
1489 if mine.is_empty() {
1490 continue;
1491 }
1492 match label {
1493 APPROVE => {
1494 approve(&mine, &format!("option,{latency}"));
1498 save_registrations(&rest);
1499 }
1500 REQUEST_CHANGES | HOLD => {
1501 for r in &mine {
1502 note(
1503 "dropped",
1504 &r.session,
1505 &r.repo,
1506 &format!("{} {label} after {latency}", r.label()),
1507 );
1508 }
1509 save_registrations(&rest);
1510 }
1511 _ => {
1512 for r in &mine {
1515 note(
1516 "unanswered",
1517 &r.session,
1518 &r.repo,
1519 &format!("{} after {latency}", r.label()),
1520 );
1521 }
1522 let mut back = rest;
1523 back.extend(mine);
1524 save_registrations(&back);
1525 }
1526 }
1527 }
1528}
1529
1530fn repo_of(session: &str, ids: &[String]) -> String {
1533 let mut repos: Vec<String> = registrations()
1534 .into_iter()
1535 .filter(|r| r.session == session && ids.contains(&r.id))
1536 .map(|r| r.repo)
1537 .collect();
1538 repos.sort();
1539 repos.dedup();
1540 match repos.as_slice() {
1541 [one] => one.clone(),
1542 _ => "-".to_string(),
1543 }
1544}
1545
1546pub fn is_typed_approval(text: &str) -> bool {
1548 let t = text
1549 .trim()
1550 .trim_end_matches(['.', '!', ' '])
1551 .to_ascii_lowercase();
1552 let t = t.strip_suffix(" it").unwrap_or(&t);
1553 matches!(t, "approve" | "approved" | "ship" | "lgtm" | "looks good")
1554}
1555
1556pub fn on_prompt(prompt: &amont_agent_guard::payload::Prompt) {
1559 let regs = registrations();
1560 let (older, rest): (Vec<Registration>, Vec<Registration>) = regs
1561 .into_iter()
1562 .partition(|r| r.session == prompt.session && r.prompt != prompt.prompt_id);
1563 if older.is_empty() {
1564 return;
1565 }
1566 let typed = is_typed_approval(&prompt.text);
1567 let (asked, never): (Vec<_>, Vec<_>) = older.into_iter().partition(|r| r.asked != "-");
1568 for r in &never {
1569 note(
1570 "dropped",
1571 &r.session,
1572 &r.repo,
1573 &format!("{} was never asked about", r.label()),
1574 );
1575 }
1576 if typed {
1577 approve(&asked, "typed");
1578 } else {
1579 for r in &asked {
1580 note(
1581 "dropped",
1582 &r.session,
1583 &r.repo,
1584 &format!("{} the next prompt was not an approval", r.label()),
1585 );
1586 }
1587 }
1588 save_registrations(&rest);
1589}
1590
1591fn plans_approved_dir() -> Option<PathBuf> {
1595 Some(journal::dir()?.join("plan-approved"))
1596}
1597
1598pub fn on_plan_answered(ev: &amont_agent_guard::payload::PlanAnswered) {
1605 if !ev.approved {
1606 return;
1607 }
1608 let Some(file) = &ev.plan_file else { return };
1609 let Ok(text) = std::fs::read_to_string(file) else {
1610 return;
1611 };
1612 let Ok(shas) = crate::plan_text::shas(&text) else {
1613 return;
1614 };
1615 let Some(d) = plans_approved_dir() else {
1616 return;
1617 };
1618 if ensure(&d).is_none() {
1619 return;
1620 }
1621 let path = d.join(&shas.sha);
1622 let _ = amont_agent_guard::atomic::write_atomic(
1623 &path,
1624 &format!(
1625 "{}\t{}\t{}\n",
1626 clean(&ev.session),
1627 clean(&file.to_string_lossy()),
1628 now()
1629 ),
1630 );
1631 journal::private(&path, 0o600);
1632 note(
1633 "plan-approved",
1634 &ev.session,
1635 "-",
1636 &format!("{} {}", &shas.sha[..12], file.display()),
1637 );
1638}
1639
1640fn plan_approved(sha: &str) -> bool {
1642 sha.len() == 64
1643 && sha.bytes().all(|b| b.is_ascii_hexdigit())
1644 && plans_approved_dir().is_some_and(|d| d.join(sha).is_file())
1645}
1646
1647pub fn preview_evidence(text: &str) -> Option<String> {
1653 let body = crate::plan_text::canonical(text);
1654 let mut fence = crate::plan_text::Fence::default();
1655 let mut lines = body.lines();
1656 lines
1657 .by_ref()
1658 .find(|l| fence.h2(l).is_some_and(|t| t == "Preview"))?;
1659 let first = lines.find(|l| !l.trim().is_empty())?;
1660 let reason = first.trim().strip_prefix("evidence:")?.trim();
1661 (!reason.is_empty() && !reason.starts_with('<')).then(|| reason.to_string())
1662}
1663
1664fn first_plan(repo: &Path, from: &str, commit: &str) -> Option<(String, String)> {
1667 let log = git(
1668 repo,
1669 &[
1670 "log",
1671 "--reverse",
1672 "--diff-filter=A",
1673 "--format=%H",
1674 "--name-only",
1675 &format!("{from}..{commit}"),
1676 "--",
1677 "docs/plans/",
1678 ],
1679 )?;
1680 let mut at: Option<&str> = None;
1681 for line in log.lines().map(str::trim).filter(|l| !l.is_empty()) {
1682 if let Some(name) = line.strip_prefix("docs/plans/") {
1683 if let Some(c) = at.filter(|_| crate::plan_phases::is_plan_name(name)) {
1684 return Some((c.to_string(), line.to_string()));
1685 }
1686 } else if line.len() >= 40 && line.bytes().all(|b| b.is_ascii_hexdigit()) {
1687 at = Some(line);
1688 }
1689 }
1690 None
1691}
1692
1693fn evidence_of(repo: &Path, commit: &str) -> Option<String> {
1706 let base = amont_agent_guard::stale::remote_of(repo)
1707 .and_then(|r| amont_agent_guard::stale::default_base(repo, &r))?;
1708 let from = git(repo, &["merge-base", &base, commit])?;
1709 let (added, path) = first_plan(repo, &from, commit)?;
1710 let text = amont_agent_guard::git::stdout_in(repo, &["show", &format!("{added}:{path}")])?;
1711 let (front, _) = crate::plans::split_front_matter(&text);
1712 if crate::plans::field(front, "canonical").is_some_and(|c| !c.is_empty()) {
1713 return None;
1714 }
1715 let Ok(shas) = crate::plan_text::shas(&text) else {
1716 return None;
1717 };
1718 if !plan_approved(&shas.sha) {
1719 return None;
1720 }
1721 let reason = preview_evidence(&text)?;
1722 Some(format!(
1723 "{path} commit={} sha={} evidence: {reason}",
1724 short(commit),
1725 &shas.sha[..12]
1726 ))
1727}
1728
1729pub fn record_before(
1736 bash: &amont_agent_guard::payload::Bash,
1737 parsed: &amont_agent_guard::shell::Parsed,
1738) {
1739 if bash.tool_use_id.is_empty() || bash.tool_use_id.contains('/') {
1740 return;
1741 }
1742 let Some(cmd) = push_target::find(parsed) else {
1743 return;
1744 };
1745 let ctx = amont_agent_guard::rules::Context {
1746 platform: Some(amont_agent_guard::rules::Platform::host()),
1747 cwd: &bash.cwd,
1748 parsed,
1749 background: bash.background,
1750 timeout_ms: bash.timeout_ms,
1751 tool_use_id: &bash.tool_use_id,
1752 transcript: bash.transcript.as_deref(),
1753 agent_transcript: bash.agent_transcript.as_deref(),
1754 command: &bash.command,
1755 };
1756 let Push::Resolved { repo, targets } = push_target::resolve_for(&ctx, cmd) else {
1757 return;
1758 };
1759 if amont_agent_guard::land::is_land(cmd) {
1764 if let (Some(t), amont_agent_guard::land::Answer::Would(l)) = (
1765 targets.first(),
1766 amont_agent_guard::land::dry_run(&ctx.cwd_at(cmd.at)),
1767 ) {
1768 amont_agent_guard::push_record::record_land(&bash.tool_use_id, &t.src, &l);
1769 }
1770 }
1771 if !gated(&repo) {
1772 return;
1773 }
1774 let rows: Vec<Before> = targets
1775 .iter()
1776 .filter(|t| t.kind == Kind::Branch)
1777 .map(|t| {
1778 let short = t.dst.trim_start_matches("refs/heads/");
1779 Before {
1780 repo: repo.clone(),
1781 remote: t.remote.clone(),
1782 dst: t.dst.clone(),
1783 before: git(
1784 &repo,
1785 &[
1786 "rev-parse",
1787 "--verify",
1788 "--quiet",
1789 &format!("refs/remotes/{}/{short}", t.remote),
1790 ],
1791 ),
1792 src: t.src.clone(),
1793 ui: carries_ui(&repo, t).unwrap_or(false),
1794 approved: approved(&repo, &t.src),
1795 }
1796 })
1797 .collect();
1798 amont_agent_guard::push_record::write_before(&bash.tool_use_id, &rows);
1799}
1800
1801pub fn sweep() {
1803 let Some(d) = dir() else { return };
1804 for sub in ["pushes", "asks", "registers"] {
1805 let Ok(entries) = std::fs::read_dir(d.join(sub)) else {
1806 continue;
1807 };
1808 for e in entries.flatten() {
1809 let old = e
1810 .metadata()
1811 .and_then(|m| m.modified())
1812 .ok()
1813 .and_then(|t| t.elapsed().ok())
1814 .is_some_and(|age| age.as_secs() > SCRATCH_TTL);
1815 if old {
1816 let _ = std::fs::remove_file(e.path());
1817 }
1818 }
1819 }
1820 let _ = registrations();
1822}
1823
1824#[cfg(test)]
1825mod tests {
1826 use super::*;
1827 use crate::plan_text::package_is_ui;
1828
1829 #[test]
1830 fn a_preview_section_declares_evidence_on_its_first_line() {
1831 let plan =
1832 |section: &str| format!("# P\n\n## Context\n\nx\n\n{section}\n## Phases\n\n1. y\n");
1833 assert_eq!(
1834 preview_evidence(&plan("## Preview\n\nevidence: decided at 28px\n")).as_deref(),
1835 Some("decided at 28px")
1836 );
1837 for held in [
1838 "## Preview\n\nevidence:\n",
1840 "## Preview\n\nevidence: <why the person will have nothing left to judge: the visible change\nis one this plan decides>\n",
1842 "## Preview\n\nThe screenshots.\nevidence: decided\n",
1844 "```md\n## Preview\n\nevidence: an example\n```\n",
1846 "## Previews\n\nevidence: decided\n",
1848 "",
1849 ] {
1850 assert_eq!(preview_evidence(&plan(held)), None, "{held}");
1851 }
1852 assert_eq!(preview_evidence("---\nstatus: active\n---\n# P\n"), None);
1854 }
1855
1856 #[test]
1857 fn a_marker_lists_its_ids() {
1858 assert_eq!(
1859 marker("[preview a1b2, c3] Ship duro-app@abc1234?"),
1860 Some(vec!["a1b2".to_string(), "c3".to_string()])
1861 );
1862 assert_eq!(marker("Ship it?"), None);
1863 assert_eq!(marker("[preview ] empty"), None);
1864 assert_eq!(marker("[preview unterminated"), None);
1865 }
1866
1867 #[test]
1868 fn only_explicit_words_are_a_typed_approval() {
1869 for yes in [
1870 "approve",
1871 "Approve.",
1872 "ship it",
1873 "Ship it!",
1874 "lgtm",
1875 "looks good",
1876 " LGTM ",
1877 ] {
1878 assert!(is_typed_approval(yes), "{yes}");
1879 }
1880 for no in [
1881 "yes",
1882 "go",
1883 "ok",
1884 "continue",
1885 "approve but fix the header",
1886 "ship it later",
1887 "",
1888 ] {
1889 assert!(!is_typed_approval(no), "{no}");
1890 }
1891 }
1892
1893 #[test]
1894 fn ui_paths_are_recognised_and_docs_are_not() {
1895 for ui in [
1896 "app/routes/home.tsx",
1897 "src/main.ts",
1898 "web/src/x.ts",
1899 "packages/ui/src/a.ts",
1900 "styles/site.css",
1901 "public/index.html",
1902 "apps/web/vite.config.ts",
1903 "app/routes/test/index.tsx",
1905 "app/tests/page.tsx",
1906 "src/e2e/Editor.tsx",
1907 "src/vitest.tsx",
1908 "src/testimonials/a.tsx",
1910 "src/latest.ts",
1911 "src/features/e2e-banner.tsx",
1912 ] {
1913 assert!(is_ui_path(ui), "{ui}");
1914 }
1915 for not in [
1916 "docs/plans/x.md",
1917 ".github/workflows/ci.yaml",
1918 "README.md",
1919 "Cargo.toml",
1920 "package.json",
1921 "apps/web/vitest.core.config.ts",
1922 "app/routes/home.test.tsx",
1923 "src/a.spec.ts",
1924 "web/e2e/login.ts",
1925 "src/__tests__/x.tsx",
1926 ] {
1927 assert!(!is_ui_path(not), "{not}");
1928 }
1929 }
1930
1931 const PR_301: &str = "\
1934diff --git a/app/.server/observedPipeline.integration.test.ts b/app/.server/observedPipeline.integration.test.ts
1935index 15750da..2770014 100644
1936--- a/app/.server/observedPipeline.integration.test.ts
1937+++ b/app/.server/observedPipeline.integration.test.ts
1938@@ -8 +8 @@
1939- * docs/plan-e4-e5-cluster-connector.md; it needs an authed Pro-org Playwright
1940+ * docs/plans/2026-07-07-e4-e5-cluster-connector.md; it needs an authed Pro-org Playwright
1941";
1942
1943 #[test]
1944 fn comment_only_diffs_from_real_pushes_are_recognised() {
1945 assert!(comment_only(PR_301));
1946 assert!(comment_only(&format!("{PR_301}{PR_301}")));
1949 for diff in [
1950 "--- a/app/components/board/BoardToolbar.tsx\n+++ b/app/components/board/BoardToolbar.tsx\n\
1951 @@ -88 +88 @@ export interface BoardToolbarProps {\n\
1952 - /** Photo mode (plan-90d A): when set, a camera button joins the controls\n\
1953 + /** Photo mode (90-day-table-stakes-plan A): when set, a camera button joins the controls\n",
1954 "--- a/app/styles/board.css\n+++ b/app/styles/board.css\n@@ -40 +40 @@\n\
1955 -/* ---- PHOTO MODE (plan-90d A) ---- */\n+/* ---- PHOTO MODE (90-day-table-stakes-plan A) ---- */\n",
1956 "--- a/app/routes/import.tsx\n+++ b/app/routes/import.tsx\n@@ -3 +3 @@\n\
1957 -// Import from a spreadsheet (plan-90d D): an onboarding path\n\
1958 +// Import from a spreadsheet (90-day-table-stakes-plan D): an onboarding path\n",
1959 "@@ -12,0 +13,2 @@\n+ {/* the header row */}\n+\n",
1960 "@@ -5,3 +5,3 @@\n- * Old text.\n- * @param {string} name the name\n-*/\n+ * New text.\n+ * @returns {JSX.Element}\n+ */\n",
1961 ] {
1962 assert!(comment_only(diff), "{diff}");
1963 }
1964 }
1965
1966 #[test]
1967 fn any_code_in_the_diff_counts_as_an_interface_change() {
1968 for diff in [
1969 "@@ -20 +20 @@ export function Toolbar() {\n- <Button>Save</Button>\n+ <Button>Save all</Button>\n",
1971 "@@ -1,2 +1,2 @@\n-// old\n+// new\n-const gap = 4\n+const gap = 8\n",
1973 "@@ -1 +1 @@\n-/* a */ foo()\n+/* b */ foo()\n",
1975 "@@ -3 +3 @@\n- */ export const x = 1\n+ */ export const x = 2\n",
1976 "@@ -1 +1 @@\n-* { margin: 0 }\n+* { margin: 4px }\n",
1978 "@@ -1,0 +1 @@\n+* {\n",
1979 "diff --git a/app/logo.png b/app/logo.png\nBinary files a/app/logo.png and b/app/logo.png differ\n",
1981 "",
1982 ] {
1983 assert!(!comment_only(diff), "{diff}");
1984 }
1985 }
1986
1987 #[test]
1988 fn a_package_json_looks_like_an_interface_by_dev_script_or_ui_dependency() {
1989 let cli = r#"{"name":"@duro-app/cli","bin":{"duro":"./dist/bin.js"},
1992 "scripts":{"build":"tsc -p tsconfig.build.json"},
1993 "peerDependencies":{"@duro-app/ui":"workspace:^"},
1994 "peerDependenciesMeta":{"@duro-app/ui":{"optional":true}},
1995 "devDependencies":{"@duro-app/ui":"workspace:^","typescript":"^5.7.0"}}"#;
1996 assert!(!package_is_ui(cli));
1997 assert!(package_is_ui(
1999 r#"{"peerDependencies":{"react":"^19","react-strict-dom":"*"}}"#
2000 ));
2001 assert!(package_is_ui(r#"{"dependencies":{"react-dom":"^19"}}"#));
2003 assert!(package_is_ui(r#"{"dependencies":{"@duro-app/ui":"^3"}}"#));
2004 assert!(package_is_ui(r#"{"scripts":{"dev":"vite"}}"#));
2005 assert!(!package_is_ui(r#"{"scripts":{"dev":" "}}"#));
2006 assert!(!package_is_ui(r#"{"name":"eslint-plugin"}"#));
2007 assert!(package_is_ui("{not json"));
2009 }
2010
2011 #[test]
2012 fn latency_is_measured_by_the_hook_and_duration_ms_rides_along() {
2013 assert_eq!(latency(Some(1_000), 185_400, Some(0)), "184s,dur=0ms");
2014 assert_eq!(latency(None, 185_400, Some(30_997)), "-,dur=30997ms");
2015 assert_eq!(latency(Some(9_000), 5_000, None), "-,dur=-");
2016 }
2017
2018 #[test]
2019 fn a_registration_round_trips_through_its_line() {
2020 let r = Registration {
2021 id: "abc123".into(),
2022 session: "s".into(),
2023 prompt: "p".into(),
2024 repo: "/w/duro-app".into(),
2025 commit: "abcdef0123".into(),
2026 url: "http://localhost:5173/x".into(),
2027 guide: "/h/a.md".into(),
2028 at: 42,
2029 asked: "-".into(),
2030 };
2031 assert_eq!(Registration::parse(&r.line()), Some(r.clone()));
2032 assert_eq!(r.label(), "duro-app@abcdef0");
2033 }
2034
2035 #[test]
2036 fn registered_json_round_trips() {
2037 let r = Registered {
2038 id: "i".into(),
2039 repo: "/r".into(),
2040 commit: "c".into(),
2041 url: "http://localhost:1".into(),
2042 guide: "/g/guide.md".into(),
2043 page: "/g/index.html".into(),
2044 page_url: "file:///g/index.html".into(),
2045 label: "r-wt-x@c".into(),
2046 aliases: vec!["r@c".into(), "repo@c".into()],
2047 question_prefix: "[preview i] r-wt-x@c".into(),
2048 };
2049 assert!(r
2050 .to_json()
2051 .contains(r#""question_prefix":"[preview i] r-wt-x@c""#));
2052 assert_eq!(Registered::from_json(&r.to_json()), Some(r));
2053 }
2054
2055 #[test]
2056 fn a_registration_printed_before_labels_still_reads() {
2057 let old = r#"{"id":"i","repo":"/r","commit":"c","url":"http://l/","guide":"/g.md","page":"","page_url":""}"#;
2058 let r = Registered::from_json(old).expect("the 2.22 shape");
2059 assert_eq!(r.label, "");
2060 assert!(r.aliases.is_empty());
2061 assert_eq!(r.question_prefix, "");
2062 }
2063
2064 #[test]
2065 fn a_word_is_quoted_only_when_it_must_be() {
2066 assert_eq!(shell_word("/tmp/g.md"), "/tmp/g.md");
2067 assert_eq!(
2068 shell_word("http://localhost:5173/"),
2069 "http://localhost:5173/"
2070 );
2071 assert_eq!(shell_word("a b"), "'a b'");
2072 assert_eq!(shell_word("it's"), "'it'\\''s'");
2073 }
2074
2075 #[test]
2076 fn a_registration_printed_before_guides_still_reads() {
2077 let old = r#"{"id":"i","repo":"/r","commit":"c","url":"http://l/","attestation":"/a.md"}"#;
2078 let r = Registered::from_json(old).expect("the 2.20.0 shape");
2079 assert_eq!(r.guide, "/a.md");
2080 assert_eq!(r.page, "");
2081 }
2082}