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

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.
claude --plugin-dir /path/to/claude-mods/rubber-duck
Or add the repo as a marketplace and install it with /plugin.
| Option | Default | What it does |
|---|---|---|
model | haiku | the review model: an alias or a full model id |
tellClaude | false | also pass new concerns to Claude with your next prompt, so it can fix them or call them false alarms |
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.
hooks/register.tsx 135 lines1import { 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}
135hooks/duck.ts 72 lines1// 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}
72types/index.d.ts 10 lines1/** 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