/reflect: Claude critiques its own recent work (what may be wrong or missed, what to check next) in a pane, with one press to hand the follow-ups back.

Seven mods for Claude Code. Each one is a plugin of function hooks: it draws panes, status lines and toasts inside Claude Code, in the terminal and in the desktop app's Code tab.
| Mod | What it does | How to use it | |
|---|---|---|---|
| savvy-progress | A live band above the prompt while Claude works: what it is doing now ("Reading format.ts · 4s"), elapsed time, steps, files read and changed, errors, and a progress bar: the real task list when Claude keeps one, otherwise a time bar guessed from how long your past turns took (clearly labelled a guess). A one-line summary after each turn. | Shows by itself. `/savvy-progress [on\ | off]` |
| skins | Re-skins the spinner (glyphs, verbs, color), the turn's closing line and the prompt hints. Skins: matrix, pirate, zen, retro, space. Your choice is remembered. | /skin (picker), /skin matrix, /skin off | |
| filetree | A live file-tree pane of the project. Marks the files Claude read (R), changed (M) and created (A) this session, and opens the folders they live in. | /filetree, /filetree touched, /filetree all, /filetree close | |
| cache-tax | How well the prompt cache works, and the "tax": the money paid above the ideal where every input token is a cache read. Status line cache 94% · tax $0.12, a per-turn table, and a toast with the likely cause when the cache busts. | /cache-tax, /cache-tax reset | |
| blast-radius | For each file Claude edits, the other files that import it; a flag on wide-reaching shell commands (git reset --hard, rm -rf, force push, ...). Shows only, never blocks. | /blast-radius, /blast-radius session, /blast-radius clear | |
| reflect | Claude critiques its own recent work: what may be wrong or missed, risky assumptions, what to check next. One button puts the follow-ups in your prompt box (it never sends them). Optional auto mode, off by default. | /reflect [focus], /reflect show, `/reflect auto on\ | off` |
| replay-theatre | Records the session and plays it back like a film: scene by scene, step by step, with play/pause, step, speed and a timeline. | /replay, /replay last, /replay copy; keys k play, j/l step, h/n scene, s speed, r restart |
In a Claude Code terminal session, install any mod by name:
/plugin install savvy-progress --marketplace gauravruhela07/claude-mods
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Repeat for each mod you want: skins, filetree, cache-tax, blast-radius, reflect, replay-theatre.
A mod installed from a terminal at the user scope also loads in the desktop app's Code tab.
git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree
reflect calls the model, and only when you run /reflect or turn auto mode on. It asks over the session's own transcript, so most of it is served from the prompt cache.cache-tax/hooks/prices.ts. Claude Code does not report 5-minute and 1-hour cache writes apart, so all writes use the 5-minute price.rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.Each mod folder holds .claude-plugin/plugin.json, hooks/ (the hooks module), types/index.d.ts (its state contract) and tests/.
claude plugin validate filetree
claude plugin test filetreehooks/register.tsx 297 lines1import { atom, read, update } from 'claude-code'
2import type { ModelForkResult, Register, UiPressArgument } from 'claude-code'
3
4import type { Reflection } from '../types'
5import { clockTime, cost, failure, followUps, reflectPrompt, toUsage } from './text'
6
7const PANE = 'reflect'
8const TITLE = 'Reflect'
9const KEEP = 5
10const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
11const USAGE = 'Use /reflect [focus words], /reflect show, or /reflect auto [on|off]. Nothing changed.'
12
13const reflections = atom({ plugin: 'reflect', key: 'reflections' } as const, [])
14const shown = atom({ plugin: 'reflect', key: 'shown' } as const, null)
15const isAuto = atom({ plugin: 'reflect', key: 'isAuto' } as const, false)
16const hasEdited = atom({ plugin: 'reflect', key: 'hasEdited' } as const, false)
17
18/** The next reflection, running, after the ones in `list`. */
19const nextReflection = (list: readonly Reflection[], at: number, focus: string, origin: Reflection['origin']): Reflection => ({
20 id: (list[list.length - 1]?.id ?? 0) + 1,
21 at,
22 focus,
23 origin,
24 status: 'running',
25 markdown: '',
26 error: '',
27 usage: null,
28})
29
30const isRunning = (list: readonly Reflection[]) => list.some(one => one.status === 'running')
31
32export const register: Register = on => {
33 // Set in session.start: runs a reflection's fork from a timer of the session's own `$`,
34 // so no hook's dispatch waits on the model.
35 let runFork: ((job: Reflection) => void) | undefined
36
37 on('session.start', async ($, e, next) => {
38 runFork = job => {
39 $.clock.after(0, async () => {
40 let r: ModelForkResult | undefined
41 let thrown = ''
42 try {
43 r = await $.model.fork({ prompt: reflectPrompt(job.focus) })
44 } catch (err) {
45 thrown = `Could not run the reflection: ${err instanceof Error ? err.message : String(err)}`
46 }
47 const text = r?.isAnswered === true ? r.text.trim() : ''
48 const isDone = text !== ''
49 const error =
50 r === undefined
51 ? thrown
52 : r.isAnswered
53 ? isDone
54 ? ''
55 : 'The model replied without any text. Run /reflect to try again.'
56 : failure(r)
57 const usage = r === undefined ? null : toUsage(r)
58
59 await update($, reflections, list =>
60 list.map(
61 (one): Reflection =>
62 one.id === job.id ? { ...one, status: isDone ? 'done' : 'failed', markdown: text, error, usage } : one,
63 ),
64 )
65 if (job.origin === 'auto') {
66 $.ui.toast(isDone ? 'Reflection ready — /reflect show' : 'Reflection failed — /reflect show')
67 }
68 })
69 }
70
71 await $.command.register({
72 name: 'reflect',
73 description: 'Claude critiques its own recent work: what may be wrong or missed, and what to check next',
74 argumentHint: '[focus words | show | auto [on|off]]',
75 immediate: true,
76 })
77 const stored = await $.store.get('auto')
78 await update($, isAuto, () => stored === true)
79 // A reload drops a fork in flight with the old environment: say so rather than spin forever.
80 await update($, reflections, list =>
81 list.map(
82 (one): Reflection =>
83 one.status === 'running'
84 ? { ...one, status: 'failed', error: 'Stopped by a reload of the mod. Run /reflect to try again.' }
85 : one,
86 ),
87 )
88
89 return next(e)
90 })
91
92 on('command.run', { command: 'reflect' }, async ($, e) => {
93 const words = e.args.trim().split(/\s+/).filter(word => word !== '')
94 const first = (words[0] ?? '').toLowerCase()
95
96 if (first === 'auto') {
97 const arg = words.slice(1).join(' ').toLowerCase()
98 if (arg !== '' && arg !== 'on' && arg !== 'off') return { text: USAGE }
99
100 let isOn = false
101 await update($, isAuto, was => (isOn = arg === '' ? !was : arg === 'on'))
102 await $.store.set('auto', isOn)
103
104 return {
105 text: isOn
106 ? 'Auto-reflect is on: after a turn that edits files, Claude reflects in the background (this uses tokens). /reflect show opens it.'
107 : 'Auto-reflect is off.',
108 }
109 }
110
111 if (words.length === 1 && first === 'show') {
112 await update($, shown, () => null)
113 await $.ui.open({ id: PANE, title: TITLE })
114 const list = await read($, reflections)
115
116 return { text: list.length === 0 ? 'No reflection yet. Run /reflect to make one.' : 'Opened the newest reflection.' }
117 }
118
119 const focus = words.join(' ')
120 const list = await read($, reflections)
121 const isBusy = isRunning(list)
122
123 if (!isBusy) {
124 const job = nextReflection(list, await $.clock.now(), focus, 'command')
125 await update($, reflections, was => [...was, job].slice(-KEEP))
126 if (runFork === undefined) {
127 await update($, reflections, was =>
128 was.map(
129 (one): Reflection =>
130 one.id === job.id ? { ...one, status: 'failed', error: 'Reflect is still starting. Run /reflect again in a moment.' } : one,
131 ),
132 )
133 } else {
134 runFork(job)
135 }
136 }
137 await update($, shown, () => null)
138 await $.ui.open({ id: PANE, title: TITLE })
139
140 if (isBusy) return { text: 'A reflection is already running; it shows in the Reflect pane.' }
141
142 return { text: focus === '' ? 'Reflecting on the recent work…' : `Reflecting on the recent work (focus: ${focus})…` }
143 })
144
145 on('turn.start', async ($, e, next) => {
146 await update($, hasEdited, () => false)
147
148 return next(e)
149 })
150
151 on('tool.call', async ($, e, next) => {
152 const ran = await next(e)
153 const isEdit = e.agentId === undefined && EDIT_TOOLS.has(String(e.tool))
154
155 if (isEdit && ran.deny === undefined && ran.isError !== true && !(await read($, hasEdited))) {
156 await update($, hasEdited, () => true)
157 }
158
159 return ran
160 }).catch(($, e, next) => next(e))
161
162 on('turn.complete', async ($, e, next) => {
163 if (e.agentId !== undefined) return next(e)
164
165 const isEdited = await read($, hasEdited)
166 if (isEdited) await update($, hasEdited, () => false)
167 if (isEdited && !e.isAborted && runFork !== undefined && (await read($, isAuto))) {
168 const list = await read($, reflections)
169 const newest = list[list.length - 1]
170
171 if (!isRunning(list)) {
172 const job = nextReflection(list, await $.clock.now(), '', 'auto')
173 // Keep the pane on what the person reads; /reflect show jumps to the new one.
174 if (newest !== undefined && (await read($, shown)) === null) await update($, shown, () => newest.id)
175 await update($, reflections, was => [...was, job].slice(-KEEP))
176 runFork(job)
177 }
178 }
179
180 return next(e)
181 })
182
183 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
184 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
185 const list = await read($, reflections)
186 const pick = await read($, shown)
187 const auto = await read($, isAuto)
188 const width = Math.max(20, e.props.bodyColumns)
189
190 const found = pick === null ? -1 : list.findIndex(one => one.id === pick)
191 const at = found < 0 ? list.length - 1 : found
192 const r = list[at]
193
194 const close = () => $.ui.close({ id: PANE })
195 const footer = (
196 <Box key="footer">
197 <Text dimColor wrap="wrap">
198 Auto-reflect after edits: {auto ? 'on' : 'off'} · /reflect auto
199 </Text>
200 </Box>
201 )
202
203 if (r === undefined) {
204 return (
205 <Box flexDirection="column" width={width} gap={1}>
206 <Box key="body">
207 <Text wrap="wrap">No reflection yet. Run /reflect (optionally with focus words) to make one.</Text>
208 </Box>
209 <Box key="actions" flexDirection="row">
210 <Button key="close" label="Close" hotkey="x" role="dismiss" onPress={close} />
211 </Box>
212 {footer}
213 </Box>
214 )
215 }
216
217 const parts = [`Reflection ${at + 1} of ${list.length}`, clockTime(r.at)]
218 if (r.status === 'running') parts.push('running')
219 if (r.usage !== null) parts.push(cost(r.usage))
220 if (r.origin === 'auto') parts.push('auto')
221 const head = parts.join(' · ')
222
223 const fix = async (press: UiPressArgument) => {
224 const text = followUps(r.markdown)
225 const box = await $.prompt.read()
226 const isEmpty = box.text.trim() === ''
227 const filled = await $.prompt.fill(isEmpty ? { text, mode: 'replace' } : { text: `\n\n${text}`, mode: 'append' })
228 if (filled.isFilled) {
229 $.ui.toast('The follow-ups are in the prompt box: edit them, then press Enter.')
230 return
231 }
232 const copied = await $.ui.copy({ text, surface: press.surface })
233 $.ui.toast(
234 copied.isCopied
235 ? 'The prompt box could not take the follow-ups, so they were copied: paste them in.'
236 : 'The prompt box could not take the follow-ups.',
237 )
238 }
239
240 const copy = async (press: UiPressArgument) => {
241 const copied = await $.ui.copy({ text: r.markdown, surface: press.surface })
242 $.ui.toast(copied.isCopied ? 'Reflection copied.' : 'Could not copy the reflection.')
243 }
244
245 const older = list[at - 1]
246 const newer = list[at + 1]
247 const isLast = at + 1 === list.length - 1
248
249 return (
250 <Box flexDirection="column" width={width} gap={1}>
251 <Box key="header" flexDirection="column">
252 <Text bold wrap="truncate-end">
253 {head}
254 </Text>
255 {r.focus !== '' && (
256 <Text dimColor wrap="truncate-end">
257 Focus: {r.focus}
258 </Text>
259 )}
260 </Box>
261 {r.status === 'running' && (
262 <Box key="body">
263 <Text color="claude">Reflecting…</Text>
264 </Box>
265 )}
266 {r.status === 'done' && <Markdown key="body" text={r.markdown} />}
267 {r.status === 'failed' && (
268 <Box key="body">
269 <Text color="warning" wrap="wrap">
270 {r.error}
271 </Text>
272 </Box>
273 )}
274 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={1}>
275 {r.status === 'done' && (
276 <Button key="fix" label="Ask Claude to fix these" hotkey="f" variant="primary" onPress={fix} />
277 )}
278 {r.status === 'done' && <Button key="copy" label="Copy" hotkey="c" onPress={copy} />}
279 {older !== undefined && (
280 <Button key="older" label="← Older" hotkey="o" onPress={() => update($, shown, () => older.id)} />
281 )}
282 {newer !== undefined && (
283 <Button
284 key="newer"
285 label="Newer →"
286 hotkey="n"
287 onPress={() => update($, shown, () => (isLast ? null : newer.id))}
288 />
289 )}
290 <Button key="close" label="Close" hotkey="x" role="dismiss" onPress={close} />
291 </Box>
292 {footer}
293 </Box>
294 )
295 })
296}
297hooks/text.ts 131 lines1import type { ModelForkResult } from 'claude-code'
2
3import type { Usage } from '../types'
4
5/** The question the fork answers, over the session's own transcript. */
6export const reflectPrompt = (focus: string): string => {
7 const lines = [
8 'Stop here and review the work you have done in this session so far, as a skeptical senior reviewer would.',
9 'Do not use any tools and do not continue the task. Answer only with the review below.',
10 '',
11 'Reply in short Markdown with exactly these five headings, in this order, and nothing before or after them:',
12 '',
13 '## Asked',
14 'One or two sentences: what the person actually asked for, in their own terms.',
15 '',
16 '## Done',
17 'At most 5 bullets: what you actually changed, ran or produced. Name files, functions and commands. Facts only.',
18 '',
19 '## Might be wrong or missed',
20 'At most 6 bullets. Concrete problems: bugs, unhandled edge cases, parts of the request skipped or half done,',
21 'tests or checks not run, errors you glossed over, claims you made without verifying them.',
22 'Name the file and the function (or line) for each one.',
23 '',
24 '## Risky assumptions',
25 'At most 4 bullets: what you assumed without checking (an API, a version, a path, how something behaves,',
26 'what the person meant) that would break the work if it were false.',
27 '',
28 '## Check next',
29 'At most 5 numbered steps that would confirm or refute the points above. Each one is a command to run',
30 'or a concrete look (which file, what to look for in it). No vague advice such as "test thoroughly".',
31 '',
32 'Rules:',
33 '- Be honest and skeptical. Assume there is at least one mistake and look for it. Do not praise the work or reassure.',
34 '- Do not repeat or summarise the transcript beyond the two short opening sections.',
35 '- When a section truly has nothing, write "Nothing found" under its heading. Do not invent problems to fill it.',
36 '- No preamble and no closing remarks.',
37 ]
38 const words = focus.trim()
39 if (words !== '') lines.push('', `Focus on: ${words}.`)
40
41 return lines.join('\n')
42}
43
44const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
45
46/** The body of the section whose heading matches `name`, without its heading; undefined when absent. */
47export const section = (markdown: string, name: RegExp): string | undefined => {
48 const lines = markdown.split('\n')
49 const start = lines.findIndex(line => {
50 const m = HEADING.exec(line)
51 return m !== null && name.test(m[2] ?? '')
52 })
53 if (start < 0) return undefined
54 const level = (HEADING.exec(lines[start] ?? '')?.[1] ?? '##').length
55 const body: string[] = []
56 for (const line of lines.slice(start + 1)) {
57 const m = HEADING.exec(line)
58 if (m !== null && (m[1] ?? '').length <= level) break
59 body.push(line)
60 }
61
62 return body.join('\n').trim()
63}
64
65export const MISSED = /^might be wrong or missed\b/i
66export const CHECK = /^check next\b/i
67
68/** What the fix button puts in the prompt box. */
69export const followUps = (markdown: string): string => {
70 const missed = section(markdown, MISSED)
71 const check = section(markdown, CHECK)
72 const intro = 'Please address these points from your reflection:\n\n'
73 if (missed === undefined && check === undefined) return intro + markdown.trim()
74 const parts: string[] = []
75 if (missed !== undefined) parts.push(`## Might be wrong or missed\n${missed || 'Nothing found'}`)
76 if (check !== undefined) parts.push(`## Check next\n${check || 'Nothing found'}`)
77
78 return intro + parts.join('\n\n')
79}
80
81/** Why a reflection has no text, in plain words. */
82export const failure = (r: ModelForkResult): string => {
83 if (r.isAnswered) return ''
84 switch (r.reason) {
85 case 'nothing-to-fork':
86 return 'Nothing to reflect on yet — do some work first.'
87 case 'aborted':
88 return 'The reflection was stopped before it finished. Run /reflect to try again.'
89 case 'empty-reply':
90 return 'The model replied without any text. Run /reflect to try again.'
91 case 'api-error': {
92 const status = r.status === null ? 'no response arrived' : `status ${r.status}`
93 return `The API returned an error (${status}, ${r.error}). Try /reflect again in a moment.`
94 }
95 }
96}
97
98/** What the fork cost; null when it cost nothing (nothing to fork, a refused request). */
99export const toUsage = (r: ModelForkResult): Usage | null => {
100 if (!r.isAnswered && r.reason === 'nothing-to-fork') return null
101 const u = r.usage
102 const usage = {
103 input: u.input_tokens,
104 output: u.output_tokens,
105 cacheRead: u.cache_read_input_tokens,
106 cacheWrite: u.cache_creation_input_tokens,
107 }
108
109 return usage.input + usage.output + usage.cacheRead + usage.cacheWrite > 0 ? usage : null
110}
111
112const short = (n: number): string =>
113 n >= 999_500 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
114
115/** `1.2k tokens (89% from cache)`. */
116export const cost = (u: Usage): string => {
117 const total = u.input + u.output + u.cacheRead + u.cacheWrite
118 const prompt = u.input + u.cacheRead + u.cacheWrite
119 const cached = prompt > 0 ? Math.round((u.cacheRead / prompt) * 100) : 0
120
121 return `${short(total)} tokens (${cached}% from cache)`
122}
123
124/** `14:02`, local time where the environment knows it. */
125export const clockTime = (ms: number): string => {
126 const d = new Date(ms)
127 const pad = (n: number) => String(n).padStart(2, '0')
128
129 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
130}
131types/index.d.ts 39 lines1/** What one reflection cost, from the fork result's `usage`. */
2export type Usage = {
3 input: number
4 output: number
5 cacheRead: number
6 cacheWrite: number
7}
8
9/** One reflection: asked by the person (`command`) or run after an editing turn (`auto`). */
10export type Reflection = {
11 id: number
12 /** When it started, ms since the epoch. */
13 at: number
14 /** Focus words given to /reflect, '' when none. */
15 focus: string
16 origin: 'command' | 'auto'
17 status: 'running' | 'done' | 'failed'
18 /** The model's Markdown once done, '' otherwise. */
19 markdown: string
20 /** Why it failed, in plain words, '' otherwise. */
21 error: string
22 usage: Usage | null
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 reflect: {
28 /** The last 5 reflections, oldest first. */
29 reflections: Reflection[]
30 /** The id the pane shows; null follows the newest. */
31 shown: number | null
32 /** Auto mode, mirrored from $.store key `auto`. */
33 isAuto: boolean
34 /** A main-loop edit succeeded in the turn that runs now. */
35 hasEdited: boolean
36 }
37 }
38}
39