Side pane for /grilling, /grill-me and /grill-with-docs: the design tree drawn top to bottom, one level per round, with each recommendation, what was chosen…

A Claude Code mod that docks a side pane beside the transcript and draws a grilling session as a map of its choices, read top to bottom: /grill-me, /grill-with-docs, or /grilling itself. Each round is one level of the design tree on a vertical spine, its questions hanging from it with the recommendation and what was chosen, and between the levels the facts the agent looked up and the docs it wrote.
╭──────────────────────────────────────────────╮
│ ◆ grill-with-docs 2/3 │
│ Offline sync for the field app │
│ │
│ ███████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ✓ took 1 ✎ yours 1 ◌ open 1 ⌕ lookups 3 │
╰──────────────────────────────────────────────╯
╭──────────────────────────────────────────────╮
│ Spine 2 levels · 1 open │
│ ◎ started 6m ago │
│ ┃ ⌕ agent · Find the current sync code done │
│ ┃ ⌕ 2 lookups · queue.ts │
│ ┃ │
│ ● L1 · 2/2 settled ▸ 4m │
│ ┃ ✓ Storage → SQLite, with WAL │
│ ┃ ✎ Sync trigger → no, every minute │
│ ┃ ≡ CONTEXT.md · Sync Log │
│ ┃ ⚑ ADR 0001 · SQLite on the device │
│ ┃ │
│ ◆ L2 · waiting for you ▾ 1m │
│ ┃ ◌ Q3 Conflicts │
│ ┃ │ Where does a conflict surface to the │
│ ┃ │ person, if anywhere at all? │
│ ┃ │ ➡ Last write wins, with a banner │
│ ┃ │
│ ▼ 1 open · reply by number, 1. yes 2. … │
╰──────────────────────────────────────────────╯
✓ took the recommendation, ✎ answered otherwise, ⏎ answered in a reply that was not split by number, ◌ still open. Beside it, the lookups, the glossary terms (≡) and the ADRs (⚑) of the grill.L1, L2, …: the frontier as it stood, every question whose prerequisites were settled. The newest level is drawn whole: each question's body, the choices it offered, the recommendation (➡, ✓ when the person said yes) and what the person answered instead (✎). An older level folds to one line per decision, title → what was chosen; a click or a tap on its heading unfolds it. Once the grill is confirmed, every level folds. Each new level takes the focus: when it arrives every level before it folds, one unfolded by hand too.●.⌕ marks a fact the agent went to find instead of asking: a sub-agent, running until it reports, or a run of reads and searches folded into one line.≡ marks the terms written to CONTEXT.md or GLOSSARY.md, and ⚑ each ADR written under docs/adr/ (/grill-with-docs, through domain-modeling).◎ wrap-up is the turn that ends without questions once the rounds are answered: the frontier is empty and the summary waits for the person's word. A yes closes the grill, ◉ shared understanding confirmed; a yes with a reserve (sì, ma …, yes, but …) or anything else pushes it back, and the next wrap-up is put to the person again.▼ says where the tree goes next: the questions still open, a lookup running, or the next frontier being worked out.grill L2 · 2 open, or grill · confirm the shared understanding at the wrap-up. It goes once the grill is confirmed or cleared.A session can hold several grills: each new /grill-me or /grill-with-docs starts one, and p / n page through them.
/wayfinderWith wayfinder-map installed too, a /wayfinder Run is wayfinder-map's: grill-map reads it and follows it.
charting grill for the rounds asked with no ticket claimed./clear, on a /grill-me or /grill-with-docs you ask for, or on /waymap clear; grill-map then holds grills of its own again.Without wayfinder-map, a /wayfinder grill is a grill like any other.
grilling, and the grill-me or grill-with-docs wrapper (grill-with-docs also needs domain-modeling).At the prompt of a Claude Code terminal session:
/plugin install grill-map --marketplace bardaxx/modsbook
Answer y to add the marketplace, then pick a scope (Enter takes the user scope).
To update it:
claude plugin update grill-map@modsbook
To run it from a clone while you change it:
claude --plugin-dir /path/to/modsbook/mods/grill-map
No command is needed: the pane opens when /grill-me, /grill-with-docs or /grilling runs, or when an answer holds a round in the grilling format.
| Command | What it does |
|---|---|
/grillmap | Opens the pane. |
/grillmap clear | Forgets the grills of this session. |
In the pane, a click or a tap on a level's heading folds or unfolds it, a (or the Unfold / Fold button) unfolds every level or folds them back, p and n show the previous and next grill, and c clears.
| It shows | It reads |
|---|---|
| the grill and its subject | the grill-me, grill-with-docs or grilling skill's prompt and its arguments; with none, the next prompt (a wrapper) or the last one (/grilling the model invoked) |
| a level | each main-loop answer holding ❓ Q1 - Title: … and ➡️ …, the grilling skill's round format |
| what was chosen | the person's next prompt: 1. yes, Q2: no, …, or one answer for all; a yes followed by a reserve (ma, però, tranne, but, unless, …) is the person's own answer |
| lookups | each main-loop Agent call during the grill, and its Read, Grep, Glob, WebFetch, WebSearch and read-only shell calls (rg, grep, cat, ls, find, git log, …) |
| terms | each Write or Edit of a CONTEXT.md or GLOSSARY.md: the Term: lines it adds |
| ADRs | each Write or Edit of docs/adr/NNNN-slug.md, titled by its # heading |
| wrap-up | the first answer without questions after a round is answered, and the person's reply to it |
| dialogs | each AskUserQuestion call and the answers it returned; an option labelled (Recommended) is the recommendation |
yes, ok, sì, lgtm, …) and no reserve follows. The grilling skill words each question so that yes takes its recommendation; a question worded against its recommendation is read the same way.⏎): the reserve cannot be pinned to one question.From this folder:
claude plugin validate .
claude plugin test .
Claude Code writes the API's types into .claude-plugin/types/ when it loads the mod, and tsconfig.json extends them, so tsc -p . type-checks it after the first load.
MIT, see the repository's license.
hooks/register.tsx 498 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Grill, GrillBook, GrillSkill, RoundStep, Step, WayfinderRun } from '../types'
5import { applyReply, dialogRound, firstLine, isWaiting, isWrapDue, lastTurn, markOf, parseRound, plain, settleWrap } from './reply'
6import type { Mark } from './reply'
7import { header } from './header'
8import {
9 ACCENT,
10 BLUE,
11 EDGE,
12 FOG,
13 GREEN,
14 MARK,
15 MARK_COLOR,
16 TEAL,
17 cut,
18 docTraceOf,
19 foldAllButton,
20 foldsInto,
21 isDocPath,
22 lookupOf,
23 spineHeading,
24 spineRows,
25 termsFoldInto,
26} from './spine'
27
28const PANE = 'grill-map'
29const TITLE = 'Grill'
30const GRILLS_KEPT = 8
31const STEPS_KEPT = 200
32
33// The modsbook palette: muted pills on a dark track, dark ink on the pills.
34const INK = '#151515'
35const TRACK = '#2b2b2b'
36
37const MARKS: Mark[] = ['took', 'own', 'replied', 'open']
38const MARK_LABEL: Record<Mark, string> = { took: 'took', own: 'yours', replied: 'in reply', open: 'open' }
39
40const SKILLS = new Set<string>(['grilling', 'grill-me', 'grill-with-docs'])
41
42const EMPTY: GrillBook = { grills: [], shown: null, expanded: [], isAllExpanded: false, lastPrompt: '' }
43const book = atom({ plugin: 'grill-map', key: 'book' } as const, EMPTY)
44
45// State kept from an older version lacks the fields added since.
46const current = async ($: EngineInterface): Promise<GrillBook> => ({ ...EMPTY, ...(await read($, book)) })
47
48let made = 0
49const newId = (prefix: string) => `${prefix}${Date.now().toString(36)}-${made++}`
50
51// ── Inside /wayfinder: wayfinder-map owns the Run, grill-map reads it ──
52
53const RUN = { plugin: 'wayfinder-map', key: 'run' } as const
54
55/** The Run wayfinder-map is on, or null: none, or no wayfinder-map. */
56const runOf = async ($: EngineInterface): Promise<WayfinderRun | null> => (await $.state.get(RUN)).value ?? null
57
58/** Which grill a step belongs to: the Run's claimed ticket, `charting`, or none outside a Run. */
59const keyOf = (run: WayfinderRun | null) => (run ? (run.ticket?.id ?? 'charting') : undefined)
60
61// ── The book of grills: the newest one still open is the one being worked ──
62
63const open = ($: EngineInterface) => void $.ui.open({ id: PANE, title: TITLE })
64
65/** The newest grill, while it is open and belongs where the step goes (the Run's ticket, or no Run). */
66const liveFor = (b: GrillBook, key: string | undefined) => {
67 const g = b.grills.at(-1)
68 return g && !g.isDone && g.run === key ? g : null
69}
70
71const withLive = ($: EngineInterface, key: string | undefined, fn: (g: Grill) => Grill) =>
72 update($, book, b => {
73 const g = liveFor(b, key)
74 return g ? { ...b, grills: [...b.grills.slice(0, -1), fn(g)] } : b
75 })
76
77const push = (g: Grill, step: Step): Grill => ({ ...g, steps: [...g.steps, step].slice(-STEPS_KEPT) })
78const mapStep = (g: Grill, id: string, fn: (s: Step) => Step): Grill => ({
79 ...g,
80 steps: g.steps.map(s => (s.id === id ? fn(s) : s)),
81})
82
83const start = async ($: EngineInterface, skill: GrillSkill, subject: string, run?: string) => {
84 const grill: Grill = {
85 id: newId('g'),
86 skill,
87 subject: cut(subject, 200),
88 startedAt: Date.now(),
89 steps: [],
90 isDone: false,
91 ...(run ? { run } : {}),
92 }
93 await update($, book, b => ({ ...b, grills: [...b.grills, grill].slice(-GRILLS_KEPT), shown: null, expanded: [] }))
94 open($)
95}
96
97/**
98 * Where a step lands: the live grill, or one opened for it. Outside a Run only a round opens one;
99 * inside, a round or a dialog opens the ticket's grill (or the charting one), and any step does once
100 * a ticket is claimed. The start of a Run leaves the grill before it behind.
101 */
102const landing = async ($: EngineInterface, step: 'round' | 'dialog' | 'trace') => {
103 const run = await runOf($)
104 const key = keyOf(run)
105 const b = await current($)
106 if (liveFor(b, key)) return { key }
107 if (run && (step !== 'trace' || run.ticket)) {
108 await start($, 'grilling', run.ticket?.title ?? 'charting', key)
109 return { key }
110 }
111 if (!run && step === 'round') {
112 await start($, 'grilling', b.lastPrompt)
113 return { key }
114 }
115 return null
116}
117
118// A new level takes the focus: every level drawn whole by hand folds again.
119const addRound = ($: EngineInterface, key: string | undefined, round: RoundStep) =>
120 update($, book, b => {
121 const g = liveFor(b, key)
122 return g ? { ...b, grills: [...b.grills.slice(0, -1), push(g, round)], expanded: [] } : b
123 })
124
125let shownStatus: string | undefined
126
127/** The status line while a grill of its own is open; inside a Run, wayfinder-map's carries it. */
128const syncStatus = async ($: EngineInterface) => {
129 const run = await runOf($)
130 const g = run ? null : liveFor(await current($), undefined)
131 let text: string | undefined
132 if (g) {
133 const rounds = g.steps.filter((s): s is RoundStep => s.kind === 'round')
134 const last = rounds.at(-1)
135 const step = lastTurn(g.steps)
136 const left = last ? last.questions.filter(q => markOf(q, last) === 'open').length : 0
137 text =
138 step?.kind === 'wrap' && step.reply === undefined
139 ? 'grill · confirm the shared understanding'
140 : last && isWaiting(last)
141 ? `grill L${rounds.length} · ${left} open`
142 : last
143 ? `grill L${rounds.length} · next frontier`
144 : 'grill · starting'
145 }
146 if (text === shownStatus) return
147 shownStatus = text
148 $.ui.status(text)
149}
150
151// Bookkeeping never fails the call it watches.
152const quietly = async (work: () => Promise<unknown>) => {
153 try {
154 await work()
155 } catch {
156 // the pane misses one update; the session goes on
157 }
158}
159
160export const register: Register = on => {
161 on('session.start', async ($, e, next) => {
162 await $.command.register({
163 name: 'grillmap',
164 description: 'Show the grill map pane: the design tree of /grilling, /grill-me and /grill-with-docs (/grillmap clear)',
165 argumentHint: '[clear]',
166 })
167 if ((await current($)).grills.length) open($)
168 shownStatus = undefined
169 await quietly(() => syncStatus($))
170 return next(e)
171 })
172
173 on('command.run', { command: 'grillmap' }, async ($, e) => {
174 if (e.args.trim() === 'clear') {
175 await update($, book, b => ({ ...EMPTY, lastPrompt: b.lastPrompt }))
176 await quietly(() => syncStatus($))
177 return { text: 'Grill map cleared.' }
178 }
179 await $.ui.open({ id: PANE, title: TITLE })
180 const { grills } = await current($)
181 return { text: grills.length ? 'Grill map opened.' : 'Grill map opened: it fills once /grill-me, /grill-with-docs or /grilling runs.' }
182 })
183
184 // The wrappers start a grill of their own; /grilling joins the one they started, or the Run's, or starts its own.
185 on('skill.prompt', async ($, e, next) => {
186 const name = e.skill.replace(/^.*:/, '')
187 // Another skill's argument (`/wayfinder <idea>`) is the subject of a grill it runs.
188 const given = plain(/ARGUMENTS:\s*(.*)$/m.exec(e.text)?.[1] ?? '')
189 if (!SKILLS.has(name) && given) await quietly(() => update($, book, b => ({ ...b, lastPrompt: cut(given, 200) })))
190 if (SKILLS.has(name))
191 await quietly(async () => {
192 if (name !== 'grilling') return start($, name as GrillSkill, given)
193 if (await runOf($)) {
194 await landing($, 'round')
195 return open($)
196 }
197 const b = await current($)
198 if (liveFor(b, undefined)) {
199 if (given) await withLive($, undefined, g => (g.subject ? g : { ...g, subject: cut(given, 200) }))
200 return open($)
201 }
202 await start($, 'grilling', given || b.lastPrompt)
203 })
204 // Beneath, wayfinder-map starts or ends its Run: the status line follows it.
205 const done = await next(e)
206 await quietly(() => syncStatus($))
207 return done
208 })
209
210 // The person's reply: the answers to the waiting round, the verdict on a wrap-up, or the subject itself.
211 on('prompt.submit', async ($, e, next) => {
212 if (!e.command && !e.isMeta && e.text.trim())
213 await quietly(async () => {
214 const key = keyOf(await runOf($))
215 await update($, book, b => {
216 const text = e.text.trim()
217 const out = { ...b, lastPrompt: cut(firstLine(text), 200) }
218 const g = liveFor(b, key)
219 if (!g) return out
220 const last = lastTurn(g.steps)
221 let now = g
222 if (last?.kind === 'round' && last.reply === undefined) now = mapStep(g, last.id, () => applyReply(last, text))
223 else if (last?.kind === 'wrap' && last.reply === undefined) {
224 const wrap = settleWrap(last, text)
225 now = { ...mapStep(g, last.id, () => wrap), isDone: wrap.isConfirmed === true }
226 } else if (!last && !g.subject) now = { ...g, subject: cut(firstLine(text), 200) }
227 return { ...out, grills: [...b.grills.slice(0, -1), now] }
228 })
229 })
230 await quietly(() => syncStatus($))
231 return next(e)
232 })
233
234 // A round is a main-loop turn that ends on questions; one that ends without them, once answered, is the wrap-up.
235 on('turn.complete', async ($, e, next) => {
236 const done = await next(e)
237 if (!e.agentId && e.reason === 'answer')
238 await quietly(async () => {
239 const questions = parseRound(e.answer)
240 if (questions.length) {
241 const at = await landing($, 'round')
242 if (!at) return
243 await addRound($, at.key, { kind: 'round', id: newId('r'), at: Date.now(), via: 'grilling', questions })
244 return open($)
245 }
246 const key = keyOf(await runOf($))
247 const g = liveFor(await current($), key)
248 if (g && isWrapDue(g.steps))
249 await withLive($, key, x => push(x, { kind: 'wrap', id: newId('w'), at: Date.now(), text: cut(plain(e.answer), 600) }))
250 })
251 await quietly(() => syncStatus($))
252 return done
253 })
254
255 on('tool.call', async ($, e, next) => {
256 if (e.agentId) return next(e)
257 const input = e as unknown as Record<string, unknown>
258 const path = e.tool === 'Write' || e.tool === 'Edit' ? e.file_path : ''
259 const isDoc = isDocPath(path)
260 const looked = lookupOf(e.tool, input)
261 const isDialog = e.tool === 'AskUserQuestion'
262 if (e.tool !== 'Agent' && !isDoc && looked === null && !isDialog) return next(e)
263 const at = await landing($, isDialog ? 'dialog' : 'trace')
264 if (!at) return next(e)
265 const { key } = at
266
267 // A fact the agent sends a sub-agent for: running until the sub-agent reports.
268 if (e.tool === 'Agent') {
269 const id = newId('l')
270 await quietly(() =>
271 withLive($, key, x =>
272 push(x, { kind: 'lookup', id, at: Date.now(), label: plain(e.description), isAgent: true, count: 1, isDone: false }),
273 ),
274 )
275 const ran = await next(e)
276 await quietly(() => withLive($, key, x => mapStep(x, id, s => ({ ...s, isDone: true }))))
277 return ran
278 }
279
280 const before = e.tool === 'Write' && isDoc ? await $.fs.read(path).catch(() => null) : null
281
282 const ran = await next(e)
283 if (('deny' in ran && ran.deny) || ran.isError) return ran
284
285 if (e.tool === 'AskUserQuestion')
286 await quietly(async () => {
287 await addRound($, key, dialogRound(e.questions, ran.result, newId('r'), Date.now()))
288 await syncStatus($)
289 })
290
291 // Reads and searches between rounds fold into one lookup.
292 if (looked !== null)
293 await quietly(() =>
294 withLive($, key, x => {
295 const last = x.steps.at(-1)
296 const label = cut(looked, 60)
297 return foldsInto(last)
298 ? mapStep(x, last.id, s => (s.kind === 'lookup' ? { ...s, count: s.count + 1, label } : s))
299 : push(x, { kind: 'lookup', id: newId('l'), at: Date.now(), label, isAgent: false, count: 1, isDone: true })
300 }),
301 )
302
303 // grill-with-docs: the glossary's resolved terms and the ADRs, as they are written.
304 if (isDoc && (e.tool === 'Write' || e.tool === 'Edit'))
305 await quietly(async () => {
306 const trace =
307 e.tool === 'Write'
308 ? docTraceOf(path, e.content, before, false, newId('d'), Date.now())
309 : docTraceOf(path, e.new_string, e.old_string, true, newId('d'), Date.now())
310 if (!trace) return
311 await withLive($, key, x => {
312 const last = x.steps.at(-1)
313 return trace.kind === 'term' && termsFoldInto(last, path)
314 ? mapStep(x, last.id, s => (s.kind === 'term' ? { ...s, terms: [...new Set([...s.terms, ...trace.terms])] } : s))
315 : push(x, trace)
316 })
317 })
318
319 return ran
320 })
321
322 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
323 const { Box, Text, Button } = $.ui.resolve(e)
324 const el = { Box, Text, Button }
325 const b = await current($)
326 const now = Date.now()
327 const inner = Math.max(24, e.props.bodyColumns - 4)
328 const g = b.grills.find(x => x.id === b.shown) ?? b.grills.at(-1)
329
330 const pill = (text: string, bg: string, fg = INK, bold = false) => (
331 <Text backgroundColor={bg} color={fg} bold={bold}>
332 {` ${text} `}
333 </Text>
334 )
335 const heading = (title: string, right?: RenderChildren) => (
336 <Box flexDirection="row" justifyContent="space-between">
337 <Text>
338 <Text color={ACCENT}>◆ </Text>
339 <Text bold>{title}</Text>
340 </Text>
341 {right}
342 </Box>
343 )
344 const card = (key: string, children: RenderChildren) => (
345 <Box key={key} flexDirection="column" borderStyle="round" borderColor={EDGE} paddingX={1}>
346 {children}
347 </Box>
348 )
349
350 if (!g)
351 return (
352 <Box flexDirection="column">
353 {header(el, PANE, e.props.bodyColumns)}
354 {card(
355 'empty',
356 <Box flexDirection="column">
357 {heading('grill map')}
358 <Text dimColor wrap="wrap">
359 /grill-me, /grill-with-docs or /grilling draws its design tree here, one level per round.
360 </Text>
361 </Box>,
362 )}
363 </Box>
364 )
365
366 const rounds = g.steps.filter((s): s is RoundStep => s.kind === 'round')
367 const all = rounds.flatMap(r => r.questions.map(q => markOf(q, r)))
368 const count = (m: Mark) => all.filter(x => x === m).length
369 const settled = all.length - count('open')
370 const terms = g.steps.reduce((n, s) => n + (s.kind === 'term' ? s.terms.length : 0), 0)
371 const adrs = g.steps.filter(s => s.kind === 'adr').length
372 const lookups = g.steps.reduce((n, s) => n + (s.kind === 'lookup' ? s.count : 0), 0)
373 const at = b.grills.indexOf(g)
374
375 const bar = () => {
376 let left = inner
377 const cells = MARKS.map((m, i) => {
378 const width = i === MARKS.length - 1 ? left : Math.round((count(m) / all.length) * inner)
379 left -= width
380 return { m, width: Math.max(0, width) }
381 })
382 return (
383 <Box flexDirection="row">
384 {cells
385 .filter(c => c.width > 0)
386 .map(c => (
387 <Text key={`bar-${c.m}`} backgroundColor={MARK_COLOR[c.m]}>
388 {' '.repeat(c.width)}
389 </Text>
390 ))}
391 </Box>
392 )
393 }
394
395 const head = card(
396 'head',
397 <Box flexDirection="column">
398 {heading(
399 g.skill,
400 <Text>
401 {b.grills.length > 1 && <Text dimColor>{`${at + 1}/${b.grills.length} `}</Text>}
402 {g.isDone
403 ? pill('done', GREEN, INK, true)
404 : all.length > 0
405 ? pill(`${settled}/${all.length}`, settled === all.length ? GREEN : TRACK, settled === all.length ? INK : 'white', true)
406 : ''}
407 </Text>,
408 )}
409 {g.subject ? (
410 <Text bold wrap="truncate-end">
411 {g.subject}
412 </Text>
413 ) : (
414 <Text dimColor>Waiting for what to grill.</Text>
415 )}
416 {all.length > 0 && <Box marginTop={1}>{bar()}</Box>}
417 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
418 {MARKS.filter(m => count(m) > 0).map(m => (
419 <Text key={`legend-${m}`}>
420 <Text color={MARK_COLOR[m]}>{MARK[m]} </Text>
421 {MARK_LABEL[m]} <Text bold>{count(m)}</Text>
422 </Text>
423 ))}
424 {lookups > 0 && (
425 <Text key="legend-lookups">
426 <Text color={FOG}>⌕ </Text>
427 lookups <Text bold>{lookups}</Text>
428 </Text>
429 )}
430 {terms > 0 && (
431 <Text key="legend-terms">
432 <Text color={TEAL}>≡ </Text>
433 terms <Text bold>{terms}</Text>
434 </Text>
435 )}
436 {adrs > 0 && (
437 <Text key="legend-adrs">
438 <Text color={BLUE}>⚑ </Text>
439 ADRs <Text bold>{adrs}</Text>
440 </Text>
441 )}
442 </Box>
443 </Box>,
444 )
445
446 // ── The Spine: the design tree top to bottom, one level per round ──
447 const toggle = (id: string) =>
448 update($, book, x => ({
449 ...x,
450 expanded: x.expanded.includes(id) ? x.expanded.filter(k => k !== id) : [...x.expanded, id],
451 }))
452 const tree = card(
453 'tree',
454 <Box flexDirection="column">
455 {spineHeading(el, 'Spine', g.steps, g.isDone)}
456 {spineRows(el, {
457 steps: g.steps,
458 keyPrefix: '',
459 isClosed: g.isDone,
460 isLive: !g.isDone && g === b.grills.at(-1),
461 isAllUnfolded: b.isAllExpanded,
462 turned: b.expanded,
463 onTurn: toggle,
464 width: inner,
465 now,
466 startedAt: g.startedAt,
467 })}
468 </Box>,
469 )
470
471 const pick = (by: number) =>
472 update($, book, x => {
473 const i = x.grills.findIndex(k => k.id === (x.shown ?? x.grills.at(-1)?.id))
474 const to = x.grills[Math.min(x.grills.length - 1, Math.max(0, i + by))]
475 return { ...x, shown: to && to !== x.grills.at(-1) ? to.id : null, expanded: [] }
476 })
477
478 return (
479 <Box flexDirection="column">
480 {header(el, PANE, e.props.bodyColumns)}
481 {head}
482 {tree}
483 <Box flexDirection="row" columnGap={1} paddingX={1}>
484 {foldAllButton(el, b.isAllExpanded, () =>
485 update($, book, x => ({ ...x, isAllExpanded: !x.isAllExpanded, expanded: [] })),
486 )}
487 {b.grills.length > 1 && <Button key="prev" label="Prev" hotkey="p" onPress={() => pick(-1)} />}
488 {b.grills.length > 1 && <Button key="next" label="Next" hotkey="n" onPress={() => pick(1)} />}
489 <Button key="clear" label="Clear" hotkey="c" onPress={async () => {
490 await update($, book, x => ({ ...EMPTY, lastPrompt: x.lastPrompt }))
491 await syncStatus($)
492 }} />
493 </Box>
494 </Box>
495 )
496 })
497}
498hooks/reply.ts 142 lines1// How a round is read from the agent's answer, and how the person's reply settles it.
2// Shared by grill-map and wayfinder-map, one identical copy in each; scripts/check.sh fails when they differ.
3
4import type { Question, RoundStep, Step, WrapStep } from '../types'
5
6export const firstLine = (text: string) => text.split('\n').find(line => line.trim())?.trim() ?? ''
7export const plain = (text: string) =>
8 text
9 .replace(/\*\*|__|`/g, '')
10 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
11 .replace(/\s+/g, ' ')
12 .trim()
13
14/** `❓ **Q1** - **Title**: body … ➡ recommended`, once per question of the round. */
15export const parseRound = (text: string): Question[] =>
16 text
17 .split(/^[ \t>]*❓️?\s*/m)
18 .slice(1)
19 .flatMap(chunk => {
20 const head = /^\**\s*Q?(\d+)\s*\**\s*[-–—:.]?\s*(?:\*\*([^*]+)\*\*\s*:?)?\s*([\s\S]*)$/i.exec(chunk)
21 if (!head) return []
22 const rest = (head[3] ?? '').split(/\n\s*---/)[0] ?? ''
23 const arrow = rest.search(/➡/)
24 const before = arrow < 0 ? rest : rest.slice(0, arrow)
25 const recommended = arrow < 0 ? '' : plain(rest.slice(arrow).replace(/^➡️?\s*/, '').split(/\n\s*\n/)[0] ?? '')
26 const option = (line: string) => /^\s*(?:[-*]|\(?[a-zA-Z0-9]\)|[A-Za-z]\.)\s+(.+)$/.exec(line)?.[1]
27 const lines = before.split('\n')
28 const options = lines
29 .map(option)
30 .filter((o): o is string => Boolean(o))
31 .map(plain)
32 .slice(0, 6)
33 const title = plain(head[2] ?? firstLine(before))
34 const body = plain(lines.filter(line => !option(line)).join('\n'))
35 return title ? [{ n: head[1] ?? '', title, body: head[2] ? body : '', options, recommended }] : []
36 })
37
38// ── A yes, and a Reserve ──
39
40// A word ends where no letter or digit follows: `\b` misses one after an accented letter (`sì`).
41const END = '(?=$|[^\\p{L}\\p{N}])'
42const YES = new RegExp(
43 `^(?:y|yes|yep|yeah|ok|okay|sure|agreed|go|lgtm|s[iì]|certo|va bene|confermo|d'accordo|perfetto|esatto|👍)${END}`,
44 'iu',
45)
46const RESERVE = new RegExp(
47 `(?:^|[^\\p{L}\\p{N}])(?:ma|però|tranne|eccetto|salvo|a parte|invece|solo se|but|except|unless|only if|however)${END}`,
48 'iu',
49)
50
51/** A reply that opens with a yes, Reserve or not. */
52export const isYes = (text: string) => YES.test(text.trim())
53
54/** A yes with a Reserve after it: `sì, ma …`, `yes, unless …`. */
55export const hasReserve = (text: string) => {
56 const m = YES.exec(text.trim())
57 return !!m && RESERVE.test(text.trim().slice(m[0].length))
58}
59
60/** A yes with no Reserve: the recommendation taken, the Wrap-up confirmed. */
61export const takes = (text: string) => isYes(text) && !hasReserve(text)
62
63// ── Settling a round ──
64
65/** How a question stands: took the recommendation, answered otherwise, answered in an unsplit reply, or open. */
66export type Mark = 'took' | 'own' | 'replied' | 'open'
67
68export const markOf = (q: Question, r: RoundStep): Mark =>
69 q.answer !== undefined ? (q.isAccepted ? 'took' : 'own') : r.reply !== undefined ? 'replied' : 'open'
70
71export const isWaiting = (r: RoundStep) => r.reply === undefined && r.questions.some(q => q.answer === undefined)
72
73/**
74 * Hands the person's reply out to the round's questions: `1. yes`, `Q2: no, …`, or one answer for all.
75 * A yes said whole to several questions answers them all, unless a Reserve follows it: the Reserve
76 * cannot be pinned to one, so the reply stays unsplit.
77 */
78export const applyReply = (round: RoundStep, reply: string): RoundStep => {
79 const known = new Set(round.questions.map(q => q.n))
80 const parts = new Map<string, string>()
81 let at: string | null = null
82 for (const line of reply.split('\n')) {
83 const m = /^\s*\**Q?(\d+)\**(?:\s*[).:\-–—]|\s+)\s*(.*)$/i.exec(line)
84 if (m && known.has(m[1] ?? '')) {
85 at = m[1] ?? null
86 if (at) parts.set(at, (m[2] ?? '').trim())
87 } else if (at && line.trim()) parts.set(at, `${parts.get(at) ?? ''} ${line.trim()}`.trim())
88 }
89 const whole = reply.trim()
90 const forAll = parts.size === 0 && (round.questions.length === 1 || takes(whole)) ? whole : undefined
91 return {
92 ...round,
93 reply: whole,
94 questions: round.questions.map(q => {
95 const said = parts.get(q.n) ?? forAll
96 return said === undefined ? q : { ...q, answer: said, isAccepted: takes(said) }
97 }),
98 }
99}
100
101/** An AskUserQuestion dialog as a Level: the recommended option is the one labelled "(Recommended)". */
102export const dialogRound = (
103 asked: readonly { question: string; options: readonly { label: string }[] }[],
104 result: unknown,
105 id: string,
106 at: number,
107): RoundStep => {
108 const { answers = {}, response } = (result ?? {}) as { answers?: Record<string, string>; response?: string }
109 const questions: Question[] = asked.map((q, i) => {
110 const answer = answers[q.question]
111 const recommended = q.options.find(o => /\(recommended\)/i.test(o.label))?.label ?? ''
112 return {
113 n: String(i + 1),
114 title: plain(q.question),
115 body: '',
116 options: q.options.map(o => o.label),
117 recommended,
118 ...(answer ? { answer, isAccepted: answer === recommended && !!recommended } : {}),
119 }
120 })
121 return { kind: 'round', id, at, via: 'dialog', questions, reply: response ?? Object.values(answers).join(', ') }
122}
123
124// ── The Wrap-up ──
125
126/** The newest Level or Wrap-up: what the person's next reply answers. */
127export const lastTurn = (steps: Step[]) =>
128 steps.filter((s): s is RoundStep | WrapStep => s.kind === 'round' || s.kind === 'wrap').at(-1)
129
130/**
131 * Whether a turn that ends without questions is a Wrap-up: once the newest Level is answered,
132 * or after a Wrap-up the person pushed back on.
133 */
134export const isWrapDue = (steps: Step[]) => {
135 const last = lastTurn(steps)
136 if (last?.kind === 'round') return last.reply !== undefined
137 return last?.kind === 'wrap' && last.reply !== undefined && !last.isConfirmed
138}
139
140/** The person's word on a Wrap-up: a yes with no Reserve confirms it. */
141export const settleWrap = (wrap: WrapStep, reply: string): WrapStep => ({ ...wrap, reply, isConfirmed: takes(reply) })
142hooks/header.tsx 47 lines1// The head every modsbook pane opens with: a terminal panel, its titles set in the border as lazygit and tmux
2// set theirs, a prompt naming the mod, and a nod to mattpocock/skills, the skills the mods follow.
3// The same in each mod, only the name changes. One identical copy in each mod; scripts/check.sh fails when they differ.
4
5import type { BoxProps, ElementConstructor, TextProps } from 'claude-code'
6
7export type HeaderElements = {
8 Box: ElementConstructor<BoxProps>
9 Text: ElementConstructor<TextProps>
10}
11
12const EDGE = '#4a4a4a'
13const ACCENT = '#d97757'
14const TOP = 'modsbook'
15const NOD = 'mattpocock/skills'
16
17/** Three rows drawn to the pane's width: `modsbook` in the top border, `❯ <name> ▌`, the nod in the bottom one. */
18export const header = ({ Box, Text }: HeaderElements, name: string, columns: number) => {
19 const width = Math.max(TOP.length + NOD.length + 10, columns)
20 const rule = (n: number) => '─'.repeat(Math.max(0, n))
21 const room = width - 4
22 const prompt = name.length + 4 > room ? `${name.slice(0, Math.max(1, room - 5))}…` : name
23 return (
24 <Box key="header" flexDirection="column">
25 <Text>
26 <Text color={EDGE}>╭─ </Text>
27 <Text dimColor>{TOP}</Text>
28 <Text color={EDGE}>{` ${rule(width - TOP.length - 5)}╮`}</Text>
29 </Text>
30 <Text>
31 <Text color={EDGE}>│ </Text>
32 <Text color={ACCENT}>❯ </Text>
33 <Text bold>{prompt}</Text>
34 <Text color={ACCENT}> ▌</Text>
35 <Text color={EDGE}>{`${' '.repeat(Math.max(0, room - prompt.length - 4))} │`}</Text>
36 </Text>
37 <Text>
38 <Text color={EDGE}>{`╰${rule(width - NOD.length - 5)} `}</Text>
39 <Text dimColor italic>
40 {NOD}
41 </Text>
42 <Text color={EDGE}> ─╯</Text>
43 </Text>
44 </Box>
45 )
46}
47hooks/spine.tsx 415 lines1// The Spine: what a grilling session leaves, top to bottom, and how it is drawn.
2// Shared by grill-map and wayfinder-map, one identical copy in each; scripts/check.sh fails when they differ.
3
4import type { BoxProps, ButtonProps, ElementConstructor, RenderChildren, TextProps } from 'claude-code'
5
6import type { AdrStep, LookupStep, RoundStep, Step, TermStep } from '../types'
7import { isWaiting, markOf } from './reply'
8import type { Mark } from './reply'
9
10// ── Traces: what a tool call leaves on the Spine ──
11
12const GLOSSARY = /(?:^|\/)(?:CONTEXT|GLOSSARY)\.md$/
13const ADR = /(?:^|\/)docs\/adr\/(\d+)-([^/]+)\.md$/
14const TERM = /^\s*\*\*([^*\n]+?)\*\*\s*:/gm
15// Search tools a build may or may not ship; where it has none, the shell's searches count instead.
16const SEARCHES = new Set<string>(['Grep', 'Glob'])
17const SHELL_LOOKUP = /^\s*(rg|grep|cat|ls|find|head|tail|tree|sed -n|git (?:log|show|diff|grep|blame))\b/
18
19const plainText = (text: string) =>
20 text
21 .replace(/\*\*|__|`/g, '')
22 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
23 .replace(/\s+/g, ' ')
24 .trim()
25
26/** What a read or a search looked at, or null for a call that looks nothing up. */
27export const lookupOf = (tool: string, input: Record<string, unknown>): string | null => {
28 const text = (key: string) => String(input[key] ?? '')
29 if (tool === 'Read') return base(text('file_path'))
30 if (SEARCHES.has(tool)) return String(input['pattern'] ?? tool)
31 if (tool === 'WebFetch') return /^https?:\/\/([^/]+)/.exec(text('url'))?.[1] ?? text('url')
32 if (tool === 'WebSearch') return text('query')
33 if (tool === 'Bash' && SHELL_LOOKUP.test(text('command')))
34 return text('command').split('\n').find(line => line.trim())?.trim() ?? null
35 return null
36}
37
38/** Whether a `Write` or `Edit` of this path is a glossary or an ADR. */
39export const isDocPath = (path: string) => GLOSSARY.test(path) || ADR.test(path)
40
41const termsIn = (text: string) => new Set([...text.matchAll(TERM)].map(m => plainText(m[1] ?? '')).filter(Boolean))
42
43/**
44 * The Trace a glossary or ADR write leaves: the ADR, or the terms the write added.
45 * `previous` is the file before a `Write` (null when new), or the `old_string` an `Edit` replaced.
46 */
47export const docTraceOf = (
48 path: string,
49 written: string,
50 previous: string | null,
51 isEdit: boolean,
52 id: string,
53 at: number,
54): AdrStep | TermStep | null => {
55 const adr = ADR.exec(path)
56 if (adr) {
57 const slug = (adr[2] ?? '').replace(/[-_]+/g, ' ')
58 const title = plainText(/^#[ \t]+(.+)$/m.exec(written)?.[1] ?? slug)
59 return { kind: 'adr', id, at, file: path, number: adr[1] ?? '', title, isUpdate: isEdit || previous !== null }
60 }
61 if (!GLOSSARY.test(path)) return null
62 const had = termsIn(previous ?? '')
63 return { kind: 'term', id, at, file: path, terms: [...termsIn(written)].filter(t => !had.has(t)) }
64}
65
66/** A read folds into the lookup just before it; a sub-agent never does. */
67export const foldsInto = (last: Step | undefined): last is LookupStep => last?.kind === 'lookup' && !last.isAgent
68
69/** Terms written to the file the last Trace wrote fold into it. */
70export const termsFoldInto = (last: Step | undefined, path: string): last is TermStep =>
71 last?.kind === 'term' && last.file === path
72
73// ── Drawing ──
74
75// The modsbook palette.
76export const EDGE = '#4a4a4a'
77export const ACCENT = '#d97757'
78export const FOG = '#8a93a6'
79export const GREEN = '#5ccf9f'
80export const AMBER = '#e2b867'
81const VIOLET = '#b79cf5'
82export const TEAL = '#6fd3c8'
83export const BLUE = '#7fb4f5'
84
85export const MARK: Record<Mark, string> = { took: '✓', own: '✎', replied: '⏎', open: '◌' }
86export const MARK_COLOR: Record<Mark, string> = { took: GREEN, own: ACCENT, replied: FOG, open: AMBER }
87
88export const since = (from: number, to = Date.now()) => {
89 const s = Math.max(0, Math.round((to - from) / 1000))
90 if (s < 60) return `${s}s`
91 const m = Math.floor(s / 60)
92 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60}m`
93}
94export const cut = (text: string, room: number) => (text.length > room ? `${text.slice(0, Math.max(1, room - 1))}…` : text)
95export const base = (path: string) => path.split('/').pop() ?? path
96
97/** Words laid into lines of `width`, at most `max` of them, the last cut with `…`. */
98export const wrapLines = (text: string, width: number, max: number): string[] => {
99 const lines: string[] = []
100 let line = ''
101 for (const raw of text.split(/\s+/).filter(Boolean)) {
102 const word = cut(raw, width)
103 if (!line) line = word
104 else if (line.length + 1 + word.length <= width) line += ` ${word}`
105 else {
106 lines.push(line)
107 line = word
108 }
109 }
110 if (line) lines.push(line)
111 if (lines.length <= max) return lines
112 const kept = lines.slice(0, max)
113 const last = kept[max - 1] ?? ''
114 kept[max - 1] = last.length >= width ? `${last.slice(0, width - 1)}…` : `${last}…`
115 return kept
116}
117
118/** The elements a Spine is drawn with, from `$.ui.resolve(e)` on any surface. */
119export type SpineElements = {
120 Box: ElementConstructor<BoxProps>
121 Text: ElementConstructor<TextProps>
122 Button: ElementConstructor<ButtonProps>
123}
124
125export type SpineView = {
126 steps: Step[]
127 /** Put before each Level heading's key, so two Spines in one pane press apart. */
128 keyPrefix: string
129 /** A closed ticket, or a grill whose Wrap-up is confirmed: every Level folded. */
130 isClosed: boolean
131 /** Lines still arrive here: the tail says what the Spine waits for. */
132 isLive: boolean
133 isAllUnfolded: boolean
134 /** Levels a press turned from how they are drawn by default. */
135 turned: string[]
136 onTurn: (id: string) => unknown
137 /** Cells across. */
138 width: number
139 now: number
140 /** Draws a `started` row first. */
141 startedAt?: number
142}
143
144const rounds = (steps: Step[]) => steps.filter((s): s is RoundStep => s.kind === 'round')
145
146/** The open questions of the newest Level. */
147export const openCount = (steps: Step[]) => {
148 const last = rounds(steps).at(-1)
149 return last ? last.questions.filter(q => markOf(q, last) === 'open').length : 0
150}
151
152/** `Spine` with its count of Levels, and of open questions where the Spine is not closed. */
153export const spineHeading = (el: SpineElements, title: string, steps: Step[], isClosed: boolean) => {
154 const { Box, Text } = el
155 const levels = rounds(steps).length
156 const open = isClosed ? 0 : openCount(steps)
157 return (
158 <Box flexDirection="row" justifyContent="space-between">
159 <Text bold>{title}</Text>
160 <Text dimColor>
161 {levels} level{levels === 1 ? '' : 's'}
162 {open ? ` · ${open} open` : ''}
163 </Text>
164 </Box>
165 )
166}
167
168/** `a`: every Level of every Spine in the pane whole, or back to how each is drawn by default. */
169export const foldAllButton = (el: SpineElements, isAllUnfolded: boolean, onPress: () => unknown) => {
170 const { Button } = el
171 return <Button key="fold-all" label={isAllUnfolded ? 'Fold' : 'Unfold'} hotkey="a" onPress={onPress} />
172}
173
174/** The Spine's rows: each Level hanging from it, the Traces between, the Wrap-up where it closes. */
175export const spineRows = (el: SpineElements, v: SpineView): RenderChildren[] => {
176 const { Box, Text, Button } = el
177 /** One row of the spine: what it hangs from on the left, a note on the right. */
178 const row = (key: string, lead: RenderChildren, body: RenderChildren, right?: RenderChildren) => (
179 <Box key={key} flexDirection="row" justifyContent="space-between">
180 <Box flexShrink={1}>
181 <Text wrap="truncate-end">
182 {lead}
183 {body}
184 </Text>
185 </Box>
186 {right !== undefined && (
187 <Box flexShrink={0} marginLeft={1}>
188 {right}
189 </Box>
190 )}
191 </Box>
192 )
193 const spine = (rest = '') => <Text color={EDGE}>┃ {rest}</Text>
194 const lines: RenderChildren[] = []
195 const room = Math.max(12, v.width - 4)
196 const all = rounds(v.steps)
197 const lastRound = all.at(-1)
198
199 if (v.startedAt !== undefined)
200 lines.push(
201 row(
202 'start',
203 <Text color={VIOLET}>◎ </Text>,
204 <Text dimColor>started</Text>,
205 <Text dimColor>{since(v.startedAt, v.now)} ago</Text>,
206 ),
207 )
208
209 let level = 0
210 for (const s of v.steps) {
211 if (s.kind === 'lookup') {
212 lines.push(
213 row(
214 s.id,
215 spine(),
216 <Text dimColor>
217 <Text color={FOG}>⌕ </Text>
218 {s.isAgent ? `agent · ${s.label}` : `${s.count} ${s.count === 1 ? 'lookup' : 'lookups'} · ${s.label}`}
219 </Text>,
220 s.isAgent ? s.isDone ? <Text dimColor>done</Text> : <Text color={AMBER}>running</Text> : undefined,
221 ),
222 )
223 continue
224 }
225 if (s.kind === 'term') {
226 lines.push(
227 row(
228 s.id,
229 spine(),
230 <Text>
231 <Text color={TEAL}>≡ </Text>
232 <Text dimColor>{base(s.file)} · </Text>
233 {s.terms.length ? s.terms.join(', ') : <Text dimColor>updated</Text>}
234 </Text>,
235 ),
236 )
237 continue
238 }
239 if (s.kind === 'adr') {
240 lines.push(
241 row(
242 s.id,
243 spine(),
244 <Text>
245 <Text color={BLUE}>⚑ </Text>
246 <Text dimColor>ADR {s.number} · </Text>
247 {s.title}
248 </Text>,
249 s.isUpdate ? <Text dimColor>updated</Text> : undefined,
250 ),
251 )
252 continue
253 }
254 if (s.kind === 'wrap') {
255 const color = s.isConfirmed ? GREEN : s.reply !== undefined ? FOG : AMBER
256 const label = s.isConfirmed
257 ? 'shared understanding confirmed'
258 : s.reply !== undefined
259 ? 'wrap-up, pushed back'
260 : 'wrap-up · confirm the shared understanding'
261 lines.push(row(`${s.id}-gap`, spine(), ''))
262 lines.push(
263 row(
264 s.id,
265 <Text color={color}>{s.isConfirmed ? '◉ ' : '◎ '}</Text>,
266 <Text bold>{label}</Text>,
267 <Text dimColor>{since(s.at, v.now)}</Text>,
268 ),
269 )
270 const isLast = s === v.steps.at(-1)
271 wrapLines(s.text, room, s.reply !== undefined || v.isClosed ? 1 : 4).forEach((l, i) =>
272 lines.push(row(`${s.id}-t${i}`, isLast ? <Text>{' '}</Text> : spine(), <Text dimColor>{l}</Text>)),
273 )
274 if (s.reply !== undefined && !s.isConfirmed)
275 lines.push(
276 row(`${s.id}-reply`, isLast ? <Text>{' '}</Text> : spine(), <Text>
277 <Text color={ACCENT}>✎ </Text>
278 {s.reply}
279 </Text>),
280 )
281 continue
282 }
283
284 // A Level: its questions hanging from the spine; the newest drawn whole, unless the Spine is closed.
285 level += 1
286 const r = s
287 const isNewest = r === lastRound
288 const isWhole = isNewest && !v.isClosed
289 const isOpen = v.isAllUnfolded || isWhole !== v.turned.includes(r.id)
290 const waiting = isNewest && v.isLive && isWaiting(r)
291 const marks = r.questions.map(q => markOf(q, r))
292 const done = marks.filter(m => m !== 'open').length
293 lines.push(row(`${r.id}-gap`, spine(), ''))
294 lines.push(
295 <Box key={r.id} flexDirection="row" justifyContent="space-between">
296 <Box flexShrink={1}>
297 <Button key={`${v.keyPrefix}level-${level}`} plain onPress={() => v.onTurn(r.id)}>
298 <Text wrap="truncate-end">
299 <Text color={waiting ? AMBER : done === marks.length ? GREEN : FOG}>{waiting ? '◆ ' : '● '}</Text>
300 <Text bold>L{level}</Text>
301 <Text dimColor>
302 {' · '}
303 {r.via === 'dialog' ? 'dialog · ' : ''}
304 {waiting ? '' : `${done}/${marks.length} settled`}
305 </Text>
306 {waiting ? <Text color={AMBER}>waiting for you</Text> : ''}
307 <Text color={EDGE}>{isOpen ? ' ▾' : ' ▸'}</Text>
308 </Text>
309 </Button>
310 </Box>
311 <Box flexShrink={0} marginLeft={1}>
312 <Text dimColor>{since(r.at, v.now)}</Text>
313 </Box>
314 </Box>,
315 )
316
317 r.questions.forEach((q, qi) => {
318 const m = marks[qi] ?? 'open'
319 const key = `${r.id}-${q.n}-${qi}`
320 const sign = <Text color={MARK_COLOR[m]}>{MARK[m]} </Text>
321 if (!isOpen) {
322 const said =
323 m === 'took'
324 ? q.recommended || q.answer || ''
325 : m === 'own'
326 ? (q.answer ?? '')
327 : m === 'replied'
328 ? 'in the reply'
329 : q.recommended
330 ? `➡ ${q.recommended}`
331 : ''
332 lines.push(
333 row(key, spine(), <Text>
334 {sign}
335 <Text bold={m === 'open'}>{q.title}</Text>
336 {said ? <Text dimColor> → {said}</Text> : ''}
337 </Text>),
338 )
339 return
340 }
341 const sub = (k: string, body: RenderChildren) => lines.push(row(`${key}-${k}`, spine('│ '), body))
342 lines.push(
343 row(key, spine(), <Text>
344 {sign}
345 <Text dimColor>Q{q.n} </Text>
346 <Text bold>{q.title}</Text>
347 </Text>),
348 )
349 wrapLines(q.body, room - 2, 4).forEach((l, i) => sub(`b${i}`, <Text dimColor>{l}</Text>))
350 q.options.forEach((o, oi) => {
351 const isChosen = q.answer !== undefined && (q.answer === o || q.answer.split(/,\s*/).includes(o))
352 sub(
353 `o${oi}`,
354 <Text dimColor={!isChosen} bold={isChosen}>
355 <Text color={isChosen ? GREEN : EDGE}>{isChosen ? '● ' : '○ '}</Text>
356 {o}
357 </Text>,
358 )
359 })
360 if (q.recommended)
361 wrapLines(q.recommended, room - 4, 3).forEach((l, i) =>
362 sub(
363 `rec${i}`,
364 <Text bold={m === 'took'} dimColor={m === 'own'}>
365 <Text color={AMBER}>{i ? ' ' : '➡ '}</Text>
366 {l}
367 {m === 'took' && i === 0 ? <Text color={GREEN}> ✓</Text> : ''}
368 </Text>,
369 ),
370 )
371 if (m === 'own' && q.answer !== undefined && !(r.via === 'dialog' && q.options.includes(q.answer)))
372 wrapLines(q.answer, room - 4, 3).forEach((l, i) =>
373 sub(
374 `ans${i}`,
375 <Text bold>
376 <Text color={ACCENT}>{i ? ' ' : '✎ '}</Text>
377 {l}
378 </Text>,
379 ),
380 )
381 })
382 if (r.reply !== undefined && marks.includes('replied'))
383 wrapLines(r.reply, room - 2, isOpen ? 4 : 1).forEach((l, i) =>
384 lines.push(
385 row(`${r.id}-reply${i}`, spine(), <Text dimColor italic>
386 {i ? ' ' : '⏎ '}
387 {l}
388 </Text>),
389 ),
390 )
391 }
392
393 // Where the Spine goes from here, while lines still arrive.
394 if (!v.isLive || v.isClosed) return lines
395 const last = v.steps.at(-1)
396 const left = openCount(v.steps)
397 const tail =
398 last?.kind === 'wrap' && last.reply === undefined
399 ? null
400 : last?.kind === 'wrap' && last.isConfirmed
401 ? null
402 : lastRound && isWaiting(lastRound)
403 ? { color: AMBER, text: `${left} open · reply by number, 1. yes 2. …` }
404 : last?.kind === 'lookup' && last.isAgent && !last.isDone
405 ? { color: FOG, text: 'looking up a fact…' }
406 : all.length
407 ? { color: FOG, text: 'the next frontier…' }
408 : { color: FOG, text: 'the first round…' }
409 if (tail) {
410 lines.push(row('tail-gap', spine(), ''))
411 lines.push(row('tail', <Text color={tail.color}>▼ </Text>, <Text color={tail.color}>{tail.text}</Text>))
412 }
413 return lines
414}
415types/index.d.ts 92 lines1// The Spine's steps, declared alike in both mods' contracts (a contract imports nothing): hooks/spine.tsx and
2// hooks/reply.ts read them, and scripts/check.sh compares the two declarations, down to `export type Step`.
3/** One question of a round (`❓ **Q1** - **Title**: body … ➡ recommended`) or of an AskUserQuestion dialog. */
4export type Question = {
5 n: string
6 title: string
7 /** The question's body, the choices left out. */
8 body: string
9 /** The choices the body listed, or the options the dialog offered. */
10 options: string[]
11 /** The recommended answer (`➡ …`), or the dialog's "(Recommended)" option. */
12 recommended: string
13 answer?: string
14 /** The person took the recommendation: a yes with no Reserve, or the recommended option. */
15 isAccepted?: boolean
16}
17
18/** A Level: one round, the frontier as it stood. */
19export type RoundStep = {
20 kind: 'round'
21 id: string
22 at: number
23 via: 'grilling' | 'dialog'
24 questions: Question[]
25 /** What the person wrote back, whole. */
26 reply?: string
27}
28
29/** A fact the agent went to find instead of asking: a sub-agent, or a run of reads and searches. */
30export type LookupStep = {
31 kind: 'lookup'
32 id: string
33 at: number
34 label: string
35 isAgent: boolean
36 /** Reads and searches folded into this step. */
37 count: number
38 isDone: boolean
39}
40
41/** Terms written to the glossary (`CONTEXT.md`, `GLOSSARY.md`). */
42export type TermStep = { kind: 'term'; id: string; at: number; file: string; terms: string[] }
43
44/** An ADR written under `docs/adr/`. */
45export type AdrStep = { kind: 'adr'; id: string; at: number; file: string; number: string; title: string; isUpdate: boolean }
46
47/** The Wrap-up: a turn that ended without questions once rounds were answered, the summary up for confirmation. */
48export type WrapStep = { kind: 'wrap'; id: string; at: number; text: string; reply?: string; isConfirmed?: boolean }
49
50export type Step = RoundStep | LookupStep | TermStep | AdrStep | WrapStep
51
52/** The skill that started the grill: the interview itself, or one of its two wrappers. */
53export type GrillSkill = 'grilling' | 'grill-me' | 'grill-with-docs'
54
55export type Grill = {
56 id: string
57 skill: GrillSkill
58 subject: string
59 startedAt: number
60 steps: Step[]
61 /** The person confirmed the shared understanding. */
62 isDone: boolean
63 /** Inside a wayfinder Run: the claimed ticket's id, or `charting`. Absent for a grill of its own. */
64 run?: string
65}
66
67export type GrillBook = {
68 grills: Grill[]
69 /** The grill the pane shows; null follows the newest. */
70 shown: string | null
71 /** Levels a press turned from how they are drawn by default. */
72 expanded: string[]
73 isAllExpanded: boolean
74 /** The person's last prompt, the subject of a grill the model starts by itself. */
75 lastPrompt: string
76}
77
78/** wayfinder-map's Run, as its contract declares it: read to know a grill runs inside /wayfinder. */
79export type WayfinderRun = {
80 /** The ticket claimed in the Run, null while charting. */
81 ticket: { id: string; title: string } | null
82 /** When the last claim was seen. */
83 claimedAt: number | null
84}
85
86declare module 'claude-code' {
87 interface PluginState {
88 'grill-map': { book: GrillBook }
89 'wayfinder-map': { run: WayfinderRun | null }
90 }
91}
92