Skip to main content

amont_agent_process/
push_preview.rs

1//! `push-preview` — a push that would publish interface changes no approved
2//! preview covers (ADR-0028, `work.preview-unless-planned-evidence`).
3//!
4//! ```sh
5//! git push -u origin feat/settings-toggle   # app/routes/settings.tsx changed
6//! ```
7//!
8//! Interface regressions kept reaching pull requests and deployed sites after
9//! the agent had checked its own screen in a browser. The person's eyes are
10//! the check that does not share the agent's blind spots, and localhost is
11//! where rejecting a screen costs one sentence instead of a fix pull request
12//! and a release. So a UI-changing commit is published only after the person
13//! approved THAT commit — registered with `amont-agent preview register` and
14//! answered on a marked question (see `crate::preview`).
15//!
16//! ## Where it speaks
17//!
18//! `examine` fires on the shape of a push. `confirm` resolves what the push
19//! would publish (`amont_agent_guard::push_target`, a documented subset), and fires only
20//! when all hold: the repository has a user interface (a `dev` script, or
21//! `git config amont.agent.push-preview.ui true`), a pushed branch carries a
22//! file under `app/`, `src/`, `web/` or a `.tsx/.jsx/.css/.html` whose
23//! nearest `package.json` looks like an interface and whose diff is not
24//! comments only (`crate::preview`; tests and test-tool configs never
25//! count: `*.test.*`, `*.spec.*`, `__tests__/`, and an unrendered file
26//! under `e2e/`, `tests/`, `test/` or named `vitest.*`, `jest.*`,
27//! `playwright.*`, `cypress.*`), the plan the branch landed does not
28//! declare the preview's evidence (an approved `## Preview` section whose
29//! first line is `evidence:`; never for a picked mockup), and that commit
30//! has no approval. A push this guard cannot read is journalled with
31//! its shape and passes — except under `deny`, where it is held so that
32//! `--all` is not the way around the gate.
33//!
34//! The rule runs BEFORE the command, so it speaks of what the push would do.
35//! Whether a push actually published anything is `push-published`'s record.
36
37use crate::preview::Needs;
38use amont_agent_guard::rules::{
39    Confirmed, Context, Evidence, Examine, Finding, Rule, Stance, Trend,
40};
41use amont_agent_guard::shell::Parsed;
42
43pub const RULE: Rule = Rule {
44    id: "push-preview",
45    prevents: "A push that publishes interface changes no approved preview covers",
46    irreversible: false,
47    // Advises from the start, `confirm`-backed like `push-preflight`, and
48    // under review: the soak decides between deny, advise, or retiring the
49    // gate while keeping the verification loop (ADR-0023, carried forward
50    // by ADR-0028).
51    default_stance: Stance::Advise,
52    max_stance: Stance::Deny,
53    evidence: Evidence {
54        per_1000: 0.0,
55        measured: "2026-09-28",
56        trend: Trend::Rare,
57    },
58    examine: Examine::Legacy(examine),
59    confirm: Some(confirm),
60};
61
62fn examine(parsed: &Parsed) -> Option<Finding> {
63    let cmd = amont_agent_guard::push_target::find(parsed)?;
64    if cmd.is_dry_run() || cmd.has_short('n') {
65        return None;
66    }
67    // A push to the default branch is amont's `branch-protect` refusal
68    // before any preview could matter, and an ordinary shape this rule must
69    // stay silent on.
70    if cmd
71        .operands()
72        .iter()
73        .skip(2)
74        .any(|w| to_default_branch(&w.text))
75    {
76        return None;
77    }
78    Some(Finding {
79        reason: "This push would publish interface changes that no approved preview covers."
80            .to_string(),
81        remedy: "Verify the final commit in a real browser, serve the clean worktree, then run \
82                 `amont-agent preview register --url <url> --guide <guide.md outside the worktree>` \
83                 in the foreground as the last command of its line (see `amont-agent preview --help` for the guide's \
84                 sections), open the app at the first step's state and the rendered page, and, in the same turn, ask \
85                 the marked question starting with its `question_prefix` (`[preview <id>] <label>`, options exactly \
86                 Approve / Request changes / Hold). \
87                 Push after the person approves. A push shape this guard cannot read is held under deny: \
88                 push the branch explicitly (`git push <remote> <branch>`)."
89            .to_string(),
90        span: cmd.at..cmd.end,
91    })
92}
93
94fn to_default_branch(refspec: &str) -> bool {
95    let dst = refspec.rsplit(':').next().unwrap_or(refspec);
96    let dst = dst.strip_prefix('+').unwrap_or(dst);
97    let dst = dst.strip_prefix("refs/heads/").unwrap_or(dst);
98    matches!(dst, "main" | "master")
99}
100
101fn confirm(ctx: &Context, _finding: &Finding) -> Confirmed {
102    let Some(cmd) = amont_agent_guard::push_target::find(ctx.parsed) else {
103        return Confirmed::No("the push could not be read a second time");
104    };
105    let stance = amont_agent_guard::stance::resolve(&RULE);
106    match crate::preview::needs_preview(ctx, cmd, stance) {
107        // A commit that carries a picked mockup is held, not advised: in PR
108        // #302 the advice was read and the push went out anyway (decided
109        // 2026-09-29, plan mockup-fidelity-checks).
110        Ok(Needs::Unapproved { mockup: true }) => Confirmed::YesAt(Stance::Deny),
111        Ok(Needs::Unapproved { mockup: false }) => Confirmed::Yes,
112        // Held for not being read, and said so: "no approved preview
113        // covers" sent a session after an approval it did not need.
114        Ok(Needs::Unreadable(shape)) => Confirmed::YesSaying {
115            floor: None,
116            ceiling: None,
117            reason: format!("This push cannot be read, so under deny it is held: {shape}."),
118            excerpt: format!("unreadable: {shape}"),
119            // Not the preview steps: nothing here is known to need one.
120            remedy: Some(
121                "Push a branch or an existing tag by name, in its own command \
122                 (`git push <remote> <ref>`); this guard then reads what it publishes. \
123                 A tag needs no preview."
124                    .to_string(),
125            ),
126        },
127        Err(why) => Confirmed::No(why),
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use amont_agent_guard::shell::lex;
135
136    fn fires(command: &str) -> bool {
137        examine(&lex(command)).is_some()
138    }
139
140    #[test]
141    fn a_push_is_examined() {
142        assert!(fires("git push -u origin feat/x"));
143        assert!(fires("cd ../wt && git push origin HEAD:feat/x"));
144        assert!(fires("git -C ../wt push origin feat/x"));
145    }
146
147    #[test]
148    fn a_dry_run_and_other_commands_are_not() {
149        assert!(!fires("git push --dry-run origin feat/x"));
150        assert!(!fires("git push -n origin feat/x"));
151        assert!(!fires("git pull"));
152        assert!(!fires("echo git push"));
153        assert!(!fires("git push origin main"));
154        assert!(!fires("git push origin HEAD:refs/heads/master"));
155    }
156
157    #[test]
158    fn the_span_is_the_push_clause() {
159        let command = "npm test && git push -u origin feat/x";
160        let f = examine(&lex(command)).expect("fires");
161        assert_eq!(command[f.span].trim(), "git push -u origin feat/x");
162    }
163}