Skip to main content

amont_agent_process/
preview.rs

1//! Preview approval (ADR-0028, which replaced ADR-0023): a UI-changing
2//! commit is published only after the person approves that exact commit,
3//! seen on localhost — unless the plan the person approved declares, in a
4//! `## Preview` section, that the evidence is enough ([`on_plan_answered`],
5//! `evidence_of`).
6//!
7//! ## The flow this module keeps honest
8//!
9//! 1. The agent verifies the final commit itself, then runs
10//!    `amont-agent preview register --url … --guide …` as the last command
11//!    of its line, in the foreground. That command VALIDATES (clean tree,
12//!    real commit, a complete guide outside the worktree), renders the guide
13//!    to `index.html` beside it ([`crate::guide`]) and prints JSON on one
14//!    line; the PostToolUse hook binds that output to the session and the
15//!    person's prompt ([`bind`]), once it has shown the line is this call's
16//!    own ([`stamp_register`]).
17//! 2. In the same turn the agent asks a *marked* question, starting with the
18//!    register's `question_prefix` — `[preview <id>] <label>`, each id
19//!    beginning with its commit's sha7 — options exactly `Approve` /
20//!    `Request changes` / `Hold`.
21//! 3. The person answers. Only their selection, read from `tool_response`
22//!    ([`on_post_ask`]), or a typed `approve|ship|lgtm|looks good` in the very
23//!    next prompt ([`on_prompt`]), approves — and only the commits the
24//!    question listed.
25//!
26//! What this is NOT: proof. A registration is the agent's attestation of
27//! worktree, commit and URL; nothing here can show the browser served that
28//! commit or that the person looked. It is a workflow aid over Claude Code's
29//! hooks, not a Git enforcement boundary.
30//!
31//! ## Why the answer has to be checked twice
32//!
33//! `AskUserQuestion`'s input schema accepts `answers`. A model could
34//! pre-answer its own question, and the PostToolUse payload would then carry
35//! an "Approve" nobody chose. [`on_pre_ask`] records, per `tool_use_id`,
36//! whether the call arrived pre-answered; a pre-answered marked question never
37//! approves, and under `deny` it is refused before it runs.
38
39use 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
45/// A registration nobody answered lapses after a day: the commit pins the
46/// content, and any other prompt drops it long before this.
47const REGISTRATION_TTL: u64 = 24 * 3600;
48/// An approval unused for a day lapses too.
49const APPROVAL_TTL: u64 = 24 * 3600;
50/// Per-call scratch (`pushes/`, `asks/`, `registers/`) older than this is
51/// nobody's call.
52const SCRATCH_TTL: u64 = 24 * 3600;
53
54pub const RULE_ID: &str = "push-preview";
55
56/// The labels of a marked question. Exact, with no "(Recommended)" suffix.
57pub 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
72/// How long the person took to answer, as the journal spells it:
73/// `<secs>s,dur=<duration_ms>ms`. The seconds are ours — PostToolUse time
74/// minus the PreToolUse time recorded in `asks/<tool_use_id>` — because the
75/// payload's `duration_ms` is not the person's answer time (a minutes-long
76/// approval arrived as 0). The raw `duration_ms` rides along for comparison.
77fn 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
90// --- gating ---------------------------------------------------------------
91
92/// Whether a repository has a user interface a person can preview.
93///
94/// `git config amont.agent.push-preview.ui true|false` decides when set (the
95/// repository's own config, which a clone never carries). Otherwise: a `dev`
96/// script in `package.json` at the root or in `web/`.
97pub 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
123/// The extensions of files a person sees rendered.
124const UI_EXTENSIONS: [&str; 4] = [".tsx", ".jsx", ".css", ".html"];
125
126/// A path whose change a person could see. Test files and test-tooling
127/// configs are never UI.
128pub 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
138/// A test, or a test tool's config. `*.test.*`, `*.spec.*` and anything
139/// under `__tests__/` always are. Under a `test/`, `tests/` or `e2e/`
140/// directory, and named `vitest.*`, `jest.*`, `playwright.*` or `cypress.*`,
141/// only a file that is not rendered is: `app/routes/test/index.tsx` is a
142/// route a person can open. A component named `*.spec.tsx` is misread as a
143/// test; no repository here names one so.
144fn 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
161/// What a branch push would publish: the files, and the base they were
162/// diffed against (`None` when they were listed from `git log` instead).
163struct Published {
164    base: Option<String>,
165    files: Vec<String>,
166}
167
168/// The files a branch push would publish, or `None` when that cannot be
169/// established without guessing.
170fn published(repo: &Path, t: &Target) -> Option<Published> {
171    let short = t.dst.strip_prefix("refs/heads/")?;
172    // Every commit already on some remote: nothing here is new to preview.
173    // Those commits were either previewed when they were pushed there, or
174    // were never this session's (seeding a fork from an upstream clone).
175    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
222/// Whether a branch push carries an interface change, or `None` when its
223/// files cannot be listed. Stops at the first file that counts.
224fn 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
234// --- what counts as an interface change -----------------------------------
235
236/// One changed file counts when all hold: its path looks like an interface
237/// ([`is_ui_path`]); its nearest `package.json` in the pushed commit looks
238/// like one ([`package_is_ui`]); and its diff is not comments only
239/// ([`comment_only`]). Every doubt — an unreadable manifest, a diff that
240/// cannot be taken — counts.
241fn 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        // No single base: every commit the remote lacks, each against its
266        // first parent. Comments-only in each is comments-only in all.
267        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            &not_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
289/// Walk up from the file to the repository root, in the pushed commit, and
290/// judge the first `package.json` met. None at all: the path decides.
291fn 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
325/// Whether a `git diff -U0` of one file changes comments and nothing else:
326/// at least one hunk, and every added or removed line that is not blank is
327/// a comment line ([`is_comment_line`]). No hunk at all (a binary file, a
328/// mode change) is not "comments only".
329pub 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            // A file's header — `index`, `---`, `+++` — until its first hunk.
335            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
360/// A line that, on its own, is nothing but a comment. Conservative: a line
361/// that also carries code — `/* x */ foo()`, `*/ bar` — is not, and neither
362/// is a `*` line that reads like code (a CSS `* {` rule, a continued
363/// multiplication ending in `;`).
364pub 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        // A JSX comment: closed on this line with `*/}`, or not closed yet.
371        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        // The end of a block comment — `*/`, or a JSX comment's `*/}`.
384        return rest.is_empty() || rest == "}";
385    }
386    if let Some(rest) = t.strip_prefix('*') {
387        // Inside a block comment: `*`, `* text`, `* text */`.
388        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            // A JSDoc tag: `* @param {string} name`.
397            return true;
398        }
399        // What reads like code: a CSS `* {` rule, a statement's end.
400        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/// Why a push needs a preview it does not have.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum Needs {
412    /// An unapproved interface change; `mockup` when the commit carries a
413    /// picked mockup (or cannot be told apart from one), held at deny.
414    Unapproved { mockup: bool },
415    /// Under deny, a push whose shape cannot be read, held so that `--all`
416    /// is not the way around the gate. Not a missing approval: saying so
417    /// sent a session looking for one it did not need (2026-10-08).
418    Unreadable(&'static str),
419}
420
421/// The rule's question: would this push publish an interface change that no
422/// approved preview covers? `Ok` fires the rule and says why; `Err` names
423/// why it did not.
424pub 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                // Under deny a shape nobody can read is held: `--all` must
436                // not be the way around the gate.
437                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
482/// What stands for one branch target's preview, decided without a journal
483/// line: the gate and the provenance record both read it.
484enum Standing {
485    /// No interface change: nothing to preview.
486    NoUi,
487    /// The approved plan declares the evidence (the journal detail).
488    Evidence(String),
489    /// An interface change that needs the person's approval; `mockup` when
490    /// a picked mockup's screens are listed or cannot be listed.
491    Approval { mockup: bool },
492}
493
494/// The standing of a branch target, or `None` when its files cannot be
495/// listed. Evidence never stands for a picked mockup's preview, nor when the
496/// screens cannot be listed.
497fn 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/// What the preview rule says of a pushed branch target, for the provenance
513/// record: `Evidence` when the gate passes on the plan's evidence, `Approved`
514/// when it passes on the person's approval, `NotRequired` for no interface
515/// change (or a repository without one). `None` when nothing has stood yet: a
516/// push the gate would hold, or whose files cannot be listed. The record is
517/// written once per commit, so it waits for the push that passes.
518/// What the preview rule knew about the push.
519#[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
547// --- mockup mode -----------------------------------------------------------
548
549/// Where a repository keeps its picked mockups: `git config
550/// amont.agent.preview.mockups`, else `docs/mockups` — the same key the
551/// application-landscape `ui-handoff` check reads.
552fn 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
559/// The screens a commit's branch carries picked mockups for: folders under
560/// the mockups directory that `merge-base(<default branch>, commit)..commit`
561/// changes and that hold a `*.dc.html` artboard at `commit`.
562///
563/// Always from the merge base, never from the upstream ref: after the first
564/// push the upstream already holds the artboards, and a range that starts
565/// there would find no screen and switch the check off (the PR #302 case).
566/// `Err` when the range cannot be established while the commit does carry
567/// artboards: a check that cannot look must not pass.
568pub 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    // Not `git()`: an empty diff (the commit IS its merge base, as when the
594    // default branch itself is tagged) is no screen, not a failure.
595    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// --- the store ------------------------------------------------------------
618
619#[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    /// The guide's path (the attestation's, before 2.21.0).
628    guide: String,
629    at: u64,
630    /// The `tool_use_id` of the marked question that listed it, or `-`.
631    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
676/// The names a person knows a checkout by, most specific first: its
677/// directory, the main worktree's directory (a task worktree is
678/// `app-wt-x`; the person calls it `app`), and the origin repository's name.
679fn 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
720/// Live registrations; lapsed ones are journalled and dropped here, on read,
721/// so expiry holds in a session that never restarts.
722fn 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
758/// One stored approval: repository, commit, when, channel, and the
759/// content id of the commit (`None` on a line written before trees were
760/// recorded, which then matches by commit only).
761type 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
788/// Whether the person approved this commit of this repository: exactly, or
789/// by content — a stored content id ([`crate::content_id::content_id`],
790/// which skips `docs/plans/`) equal to this commit's. A reword, an amend or
791/// a plan-only change keeps the tree, so the approval still stands.
792pub 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
808/// How an approved commit is approved.
809enum ApprovedBy {
810    /// The commit itself.
811    Exact,
812    /// A commit with the same content id: the approved commit's sha.
813    ByTree(String),
814}
815
816/// How the commit is approved, or `None` when it is not.
817fn 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
874/// One journal line under the rule's id. The soak reads these back.
875fn 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// --- registration ---------------------------------------------------------
892
893/// What `preview register` validated and printed.
894#[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    /// The guide, absolute.
901    pub guide: String,
902    /// `index.html` beside the guide, absolute; empty when read from a
903    /// release that rendered none.
904    pub page: String,
905    /// The page as a `file://` URL.
906    pub page_url: String,
907    /// What the marked question must show: `<checkout>@<sha7>`.
908    pub label: String,
909    /// Other labels that name the same commit: the main worktree's
910    /// directory and the origin repository's name, each `@<sha7>`.
911    pub aliases: Vec<String>,
912    /// What the marked question starts with, verbatim:
913    /// `[preview <id>] <label>`. Empty when read from a release that printed
914    /// none.
915    pub question_prefix: String,
916}
917
918impl Registered {
919    /// `attestation` repeats `guide` for one release, so a hook older than
920    /// the CLI that printed this still binds it.
921    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    /// Reads 2.21.0's shape, and the older one that carried only
937    /// `attestation`.
938    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
964/// Validate a preview for registration and render its guide to `index.html`
965/// beside it. Touches no store: the hook binds.
966pub 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![Mockup, direction B](artboard.png)\n![Built screen](after.png)\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
1097/// The text a marked question starts with: the marker, then the label the
1098/// person knows the commit by.
1099pub fn question_prefix(id: &str, label: &str) -> String {
1100    format!("[preview {id}] {label}")
1101}
1102
1103/// Before a Bash call that runs `preview register`: record, per
1104/// `tool_use_id`, when the call started. [`bind`] reads it back, so a line an
1105/// earlier clause printed — or a page an earlier call rendered — cannot be
1106/// passed off as this call's.
1107pub 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
1127/// Take (read and remove) the start [`stamp_register`] recorded for this
1128/// call, in milliseconds; `None` when there is none for this session.
1129fn 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
1141/// The JSON `preview register` printed: the last non-empty line of stdout,
1142/// where it prints itself compact on one line after anything an earlier
1143/// clause printed.
1144fn 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
1149/// Whether the printed registration is this call's own, and not a line an
1150/// earlier clause printed: its id starts with the commit's sha7, and its
1151/// page exists, names the full commit, and was written after the call began.
1152fn 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    // A filesystem that keeps whole-second mtimes is compared in seconds;
1172    // a finer one in milliseconds, so a page from earlier that second fails.
1173    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
1183/// After a Bash call: bind a `preview register` that ran as the last
1184/// command of the line, in the foreground, to this session and prompt.
1185///
1186/// Returns what the session must hear: a register that did not bind cannot
1187/// be approved by any answer, and in PR #302 that failure was silent — the
1188/// journal knew, the agent asked anyway, and the approval bound nothing.
1189pub 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    // Where the leading `cd`s left the shell, with `cwd_at`'s semantics.
1196    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    // Even a refusal is journalled under the repository the command named.
1218    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    // Taken now, whatever the outcome, so no stamp outlives its call.
1222    let started = take_stamp(&bash.session, &bash.tool_use_id);
1223    // The same command, alone: what the session runs to bind it.
1224    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        &reg.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(&regs);
1320    None
1321}
1322
1323/// A word as a shell reads it back: bare when it is plain, single-quoted
1324/// otherwise.
1325fn 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
1336/// The register clause at `at` is the last clause of the line: not piped,
1337/// redirected, backgrounded or inside a substitution, and every clause of
1338/// the line before it is joined to the next by `&&` or `;`. What those
1339/// clauses are does not matter: the printed JSON is checked against the
1340/// repository's HEAD and against this call ([`printed_by_this_call`]).
1341fn register_is_last(clauses: &[amont_agent_guard::shell::Simple], at: usize) -> bool {
1342    use amont_agent_guard::shell::Connector;
1343    // A substitution's clauses sit after every clause of the line itself.
1344    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    // `--help` prints usage and registers nothing: telling the session it
1363    // "did not bind" was a false alarm (seen 2026-09-29, the first session
1364    // on 2.23.0).
1365    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
1372// --- marked questions and approval ----------------------------------------
1373
1374/// The ids in a `[preview a,b]` marker, if the question carries one.
1375pub 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
1386/// Before an `AskUserQuestion` runs. Returns the refusal text when a marked
1387/// question arrives pre-answered and the rule is denying.
1388pub 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    // Mark the listed registrations as asked in this turn.
1425    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(&regs);
1435    }
1436    None
1437}
1438
1439/// After an `AskUserQuestion`: an answer to a marked question approves,
1440/// drops, or — unanswered — leaves its registrations pending. Approve takes
1441/// every pending registration whose id the marker lists; no label is needed.
1442pub 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        // No PreToolUse record: the question was never seen before it ran,
1453        // so nothing proves its answers were not pre-filled.
1454        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    // Four fields: written by a release that did not yet time the ask.
1473    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                // The id in the marker is enough: it begins with the commit's
1498                // sha7, which the person sees, and the registration it names is
1499                // bound to this session, this prompt and this question already.
1500                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                // Unanswered — a timeout, or an empty answer. Still pending, so
1516                // the typed fallback works for a person who was not watching.
1517                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
1533/// The repository of this session's registrations a marked question names,
1534/// for the journal; `-` when it names none (or several repositories).
1535fn 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
1549/// A typed approval: the whole prompt, trimmed, is one of these words.
1550pub 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
1559/// A new prompt: previews shown in an earlier turn either get the typed
1560/// approval right now, or lapse.
1561pub 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
1594// --- planned evidence (ADR-0028) ------------------------------------------
1595
1596/// Where approved plan bodies are recorded: `<state>/plan-approved/<sha>`.
1597fn plans_approved_dir() -> Option<PathBuf> {
1598    Some(journal::dir()?.join("plan-approved"))
1599}
1600
1601/// After an `ExitPlanMode`: when the person approved the plan, record its
1602/// canonical body sha ([`crate::plan_text::body_sha`]), hashed from the
1603/// file as it is now.
1604///
1605/// Like the `approvals` file, this is a workflow aid an agent could write
1606/// to, not proof: every pass it grants is journalled `evidence`.
1607pub 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
1643/// Whether the person approved a plan whose canonical body has this sha.
1644fn 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
1650/// The reason a plan's `## Preview` section declares: the section's first
1651/// non-empty line, when it starts with `evidence:` and gives one that is not
1652/// the template's `<…>` placeholder, left unedited. Read from
1653/// the canonical body, the part the person approved; a heading inside a
1654/// code fence is text.
1655pub 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
1667/// The first plan the branch adds: `(commit, path)` of the earliest
1668/// `docs/plans/<name>` added in `from..commit`.
1669fn 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
1696/// Whether the plan this branch landed, as the person approved it, says
1697/// the evidence is enough (ADR-0028, `work.preview-unless-planned-evidence`).
1698///
1699/// All must hold: the default branch is known (`stale::remote_of` +
1700/// `stale::default_base`) and shares history with `commit`; the branch adds
1701/// a plan under `docs/plans/` since that merge base, and it is not a
1702/// pointer (`canonical:`); that plan, read at the commit that added it, has
1703/// a canonical body sha in `plan-approved/`; and its `## Preview` section
1704/// starts with `evidence: <reason>`. The result is the journal detail of a
1705/// pass (the plan, the commit and the sha), and the gate journals it as
1706/// `evidence`. Any miss is `None`, silent: the approval path
1707/// decides, as before.
1708fn 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
1732// --- publication ----------------------------------------------------------
1733
1734/// Before a push runs: remember, per `tool_use_id`, where each branch
1735/// destination stood, and whether the commit was an approved UI change
1736/// (this module's policy), through [`amont_agent_guard::push_record`]. `push-published`
1737/// reads it back after the push.
1738pub 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    // A land's judged sha is kept for every repository, gated or not: the
1766    // retry check (`land-retry`) compares it with what the land's receipt
1767    // says it pushed, and the implementation review covers more repositories
1768    // than the preview does.
1769    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
1807/// Delete per-call scratch nobody came back for.
1808pub 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    // Reading prunes lapsed registrations and journals them.
1827    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            // No reason.
1845            "## Preview\n\nevidence:\n",
1846            // The template's placeholder, left as it was.
1847            "## Preview\n\nevidence: <why the person will have nothing left to judge: the visible change\nis one this plan decides>\n",
1848            // Not the first non-empty line.
1849            "## Preview\n\nThe screenshots.\nevidence: decided\n",
1850            // Inside a code fence.
1851            "```md\n## Preview\n\nevidence: an example\n```\n",
1852            // Another heading.
1853            "## Previews\n\nevidence: decided\n",
1854            "",
1855        ] {
1856            assert_eq!(preview_evidence(&plan(held)), None, "{held}");
1857        }
1858        // Front matter is not the body the person approved.
1859        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            // Rendered files under a test-named directory are routes.
1910            "app/routes/test/index.tsx",
1911            "app/tests/page.tsx",
1912            "src/e2e/Editor.tsx",
1913            "src/vitest.tsx",
1914            // Near misses of the test names.
1915            "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    /// application-landscape #301 (2026-09-28), which was advised although
1938    /// it changed only comments under app/.
1939    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        // Two commits' diffs back to back, as `git log -p` prints them: the
1953        // second header is not a changed line.
1954        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            // A real JSX change.
1976            "@@ -20 +20 @@ export function Toolbar() {\n-      <Button>Save</Button>\n+      <Button>Save all</Button>\n",
1977            // A comment and a line of code together.
1978            "@@ -1,2 +1,2 @@\n-// old\n+// new\n-const gap = 4\n+const gap = 8\n",
1979            // Code after a closed block comment, on the same line.
1980            "@@ -1 +1 @@\n-/* a */ foo()\n+/* b */ foo()\n",
1981            "@@ -3 +3 @@\n- */ export const x = 1\n+ */ export const x = 2\n",
1982            // CSS's universal selector starts with `*` and is not a comment.
1983            "@@ -1 +1 @@\n-* { margin: 0 }\n+* { margin: 4px }\n",
1984            "@@ -1,0 +1 @@\n+* {\n",
1985            // No hunk: a binary file or a mode change.
1986            "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        // duro-design-system/packages/cli: `@duro-app/ui` only as a dev and
1996        // an optional peer dependency. A CLI; renders nothing.
1997        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        // duro-design-system/packages/ui: a required react peer.
2004        assert!(package_is_ui(
2005            r#"{"peerDependencies":{"react":"^19","react-strict-dom":"*"}}"#
2006        ));
2007        // An app: react in dependencies.
2008        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        // Unreadable: doubt counts.
2014        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}