Shows you what gsd-core's hooks quietly tell the agent: context warnings, guard advice and guard denials.

A Claude Code mod that shows you what gsd-core's own hooks quietly tell the agent: context warnings, read-before-edit and workflow advice, .planning edit reminders, and guard denials (secret reads, worktree paths, catastrophic STATE/ROADMAP shrinks, commit format, agent isolation).
Status: v0.3.0 (UI), mechanism confirmed live (Claude Code 2.1.291, gsd-core hooks installed through settings.json): the mod saw a gsd-core PostToolUse advisory and a PreToolUse deny in a real session. Plugin-install gsd-core is not yet checked. Results: ../../research/spike-results-gsd-whisper.md.
userMessageBackground), so it reads in any theme. The most severe message leads: GSD [ BLOCKED ] secret-read-guard: Secret read guard: Read would read '.../spike/.env' ... or GSD [ ADVISED ] phase-boundary: .planning edit (...). Reads and searches that fold into one group line get one badge on the group, led by a block if there is one./gsd-pause-work into the prompt; you press Enter) and Dismiss./gsd-whisper opens a pane, docked on the right in the gsd-status-mod style: counts (blocked / asked / advised) and the session's history, newest first, each row one line with a toggle that opens the whole message, and a Clear button (hotkey c once the pane has the keys: ctrl+x then tab; clicks reach it only in the fullscreen terminal). /gsd-whisper clear clears it from anywhere. Option openOnStart (off by default) opens it at session start in a GSD project, in the fullscreen layout only; see "Panes (and Orca)" in the repository README. Where no pane can be placed it answers with the same history as text.next(e) first and returns that result unchanged, and a failure inside the mod falls through to next(e).gsd-core/hooks/ on next (2026-10-06). Other hooks' output is counted and left alone.Claude Code runs a classic event's chain as [managed settings hooks, ...hooks modules, the other settings hooks], so a mod's await next(e) returns what the settings hooks answered: additionalContext, block, and for PreToolUse deny / ask. Texts arrive merged with no per-hook attribution, which is why recognition is by message text.
v0.1 also registers a read-only model tool, mcp__gsd-whisper__report, so an agent in a live session can read the same report and confirm what the mod saw. It will become opt-in or go away after the spike.
claude plugin validate . claude plugin test . claude --plugin-dir .
hooks/register.tsx 533 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Critical, Whisper, WhisperKind, WhisperStats } from '../types'
5import { classify } from './classify'
6
7// gsd-whisper: shows the person what gsd-core's hooks tell the agent.
8//
9// A hooks module sits above the settings hooks in every classic event's chain,
10// so `await next(e)` returns what gsd-core's command hooks answered. This mod
11// only reads that answer and always returns it unchanged: it never rewrites
12// what the model receives and never decides a tool call.
13//
14// Where it shows up:
15// - a badge under the tool row a gsd-core hook spoke about,
16// - a toast for messages tied to no tool call (Stop, session start),
17// - a band above the prompt at gsd-core's context CRITICAL,
18// - a pane, /gsd-whisper, with the session's history.
19
20const whispers = atom({ plugin: 'gsd-whisper', key: 'whispers' } as const, [] as Whisper[])
21const stats = atom({ plugin: 'gsd-whisper', key: 'stats' } as const, {
22 events: {},
23 withOutput: {},
24 recognized: 0,
25 unrecognized: [],
26} as WhisperStats)
27const critical = atom({ plugin: 'gsd-whisper', key: 'critical' } as const, null as Critical | null)
28const byCall = atom({ plugin: 'gsd-whisper', key: 'byCall' } as const, [] as Whisper[])
29
30const PANE = 'gsd-whisper'
31const KEEP = 200
32const REPEAT_MS = 60_000
33
34// Texts toasted recently, so a message tied to no tool call is toasted once a
35// minute. A module variable: a reload resets it, which only re-shows a toast.
36const lastToasted = new Map<string, number>()
37
38// ---- pane placement: the gsd-status-mod setup (a docked pane, `openOnStart`),
39// off by default here since the history is empty until a hook speaks. The pane
40// opens unasked only where Claude Code's fullscreen layout docks it as a
41// sidebar (`/tui fullscreen`), and says why when it waits: an Orca split is
42// often under the 144 columns an unasked pane needs (110 once opened by hand).
43
44const PANE_SIZE = { columns: 64, rows: 16 }
45const LAYOUT_KEY = 'fullscreen'
46const HINTED_KEY = 'hinted-main-screen'
47
48let openOnStart = false
49let fullscreen: boolean | undefined // as a render or command last reported it; fixed per session
50let autoTried = false
51let closedByHand = false
52const openRows = new Set<string>() // history rows the person opened with their ▸
53
54function rowId(w: Whisper): string {
55 return `${w.at}|${w.event}|${w.rule}|${w.toolUseId ?? ''}`
56}
57
58export const MAIN_SCREEN_HINT =
59 'Claude Code is on its main-screen layout, so the pane sits above the prompt. Run /tui fullscreen to dock it on the right (Orca included).'
60
61export function waitingHint(reason: string): string {
62 return `GSD whispers pane is waiting: ${reason.replace(/\.?\s*$/, '.')} Run /gsd-whisper to open it now.`
63}
64
65function noteLayout(v: { isFullscreen?: boolean } | undefined): void {
66 if (typeof v?.isFullscreen === 'boolean') fullscreen = v.isFullscreen
67}
68
69async function keepLayout($: EngineInterface): Promise<void> {
70 if (fullscreen !== undefined && (await $.store.get(LAYOUT_KEY)) !== fullscreen) await $.store.set(LAYOUT_KEY, fullscreen)
71}
72
73// The unasked open, tried once per session as soon as the layout is known, and
74// only in a GSD project (a .planning/STATE.md where the session started).
75async function autoOpen($: EngineInterface): Promise<void> {
76 if (autoTried || closedByHand || !openOnStart) return
77 if (!(await $.fs.exists(`${await $.session.cwd()}/.planning/STATE.md`))) return
78 const stored = await $.store.get(LAYOUT_KEY)
79 const layout = fullscreen ?? (typeof stored === 'boolean' ? stored : undefined)
80 if (layout === undefined) return
81 // A remembered layout is a guess (the person may have run /tui since): it can
82 // open the pane early, but only this session's own report settles a "no".
83 if (layout || fullscreen !== undefined) autoTried = true
84 if (!layout) {
85 if ((await $.store.get(HINTED_KEY)) !== true) {
86 await $.store.set(HINTED_KEY, true)
87 $.ui.toast(`gsd-whisper: ${MAIN_SCREEN_HINT}`)
88 }
89 return
90 }
91 const opened = await $.ui.open({ id: PANE, title: 'GSD whispers', ...PANE_SIZE })
92 if (!opened.isPlaced) $.ui.toast(waitingHint(opened.reason))
93}
94
95type Seen = { kind: WhisperKind; text: string }
96
97function textsOf(result: unknown): Seen[] {
98 const out: Seen[] = []
99 if (result === null || typeof result !== 'object') return out
100 const r = result as { deny?: unknown; ask?: unknown; block?: unknown; additionalContext?: unknown }
101 if (typeof r.deny === 'string') out.push({ kind: 'block', text: r.deny })
102 if (typeof r.block === 'string') out.push({ kind: 'block', text: r.block })
103 if (typeof r.ask === 'string') out.push({ kind: 'ask', text: r.ask })
104 if (Array.isArray(r.additionalContext)) {
105 for (const t of r.additionalContext) if (typeof t === 'string') out.push({ kind: 'advice', text: t })
106 }
107 return out
108}
109
110async function observe(
111 $: EngineInterface,
112 event: string,
113 result: unknown,
114 tool: string | undefined,
115 toolUseId: string | undefined,
116 isSubagent: boolean,
117): Promise<void> {
118 const seen = textsOf(result)
119 const now = await $.clock.now()
120 await update($, stats, s => ({
121 ...s,
122 events: { ...s.events, [event]: (s.events[event] ?? 0) + 1 },
123 withOutput: seen.length > 0 ? { ...s.withOutput, [event]: (s.withOutput[event] ?? 0) + 1 } : s.withOutput,
124 }))
125
126 for (const one of seen) {
127 const hit = classify(one.text)
128 if (hit === null) {
129 const sample = `${event} ${one.kind}: ${one.text.replace(/\s+/g, ' ').slice(0, 90)}`
130 await update($, stats, s => ({ ...s, unrecognized: [...s.unrecognized, sample].slice(-10) }))
131 continue
132 }
133
134 const whisper: Whisper = {
135 id: now,
136 at: now,
137 event,
138 rule: hit.rule,
139 kind: one.kind,
140 summary: hit.summary,
141 tool,
142 toolUseId,
143 isSubagent,
144 }
145 await update($, stats, s => ({ ...s, recognized: s.recognized + 1 }))
146 await update($, whispers, list => [...list, whisper].slice(-KEEP))
147 $.ui.log(`${whisper.kind} ${whisper.rule}: ${whisper.summary}`, { to: 'debug' })
148
149 if (hit.rule === 'context-critical' && !isSubagent) {
150 await update($, critical, () => ({
151 usedPct: hit.usedPct ?? 0,
152 remainingPct: hit.remainingPct ?? 0,
153 at: now,
154 }))
155 $.ui.toast(`GSD: context critical, ${hit.remainingPct}% left. The agent was told to stop and save state.`)
156 } else if (hit.rule === 'context-warning' && !isSubagent) {
157 $.ui.toast(`GSD: context at ${hit.remainingPct}% left. The agent was told to wrap up.`)
158 }
159
160 if (toolUseId !== undefined) {
161 // The tool row draws it as a badge.
162 await update($, memberOf(byCall, { requestId: toolUseId }), list => [...list, whisper])
163 continue
164 }
165
166 if (hit.rule.startsWith('context-')) continue
167 const key = `${hit.rule}|${hit.summary}`
168 const last = lastToasted.get(key)
169 if (last !== undefined && now - last < REPEAT_MS) continue
170 lastToasted.set(key, now)
171 $.ui.toast(`GSD ${labelOf(whisper.kind).toLowerCase()}: ${hit.summary}`)
172 }
173}
174
175async function watch<R>(
176 $: EngineInterface,
177 event: string,
178 e: unknown,
179 next: (e: never) => Promise<R>,
180): Promise<R> {
181 const result = await next(e as never)
182 try {
183 const input = e as { tool_name?: unknown; tool_use_id?: unknown; agent_id?: unknown }
184 const tool = typeof input.tool_name === 'string' ? input.tool_name : undefined
185 const toolUseId = typeof input.tool_use_id === 'string' ? input.tool_use_id : undefined
186 const isSubagent = typeof input.agent_id === 'string' || event === 'classic.SubagentStop'
187 await observe($, event, result, tool, toolUseId, isSubagent)
188 } catch {
189 // observation never changes the outcome
190 }
191 return result
192}
193
194function labelOf(kind: WhisperKind): string {
195 return kind === 'block' ? 'BLOCKED' : kind === 'ask' ? 'ASKED' : 'ADVISED'
196}
197
198function colorOf(kind: WhisperKind): string {
199 return kind === 'block' ? 'error' : kind === 'ask' ? 'warning' : 'suggestion'
200}
201
202// The card look: a rounded border in the message's colour on the same subtle
203// background Claude Code draws the person's own prompt rows on, so a card stands
204// apart from tool output in any theme (`userMessageBackground` is a key of the
205// person's theme, not a fixed colour).
206const CARD_BG = 'userMessageBackground'
207
208// The surface's Box and Text, as `$.ui.resolve(e)` hands them to a render hook.
209// Typed loosely here: every surface's table has both, with the props used below.
210// eslint-disable-next-line @typescript-eslint/no-explicit-any
211type Els = { Box: any; Text: any }
212
213// One message as two lines: label and rule, then the summary cut to the width.
214// Two lines because one row squeezed the rule name into a wrap.
215function cardLines({ Box, Text }: Els, w: Whisper, i: number, extra: string, withGsd: boolean) {
216 return (
217 <Box key={`gsd-line-${i}`} flexDirection="column">
218 <Box flexShrink={0}>
219 {withGsd && (
220 <Text color="claude" bold>
221 GSD{' '}
222 </Text>
223 )}
224 <Text color={colorOf(w.kind)} inverse>
225 {` ${labelOf(w.kind)} `}
226 </Text>
227 <Text bold>{` ${w.rule}`}</Text>
228 <Text color="subtle">{extra}</Text>
229 </Box>
230 <Box paddingLeft={2}>
231 <Text wrap="truncate-end">{w.summary}</Text>
232 </Box>
233 </Box>
234 )
235}
236
237function severity(kind: WhisperKind): number {
238 return kind === 'block' ? 3 : kind === 'ask' ? 2 : 1
239}
240
241function ago(now: number, at: number): string {
242 const s = Math.max(0, Math.round((now - at) / 1000))
243 if (s < 60) return `${s}s`
244 if (s < 3600) return `${Math.round(s / 60)}m`
245 return `${Math.round(s / 3600)}h`
246}
247
248function report(list: Whisper[], s: WhisperStats): string {
249 const lines: string[] = ['gsd-whisper', '']
250 if (list.length === 0) {
251 lines.push('No message from gsd-core hooks seen yet this session.')
252 } else {
253 lines.push(`Last ${Math.min(list.length, 15)} of ${list.length} messages from gsd-core hooks:`)
254 for (const w of list.slice(-15)) {
255 const where = w.tool ? ` ${w.tool}` : ''
256 lines.push(`- [${w.kind}] ${w.rule}${where}${w.isSubagent ? ' (subagent)' : ''}: ${w.summary}`)
257 }
258 }
259 lines.push('', 'Classic events seen (with hook output / total):')
260 const names = Object.keys(s.events).sort()
261 if (names.length === 0) lines.push('- none yet')
262 for (const n of names) lines.push(`- ${n}: ${s.withOutput[n] ?? 0} / ${s.events[n]}`)
263 lines.push(`Recognized as gsd-core: ${s.recognized}`)
264 if (s.unrecognized.length > 0) {
265 lines.push('', 'Hook output not from gsd-core (last 10, truncated):')
266 for (const u of s.unrecognized) lines.push(`- ${u}`)
267 }
268 return lines.join('\n')
269}
270
271export const register: Register = (on, options) => {
272 openOnStart = options.openOnStart === true
273 on('session.start', async ($, e, next) => {
274 await $.command.register({
275 name: 'gsd-whisper',
276 description: 'Open the pane of what gsd-core hooks told the agent this session',
277 argumentHint: '[clear]',
278 })
279 // Spike aid (v0.1): lets the agent read the same report, so a live session
280 // can check what the mod saw. Read-only.
281 await $.tool.register({
282 name: 'report',
283 description:
284 'Read-only. Returns what gsd-core hooks told the agent this session, as seen by the gsd-whisper mod, with per-event counts.',
285 })
286 if (e.isInteractive) void autoOpen($).catch(() => undefined)
287 return next(e)
288 })
289
290 // The first turn settles the layout a first-ever session did not know.
291 on('turn.complete', async ($, e, next) => {
292 const result = await next(e)
293 if (e.agentId === undefined) {
294 void autoOpen($).catch(() => undefined)
295 void keepLayout($).catch(() => undefined)
296 }
297 return result
298 })
299
300 // Closed with its close mark (or ctrl+x x): not reopened unasked this session.
301 on('ui.close', { id: PANE }, async ($, e, next) => {
302 const result = await next(e)
303 if (e.origin.kind === 'person') closedByHand = true
304 return result
305 }).catch(($, e, next) => next(e))
306
307 on('tool.call', { tool: 'mcp__gsd-whisper__report' }, async $ => {
308 return { result: report(await read($, whispers), await read($, stats)) }
309 })
310
311 on('command.run', { command: 'gsd-whisper' }, async ($, e) => {
312 if (e.args.trim() === 'clear') {
313 await update($, whispers, () => [])
314 return { text: 'Cleared the GSD whispers history.' }
315 }
316 noteLayout(e.presentation)
317 closedByHand = false
318 const opened = await $.ui.open({ id: PANE, title: 'GSD whispers', ...PANE_SIZE })
319 await keepLayout($).catch(() => undefined)
320 if (opened.isPlaced) {
321 return { text: `Opened the GSD whispers pane.${e.presentation?.isFullscreen === false ? ` ${MAIN_SCREEN_HINT}` : ''}` }
322 }
323 return { text: report(await read($, whispers), await read($, stats)) }
324 })
325
326 // Tool calls: gsd-core's PreToolUse guards answer here (deny or advice).
327 on('classic.PreToolUse', async ($, e, next) => {
328 const result = await next(e)
329 try {
330 await observe($, 'classic.PreToolUse', result, String(e.tool), e.tool_use_id, false)
331 } catch {
332 // observation never changes the outcome
333 }
334 return result
335 }).catch(($, e, next) => next(e))
336
337 // Every other classic event that carries advice or a block. Each name is a
338 // literal: the engine reads the event names from the source.
339 on('classic.PostToolUse', ($, e, next) => watch($, 'classic.PostToolUse', e, next)).catch(($, e, next) => next(e))
340 on('classic.PostToolUseFailure', ($, e, next) => watch($, 'classic.PostToolUseFailure', e, next)).catch(($, e, next) => next(e))
341 on('classic.Stop', ($, e, next) => watch($, 'classic.Stop', e, next)).catch(($, e, next) => next(e))
342 on('classic.SubagentStop', ($, e, next) => watch($, 'classic.SubagentStop', e, next)).catch(($, e, next) => next(e))
343 on('classic.SessionStart', ($, e, next) => watch($, 'classic.SessionStart', e, next)).catch(($, e, next) => next(e))
344 // Not classic.FileChanged: Claude Code 2.1.291's types list no additionalContext
345 // for that event (ClassicResultFields), so there is nothing to observe there.
346 on('classic.UserPromptSubmit', ($, e, next) => watch($, 'classic.UserPromptSubmit', e, next)).catch(($, e, next) => next(e))
347
348 // A badge under a standalone tool row that a gsd-core hook spoke about.
349 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
350 const list = await read($, memberOf(byCall, e))
351 if (list.length === 0) return next(e)
352 const own = await next(e)
353 const { Box, Text } = $.ui.resolve(e)
354 const shown = [...list].sort((a, b) => severity(b.kind) - severity(a.kind)).slice(0, 2)
355 const top = shown[0]!
356 return (
357 <Box flexDirection="column">
358 {own}
359 <Box
360 key="gsd-badge-0"
361 flexDirection="column"
362 marginLeft={2}
363 paddingX={1}
364 borderStyle="round"
365 borderColor={colorOf(top.kind)}
366 backgroundColor={CARD_BG}
367 >
368 {shown.map((w, i) => cardLines({ Box, Text }, w, i, '', true))}
369 {list.length > 2 && <Text color="subtle">{`+${list.length - 2} more in /gsd-whisper`}</Text>}
370 </Box>
371 </Box>
372 )
373 })
374
375 // Reads and searches fold into one group line; badge the group instead.
376 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
377 if (e.props.isExpanded) return next(e)
378 const found: Whisper[] = []
379 for (const call of e.props.calls) {
380 if (call.tool_use_id === undefined) continue
381 const list = await read($, memberOf(byCall, { requestId: call.tool_use_id }))
382 found.push(...list)
383 }
384 if (found.length === 0) return next(e)
385 const own = await next(e)
386 const { Box, Text } = $.ui.resolve(e)
387 // Lead with the most severe: a block matters more than advice beside it.
388 const top = [...found].sort((a, b) => severity(b.kind) - severity(a.kind))[0]!
389 return (
390 <Box flexDirection="column">
391 {own}
392 <Box
393 key="gsd-badge-group"
394 flexDirection="column"
395 marginLeft={2}
396 paddingX={1}
397 borderStyle="round"
398 borderColor={colorOf(top.kind)}
399 backgroundColor={CARD_BG}
400 >
401 {cardLines(
402 { Box, Text },
403 top,
404 0,
405 found.length > 1 ? ` +${found.length - 1} more in /gsd-whisper` : '',
406 true,
407 )}
408 </Box>
409 </Box>
410 )
411 })
412
413 // The session's history, drawn as gsd-status-mod draws its pane: a centred
414 // header, then one round-bordered panel per topic, title left, note right.
415 // A row's ▸ opens its whole message; the rest stay one line each.
416 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
417 const { Box, Button, Text } = $.ui.resolve(e)
418 noteLayout(e.viewport)
419 const W = Math.max(40, e.props.bodyColumns)
420 const list = await read($, whispers)
421 const now = await $.clock.now()
422 const room = Math.max(3, Math.floor((e.props.scroll.bodyRows - 9) / 2))
423 const counts = { block: 0, ask: 0, advice: 0 }
424 for (const w of list) counts[w.kind] += 1
425 const worst: WhisperKind | null = counts.block > 0 ? 'block' : counts.ask > 0 ? 'ask' : counts.advice > 0 ? 'advice' : null
426 const panel = (key: string, color: string, title: string, right: RenderChildren, children: RenderChildren[]) => (
427 <Box key={key} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={W}>
428 <Box justifyContent="space-between">
429 <Text bold wrap="truncate">
430 {title}
431 </Text>
432 {right}
433 </Box>
434 {children}
435 </Box>
436 )
437 const shown = list.slice(-room).reverse()
438 return (
439 <Box flexDirection="column" width={W}>
440 <Box justifyContent="center">
441 <Text bold>
442 <Text color="claude">GSD</Text> whispers
443 </Text>
444 </Box>
445 {panel(
446 'counts',
447 worst ? colorOf(worst) : 'subtle',
448 'this session',
449 <Text color="subtle">{`${list.length} in all`}</Text>,
450 [
451 <Box key="tally">
452 <Text color="error">{`${counts.block} blocked `}</Text>
453 <Text color="warning">{`${counts.ask} asked `}</Text>
454 <Text color="suggestion">{`${counts.advice} advised`}</Text>
455 </Box>,
456 <Text key="what" color="subtle" wrap="truncate-end">
457 What gsd-core's hooks told the agent.
458 </Text>,
459 ],
460 )}
461 {panel(
462 'history',
463 'claude',
464 'newest first',
465 list.length > shown.length ? <Text color="subtle">{`${shown.length} of ${list.length}`}</Text> : null,
466 [
467 list.length === 0 ? (
468 <Text key="none" color="subtle">
469 Nothing yet this session.
470 </Text>
471 ) : null,
472 ...shown.map((w, i) => {
473 const id = rowId(w)
474 const isOpen = openRows.has(id)
475 return (
476 <Box key={`row-${i}`} flexDirection="column">
477 <Box>
478 <Button
479 key={`open-${i}`}
480 label={isOpen ? '▾' : '▸'}
481 plain
482 onPress={() => {
483 if (!openRows.delete(id)) openRows.add(id)
484 $.ui.invalidate('ui.render')
485 }}
486 />
487 <Text color={colorOf(w.kind)} inverse>{` ${labelOf(w.kind)} `}</Text>
488 <Text bold wrap="truncate">{` ${w.rule}`}</Text>
489 <Text color="subtle" wrap="truncate">
490 {`${w.tool ? ` ${w.tool}` : ''}${w.isSubagent ? ' (subagent)' : ''} ${ago(now, w.at)} ago`}
491 </Text>
492 </Box>
493 <Box paddingLeft={2}>
494 <Text wrap={isOpen ? 'wrap' : 'truncate-end'}>{w.summary}</Text>
495 </Box>
496 </Box>
497 )
498 }),
499 ],
500 )}
501 <Box key="footer">
502 <Button key="clear" label="clear" plain hotkey="c" onPress={() => update($, whispers, () => [])} />
503 <Text color="subtle" wrap="truncate">
504 {' or /gsd-whisper clear; keys after ctrl+x, tab'}
505 </Text>
506 </Box>
507 </Box>
508 )
509 })
510
511 // At CRITICAL the agent is told to stop; offer the person the matching move.
512 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
513 noteLayout(e.viewport)
514 const c = await read($, critical)
515 if (c === null) return next(e)
516 const { Box, Button, Text } = $.ui.resolve(e)
517 return (
518 <Box>
519 <Text color="warning">GSD: context critical ({c.remainingPct}% left). </Text>
520 <Button
521 key="pause"
522 label="Pause work"
523 hotkey="p"
524 variant="primary"
525 onPress={() => $.prompt.fill({ text: '/gsd-pause-work', mode: 'replace' })}
526 />
527 <Text> </Text>
528 <Button key="dismiss" label="Dismiss" onPress={() => update($, critical, () => null)} />
529 </Box>
530 )
531 })
532}
533hooks/classify.ts 134 lines1// Recognizes the text gsd-core's own hooks hand the model, so it can be shown
2// to the person. Patterns are the openings of each hook's message as written
3// in gsd-core/hooks/ on `next` (2026-10-06). An unknown text returns null: the
4// mod never guesses which hook wrote something.
5
6export type Recognized = {
7 rule: string
8 summary: string
9 usedPct?: number
10 remainingPct?: number
11}
12
13type Rule = {
14 rule: string
15 test: RegExp
16 summary: (m: RegExpMatchArray, text: string) => string
17}
18
19// Long absolute paths make a line unreadable; keep the last two segments.
20export const shortPaths = (text: string): string =>
21 text.replace(/(?:\/[^\s'"/]+){3,}\/([^\s'"/]+\/[^\s'"/]+)/g, '.../$1')
22
23const firstSentence = (text: string, max = 140): string => {
24 const one = shortPaths(text.replace(/\s+/g, ' ').trim())
25 const cut = one.search(/\.\s/)
26 const head = cut > 0 ? one.slice(0, cut + 1) : one
27 return head.length > max ? head.slice(0, max - 3) + '...' : head
28}
29
30const RULES: Rule[] = [
31 {
32 rule: 'context-critical',
33 test: /^CONTEXT CRITICAL: Usage at (\d+)%\. Remaining: (\d+)%/,
34 summary: m => `context CRITICAL (${m[2]}% left): agent told to stop and save state`,
35 },
36 {
37 rule: 'context-warning',
38 test: /^CONTEXT WARNING: Usage at (\d+)%\. Remaining: (\d+)%/,
39 summary: m => `context warning (${m[2]}% left): agent told to wrap up`,
40 },
41 {
42 rule: 'read-guard',
43 test: /^READ-BEFORE-EDIT REMINDER: You are about to modify "([^"]+)"/,
44 summary: m => `read-before-edit reminder for ${m[1]}`,
45 },
46 {
47 rule: 'prompt-guard',
48 test: /PROMPT INJECTION WARNING: Content being written to (\S+)/,
49 summary: m => `prompt-injection warning on write to ${m[1]}`,
50 },
51 {
52 rule: 'workflow-guard',
53 test: /WORKFLOW ADVISORY: You're editing (\S+) directly without a GSD command/,
54 summary: m => `editing ${m[1]} outside a GSD command (suggests /gsd-quick)`,
55 },
56 {
57 rule: 'workflow-guard',
58 test: /^(agent\/worktree-agent branches must not run git add -f|workflow guard internal error)/,
59 summary: (_m, t) => firstSentence(t),
60 },
61 {
62 rule: 'read-injection-scanner',
63 test: /INJECTION SCAN \[(\w+)\] \((\w+)\)/,
64 summary: m => `injection scan ${m[1]} on ${m[2]} output`,
65 },
66 {
67 rule: 'read-injection-scanner',
68 test: /^Prompt-injection blocked \((\w+)\)/,
69 summary: m => `prompt injection blocked in ${m[1]} output`,
70 },
71 {
72 rule: 'phase-boundary',
73 test: /^\.planning\/ file modified: (.+)/,
74 summary: m => `.planning edit (${shortPaths((m[1] ?? '').trim())}): agent asked whether STATE.md needs updating`,
75 },
76 {
77 rule: 'session-state',
78 test: /^## Project State Reminder/,
79 summary: () => 'session start: STATE.md reminder given to the agent',
80 },
81 {
82 rule: 'config-reload',
83 test: /^GSD config (reloaded|\(\.planning\/config\.json\) was deleted)/,
84 summary: m => (m[1] === 'reloaded' ? 'config.json reloaded; summary given to the agent' : 'config.json deleted; agent told defaults apply'),
85 },
86 {
87 rule: 'worktree-path-guard',
88 test: /^Worktree path guard: /,
89 summary: (_m, t) => firstSentence(t),
90 },
91 {
92 rule: 'write-guard',
93 test: /^Write guard: /,
94 summary: (_m, t) => firstSentence(t),
95 },
96 {
97 rule: 'agent-isolation-guard',
98 test: /^Agent isolation guard: /,
99 summary: (_m, t) => firstSentence(t),
100 },
101 {
102 rule: 'secret-read-guard',
103 test: /^Secret read guard: /,
104 summary: (_m, t) => firstSentence(t),
105 },
106 {
107 rule: 'validate-commit',
108 test: /^(Commit message must follow Conventional Commits|Commit subject must be 72 characters or less)/,
109 summary: (_m, t) => firstSentence(t),
110 },
111]
112
113// The engine leads a settings hook's deny with where it came from
114// ("PreToolUse:Read hook error: ", seen live on 2.1.291), and hooks lead some
115// texts with a warning sign; drop both before matching.
116const ENGINE_PREFIX = /^[A-Za-z]+(?::[A-Za-z0-9_]+)? hook (?:blocking )?error: /
117const strip = (text: string) => text.trim().replace(ENGINE_PREFIX, '').replace(/^[^A-Za-z#.]+/, '')
118
119export const classify = (text: string): Recognized | null => {
120 const body = strip(text)
121 for (const r of RULES) {
122 const m = body.match(r.test)
123 if (m) {
124 const out: Recognized = { rule: r.rule, summary: r.summary(m, body) }
125 if (r.rule.startsWith('context-')) {
126 out.usedPct = Number(m[1])
127 out.remainingPct = Number(m[2])
128 }
129 return out
130 }
131 }
132 return null
133}
134types/index.d.ts 34 lines1export type WhisperKind = 'advice' | 'block' | 'ask'
2
3export type Whisper = {
4 id: number
5 at: number
6 event: string
7 rule: string
8 kind: WhisperKind
9 summary: string
10 tool?: string
11 toolUseId?: string
12 isSubagent: boolean
13}
14
15export type WhisperStats = {
16 events: Record<string, number>
17 withOutput: Record<string, number>
18 recognized: number
19 unrecognized: string[]
20}
21
22export type Critical = { usedPct: number; remainingPct: number; at: number }
23
24declare module 'claude-code' {
25 interface PluginState {
26 'gsd-whisper': {
27 whispers: Whisper[]
28 stats: WhisperStats
29 critical: Critical | null
30 byCall: StateFamily<Whisper[]>
31 }
32 }
33}
34