SLOPSHOPPER

rubber-duck

A fast second opinion on every edit: a small model flags removed error handling, deleted tests, secrets and more

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-04ShriD5/claude-mods/rubber-duck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rubber-duck
│ ┃ Rubber duck ✕ › fix the failing auth test and add an audit log call │ ┃ No concerns yet. Quack. │ ⏺ 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 │ │ › /duck │ ⎿ rubber-duck: 🦆 0 concerns in the duck pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Rubber duck
No concerns yet. Quack.
README

rubber-duck

A fast second opinion on every edit Claude makes.

After each successful Edit, MultiEdit or Write, the change goes to a small, fast model (Haiku by default) with one question: is there a real problem here? It looks for removed error handling, a deleted or disabled test, a swallowed exception, a hardcoded secret or URL, an off-by-one, a changed public signature, or a TODO left in. If it finds one you get a toast:

🦆 Removed error handling around fetch (api.ts)

Otherwise it stays quiet. /duck opens a pane with the last 20 concerns (time, file, concern) and a Clear button.

It never blocks or slows the edit: the review runs after the tool call has returned, and if it fails, nothing happens (details go to the debug log). At most one review per file runs at a time; if Claude edits a file again meanwhile, only the latest edit gets reviewed next. Lockfiles, build output (dist/, node_modules/, ...), minified or generated files, and files over 200 KB are skipped.

Install

claude --plugin-dir /path/to/claude-mods/rubber-duck

Or add the repo as a marketplace and install it with /plugin.

Options

OptionDefaultWhat it does
modelhaikuthe review model: an alias or a full model id
tellClaudefalsealso pass new concerns to Claude with your next prompt, so it can fix them or call them false alarms

How it works

A tool.call hook awaits next(e), takes the diff from the tool's own patch, and starts the review on a $.clock.after timer, which calls $.model.complete. Concerns live in $.state, which the /duck pane reads, and with tellClaude on a prompt.submit hook adds them to your next prompt as context.

Source 3 files
hooks/register.tsx 135 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DuckConcern } from '../types'
5import { basename, changeOf, concernOf, isSkippable, MAX_FILE_BYTES, promptOf, SYSTEM, type Change } from './duck'
6
7const PANE = 'rubber-duck'
8const KEEP = 20
9const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write'])
10
11const concerns = atom({ plugin: 'rubber-duck', key: 'concerns' } as const, [] as DuckConcern[])
12const unsent = atom({ plugin: 'rubber-duck', key: 'unsent' } as const, [] as DuckConcern[])
13
14type Settings = { model: string; tellClaude: boolean }
15
16// At most one review in flight per file: a newer edit made meanwhile waits,
17// and only the latest one waiting is reviewed next.
18const inFlight = new Set<string>()
19const waiting = new Map<string, Change>()
20
21const review = async ($: EngineInterface, change: Change, settings: Settings) => {
22  try {
23    const stat = await $.fs.stat(change.file).catch(() => undefined)
24    if (stat !== undefined && stat.size > MAX_FILE_BYTES) return
25    const reply = await $.model.complete({
26      model: settings.model,
27      system: SYSTEM,
28      prompt: promptOf(change),
29      maxTokens: 60,
30      effort: 'low',
31      timeoutMs: 20_000,
32    })
33    if (!reply.isAnswered) return $.ui.log(`rubber-duck: no review (${reply.reason})`, { to: 'debug' })
34    const concern = concernOf(reply.text)
35    if (concern === undefined) return
36
37    const flagged: DuckConcern = { file: change.file, concern, at: await $.clock.now() }
38    await update($, concerns, list => [...list, flagged].slice(-KEEP))
39    if (settings.tellClaude) await update($, unsent, list => [...list, flagged].slice(-KEEP))
40    $.ui.toast(`🦆 ${concern} (${basename(change.file)})`, { timeoutMs: 8000 })
41  } catch (error) {
42    $.ui.log(`rubber-duck: review failed: ${String(error)}`, { to: 'debug' })
43  }
44}
45
46const reviewFile = async ($: EngineInterface, first: Change, settings: Settings) => {
47  let change: Change | undefined = first
48  while (change !== undefined) {
49    await review($, change, settings)
50    change = waiting.get(first.file)
51    waiting.delete(first.file)
52  }
53  inFlight.delete(first.file)
54}
55
56const timeOf = (at: number) => {
57  const date = new Date(at)
58  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
59}
60
61export const register: Register = (on, options) => {
62  const settings: Settings = {
63    model: String(options.model || 'haiku'),
64    tellClaude: options.tellClaude === true,
65  }
66
67  on('session.start', async ($, e, next) => {
68    await $.command.register({ name: 'duck', description: "Show the rubber duck's recent concerns" })
69    return next(e)
70  })
71
72  // React after a successful edit; the review runs on a timer so the tool
73  // call returns at once and a slow or failed review never touches it.
74  on('tool.call', async ($, e, next) => {
75    const ran = await next(e)
76    if (!EDIT_TOOLS.has(String(e.tool)) || ran.deny !== undefined || ran.isError) return ran
77    const change = changeOf(e as unknown as Record<string, unknown>, ran.result)
78    if (change === undefined || isSkippable(change.file)) return ran
79
80    if (inFlight.has(change.file)) {
81      waiting.set(change.file, change)
82    } else {
83      inFlight.add(change.file)
84      $.clock.after(0, () => void reviewFile($, change, settings))
85    }
86    return ran
87  })
88
89  on('prompt.submit', async ($, e, next) => {
90    if (!settings.tellClaude) return next(e)
91    const pending = await read($, unsent)
92    if (pending.length === 0) return next(e)
93
94    await update($, unsent, () => [])
95    const note = [
96      'rubber-duck (a quick second-opinion reviewer on a small model) flagged these in your recent edits.',
97      "Check each one: fix it if it's real, and say so briefly if it's a false alarm.",
98      ...pending.map(one => `- ${one.file}: ${one.concern}`),
99    ].join('\n')
100
101    return next({ ...e, context: [...(e.context ?? []), note] })
102  })
103
104  on('command.run', { command: 'duck' }, async $ => {
105    await $.ui.open({ id: PANE, title: 'Rubber duck' })
106    const count = (await read($, concerns)).length
107
108    return { text: `🦆 ${count} concern${count === 1 ? '' : 's'} in the duck pane.` }
109  })
110
111  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
112    const { Box, Button, Text } = $.ui.resolve(e)
113    const list = await read($, concerns)
114
115    return (
116      <Box flexDirection="column" gap={1}>
117        {list.length === 0 && <Text dimColor>No concerns yet. Quack.</Text>}
118        {[...list].reverse().map(one => (
119          <Box flexDirection="column">
120            <Text>
121              <Text dimColor>{timeOf(one.at)}</Text> <Text bold>{basename(one.file)}</Text>
122            </Text>
123            <Text>{one.concern}</Text>
124          </Box>
125        ))}
126        {list.length > 0 && (
127          <Box>
128            <Button key="clear" label="Clear" hotkey="c" onPress={() => update($, concerns, () => [])} />
129          </Box>
130        )}
131      </Box>
132    )
133  })
134}
135
hooks/duck.ts 72 lines
1// What the duck looks at, what it asks the model, and how it reads the answer.
2
3export type Change = { file: string; diff: string }
4
5export const MAX_FILE_BYTES = 200 * 1024
6const MAX_DIFF_CHARS = 6000
7const MAX_CONCERN_CHARS = 140
8
9const LOCKFILE =
10  /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|bun\.lockb|go\.sum|[^/]+\.lock)$/
11const GENERATED_DIR =
12  /(^|\/)(node_modules|dist|build|out|coverage|vendor|target|generated|__generated__|\.next|\.nuxt|\.svelte-kit|\.output|\.turbo|\.vercel|\.wrangler)\//
13const GENERATED_FILE = /(\.min\.(js|mjs|css)|\.map|\.snap|\.pb\.go|_pb2\.py|\.g\.dart)$|\.generated\./
14
15/** Lockfiles, build output and minified or generated files are not worth a review. */
16export const isSkippable = (file: string): boolean =>
17  LOCKFILE.test(file) || GENERATED_DIR.test(file) || GENERATED_FILE.test(file)
18
19export const basename = (file: string): string => file.split(/[\\/]/).pop() || file
20
21type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
22
23const isHunk = (value: unknown): value is Hunk =>
24  typeof value === 'object' && value !== null && Array.isArray((value as Hunk).lines)
25
26const prefixed = (sign: '-' | '+', text: unknown): string[] =>
27  typeof text === 'string' && text !== '' ? text.split('\n').map(line => `${sign}${line}`) : []
28
29/**
30 * The change an Edit, MultiEdit or Write made, as a unified diff cut to fit a
31 * small prompt: from the tool's own patch when it has one, else its input.
32 */
33export const changeOf = (input: Record<string, unknown>, result: unknown): Change | undefined => {
34  const file = input.file_path
35  const output = (typeof result === 'object' && result !== null ? result : {}) as Record<string, unknown>
36  if (typeof file !== 'string' || output.staged === true) return undefined
37  if (typeof input.content === 'string' && input.content.length > MAX_FILE_BYTES) return undefined
38
39  const hunks = Array.isArray(output.structuredPatch) ? output.structuredPatch.filter(isHunk) : []
40  const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : [input]
41  const lines = hunks.length
42    ? hunks.flatMap(h => [`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, ...h.lines])
43    : [
44        ...edits.flatMap(edit => [...prefixed('-', edit.old_string), ...prefixed('+', edit.new_string)]),
45        ...prefixed('+', input.content),
46      ]
47  if (!lines.some(line => /^[-+]/.test(line))) return undefined
48  const diff = lines.join('\n')
49
50  return { file, diff: diff.length > MAX_DIFF_CHARS ? `${diff.slice(0, MAX_DIFF_CHARS)}\n[... cut]` : diff }
51}
52
53export const SYSTEM = [
54  'You are a rubber duck reviewing one code change for a busy developer.',
55  'Reply with ONE short concern, under 15 words, only if the change clearly introduces one of these:',
56  'removed error handling, a deleted or disabled test, a swallowed exception, a hardcoded secret, token or URL,',
57  'an off-by-one, a changed public signature, or a TODO or FIXME left in.',
58  'Ignore style, naming, formatting and missing comments.',
59  'If nothing is clearly wrong, reply with exactly: OK',
60].join(' ')
61
62export const promptOf = (change: Change): string =>
63  `File: ${change.file}\n\nDiff (- removed, + added):\n${change.diff}`
64
65/** The concern in the model's reply, or undefined for OK (or nothing). */
66export const concernOf = (reply: string): string | undefined => {
67  const line = (reply.trim().split('\n')[0] ?? '').replace(/^[\s"'`*-]+|[\s"'`*]+$/g, '')
68  if (line === '' || /^ok\b/i.test(line)) return undefined
69
70  return line.length > MAX_CONCERN_CHARS ? `${line.slice(0, MAX_CONCERN_CHARS - 3)}...` : line
71}
72
types/index.d.ts 10 lines
1/** One thing the duck flagged: the file, the concern, and when (ms since the epoch). */
2export type DuckConcern = { file: string; concern: string; at: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    // `concerns`: the last 20, for the /duck pane. `unsent`: not yet told to Claude.
7    'rubber-duck': { concerns: DuckConcern[]; unsent: DuckConcern[] }
8  }
9}
10