SLOPSHOPPER

feedback

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

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · feedback
│ ┃ Feedback ✕ › fix the failing auth test and add an audit log call │ ┃ Feedback p: This project 0 · 0 elsewhere │ ┃ ⏺ Read(src/auth.ts) │ ┃ o: Open 0 r: Resolved 0 w: Won't fix 0 e ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 1: All 0 2: Bug 0 3: Pain point 0 4: Amb ⎿ Added 2 lines, removed 1 line │ ┃ ────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ──────────── ⎿ 3 pass, 1 fail │ ┃ │ ┃ Nothing reported yet. Log one with /fb ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ <what went wrong>. │ ✻ Worked for 42s · done 4:20 PM │ │ › /feedback │ ⎿ feedback: Feedback pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Feedback
Feedback p: This project 0 · 0 elsewhere q: ✕ Close0 open o: Open 0 r: Resolved 0 w: Won't fix 0 e: Everything 0 1: All 0 2: Bug 0 3: Pain point 0 4: Ambiguity 0 5: Ide ────────────────────────────────────────────────────── Nothing reported yet. Log one with /fb <what went wrong>.
README

feedback

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.
  • Every successful collect_feedback call shows a toast and a card above the prompt; the footer counts open entries by severity.
  • Feedback tool calls draw as compact rows in the transcript.

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.

Source 3 files
hooks/register.tsx 315 lines
1import { 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}
315
hooks/transcript.tsx 73 lines
1import 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}
73
types/index.d.ts 23 lines
1export 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