Live board and guardrails for pr-review-loop's /review-pr: per-seat progress, build gate, cost, tool-call budget, and a dry-run posting guard.

A Claude Code plugin that turns PR review into a repeatable, self-improving loop. The review commands share one 5-agent panel and a per-repo memory that learns from how developers respond — so across an 8-round PR it stops re-raising things the team already dismissed, and remembers what to keep an eye on.
It posts to GitLab/GitHub only as a normal reviewer would — inline comments and an overview via
/review-pr. It never overrides your repo'sCLAUDE.md/ ADRs — those always win.
| Command | Delivers to | Use it for |
|---|---|---|
/review-pr <PR_URL> | inline comments on the PR | a normal reviewer pass on one PR |
/review-pr-watch [owner/repo] | drives /review-pr in a loop | auto-review PRs you're requested on |
/review-pr-stats · CLI review-stats | your terminal | real token usage + the review scoreboard (approvals, P0/P1, disputes) |
Both review paths run the same 5-agent panel and share the same per-repo memory. /review-pr-watch is one poll cycle — wrap them in /loop to run continuously. review-stats reports what the loop produced.
A real, versioned plugin (claude plugin update / list / enable). Same mechanism from the terminal or from inside Claude Code — pick either:
# terminal (CLI)
claude plugin marketplace add NarekManukyan/pr-review-loop
claude plugin install pr-review-loop@pr-review-loop
# inside Claude Code (slash commands)
/plugin marketplace add NarekManukyan/pr-review-loop
/plugin install pr-review-loop@pr-review-loop
Restart the session — the first start syncs the skills and best-effort installs graphify in the background. Then run /review-pr-init — a guided setup that asks which PR platform you use (required) and whether you want graphify (optional), then connects them and runs the doctor.
/review-pr-doctor re-checks setup anytime; nothing has to be done by hand.
Update later: claude plugin update pr-review-loop (or /plugin marketplace update pr-review-loop), then restart.
Installs into ~/.claude for the current user — available in every project. Re-run the same line any time to update.
curl -fsSL https://raw.githubusercontent.com/NarekManukyan/pr-review-loop/main/install-remote.sh | bash
Clones the plugin to ~/.pr-review-loop, then installs commands + skills and (optionally) graphify. Requires git + curl. Restart Claude Code, then /review-pr-init.
unzip pr-review-loop.zip && ./pr-review-loop/install.sh
Every review, whichever command triggers it, runs the same pipeline:
trigger ─▶ recall memory ─▶ 5-agent panel ─▶ verify ─▶ report ─▶ deliver ─▶ record memory
▲ │
└──────────────────────── next round ◀──────────────────────────────┘
CLAUDE.md / ADRs still rank above it.review-core engine detects the repo's stack and libraries (Flutter BLoC/MobX/Riverpod/Provider, Go/Postgres, NestJS, …) and loads the matching lens pack(s) on top of stack-agnostic universal lenses. Unknown stack → universal-only fallback. See Stack detection & lens packs.2b. Detect stacked chains — when an MR's target branch is another open MR's source branch, they form a chain. The panel prints the map (Stack detected: !41 → … → !53 (10 MRs, tip = …)) and asks how the chain merges: atomic (lands together) or piecemeal (each MR reaches main on its own). It never guesses — that answer is what makes stack-level review correct. Either way the panel then reviews the cumulative diff at the tip as one unit rather than intermediate states that never reach main; piecemeal adds a per-MR build gate answering only "does this MR alone leave main compiling?". Findings are attributed back to the MR that introduced them (git blame at the tip → the lowest chain branch containing that commit) so every author still gets their own comments, and the stack gets one verdict. Reviewing a 10-deep chain MR-by-MR produced 18 findings a later MR in the same chain had already fixed. MRs targeting main with no children are independent and keep the per-MR path.
Read/Grep/Glob/Bash) — measured 33% cheaper than general-purpose agents, at no quality cost:3b. Verify — before anything is delivered, every finding is re-located by its verbatim snippet (relocate-findings.py: deterministic, corrects drifted line numbers and decides inline vs MR-level anchoring up front), then the merged set gets one fact-check pass (review-filter agent on haiku) that may drop only findings the code at HEAD proves wrong — never a P0/P1 — and lists every drop in the overview. Both stages are adapted from Alibaba's OpenCodeReview.
/review-pr)..review-memory/ folder, feeding the next round. One verdict roll-up per reviewed MR×round is recorded alongside them (kind:"review" — verdict, head SHA, P0/P1/P2 counts, build, conflicts) so review-stats can count approvals; roll-ups are stats only and are excluded from recall, so they can never suppress a finding.Two loops sit on top:
CLAUDE.md / an ADR. A human makes that change; the bot never edits its own rules.Review knowledge is split into two layers so the same engine works across every stack without a per-stack fork of the commands:
review-core/references/universal-lenses.md, U1–U14) — stack-agnostic principles that hold in any language: check-then-act races, write+event atomicity, idempotency on money/events, fail-closed security gates, reachability (defined ≠ wired), spec/AC match, test-effectiveness, stale-doc-after-rename, verify at HEAD (don't trust resolved/stale merge flags), CI parity, complexity, resource lifecycle, sibling parity, and lifecycle (started ≠ drained).review-core/references/lenses/*.md) — the idioms of one stack: the concrete shape those principles take (e.g. Go SQL FOR UPDATE/CAS, Flutter copyWith reset, NestJS provider wiring). Loaded only when detected.A resolver detects the base stack from the manifest and the state library from the dependencies, then composes base + overlays:
| Repo | Loads |
|---|---|
| Flutter + BLoC | _base-flutter + flutter-bloc |
| Flutter + MobX (+ Provider) | _base-flutter + flutter-mobx (+ flutter-provider) |
| Go / Postgres | go-postgres |
| NestJS | nestjs |
| Unknown (Svelte, Rails, …) | universal-only fallback — still a full review |
The repo's own CLAUDE.md / ADRs / linter config / .review-memory always outrank a generic pack. /review-pr-doctor prints which pack(s) the current repo would load.
Adding a stack: drop a file in review-core/references/lenses/ following lenses/README.md, then add its detection row to resolver.md. No command changes — that's the whole point of the split. Packs shipped today: go-postgres, flutter-bloc, flutter-mobx, nestjs (full); react, flutter-provider, flutter-riverpod, python (stubs — universal lenses still apply).
/review-pr <PR_URL> [--fork|--packet] [--no-reuse]Full panel review of one PR, posting inline comments. Detects the review round from existing PR comments, recalls memory, records outcomes. This is the command a human runs manually and the one the PR watcher drives.
Seat modes. Every seat gets a context packet built in code (changed files, rules, likely reads, its own lenses) instead of fetching material turn by turn.
memory.py seat-mode. Round 1 keeps full recall; later rounds cost about 40% less.--packet: each seat reads the packet itself. On a two-PR benchmark it matched the old panel's results with less than half the tokens and in about half the time.--fork: you read the packet once and the seats are forks of the orchestrator, so the packet is cached once instead of four times. About 40% cheaper again, at slightly lower recall in that benchmark. Good for routine PRs, re-reviews, or two cheap passes on an important PR. Needs CLAUDE_CODE_FORK_SUBAGENT=1; without it the review says so and runs in packet mode.Pin one mode for a repo with "seat_mode": "packet" or "fork" in .review-memory/config.json (default "auto"); a flag overrides it for one run.
Reuse check (on by default). Reviewer C gets reuse-catalog.md, a list of what the repo already has at the merge-base (design-system widgets, app-wide widgets, core use cases, helpers and extensions, every feature use-case name), and flags new code that copies any of it, or code the PR writes twice, as P1. Panels otherwise drop copies as "style": on one Flutter repo an 8-PR feature stack passed 6–16 review rounds with no reuse finding and needed a cleanup refactor after merge. The catalog is built only when the diff adds a class or function, so merge-only re-checks cost nothing; otherwise it is one extra read for seat C (~12k tokens on a large Flutter repo, measured). --no-reuse turns it off for one run, "reuse_check": false in .review-memory/config.json for the repo (--reuse turns it back on). The catalog script ships for Dart/Flutter; other stacks get reuse: skipped.
/review-pr-stats · CLI: review-statsReal token usage + the review scoreboard, read from disk — nothing estimated.
review-stats # this repo, from any terminal (no Claude Code needed)
review-stats --json # machine-readable
review-stats --sessions 20 # widen the token window
~/.claude/projects/<slug>/*.jsonl). The number to watch is cache hit rate: high (>90%) means the prompt cache is working and content is paid ~once; low means caches keep re-warming..review-memory/decisions.jsonl: reviews (MR × round), MRs, rounds, findings, P0 / P1 + blockers, approved vs request-changes (+%), the dev-verdict split with % fixed / % disputed, and the recurring findings ripe to distill. The useful one is % disputed — it means the reviewers are wrong about this repo, so rules.md / CLAUDE.md needs the fix, not the code.What it deliberately does not report: your token savings, or per-reviewer cost. Claude Code does not persist subagent turns (verified: zero sidechain turns across every project log; subagent_tokens never reaches disk), so a review's true cost cannot be attributed and the counterfactual is not measurable. The totals shown are main-thread only — a review's real cost is higher. Rather than invent a number, the command says so.
/review-pr-watch [owner/repo]One watch cycle for /review-pr. Finds PRs where you're a requested reviewer, skips any already reviewed at their current head commit, and runs the next round on the rest. See Loop / automation.
mods/review-board is a second plugin in this marketplace. It adds a live view of a review and puts limits in code where prompts weren't enough:
/review-board budget <n> changes it.general-purpose is refused./review-board guard on refuses every gh/glab call that comments, reviews, approves or merges, and GitLab MCP writes: a dry run of /review-pr. guard off lifts it.It works in the terminal (as a docked pane in the fullscreen layout, inline otherwise) and in the desktop app. Hooks-module mods are early access in Claude Code right now, so set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env block of ~/.claude/settings.json, then:
/plugin install review-board --marketplace NarekManukyan/pr-review-loop
Tests: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test mods/review-board.
Wrap the watch command in /loop:
/loop 10m /review-pr-watch # inline, PRs you're requested on
/loop 10m /review-pr-watch --fork # same, fork-mode seats on every round
How a next round is detected
State is keyed by PR number + head commit in review memory. A PR is reviewed once per head. The next round fires only when the developer pushes new commits (head advances) or a re-request produces a new head. A bare re-request with no new commits is intentionally skipped — nothing changed to review.
The watcher caps work per cycle (default 3 PRs) so a cycle can't run away; the next cycle continues. In loop mode the interactive confirmation is skipped — the /loop invocation is the standing authorization — but every other guardrail stays.
Tip: point a new loop at a single test PR for the first day to confirm detection behaves before turning it on for real.
Dry run first. Validate detection without acting — reports what it would review, but posts and reviews nothing:
/review-pr-watch --dry-run
Per-repo config (.review-memory/config.json, committed) tunes the loop without editing skills — the per-cycle cap, stack, extra generated-file globs:
python3 ~/.claude/skills/review-memory/scripts/memory.py config . --init # writes an editable default
A committed .review-memory/ folder in each repo, written and read by skills/review-memory/scripts/memory.py.
Why per-repo, not shared: the same command runs on repos with opposite architectures (a MobX app and a BLoC app share a language but not their rules). Memory is keyed to each repo — aligned with that repo's own CLAUDE.md + ADRs — and never shared across repos.
Authority hierarchy (never inverts):
CLAUDE.md + ADRs — authoritative, hand-written..review-memory/rules.md — curated, human-approved distilled rules..review-memory/decisions.jsonl — raw outcome log; calibrates confidence.Memory only calibrates a review — it cuts repeat-noise and honors prior deferrals. Every finding must still be provable against the current code.
What it stores & does
| Action | Command | Effect |
|---|---|---|
| recall | memory.py recall . --area "<paths>" | surfaces watch items + prior verdicts before reviewing |
| record | memory.py record . --input decisions.json | logs this round's findings + developer responses |
| watch item | memory.py note . --area "<file>" --text "…" | a sticky human "check this in future PRs" that resurfaces until closed |
| close | memory.py close . --signature "<sig>" | marks a watch item checked |
| distill | memory.py distill . | lists recurring findings (candidates to codify) |
| ripe | memory.py ripe . | findings with a consistent verdict, ready to codify (used by the nudge) |
| health | memory.py health . | review-health report: volume, dispute rate per category (precision), open debt |
| config | memory.py config . --init | write/read the per-repo config.json |
Review health. memory.py health . surfaces whether the bot is actually helping: findings per round, dispute rate per category (if e.g. "performance" findings get disputed 60% of the time, you see it and can down-weight that lane), open watch items, and deferred-not-closed count. This is also the guard against feedback drift — you measure precision instead of trusting suppression blindly.
Watch items are the "important, don't forget" channel: flag complex logic that wasn't fully verified and it shows at the top of every future review touching that area — across PRs, not just rounds — until a human closes it.
Promotion is human. Recurring, confirmed lessons get moved into CLAUDE.md / a new ADR via a normal PR. Once codified there, the memory bullet can be removed. The bot never rewrites its own rules from unreviewed replies.
graphify, if installed, adds a semantic recall layer over the corpus; without it, deterministic JSONL recall works fine.
/review-pr writes on GitLab/GitHub, as a normal reviewer would; --dry-run on the watcher posts nothing.*.g.dart, *.freezed.dart, lockfiles, etc.; extends from the repo's CLAUDE.md)./loop.CLAUDE.md/ADRs and never self-edits its rules.You ship an improvement once; teammates pull it:
# maintainer
cd <plugin checkout> && git add -A && git commit -m "…" && git push
# teammates — terminal
claude plugin update pr-review-loop # then restart Claude Code
# teammates — inside Claude Code
/plugin marketplace update pr-review-loop
/plugin install pr-review-loop@pr-review-loop
~/.claude/skills/.../scripts/ensure-graphify.sh --force (or pipx install graphifyy).gh auth status / glab auth status, and that you're actually a requested reviewer on an open PR.memory.py distill . to see candidates manually.pr-review-loop/
├── .claude-plugin/{plugin,marketplace}.json # manifests (repo is its own marketplace)
├── agents/
│ ├── review-panel.md # reviewer seat A/B/C/E/F (Read/Grep/Glob/Bash only)
│ ├── review-build.md # Reviewer D — build & CI parity (haiku)
│ └── review-filter.md # fact-check pass before delivery (haiku)
├── commands/
│ ├── review-pr.md # /review-pr
│ ├── review-pr-watch.md # /review-pr-watch
│ ├── review-pr-stats.md # /review-pr-stats
│ ├── review-pr-init.md # /review-pr-init (guided setup)
│ └── review-pr-doctor.md # /review-pr-doctor
├── payload/skills/ # synced to ~/.claude/skills on install/first-run
│ ├── review-core/ # engine: personas, lenses, resolver, verify-findings + relocate-findings.py
│ └── review-memory/ # learning engine (recall/record/note/distill/health/config/doctor)
├── hooks/ensure-and-nudge.sh # SessionStart: sync skills, install graphify, distill nudge
├── scripts/ensure-graphify.sh # best-effort graphify installer
├── scripts/attribute-findings.sh # stack mode: finding → introducing MR
├── tests/ # stdlib unittest (memory recall, relocate, packets, build gate)
├── mods/review-board/ # optional mod: live board + seat budget + posting guard
├── install.sh # standalone (no-marketplace) installer
├── CHANGELOG.md
└── README.mdhooks/register.tsx 328 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Build, Run, RunSummary, Seat } from '../types'
5
6// Live board and guardrails for pr-review-loop's /review-pr.
7//
8// The guardrails are the part that saves money: on a two-PR benchmark the
9// reviewer seats ignored "batch your reads" and spent 17-51 turns each, one tool
10// call per turn. A prompt asks; this refuses. Each seat gets a tool-call budget
11// (reads of its own packet do not count), review seats may not run as
12// general-purpose agents, and an optional guard blocks every posting command.
13
14const PANE = 'review-board'
15const run = atom({ plugin: 'review-board', key: 'run' } as const, null as Run | null)
16const guard = atom({ plugin: 'review-board', key: 'guard' } as const, false)
17const budget = atom({ plugin: 'review-board', key: 'budget' } as const, 15)
18const history = atom({ plugin: 'review-board', key: 'history' } as const, [] as RunSummary[])
19
20const REVIEW_CMD = /^\/(?:pr-review-loop:)?review-pr(?:\s|$)/
21const SEAT_PROMPT = /You are Reviewer `?([A-F])`?/
22const PR_URL = /https?:\/\/\S+?\/(?:pull|merge_requests)\/\d+/
23const POSTING: RegExp[] = [
24 /\bgh\s+pr\s+(?:comment|review|merge|close|edit|ready)\b/,
25 /\bgh\s+api\b[^|;&]*(?:-X|--method)\s*(?:POST|PUT|PATCH|DELETE)\b/i,
26 /\bgh\s+api\s+graphql\b[^|;&]*\bmutation\b/,
27 /\bglab\s+mr\s+(?:note|approve|unapprove|merge|close|update)\b/,
28 /\bglab\s+api\b[^|;&]*(?:-X|--method)\s*(?:POST|PUT|PATCH|DELETE)\b/i,
29]
30const POSTING_MCP = /^mcp__[^_]*gitlab[^_]*__(?:create_|update_|resolve_|delete_)/
31const SEAT_NAMES: Record<string, string> = {
32 A: 'architecture', B: 'correctness', C: 'perf / quality', E: 'seams', F: 'spec & ACs',
33}
34
35const field = (e: unknown, name: string): string => {
36 const v = (e as Record<string, unknown>)[name]
37 return typeof v === 'string' ? v : ''
38}
39
40const usd = async ($: EngineInterface) => (await $.session.usage()).cost?.usd ?? 0
41
42const count = (text: string, sev: string) =>
43 (text.match(new RegExp(`"severity"\\s*:\\s*"${sev}"`, 'g')) ?? []).length
44
45const startRun = async ($: EngineInterface, args: string) => {
46 const now = await $.clock.now()
47 const next: Run = {
48 pr: args.match(PR_URL)?.[0] ?? (args.trim().split(/\s+/)[0] || 'local diff'),
49 mode: /--fork\b/.test(args) ? 'fork' : /--packet\b/.test(args) ? 'packet' : 'auto',
50 startedAt: now, costAtStart: await usd($), cost: 0,
51 seats: [], build: null, denied: 0, isFinished: false,
52 }
53 await update($, run, () => next)
54 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Review board' })
55 if (!isPlaced) $.ui.status(statusText(next))
56}
57
58const statusText = (r: Run) => {
59 const done = r.seats.filter(s => s.state !== 'reviewing').length
60 const calls = r.seats.reduce((n, s) => n + s.calls, 0)
61 const pr = r.pr.match(/(\d+)$/)?.[1]
62 return `review ${pr ? '#' + pr : ''} · seats ${done}/${r.seats.length} · ${calls} calls · $${r.cost.toFixed(2)}`
63}
64
65const shortPr = (pr: string) => {
66 const m = pr.match(/([^/]+)\/(?:pull|merge_requests)\/(\d+)$/)
67 return m ? `${m[1]}#${m[2]}` : pr
68}
69
70const elapsed = (from: number, to: number) => {
71 const s = Math.max(0, Math.round((to - from) / 1000))
72 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
73}
74
75let ticker: { cancel: () => void } | undefined
76
77async function tick($: EngineInterface) {
78 const r = await read($, run)
79 if (!r || r.isFinished) {
80 ticker?.cancel()
81 ticker = undefined
82 return
83 }
84 const cost = (await usd($)) - r.costAtStart
85 let build = r.build
86 if (build && build.state === 'running') {
87 try {
88 const rec = JSON.parse(await $.fs.read(build.path)) as {
89 build?: { compiles?: boolean | null; analyzer_errors?: number; analyzer_warnings?: number;
90 pipeline?: string; ci_gates?: string[] }
91 seconds?: number
92 }
93 const b = rec.build ?? {}
94 build = { ...build, state: 'done', compiles: b.compiles, errors: b.analyzer_errors,
95 warnings: b.analyzer_warnings, pipeline: b.pipeline, gates: b.ci_gates,
96 seconds: rec.seconds }
97 } catch {
98 // not written yet: the gate is still running
99 }
100 }
101 const nextRun = await update($, run, cur => (cur ? { ...cur, cost, build } : cur))
102 if (nextRun) $.ui.status(statusText(nextRun))
103}
104
105function ensureTicker($: EngineInterface) {
106 ticker ??= $.clock.every(4000, () => void tick($))
107}
108
109
110export const register: Register = on => {
111 on('session.start', async ($, e, next) => {
112 await $.command.register({
113 name: 'review-board',
114 description: 'Review board: open the pane, or `guard on|off`, `budget <n>`',
115 argumentHint: '[guard on|off | budget <n>]',
116 })
117 return next(e)
118 })
119
120 on('command.run', { command: 'review-board' }, async ($, e) => {
121 const [verb, value] = e.args.trim().split(/\s+/)
122 if (verb === 'guard') {
123 const on_ = value !== 'off'
124 await update($, guard, () => on_)
125 return { text: on_
126 ? 'Posting guard on: gh/glab commands that comment, review, approve or merge are refused (dry run).'
127 : 'Posting guard off.' }
128 }
129 if (verb === 'budget') {
130 const n = Math.max(5, Math.min(60, Number(value) || 15))
131 await update($, budget, () => n)
132 return { text: `Seat tool-call budget set to ${n} (packet reads do not count).` }
133 }
134 await $.ui.open({ id: PANE, title: 'Review board' })
135 return { text: 'Review board opened.' }
136 })
137
138 // a review starts: typed by the person, or run by the model / the watcher
139 on('prompt.submit', async ($, e, next) => {
140 if (REVIEW_CMD.test(e.text.trim())) {
141 await startRun($, e.text.trim().replace(REVIEW_CMD, ''))
142 ensureTicker($)
143 }
144 return next(e)
145 })
146
147 // review seats: refuse general-purpose, record the rest as they start
148 on('agent.spawn', async ($, e, next) => {
149 const seat = e.prompt.match(SEAT_PROMPT)?.[1]
150 if (!seat) return next(e)
151 if (e.subagentType === 'general-purpose') {
152 return { deny: 'review-board: run review seats as `review-panel` agents (or forks with --fork), ' +
153 'not general-purpose — it re-sends ~100 unused tool schemas every turn.' }
154 }
155 const res = await next(e)
156 if (res.deny === undefined && res.agentId) {
157 const s: Seat = {
158 agentId: res.agentId, label: seat, kind: e.subagentType, calls: 0, budgetHits: 0,
159 state: 'reviewing', startedAt: await $.clock.now(), findings: 0, p0: 0, p1: 0, p2: 0,
160 }
161 let r = await read($, run)
162 if (!r) {
163 await startRun($, '')
164 r = await read($, run)
165 }
166 // the first seat shows the mode the review really ran in (auto resolves per PR)
167 const mode = e.subagentType === 'fork' ? 'fork' : 'packet'
168 await update($, run, cur => (cur ? { ...cur, mode: cur.seats.length ? cur.mode : mode, seats: [...cur.seats, s] } : cur))
169 ensureTicker($)
170 }
171 return res
172 }).catch(($, e, next) => next(e))
173
174 on('tool.call', async ($, e, next) => {
175 // 1. posting guard (any loop)
176 if (await read($, guard)) {
177 const cmd = e.tool === 'Bash' ? field(e, 'command') : ''
178 if ((cmd && POSTING.some(rx => rx.test(cmd))) || POSTING_MCP.test(e.tool)) {
179 await update($, run, cur => (cur ? { ...cur, denied: cur.denied + 1 } : cur))
180 return { deny: 'review-board guard is on: posting to GitHub/GitLab is refused (dry run). ' +
181 'Write the overview and findings to a local file instead. `/review-board guard off` to allow.' }
182 }
183 }
184
185 // 2. a review started by the model or the watcher, not typed
186 if (e.tool === 'Skill' && /(?:^|:)review-pr$/.test(field(e, 'skill'))) {
187 await startRun($, field(e, 'args'))
188 ensureTicker($)
189 }
190
191 // 3. the build gate's output file, so the pane can follow it
192 if (e.tool === 'Bash') {
193 const out = field(e, 'command').match(/build-gate\.py[^\n]*--out\s+(\S+)/)?.[1]
194 if (out) {
195 const b: Build = { path: out.replace(/^['"]|['"]$/g, ''), state: 'running' }
196 await update($, run, cur => (cur ? { ...cur, build: b } : cur))
197 }
198 }
199
200 // 4. seat budget
201 if (e.agentId) {
202 const r = await read($, run)
203 const seat = r?.seats.find(s => s.agentId === e.agentId)
204 if (seat) {
205 const isPacketRead = e.tool === 'Read' && /\/packets\/(?:common-\d+|seat-[A-F])\.md$/.test(field(e, 'file_path'))
206 const limit = await read($, budget)
207 if (!isPacketRead && seat.calls >= limit) {
208 await update($, run, cur => cur && {
209 ...cur,
210 seats: cur.seats.map(s => (s.agentId === seat.agentId ? { ...s, budgetHits: s.budgetHits + 1 } : s)),
211 })
212 return { deny: `review-board: Reviewer ${seat.label} has used its ${limit} tool calls. ` +
213 'Stop reading and return your findings JSON now with what you have.' }
214 }
215 if (!isPacketRead) {
216 await update($, run, cur => cur && {
217 ...cur,
218 seats: cur.seats.map(s => (s.agentId === seat.agentId ? { ...s, calls: s.calls + 1 } : s)),
219 })
220 }
221 }
222 }
223 return next(e)
224 }).catch(($, e, next) => next(e))
225
226 // a seat hands back; the main loop's turn ending closes the run
227 on('turn.complete', async ($, e, next) => {
228 const r = await read($, run)
229 if (r && !r.isFinished) {
230 const now = await $.clock.now()
231 if (e.agentId && r.seats.some(s => s.agentId === e.agentId)) {
232 const text = e.answer ?? ''
233 await update($, run, cur => cur && {
234 ...cur,
235 seats: cur.seats.map(s => (s.agentId === e.agentId ? {
236 ...s, state: e.isAborted ? 'stopped' : 'done', endedAt: now,
237 findings: count(text, 'P[012]'), p0: count(text, 'P0'), p1: count(text, 'P1'), p2: count(text, 'P2'),
238 } : s)),
239 })
240 } else if (!e.agentId && r.seats.length > 0 && r.seats.every(s => s.state !== 'reviewing')) {
241 await tick($)
242 const done = await update($, run, cur => cur && { ...cur, isFinished: true, endedAt: now })
243 if (done) {
244 const sum: RunSummary = {
245 pr: done.pr, mode: done.mode, minutes: Math.round((now - done.startedAt) / 6000) / 10,
246 cost: Math.round(done.cost * 100) / 100,
247 findings: done.seats.reduce((n, s) => n + s.findings, 0),
248 p0: done.seats.reduce((n, s) => n + s.p0, 0), p1: done.seats.reduce((n, s) => n + s.p1, 0),
249 denied: done.denied, at: now,
250 }
251 await update($, history, list => [sum, ...list].slice(0, 20))
252 await $.store.set('history', await read($, history))
253 $.ui.status(undefined)
254 $.ui.toast(`Review ${shortPr(done.pr)}: ${sum.findings} raw findings (P0 ${sum.p0}, P1 ${sum.p1}) · ` +
255 `$${sum.cost.toFixed(2)} · ${sum.minutes} min`)
256 }
257 }
258 }
259 return next(e)
260 })
261
262 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
263 const { Box, Text } = $.ui.resolve(e)
264 const r = await read($, run)
265 const isGuarded = await read($, guard)
266 const limit = await read($, budget)
267 const past = await read($, history)
268 const now = await $.clock.now()
269
270 const header = (
271 <Text dimColor>
272 guard {isGuarded ? 'ON (dry run)' : 'off'} · seat budget {limit} calls
273 </Text>
274 )
275 if (!r) {
276 return (
277 <Box flexDirection="column">
278 {header}
279 <Text dimColor>No review yet. Run /review-pr <url> [--fork].</Text>
280 {past.length > 0 && <Text bold>Recent</Text>}
281 {past.slice(0, 8).map(h => (
282 <Text dimColor>
283 {shortPr(h.pr)} · {h.mode} · {h.minutes} min · ${h.cost.toFixed(2)} · P0 {h.p0} P1 {h.p1}
284 </Text>
285 ))}
286 </Box>
287 )
288 }
289
290 const b = r.build
291 const buildLine = !b ? 'not started'
292 : b.state === 'running' ? 'running…'
293 : `${b.compiles === true ? 'compiles' : b.compiles === false ? 'BROKEN' : 'unverified'}` +
294 ` · analyzer ${b.errors ?? 0} err / ${b.warnings ?? 0} warn · pipeline ${b.pipeline ?? 'n/a'}` +
295 (b.seconds ? ` · ${Math.round(b.seconds / 60)} min` : '')
296 const totals = r.seats.reduce((t, s) => ({ f: t.f + s.findings, p0: t.p0 + s.p0, p1: t.p1 + s.p1 }),
297 { f: 0, p0: 0, p1: 0 })
298
299 return (
300 <Box flexDirection="column">
301 <Text bold>
302 {shortPr(r.pr)} · {r.mode} mode · {elapsed(r.startedAt, r.endedAt ?? now)} · ${r.cost.toFixed(2)}
303 {r.isFinished ? ' · done' : ''}
304 </Text>
305 {header}
306 <Text> </Text>
307 {r.seats.length === 0 && <Text dimColor>Building packets / waiting for seats…</Text>}
308 {r.seats.map(s => (
309 <Text color={s.state === 'reviewing' ? undefined : s.state === 'done' ? 'success' : 'warning'}>
310 {s.label} {(SEAT_NAMES[s.label] ?? '').padEnd(15)}{' '}
311 {s.state === 'reviewing' ? 'reviewing ' : s.state === 'done' ? 'done ' : 'stopped '}
312 {String(s.calls).padStart(2)}/{limit} calls
313 {s.budgetHits > 0 ? ` (+${s.budgetHits} refused)` : ''}
314 {s.state !== 'reviewing' ? ` · ${s.findings} findings` : ''}
315 {' · '}{elapsed(s.startedAt, s.endedAt ?? now)}
316 </Text>
317 ))}
318 <Text>D build-gate {buildLine}</Text>
319 <Text> </Text>
320 <Text>
321 raw findings {totals.f} · P0 {totals.p0} · P1 {totals.p1}
322 {r.denied > 0 ? ` · ${r.denied} posting call(s) refused` : ''}
323 </Text>
324 </Box>
325 )
326 })
327}
328types/index.d.ts 64 lines1export type SeatState = 'reviewing' | 'done' | 'stopped'
2
3export type Seat = {
4 agentId: string
5 label: string
6 kind: string
7 calls: number
8 budgetHits: number
9 state: SeatState
10 startedAt: number
11 endedAt?: number
12 findings: number
13 p0: number
14 p1: number
15 p2: number
16}
17
18export type Build = {
19 path: string
20 state: 'running' | 'done'
21 compiles?: boolean | null
22 errors?: number
23 warnings?: number
24 pipeline?: string
25 gates?: string[]
26 seconds?: number
27}
28
29export type Run = {
30 pr: string
31 mode: 'auto' | 'packet' | 'fork'
32 startedAt: number
33 costAtStart: number
34 cost: number
35 seats: Seat[]
36 build: Build | null
37 denied: number
38 isFinished: boolean
39 endedAt?: number
40}
41
42export type RunSummary = {
43 pr: string
44 mode: 'auto' | 'packet' | 'fork'
45 minutes: number
46 cost: number
47 findings: number
48 p0: number
49 p1: number
50 denied: number
51 at: number
52}
53
54declare module 'claude-code' {
55 interface PluginState {
56 'review-board': {
57 run: Run | null
58 guard: boolean
59 budget: number
60 history: RunSummary[]
61 }
62 }
63}
64