A quiet second reviewer: after a turn that changed files, a read-only subagent looks for correctness bugs in the diff and reports in a band above the prompt

A quiet second reviewer. After a turn that changed files, a read-only subagent reads the diff and looks for correctness bugs. A row above the prompt says rival: 2 findings with Show (a pane listing each as file:line, a concrete failure scenario and a confidence), Fix these (submits a prompt that quotes the findings and asks Claude to verify each and fix only the real ones) and Dismiss.
| Part | What it does | Hooks |
|---|---|---|
| Reviewer agent | rival:reviewer, registered at session start. Tools: Read, Grep, Glob, Bash. Its prompt asks for correctness bugs only, each with file:line, a failing scenario and a confidence, as JSON. No style, naming or test remarks. | $.agent.register |
| Read-only guard | While the reviewer runs, its Bash is limited to git diff, git log and git show (no chaining, redirects, --output, -c), and any tool outside the four is refused. | tool.call |
| Hidden from the model | The reviewer is not in the main model's agent list and cannot be dispatched by it. | agent.offer |
| Trigger | At the end of a main-loop turn, git diff HEAD --stat is hashed. A changed hash with at least minLines changed lines starts a background review. One review at a time, at most one per cooldownMinutes; an unchanged diff is never reviewed twice. | turn.complete, $.agent.spawn |
| Band and pane | The row, the findings pane, the fix prompt. The row yields to a survey and sits above whatever other mods draw. | ui.render |
| Field | Default | Meaning |
|---|---|---|
enabled | true | Turn the reviews off without uninstalling |
model | sonnet | Alias or id the reviewer runs on |
minLines | 20 | Skip diffs with fewer changed lines |
cooldownMinutes | 10 | Minutes between reviews; changes made meanwhile go to the next review. 0 reviews every changed turn |
minConfidence | 50 | Drop findings the reviewer rates lower (0 to 100) |
Every qualifying turn is one extra subagent run on model, billed to you, with its own prompt (it does not share the main session's cache and loads no CLAUDE.md). Input is the diff (cut at 60,000 characters, about 15k tokens at most) plus whatever files it reads over at most 14 turns; output is a few hundred tokens. Expect on the order of 20k to 80k input tokens per review on a mid-size change, so a long session of small edits adds up. Raise minLines or cooldownMinutes, pick a cheaper model, or set enabled to false to cut it. These numbers are estimates, not measurements.
git diff HEAD: untracked files are not in it. Outside a git repository nothing happens.hooks/register.tsx 220 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RivalFinding, RivalReview } from '../types'
5import {
6 REVIEWER_PROMPT,
7 REVIEWER_TOOLS,
8 REVIEWER_TYPE,
9 buildTask,
10 changedLines,
11 fixPrompt,
12 hashText,
13 isReviewerBash,
14 isReviewerTool,
15 parseFindings,
16 where,
17} from './review'
18
19const PANE = 'rival'
20const STALE_MS = 15 * 60_000
21
22const initial: RivalReview = { status: 'idle', findings: [], lines: 0, message: '', isDismissed: false }
23const review = atom({ plugin: 'rival', key: 'review' } as const, initial)
24
25// Per-session bookkeeping; a reload starts it over.
26type Ctx = {
27 isOn: boolean
28 model: string
29 minLines: number
30 minConfidence: number
31 cwd: string
32 lastStat: string
33 runningId: string | undefined
34 isSpawning: boolean
35 startedAt: number
36 cooldownMs: number
37 lastReviewAt: number
38}
39
40const set = ($: EngineInterface, patch: Partial<RivalReview>) => update($, review, s => ({ ...s, ...patch }))
41
42async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | undefined> {
43 const r = await $.process.run(['git', '-C', cwd, ...args]).catch(() => undefined)
44 return r && r.exitCode === 0 ? r.stdout : undefined
45}
46
47// Runs at the end of a main-loop turn; a reviewer already running, an unchanged diff or a small one end it quietly.
48async function maybeReview($: EngineInterface, ctx: Ctx): Promise<void> {
49 const now = await $.clock.now()
50 if (ctx.runningId !== undefined || ctx.isSpawning) {
51 if (now - ctx.startedAt < STALE_MS) return
52 ctx.runningId = undefined
53 ctx.isSpawning = false
54 }
55 // The diff hash is left alone during the cooldown, so the next turn after it still reviews.
56 if (ctx.lastReviewAt && now - ctx.lastReviewAt < ctx.cooldownMs) return
57 const stat = await git($, ctx.cwd, ['diff', 'HEAD', '--stat'])
58 if (stat === undefined) return
59 const hash = hashText(stat)
60 if (hash === ctx.lastStat) return
61 ctx.lastStat = hash
62 if (stat.trim() === '') return
63
64 const diff = await git($, ctx.cwd, ['diff', 'HEAD'])
65 if (diff === undefined) return
66 const lines = changedLines(diff)
67 if (lines < ctx.minLines) return
68
69 ctx.isSpawning = true
70 ctx.startedAt = now
71 ctx.lastReviewAt = now
72 await set($, { status: 'running', findings: [], lines, message: '', isDismissed: false })
73 const spawned = await $.agent
74 .spawn({ subagentType: REVIEWER_TYPE, prompt: buildTask(diff), description: 'rival: review the diff', model: ctx.model })
75 .catch((err: unknown) => ({ deny: String(err) }))
76 // The id is the engine's to set; a run that did not state it is found in the agent list.
77 const id = spawned.deny === undefined ? (spawned.agentId ?? (await newestReviewer($))) : undefined
78 ctx.isSpawning = false
79 if (id === undefined) {
80 await set($, { status: 'error', message: spawned.deny ?? 'the reviewer did not start' })
81 return
82 }
83 ctx.runningId = id
84}
85
86async function newestReviewer($: EngineInterface): Promise<string | undefined> {
87 const agents = await $.agent.list().catch(() => [])
88 return agents.filter(a => a.type === REVIEWER_TYPE && !['completed', 'failed', 'killed'].includes(a.status)).at(-1)?.id
89}
90
91async function finish($: EngineInterface, ctx: Ctx, answer: string, reason: string): Promise<void> {
92 ctx.runningId = undefined
93 if (reason !== 'answer') return void (await set($, { status: 'error', message: `the reviewer ended early (${reason})` }))
94 const findings = parseFindings(answer, ctx.minConfidence)
95 if (findings === undefined) return void (await set($, { status: 'error', message: 'the reviewer did not answer in JSON' }))
96 await set($, { status: 'done', findings, message: '' })
97}
98
99async function openPane($: EngineInterface) {
100 await $.ui.open({ id: PANE, title: 'rival', focus: true, closeOnEscape: true })
101}
102
103async function fixThese($: EngineInterface, findings: readonly RivalFinding[]) {
104 await set($, { isDismissed: true })
105 await $.ui.close({ id: PANE }).catch(() => undefined)
106 await $.prompt.submit({ text: fixPrompt(findings) })
107}
108
109export const register: Register = (on, options) => {
110 const ctx: Ctx = {
111 isOn: options.enabled !== false,
112 model: typeof options.model === 'string' && options.model.trim() ? options.model.trim() : 'sonnet',
113 minLines: typeof options.minLines === 'number' ? options.minLines : 20,
114 minConfidence: typeof options.minConfidence === 'number' ? options.minConfidence : 50,
115 cwd: '',
116 lastStat: '',
117 runningId: undefined,
118 isSpawning: false,
119 startedAt: 0,
120 cooldownMs: (typeof options.cooldownMinutes === 'number' ? options.cooldownMinutes : 10) * 60_000,
121 lastReviewAt: 0,
122 }
123
124 on('session.start', async ($, e, next) => {
125 ctx.cwd = e.cwd
126 await update($, review, () => initial)
127 if (ctx.isOn) {
128 // Read-only by tool list; the tool.call hook below narrows Bash to git diff, log, show, grep and blame.
129 await $.agent
130 .register({
131 name: 'reviewer',
132 description: 'Internal second reviewer for the rival plugin. Not for delegation.',
133 prompt: REVIEWER_PROMPT,
134 tools: REVIEWER_TOOLS,
135 disallowedTools: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Agent'],
136 model: ctx.model,
137 maxTurns: 14,
138 omitClaudeMd: true,
139 background: true,
140 })
141 .catch(err => $.ui.log(`rival: could not register the reviewer (${String(err)})`))
142 }
143 return next(e)
144 })
145
146 // The reviewer is rival's own: the model never sees it listed or can dispatch it.
147 on('agent.offer', { agent: 'rival:reviewer' }, () => ({ isOffered: false })).catch(() => ({ isOffered: false }))
148
149 on('tool.call', async ($, e, next) => {
150 // A subagent that is rival's reviewer; before spawn() returns its id, any subagent during the spawn counts.
151 const isReviewer = e.agentId !== undefined && (e.agentId === ctx.runningId || (ctx.isSpawning && ctx.runningId === undefined))
152 if (!isReviewer) return next(e)
153 const tool = String(e.tool)
154 if (!isReviewerTool(tool)) return { deny: 'rival: the reviewer is read-only.' }
155 if (e.tool === 'Bash' && !isReviewerBash(e.command)) return { deny: 'rival: Bash runs one plain git diff, log, show, grep or blame (git -C <dir> for another folder; no cd, && or pipes). Read files with Read, Grep and Glob.' }
156 return next(e)
157 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'rival: its guard failed; retry once.' }))
158
159 on('turn.complete', async ($, e, next) => {
160 if (!ctx.isOn) return next(e)
161 if (e.agentId !== undefined) {
162 if (e.agentId === ctx.runningId) await finish($, ctx, e.answer, e.reason)
163 } else if (e.reason === 'answer') {
164 await maybeReview($, ctx).catch(err => set($, { status: 'error', message: String(err).slice(0, 200) }))
165 }
166 return next(e)
167 })
168
169 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170 const rest = await next(e)
171 const s = await read($, review)
172 if (!ctx.isOn || e.props.hasSurvey || s.status === 'idle' || s.isDismissed) return rest
173 const { Box, Text, Button } = $.ui.resolve(e)
174 const n = s.findings.length
175 const mine =
176 s.status === 'running' ? (
177 <Text dimColor>rival: reviewing {s.lines} changed lines…</Text>
178 ) : s.status === 'error' ? (
179 <Box>
180 <Text dimColor>rival: {s.message} </Text>
181 <Button key="dismiss" label="Dismiss" onPress={() => set($, { isDismissed: true })} />
182 </Box>
183 ) : (
184 <Box>
185 <Text>
186 rival: {n === 0 ? 'no findings' : `${n} finding${n === 1 ? '' : 's'}`}{' '}
187 </Text>
188 {n > 0 && <Button key="show" label="Show" onPress={() => openPane($)} />}
189 {n > 0 && <Button key="fix" label="Fix these" variant="primary" onPress={() => fixThese($, s.findings)} />}
190 <Button key="dismiss" label="Dismiss" onPress={() => set($, { isDismissed: true })} />
191 </Box>
192 )
193 return (
194 <Box flexDirection="column">
195 {mine}
196 {rest}
197 </Box>
198 )
199 })
200
201 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
202 const { Box, Text } = $.ui.resolve(e)
203 const s = await read($, review)
204 return (
205 <Box flexDirection="column">
206 {s.findings.length === 0 && <Text dimColor>No findings.</Text>}
207 {s.findings.map((f, i) => (
208 <Box key={`f${i}`} flexDirection="column" marginBottom={1}>
209 <Text bold>
210 {where(f)} <Text dimColor>({f.confidence}% sure)</Text>
211 </Text>
212 <Text>{f.title}</Text>
213 <Text dimColor>{f.scenario}</Text>
214 </Box>
215 ))}
216 </Box>
217 )
218 })
219}
220hooks/review.ts 111 lines1import type { RivalFinding } from '../types'
2
3export const REVIEWER_TYPE = 'rival:reviewer'
4export const REVIEWER_TOOLS = ['Read', 'Grep', 'Glob', 'Bash'] as const
5const MAX_DIFF_CHARS = 60_000
6
7export const REVIEWER_PROMPT = `You are a second reviewer. Another engineer just changed this working tree; you review the diff for correctness bugs only.
8
9Report a finding only if you can name a concrete input or sequence of events under which the code does the wrong thing: wrong result, crash, data loss, race, security hole, broken contract with a caller you have read. Read the surrounding code and the callers before you decide. Do not report style, naming, formatting, missing tests, missing comments, speculative "might" concerns, or taste.
10
11Read code with the Read, Grep and Glob tools. Bash runs one plain git command only: diff, log, show, grep or blame, with no cd, &&, pipes or redirects; use git -C <dir> for another folder. You cannot change anything.
12
13Finish with one JSON object and nothing after it:
14{"findings":[{"file":"path/relative/to/repo","line":123,"title":"one line","scenario":"the concrete input or sequence that fails, and what goes wrong","confidence":0-100}]}
15"confidence" is how sure you are, as a whole number. If you find nothing, answer {"findings":[]}.`
16
17export function changedLines(diff: string): number {
18 let n = 0
19 for (const line of diff.split('\n')) {
20 if ((line.startsWith('+') && !line.startsWith('+++')) || (line.startsWith('-') && !line.startsWith('---'))) n += 1
21 }
22 return n
23}
24
25// FNV-1a: a cheap stable fingerprint of `git diff --stat`.
26export function hashText(text: string): string {
27 let h = 0x811c9dc5
28 for (let i = 0; i < text.length; i += 1) {
29 h ^= text.charCodeAt(i)
30 h = Math.imul(h, 0x01000193) >>> 0
31 }
32 return h.toString(16)
33}
34
35export function buildTask(diff: string): string {
36 const isCut = diff.length > MAX_DIFF_CHARS
37 const body = isCut ? diff.slice(0, MAX_DIFF_CHARS) : diff
38 return (
39 'Review this uncommitted diff (git diff HEAD).' +
40 (isCut ? ' It is cut; run `git diff HEAD -- <path>` to read the rest.' : '') +
41 `\n\n\`\`\`diff\n${body}\n\`\`\``
42 )
43}
44
45// The reviewer's Bash is git diff, log or show and nothing else: no chaining, no redirects, no output files.
46export function isReviewerBash(command: string): boolean {
47 const c = command.trim()
48 if (!/^git (-C \S+ )?(diff|log|show|grep|blame)( |$)/.test(c)) return false
49 if (/[;&|`$()<>\n\\]/.test(c)) return false
50 return !/(--output|--ext-diff|--textconv|\s-c\s|--config|--exec|--open-files-in-pager|\s-O)/.test(c)
51}
52
53export function isReviewerTool(tool: string): boolean {
54 return (REVIEWER_TOOLS as readonly string[]).includes(tool)
55}
56
57function extractJson(text: string): unknown {
58 const fenced = /```(?:json)?\s*([\s\S]*?)```/g
59 const candidates: string[] = []
60 for (const m of text.matchAll(fenced)) if (m[1]) candidates.push(m[1])
61 const first = text.indexOf('{')
62 const last = text.lastIndexOf('}')
63 if (first >= 0 && last > first) candidates.push(text.slice(first, last + 1))
64 for (const c of candidates.reverse()) {
65 try {
66 return JSON.parse(c)
67 } catch {
68 /* try the next candidate */
69 }
70 }
71 return undefined
72}
73
74// undefined: no JSON at all. []: the reviewer found nothing (or nothing above minConfidence).
75export function parseFindings(text: string, minConfidence: number): RivalFinding[] | undefined {
76 const json = extractJson(text)
77 const list = Array.isArray(json) ? json : (json as { findings?: unknown } | undefined)?.findings
78 if (!Array.isArray(list)) return undefined
79 const out: RivalFinding[] = []
80 for (const raw of list) {
81 const f = raw as Record<string, unknown> | null
82 if (!f || typeof f.file !== 'string' || typeof f.title !== 'string') continue
83 const line = Number(f.line)
84 const rawConfidence = Number(f.confidence)
85 const confidence = Number.isFinite(rawConfidence) ? Math.round(rawConfidence <= 1 ? rawConfidence * 100 : rawConfidence) : 50
86 if (confidence < minConfidence) continue
87 out.push({
88 file: f.file,
89 line: Number.isFinite(line) && line > 0 ? Math.floor(line) : 0,
90 title: f.title.slice(0, 200),
91 scenario: typeof f.scenario === 'string' ? f.scenario.slice(0, 1000) : '',
92 confidence: Math.max(0, Math.min(100, confidence)),
93 })
94 }
95 return out.sort((a, b) => b.confidence - a.confidence)
96}
97
98export const where = (f: RivalFinding) => (f.line > 0 ? `${f.file}:${f.line}` : f.file)
99
100export function fixPrompt(findings: readonly RivalFinding[]): string {
101 const list = findings
102 .map((f, i) => `${i + 1}. ${where(f)} (confidence ${f.confidence}%): ${f.title}\n Failure: ${f.scenario}`)
103 .join('\n')
104 return (
105 'A second reviewer read my uncommitted diff and reports these possible correctness bugs:\n\n' +
106 `${list}\n\n` +
107 'Verify each one against the code first: open the file, trace the scenario, and decide whether it is real. ' +
108 'Fix the real ones with the smallest change, and for each one you reject say in one line why. Do not touch anything else.'
109 )
110}
111types/index.d.ts 22 lines1export type RivalFinding = {
2 file: string
3 line: number
4 title: string
5 scenario: string
6 confidence: number
7}
8
9export type RivalReview = {
10 status: 'idle' | 'running' | 'done' | 'error'
11 findings: RivalFinding[]
12 lines: number
13 message: string
14 isDismissed: boolean
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 rival: { review: RivalReview }
20 }
21}
22