Local inbox for anything that could be better: collect_feedback records pain points, slow steps, ambiguities, bugs, and work worth automating, in the project…

A local inbox for anything that could be better: pain points, slow steps, ambiguities, bugs, and work worth automating, in the project being worked on or in the AI setup. It is an MCP server with three tools.
collect_feedback records a bug, pain_point, ambiguity, or idea with a title, details, and optional area, severity, and evidence. The server also stores the time and the working directory it ran in.read_feedback lists entries newest first, for the current project unless project is all, filtered by status (open by default), kind, area, severity, or a text query.update_feedback resolves an entry, closes it as wontfix, reopens it, or corrects its fields. Updates are appended as { "op": "update" } lines and merged on read, so concurrent sessions never overwrite each other.A mod (hooks/register.tsx) adds the UI:
/feedback opens a pane listing this project's entries newest first, with a filter button per kind; p toggles to every project. The footer counts this project's open entries./fb <text> logs a pain_point through collect_feedback without a model turn.collect_feedback call shows a toast and a card above the prompt; the footer counts open entries by severity.An async Stop hook (hooks/gap-nudge.cjs) scores each finished answer with a local classifier: multilingual-e5-small embeddings (transformers.js, downloaded on first use) plus logistic weights in hooks/gap-model.json. When the answer looks like it named a wrong, stale or missing doc, skill, rule, script or config, Claude Code gives the agent a suggestion to log it with collect_feedback on the next turn. Running in the background at Stop keeps the model load (~0.8 s) off the next prompt. It nudges about 40% of turns and catches about 60% of gaps on projects it was not trained on; a false alarm costs one ignored line. The SessionStart hook installs @huggingface/transformers into the plugin data dir and downloads the model in the background into ~/.claude/models, shared with the verified plugin, so the first prompt does not wait for it. While it downloads, the footer shows a progress bar (nudge model ██████░░░░░░ 50% · 59/118 MB), read from data/warmup.json.
Entries go to data/feedback.jsonl inside this plugin, one JSON object per line. The folder is git-ignored. The server is plain Node with no dependencies.
Test: node --test claude/plugins/feedback/server/feedback-mcp.test.cjs claude/plugins/feedback/hooks/gap-nudge.test.cjs and claude plugin test claude/plugins/feedback.
hooks/register.tsx 315 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry, Kind, Status, Warmup } from '../types'
5import { registerTranscript } from './transcript'
6
7const PANE = 'feedback'
8const COLLECT = 'mcp__plugin_feedback_feedback__collect_feedback'
9const UPDATE = 'mcp__plugin_feedback_feedback__update_feedback'
10const SHOWS = [
11 { show: 'open', label: 'Open', hotkey: 'o' },
12 { show: 'resolved', label: 'Resolved', hotkey: 'r' },
13 { show: 'wontfix', label: "Won't fix", hotkey: 'w' },
14 { show: 'all', label: 'Everything', hotkey: 'e' },
15] as const
16const FILTERS = ['all', 'bug', 'pain_point', 'ambiguity', 'idea'] as const
17const LABEL = { all: 'All', bug: 'Bug', pain_point: 'Pain point', ambiguity: 'Ambiguity', idea: 'Idea' }
18const KIND_COLOR = { bug: 'red', pain_point: 'yellow', ambiguity: 'magenta', idea: 'cyan' }
19const SEVERITY_COLOR = { high: 'red', medium: 'yellow', low: 'gray' }
20const entries = atom({ plugin: 'feedback', key: 'entries' } as const, [])
21const filter = atom({ plugin: 'feedback', key: 'filter' } as const, 'all' as Kind | 'all')
22const justLogged = atom({ plugin: 'feedback', key: 'justLogged' } as const, null as Entry | null)
23const show = atom({ plugin: 'feedback', key: 'show' } as const, 'open' as Status | 'all')
24const secondsLeft = atom({ plugin: 'feedback', key: 'secondsLeft' } as const, 0)
25const scope = atom({ plugin: 'feedback', key: 'scope' } as const, 'here' as 'here' | 'all')
26const here = atom({ plugin: 'feedback', key: 'here' } as const, '')
27// Same rule as read_feedback: logged in this folder or one inside it.
28const inProject = (cwd: string, dir: string) => cwd === dir || cwd.startsWith(`${dir}/`)
29const warmup = atom({ plugin: 'feedback', key: 'warmup' } as const, null as Warmup | null)
30// A warm-up the prefetch stopped writing (killed, crashed) is not shown forever.
31const WARMUP_STALE_MS = 2 * 60 * 1000
32const warmupPath = ($: EngineInterface) => `${$.plugin.root}/data/warmup.json`
33
34const megabytes = (bytes: number) => Math.round(bytes / 1e6)
35function warmupText(w: Warmup) {
36 if (w.state === 'installing') return 'nudge model: installing…'
37 if (w.state !== 'downloading' || !w.total) return 'nudge model: warming up…'
38 const share = Math.min(1, w.loaded / w.total)
39 const filled = Math.round(share * 12)
40 return `nudge model ${'█'.repeat(filled)}${'░'.repeat(12 - filled)} ${Math.round(share * 100)}% · ${megabytes(w.loaded)}/${megabytes(w.total)} MB`
41}
42
43// The MCP server owns the store; the mod only reads it, so there is one writer. The store is
44// entry lines plus `{ op: 'update' }` lines merged into them, the same fold the server does.
45const POLL_MS = 3000
46const CARD_SECONDS = 8
47const storePath = ($: EngineInterface) => `${$.plugin.root}/data/feedback.jsonl`
48
49async function reload($: EngineInterface) {
50 const path = storePath($)
51 const text = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
52 const byId = new Map<string, Entry>()
53 for (const line of text.split('\n').filter(Boolean)) {
54 const { op, ...record } = JSON.parse(line)
55 const prior = byId.get(record.id)
56 if (op !== 'update') byId.set(record.id, { status: 'open', ...record })
57 else if (prior) byId.set(record.id, { ...prior, ...record })
58 }
59 const list = [...byId.values()].reverse()
60 await update($, entries, () => list)
61 // The project root, not cwd(): a shell `cd` into a subfolder moves cwd and would hide this project's entries.
62 const dir = await $.session.root()
63 await update($, here, () => dir)
64 await showStatus($)
65
66 return list
67}
68
69async function showStatus($: EngineInterface) {
70 const w = await read($, warmup)
71 if (w && w.state !== 'ready' && w.state !== 'failed') return $.ui.status(warmupText(w))
72 const dir = await read($, here)
73 const open = (await read($, entries)).filter(one => one.status === 'open' && inProject(one.cwd, dir))
74 const count = (severity: Entry['severity']) => open.filter(one => one.severity === severity).length
75 // The engine prefixes the plugin name, so this reads "feedback: 3 open · 1 high ...".
76 $.ui.status(
77 open.length === 0
78 ? undefined
79 : `${open.length} open · ${count('high')} high · ${count('medium')} medium · ${count('low')} low · /feedback to view`,
80 )
81}
82
83async function readWarmup($: EngineInterface) {
84 const path = warmupPath($)
85 if (!(await $.fs.exists(path))) return null
86 const w = JSON.parse(await $.fs.read(path)) as Warmup
87 return Date.now() - w.at > WARMUP_STALE_MS ? null : w
88}
89
90export const register: Register = on => {
91 registerTranscript(on)
92 // Deferred behind ToolSearch, the model saw only the name and logged nothing outside ai-setup.
93 on('tool.describe', { tool: COLLECT }, ($, e) => ({ description: e.description, isDeferred: false }))
94
95 on('session.start', async ($, e, next) => {
96 await $.command.register({ name: 'feedback', description: 'Show reported feedback in a pane' })
97 await $.command.register({ name: 'fb', description: 'Log feedback without a model turn: /fb <what could be better>' })
98 await reload($)
99 // Other sessions and scripts write the store too, so follow the file, not only this session's calls.
100 let seen = (await $.fs.exists(storePath($))) ? (await $.fs.stat(storePath($))).mtimeMs : 0
101 $.clock.every(POLL_MS, () => {
102 void (async () => {
103 const now = (await $.fs.exists(storePath($))) ? (await $.fs.stat(storePath($))).mtimeMs : 0
104 if (now === seen) return
105 seen = now
106 await reload($)
107 })()
108 })
109 // The background prefetch writes its progress here; follow it until the model is ready.
110 const warmupTick = $.clock.every(1000, () => {
111 void (async () => {
112 const w = await readWarmup($)
113 await update($, warmup, () => w)
114 await showStatus($)
115 if (w?.state === 'ready' || w?.state === 'failed') warmupTick.cancel()
116 })()
117 })
118 $.clock.after(10 * 60 * 1000, () => warmupTick.cancel())
119
120 return next(e)
121 })
122
123 on('command.run', { command: 'feedback' }, async $ => {
124 await reload($)
125 await $.ui.open({ id: PANE, title: 'Feedback', focus: true, closeOnEscape: true })
126
127 return { text: 'Feedback pane opened.' }
128 })
129
130 on('command.run', { command: 'fb' }, async ($, e) => {
131 const text = e.args.trim()
132 if (!text) return { text: 'Usage: /fb <what went wrong>' }
133 const ran = await $.tool.call({ tool: COLLECT, kind: 'pain_point', title: text.slice(0, 80), details: text })
134
135 return { text: ran.deny ?? ran.text ?? 'Feedback logged.' }
136 })
137
138 on('tool.call', { tool: COLLECT }, async ($, e, next) => {
139 const ran = await next(e)
140 if (ran.deny === undefined && ran.isError !== true) {
141 $.ui.toast(ran.text ?? 'Feedback logged.')
142 const list = await reload($)
143 const logged = list[0] ?? null
144 await update($, justLogged, () => logged)
145 await update($, secondsLeft, () => CARD_SECONDS)
146 const tick = $.clock.every(1000, () => {
147 void (async () => {
148 const shown = await read($, justLogged)
149 if (shown?.id !== logged?.id) return tick.cancel()
150 const left = (await read($, secondsLeft)) - 1
151 await update($, secondsLeft, () => left)
152 if (left > 0) return
153 tick.cancel()
154 await update($, justLogged, () => null)
155 })()
156 })
157 }
158
159 return ran
160 })
161
162 on('tool.call', { tool: UPDATE }, async ($, e, next) => {
163 const ran = await next(e)
164 if (ran.deny === undefined && ran.isError !== true) {
165 $.ui.toast(ran.text ?? 'Feedback updated.')
166 await reload($)
167 }
168
169 return ran
170 })
171
172 on('prompt.submit', async ($, e, next) => {
173 await update($, justLogged, () => null)
174
175 return next(e)
176 })
177
178 // A card above the prompt for the entry just logged; whatever other plugins draw there stays below it.
179 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180 const entry = await read($, justLogged)
181 if (entry === null || e.props.hasSurvey) return next(e)
182 const { Box, Button, Text } = $.ui.resolve(e)
183 const left = await read($, secondsLeft)
184 const rest = await next(e)
185
186 return (
187 <Box flexDirection="column">
188 <Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
189 <Text color="green" bold>
190 ✓ Feedback logged · {entry.id}
191 </Text>
192 <Box gap={1}>
193 <Text backgroundColor={SEVERITY_COLOR[entry.severity]} color="black" bold>
194 {` ${entry.severity.toUpperCase()} `}
195 </Text>
196 <Text color={KIND_COLOR[entry.kind]} bold>
197 {LABEL[entry.kind]}
198 </Text>
199 <Text wrap="truncate-end">{entry.title}</Text>
200 </Box>
201 <Box gap={1}>
202 <Text dimColor>/feedback to see all · closes in {left}s ·</Text>
203 <Button key="dismiss-logged" plain role="dismiss" label="Dismiss" onPress={() => update($, justLogged, () => null)} />
204 </Box>
205 </Box>
206 {rest}
207 </Box>
208 )
209 })
210
211 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212 const { Box, Button, Text } = $.ui.resolve(e)
213 const active = await read($, filter)
214 const shown = await read($, show)
215 const scoped = await read($, scope)
216 const dir = await read($, here)
217 const stored = await read($, entries)
218 const everything = scoped === 'all' ? stored : stored.filter(one => inProject(one.cwd, dir))
219 const all = everything.filter(one => shown === 'all' || one.status === shown)
220 const list = all.filter(one => active === 'all' || one.kind === active)
221 const width = Math.max(20, e.props.bodyColumns - 2)
222
223 return (
224 <Box flexDirection="column" paddingX={1}>
225 <Box justifyContent="space-between">
226 <Box gap={2}>
227 <Text bold>Feedback</Text>
228 <Button
229 key="scope-toggle"
230 plain
231 hotkey="p"
232 label={scoped === 'all' ? `All projects ${stored.length}` : `This project ${everything.length} · ${stored.length - everything.length} elsewhere`}
233 onPress={() => update($, scope, now => (now === 'all' ? 'here' : 'all'))}
234 />
235 <Button key="close-pane" plain hotkey="q" label="✕ Close" onPress={() => $.ui.close({ id: PANE })} />
236 </Box>
237 <Text dimColor>
238 {everything.filter(one => one.status === 'open').length} open ·{' '}
239 {everything.filter(one => one.status === 'open' && one.severity === 'high').length} high
240 </Text>
241 </Box>
242 <Box gap={2} marginTop={1} flexWrap="wrap">
243 {SHOWS.map(one => (
244 <Button
245 key={`show-${one.show}`}
246 plain
247 hotkey={one.hotkey}
248 dimColor={one.show !== shown}
249 label={`${one.label} ${one.show === 'all' ? everything.length : everything.filter(entry => entry.status === one.show).length}`}
250 onPress={() => update($, show, () => one.show)}
251 />
252 ))}
253 </Box>
254 <Box gap={2} marginTop={1} flexWrap="wrap">
255 {FILTERS.map((kind, i) => (
256 <Button
257 key={`filter-${kind}`}
258 plain
259 hotkey={String(i + 1)}
260 dimColor={kind !== active}
261 label={`${LABEL[kind]} ${kind === 'all' ? all.length : all.filter(one => one.kind === kind).length}`}
262 onPress={() => update($, filter, () => kind)}
263 />
264 ))}
265 </Box>
266 <Text dimColor>{'─'.repeat(width)}</Text>
267 {list.length === 0 && (
268 <Box marginTop={1}>
269 <Text dimColor italic>
270 {everything.length === 0 ? 'Nothing reported yet. Log one with /fb <what went wrong>.' : 'Nothing under this filter.'}
271 </Text>
272 </Box>
273 )}
274 {list.map(one => (
275 <Box
276 key={one.id}
277 flexDirection="column"
278 marginTop={1}
279 paddingX={1}
280 borderStyle="round"
281 borderColor={one.status === 'open' ? SEVERITY_COLOR[one.severity] : 'gray'}
282 >
283 <Box gap={1}>
284 <Text backgroundColor={SEVERITY_COLOR[one.severity]} color="black" bold>
285 {` ${one.severity.toUpperCase()} `}
286 </Text>
287 <Text color={KIND_COLOR[one.kind]} bold>
288 {LABEL[one.kind]}
289 </Text>
290 {one.area && <Text dimColor>· {one.area}</Text>}
291 {one.status !== 'open' && <Text color={one.status === 'resolved' ? 'green' : 'gray'}>· {one.status === 'resolved' ? '✓ resolved' : "won't fix"}</Text>}
292 </Box>
293 <Text bold wrap="wrap" strikethrough={one.status !== 'open'}>
294 {one.title}
295 </Text>
296 {one.details !== one.title && (
297 <Text dimColor wrap="wrap">
298 {one.details.length > 280 ? `${one.details.slice(0, 279)}…` : one.details}
299 </Text>
300 )}
301 {one.resolution && (
302 <Text color="green" wrap="wrap">
303 → {one.resolution}
304 </Text>
305 )}
306 <Text dimColor>
307 {one.createdAt.slice(0, 16).replace('T', ' ')} · {one.cwd.split('/').pop()} · {one.id}
308 </Text>
309 </Box>
310 ))}
311 </Box>
312 )
313 })
314}
315hooks/transcript.tsx 73 lines1import type { On } from 'claude-code'
2
3const PREFIX = 'mcp__plugin_feedback_feedback__'
4const SEVERITY_COLOR: Record<string, string> = { high: 'red', medium: 'yellow', low: 'gray' }
5const KIND_LABEL: Record<string, string> = { bug: 'Bug', pain_point: 'Pain point', ambiguity: 'Ambiguity', idea: 'Idea' }
6
7type Input = Record<string, string | number | undefined>
8
9// MCP results reach the row as text or as content blocks; the first line is the summary.
10function firstLine(output: unknown) {
11 const text = Array.isArray(output)
12 ? output.map(block => (block && typeof block === 'object' && 'text' in block ? String(block.text) : '')).join('\n')
13 : String(output ?? '')
14
15 return text.split('\n')[0] ?? ''
16}
17
18export function registerTranscript(on: On) {
19 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
20 if (!e.props.tool.startsWith(PREFIX)) return next(e)
21 const { Box, Text } = $.ui.resolve(e)
22 const action = e.props.tool.slice(PREFIX.length)
23 const input = (e.props.input ?? {}) as Input
24 const dot = <Text color={e.props.isErrored ? 'red' : e.props.isRunning ? 'gray' : 'green'}>●</Text>
25
26 if (action === 'collect_feedback') {
27 const severity = String(input.severity ?? 'medium')
28 return (
29 <Box marginX={1} gap={1}>
30 {dot}
31 <Text bold>Log feedback</Text>
32 <Text backgroundColor={SEVERITY_COLOR[severity] ?? 'gray'} color="black">{` ${severity.toUpperCase()} `}</Text>
33 <Text dimColor>{KIND_LABEL[String(input.kind)] ?? String(input.kind)}</Text>
34 <Text wrap="truncate-end">{String(input.title ?? '')}</Text>
35 </Box>
36 )
37 }
38 if (action === 'update_feedback') {
39 const change = input.status ? String(input.status) : 'edit'
40 return (
41 <Box marginX={1} gap={1}>
42 {dot}
43 <Text bold>Update feedback</Text>
44 <Text dimColor>{String(input.id)}</Text>
45 <Text color={change === 'resolved' ? 'green' : undefined}>{change === 'resolved' ? '✓ resolved' : change}</Text>
46 {input.resolution !== undefined && <Text dimColor wrap="truncate-end">→ {String(input.resolution)}</Text>}
47 </Box>
48 )
49 }
50 const filters = ['status', 'kind', 'severity', 'area', 'query']
51 .filter(key => input[key] !== undefined)
52 .map(key => `${key}: ${input[key]}`)
53 return (
54 <Box marginX={1} gap={1}>
55 {dot}
56 <Text bold>Read feedback</Text>
57 <Text dimColor>{filters.length ? filters.join(' · ') : 'open entries'}</Text>
58 </Box>
59 )
60 })
61
62 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
63 if (!e.props.tool.startsWith(PREFIX) || e.props.isErrored) return next(e)
64 const { Box, Text } = $.ui.resolve(e)
65
66 return (
67 <Box marginX={1}>
68 <Text dimColor> ⎿ {firstLine(e.props.output)}</Text>
69 </Box>
70 )
71 })
72}
73types/index.d.ts 23 lines1export type Status = 'open' | 'resolved' | 'wontfix'
2export type Kind = 'bug' | 'pain_point' | 'ambiguity' | 'idea'
3export type Warmup = { state: 'installing' | 'loading' | 'downloading' | 'ready' | 'failed'; loaded?: number; total?: number; at: number }
4export type Entry = {
5 id: string
6 createdAt: string
7 kind: Kind
8 severity: 'low' | 'medium' | 'high'
9 title: string
10 details: string
11 area?: string
12 cwd: string
13 status: Status
14 resolution?: string
15 updatedAt?: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 feedback: { entries: Entry[]; filter: Kind | 'all'; justLogged: Entry | null; show: Status | 'all'; secondsLeft: number; scope: 'here' | 'all'; here: string; warmup: Warmup | null }
21 }
22}
23