SLOPSHOPPER

review-board

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.

newpaneguardcommandtoaststatus
★ 1v0.1.0no licenseupdated 2026-10-16NarekManukyan/pr-review-loop/mods/review-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-board
│ ┃ Review board ✕ › fix the failing auth test and add an audit log call │ ┃ guard off · seat budget 15 calls │ ┃ No review yet. Run /review-pr <url> ⏺ Read(src/auth.ts) │ ┃ [--fork]. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /review-board │ ⎿ review-board: Review board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Review board
guard off · seat budget 15 calls No review yet. Run /review-pr <url> [--fork].
README

pr-review-loop

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's CLAUDE.md / ADRs — those always win.


Table of contents


What you get

CommandDelivers toUse it for
/review-pr <PR_URL>inline comments on the PRa normal reviewer pass on one PR
/review-pr-watch [owner/repo]drives /review-pr in a loopauto-review PRs you're requested on
/review-pr-stats · CLI review-statsyour terminalreal 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.


Quick start

Option A — marketplace (recommended)

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.

Option B — one line (global, no marketplace)

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.

Option C — from the zip

unzip pr-review-loop.zip && ./pr-review-loop/install.sh

How it works (under the hood)

Every review, whichever command triggers it, runs the same pipeline:

trigger ─▶ recall memory ─▶ 5-agent panel ─▶ verify ─▶ report ─▶ deliver ─▶ record memory
             ▲                                                                   │
             └──────────────────────── next round ◀──────────────────────────────┘
  1. Recall memory — before reviewing, the panel loads this repo's past outcomes and any human watch items (see Review memory). This is calibration only; CLAUDE.md / ADRs still rank above it.
  2. Resolve the stack — the shared 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.

  1. 5-agent panel — five reviewers run in parallel, each applying the universal lenses + the loaded stack pack. They run on a minimal toolset (Read/Grep/Glob/Bash) — measured 33% cheaper than general-purpose agents, at no quality cost:
  2. A – architecture & patterns (layering, DI, state management, atomicity, reachability)
  3. B – correctness & edge cases (logic, null-safety, races/TOCTOU, idempotency, fail-closed security)
  4. C – performance & quality (hot-path work, complexity, naming, dead code, observability)
  5. D – build & CI parity (isolated worktree; mirrors the repo's real CI — formatter/linter/analyzer incl. build tags; errors are blockers; runs on haiku)
  6. E – seams & blast radius (the code outside the diff: is the new thing wired, drained like its siblings, consistent with its neighbours, and what does the downstream consumer dedup on?)

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.

  1. Report — findings are merged and de-duplicated (overlapping reviewers become one thread with "+1" replies), tagged P0/P1/P2, with a copy-paste fix prompt.
  2. Deliver — inline comments, an overview, a fix prompt and the platform's own review state (/review-pr).
  3. Record memory — findings + how the developer responded are written to a committed .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:

  • Next-round loop — recording feeds recall, so round N+1 is aware of rounds 1…N. The next round is kicked off by a PR re-request / new commits.
  • Distill loop — when a finding keeps getting the same developer verdict, a SessionStart nudge suggests promoting it into CLAUDE.md / an ADR. A human makes that change; the bot never edits its own rules.

Stack detection & lens packs

Review knowledge is split into two layers so the same engine works across every stack without a per-stack fork of the commands:

  • Universal lenses (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).
  • Stack lens packs (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:

RepoLoads
Flutter + BLoC_base-flutter + flutter-bloc
Flutter + MobX (+ Provider)_base-flutter + flutter-mobx (+ flutter-provider)
Go / Postgresgo-postgres
NestJSnestjs
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).


Commands

/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.

  • auto (default): packet mode on a PR's first review, fork mode on every later one (re-reviews and merge-only re-checks), decided from review memory by 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-stats

Real 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
  • token usage — from Claude Code's own session logs (~/.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 scoreboard — from .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.


Review board (optional mod)

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:

  • Board. A pane with each seat's progress (tool calls used, findings, time), the build gate's result, the running cost and a findings total. A short version shows in the status line, and a toast summarizes the run when it ends.
  • Seat budget. A seat's 16th tool call is refused with "return your findings now"; reads of its own packet don't count. In the benchmark seats ignored "batch your reads" in the prompt; this makes the rule stick. /review-board budget <n> changes it.
  • No general-purpose seats. Spawning a review seat as general-purpose is refused.
  • Posting guard. /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.

Loop / automation

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

Review memory

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):

  1. CLAUDE.md + ADRs — authoritative, hand-written.
  2. .review-memory/rules.md — curated, human-approved distilled rules.
  3. .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

ActionCommandEffect
recallmemory.py recall . --area "<paths>"surfaces watch items + prior verdicts before reviewing
recordmemory.py record . --input decisions.jsonlogs this round's findings + developer responses
watch itemmemory.py note . --area "<file>" --text "…"a sticky human "check this in future PRs" that resurfaces until closed
closememory.py close . --signature "<sig>"marks a watch item checked
distillmemory.py distill .lists recurring findings (candidates to codify)
ripememory.py ripe .findings with a consistent verdict, ready to codify (used by the nudge)
healthmemory.py health .review-health report: volume, dispute rate per category (precision), open debt
configmemory.py config . --initwrite/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.


Guardrails & safety

  • No surprise comments. Only /review-pr writes on GitLab/GitHub, as a normal reviewer would; --dry-run on the watcher posts nothing.
  • FACT vs ASSUMPTION. Only findings provable from the code are posted; the rest become questions.
  • Generated files skipped (*.g.dart, *.freezed.dart, lockfiles, etc.; extends from the repo's CLAUDE.md).
  • Per-cycle caps on loops; interactive confirmation only skipped inside /loop.
  • Memory never overrides CLAUDE.md/ADRs and never self-edits its rules.

Updating (for the team)

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

Requirements

  • Claude Code with plugin support.
  • gh CLI authenticated (GitHub) or glab (GitLab).
  • graphify (optional): semantic review-memory recall; auto-installed best-effort.
  • macOS / Linux for the hooks and shell scripts. On native Windows, use WSL.

Troubleshooting

  • graphify not installed → harmless; JSONL recall still works. Retry with ~/.claude/skills/.../scripts/ensure-graphify.sh --force (or pipx install graphifyy).
  • Watcher re-reviews nothing → check gh auth status / glab auth status, and that you're actually a requested reviewer on an open PR.
  • Nudge never appears → it only fires when a finding has recurred with a consistent verdict; run memory.py distill . to see candidates manually.

Repo layout

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.md
Source 2 files
hooks/register.tsx 328 lines
1import { 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 &lt;url&gt; [--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}
328
types/index.d.ts 64 lines
1export 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