---
name: pr-review
description: Adjudicate open pull requests as a maintainer. `/pr-review` renders a queue of every open PR you can act on, one verdict each (stamp / judge / route / blocked), grouped by owner and domain and listed in PR number order; `/pr-review N` renders one PR as that row expanded. Batch-approves and squash-merges the stamp set with a per-PR preflight, routes what isn't yours with the defect attached, unblocks conflicts, and refreshes stale reviews — one data model, one act layer, two renderings.
argument-hint: "[N] [--owner me|any|<role>|@login] [--domain docs,blog,…] [--verdict stamp,judge,route,blocked] [--author app/workprentice,pulumi-bot] [--since 7d] [--include-infra] [--strict-stances] [--board|--terminal] [--ai|--no-ai] [--act …]"
user-invocable: true
---

# Pull request review queue

The maintainer side of the review pipeline. CI posts the pinned review (the v3 author card + reviewer brief, or a legacy `<!-- CLAUDE_REVIEW N/M -->` monolith); this skill reads it as the source of truth for every open PR at once, computes one verdict per PR, and turns the approver's decisions into a batch of GitHub actions.

Deterministic scripts do the collecting, judging-by-rule and rendering; the model writes only the judgment calls on rows that earned one; a shared act primitive executes. Everything lives in `scripts/review-v3/` (covered by `make test-review-pipeline`):

| Step | Script | Model? |
|---|---|---|
| collect | `collect.py` → `.pr-review-queue.json` | no |
| analyze | `analyze.py` (verdict, reason codes, cross-PR) | no |
| judge | this skill, judge rows only → `.pr-review-judgments.json` | **yes** |
| render | `render.py` (board Artifact / detail / `--terminal`) | no |
| act | `act.py` (plan → preview → execute) — **only on an explicit `--act`** | no |

## Usage

```text
/pr-review                                  # the queue: my lanes, rendered as the board Artifact
/pr-review 21598                            # one row, expanded (findings, preview links, actions)
/pr-review --verdict stamp --terminal       # just the stampable set, with the act command
/pr-review --owner any --domain blog        # what marketing has to decide
/pr-review --act --stamp 21550,21577 --route 21431:@cnunciato --unblock 21525

flags: --owner me|any|<role>|@login   --domain docs,blog,website,programs,infra,frontend,other   --include-handed-off
       --verdict stamp,judge,route,blocked   --author app/workprentice,pulumi-bot,any
       --since 7d   --include-infra   --strict-stances   --board|--terminal   --ai|--no-ai
       --act --stamp N[:merge|:no-merge],N   --route N[:@target]   --request-changes N   --chain C1
             --unblock N   --fix N   --close N [--superseded-by M | N:M]   --refresh N   --rerun N   --rerun-checks N
             --render N   --deploy N   --reason "TEXT" | "N=TEXT"   --approve-note "one sentence"
             [--merge-humans] [--no-merge] [--force] [--dry-run]
```

Every `--act` flag that takes a PR is repeatable (`--close 1 --close 2`); `--stamp` also takes a comma list, and its `:merge` / `:no-merge` suffix is per PR. `--reason "N=text"` scopes a reason to one PR; bare text is accepted only when exactly one step in the plan takes a reason. `--superseded-by M` needs exactly one `--close`; with several, say which as `--superseded-by N:M`. `--approve-note` is one sentence appended to every approval body in the batch. `act.py --help` is authoritative.

`--board` is the default, always: render the page and publish it with the Artifact tool. Only an explicit `--terminal` in the person's own invocation prints the table instead — a queue with no judge rows in it is still a board, and "the rows were easy to describe" is not a reason to fall back to text. Both are renderings of the same `queue.json`; there is no separate code path.

## How a run ends

A run invoked without `--act` ends at the published board: collect → analyze → judge → render → publish → stop. The last thing you say is one line — the board link and the command the page composed. Do not plan, do not ask whether to plan, and do not ask whether to execute. The board *is* the deliverable. The person reads each row, sets the decisions themselves, and copies the command the page composed for them; **that copy-paste is what authorizes the writes**, and a batch that reaches GitHub without it has skipped the reading the board exists to make possible.

**A write is never the default answer to a question.** `AskUserQuestion` may carry one — a single named action on a row the person just walked through is a fair thing to offer, and never offering it is its own kind of unhelpful — but three things have to hold together:

1. **It is neither the first option nor the recommended one.** The default sits on the choice that changes nothing, because the cost of a mis-click is asymmetric: the wrong read-only answer costs a round trip, the wrong write answer costs an approval nobody can take back.
2. **The label says the write out loud, and how far it reaches.** "Approve and squash-merge #21622" is a label. "Proceed", "Yes, go ahead" and "Run it" are not: from the option alone, the person has to be able to tell that GitHub is about to change and by how much.
3. **They have already seen the thing itself** — that row, its findings, its diff — in this turn, not just a count of rows. A question is consent to what was read; it cannot manufacture the reading.

A batch fails the third one by construction. "Approve & merge these 7" is one keystroke over seven PRs nobody opened, so the stampable set goes to the board and comes back as a command the person invokes, never as a menu option. Scale is the line: one reviewed row may be a question, a set is always the board.

The corollary, so this doesn't swing back the other way: the question you must not ask at all is *"want me to plan it?"* Planning is read-only, so proposing it wastes a round trip — and on a queue run it isn't yours to propose in the first place, because step 5 starts with the person, not with you. Publish and stop; their next invocation is the yes.

## Config: `~/.pr-review.yml`

Local, never committed. It says which routing lanes are *yours*; routing itself (lane → owning team) comes from `.github/review-routing.yml`.

```yaml
me: [docs, infra, frontend, other]   # lanes I approve for; blog/website route to marketing
stamp_max_lines: 40                  # a diff at or over this is never a stamp
stale_date_days: 3                   # a blog `date:` older than this is stale
link_fixes: mine                     # mine (default) | route: a link-only diff is yours whatever its lane
```

`.github/review-routing.yml` carries `link_only: { approval: any-team }`, which says any review team may approve a diff whose every changed line differs only in a link. Those rows get `gate:any-team` and are any approver's, and the Sentinel agrees, so `link_fixes` only matters where that policy is `lane`. `link_fixes: mine` is then for the redirect sweeps: a diff where every changed line is the same sentence with only a link rewritten (text or target; a word's casing may change too) gets `shape:link-only` and skips the lane check, since the lane owner's review buys nothing there. It's the queue's own bar, narrower than the Sentinel's mechanical bar, which counts any link edit as substantive. The row still has to clear the stamp bar; a sweep with open ⚠️ rows is a judge row with "approve as-is" as its primary.

### What makes a row "mine"

Ownership is per **lane** (a content domain), never per author or per repo area, and it is computed, not declared on the PR:

1. **Every changed path gets a subject** from `classify_path()` in triage-classify.py — the same classifier that writes the `domain:*` labels, so the board and triage can't disagree. The closed set is `docs`, `blog`, `website`, `programs`, `infra`, `frontend`, `other`; a path nothing else claims (`.claude/`, `styles/`, generated `data/`) lands in `other`.
2. **The PR's domains are the union of its files' subjects.** A PR that touches a blog post and a doc is in both lanes at once.
3. **Every row has a lane team on the hook.** `resolve_lanes` always comes back with at least one required role: `none` cells are a config error in `.github/review-routing.yml`, and a mechanical docs/blog edit routes to its lane exactly like a substantive one — `mechanical` skips the *model* review, never the approver. The `gate:none` reason code that used to mark an ungated row is gone with the state it described.
4. **Otherwise a row is mine if any of its domains is in `me`.** Intersection, not containment: one file in a lane you own makes the whole PR yours to approve, because the queue is asking "can I act on this?", not "is all of it mine?". With `me: [docs, infra, frontend, other]`, that means everything except a PR whose files are *entirely* blog and website.
5. **The rest routes**, and `.github/review-routing.yml` picks the target: the matrix maps subject × change type (mechanical vs substantive) to a role, the role maps to a GitHub team, and the team is the target unless GitHub says it doesn't exist (`route:no-team`, which falls back to that role's `sla.escalate_to` person; `route:team-unverified` means the token couldn't read teams, so the configured team is used unchecked). Blog is the blog team's, website and frontend marketing's, docs and programs the docs guild's, infra and other tools'.
6. **Two overrides, both visible as reason chips.** `link-fixes:mine` makes a `shape:link-only` diff yours whatever lane it sits in (config above). `handed-off:@who` takes a row out of your list entirely — someone else is already requested as reviewer — and parks it under "Waiting on others" until they act.
7. **`--owner` reinterprets step 4 only.** `me` is the default, `any` drops the lane check, and a role name or `@login` substitutes someone else's lanes so you can see the queue through their eyes.

The `owner:<domain>:<role>` chip on each row shows the computation, one chip per domain the PR touches. The chips that explain why a row is on your board at all — `gate:any-team`, `link-fixes:mine`, `route:no-team`, `route:team-unverified` — render as plain words, with the code kept in the chip's tooltip.

Missing file: the lanes come from your GitHub team memberships — every team in `.github/review-routing.yml` you belong to contributes the subjects its role owns, so a first run with no local setup already shows route rows. Only when those memberships can't be read (a token without `read:org`) does every lane count as mine, and the analyzer says which of the two happened. `python3 scripts/review-v3/pr_review_config.py` prints the effective config. The AI-suspect allowlist stays at `~/.claude/pr-review/ai-suspect-authors.txt` (see `pr-review:references:trust-and-scrutiny`).

## The flow

Run the scripts from the repo root. Each is idempotent; re-running after acting updates the board rather than rewriting it.

### 1. Collect

```bash
python3 scripts/review-v3/collect.py --out .pr-review-queue.json [--pr N] [--author A] [--since 7d] [--ai|--no-ai]
```

Every open, non-draft PR (an explicit `--pr N` also collects a draft): title, author with the trust axes and risk tier, labels, files with patches, head/base SHAs, `mergeable_state` (re-asked while GitHub still says `unknown`), the check rollup, reviews, requested reviewers, the parsed pinned review (`review-worklist.py`, both surfaces), its `REVIEW_STATE`, triage's `<!-- TRIAGE_PROSE -->` comment, the reviewed-head SHA from `<!-- CLAUDE_REVIEW_HEAD … -->`, and the preview URL plus per-page links. Responses that only change when the PR does are cached under `/.pr-review-cache/<pr>/` per (head SHA, updated_at).

GitHub access: `gh` when installed, otherwise the REST API with `GITHUB_TOKEN` / `GH_TOKEN` (a Claude Code web session has the token but not `gh`). `--snapshot-dir DIR` reads endpoint JSON from files instead — the test backend, and the way to hand the scripts data fetched by the GitHub MCP tools: write each response to `DIR/GET/<endpoint path>.json` (`gh_client.py` documents the layout). GitHub App authors search as `author:app/<slug>` (`author:WorkPrentice` returns nothing); `--author` accepts any spelling.

A legacy (v2) review over one comment's size limit is split across several, each stamped `<!-- CLAUDE_REVIEW k/N -->` on its first line; the collector gathers every page and joins them in page order. Its findings sections are the *tail* of the document, so a page-1-only read reported a review with no findings at all — which on #21490 turned a `blocked` row into a `judge` row wearing an "approve as-is & merge" button and passed the Sentinel's G2 gate as "legacy review clean".

Review status per PR, label-independent: `CURRENT` needs the card's `CLAUDE_REVIEW_HEAD` to prefix-match the live head — pushes made with `GITHUB_TOKEN` never fire `synchronize`, so a PR can sit at `review:no-blockers` with a review describing content a later commit replaced. `STALE` / `IN_PROGRESS` / `ERROR` / `ABSENT` / `TRIAGE_PROSE` otherwise.

> **v3 surface guard.** If a PR's review comments contain `<!-- CLAUDE_REVIEW_AUTHOR -->`, the PR is on the v3 surface and no local refresh machinery applies to it. The findings are the author card's blocking rows (🚨 / ❓, each with an `F<n>` id; dispositions live in its `REVIEW_STATE` block) plus the reviewer's guide's `### ⚠️ Check these before approving` rows — `collect.py` reads both. **Never run the local `docs-review:references:update` refresh or `pinned-comment.sh upsert` on a v3 PR** — they write a legacy monolith beside the cards. When a v3 review is `STALE`, the row is *blocked* with a `--refresh N` action that comments `@claude <reason> #update-review`; when it is `ERROR`, with a `--rerun N` action that comments `@claude <reason> #new-review` (the pipeline's from-scratch escape hatch); wait for the card to re-render (the label returns to `review:outstanding-issues` / `review:no-blockers`), then re-collect. The Sentinel check on the PR is the merge gate; stamp only when it is green or would be.

A legacy (v2) PR that is `STALE` gets the same `--refresh` action; the CI update lane rewrites the monolith. This skill never refreshes a review locally.

### 2. Analyze

```bash
python3 scripts/review-v3/analyze.py --in .pr-review-queue.json [--owner me] [--include-infra] [--strict-stances] [--domain …] [--verdict …] [--author …]
```

Per PR: `domains` (`classify_path` via `routing.resolve_lanes`), the owning role and team per domain, exactly one `verdict`, a `reasons` list of machine-readable codes, `gate_fails` (every stamp gate this row missed, which is how the opening tells a row that needs reading from one that merely has to merge after another), `actions` (the `--act` fragments that apply), and `handoffs` (interactive runs that are not `act.py` fragments — today just "fix it yourself" on a workflow-authored row with open findings). Cross-PR, computed over the full set before any filter: **collision clusters** (union-find over shared paths; a pair is `overlap` when the hunks intersect on the base side, else `same-file`, with a suggested merge order), **directional conflicts** (a PR adds links to a URL that exists only as a Hugo `aliases:` entry while another open PR removes links from it), **duplicate candidates** (shared file, similar title, opened within 10 minutes), **stale blog dates**, **self-accepted findings** (a `REVIEW_STATE` actor who is the PR author), **stale reviews**, and **stale brief summaries** (a "What this PR changes" bullet naming a value the diff no longer contains).

| Verdict | Meaning | Board |
|---|---|---|
| `stamp` | Every gate passes; approving asserts nothing beyond what's machine-verified. | "Approve & merge" starts selected. One command approves and squash-merges the set. |
| `judge` | Decidable from the board; something needs a human's call. | Judgment box: the lines, the question, a deep link, a recommended disposition. |
| `route` | Not this approver's lane per the routing matrix. | Batch "request review from owner"; any defect found rides along. |
| `blocked` | Can't merge regardless: unanswered 🚨 review findings, conflict, red CI, stale or errored review, changes requested by someone else, your own PR. | Names the blocker and offers the mechanical unblock (merge base, refresh, re-run the review, re-run the failed checks, send back / close for open 🚨, route for another lane's). A row with none says "no action available". |

**Handed off.** A PR whose requested reviewers include a human who isn't you (and don't include you) is waiting on them, not you. It keeps its verdict in `queue.json` but leaves the groups: the board and `--terminal` list it once, compactly, under "Waiting on others" (who, how old, a ✗ for red CI or ⚠ for a conflict), and `--include-handed-off` brings the full rows back when you need to act on one. A requested team counts as yours when `review-routing.yml` maps it to a lane in `me:`. The review request is the memory: it lives on the PR, every session sees it, and GitHub clears it when the reviewer acts, which is when the row returns. `--route N:@login` is therefore also the "don't show me this again" button. Collisions and directional conflicts against a handed-off PR are advisory (`:theirs`): they no longer gate your stamp, and a cluster with at most one of your PRs in it drops out of the pinned slots.

The stamp bar, **all required**: label `review:no-blockers` (`review:trivial` is not enough — no review ran; after a base merge the card itself saying nothing blocks, with no open ⚠️ row, also counts); zero ⚠️ rows on the brief (zero low-confidence items on a legacy review); no self-accepted disposition; review `CURRENT`; `mergeable_state` in {clean, blocked} with checks green, the Sentinel left out of that rollup as `act.py` leaves it out; no changes-requested review from anyone but you; no overlap collision, directional conflict or duplicate; one of the PR's domains is in `me`; not your own PR; no new file under `content/blog/`; no blog `date:` this PR sets that is older than `stale_date_days` (`blog:stale-date:<date>`; a link fix in an old post is not a backdate, only an added post or a `+date:` line in the diff counts); no `layouts/` or `.github/` change unless `--include-infra`; changed lines under `stamp_max_lines`; scrutiny not heightened. Editorial stances on the brief block only with `--strict-stances`. Heightened scrutiny (AI-suspect on a human author) caps a row at `judge`. Precedence: blocked > route > stamp/judge.

**A review that did not arrive whole is blocked, never judged.** Two signals, either one enough: GitHub did not return a page the `k/N` markers promise (`review.pages_missing`), or the card's own tally table declares more findings than its sections parsed into (`counts_shortfall` — the tally and the sections are rendered by the same pass, so the table is the one thing that can notice a truncated body from the outside). The row wears `review:unreadable:<why>`, its unblock is a fresh review, and `act.py` refuses the merge in its own preflight rather than trusting the queue. A review that merely parsed into nothing with nothing to corroborate it — a v2 card with no tally table, a v3 card missing its head sentinel or carrying a broken `REVIEW_STATE` — is not blocked (nothing says there *are* findings) but is never stampable either: `review:parse-confidence:low`.

Two things that look like blockers are not. Your **own** changes-requested review does not block your stamp: the approval you are about to post supersedes it (`act.py` skips it in the preflight, and the analyzer reads it as `sent-back:<date>` — the row is waiting on the author, not on you). And an open 🚨 finding stops blocking once the judge step has answered it: judge **every** open blocker on the row with a resolvable disposition (`fixed | refuted | accepted | not-applicable`) and a `note`, and the `--judgments` merge lifts the row from `blocked` to `judge` with its stamp buttons, wearing `outstanding:judged:<ids>` instead of `outstanding:<n>`, because the stamp will post those `/resolve` lines before it approves (`act.resolve_lines` is the contract). A `deferred` judgment, or one with no note, leaves the finding open and the row blocked.

**An unanswered 🚨 finding is `blocked`, not `judge`.** A judge row's other gates — size, `shape:infra`, a human author, an open ⚠️ — are an approver's call to make, and `--force` is how they make it. An open blocking finding is not: it is the review still waiting on an answer, and squash-merging past it destroys the only chance to give one. So the row lands in the one lane `--force` never reaches, and merging anyway is spelled as a disposition rather than an override — `/resolve F<n> accepted: <why>`, which records the reason against the finding it answers. `act.py` re-asks the row's own card at plan time and re-reads it live in the preflight, so neither a stale verdict nor a review landing mid-batch carries a merge through. (This is the gate #21482 and #21549 did not have: both squash-merged wearing `review:outstanding-issues`, approved and merged three seconds apart, with no `/resolve` recorded against either open finding.)

Reason codes are `code[:detail]` from a closed vocabulary (`analyze.REASON_CODES`, echoed into `queue.json`): `risk:`, `scrutiny:`, `ai-suspect:`, `review:`, `label:`, `warnings:`, `outstanding:`, `self-accepted:`, `stances:`, `mergeable:`, `checks:`, `cluster:` (one per cluster: `C1:overlap:3/19` is this PR's place in the merge order, `C1:same-file` merges in any order, `C1:theirs` is mostly someone else's; `overlap` means two PRs touch the same or adjacent base lines, git's own conflict rule, not merely the same hunk), `review:base-merged` (the head moved only by merging the base, so the review still describes the diff and the row is not stale), `review:unreadable:<why>` (a page of a split review is missing, or the card's tally outruns its parsed sections — blocked, with a fresh review as the unblock), `review:parse-confidence:low` (it parsed into nothing and nothing corroborates that — not stampable, not blocked), `directional:`, `duplicate:`, `blog:`, `brief:`, `desc:`, `shape:` (`infra`, `link-only`), `size:`, `owner:`, `link-fixes:`, `route:` (the lane; `route:no-team` when its GitHub team doesn't exist yet, so the SLA person is the target instead), `handed-off:`, `merging-over:`, `not-governed`, `author:` (`internal` / `external` / `generated` / `self` — your own PR: no stamp and no send-back, route it and answer its findings with `/address-review`), `sent-back:<date>` (your own changes-requested review is the latest word on the PR, which never blocks you; when the head has not moved since and the author can answer, the row also carries `waiting_on_author: true`, loses its decision buttons and parks under "Waiting on the author"), `unblock:refused:<why>` (the mechanical unblock could not be offered — a branch that may not be pushed to, say), `trust:`, `draft`. The board shows the chips that change what you'd click and folds the rest behind "why".

Each collision cluster gets one recommendation, in words: **consolidate** (mostly one bot author's overlapping sweeps: one `--request-changes` on the newest asking for a single PR beats N serial merges), **chain** (merge the first in the order, then unblock the next: `--chain C1` does one link per run, and each link waits on CI), **ignore** (same-file only: any order works), or **theirs**. On the board a recommendation is a button on the row it belongs to (`analyze.attach_cluster_actions`): the chain on its lead, offered only where the collision is the one thing holding that row back (`only_collisions_hold`), covering the next link while it is lit; the consolidation on the newest sweep. `queue.do_next` — the same moves plus the send-back / route / stamp / unblock batches, one command each — is what `--terminal` prints after the table; the board does not render it.

### 3. Judge — the only model step

Only for rows whose verdict is `judge`. For each open finding on the row (`review.items` without a disposition, the brief's ⚠️ rows, or triage's prose bullets when `review:triage-prose`):

1. Quote the exact `-`/`+` lines at the finding's `file` and line from `files[].patch` in `queue.json`. Never paraphrase and never invent a quote; if the lines aren't in the patch, quote nothing and say so.
2. State in one sentence what the approver is deciding.
3. Recommend a disposition from `/address-review`'s vocabulary: `fixed | refuted | deferred | accepted | not-applicable`.
4. Emit the deep link `https://github.com/pulumi/docs/pull/N/files#diff-<sha256(path)>R<line>` (`compose-review.py::diff_anchor` is the helper; `render.py` computes it from `file` + `anchor` when you don't). No file or line → the link is "open the PR".

Also draft, when the row carries `desc:stale:*` or `desc:empty`, a corrected PR description (`fix_draft`), and optionally a `recommended` action: `stamp` to approve as-is, `request-changes` when the findings are the author's to fix (the judgments become the review body), `route`, or `close`. Send-back only reaches an author who can act on it: a person, or an agent bot that answers reviews (`workprentice`, Copilot). A PR opened by a workflow run — `pulumi-bot`'s content-review, glow-up and regen lanes, dependabot — carries `author:generated`, and a changes-requested review there would sit unread forever, so those rows take `close` instead (the lane re-queues the page on its next run) — or a fix applied by hand, which is what the row's **fix it yourself** handoff is for, and which is offered on every such row that still has an open finding and a branch `act.py` may push to. `merge_judgments` rewrites a `request-changes` recommendation on such a row to `close` rather than promising an author who will never answer. Write it all to `.pr-review-judgments.json`:

```json
{ "21598": { "judgments": [ { "finding_id": "F1", "file": "content/docs/iac/automation-api.md", "line": 412,
              "quote_minus": ["- [file an issue](…&template=bug_report.md&title=)"],
              "quote_plus": ["+ [file an issue](…?labels=needs-triage)"],
              "decision": "Did you mean to drop the bug-report template, or only the empty params?",
              "ask": "Restore template=bug_report.md so the link lands on the bug form.",
              "disposition": "accepted",
              "note": "The chooser page is a worse landing than the form.",
              "deep_link": "https://github.com/pulumi/docs/pull/21598/files#diff-…R412" } ],
            "fix_draft": { "kind": "description", "body": "### Proposed changes\n\n…" },
            "recommended": "stamp" } }
```

`decision` is the question you answered; `ask` is the sentence the author reads if the row goes back with `--request-changes` (on a generated row it is what the closing comment carries). A finding you resolved yourself (`fixed`, `refuted`, `accepted`, `not-applicable`) stays out of that review unless you give it an `ask`, and `decision` never stands in for one. **`note` is posted on the PR**: approving the row comments `/resolve F<n> <disposition>: <note>` for each resolved judgment, so the note is the public rationale the next reader finds against the finding. Write it for the PR — one or two sentences about the finding itself — and never mention scrutiny level, AI-suspect signals, trust tier or what the queue checked; a judgment with no note posts nothing (the stamp never falls back to the question, so an unanswered 🚨 without a note stays open and keeps the row blocked). `decision` is never posted anywhere; it stays on the board. Then merge it: `python3 scripts/review-v3/analyze.py --in .pr-review-queue.json --judgments .pr-review-judgments.json`. A recommendation never lowers the computed verdict.

Each judgment's disposition renders as a badge beside the question, worded as the reason the finding does not stop the merge ("not a real issue", "fair, not blocking", "doesn't apply", "needs the author", "no author to ask" on a row a workflow opened): never as something the PR's author answered, because they haven't. Hovering says what approving does about it, and a line under the findings says the same in full — approving records each call on the PR as a `/resolve F<n> <disposition>: <why>` comment before it merges, so nothing on the row is a separate click. A `fixed` disposition means the diff already addresses the finding; the board shows it as "already fixed", not as a recommendation, and it is never a `--fix` action (that button appears only when the row carries a drafted description or one-click suggestions). For `/pr-review N` judge the one row. For the queue, judge every judge row before rendering; a row you skip renders its open findings under "Needs a call" without a quote.

### 4. Render and publish

```bash
python3 scripts/review-v3/render.py --in .pr-review-queue.json --board .pr-review-board.html   # the queue
python3 scripts/review-v3/render.py --in .pr-review-queue.json --detail N --out .pr-review-board.html   # /pr-review N
python3 scripts/review-v3/render.py --in .pr-review-queue.json --terminal [--pr N]
```

Publish with the Artifact tool (favicon 🗂️; update the same artifact on re-render rather than creating a new one). There is no batch strip: every decision is on its row, next to the evidence for it. The stampable rows arrive with their approve button selected, so the default command already merges the set; a cluster's move is a button on the row it belongs to — the chain on its lead, offered only where the collision is the one thing holding it back (`--chain` approves that lead with `--force`, so a lead held by anything else keeps its own approve-as-is button beside the findings) and marking the next link "covered" while it is lit; the consolidation on the newest sweep. Pass `--artifact` when rendering for it: that form omits the document skeleton the Artifact tool adds itself. The default form is a standalone file for a browser or `screenshot.mjs`. The board opens with the tally and a progress line ("3 of 21 decisions made"); then the rows grouped owner → domain and, inside a group, in PR number order — the verdict is already on the row, in the tally and on a filter chip, so sorting by it too only meant that finding a PR you had a number for required knowing its verdict first. Every verdict is shown by default (the view chips narrow it: turning off `stampable` or `blocked` shortens the board, and the defaults keep every row the composed command touches on the page); the collision detail folds at the bottom. Filter chips cover view / owner / domain / author / since and are literal: a row shows only while its value is lit in every group, so a group with nothing lit empties the board and says so ("reset chips" restores the defaults; `since` is the one threshold). Every action button is a toggle that composes the `/pr-review --act …` command shown at the bottom (a stamp row's "approve & merge" starts selected, so the default command merges every stampable row, all of which are on the page; the composer never repeats a fragment). Every row says on the button whether approving also merges: a bot row leads with "approve & merge" and offers "approve, don't merge"; a human-authored row leads with "approve, no merge" and offers "approve & merge". Both are decisions, so picking one puts out the other. A row takes one decision (approve either way, send back or close it out, route, unblock, refresh, re-run): lighting a second one puts out the first. A row a workflow opened that still has open findings also carries **fix it yourself** — the only remaining way out when no author will ever answer the review. It is a *handoff*, not a write: it composes `/address-review N` onto a second, amber line above the `--act` command, one run per PR, and never joins the batch. Side actions (apply fixes, screenshot the preview, deploy) and handoffs ride alongside whichever decision is lit, and only decisions count toward the progress line: approve (either way), send back, close it out, route, unblock, refresh, re-run the review, re-run the failed checks — one lit decision on a row, or a lit chain covering it from its lead, is one decision made, and the denominator is every row on the page that has a decision to make. A blocked row that carries no unblock at all shows "Blocked: … no action available" and is counted on its own tally tile, so nothing on the page can sit silent. The page never calls GitHub itself, and neither do you off the back of it: publishing the board ends the run. Report the link and the composed command in one line and stop there. Every decision on the page is the person's, including the ones that started selected.

Both renderings park two kinds of row at the foot of the page unless `--include-handed-off`: PRs waiting on another reviewer ("Waiting on others") and PRs you already sent back with nothing pushed since (`waiting_on_author`, chip `sent-back:<date>`; "Waiting on the author", with the date and a ✗ for red CI or ⚠ for a conflict). `--terminal` prints the same facts as the board — every row with its reasons wrapped rather than cut, its blockers, its open findings or judgments, and every action with its `--act` fragment, then the Do-next moves with their commands, the collision clusters (members listed when every one is waiting on others), and both waiting lists — so a terminal reader can compose exactly the command the page would. In this mode only, walk each judge row with AskUserQuestion (options: approve as-is / send back / route / skip), then compose the same act command and stop. Those questions record a disposition for a row; answering them is not consent to run anything, so the walk ends where the board does — with a command the person invokes.

### 5. Act

```bash
python3 scripts/review-v3/act.py --in .pr-review-queue.json --stamp 21550,21577 --route 21431:@cnunciato --unblock 21525
python3 scripts/review-v3/act.py --execute .pr-review-plan.json [--dry-run]
```

**This step needs the person's explicit go-ahead, and for anything more than one row that means an `--act` invocation** — typed, or pasted from the board. That invocation is the authorization for every write in it, which is why it names its PRs one by one rather than saying "the stampable ones". The single exception is a row they just walked through with you, where a named, non-default `AskUserQuestion` option can carry that one row's action under the three conditions in "How a run ends"; it opens for one row and never for a set. Otherwise: never arrive here on your own initiative, and never treat a board you just published as a decision — an unread board's command is a proposal, not an instruction.

The first command validates against the queue and writes `.pr-review-plan.json` (schema 2) plus a preview: for each step the PR, head SHA, the preflight it will run, and every write with its exact body. Every comment, review body, `/resolve` line and suggestion is rendered **at plan time** into the step, and `--execute` sends exactly that — the preview is the writes, and nothing is rebuilt from whatever queue is on disk later. The plan also dedupes an identical fragment given twice and refuses two decisions on one PR (approve and send back, say) rather than posting both.

Inside the step there is no question to ask. Planning is read-only — one local file and a printed preview, no GitHub call — so never ask permission to plan, and never offer "plan all" versus "plan some": plan exactly what the `--act` fragments name, no more. Then read the preview yourself before executing, because you are the one check between the command and the writes: if it contains a step the invocation didn't ask for, or a PR whose head moved since the board was rendered, stop and say which, rather than executing and reporting it afterward. Otherwise execute, and report the per-step results. `--dry-run` runs every preflight and records every write each step would make without sending one — the report lists them per step — and never runs git; it is always safe to run first when the preview leaves you unsure.

**Every write has a preflight.** Immediately before each step, `act.py` re-reads the PR: still open, head unchanged since the plan. A stamp adds the merge gates below; `--unblock` also fetches and checks that `origin/<branch>` still equals that head before it merges. A failed preflight skips that step and the batch continues. Pushes (`--unblock`, `--fix`) happen in a temporary detached worktree and go out as `push HEAD:<branch>` — your checkout is never reset, never left with a half-applied merge, and nothing is ever rebased or force-pushed.

- `--stamp N,N` — per PR, immediately before merging, re-fetch the PR: head unchanged since the plan, `mergeable_state` in {clean, blocked}, checks green with the Sentinel left out of that rollup (it concludes failure until an approval exists, so it is polled on its own between the approval and the merge, up to 90 s), no changes-requested review from anyone but you (your own is superseded by the approval this step posts), and — when the step merges — a live re-read of the review cards showing no blocking finding this step does not answer. Then post one `/resolve F<n> <disposition>: <why>` per judged finding (`fixed`, `refuted`, `accepted`, `not-applicable`, each with a note; `deferred` is the author's and goes back instead, a judgment without a note posts nothing, and a triage-prose id is not a card finding), approve (one line per `pr-review:references:message-templates`, no footer; `--approve-note` appends one sentence) and squash-merge. The resolves land first, so the record of why each finding closed never loses a race with the merge. A stamp that fails after posting them reports what landed, and a re-run skips a `/resolve` comment or an approval this login already posted at this head. A bot PR merges — dependabot, pulumi-bot and WorkPrentice alike, since they exist to be merged; a human-authored PR is approved only, because it is the author's to merge. `N:merge` and `N:no-merge` override that default for one PR (`--stamp 21550,21577:no-merge`), so a mixed batch stays one command; the blanket `--merge-humans` and `--no-merge` still apply where no per-PR mode is given. Repeatable; values accumulate. A failed preflight skips that PR and the batch continues. `--force` stamps a judge or route row (approve as-is / approve anyway); a blocked row is never stampable, and no flag merges over an unanswered 🚨 — judge the row so the stamp posts the `/resolve` lines, or `--stamp N:no-merge` to approve without merging.
- `--route N:@user|@org/team` — request review and post one comment with the row's defects (reason codes + judgments). `N` alone uses the row's own route target.
- `--request-changes N` — post a changes-requested review built from the row's judgments (one line-anchored item each, the `ask` of every judgment that has one plus the open findings, `--reason "N=…"` as the opening line, voice per author type from `pr-review:references:message-templates`) and apply `needs-author-response`. The author's turn; nothing merges until they answer. Refused when there is nothing to send (no `ask`, no open finding), and refused on an `author:generated` row, which has no author to answer it, unless you pass `--force`.
- `--chain C1` — start a cluster's chain: stamp the first PR in its merge order through exactly `--stamp`'s code path and gates (`--force` implied for a judge row, the `/resolve` lines, the plan-time and preflight blocker checks), then merge base into the next so the following run finds it green. The unblock step `requires` the stamp: if the stamp is refused or approves without merging, the unblock is skipped rather than pushing a merge nobody asked for. The cluster's recommendation must be `chain`, and the next link must be a branch that may be pushed to. One link per run.
- `--unblock N` — fetch, check `origin/<branch>` still matches the planned head, merge the base branch into the head as a merge commit in a temporary worktree and push `HEAD:<branch>`; a conflicted merge is aborted and reported (the worktree goes with it), never resolved by hand here and never rebased or force-pushed. Refused at plan time on a branch that may not be pushed to (`push_allowed`).
- `--fix N` — apply the drafted description (a `PATCH` on the PR body) and any one-click ✏️ suggestions, in a temporary worktree, bottom-up so earlier inserts never shift later lines; a suggestion whose review comment is outdated on the head is skipped and reported; commit, push `HEAD:<branch>`. Refused when the row has neither.
- `--close N [--superseded-by M | N:M]` — with `M`, cross-link both (one comment on each) and close N. Alone, close N with one comment: on an `author:generated` row that comment is written from the row's judgments and needs no `--reason`; on anyone else's PR `--reason "N=…"` is required, because closing someone's work without saying why is worse than sitting on it. Repeatable; with several closes, `--superseded-by N:M` says which.
- `--refresh N` — comment `@claude <reason> #update-review` (the reason defaults to the row's `review:*` code).
- `--rerun N` — comment `@claude <reason> #new-review`: clears the cards and dispatches a fresh initial review, bypassing the trivial / frontmatter-only / draft / bot skips. The unblock on a `review:error` row; a side action ("run a full review") on a row where no review ran.
- `--rerun-checks N` — re-run the failed jobs of the head's workflow runs (the newest run per workflow that concluded failure), without a push: the unblock on a `checks:red` row when the failure looks like CI flake rather than the diff. A red commit status has no run to re-run, and the step reports that instead of guessing. The review is not touched and nothing merges.
- `--render N` — screenshot the preview pages into `/.pr-review-shots/N/` (`screenshot.mjs`, Playwright); the detail view embeds them.
- `--deploy N` — dispatch `testing-build-and-deploy.yml` at the head branch (the `risk:infra` row action; see `pr-review:references:infrastructure-deployment`).

`pr-review:references:reading-the-board` is the manual for the rendered page — every element, what it means, and what clicking it does. Point a new approver at it, and read it yourself before changing what the board shows. Which bot branches may be pushed to, and the action bar per row, are in `pr-review:references:action-menus`. Every comment `act.py` posts carries the Claude Code attribution footer except the approval body.

### 6. Re-collect, re-render

After acting, run collect (`--pr` for the touched PRs is enough) → analyze (`--judgments` again) → render, and update the same Artifact. Merged and closed PRs drop out; the board is the report.

## Hand-offs

- Author work (an open 🚨/❓ item, a fix the author should make): tell them to run `/address-review N`; that is where dispositions get recorded with the outcome the post-merge scrape reads.
- Your own PR: run `/address-review` first, then adjudicate.
- `/dashboard` shows only the open-PR count and points here.

## Errors

A failed step is reported in place (`✗ stamp #N: preflight refused: head-moved …`) and never stops the batch. Recover by re-collecting and re-planning; a `head-moved` refusal means someone pushed — judge the new diff. If `collect.py` can't reach GitHub it says which backend it tried; a `GhNotFound` from the snapshot backend names the endpoint file it wanted.
