SLOPSHOPPER

rival

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

newpanebandguardprocessagents
v0.1.0MITupdated 2026-10-26Egoushka/claude-mods/mods/rival
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rival
│ ┃ rival ✕ › fix the failing auth test and add an audit log call │ ┃ No findings. │ ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · rival
No findings.
README

rival

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.

PartWhat it doesHooks
Reviewer agentrival: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 guardWhile 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 modelThe reviewer is not in the main model's agent list and cannot be dispatched by it.agent.offer
TriggerAt 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 paneThe row, the findings pane, the fix prompt. The row yields to a survey and sits above whatever other mods draw.ui.render

Settings

FieldDefaultMeaning
enabledtrueTurn the reviews off without uninstalling
modelsonnetAlias or id the reviewer runs on
minLines20Skip diffs with fewer changed lines
cooldownMinutes10Minutes between reviews; changes made meanwhile go to the next review. 0 reviews every changed turn
minConfidence50Drop findings the reviewer rates lower (0 to 100)

Cost

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.

Limits

  • The diff is git diff HEAD: untracked files are not in it. Outside a git repository nothing happens.
  • Finding quality is the model's. Treat a finding as a lead, which is why Fix these asks Claude to verify before it edits.
  • The review is a background subagent started by this mod. Whether the engine also hands its report to the main model as a notification is not documented; if it does, the JSON arrives in the transcript as well.
  • The Bash restriction is a pattern check, best effort, like the other guards in this repo.
Source 3 files
hooks/register.tsx 220 lines
1import { 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}
220
hooks/review.ts 111 lines
1import 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}
111
types/index.d.ts 22 lines
1export 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