SLOPSHOPPER

gsd-whisper

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

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gsd-whisper
│ ┃ GSD whispers ✕ › fix the failing auth test and add an audit log call │ ┃ GSD whispers │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ this session ⎿ Read 6 lines │ ┃ │ 0 blocked 0 asked 0 advised ⏺ Update(src/auth.ts) │ ┃ │ What gsd-core's hooks told the agent. ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ newest first │ ┃ │ Nothing yet this session. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ c: clear or /gsd-whisper clear; keys after ✻ Worked for 42s · done 4:20 PM │ │ › /gsd-whisper │ ⎿ gsd-whisper: Opened the GSD whispers pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · GSD whispers
GSD whispers ╭──────────────────────────────────────────────────────╮ │ this session 0 in all │ │ 0 blocked 0 asked 0 advised │ │ What gsd-core's hooks told the agent. │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ newest first │ │ Nothing yet this session. │ ╰──────────────────────────────────────────────────────╯ c: clear or /gsd-whisper clear; keys after ctrl+x, tab
README

gsd-whisper

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.

What you see

  • A card under the tool row a gsd-core hook spoke about: a rounded border in the message's colour on the same subtle background Claude Code gives your own prompt rows (the theme's 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.
  • A toast for a message tied to no tool call (session start, Stop), at most once a minute per message, and at gsd-core's context WARNING and CRITICAL thresholds.
  • At CRITICAL, a band above the prompt with Pause work (fills /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.

What it never does

  • It never changes what the model receives and never decides a tool call: every hook calls next(e) first and returns that result unchanged, and a failure inside the mod falls through to next(e).
  • It never sends a prompt; Pause only fills the prompt box.
  • No network, no files written, no processes run.
  • It recognizes only gsd-core's own hook messages, by their opening text as written in gsd-core/hooks/ on next (2026-10-06). Other hooks' output is counted and left alone.

How it works

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.

Spike aid

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.

Develop

claude plugin validate . claude plugin test . claude --plugin-dir .

Source 3 files
hooks/register.tsx 533 lines
1import { 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}
533
hooks/classify.ts 134 lines
1// 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}
134
types/index.d.ts 34 lines
1export 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