Notices snags that come back across sessions (search spirals, fix loops, large tool outputs) and offers one change to the environment that stops them.

It is a mod rather than a skill because it has to watch every tool call, which a skill cannot, and a fix is offered as a row you answer with a click, not a question Claude has to remember to ask.
It watches the main loop for snags that waste a session, each one a kind the retro skill can fix in the environment:
Snags are kept per repo, named by the file searched most (or the folder the searches share), by the command, or by each program and its target. Each keeps the calls that showed it, one per session, and what it cost there: searches, failures, tokens (characters ÷ 4), seconds or refusals.
When a snag comes back in a second session, one model call proposes a fix and its kind: a pointer line for ./CLAUDE.md, a check, a standard, more information, a cheaper way, or a settings change such as a permission rule. It is told the calls, what the kind of snag usually means, and for a large read the current sizes in that folder, so a proposed check fits the repo as it is. A large read whose file has since dropped under the limit is not proposed. A row above the prompt offers the fix with what the snag costs a session:
./CLAUDE.md.After a fix is saved or sent, hindsight counts the sessions in that repo without the snag. Five make the fix held. A snag that comes back first reopens as "back after a fix", with its cost then and before, and the next proposal is told what did not hold. Putting a task in the prompt does not run or verify the fix; you send it.
/hindsight lists the repo's snags grouped by what happens next (fix proposed, waiting for a 2nd session, fix in progress, fixes that held), each with how often it was seen and what it costs, and puts /mattpocock-skills:retro with them and their calls in the prompt box (see Install).
hooks/register.tsx 477 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { Seen, Signal, Snag } from '../types'
5import { costOf, describe, listText, parseFix, proposePrompt, retroText, standing, taskText, withPointer } from './propose'
6import type { RepoFacts } from './propose'
7import {
8 BIG_OUTPUT_CHARS,
9 addAsked,
10 addOutput,
11 addReached,
12 addRun,
13 addSearch,
14 addTiming,
15 addWrite,
16 areaOf,
17 callText,
18 commandKey,
19 emptyLog,
20 signalsOf,
21 targetKey,
22} from './signals'
23import type { TurnLog } from './signals'
24
25const shown = atom({ plugin: 'hindsight', key: 'shown' } as const, null)
26
27const SNAGS_KEY = 'snags'
28const MUTED_KEY = 'muted'
29const RECUR_SESSIONS = 2
30const MAX_SNAGS = 200
31/** Forks are cached reads of the whole session: at most this many per session. */
32const MAX_FORKS = 2
33const SEEN_KEPT = 5
34/** Sessions without the snag after its fix before the fix counts as held. */
35const HELD_SESSIONS = 5
36const SIZES_SHOWN = 8
37
38async function snagsOf($: EngineInterface): Promise<Snag[]> {
39 const held = await $.store.get(SNAGS_KEY)
40
41 return Array.isArray(held) ? (held as Snag[]) : []
42}
43
44/** Keeps the newest MAX_SNAGS, dropping finished ones first. */
45async function saveSnags($: EngineInterface, list: Snag[]) {
46 const isOpen = (snag: Snag) => snag.status === 'watching' || snag.status === 'proposed'
47 const kept = list.length <= MAX_SNAGS ? list : [...list.filter(isOpen), ...list.filter(snag => !isOpen(snag))]
48
49 await $.store.set(SNAGS_KEY, kept.sort((a, b) => b.lastAt - a.lastAt).slice(0, MAX_SNAGS))
50}
51
52async function mutedOf($: EngineInterface): Promise<string[]> {
53 const held = await $.store.get(MUTED_KEY)
54
55 return Array.isArray(held) ? (held as string[]) : []
56}
57
58const muteKey = (snag: Pick<Snag, 'project' | 'kind' | 'area'>) => `${snag.project}\n${snag.kind}\n${snag.area}`
59
60async function refreshShown($: EngineInterface) {
61 const root = await $.session.root()
62 const next = (await snagsOf($)).find(snag => snag.project === root && snag.status === 'proposed') ?? null
63
64 await update($, shown, () => next)
65}
66
67const folderOf = (path: string) => path.split('\\').join('/').split('/').slice(0, -1).join('/')
68
69async function sizesNear($: EngineInterface, path: string | undefined): Promise<string[]> {
70 if (path === undefined) return []
71
72 const entries = await $.fs.list(folderOf(path)).catch(() => [])
73
74 return entries
75 .filter(entry => entry.kind === 'file')
76 .sort((a, b) => b.size - a.size)
77 .slice(0, SIZES_SHOWN)
78 .map(entry => `${entry.name} ${entry.size.toLocaleString('en-US')}`)
79}
80
81async function factsOf($: EngineInterface, root: string, path?: string): Promise<RepoFacts> {
82 const has = (path: string) => $.fs.exists(`${root}/${path}`).catch(() => false)
83 const claudeMd = (await has('CLAUDE.md')) ? await $.fs.read(`${root}/CLAUDE.md`).catch(() => '') : ''
84
85 return {
86 hasPreCommit: (await has('.git/hooks/pre-commit')) || (await has('.husky')) || (await has('.pre-commit-config.yaml')),
87 hasCi: await has('.github/workflows'),
88 claudeMdLines: typeof claudeMd === 'string' && claudeMd !== '' ? claudeMd.split('\n').length : 0,
89 sizes: await sizesNear($, path),
90 }
91}
92
93async function setStatus($: EngineInterface, id: string, change: (snag: Snag) => Snag) {
94 const list = await snagsOf($)
95 await saveSnags($, list.map(snag => (snag.id === id ? change(snag) : snag)))
96 await refreshShown($)
97}
98
99/** A big read whose file has since shrunk under the limit, or gone, needs no fix. */
100async function isCauseGone($: EngineInterface, snag: Snag) {
101 if (snag.kind !== 'big-output' || snag.path === undefined) return false
102
103 const stat = await $.fs.stat(snag.path).catch(() => undefined)
104
105 return stat === undefined || stat.size < BIG_OUTPUT_CHARS
106}
107
108async function judge($: EngineInterface, snag: Snag) {
109 if (await isCauseGone($, snag)) {
110 await setStatus($, snag.id, held => ({ ...held, status: 'watching', sessions: [] }))
111
112 return
113 }
114
115 const reply = await $.model.fork({ prompt: proposePrompt(snag, await factsOf($, snag.project, snag.path)) })
116 const fix = reply.isAnswered ? parseFix(reply.text) : undefined
117
118 if (fix === undefined) {
119 await setStatus($, snag.id, held => ({ ...held, status: 'watching' }))
120
121 return
122 }
123
124 await setStatus($, snag.id, held =>
125 fix === 'none' ? { ...held, status: 'quiet' } : { ...held, status: 'proposed', fix },
126 )
127}
128
129/** Counts this session against each signal; returns the snags that just came back. */
130async function record($: EngineInterface, signals: Signal[]): Promise<Snag[]> {
131 const root = await $.session.root()
132 const sessionId = await $.session.id()
133 const now = await $.clock.now()
134 const muted = await mutedOf($)
135 const list = await snagsOf($)
136 const recurred: Snag[] = []
137
138 for (const { call, cost, ...signal } of signals) {
139 if (muted.includes(muteKey({ ...signal, project: root }))) continue
140
141 const same = list.find(snag => snag.project === root && snag.kind === signal.kind && snag.area === signal.area)
142 const seen: Seen = { session: sessionId, call, cost }
143
144 if (same === undefined) {
145 list.push({ ...signal, seen: [seen], id: `s${now.toString(36)}${list.length}`, project: root, sessions: [sessionId], status: 'watching', lastAt: now })
146 continue
147 }
148
149 same.lastAt = now
150 const before = same.seen?.find(one => one.session === sessionId)?.cost ?? 0
151 same.seen = [...(same.seen ?? []).filter(one => one.session !== sessionId), { ...seen, cost: before + cost }].slice(-SEEN_KEPT)
152
153 if (signal.path !== undefined) same.path = signal.path
154
155 if (!same.sessions.includes(sessionId)) same.sessions.push(sessionId)
156
157 const isFixed = same.status === 'sent' || same.status === 'done' || same.status === 'held'
158
159 if (isFixed && same.fixedIn !== sessionId) {
160 same.tried = [...(same.tried ?? []), ...(same.fix === undefined ? [] : [same.fix.text])]
161 same.fix = undefined
162 same.fixedIn = undefined
163 same.clean = undefined
164 same.status = 'judging'
165 recurred.push(same)
166 } else if (same.status === 'watching' && same.sessions.length >= RECUR_SESSIONS) {
167 same.status = 'judging'
168 recurred.push(same)
169 }
170 }
171
172 await saveSnags($, list)
173
174 return recurred
175}
176
177const fixed = (snag: Snag): Snag => {
178 const cost = costOf(snag)
179
180 return { ...snag, clean: [], ...(cost === undefined ? {} : { costBefore: cost }) }
181}
182
183/** Counts this session for every fix in the repo still waiting to prove itself; enough clean sessions make it held. */
184async function countClean($: EngineInterface) {
185 const root = await $.session.root()
186 const sessionId = await $.session.id()
187 const list = await snagsOf($)
188 const isWaiting = (snag: Snag) =>
189 snag.project === root &&
190 (snag.status === 'done' || snag.status === 'sent') &&
191 snag.fixedIn !== sessionId &&
192 !(snag.clean ?? []).includes(sessionId)
193
194 if (!list.some(isWaiting)) return
195
196 await saveSnags(
197 $,
198 list.map(snag => {
199 if (!isWaiting(snag)) return snag
200
201 const clean = [...(snag.clean ?? []), sessionId]
202
203 return { ...snag, clean, ...(clean.length >= HELD_SESSIONS ? { status: 'held' as const } : {}) }
204 }),
205 )
206}
207
208async function save($: EngineInterface, snag: Snag) {
209 if (snag.fix === undefined) return
210
211 const path = `${snag.project}/CLAUDE.md`
212 const before = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
213 const after = withPointer(typeof before === 'string' ? before : '', snag.fix.text)
214
215 if (after === undefined) {
216 $.ui.toast(`hindsight: the hindsight section in ${path} is full or its markers are broken; nothing written`)
217
218 return
219 }
220
221 if (after !== before) await $.fs.write(path, after)
222
223 const fixedIn = await $.session.id()
224 await setStatus($, snag.id, held => ({ ...fixed(held), status: 'done', fixedIn }))
225 $.ui.toast(after === before ? `hindsight: already in ${path}` : `hindsight: added to ${path}`)
226}
227
228async function sendToPrompt($: EngineInterface, snag: Snag) {
229 if (snag.fix === undefined) return
230
231 const box = await $.prompt.read()
232
233 if (box.text.trim() !== '') {
234 $.ui.toast('hindsight: clear the prompt box first; the task goes there')
235
236 return
237 }
238
239 const filled = await $.prompt.fill({ text: taskText(snag, snag.fix.text), mode: 'replace' })
240
241 if (!filled.isFilled) return
242
243 const fixedIn = await $.session.id()
244 await setStatus($, snag.id, held => ({ ...fixed(held), status: 'sent', fixedIn }))
245}
246
247/** Not now: the snag starts counting sessions again, and is proposed afresh if it keeps coming back. */
248async function skip($: EngineInterface, snag: Snag) {
249 await setStatus($, snag.id, held => ({ ...held, status: 'watching', sessions: [], fix: undefined }))
250}
251
252async function mute($: EngineInterface, snag: Snag) {
253 const muted = await mutedOf($)
254 await $.store.set(MUTED_KEY, [...muted, muteKey(snag)])
255 await setStatus($, snag.id, held => ({ ...held, status: 'skipped' }))
256}
257
258// The module's own: a reload starts a fresh turn log; the store keeps the snags.
259let log: TurnLog = emptyLog()
260let forks = 0
261
262/** A shell call is named by its command words, the grain a permission rule takes ("Bash(npm test:*)"); any other tool by its name. */
263function askedCall(tool: string, input: unknown) {
264 const command = typeof input === 'object' && input !== null && 'command' in input ? String(input.command) : undefined
265
266 if (command === undefined) return { area: tool, call: callText(`${tool} ${JSON.stringify(input)}`) }
267
268 return { area: `${tool} ${commandKey(command) || targetKey(command)}`, call: callText(`${tool}: ${command}`) }
269}
270
271async function noteSearch($: EngineInterface, agentId: string | undefined, path: string | undefined) {
272 if (agentId === undefined) log = addSearch(log, path, await $.session.root())
273}
274
275function noteOutput(agentId: string | undefined, output: { area: string; call: string; path?: string }, text: string | undefined) {
276 if (agentId === undefined && text !== undefined) log = addOutput(log, output, text.length)
277}
278
279function noteWrite(agentId: string | undefined, path: string, ran: ToolCallResult) {
280 if (agentId === undefined && ran.deny === undefined && ran.isError !== true) log = addWrite(log, path)
281}
282
283function noteRun(agentId: string | undefined, shell: string, command: string, ran: ToolCallResult, ms: number) {
284 if (agentId !== undefined || ran.deny !== undefined) return
285
286 log = addRun(log, command, ran.isError === true)
287 log = addTiming(log, { area: `${shell} ${targetKey(command)}`, call: callText(`${shell}: ${command}`) }, ms)
288 noteOutput(agentId, { area: `${shell} ${targetKey(command)}`, call: callText(`${shell}: ${command}`) }, ran.text)
289}
290
291export const register: Register = on => {
292 log = emptyLog()
293 forks = 0
294
295 on('session.start', async ($, e, next) => {
296 await $.command.register({ name: 'hindsight', description: 'Snags hindsight saw in this repo; hands them to the retro skill' })
297 await countClean($)
298 const list = await snagsOf($)
299 // Snags from 0.2.0 have no calls and an area of another shape: they would never match a new one.
300 const current = list.filter(snag => snag.seen !== undefined)
301
302 if (current.length !== list.length || current.some(snag => snag.status === 'judging')) {
303 await saveSnags($, current.map(snag => (snag.status === 'judging' ? { ...snag, status: 'watching' } : snag)))
304 }
305
306 await refreshShown($)
307
308 return next(e)
309 })
310
311 on('turn.start', async ($, e, next) => {
312 log = emptyLog()
313
314 return next(e)
315 })
316
317 on('tool.check', async ($, e, next) => {
318 const decided = await next(e)
319
320 if (decided.decision === 'ask' && e.agentId === undefined && e.tool_use_id !== undefined) {
321 log = addAsked(log, e.tool_use_id, askedCall(e.tool, e.input))
322 }
323
324 return decided
325 }).catch(($, e, next) => next(e))
326
327 on('tool.call', async ($, e, next) => {
328 log = addReached(log, e.tool_use_id)
329
330 return next(e)
331 }).catch(($, e, next) => next(e))
332
333 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
334 const ran = await next(e)
335 await noteSearch($, e.agentId, e.file_path)
336 noteOutput(
337 e.agentId,
338 { area: `Read ${areaOf(e.file_path, await $.session.root())}`, call: `Read ${e.file_path}`, path: e.file_path },
339 ran.text,
340 )
341
342 return ran
343 }).catch(($, e, next) => next(e))
344
345 on('tool.call', { tool: 'Grep' }, async ($, e, next) => {
346 const ran = await next(e)
347 await noteSearch($, e.agentId, e.path)
348 const where = e.path === undefined ? '.' : areaOf(e.path, await $.session.root())
349 noteOutput(e.agentId, { area: `Grep ${where}`, call: `Grep "${e.pattern}" in ${where}` }, ran.text)
350
351 return ran
352 }).catch(($, e, next) => next(e))
353
354 on('tool.call', { tool: 'Glob' }, async ($, e, next) => {
355 const ran = await next(e)
356 await noteSearch($, e.agentId, e.path)
357
358 return ran
359 }).catch(($, e, next) => next(e))
360
361 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
362 const ran = await next(e)
363 noteWrite(e.agentId, e.file_path, ran)
364
365 return ran
366 }).catch(($, e, next) => next(e))
367
368 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
369 const ran = await next(e)
370 noteWrite(e.agentId, e.file_path, ran)
371
372 return ran
373 }).catch(($, e, next) => next(e))
374
375 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
376 const ran = await next(e)
377 noteWrite(e.agentId, e.notebook_path, ran)
378
379 return ran
380 }).catch(($, e, next) => next(e))
381
382 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
383 const startedAt = await $.clock.now()
384 const ran = await next(e)
385 noteRun(e.agentId, 'Bash', e.command, ran, (await $.clock.now()) - startedAt)
386
387 return ran
388 }).catch(($, e, next) => next(e))
389
390 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
391 const startedAt = await $.clock.now()
392 const ran = await next(e)
393 noteRun(e.agentId, 'PowerShell', e.command, ran, (await $.clock.now()) - startedAt)
394
395 return ran
396 }).catch(($, e, next) => next(e))
397
398 on('turn.complete', async ($, e, next) => {
399 const done = await next(e)
400
401 if (e.agentId !== undefined) return done
402
403 const signals = signalsOf(log)
404 log = emptyLog()
405
406 if (signals.length === 0) return done
407
408 const recurred = await record($, signals)
409
410 for (const snag of recurred) {
411 if (forks >= MAX_FORKS) {
412 await setStatus($, snag.id, held => ({ ...held, status: 'watching' }))
413 continue
414 }
415
416 forks += 1
417 $.clock.after(0, () => void judge($, snag).catch(() => setStatus($, snag.id, held => ({ ...held, status: 'watching' }))))
418 }
419
420 return done
421 }).catch(($, e, next) => next(e))
422
423 on('command.run', { command: 'hindsight' }, async $ => {
424 const root = await $.session.root()
425 const here = (await snagsOf($)).filter(snag => snag.project === root)
426
427 if (here.length === 0) return { text: 'hindsight has seen no snags in this repo yet.' }
428
429 const lines = [listText(here, HELD_SESSIONS)]
430 const box = await $.prompt.read()
431 const filled = box.text.trim() === '' ? await $.prompt.fill({ text: retroText(here), mode: 'replace' }) : undefined
432 const handoff = filled?.isFilled === true
433 ? 'The retro skill with these snags is in your prompt box. Press Enter to run it.'
434 : 'Clear the prompt box and run /hindsight again to hand these to the retro skill.'
435
436 return { text: [...lines, '', handoff].join('\n') }
437 })
438
439 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
440 const below = await next(e)
441 const snag = await read($, shown)
442
443 if (e.props.hasSurvey || e.props.isWorking || snag === null || snag.fix === undefined) return below
444
445 const { Box, Button, Text } = $.ui.resolve(e)
446 const isPointer = snag.fix.kind === 'pointer'
447
448 return (
449 <Box flexDirection="column">
450 {below.type === 'engine' ? null : below}
451 <Box>
452 <Text color="warning">⚑ </Text>
453 <Text wrap="wrap">
454 {`hindsight · ${snag.tried === undefined ? standing(snag) : 'back after a fix'}: ${describe(snag)}`}
455 </Text>
456 </Box>
457 <Text wrap="wrap">{` ${isPointer ? `Add to ./CLAUDE.md: "${snag.fix.text}"` : snag.fix.text}`}</Text>
458 <Box gap={1}>
459 <Text>{' '}</Text>
460 <Button
461 key="act"
462 variant="primary"
463 label={isPointer ? 'Save' : 'Put task in prompt'}
464 onPress={() => (isPointer ? save($, snag) : sendToPrompt($, snag))}
465 />
466 <Button key="mute" label="Not in this repo" onPress={() => mute($, snag)} />
467 <Button
468 key="skip"
469 label="Skip"
470 onPress={() => skip($, snag)}
471 />
472 </Box>
473 </Box>
474 )
475 })
476}
477hooks/propose.ts 208 lines1import type { FixKind, Snag } from '../types'
2
3export const MAX_POINTERS = 15
4const MAX_FIX = 300
5const START = '<!-- hindsight: navigation pointers (edit freely) -->'
6const END = '<!-- end hindsight -->'
7const FIX_KINDS: readonly FixKind[] = ['pointer', 'check', 'standard', 'info', 'economy', 'setting']
8const UNIT: Record<Snag['kind'], [string, string]> = {
9 search: ['search', 'searches'],
10 'fix-loop': ['failure', 'failures'],
11 retry: ['failure', 'failures'],
12 'big-output': ['token', 'tokens'],
13 slow: ['second', 'seconds'],
14 denied: ['decline', 'declines'],
15}
16
17const thousands = (count: number) => (count >= 10_000 ? `${Math.round(count / 1000)}k` : count.toLocaleString('en-US'))
18
19export const costText = (kind: Snag['kind'], cost: number) => {
20 const rounded = Math.round(cost)
21 const [one, many] = UNIT[kind]
22
23 return `${kind === 'big-output' ? '~' : ''}${thousands(rounded)} ${rounded === 1 ? one : many}`
24}
25
26/** The average cost per session the snag was seen in. */
27export function costOf(snag: Pick<Snag, 'seen'>): number | undefined {
28 const costs = (snag.seen ?? []).flatMap(one => (one.cost === undefined ? [] : [one.cost]))
29
30 return costs.length === 0 ? undefined : costs.reduce((sum, cost) => sum + cost, 0) / costs.length
31}
32
33/** `sizes`: the big file's folder as it is now, largest first, so a proposed check fits the repo today. */
34export type RepoFacts = { hasPreCommit: boolean; hasCi: boolean; claudeMdLines: number; sizes: string[] }
35
36export function describe(snag: Pick<Snag, 'kind' | 'area'>): string {
37 if (snag.kind === 'search') return `10 or more searches in ${snag.area} before the first edit of a turn`
38 if (snag.kind === 'fix-loop') return `\`${snag.area}\` failed, was fixed by an edit, and failed again`
39 if (snag.kind === 'retry') return `\`${snag.area}\` failed 3 or more times in a row with no edit between`
40 if (snag.kind === 'slow') return `a call that took over a minute: ${snag.area}`
41 if (snag.kind === 'denied') return `a call refused at the permission prompt (by you or auto mode): ${snag.area}`
42
43 return `a tool output over 20,000 characters from ${snag.area}`
44}
45
46/** What the snag usually means, so the proposal starts from the likely cause. */
47const HINT: Partial<Record<Snag['kind'], string>> = {
48 retry: 'The agent ran the same failing command again without changing anything it could see: often a tool quirk (quoting, paths, the shell) it does not know about.',
49 slow: 'A slow call blocks the turn: often a command that waits (a watcher, a prompt, a network fetch) or does more than needed.',
50 denied: 'This call was refused at the permission prompt in more than one session. Either the agent should stop asking for it, or the user should allow it in settings; say which, and the exact rule.',
51}
52
53export function proposePrompt(snag: Snag, facts: RepoFacts): string {
54 return [
55 'Pause the task. This is a question about the environment, not about the work.',
56 `This session hit a snag that also came up in ${snag.sessions.length - 1} earlier session(s): ${describe(snag)}.`,
57 ...(HINT[snag.kind] === undefined ? [] : [HINT[snag.kind] ?? '']),
58 `Repo facts: pre-commit hook ${facts.hasPreCommit ? 'yes' : 'no'}; CI workflow ${facts.hasCi ? 'yes' : 'no'}; ./CLAUDE.md ${facts.claudeMdLines} lines.`,
59 ...(snag.seen === undefined ? [] : ['Calls that showed it:', ...snag.seen.map(one => `- ${one.call}`)]),
60 ...(facts.sizes.length === 0
61 ? []
62 : [
63 'Files in that folder now (bytes):',
64 ...facts.sizes.map(line => `- ${line}`),
65 'A proposed check must pass on these files as they are, or the change must name what to split first.',
66 ]),
67 ...(snag.tried === undefined
68 ? []
69 : ['These fixes were saved or sent before, and the snag came back anyway. Do not propose them again:', ...snag.tried.map(text => `- ${text}`)]),
70 '',
71 'Propose ONE change to the coding agent\'s environment that stops this snag next time. Kinds:',
72 '- pointer: one line for ./CLAUDE.md that tells the agent where to look ("Mod hook code: plugins/<mod>/hooks/register.tsx").',
73 '- check: an automated check (lint rule, pre-commit hook, CI job, test) that catches the mistake. A mechanical rule always gets a check, never a written rule.',
74 '- standard: a judgement call for the code reviewer, which no check can catch.',
75 '- info: a way to give the agent information it lacked (a log, a read-only command).',
76 '- economy: a cheaper way to get the same information (a narrower command, a quiet flag, a limit).',
77 '- setting: a Claude Code settings change, such as a permission rule in .claude/settings.json ("Bash(npm test:*)").',
78 '- none: nothing in the environment would have helped.',
79 'Write the change as one imperative sentence a person can act on, at most 40 words. A pointer is the exact line, without "Add to CLAUDE.md".',
80 '',
81 'Answer with JSON only: {"fix": "pointer"|"check"|"standard"|"info"|"economy"|"setting"|"none", "text": "..."}',
82 ].join('\n')
83}
84
85export type Fix = { kind: FixKind; text: string } | 'none'
86
87/** A pointer the model wrapped as an instruction ("Add to ./CLAUDE.md: \"...\""): the row adds that itself. */
88const POINTER_WRAPPER = /^(add|append)( (this|the|a|one))?( line)? to (\.\/)?CLAUDE\.md\s*:\s*/i
89
90const pointerLine = (text: string) => {
91 const bare = text.replace(POINTER_WRAPPER, '')
92
93 return bare === text ? text : bare.replace(/^["'“](.*)["'”]$/, '$1').trim()
94}
95
96export function parseFix(reply: string): Fix | undefined {
97 const json = /\{[\s\S]*\}/.exec(reply)?.[0]
98
99 if (json === undefined) return undefined
100
101 try {
102 const parsed = JSON.parse(json) as Record<string, unknown>
103
104 if (parsed.fix === 'none') return 'none'
105
106 const kind = FIX_KINDS.find(one => one === parsed.fix)
107 const said = typeof parsed.text === 'string' ? parsed.text.trim().replace(/\s+/g, ' ') : ''
108 const text = kind === 'pointer' ? pointerLine(said) : said
109
110 if (kind === undefined || text === '' || text.length > MAX_FIX) return undefined
111 if (kind === 'pointer' && (text.includes('<!--') || text.includes('-->'))) return undefined
112
113 return { kind, text }
114 } catch {
115 return undefined
116 }
117}
118
119/**
120 * `file` with `pointer` added inside the marked section; the file unchanged when the line is there; undefined when
121 * the markers are broken or the section already holds MAX_POINTERS lines: never guess where to write.
122 */
123export function withPointer(file: string, pointer: string): string | undefined {
124 const line = `- ${pointer.replace(/^[-*]\s+/, '')}`
125
126 if (file.split(/\r?\n/).some(existing => existing.trim() === line)) return file
127
128 const starts = file.split(START).length - 1
129 const ends = file.split(END).length - 1
130
131 if (starts === 0 && ends === 0) {
132 const gap = file === '' || file.endsWith('\n\n') ? '' : file.endsWith('\n') ? '\n' : '\n\n'
133
134 return `${file}${gap}${START}\n${line}\n${END}\n`
135 }
136
137 const start = file.indexOf(START)
138 const end = file.indexOf(END)
139
140 if (starts !== 1 || ends !== 1 || end < start) return undefined
141
142 const held = file.slice(start + START.length, end).split(/\r?\n/).filter(existing => existing.startsWith('- '))
143
144 if (held.length >= MAX_POINTERS) return undefined
145
146 return `${file.slice(0, end)}${line}\n${file.slice(end)}`
147}
148
149export const taskText = (snag: Snag, fixText: string) =>
150 `hindsight saw this snag in ${snag.sessions.length} sessions: ${describe(snag)}.\nMake this change: ${fixText}`
151
152const times = (count: number) => (count === 1 ? 'once' : `in ${count} sessions`)
153
154/** How the snag stands, in words: what it costs, and whether a fix held. */
155export function standing(snag: Snag, heldAfter = 5): string {
156 const cost = costOf(snag)
157 const perSession = cost === undefined ? '' : `, ${costText(snag.kind, cost)} a session`
158 const clean = snag.clean?.length ?? 0
159
160 if (snag.status === 'held') {
161 return `quiet for ${clean} sessions since the fix${snag.costBefore === undefined ? '' : `; was ${costText(snag.kind, snag.costBefore)} a session`}`
162 }
163 if (snag.status === 'done' || snag.status === 'sent') {
164 return `fix ${snag.status === 'done' ? 'saved' : 'sent'}; ${clean} of ${heldAfter} clean sessions`
165 }
166
167 const latest = snag.seen?.at(-1)?.cost
168
169 if (snag.tried !== undefined && snag.costBefore !== undefined && latest !== undefined) {
170 return `back after a fix: ${costText(snag.kind, latest)} last session, was ${costText(snag.kind, snag.costBefore)}`
171 }
172
173 return `seen ${times(snag.sessions.length)}${perSession}`
174}
175
176const GROUPS: readonly { title: string; statuses: readonly Snag['status'][] }[] = [
177 { title: 'Fix proposed: it is in the row above the prompt', statuses: ['proposed', 'judging'] },
178 { title: 'Waiting for a 2nd session before proposing a fix', statuses: ['watching'] },
179 { title: 'Fix in progress: held after 5 sessions without the snag', statuses: ['sent', 'done'] },
180 { title: 'Fixes that held', statuses: ['held'] },
181 { title: 'No environment fix found; watching for a change', statuses: ['quiet'] },
182]
183
184/** The `/hindsight` list: snags grouped by what happens next, muted ones only counted. */
185export function listText(snags: Snag[], heldAfter = 5): string {
186 const groups = GROUPS.flatMap(({ title, statuses }) => {
187 const here = snags.filter(snag => statuses.includes(snag.status))
188
189 if (here.length === 0) return []
190
191 const lines = here.map(snag => ` · ${describe(snag)} — ${standing(snag, heldAfter)}`)
192
193 return [[`${title} (${here.length}):`, ...lines].join('\n')]
194 })
195 const muted = snags.filter(snag => snag.status === 'skipped').length
196
197 return [...groups, ...(muted === 0 ? [] : [`Muted in this repo: ${muted}.`])].join('\n\n')
198}
199
200export function retroText(snags: Snag[]): string {
201 const lines = snags.flatMap(snag => [
202 `- ${describe(snag)} (${standing(snag)})`,
203 ...(snag.seen ?? []).map(one => ` - session ${one.session}: ${one.call}`),
204 ])
205
206 return ['/mattpocock-skills:retro', 'Start from these snags hindsight recorded in this repo:', ...lines].join('\n')
207}
208hooks/signals.ts 211 lines1import type { Signal } from '../types'
2
3export const SPIRAL_SEARCHES = 10
4export const LOOP_FAILS = 2
5export const BIG_OUTPUT_CHARS = 20_000
6const CALL_CHARS = 200
7const TARGET_SEGMENTS = 3
8const COMMAND_WORDS = 3
9const SHOWN_PATHS = 3
10export const SLOW_MS = 60_000
11/** Failures in a row with no edit between: the same command tried again unchanged. */
12export const BLIND_FAILS = 3
13const CHARS_PER_TOKEN = 4
14
15/** `blind`: failures since the last edit or pass, so a red-green-red test cycle never counts. */
16type Run = { fails: number; blind: number; isEditedSince: boolean; command: string }
17
18type Call = { area: string; call: string }
19
20type BigOutput = Call & { path?: string; chars: number }
21
22type Slow = Call & { ms: number }
23
24export type TurnLog = {
25 searches: number
26 paths: string[]
27 hasWritten: boolean
28 written: string[]
29 runs: Record<string, Run>
30 bigOutputs: BigOutput[]
31 slow: Slow[]
32 /** Calls put to the permission dialog that have not run yet; any left at the turn's end were declined. */
33 asked: Record<string, Call>
34}
35
36export const emptyLog = (): TurnLog => ({
37 searches: 0,
38 paths: [],
39 hasWritten: false,
40 written: [],
41 runs: {},
42 bigOutputs: [],
43 slow: [],
44 asked: {},
45})
46
47const slashed = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '')
48
49const samePath = (a: string, b: string) => slashed(a).toLowerCase() === slashed(b).toLowerCase()
50
51/** Hashes, UUIDs and version folders change between installs; a snag must not. */
52const isVolatile = (part: string) => /^[0-9a-f]{16,}$/i.test(part) || /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(part)
53
54/** `path` relative to `root` when under it, with volatile folders as `*`; '.' for the root itself. */
55export function areaOf(path: string, root: string): string {
56 const full = slashed(path)
57 const base = slashed(root)
58 const isUnder = full.toLowerCase().startsWith(`${base.toLowerCase()}/`)
59 const parts = (isUnder ? full.slice(base.length + 1) : full).split('/').filter(part => part !== '' && part !== '.')
60
61 return parts.map(part => (isVolatile(part) ? '*' : part)).join('/') || '.'
62}
63
64/** The command a shell line runs, as its first words: "npm test", "claude plugin test". */
65export function commandKey(command: string): string {
66 const last = command.split(/&&|\|\||;|\n/).map(part => part.trim()).filter(Boolean).at(-1) ?? ''
67 const words = last
68 .split(/\s+/)
69 .filter(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))
70 .filter(word => !word.startsWith('-') && !/[/\\.]/.test(word) && !/["'$`|<>]/.test(word))
71
72 return words.slice(0, COMMAND_WORDS).join(' ')
73}
74
75const baseName = (word: string) => slashed(word.replace(/^["']|["']$/g, '')).split('/').at(-1) ?? word
76
77/** Each step of a shell line as its program and first target: "cat HANDOFF.md, ls plugins". */
78export function targetKey(command: string): string {
79 const steps = command
80 .split(/&&|\|\||;|\||\n/)
81 .map(step => step.trim().split(/\s+/).filter(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)))
82 .filter(words => words.length > 0 && !['cd', 'echo', 'printf'].includes(words[0] ?? ''))
83 .map(([program = '', ...args]) => {
84 const target = args.find(word => !word.startsWith('-') && !/^[|<>&$`(]/.test(word))
85
86 return [baseName(program), ...(target === undefined ? [] : [baseName(target)])].join(' ')
87 })
88
89 return steps.slice(0, TARGET_SEGMENTS).join(', ')
90}
91
92export const callText = (text: string) => {
93 const flat = text.replace(/\s+/g, ' ').trim()
94
95 return flat.length > CALL_CHARS ? `${flat.slice(0, CALL_CHARS - 1)}…` : flat
96}
97
98export function addSearch(log: TurnLog, path: string | undefined, root: string): TurnLog {
99 if (log.hasWritten) return log
100
101 return { ...log, searches: log.searches + 1, paths: path === undefined ? log.paths : [...log.paths, areaOf(path, root)] }
102}
103
104/** An edit also clears a big read of the same file: reading a file whole to change it is the work, not waste. */
105export function addWrite(log: TurnLog, path?: string): TurnLog {
106 const runs = Object.fromEntries(Object.entries(log.runs).map(([key, run]) => [key, { ...run, blind: 0, isEditedSince: true }]))
107 const bigOutputs = path === undefined ? log.bigOutputs : log.bigOutputs.filter(big => big.path === undefined || !samePath(big.path, path))
108
109 return { ...log, hasWritten: true, written: path === undefined ? log.written : [...log.written, path], runs, bigOutputs }
110}
111
112/** A failure counts once for a fix loop until an edit happens, and once more for `blind` each time; a pass clears the command. */
113export function addRun(log: TurnLog, command: string, isError: boolean): TurnLog {
114 const key = commandKey(command)
115
116 if (key === '') return log
117 if (!isError) return { ...log, runs: { ...log.runs, [key]: { fails: 0, blind: 0, isEditedSince: false, command } } }
118
119 const was = log.runs[key] ?? { fails: 0, blind: 0, isEditedSince: false, command }
120 const fails = was.fails === 0 || was.isEditedSince ? was.fails + 1 : was.fails
121 const blind = was.command === command ? was.blind + 1 : 1
122
123 return { ...log, runs: { ...log.runs, [key]: { fails, blind, isEditedSince: false, command } } }
124}
125
126export function addOutput(log: TurnLog, output: Omit<BigOutput, 'chars'>, chars: number): TurnLog {
127 if (chars < BIG_OUTPUT_CHARS || log.bigOutputs.some(big => big.area === output.area)) return log
128 if (output.path !== undefined && log.written.some(path => samePath(path, output.path ?? ''))) return log
129
130 return {
131 ...log,
132 bigOutputs: [...log.bigOutputs, { ...output, chars, call: callText(`${output.call} → ${chars.toLocaleString('en-US')} chars`) }],
133 }
134}
135
136export function addTiming(log: TurnLog, call: Call, ms: number): TurnLog {
137 if (ms < SLOW_MS || log.slow.some(one => one.area === call.area)) return log
138
139 return { ...log, slow: [...log.slow, { ...call, ms }] }
140}
141
142export const addAsked = (log: TurnLog, id: string, call: Call): TurnLog => ({ ...log, asked: { ...log.asked, [id]: call } })
143
144export function addReached(log: TurnLog, id: string | undefined): TurnLog {
145 if (id === undefined || !(id in log.asked)) return log
146
147 return { ...log, asked: Object.fromEntries(Object.entries(log.asked).filter(([held]) => held !== id)) }
148}
149
150/** The one path searched twice or more, else the deepest folder every search shares. */
151export function focusOf(paths: string[]): string {
152 const counts = new Map<string, number>()
153
154 for (const path of paths) counts.set(path, (counts.get(path) ?? 0) + 1)
155
156 const [top, times] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0] ?? ['.', 0]
157
158 if (times >= 2) return top
159
160 const folders = paths.map(path => path.split('/').slice(0, /\.[A-Za-z0-9]+$/.test(path) ? -1 : undefined))
161 const shared: string[] = []
162
163 for (const [at, part] of (folders[0] ?? []).entries()) {
164 if (!folders.every(folder => folder[at] === part)) break
165 shared.push(part)
166 }
167
168 return shared.join('/') || '.'
169}
170
171export function signalsOf(log: TurnLog): Signal[] {
172 const spiral: Signal[] =
173 log.searches >= SPIRAL_SEARCHES
174 ? [
175 {
176 kind: 'search',
177 area: focusOf(log.paths),
178 call: callText(`${log.searches} searches before an edit, last: ${log.paths.slice(-SHOWN_PATHS).join(', ')}`),
179 cost: log.searches,
180 },
181 ]
182 : []
183 const loops: Signal[] = Object.entries(log.runs)
184 .filter(([, run]) => run.fails >= LOOP_FAILS)
185 .map(([key, run]) => ({ kind: 'fix-loop', area: key, call: callText(run.command), cost: run.fails }))
186 const retries: Signal[] = Object.entries(log.runs)
187 .filter(([, run]) => run.blind >= BLIND_FAILS && run.fails < LOOP_FAILS)
188 .map(([key, run]) => ({ kind: 'retry', area: key, call: callText(run.command), cost: run.blind }))
189 const big: Signal[] = log.bigOutputs.map(({ area, call, path, chars }) => ({
190 kind: 'big-output',
191 area,
192 call,
193 cost: Math.round(chars / CHARS_PER_TOKEN),
194 ...(path === undefined ? {} : { path }),
195 }))
196 const slow: Signal[] = log.slow.map(({ area, call, ms }) => ({
197 kind: 'slow',
198 area,
199 call: callText(`${call} → ${Math.round(ms / 1000)}s`),
200 cost: Math.round(ms / 1000),
201 }))
202 const declines = new Map<string, Signal>()
203
204 for (const { area, call } of Object.values(log.asked)) {
205 const was = declines.get(area)
206 declines.set(area, { kind: 'denied', area, call: was?.call ?? call, cost: (was?.cost ?? 0) + 1 })
207 }
208
209 return [...spiral, ...loops, ...retries, ...big, ...slow, ...declines.values()]
210}
211types/index.d.ts 43 lines1export type SnagKind = 'search' | 'fix-loop' | 'retry' | 'big-output' | 'slow' | 'denied'
2export type FixKind = 'pointer' | 'check' | 'standard' | 'info' | 'economy' | 'setting'
3export type SnagStatus = 'watching' | 'judging' | 'proposed' | 'quiet' | 'sent' | 'done' | 'held' | 'skipped'
4
5export type Signal = {
6 kind: SnagKind
7 area: string
8 /** The call that showed the snag, cut short: the evidence a retro starts from. */
9 call: string
10 /** What the snag cost in that turn, in its kind's unit: searches, failures, tokens, seconds or declines. */
11 cost: number
12 /** The file a big read came from, so its size can be checked again before proposing. */
13 path?: string
14}
15
16/** `cost` sums every turn of that session. */
17export type Seen = { session: string; call: string; cost?: number }
18
19export type Snag = Omit<Signal, 'call' | 'cost'> & {
20 /** The latest calls that showed it, one per session. */
21 seen?: Seen[]
22 id: string
23 project: string
24 sessions: string[]
25 status: SnagStatus
26 fix?: { kind: FixKind; text: string }
27 /** The session the fix was saved or sent in; the snag reopens if it comes back in another one. */
28 fixedIn?: string
29 /** The cost per session when the fix went in, to compare against if the snag comes back. */
30 costBefore?: number
31 /** Sessions started since the fix without the snag; enough of them make the fix `held`. */
32 clean?: string[]
33 /** Fixes that were saved or sent before, and the snag came back anyway. */
34 tried?: string[]
35 lastAt: number
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 hindsight: { shown: Snag | null }
41 }
42}
43