SLOPSHOPPER

hindsight

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

newbandguardcommandtoastmodel
v0.4.1no licenseupdated 2026-10-13a-pavithraa/claude-mods/plugins/hindsight
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hindsight
› 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 › /hindsight ⎿ hindsight: hindsight has seen no snags in this repo yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

hindsight

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:

  • many searches and reads before the first edit of a turn
  • a command that fails, gets an edit, and fails again
  • the same command failing 3 times in a row with no edit between: a tool quirk the agent does not know (a red, edit, red test cycle is not this)
  • a very large tool output, unless the same file is edited in that turn: reading a file whole to change it is the work
  • a shell call that takes over a minute
  • a call refused at the permission prompt, by you or auto mode

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:

  • Save writes a pointer into a marked, size-capped section of ./CLAUDE.md.
  • Put task in prompt hands any other fix to Claude.
  • Skip means not now: the snag is proposed again if it keeps coming back.
  • Not in this repo mutes that snag for good.

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).

Source 4 files
hooks/register.tsx 477 lines
1import { 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}
477
hooks/propose.ts 208 lines
1import 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}
208
hooks/signals.ts 211 lines
1import 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}
211
types/index.d.ts 43 lines
1export 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