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
/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:
- 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.
- 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.
- 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.
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:
- Every changed path gets a subject from
classify_path()in triage-classify.py — the same classifier that writes thedomain:*labels, so the board and triage can't disagree. The closed set isdocs,blog,website,programs,infra,frontend,other; a path nothing else claims (.claude/,styles/, generateddata/) lands inother. - 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.
- Every row has a lane team on the hook.
resolve_lanesalways comes back with at least one required role:nonecells are a config error in.github/review-routing.yml, and a mechanical docs/blog edit routes to its lane exactly like a substantive one —mechanicalskips the model review, never the approver. Thegate:nonereason code that used to mark an ungated row is gone with the state it described. - 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?". Withme: [docs, infra, frontend, other], that means everything except a PR whose files are entirely blog and website. - The rest routes, and
.github/review-routing.ymlpicks 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'ssla.escalate_toperson;route:team-unverifiedmeans 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'. - Two overrides, both visible as reason chips.
link-fixes:minemakes ashape:link-onlydiff yours whatever lane it sits in (config above).handed-off:@whotakes a row out of your list entirely — someone else is already requested as reviewer — and parks it under "Waiting on others" until they act. --ownerreinterprets step 4 only.meis the default,anydrops the lane check, and a role name or@loginsubstitutes 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
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 anF<n>id; dispositions live in itsREVIEW_STATEblock) plus the reviewer's guide's### ⚠️ Check these before approvingrows —collect.pyreads both. Never run the localdocs-review:references:updaterefresh orpinned-comment.sh upserton a v3 PR — they write a legacy monolith beside the cards. When a v3 review isSTALE, the row is blocked with a--refresh Naction that comments@claude <reason> #update-review; when it isERROR, with a--rerun Naction that comments@claude <reason> #new-review(the pipeline's from-scratch escape hatch); wait for the card to re-render (the label returns toreview: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
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. Those recommendations, plus the send-back / route / stamp batches, are the board's "Do next" list: one sentence and one button each, most leverage first.
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):
- Quote the exact
-/+lines at the finding'sfileand line fromfiles[].patchinqueue.json. Never paraphrase and never invent a quote; if the lines aren't in the patch, quote nothing and say so. - State in one sentence what the approver is deciding.
- Recommend a disposition from
/address-review's vocabulary:fixed | refuted | deferred | accepted | not-applicable. - Emit the deep link
https://github.com/pulumi/docs/pull/N/files#diff-<sha256(path)>R<line>(compose-review.py::diff_anchoris the helper;render.pycomputes it fromfile+anchorwhen 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:
{ "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
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). The Do next strip is the mechanical moves only: "approve the set" is the rows that cleared every gate without anybody reading them, and a chain whose lead did not clear them states the fact and offers no button, because --chain approves that lead with --force. An approval that needed a judgment call stays on its own row, next to the findings behind it. Each card also carries a live on/total tally of the rows still holding its decision, and paints a third state — partly lit — between lit and out, so a card one contrary row put out never reads as a button that did nothing. 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, a progress line ("3 of 21 decisions made") and the "Do next" list; then the rows grouped owner → domain and, inside a group, in PR number order — the verdict is already on the row, in the tally, on a filter chip and in the cards, 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 Do-next card covering it, 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
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_statein {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;deferredis 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 perpr-review:references:message-templates, no footer;--approve-noteappends 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/resolvecomment 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:mergeandN:no-mergeoverride that default for one PR (--stamp 21550,21577:no-merge), so a mixed batch stays one command; the blanket--merge-humansand--no-mergestill apply where no per-PR mode is given. Repeatable; values accumulate. A failed preflight skips that PR and the batch continues.--forcestamps 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/resolvelines, or--stamp N:no-mergeto approve without merging.--route N:@user|@org/team— request review and post one comment with the row's defects (reason codes + judgments).Nalone 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, theaskof every judgment that has one plus the open findings,--reason "N=…"as the opening line, voice per author type frompr-review:references:message-templates) and applyneeds-author-response. The author's turn; nothing merges until they answer. Refused when there is nothing to send (noask, no open finding), and refused on anauthor:generatedrow, 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 (--forceimplied for a judge row, the/resolvelines, the plan-time and preflight blocker checks), then merge base into the next so the following run finds it green. The unblock steprequiresthe 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 bechain, and the next link must be a branch that may be pushed to. One link per run.--unblock N— fetch, checkorigin/<branch>still matches the planned head, merge the base branch into the head as a merge commit in a temporary worktree and pushHEAD:<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 (aPATCHon 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, pushHEAD:<branch>. Refused when the row has neither.--close N [--superseded-by M | N:M]— withM, cross-link both (one comment on each) and close N. Alone, close N with one comment: on anauthor:generatedrow 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:Msays which.--refresh N— comment@claude <reason> #update-review(the reason defaults to the row'sreview:*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 areview:errorrow; 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 achecks:redrow 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— dispatchtesting-build-and-deploy.ymlat the head branch (therisk:infrarow action; seepr-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-reviewfirst, then adjudicate. /dashboardshows 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.
Architecte Docker Compose
DevOps
Concoit des configurations Docker Compose optimisees.
Rapport de Post-Mortem
DevOps
Rédige des rapports post-mortem d'incidents structurés et blameless.
Créateur de Runbooks
DevOps
Crée des runbooks opérationnels clairs pour les procédures DevOps courantes.