SLOPSHOPPER

grill-map

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…

newpaneguardcommandstatusprompt
v0.3.0MITupdated 2026-10-16bardaxx/modsbook/mods/grill-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grill-map
│ ┃ Grill ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─ modsbook │ ┃ ───────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ ❯ grill-map ▌ ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ ╰────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ mattpocock/skills ─╯ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ ◆ grill map │ │ ┃ │ /grill-me, /grill-with-docs or /grilling │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ draws its design tree here, one level │ │ ┃ │ per round. │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ › /grillmap │ ⎿ grill-map: Grill map opened: it fills once /grill-me, /grill-wit │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Grill
╭─ modsbook ───────────────────────────────────────────╮ │ ❯ grill-map ▌ │ ╰────────────────────────────────── mattpocock/skills ─╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ grill map │ │ /grill-me, /grill-with-docs or /grilling draws its │ │ design tree here, one level per round. │ ╰──────────────────────────────────────────────────────────╯
README

grill-map

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. …     │
╰──────────────────────────────────────────────╯

What it shows

  • header: the skill that started the grill, its subject, the questions settled out of those asked, and one bar split by how each stands: ✓ 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.
  • Spine: the design tree, top to bottom, drawn as wayfinder-map draws each ticket's, from the same file.
  • Each round is a level, 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.
  • An AskUserQuestion dialog asked during the grill is a level too, the option picked marked ●.
  • ⌕ 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.
  • status line: while a grill is open, its level and what waits on the person, 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.

Inside /wayfinder

With wayfinder-map installed too, a /wayfinder Run is wayfinder-map's: grill-map reads it and follows it.

  • Its status line goes, and wayfinder-map's carries the ticket being worked.
  • It opens one grill per claimed ticket, named after the ticket, and a charting grill for the rounds asked with no ticket claimed.
  • The Run ends on /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.

Requirements

  • Claude Code with function-hook mods (2.1.292 or later).
  • The grilling skills from mattpocock/skills: grilling, and the grill-me or grill-with-docs wrapper (grill-with-docs also needs domain-modeling).

Install

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

Use

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.

CommandWhat it does
/grillmapOpens the pane.
/grillmap clearForgets 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.

How it knows

It showsIt reads
the grill and its subjectthe 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 leveleach main-loop answer holding ❓ Q1 - Title: … and ➡️ …, the grilling skill's round format
what was chosenthe 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
lookupseach 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, …)
termseach Write or Edit of a CONTEXT.md or GLOSSARY.md: the Term: lines it adds
ADRseach Write or Edit of docs/adr/NNNN-slug.md, titled by its # heading
wrap-upthe first answer without questions after a round is answered, and the person's reply to it
dialogseach AskUserQuestion call and the answers it returned; an option labelled (Recommended) is the recommendation

Limits

  • A reply that answers a round in prose, without numbering, is kept whole under the level rather than split across its questions.
  • A reply is read as a yes when it opens with one (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.
  • A yes with a reserve said whole to a round of several questions stays unsplit (⏎): the reserve cannot be pinned to one question.
  • A level is the round as the agent asked it: when the agent puts dependent questions in the same round, they sit on the same level.
  • The wrap-up is read from the shape of the turn. An answer that ends without questions for another reason, such as a pause to look something up, shows as a wrap-up until the next round arrives.
  • Grills are kept for the session, not across sessions.

Develop

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.

License

MIT, see the repository's license.

Source 5 files
hooks/register.tsx 498 lines
1import { 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}
498
hooks/reply.ts 142 lines
1// 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) })
142
hooks/header.tsx 47 lines
1// 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}
47
hooks/spine.tsx 415 lines
1// 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}
415
types/index.d.ts 92 lines
1// 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