# Preview approval
`push-preview` and `push-published` carry ADR-0028 (`work.preview-approval`,
fleet decision corpus, replacing ADR-0023): a commit that changes a user
interface is published only after the person approved **that commit**, seen
on localhost — **unless the plan the person approved declares the evidence
is enough** ([below](#planned-evidence), rule
`work.preview-unless-planned-evidence`).
The rule exists because interface regressions kept reaching pull requests and
deployed sites after the agent had checked its own screen. The person's eyes
are the check that does not share the agent's blind spots, and localhost is
where rejecting a screen costs one sentence instead of a fix pull request and
a release.
## The flow
The person works on several projects at once and does not remember where
this one stood. A bare "[preview f0223d3] Ship website-builder@f0223d3?"
gives them nothing to decide with, so a preview reaches them as a **guide**
(fleet rule `work.preview-is-guided`), three ways at once: the brief in the
terminal, a local page, and the app already open in their browser.
1. **Verify.** The agent drives the changed route in a real browser, checks the
console and network, and takes before and after screenshots.
2. **Write the guide** — markdown, **outside the worktree**
(`~/.claude/amont-agent/attestations/<sha>/guide.md` by convention, the
screenshots beside it), because a file inside it, or the page rendered
beside it, would dirty the commit it describes. The format is
[below](#the-guide).
3. **Serve and register.** From the clean worktree, at the commit to be
pushed:
```sh
amont-agent preview register --url http://localhost:5173/settings \
--guide ~/.claude/amont-agent/attestations/abc1234/guide.md
```
Run it in the foreground as the **last command of its line**. Anything
may come before it, joined by `&&` or `;`:
```sh
cd ~/Developer/app-wt-settings && npm run build && amont-agent preview register --url … --guide …
```
It refuses a dirty tree, a non-commit `HEAD`, a guide inside the worktree
and a guide missing a required section (exit 1; stderr lists exactly what
is missing). Otherwise it renders the guide to **`index.html` beside it**
and prints one JSON object, compact on one line:
| field | what it is |
|---|---|
| `id` | the registration; it starts with the commit's sha7 |
| `repo`, `commit` | the checkout's top level and its full `HEAD` |
| `url` | where the clean worktree is served |
| `guide`, `page`, `page_url` | the guide, its rendered `index.html` (absolute), and that page as a `file://` URL |
| `label`, `aliases` | the names a person knows the commit by: the checkout's directory, then the main worktree's directory and the origin repository's name, each `@sha7` |
| `question_prefix` | `[preview <id>] <label>`: what the marked question starts with, verbatim |
| `attestation` | `guide` again, for hooks older than 2.21.0 |
`--open` also hands the page
to the platform opener (`open`, `xdg-open`, `cmd /c start`) without
waiting; failing to open never fails the command.
The `PostToolUse` hook binds that output to the session and to the
person's current prompt, in the repository the command ran in — the
leading `cd`s followed — which must still be the printed repository at
the printed `HEAD`; the journal line names the page. The JSON is read
from the **last non-empty line** of stdout, so what earlier clauses
print does not matter. The register must be the last clause: one that
is followed by anything (`register && echo`), piped (`| tee`),
redirected, inside `$(…)`, after `||`, or in the background is not
bound, and the journal says why.
A line an earlier clause printed cannot pass for the register's own
(`printf '<json>'; amont-agent preview register --bad`): the
`PreToolUse` hook stamps when the call started, in
`previews/registers/<tool_use_id>`, and the bind requires that the id
starts with the commit's sha7 and that the page exists, names the full
commit and was written after that stamp. A call with no stamp is not
bound. Stamps nobody came back for are swept after a day.
4. **Open the app and the page.** Before asking, the agent opens the app in
the person's browser **at the state the first step reaches** (the panel
already open, the form already filled) and the rendered page beside it.
That is the agent's job — the `worktree-task` skill's F7, with Claude in
Chrome — not this binary's: `register` only renders the page and, with
`--open`, opens it.
5. **Ask, in the same turn.** The brief first — the guide's sections, in the
terminal — then one `AskUserQuestion` whose text **starts with the
register's `question_prefix`** (`[preview <id>] <label>`), with options
exactly `Approve`, `Request changes`, `Hold` — no "(Recommended)"
suffix.
6. **Push after `Approve`.** The approval covers that commit; a new commit,
amend or rebase needs a new preview.
`--attestation <file>` is accepted for 2.21.0 only, as a deprecated alias of
`--guide`: the file is read as the guide and refused unless it is one.
## The guide
Markdown with these five **H2** sections. Headings match with emoji,
punctuation and case aside (`## 📍 Where we are:` is `Where we are`); the
page shows them in this order, then any other section the guide adds.
| `Where we are` | project, branch and plan, and why the person is asked. Its first line is the page's title | present, not empty |
| `What you should see` | in their words; "nothing new" when that is the point | present, not empty |
| `Try it` | numbered steps: the exact URL, each click named by its visible label and position, and after each step what should appear | at least one numbered step (`1.`) and one http(s) URL |
| `Reference` | before and after screenshots | present, not empty |
| `Already checked` | what the agent verified, and what to look at especially | present, not empty |
A full example (`guide.md`, with `before.png`, `after.png` and `panel.png`
beside it):
```markdown
# Settings: Save moves to the header
## 📍 Where we are
duro-app, branch `feat/settings-save`, plan **Settings panel** (phase 2 of 3).
You are asked because the Save button moved; nothing else on the page changed.
## What you should see
The **Save** button now sits in the header, top right, instead of at the
bottom of the form. Saving works exactly as before.
## 👉 Try it
1. Open http://localhost:5173/settings
- You should see the Settings form, with **Save** in the header, top right.
2. Change **Display name** (first field) to anything.
- **Save** turns from grey to blue.
3. Click **Save**, top right.
- A green toast "Saved" appears bottom left, and **Save** is grey again.

## Reference


## Already checked
- `/settings` at 1280 and 390 px wide: no console error, no failed request.
- Keyboard: Tab reaches **Save** right after the page title.
- Look especially at the narrow window: the button must not cover the title.
```
The page is one self-contained file: inline CSS readable in light and dark
(`prefers-color-scheme`), no script, no external asset. Its title is
`repo@shortsha — <first line of Where we are>`; under it, a large **Open the
app** link to `--url`, then the sections. Images (``,
relative to the guide's directory) render inline; a missing one is a warning
on stderr, not a refusal. When one image's file name says `before` and
another's says `after`, the two show side by side (`class="pair"`) at the top
of `Reference`. The converter is hand-rolled for this subset — headings,
paragraphs, bullet and numbered lists with one nested level, fenced code,
quotes, **bold**, `code`, links, images and bare URLs — and escapes
everything else; a link whose scheme is not http(s), mailto or file is text.
No markdown crate: amont-agent is on a trust path and a page only the person
reads does not clear `change.dependency-bar`.
## Mockup mode
When the previewed branch commits a picked mockup — a `*.dc.html` artboard
under `docs/mockups/<screen>/` (or the directory `git config
amont.agent.preview.mockups` names; the application-landscape `ui-handoff`
PR check reads the same key) — the guide must prove fidelity
(`handoff.prove-fidelity`, ADR-0016). Added to the five sections:
- under `## Reference`: the screen's path, a `Viewport: 1120px · light` line
(the width and theme both images were taken at), and the picked artboard
beside the built screen as image files next to the guide:
`artboard.png` and `after.png` (`mockup` and `live` are read the same;
states pair by suffix, `artboard-empty.png` with `after-empty.png`);
- a section `## Differences from the mockup`: `None`, or one bullet per
difference, each `- fixed: …` or `- deliberate: <reason>`.
The range is the branch's own commits, from its merge base with the default
branch — never from its upstream, which after a first push already holds the
artboards. Artboards on a branch whose base cannot be resolved are a refusal,
not a skip. A later branch that only edits a screen whose artboards are
already on `main` is not in mockup mode; the PR check asks it for the
side-by-side or `Mockup: none — <reason>`.
The images are copies of the proof PNGs committed next to the artboards, so
copying them beside the guide comes first, **as its own command**: a
register chained after a `cp` is not bound (see below). Two PNGs whose widths
differ by more than a tenth are a warning. On the page, each pair shows full
width at the same scale, each image links to its full-size file, and an
**Overlay** toggle lays the built screen over the mockup at half opacity.
A branch in mockup mode that is pushed without a bound approval is **held**
(deny) rather than advised, unless the rule is set to `observe`.
## What approves
| the person… | result |
|---|---|
| picks `Approve` on the marked question | every pending registration whose id the marker lists is approved |
| picks `Request changes` or `Hold` | the previews are dropped |
| does not answer (a timeout) | still pending |
| types `approve`, `ship`, `lgtm` or `looks good` as the next prompt, after a marked question was asked | approved |
| types anything else next — including `yes`, `go`, `ok`, `continue` | the previews lapse |
The id in the marker is what approves: it begins with the commit's sha7,
which the person sees, and the registration it names is already bound to
the session, the prompt and the question. The label is for the person; a
question without it still approves (it did not before 2.30.0, and the
person was asked twice). A marker that names no pending id approves
nothing. A `register` that did not bind — not the last command, run in the
background, with no `PreToolUse` stamp, or whose output does not match
HEAD or this call — says so right after it runs, with the command to run
and the question to ask.
Unmarked questions are ignored, so a release question answered "Approve"
approves no preview, even in the same turn. Another session's answers never
touch this session's previews. Registrations and approvals lapse after a day,
checked whenever they are read.
`AskUserQuestion` accepts `answers` in its input, so a model could pre-answer
its own question. The `PreToolUse` hook records, per `tool_use_id`, whether a
marked question arrived pre-answered; such a question never marks its
previews as asked (so no answer to it can approve), the `PostToolUse` side
checks the same record again, and under `deny` the question is refused before
it runs.
## When the rule speaks
`push-preview` fires on a push only when all of these hold:
- the repository has a user interface: a `dev` script in `package.json` at
the root or in `web/`, or `git config amont.agent.push-preview.ui true`
(`false` opts a repository out);
- a pushed branch carries an **interface change** (below);
- the plan the branch landed does not declare the evidence
([below](#planned-evidence));
- that commit has no approval.
### Planned evidence
Some previews leave the person nothing to judge: they already decided the
only visible change when they approved the plan (ADR-0028). The exemption
is declared **in the plan's body**, so the person decides it when they
approve the plan:
```md
## Preview
evidence: the only visible change is the 28px small controls, decided in this plan.
```
Two hooks make it checkable:
1. **The approval record.** A `PostToolUse` hook on `ExitPlanMode` reads
`tool_input.planFilePath` when it runs (the person may have edited the
plan) and, when the result has the approve shape (`tool_response` an
object carrying `plan`, no `is_error`), writes the plan's canonical body
sha ([plan review](plan-review.md): front matter and review sections
left out) to `~/.claude/amont-agent/plan-approved/<sha>`, journalled
`plan-approved`.
2. **The check, at the push.** For a branch whose interface change carries
no picked mockup (mockup mode always asks; a screen list that cannot be
taken is held as before), the evidence stands when all hold:
- the default branch is known (`checkout.defaultRemote` or the single
remote, then its `HEAD`, `main` or `master`) and shares a merge base
with the pushed commit;
- the branch adds a plan under `docs/plans/` since that merge base (the
first one added), and it is not a pointer (`canonical:`);
- that plan, **read at the commit that added it**, has its canonical sha
in `plan-approved/`;
- its `## Preview` section (outside any code fence) has a first
non-empty line starting with `evidence:` and a reason.
A pass is journalled `evidence`, with the plan's path, the commit and the
sha. Any miss — no plan, a plan on `main` before the branch, a section
added in a later commit, an unapproved body — falls through to the
approval path, unchanged.
The record has the trust of the `approvals` file: a workflow aid an agent
could write to, not proof. That is why every pass is journalled. The
exemption lapses when the screenshots show a visible change the plan did
not decide; that part is the skill's (`worktree-task` F7) to enforce: the
hook can only see that the person approved the declaration.
### What counts as an interface change
The repository-level decision above says whether a repository is gated at
all. Within a gated repository, one changed file counts only when all hold:
1. **The path.** It is under `app/`, `src/` or `web/` (at any depth), or it
is a `.tsx`, `.jsx`, `.css` or `.html` file.
2. **The package.** Its nearest `package.json` — walking up from the file
to the repository root, read from the **pushed commit** (`git show
<commit>:<path>`), not the working tree — has a `dev` script, or lists
`react`, `react-dom`, `react-native`, `react-strict-dom` or
`@duro-app/ui` in `dependencies` or as a **required** peer dependency.
`devDependencies` and optional peers (`peerDependenciesMeta.<name>.optional`)
do not count: duro-design-system's `packages/cli` carries `@duro-app/ui`
exactly there, and renders nothing. A monorepo whose root has a `dev`
script is therefore judged package by package. No `package.json` at all
on the way up, or one that does not parse: the path decides.
3. **Not comments only.** For a `.ts`, `.tsx`, `.js`, `.jsx` or `.css`
file, the diff (`git diff -U0 <base>..<commit> -- <file>`; on a new
branch with no base, each unpublished commit against its first parent)
is read. When every added and removed line that is not blank is a comment
line — starting with `//`, `/*`, `*`, `*/`, or a JSX `{/* … */}` — the
file does not count. A line with code after a closed comment (`/* a */
foo()`), a `*` line that reads like code (CSS `* {`), a binary file or a
diff that cannot be taken all count. Any doubt counts.
`git config amont.agent.push-preview.ui` still overrides the repository
decision; it does not change how files are judged.
A push to `main` or `master` is left to amont's `branch-protect`.
### Supported push shapes
v1 reads an explicit remote plus explicit refspecs — `git push -u origin
feat/x`, `git push origin HEAD:feat/x`, several refspecs — with `-C <dir>`
and a leading `cd` honoured. A destination under `refs/tags/` is excluded;
one under `refs/heads/` is judged, including a tag's commit pushed onto a
branch.
Everything else is **unresolvable** and journalled with its shape: a bare
`git push`, a remote with no refspec (git's `push.default` would decide),
`--all`, `--mirror`, `--tags`, deletes, glob refspecs, a URL as the remote,
a word from a substitution, an unknown flag. Under `advise` an unresolvable
push passes with a journal note; under `deny` a push to a UI repository is
held — `--all` is not the way around the gate. The shapes the journal
collects decide what the next version reads.
## What `push-published` records
It never speaks. Before a push to a UI repository the hook remembers where
each branch destination stood (keyed by `tool_use_id`); after it, one
`git ls-remote` per destination, and one journal line:
| `published-approved` | the remote now holds an approved UI commit it did not hold before |
| `published-unapproved` | the same, without an approval — the gap `advise` leaves |
| `published-no-ui` | published, nothing a person could preview |
| `already-present` | the remote held the commit before the push |
| `unverified` | the remote does not hold it, or could not be asked |
## What this is not
A registration is the agent's attestation of worktree, commit and URL, and
its guide is the agent's account of what it checked.
Nothing here proves the browser served that commit or that the person
looked. It covers pushes made through Claude Code, not pushes typed in a
shell, and it is a workflow aid, not a Git enforcement boundary.
## Reading the soak
```sh