SLOPSHOPPER

reflect

When you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or in a rules folder you choose

newbandcommandpromptmodelprocess
v0.1.1no licenseupdated 2026-10-23jz-wilson/claude-mods/reflect
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reflect
› fix the failing auth test and add an audit log call ⏺ 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 › /reflect ⎿ reflect: Reflect is off. Type /reflect to turn it back on. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Mods for Claude Code.

ModWhat it does
cache-barStatus-line bar under your existing status line showing how long the prompt cache stays warm, with a handoff countdown: cache ▰▰▰▱▱▱▱▱ 23m 182k · handoff in 18m. Turns yellow at a quarter of the TTL, red at the handoff threshold, and follows the drop to a 5-minute TTL. /cache-bar:setup installs it.
filetreeFork of data-goblin/claude-code-filetree that shows only the files Claude touched this session; the header's ◆ button toggles to the whole tree.
helper-deskTask list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent.
helpin-perPane listing open Helpin PER-* tasks; pick one to have Claude start working on it.
cache-clockCountdown above the prompt until the chat's cache goes cold, with a Keep warm button.
handoffWhen a chat gets very long, writes a handoff prompt and starts a fresh chat with it (/refresh <focus>; the handoff tells the new chat to run /pickup).
reflectWhen you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder.
chat-inventorySide pane, In this chat, listing the to-do list, connectors and calls per connector, files and skills this chat used.
screen-share/screen-share hides names, emails, phone numbers and money amounts on screen while you record.
replay-theater/replay steps through the file edits Claude made in the last turn, one diff at a time.
mods/mods opens a pane to turn each mod on or off, including mods installed from other marketplaces, and to install mods a marketplace offers that are not installed yet.
effort-optimizerBefore Claude starts on a prompt, a quick Sonnet check picks the effort level it needs (Low to Max) and offers a one-click switch, or sets it by itself with /effort-optimizer auto.
prompt-refinerA Refine button in the hint line under the prompt, any time in the chat. Press it and pick a rewrite style for the prompt in the box, like a phone keyboard's suggestions: Clearer, Shorter, Elaborate (fuller, same shape), Structured (the job, the why, the guardrails, done means), Ask me first (up to 3 questions on gaps), or My guidance. The rewrite shows above the prompt with what changed; Use it fills the box, Refine runs another pass. /refine <prompt> does the same.

The eight mods other than replay-theater, from helper-desk to effort-optimizer, come from Ben AI's benai-mods (benai.co) and are redistributed with permission, with Windows fixes added here. replay-theater (one Ben AI change: /mods switch support) is Anthropic's sample mod from anthropics/claude-code-playground, under the Apache 2.0 license in its LICENSE.

Reflect setting

Reflect saves a rule either to ~/.claude/CLAUDE.md (Save everywhere) or as its own note in a folder you choose (Save to vault). Set that folder as rules_folder when you install reflect, or later for reflect in /config. If it is empty, reflect asks for the folder the first time you press Save to vault.

Install

At the prompt of a terminal session:

/plugin install helper-desk --marketplace jz-wilson/claude-mods

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install helper-desk --marketplace jz-wilson/claude-mods
claude plugin install cache-bar --marketplace jz-wilson/claude-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jz-wilson/claude-mods, then Add plugin.

cache-bar and claude-hud

cache-bar is a status-line script, so it takes over statusLine and runs the previous status line inside it. After installing, run /cache-bar:setup. It:

  • backs up settings.json to settings.json.bak-cache-bar;
  • moves the current statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;
  • points statusLine.command at the newest installed cache-bar version, and sets refreshInterval: 60 so the countdown repaints while you are idle.

The previous status line prints first, unchanged, and the bar goes on its own line underneath. If the inner command fails, the bar still prints. Before the first request of a session there is no cache yet, so the bar is hidden; after expiry it shows a dim cold.

The same config file sets the handoff threshold and the context floor:

{ "inner": "…", "handoffMinutes": 5, "floorTokens": 80000 }

The threshold is capped at a fifth of the TTL, so it fits the 5-minute TTL too. Under floorTokens of context the bar says no handoff (<floor).

To undo, copy settings.json.bak-cache-bar back over settings.json, or set statusLine.command back to the inner value.

Development

Each mod is a folder with .claude-plugin/plugin.json. CI runs claude plugin validate on every mod for each push and pull request, then claude plugin test for mods with a hooks module and bun test for the rest. A merge to main that bumps a mod's version publishes a GitHub release tagged <mod>-v<version>.

claude plugin validate agent-tree
claude plugin test agent-tree
bun test cache-bar
Source 2 files
hooks/register.tsx 474 lines
1import type { EngineInterface, Register, SessionMessage } from 'claude-code'
2
3import type { Band } from '../types'
4
5const BAND = { plugin: 'reflect', key: 'band' } as const
6
7const DEFAULT_FOLDER = '~/Documents/rules'
8const CANCEL = 'Cancel'
9const HEADING = '## Rules from corrections'
10const SAVED_MS = 8000
11
12// The cheap first pass: only prompts with one of these go on to the model.
13const HINTS = [
14  /\bno\b/i,
15  /\bdon'?t\b/i,
16  /\bdo not\b/i,
17  /\bnever\b/i,
18  /\bstop\b/i,
19  /\binstead\b/i,
20  /\bi said\b/i,
21  /\bwrong\b/i,
22  /\btoo (long|short|much|many|wordy|formal|casual)\b/i,
23  /\bshorter\b/i,
24  /\bnot like that\b/i,
25  /\balways\b/i,
26  /\buse\b.{1,40}\bnot\b/i,
27  /\bremove\b/i,
28  /\bavoid\b/i,
29]
30
31// Module variables reset on a reload; the band lives in $.state.
32let home = ''
33// The rules_folder option from the config menu; empty means ask once and remember.
34let configured = ''
35let enabled = true
36// Bumped on every prompt, so a slow draft for an older prompt is dropped.
37let gen = 0
38let turnDone = true
39let pending: { rule: string; slug: string; correction: string } | null = null
40let nextId = 1
41
42// The /mods switchboard turns this mod off in every chat with switches.json.
43const NAME = 'reflect'
44let off = false
45
46async function readSwitch($: EngineInterface) {
47  let now = false
48  try {
49    const path = `${$.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
50    if (await $.fs.exists(path)) {
51      const json = JSON.parse(String(await $.fs.read(path))) as Record<string, unknown>
52      now = json[NAME] === false
53    }
54  } catch {
55    now = off
56  }
57  if (now === off) return
58  off = now
59  try {
60    $.ui.invalidate('ui.render')
61  } catch {
62    // The next state change redraws anyway.
63  }
64  if (off) pending = null
65}
66
67const tilde = (p: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p)
68
69async function getBand($: EngineInterface): Promise<Band | null> {
70  const { value } = await $.state.get(BAND)
71  return value ?? null
72}
73
74async function setBand($: EngineInterface, b: Band | null) {
75  await $.state.set(BAND, b)
76}
77
78async function log($: EngineInterface, what: string, err: unknown) {
79  try {
80    const path = `${home}/.claude/mods-logs/reflect.log`
81    const old = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
82    const msg = err instanceof Error ? err.message : String(err)
83    await $.fs.write(path, `${old}${new Date(await $.clock.now()).toISOString()} ${what}: ${msg}\n`)
84  } catch {
85    // Nowhere left to report it.
86  }
87}
88
89async function today($: EngineInterface) {
90  const d = new Date(await $.clock.now())
91  const pad = (n: number) => String(n).padStart(2, '0')
92  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
93}
94
95function looksLikeCorrection(text: string) {
96  const t = text.trim()
97  if (!t || t.startsWith('/') || t.length > 1500) return false
98  return HINTS.some(re => re.test(t))
99}
100
101// Plain text rules: no em or en dashes, never the banned word, one line, a full stop.
102function cleanRule(raw: string) {
103  let r = raw
104    .replace(/\s*[—–]\s*/g, ', ')
105    .replace(/\bship(s|ped|ping)?\b/gi, (_m, end: string | undefined) => {
106      const e = (end ?? '').toLowerCase()
107      return e === 's' ? 'publishes' : e === 'ped' ? 'published' : e === 'ping' ? 'publishing' : 'publish'
108    })
109    .replace(/\s+/g, ' ')
110    .trim()
111    .replace(/^[-*"'`\s]+|["'`\s]+$/g, '')
112  if (!r) return ''
113  r = r[0]!.toUpperCase() + r.slice(1)
114  if (!/[.!?]$/.test(r)) r += '.'
115  return r
116}
117
118const STOP = new Set(['a', 'an', 'the', 'in', 'on', 'of', 'to', 'for', 'and', 'or', 'with', 'when', 'any', 'all', 'my', 'your'])
119
120function cleanSlug(raw: string, rule: string) {
121  const words = (s: string) =>
122    s
123      .toLowerCase()
124      .replace(/[^a-z0-9]+/g, '-')
125      .split('-')
126      .filter(Boolean)
127  let w = words(raw)
128  if (w.length < 2) w = words(rule).filter(x => !STOP.has(x))
129  return w.slice(0, 6).join('-').slice(0, 60) || 'rule'
130}
131
132function draftPrompt(correction: string, lastReply: string) {
133  return `A person corrected an AI assistant. Turn the correction into ONE general rule the assistant should follow in every future chat.
134
135The assistant's last reply (first 1,500 characters):
136<reply>
137${lastReply}
138</reply>
139
140The person's correction:
141<correction>
142${correction}
143</correction>
144
145Rules for the rule:
146- One line, plain imperative English, under 20 words. Example: Never use em dashes in newsletter copy.
147- General enough to apply next time, specific enough to act on. Name the kind of work it applies to when that matters.
148- No em dashes. Never use the word "ship".
149
150Also give a 3 to 6 word kebab-case slug for it, for example no-em-dashes-in-newsletters.
151
152Reply with JSON only: {"rule": "...", "slug": "..."}
153If the message holds no lasting lesson (a one-off fix to this task only), reply {"rule": "NONE", "slug": ""}.`
154}
155
156function parseDraft(text: string): { rule: string; slug: string } | null {
157  const m = /\{[\s\S]*\}/.exec(text)
158  let rule = ''
159  let slug = ''
160  if (m) {
161    try {
162      const j = JSON.parse(m[0]) as { rule?: unknown; slug?: unknown }
163      rule = typeof j.rule === 'string' ? j.rule : ''
164      slug = typeof j.slug === 'string' ? j.slug : ''
165    } catch {
166      // Fall through to the first line.
167    }
168  }
169  if (!rule) rule = text.split('\n').find(l => l.trim()) ?? ''
170  if (!rule || /^none\.?$/i.test(rule.trim())) return null
171  const clean = cleanRule(rule)
172  if (!clean || /^none\.?$/i.test(clean)) return null
173  return { rule: clean, slug: cleanSlug(slug, clean) }
174}
175
176function lastAssistantText(messages: readonly SessionMessage[]) {
177  for (let i = messages.length - 1; i >= 0; i -= 1) {
178    const m = messages[i]!
179    if (m.role === 'assistant' && m.text.trim()) return m.text
180  }
181  return ''
182}
183
184async function show($: EngineInterface, d: { rule: string; slug: string; correction: string }) {
185  if (!enabled) return
186  const id = nextId++
187  await setBand($, { phase: 'offer', id, rule: d.rule, slug: d.slug, correction: d.correction, path: '', shortPath: '', note: '' })
188}
189
190// Runs in the background after a prompt: filter, classify, draft.
191async function check($: EngineInterface, text: string, mine: number) {
192  if (!looksLikeCorrection(text)) return
193  const messages = await $.session.messages()
194  const lastReply = lastAssistantText(messages)
195  if (!lastReply) return
196  const kind = await $.model.classify(text, ['correction', 'other'])
197  if (kind !== 'correction' || mine !== gen) return
198  const r = await $.model.complete({ model: 'sonnet', prompt: draftPrompt(text, lastReply.slice(0, 1500)), maxTokens: 300, timeoutMs: 30_000 })
199  if (!r.isAnswered) throw new Error(`draft failed: ${r.reason}`)
200  const draft = parseDraft(r.text)
201  if (!draft || mine !== gen) return
202  const d = { ...draft, correction: text.trim() }
203  // Show it when the turn ends; if it already has, show it now.
204  if (turnDone) await show($, d)
205  else pending = d
206}
207
208// Adds the rule under the heading at the end of the file, keeping every other line.
209function withRule(old: string, line: string) {
210  const lines = old.split('\n')
211  const at = lines.findIndex(l => l.trim() === HEADING)
212  if (at === -1) {
213    const base = old.replace(/\s+$/, '')
214    return `${base}${base ? '\n\n' : ''}${HEADING}\n\n${line}\n`
215  }
216  let end = lines.length
217  for (let k = at + 1; k < lines.length; k += 1) {
218    if (/^#{1,2}\s/.test(lines[k]!)) {
219      end = k
220      break
221    }
222  }
223  let last = end
224  while (last > at + 1 && !lines[last - 1]!.trim()) last -= 1
225  const before = lines.slice(0, last)
226  if (last === at + 1) before.push('')
227  const after = lines.slice(end)
228  const out = [...before, line, ...(after.length ? ['', ...after] : [])].join('\n')
229  return out.endsWith('\n') ? out : `${out}\n`
230}
231
232async function saveEverywhere($: EngineInterface, b: Band) {
233  const path = `${home}/.claude/CLAUDE.md`
234  const old = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
235  const already = old.split('\n').some(l => l.startsWith(`- ${b.rule} (added `))
236  if (already) return { path, note: 'Already saved' }
237  const line = `- ${b.rule} (added ${await today($)})`
238  const next = withRule(old, line)
239  const oldLines = old.split('\n').filter(l => l.trim())
240  const nextLines = new Set(next.split('\n'))
241  if (next.length < old.length || oldLines.some(l => !nextLines.has(l))) throw new Error('the new CLAUDE.md would lose lines; nothing written')
242  await $.fs.write(path, next)
243  const back = String(await $.fs.read(path))
244  if (back !== next) throw new Error('CLAUDE.md did not read back as written')
245  return { path, note: '' }
246}
247
248// Turns ~ into the home folder and drops a trailing slash.
249function expand(p: string) {
250  let r = p.trim()
251  if (r === '~') r = home
252  else if (r.startsWith('~/')) r = `${home}${r.slice(1)}`
253  return r.replace(/\/+$/, '')
254}
255
256// The folder rule notes go to: the rules_folder option, else the remembered answer, else ask once.
257async function pickFolder($: EngineInterface) {
258  if (configured.trim()) return expand(configured)
259  const saved = await $.store.get('folder')
260  if (typeof saved === 'string' && saved.trim()) return expand(saved)
261  let answer = ''
262  try {
263    // The dialog needs two options; any other path is typed under Other.
264    answer = (await $.ui.ask('Which folder should rule notes go in?', { options: [DEFAULT_FOLDER, CANCEL], header: 'Rules folder' })).trim()
265  } catch {
266    // A cancelled dialog saves nothing.
267    return ''
268  }
269  if (!answer || answer === CANCEL || (!answer.startsWith('/') && !answer.startsWith('~'))) return ''
270  const dir = expand(answer)
271  if (!dir) return ''
272  await $.store.set('folder', answer)
273  return dir
274}
275
276function vaultNote(b: Band, date: string) {
277  const title = b.rule.replace(/[.!?]+$/, '')
278  const why = b.correction.replace(/\s+/g, ' ').trim().slice(0, 200).replace(/"/g, "'")
279  return `---
280type: rule
281status: active
282date: ${date}
283source: correction in a Claude Code chat
284tags: [rule]
285---
286
287# ${title}
288
289${b.rule}
290
291Why: "${why}"
292`
293}
294
295async function saveToVault($: EngineInterface, b: Band) {
296  const dir = await pickFolder($)
297  if (!dir) return null
298  // $.fs.write creates the folder and any missing parents.
299  let path = `${dir}/${b.slug}.md`
300  for (let k = 2; (await $.fs.exists(path)) && k < 100; k += 1) path = `${dir}/${b.slug}-${k}.md`
301  const text = vaultNote(b, await today($))
302  await $.fs.write(path, text)
303  const back = String(await $.fs.read(path))
304  if (back !== text) throw new Error('the rule file did not read back as written')
305  return { path, short: tilde(path) }
306}
307
308async function hideLater($: EngineInterface, id: number) {
309  await $.clock.sleep(SAVED_MS)
310  const b = await getBand($)
311  if (b && b.id === id && b.phase === 'saved') await setBand($, null)
312}
313
314async function markSaved($: EngineInterface, b: Band, path: string, shortPath: string, note: string) {
315  const id = nextId++
316  await setBand($, { ...b, phase: 'saved', id, path, shortPath, note })
317  void hideLater($, id).catch(err => log($, 'hide', err))
318}
319
320async function fail($: EngineInterface, b: Band, what: string, err: unknown) {
321  await log($, what, err)
322  await setBand($, { ...b, note: `Could not save: ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}` })
323}
324
325export const register: Register = (on, options) => {
326  configured = typeof options.rules_folder === 'string' ? options.rules_folder : ''
327  on('session.start', async ($, e, next) => {
328    home = $.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''
329    await $.command.register({ name: 'reflect', description: 'Turn reflect on or off. /reflect folder forgets the remembered rules folder', argumentHint: '[folder]' })
330    const saved = await $.store.get('enabled')
331    enabled = saved !== false
332    await readSwitch($).catch(() => undefined)
333    $.clock.every(3000, () => {
334      void readSwitch($).catch(() => undefined)
335    })
336    return next(e)
337  })
338
339  on('prompt.submit', async ($, e, next) => {
340    if (off) return next(e)
341    if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
342    gen += 1
343    turnDone = false
344    pending = null
345    const mine = gen
346    const text = e.text
347    // A new prompt makes an unanswered offer stale.
348    void (async () => {
349      const b = await getBand($)
350      if (b?.phase === 'offer') await setBand($, null)
351    })().catch(err => log($, 'clear', err))
352    if (enabled) void check($, text, mine).catch(err => log($, 'check', err))
353    return next(e)
354  })
355
356  on('turn.complete', async ($, e, next) => {
357    if (off) return next(e)
358    const result = await next(e)
359    if (e.agentId === undefined) {
360      turnDone = true
361      if (pending) {
362        const d = pending
363        pending = null
364        void show($, d).catch(err => log($, 'show', err))
365      }
366    }
367    return result
368  })
369
370  on('command.run', { command: 'reflect' }, async ($, e) => {
371    if (off) return { text: 'Reflect is off. Turn it on with /mods.' }
372    const arg = e.args.trim().toLowerCase()
373    if (arg === 'folder') {
374      await $.store.delete('folder')
375      return {
376        text: configured.trim()
377          ? `Remembered folder cleared. Save to vault still uses the rules_folder setting: ${configured.trim()}`
378          : 'Remembered folder cleared. Reflect asks again at the next Save to vault.',
379      }
380    }
381    enabled = !enabled
382    await $.store.set('enabled', enabled)
383    if (!enabled) {
384      pending = null
385      await setBand($, null)
386    }
387    return { text: enabled ? 'Reflect is on. Corrections get a Save this as a rule? offer.' : 'Reflect is off. Type /reflect to turn it back on.' }
388  })
389
390  on('ui.press', { plugin: 'reflect', element: 'everywhere' }, async ($, e) => {
391    const b = await getBand($)
392    if (!b || b.phase !== 'offer') return { element: e.element }
393    try {
394      const r = await saveEverywhere($, b)
395      await markSaved($, b, r.path, tilde(r.path), r.note)
396    } catch (err) {
397      await fail($, b, 'save everywhere', err)
398    }
399    return { element: e.element }
400  })
401
402  on('ui.press', { plugin: 'reflect', element: 'vault' }, async ($, e) => {
403    const b = await getBand($)
404    if (!b || b.phase !== 'offer') return { element: e.element }
405    try {
406      const r = await saveToVault($, b)
407      if (!r) {
408        await setBand($, { ...b, note: 'No folder picked. Nothing saved.' })
409      } else {
410        await markSaved($, b, r.path, r.short, '')
411      }
412    } catch (err) {
413      await fail($, b, 'save to vault', err)
414    }
415    return { element: e.element }
416  })
417
418  on('ui.press', { plugin: 'reflect', element: 'skip' }, async ($, e) => {
419    await setBand($, null)
420    return { element: e.element }
421  })
422
423  on('ui.press', { plugin: 'reflect', element: 'open' }, async ($, e) => {
424    const b = await getBand($)
425    // Windows has no `open`; rundll32 hands the file to its default app.
426    if (b?.path) await $.process.run((await $.env.get('OS')) === 'Windows_NT' ? ['rundll32', 'url.dll,FileProtocolHandler', b.path.replace(/\//g, '\\')] : ['open', b.path])
427    return { element: e.element }
428  })
429
430  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
431    if (off || !enabled || e.props.hasSurvey || e.props.isWorking) return next(e)
432    const b = await getBand($)
433    if (!b) return next(e)
434    const rest = await next(e)
435    const { Box, Text, Button } = $.ui.resolve(e)
436    const noop = () => undefined
437
438    if (b.phase === 'saved') {
439      return (
440        <Box flexDirection="column">
441          {rest}
442          <Box flexDirection="row" alignItems="center" gap={1}>
443            <Text wrap="truncate-middle">
444              <Text color="success">✓</Text>
445              <Text> {b.note ? `${b.note} in` : 'Saved to'} </Text>
446              <Text dimColor>{b.shortPath}</Text>
447            </Text>
448            <Button key="open" label="Open" onPress={noop} />
449          </Box>
450        </Box>
451      )
452    }
453
454    return (
455      <Box flexDirection="column">
456        {rest}
457        <Box flexDirection="column">
458          <Text>
459            <Text color="claude">●</Text>
460            <Text weight="bold"> Save this as a rule?</Text>
461          </Text>
462          <Text>"{b.rule}"</Text>
463          <Box flexDirection="row" flexWrap="wrap" gap={1}>
464            <Button key="everywhere" label="Save everywhere" variant="primary" onPress={noop} />
465            <Button key="vault" label="Save to vault" variant="secondary" onPress={noop} />
466            <Button key="skip" label="Skip" role="dismiss" onPress={noop} />
467          </Box>
468          {b.note ? <Text color="error">{b.note}</Text> : null}
469        </Box>
470      </Box>
471    )
472  })
473}
474
types/index.d.ts 25 lines
1export type Band = {
2  /** offer: the rule and the three buttons. saved: one line with Open, for 8 seconds. */
3  phase: 'offer' | 'saved'
4  /** Changes with every new band, so an old timer never clears a newer one. */
5  id: number
6  /** The drafted rule, one line. */
7  rule: string
8  /** 3 to 6 words, kebab-case, for the vault file name. */
9  slug: string
10  /** The person's correction, as typed. */
11  correction: string
12  /** Where the rule was saved (saved phase). */
13  path: string
14  /** The path as shown on the band. */
15  shortPath: string
16  /** A line under the buttons: an error or "Already saved". */
17  note: string
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    reflect: { band: Band | null }
23  }
24}
25