Review a PR or a branch through four lenses (simplicity, functionality, security, maintainability), with fresh-context local subagents or a tribunal of…

One command that judges a change from four angles and returns one report. It runs locally with fresh-context Claude subagents, or as a tribunal of external AI coding harnesses that read the tree. Reports only: it changes no code, posts no comments, creates no branches and writes nothing inside the repository.
In priority order. Each lens is one self-contained prompt in prompts/lenses/; the same text goes to the local subagent and to every seat.
| Lens | What it judges |
|---|---|
simplicity | KISS: what the change can lose and still do its job. Dead code, yagni, hardening for load the repo does not have, wrappers, duplication. Never asks for more code. |
functionality | Grumpy, no benefit of the doubt: business logic, edge cases, requirements, every PR and commit claim marked VERIFIED / FALSE / UNPROVABLE, every symbol, package, flag and path shown to exist, tests that really fail. |
security | Read-only audit. Findings only above 80% confidence with a concrete attack path. |
maintainability | Readability, naming against the repo's own conventions, docs and comments that describe what the code does. |
Exclusive; pick one per run.
--effort= overrides the effort.--tribunal): every available seat runs every lens prompt in the worktree, detached in the background. Seats are blind to each other. Zero available seats aborts before anything runs.Findings from two or more sources are CONFIRMED. A single-source finding is verified against the code or marked UNCORROBORATED. The verdict comes only from findings that survived verification. Claude sources count as one voice.
Harnesses only, each in its own read-only or plan sandbox, with no approval bypass and no passthrough flags. The diff leaves the machine only through a harness you installed.
| Seat | Available when |
|---|---|
codex | codex on PATH. GPT-6 Astra low; GPT-6.1 Sol xhigh when the weekly Codex usage is above 60% |
antigravity | agy on PATH (merges a pinned read_file(*) allow-list into its settings on first use) |
grok | grok on PATH |
claude-cli | claude on PATH and logged in. Fable medium; Opus xhigh when the weekly Fable usage is above 80% |
kimi-cli | kimi and jq on PATH |
cursor-cli | cursor-agent on PATH |
codex-oss | codex and ollama on PATH and OLLAMA_REVIEWER_MODEL set, and the diff fits the model's context; its four runs go one at a time |
Wall-clock cap per process: 600 s up to 2000 changed lines, 1200 s up to 6000, 1800 s beyond. A timeout that was still producing output gets one retry at twice the cap, once per seat.
/plugin marketplace add CornelB/AI-review-council
/plugin install review-council@cornelb-plugins
A tribunal spans turns. For a prompt-free run add once to ~/.claude/settings.json permissions.allow:
"Bash(bash */review-council/scripts/prepare-run.sh *)",
"Bash(bash */review-council/scripts/run-tribunal.sh *)",
"Bash(bash */review-council/scripts/runs.sh *)"
/review-council:review 123
/review-council:review https://github.com/owner/repo/pull/123 --tribunal
/review-council:review
/review-council:review feature/branch --tribunal
/review-council:review feature/branch --effort=high
/review-council:status
/review-council:result
/review-council:result review-pr-123-20261015-101500
/review-council:result cancel review-pr-123-20261015-101500
Targets: PR number or URL, or a branch. Nothing named: the current branch, that is, the commits on it that the default branch does not have. Uncommitted changes are never in scope. A PR is reviewed in a detached worktree pinned to the PR head OID, with an explicit <merge-base>..<head> range, and the worktree is removed at the end.
/review-council:status lists every seat as available or disabled with the reason, and the Claude and codex models that apply now. /review-council:result lists runs newest first, prints a run's report (or the seat reports so far while a tribunal runs), or cancels a running tribunal.
While a tribunal runs, a REVIEW band above the prompt shows how many lens/seat processes finished (2 of 16), a line that fills with them, the run's clock and the latest process to end. When the run ends the band says how many reports came back ok for 20 seconds. The band is a Claude Code mod (mods/review-band/, loaded through hooks/hooks.json); it reads the run's status.json once a second and draws nothing when no tribunal is live.
| Variable | Effect |
|---|---|
OLLAMA_REVIEWER_MODEL | Enables the codex-oss seat with this local ollama model. |
CURSOR_CLI_MODEL, KIMI_CLI_MODEL | Model for those seats; unset uses the CLI's default. |
CLAUDE_SCRATCHPAD, TMPDIR | Parent of the default run directory, <dir>/review-council/review-<target>-<ts>/. |
XDG_STATE_HOME | Where the run index lives (review-council/runs.txt, default ~/.local/state). |
RC_CAP_SECONDS | Pins the per-process cap instead of sizing it from the diff. |
A run directory holds run.json, diff.patch, pr-body.md (PR), scope.md, prompts/<lens>.md, reports/<lens>.<seat>.md with .log and .status, status.json and report.md.
commands/ - review, status, result.agents/<lens>-reviewer.md - local lens subagents; each reads its assembled prompt file.prompts/lenses/ - the four lens prompts.skills/review-engine/SKILL.md - hard constraints, run layout, seat contract and setup.scripts/prepare-run.sh - run directory, scope and prompt assembly. scripts/pr-worktree.sh - PR worktree.scripts/run-tribunal.sh - seat discovery and the detached tribunal. scripts/runs.sh - run listing.scripts/seats/<seat>.sh - one per seat; every CLI flag lives there.mods/review-band/ - the progress band above the prompt. bun test in that folder runs its tests.tests/ - bats suite with fake CLIs. bats tests and shellcheck run in CI on every push.functionality and security lenses grew out of the grumpy-review skill.MIT.
mods/review-band/hooks/band.tsx 136 lines1// ABOUTME: Hooks module that draws a live tribunal run's progress in a band above the prompt
2// ABOUTME: Follows the run dir a run-tribunal.sh start or wait names, and polls its status.json
3import type { Elements, EngineInterface, Register, Timer } from 'claude-code'
4import { abandonedNotice, band, finishNotice, isLive, latestEvent, parseStatus, tribunalRunDir, type RunStatus } from './progress'
5
6const POLL_MS = 1000
7const PID_CHECK_MS = 5000
8// A run that never writes status.json (start refused, zero seats) is let go.
9const NO_STATUS_MS = 60_000
10const FINISH_NOTICE_MS = 20_000
11
12// Theme keys follow the person's theme; the chip fill is fixed so its white
13// letters keep their contrast on light and dark alike (claude-council's values).
14const COLOR = { accent: 'claude', success: 'success', danger: 'error', warning: 'warning', onFill: 'white' } as const
15const FILL = { chip: 'rgb(180,85,50)', chipMark: 'rgb(150,68,40)' } as const
16
17type Follow = { runDir: string; sinceMs: number; status?: RunStatus; event?: string; pidCheckedAtMs: number }
18type Notice = { text: string; isFailure: boolean; untilMs: number }
19type BandState = { run?: Follow; finished?: Notice; ended: Set<string>; timer?: Timer; nowMs: number }
20
21async function readStatus($: EngineInterface, runDir: string): Promise<RunStatus | undefined> {
22 const path = `${runDir}/status.json`
23 if (!(await $.fs.exists(path))) return undefined
24 return parseStatus(await $.fs.read(path))
25}
26
27function end(state: BandState, runDir: string, text: string, isFailure: boolean): void {
28 state.ended.add(runDir)
29 state.run = undefined
30 state.finished = { text, isFailure, untilMs: state.nowMs + FINISH_NOTICE_MS }
31}
32
33async function poll($: EngineInterface, state: BandState): Promise<void> {
34 state.nowMs = await $.clock.now()
35 if (state.finished && state.nowMs >= state.finished.untilMs) state.finished = undefined
36 const run = state.run
37 if (run) {
38 const status = await readStatus($, run.runDir)
39 if (status) {
40 run.event = latestEvent(run.status?.cells, status.cells) ?? run.event
41 run.status = status
42 if (!isLive(status)) {
43 const notice = finishNotice(status)
44 end(state, run.runDir, notice.text, notice.isFailure)
45 } else if (status.pid > 0 && state.nowMs - run.pidCheckedAtMs >= PID_CHECK_MS) {
46 // A SIGKILL skips the worker's final status write; without this the
47 // band would show the run as live for the rest of the session.
48 run.pidCheckedAtMs = state.nowMs
49 const alive = await $.process.run(['kill', '-0', String(status.pid)])
50 const again = alive.exitCode === 0 ? status : await readStatus($, run.runDir)
51 if (again && isLive(again) && alive.exitCode !== 0) end(state, run.runDir, abandonedNotice(again), true)
52 }
53 } else if (state.nowMs - run.sinceMs >= NO_STATUS_MS) {
54 end(state, run.runDir, abandonedNotice(undefined), true)
55 }
56 }
57 // The clock moves every second while a run is live, so every tick redraws.
58 $.ui.invalidate('ui.render')
59 if (!state.run && !state.finished) {
60 state.timer?.cancel()
61 state.timer = undefined
62 }
63}
64
65// Polling starts with the first tribunal command and stops once nothing is
66// left to show: a session that never runs a tribunal pays nothing.
67async function follow($: EngineInterface, state: BandState, runDir: string): Promise<void> {
68 if (state.ended.has(runDir) || state.run?.runDir === runDir) return
69 state.nowMs = await $.clock.now()
70 state.run = { runDir, sinceMs: state.nowMs, pidCheckedAtMs: state.nowMs }
71 state.finished = undefined
72 if (!state.timer) state.timer = $.clock.every(POLL_MS, () => { void poll($, state) })
73}
74
75function chip(ui: Pick<Elements['terminal'], 'Box' | 'Text'>) {
76 return (
77 <ui.Box key="chip" flexDirection="row" flexShrink={0}>
78 <ui.Text bold color={COLOR.onFill} backgroundColor={FILL.chipMark}>{' ✦ '}</ui.Text>
79 <ui.Text bold color={COLOR.onFill} backgroundColor={FILL.chip}>{' REVIEW '}</ui.Text>
80 </ui.Box>
81 )
82}
83
84export const register: Register = on => {
85 const state: BandState = { ended: new Set(), nowMs: 0 }
86
87 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
88 const runDir = tribunalRunDir(e.command)
89 // The band is a convenience: a failure to follow never holds the run up.
90 if (runDir) await follow($, state, runDir).catch((err: unknown) => $.ui.log(`review band: ${String(err)}`))
91 return next(e)
92 })
93
94 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
95 if (e.props.hasSurvey) return next(e)
96 const finished = state.finished
97 const run = state.run
98 if (!finished && !run) return next(e)
99 const ui = $.ui.resolve(e)
100 if (finished) {
101 return (
102 <ui.Box key="finished" flexDirection="row" marginTop={1}>
103 {chip(ui)}
104 <ui.Text bold {...(finished.isFailure ? { color: COLOR.danger } : {})}>{` ${finished.isFailure ? '✗' : '✓'} ${finished.text} `}</ui.Text>
105 <ui.Button key="finished:dismiss" hotkey="x" label={'x · dismiss'} onPress={() => { state.finished = undefined; $.ui.invalidate('ui.render') }} />
106 </ui.Box>
107 )
108 }
109 const status = run?.status
110 if (!run || !status) {
111 return (
112 <ui.Box key="starting" flexDirection="row" marginTop={1}>
113 {chip(ui)}
114 <ui.Text dimColor>{' starting…'}</ui.Text>
115 </ui.Box>
116 )
117 }
118 const shown = band(status, run.sinceMs, state.nowMs, run.event)
119 return (
120 <ui.Box key="progress" flexDirection="row" marginTop={1}>
121 {chip(ui)}
122 <ui.Box flexShrink={0}>
123 <ui.Text>
124 <ui.Text bold>{` ${shown.count}`}</ui.Text>
125 <ui.Text color={COLOR.accent}>{` ${shown.bar}`}</ui.Text>
126 <ui.Text bold color={COLOR.warning}>{` ● ${shown.clock}`}</ui.Text>
127 </ui.Text>
128 </ui.Box>
129 <ui.Box flexGrow={1} flexShrink={1}>
130 <ui.Text dimColor wrap="truncate-end">{` ${shown.event ?? ''} `}</ui.Text>
131 </ui.Box>
132 </ui.Box>
133 )
134 })
135}
136mods/review-band/hooks/progress.ts 96 lines1// ABOUTME: Words for a tribunal run's band above the prompt
2// ABOUTME: Pure functions over the Bash command that starts or waits on a run, and the status.json it writes
3
4export type Cell = { lens: string; seat: string; status: string }
5export type RunStatus = { state: string; pid: number; started: string; total: number; done: number; cells: Cell[] }
6export type Band = { count: string; bar: string; clock: string; event?: string }
7
8const LIVE_STATES = new Set(['prepared', 'running'])
9const BAR_CELLS = 8
10
11// The run dir a `run-tribunal.sh start` or `wait` names: its last word before
12// any shell operator. Other subcommands (discover, cancel, run) and a command
13// that only names the script (a grep, a cat) are not followed. Only an
14// absolute dir is followed, since the mod has no cwd to resolve one against.
15export function tribunalRunDir(command: string): string | undefined {
16 const match = /\bbash\s+["']?[^\s"']*run-tribunal\.sh["']?\s+(?:start|wait)\b([^;&|<>\n]*)/.exec(command)
17 if (!match) return undefined
18 const words = (match[1] ?? '').trim().split(/\s+/)
19 // `wait <dir> 2>&1` leaves the redirect's fd number as the last word.
20 if (/^\d$/.test(words.at(-1) ?? '')) words.pop()
21 const last = words.at(-1)?.replace(/^["']|["']$/g, '')
22 return last && last.startsWith('/') ? last.replace(/\/+$/, '') : undefined
23}
24
25// status.json as run-tribunal.sh writes it (through a tmp file and a rename,
26// so a read never sees half of it). Anything else reads as no status yet.
27export function parseStatus(text: string): RunStatus | undefined {
28 let raw: unknown
29 try {
30 raw = JSON.parse(text)
31 } catch {
32 return undefined
33 }
34 if (typeof raw !== 'object' || raw === null) return undefined
35 const r = raw as Record<string, unknown>
36 if (typeof r.state !== 'string' || !Array.isArray(r.cells)) return undefined
37 const cells = r.cells.filter((c): c is Cell => typeof c === 'object' && c !== null && typeof c.lens === 'string' && typeof c.seat === 'string' && typeof c.status === 'string')
38 return {
39 state: r.state,
40 pid: typeof r.pid === 'number' ? r.pid : 0,
41 started: typeof r.started === 'string' ? r.started : '',
42 total: typeof r.cells_total === 'number' ? r.cells_total : cells.length,
43 done: typeof r.cells_done === 'number' ? r.cells_done : cells.filter(c => c.status !== 'running').length,
44 cells,
45 }
46}
47
48export function isLive(status: RunStatus): boolean {
49 return LIVE_STATES.has(status.state)
50}
51
52// The newest cell to finish between two reads, as `security · codex ok`. The
53// first read has nothing to compare with, so it names no event.
54export function latestEvent(before: Cell[] | undefined, now: Cell[]): string | undefined {
55 if (!before) return undefined
56 const was = new Map(before.map(c => [`${c.lens}.${c.seat}`, c.status]))
57 const ended = now.filter(c => c.status !== 'running' && was.get(`${c.lens}.${c.seat}`) === 'running')
58 const last = ended.at(-1)
59 return last ? `${last.lens} · ${last.seat} ${last.status}` : undefined
60}
61
62// m:ss, or h:mm:ss past the hour, since the run's own start time.
63export function clock(startedMs: number, nowMs: number): string {
64 const secs = Math.max(0, Math.floor((nowMs - startedMs) / 1000))
65 const hours = Math.floor(secs / 3600)
66 const mins = Math.floor((secs % 3600) / 60)
67 const ss = String(secs % 60).padStart(2, '0')
68 return hours > 0 ? `${hours}:${String(mins).padStart(2, '0')}:${ss}` : `${mins}:${ss}`
69}
70
71// The line rounds down, so it never reads full while a cell is still out.
72export function band(status: RunStatus, followedSinceMs: number, nowMs: number, event?: string): Band {
73 const filled = status.total > 0 ? Math.floor((status.done / status.total) * BAR_CELLS) : 0
74 const startedMs = Date.parse(status.started)
75 return {
76 count: `${status.done} of ${status.total}`,
77 bar: '━'.repeat(filled) + '─'.repeat(BAR_CELLS - filled),
78 clock: clock(Number.isNaN(startedMs) ? followedSinceMs : startedMs, nowMs),
79 ...(event ? { event } : {}),
80 }
81}
82
83// What the band says once the run has ended. A seat that timed out or wrote
84// no valid report counts as done, not as ok.
85export function finishNotice(status: RunStatus): { text: string; isFailure: boolean } {
86 const ok = status.cells.filter(c => c.status === 'ok').length
87 const notOk = status.done - ok
88 const tally = `${ok} of ${status.total} reports ok${notOk > 0 ? `, ${notOk} failed` : ''}`
89 if (status.state === 'done') return { text: `finished: ${tally}`, isFailure: false }
90 return { text: `${status.state}: ${tally}`, isFailure: true }
91}
92
93export function abandonedNotice(status: RunStatus | undefined): string {
94 return status ? `stopped before it finished: ${status.done} of ${status.total} done` : 'stopped before it wrote any status'
95}
96