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

Mods for Claude Code.
| Mod | What it does |
|---|---|
| cache-bar | Status-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. |
| filetree | Fork 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-desk | Task list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent. |
| helpin-per | Pane listing open Helpin PER-* tasks; pick one to have Claude start working on it. |
| cache-clock | Countdown above the prompt until the chat's cache goes cold, with a Keep warm button. |
| handoff | When 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). |
| reflect | When you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder. |
| chat-inventory | Side 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-optimizer | Before 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-refiner | A 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 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.
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 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:
settings.json to settings.json.bak-cache-bar;statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;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.
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-barhooks/register.tsx 474 lines1import 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}
474types/index.d.ts 25 lines1export 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