Side pane for /wayfinder: the map as a navigable graph of decision tickets from start to destination, the decisions so far, the fog, and the grilling's design…

A Claude Code mod that docks a side pane beside the transcript and draws a /wayfinder map: the route of decision tickets from start to destination as a graph you can walk with the arrows, the decisions taken so far, the fog not yet specified, and in each ticket's card its Spine: the grilling rounds, the facts looked up and the docs written while it was worked.
⚑ Start
A foggy idea, with no idea how to get there
│
▼
╭───┬─────────────────────╮
│ ? │ ✓ Who is it for │
╰───┴────────┬────────────╯
│
╭────────────────────────────┼────────────────────────────╮
▼ ▼ ▼
╭───┬─────────────────────╮ ╭───┬─────────────────────╮ ╭───┬─────────────────────╮
│ ≡ │ ▶ Stand up the sand…│ │ ? │ ◇ Pricing model │ │ ⌕ │ ◇ Competitor APIs │
╰───┴────────┬────────────╯ ╰───┴────────┬────────────╯ ╰───┴────────┬────────────╯
│ │ │
╰──────────────╮ │ ╭─────────────╯
├─────────────┴──────────────┤
▼ ▼
╭───┬─────────────────────╮ ╭───┬─────────────────────╮
│ ◫ │ ○ Onboarding flow s…│ │ ? │ ○ Launch scope │
╰───┴────────┬────────────╯ ╰───┴────────┬────────────╯
│ │
╰─────────────╮ │
├──────────────╯
▼
⚑ Destination
All tasks on the map are complete.
m): the map as a graph, drawn the way the wayfinder article draws it. Start (the map's title) sits on top and Destination at the bottom. Each ticket is a box in the row of its depth, with arrows from the tickets that block it. A box's border and icon give its type: ? grilling, ◫ prototype, ⌕ research, ≡ task. The mark before its title gives its state: ✓ resolved, ● claimed, ◇ on the frontier (open, unblocked, unclaimed), ○ blocked, ▶ next (the first frontier ticket in map order, the one /wayfinder takes). Out-of-scope tickets stay off the map, and the map's Not yet specified section shows below it as fog.h j k l). Up and down follow the blocking edges first, left and right move along a row. The selected box gets a double border, its edges light up, and its card opens below. ⏎ closes or reopens the card. Without giving the graph the keys, n and p step through the tickets row by row. On a map wider than the pane, the view scrolls to keep the selected box in sight.## Answer of a local ticket, or the last comment of a GitHub one). Under them, its Spine, drawn as grill-map draws a grill, from the same file. A long Spine makes the card longer, and the pane scrolls. x closes the card.L1, L2, …. On an open ticket the newest level is drawn whole: each question's body, the choices, the recommendation (➡, ✓ when taken) and the person's own answer (✎). Older levels fold to one line per decision. A click or a tap on a level's heading folds or unfolds it, and a (the Unfold / Fold button) does every level at once.⌕ a fact looked up (a sub-agent, or a run of reads folded into one line), ≡ the glossary terms written, ⚑ an ADR.◎ wrap-up is the summary put to the person once the rounds are answered, and ◉ marks it confirmed.d): the map's Decisions so far, one gist per resolved ticket, then Out of scope and the map's Notes.Only a Run writes to a Spine. A Run starts with /wayfinder and lasts until /clear, a /grill-me or /grill-with-docs you ask for, or /waymap clear. A map you open by hand with /waymap is shown, not grilled into.
Pick the storage engine · L2 · 2 open, or charting · … with no ticket claimed. Outside a Run there is none.Spines are kept per map across sessions, up to 400 lines a map. When a map goes over, the oldest lookups are dropped first, never a level or a wrap-up. After that go the oldest lines of resolved tickets, never those of the open ticket or of charting.
/wayfinder charts one, on either tracker setup-matt-pocock-skills configures:wayfinder:map, its tickets sub-issues (or a task list in its body) labelled wayfinder:<type>, blocked through GitHub's native dependencies or a Blocked by: #n line. Needs the GitHub CLI, signed in, run from a checkout of the repository..scratch/<effort>/map.md and one .scratch/<effort>/issues/NN-<slug>.md per ticket, with Type:, Status: and Blocked by: lines.At the prompt of a Claude Code terminal session:
/plugin install wayfinder-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 wayfinder-map@modsbook
To run it from a clone while you change it:
claude --plugin-dir /path/to/modsbook/mods/wayfinder-map
| Command | What it does |
|---|---|
/waymap | Opens the pane, following the map it already follows, or else the newest .scratch/*/map.md, or else the newest open wayfinder:map issue. |
/waymap <n> or /waymap <issue URL> | Follows the map in GitHub issue #n. |
/waymap .scratch/<effort> | Follows a local map. |
/waymap refresh | Reads the map and its tickets again. |
/waymap clear | Forgets the map followed in this session, and ends the Run. |
In the pane, m and d switch tabs, n and p step through the tickets, x closes a ticket's card, a unfolds or folds every level, r refreshes and c clears. On mobile, which has no region for the graph, the map tab draws the route as an indented tree instead: a digit or a tap opens a ticket, and its card carries the same Spine, levels folded and unfolded by a tap. VS Code draws no mod panes yet.
Mostly no command is needed. The pane opens when /wayfinder runs: with a map as its argument it follows that map, and with a loose idea it waits for the map to be created (the gh issue create --label wayfinder:map call, or the write of .scratch/<effort>/map.md) and follows it from there. It reads the map again after each gh issue or gh api call that changes the tracker, after each write under .scratch/, and every minute.
| It shows | It reads |
|---|---|
| map, destination, decisions, fog, out of scope | the map's body: ## Destination, ## Notes, ## Decisions so far, ## Not yet specified, ## Out of scope |
| tickets, types, claims | gh api …/issues/<map>/sub_issues (labels, assignees, state), or the local issue files' Type: and Status: lines |
| blocking edges | a Blocked by: line, else …/issues/<n>/dependencies/blocked_by |
| out of scope | a ticket the map's Out of scope section links, or one closed as not planned |
| the ticket this session works | gh issue edit <n> --add-assignee; Status: claimed written to a local ticket, by a write or by a shell command naming its file |
| levels | each main-loop answer holding ❓ Q1 - Title: … and ➡️ …, the grilling skill's round format, and the person's next prompt (1. yes, Q2: no, …, or one answer for all; a yes followed by a reserve such as ma or but is the person's own answer) |
| dialogs | each AskUserQuestion call and the answers it returned; an option labelled (Recommended) is the recommendation |
| lookups, terms, ADRs | each main-loop Agent call, read and search; each Write or Edit of a GLOSSARY.md, CONTEXT.md or docs/adr/NNNN-slug.md |
| wrap-up | the first answer without questions after a level is answered, and the person's reply to it |
n and p work without it.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 1179 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type {
5 Entry,
6 GraphMessage,
7 GraphProps,
8 Line,
9 MapView,
10 Question,
11 RoundStep,
12 Run,
13 Step,
14 Tab,
15 Target,
16 Ticket,
17 TicketType,
18 Wayfinder,
19} from '../types'
20import { applyReply, dialogRound, firstLine, isWaiting, isWrapDue, lastTurn, markOf, parseRound, plain, settleWrap } from './reply'
21import { header } from './header'
22import {
23 cut,
24 docTraceOf,
25 foldAllButton,
26 foldsInto,
27 isDocPath,
28 lookupOf,
29 since,
30 spineHeading,
31 spineRows,
32 termsFoldInto,
33} from './spine'
34
35const PANE = 'wayfinder-map'
36const TITLE = 'Wayfinder'
37const POLL_MS = 60_000
38// About 800 KB at worst, of the store's 4 MiB in all.
39const LINES_KEPT = 400
40
41// The modsbook palette: muted pills on a dark track, dark ink on the pills.
42const INK = '#151515'
43const TRACK = '#2b2b2b'
44const EDGE = '#4a4a4a'
45const ACCENT = '#d97757'
46const FOG = '#8a93a6'
47type State = 'resolved' | 'claimed' | 'frontier' | 'blocked'
48const STATE_COLOR: Record<State, string> = {
49 resolved: '#5ccf9f',
50 claimed: ACCENT,
51 frontier: '#e2b867',
52 blocked: '#5a5a5a',
53}
54const STATE_MARK: Record<State, string> = { resolved: '✓', claimed: '●', frontier: '◇', blocked: '○' }
55const LEGEND: { state: State; label: string }[] = [
56 { state: 'resolved', label: 'resolved' },
57 { state: 'claimed', label: 'claimed' },
58 { state: 'frontier', label: 'frontier' },
59 { state: 'blocked', label: 'blocked' },
60]
61const TYPE_COLOR: Record<TicketType, string> = {
62 grilling: '#b79cf5',
63 prototype: '#7fb4f5',
64 research: '#6fd3c8',
65 task: '#c9a26b',
66}
67// Who resolves each type (the skill's Ticket Types).
68const MODE: Record<TicketType, string> = { grilling: 'HITL', prototype: 'HITL', research: 'AFK', task: 'either' }
69const TYPES = new Set<string>(['research', 'prototype', 'grilling', 'task'])
70
71const EMPTY: Wayfinder = {
72 target: null,
73 map: null,
74 lines: [],
75 tab: 'route',
76 focus: null,
77 turned: [],
78 isAllUnfolded: false,
79 error: null,
80 refreshedAt: null,
81}
82const way = atom({ plugin: 'wayfinder-map', key: 'way' } as const, EMPTY)
83const running = atom({ plugin: 'wayfinder-map', key: 'run' } as const, null)
84
85// State kept from an older version lacks the fields added since; its `grill` tab is gone.
86const current = async ($: EngineInterface): Promise<Wayfinder> => {
87 const w = { ...EMPTY, ...(await read($, way)) }
88 return w.tab === 'route' || w.tab === 'decisions' ? w : { ...w, tab: 'route' }
89}
90const runOf = async ($: EngineInterface): Promise<Run | null> => (await read($, running)) ?? null
91
92let made = 0
93const newId = (prefix: string) => `${prefix}${Date.now().toString(36)}-${made++}`
94
95// ── The map and its tickets, as the tracker doc's "Wayfinding operations" lay them out ──
96
97/** `## Heading` → its body, lower-cased headings, HTML comments dropped. */
98const sections = (md: string): Record<string, string> => {
99 const out: Record<string, string> = {}
100 for (const part of md.replace(/<!--[\s\S]*?-->/g, '').split(/^##[ \t]+/m).slice(1)) {
101 const nl = part.indexOf('\n')
102 const head = (nl < 0 ? part : part.slice(0, nl)).trim().toLowerCase()
103 out[head] = nl < 0 ? '' : part.slice(nl + 1).trim()
104 }
105 return out
106}
107
108const items = (body: string) =>
109 body
110 .split('\n')
111 .map(line => line.replace(/^\s*(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?/, '').trim())
112 .filter(Boolean)
113
114const ticketIdIn = (text: string): string | null => {
115 const m = /issues\/(\d+)/.exec(text) ?? /#(\d+)\b/.exec(text)
116 return m ? String(Number(m[1])) : null
117}
118
119/** `- [Title](link): gist`, or `- Title: gist`. */
120const entry = (line: string): Entry => {
121 const linked = /^\[([^\]]+)\]\(([^)]*)\)\s*(?:[:—–-]\s*)?([\s\S]*)$/.exec(line)
122 if (linked) return { title: plain(linked[1] ?? ''), gist: plain(linked[3] ?? ''), ticket: ticketIdIn(linked[2] ?? '') }
123 const split = /^(.+?):\s+([\s\S]+)$/.exec(line)
124 return split
125 ? { title: plain(split[1] ?? ''), gist: plain(split[2] ?? ''), ticket: ticketIdIn(line) }
126 : { title: plain(line), gist: '', ticket: ticketIdIn(line) }
127}
128
129const mapBody = (md: string) => {
130 const s = sections(md)
131 return {
132 destination: plain(s['destination'] ?? ''),
133 notes: plain(s['notes'] ?? ''),
134 decisions: items(s['decisions so far'] ?? '').map(entry),
135 fog: items(s['not yet specified'] ?? '').map(plain),
136 outOfScope: items(s['out of scope'] ?? '').map(entry),
137 }
138}
139
140const blockersIn = (text: string): string[] | null => {
141 const m = /^\s*\**blocked by\**:?\**\s*(.*)$/im.exec(text)
142 return m ? [...(m[1] ?? '').matchAll(/\d+/g)].map(n => String(Number(n[0]))) : null
143}
144
145// A ticket the map's Out of scope section links, or one closed as not planned.
146const scoped = (tickets: Ticket[], outOfScope: Entry[]) => {
147 const out = new Set(outOfScope.map(e => e.ticket).filter(Boolean))
148 return tickets.map(t => (out.has(t.id) ? { ...t, isOutOfScope: true, isClosed: true } : t))
149}
150
151const gh = async ($: EngineInterface, args: string[]) => {
152 const run = await $.process.run(['gh', ...args], { timeoutMs: 20_000 })
153 if (run.exitCode !== 0) throw new Error(run.stderr.trim().split('\n')[0] || `gh ${args[0]} failed`)
154 return run.stdout
155}
156
157type GhIssue = {
158 number: number
159 title: string
160 state: string
161 state_reason?: string | null
162 stateReason?: string | null
163 body: string | null
164 labels?: { name: string }[]
165 assignees?: { login: string }[]
166 html_url?: string
167 url?: string
168 issue_dependencies_summary?: { total_blocked_by?: number }
169}
170
171const fromGh = async ($: EngineInterface, issue: GhIssue): Promise<Ticket> => {
172 const body = issue.body ?? ''
173 const label = (issue.labels ?? []).map(l => l.name).find(n => n.startsWith('wayfinder:'))
174 const type = label?.slice('wayfinder:'.length) ?? ''
175 let blockedBy = blockersIn(body)
176 if (blockedBy === null) {
177 // Native blocking: the canonical edges, one call per ticket that has any.
178 if (issue.issue_dependencies_summary?.total_blocked_by === 0) blockedBy = []
179 else {
180 try {
181 const deps = JSON.parse(
182 await gh($, ['api', `repos/{owner}/{repo}/issues/${issue.number}/dependencies/blocked_by`]),
183 ) as { number: number }[]
184 blockedBy = deps.map(d => String(d.number))
185 } catch {
186 blockedBy = []
187 }
188 }
189 }
190 const reason = (issue.state_reason ?? issue.stateReason ?? '').toLowerCase()
191 return {
192 id: String(issue.number),
193 title: issue.title,
194 type: TYPES.has(type) ? (type as TicketType) : null,
195 isClosed: issue.state.toLowerCase() === 'closed',
196 isClaimed: (issue.assignees ?? []).length > 0,
197 isOutOfScope: reason === 'not_planned',
198 blockedBy,
199 link: issue.html_url ?? issue.url ?? '',
200 question: plain(sections(body)['question'] ?? firstLine(body)),
201 }
202}
203
204const readGithub = async ($: EngineInterface, ref: string): Promise<MapView> => {
205 const head = JSON.parse(await gh($, ['issue', 'view', ref, '--json', 'number,title,body,url'])) as {
206 title: string
207 body: string
208 url: string
209 }
210 let subs: GhIssue[] = []
211 try {
212 subs = JSON.parse(await gh($, ['api', `repos/{owner}/{repo}/issues/${ref}/sub_issues?per_page=100`])) as GhIssue[]
213 } catch {
214 // no sub-issues on this repository: the map's task list names the children
215 }
216 if (!subs.length) {
217 const refs = [...head.body.matchAll(/^\s*[-*]\s*\[[ xX]\]\s*.*?#(\d+)/gm)].map(m => m[1] ?? '')
218 subs = await Promise.all(
219 refs.map(
220 async n =>
221 JSON.parse(
222 await gh($, ['issue', 'view', n, '--json', 'number,title,state,stateReason,body,labels,assignees,url']),
223 ) as GhIssue,
224 ),
225 )
226 }
227 const body = mapBody(head.body)
228 const tickets = await Promise.all(subs.map(s => fromGh($, s)))
229 return { title: head.title, link: head.url, ...body, tickets: scoped(tickets, body.outOfScope) }
230}
231
232const readLocal = async ($: EngineInterface, dir: string): Promise<MapView> => {
233 const md = await $.fs.read(`${dir}/map.md`)
234 const body = mapBody(md)
235 const files = (await $.fs.list(`${dir}/issues`).catch(() => []))
236 .filter(f => f.kind === 'file' && /^\d+.*\.md$/.test(f.name))
237 .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
238 const tickets = await Promise.all(
239 files.map(async (f): Promise<Ticket> => {
240 const path = `${dir}/issues/${f.name}`
241 const text = await $.fs.read(path)
242 const s = sections(text)
243 const status = (/^\s*\**status\**:?\**\s*(.+)$/im.exec(text)?.[1] ?? '').toLowerCase()
244 const type = (/^\s*\**type\**:?\**\s*(\w+)/im.exec(text)?.[1] ?? '').toLowerCase()
245 const slug = f.name.replace(/^\d+-?/, '').replace(/\.md$/, '').replace(/[-_]+/g, ' ')
246 const answer = plain(s['answer'] ?? '')
247 return {
248 id: String(Number(/^\d+/.exec(f.name)?.[0] ?? f.name)),
249 title: plain(/^#[ \t]+(.+)$/m.exec(text)?.[1] ?? slug),
250 type: TYPES.has(type) ? (type as TicketType) : null,
251 isClosed: /resolved|closed|done|scope|wontfix|dropped/.test(status),
252 isClaimed: /claimed/.test(status),
253 isOutOfScope: /scope/.test(status),
254 blockedBy: blockersIn(text) ?? [],
255 link: path,
256 question: plain(s['question'] ?? ''),
257 ...(answer ? { answer } : {}),
258 }
259 }),
260 )
261 const effort = dir.split('/').pop() ?? dir
262 return {
263 title: plain(/^#[ \t]+(.+)$/m.exec(md)?.[1] ?? effort.replace(/[-_]+/g, ' ')),
264 link: `${dir}/map.md`,
265 ...body,
266 tickets: scoped(tickets, body.outOfScope),
267 }
268}
269
270// ── The Spines: each ticket's lines and Charting's, kept per map ──
271
272const spineKey = (t: Target) => `spine:${t.source}:${t.ref}`
273// Before the Spine: the last rounds alone, read once and moved over.
274const roundsKey = (t: Target) => `rounds:${t.source}:${t.ref}`
275type OldRound = {
276 id: string
277 ticket: string | null
278 at: number
279 kind: 'grilling' | 'dialog'
280 questions: (Omit<Question, 'body'> & { body?: string })[]
281 reply?: string
282}
283const fromOld = (r: OldRound): Line => ({
284 kind: 'round',
285 id: r.id,
286 at: r.at,
287 via: r.kind,
288 questions: r.questions.map(q => ({ ...q, body: q.body ?? '' })),
289 ...(r.reply !== undefined ? { reply: r.reply } : {}),
290 ticket: r.ticket,
291})
292
293/** A ticket's Spine, or Charting's for `null`. */
294const linesOf = (lines: Line[], ticket: string | null): Step[] => lines.filter(l => l.ticket === ticket)
295
296/**
297 * Past the cap, the oldest lookups go first, never a Level or a Wrap-up; past that, the oldest lines
298 * of closed tickets, never the open ticket's or Charting's.
299 */
300const capped = (lines: Line[], map: MapView | null, open: string | null): Line[] => {
301 const closed = new Set((map?.tickets ?? []).filter(t => t.isClosed).map(t => t.id))
302 const out = [...lines]
303 while (out.length > LINES_KEPT) {
304 let i = out.findIndex(l => l.kind === 'lookup')
305 if (i < 0) i = out.findIndex(l => l.ticket !== null && l.ticket !== open && closed.has(l.ticket))
306 if (i < 0) break
307 out.splice(i, 1)
308 }
309 return out
310}
311
312const saveLines = async ($: EngineInterface) => {
313 const { target, lines } = await current($)
314 if (target) await $.store.set(spineKey(target), lines)
315}
316
317/** The lines stored for a map; a map kept before the Spine has its rounds moved over, once. */
318const storedLines = async ($: EngineInterface, target: Target): Promise<Line[]> => {
319 const stored = (await $.store.get(spineKey(target))) as Line[] | undefined
320 if (stored) return stored
321 const old = (await $.store.get(roundsKey(target))) as OldRound[] | undefined
322 if (!old) return []
323 const lines = old.map(fromOld)
324 await $.store.set(spineKey(target), lines)
325 await $.store.delete(roundsKey(target))
326 return lines
327}
328
329// ── The Run: the stretch of a conversation that works a map ──
330
331let shownStatus: string | undefined
332
333/** Inside a Run, the line the claimed ticket's Spine (or Charting's) stands at; none outside. */
334const syncStatus = async ($: EngineInterface) => {
335 const run = await runOf($)
336 let text: string | undefined
337 if (run) {
338 const steps = linesOf((await current($)).lines, run.ticket?.id ?? null)
339 const subject = run.ticket ? cut(run.ticket.title, 32) : 'charting'
340 const rounds = steps.filter((s): s is RoundStep => s.kind === 'round')
341 const last = rounds.at(-1)
342 const step = lastTurn(steps)
343 const left = last ? last.questions.filter(q => markOf(q, last) === 'open').length : 0
344 text =
345 step?.kind === 'wrap' && step.reply === undefined
346 ? `${subject} · confirm the shared understanding`
347 : last && isWaiting(last)
348 ? `${subject} · L${rounds.length} · ${left} open`
349 : last
350 ? `${subject} · L${rounds.length} · next frontier`
351 : `${subject} · starting`
352 }
353 if (text === shownStatus) return
354 shownStatus = text
355 $.ui.status(text)
356}
357
358const endRun = async ($: EngineInterface) => {
359 await update($, running, () => null)
360 await syncStatus($)
361}
362
363/** A claim seen in the Run: its lines go to that ticket until it closes. */
364const claim = async ($: EngineInterface, id: string) => {
365 if (!(await runOf($))) return
366 const { map } = await current($)
367 const title = map?.tickets.find(t => t.id === id)?.title ?? `#${id}`
368 await update($, running, r => (r ? { ticket: { id, title }, claimedAt: Date.now() } : r))
369 await syncStatus($)
370}
371
372// ── Following a map ──
373
374let refreshing: Promise<void> | null = null
375let isPending = false
376
377const refresh = async ($: EngineInterface): Promise<void> => {
378 if (refreshing) {
379 isPending = true
380 return refreshing
381 }
382 refreshing = (async () => {
383 do {
384 isPending = false
385 const { target } = await current($)
386 if (!target) break
387 try {
388 const map = target.source === 'github' ? await readGithub($, target.ref) : await readLocal($, target.ref)
389 await update($, way, w => {
390 // A GitHub answer fetched on focus stays until the ticket reopens.
391 const kept = new Map((w.map?.tickets ?? []).map(t => [t.id, t.answer]))
392 const tickets = map.tickets.map(t => (t.answer || !t.isClosed ? t : { ...t, answer: kept.get(t.id) }))
393 return { ...w, map: { ...map, tickets }, error: null, refreshedAt: Date.now() }
394 })
395 // The ticket worked on closed: later lines land on Charting until the next claim.
396 const run = await runOf($)
397 if (run?.ticket && map.tickets.find(t => t.id === run.ticket?.id)?.isClosed) {
398 await update($, running, r => (r ? { ...r, ticket: null } : r))
399 await syncStatus($)
400 }
401 } catch (err) {
402 await update($, way, w => ({ ...w, error: String(err instanceof Error ? err.message : err) }))
403 }
404 } while (isPending)
405 })().finally(() => {
406 refreshing = null
407 })
408 return refreshing
409}
410
411const follow = async ($: EngineInterface, target: Target) => {
412 const was = (await current($)).target
413 if (was?.source === target.source && was.ref === target.ref) return refresh($)
414 const stored = await storedLines($, target)
415 await update($, way, w => {
416 // Charting held before the map existed joins it; another map's lines stay with that map.
417 const carried = w.target ? [] : w.lines
418 const ids = new Set(carried.map(l => l.id))
419 const lines = [...stored.filter(l => !ids.has(l.id)), ...carried].sort((a, b) => a.at - b.at)
420 return { ...w, target, map: null, focus: null, lines, turned: [], error: null }
421 })
422 await saveLines($)
423 void $.ui.open({ id: PANE, title: TITLE })
424 return refresh($)
425}
426
427/** A map named in the skill's or the command's argument: a URL, `#42`, `42`, or a `.scratch/<effort>` path. */
428const targetIn = (text: string): Target | null => {
429 const local = /(\S*\.scratch\/[^/\s]+)/.exec(text)?.[1]
430 if (local) return { source: 'local', ref: local.replace(/\/(map\.md|issues.*)$/, '') }
431 const n = /issues\/(\d+)/.exec(text)?.[1] ?? /^\s*#?(\d+)\s*$/.exec(text)?.[1]
432 return n ? { source: 'github', ref: n } : null
433}
434
435// A map the repo already has: the newest `.scratch/*/map.md`, else the newest open `wayfinder:map` issue.
436const discover = async ($: EngineInterface): Promise<Target | null> => {
437 const dirs = (await $.fs.list('.scratch').catch(() => [])).filter(d => d.kind === 'dir')
438 const maps = await Promise.all(
439 dirs.map(async d => {
440 const stat = await $.fs.stat(`.scratch/${d.name}/map.md`).catch(() => null)
441 return stat ? { ref: `.scratch/${d.name}`, at: stat.mtimeMs } : null
442 }),
443 )
444 const local = maps.filter(m => m !== null).sort((a, b) => b.at - a.at)[0]
445 if (local) return { source: 'local', ref: local.ref }
446 try {
447 const open = JSON.parse(
448 await gh($, ['issue', 'list', '--label', 'wayfinder:map', '--state', 'open', '--limit', '1', '--json', 'number']),
449 ) as { number: number }[]
450 return open[0] ? { source: 'github', ref: String(open[0].number) } : null
451 } catch {
452 return null
453 }
454}
455
456const SCRATCH = /^(.*\.scratch\/[^/]+)\/(?:map\.md|issues\/(\d+)[^/]*\.md)$/
457
458// Bookkeeping never fails the call it watches.
459const quietly = async (work: () => Promise<unknown>) => {
460 try {
461 await work()
462 } catch {
463 // the pane misses one update; the session goes on
464 }
465}
466
467/**
468 * Writes the Run's lines: `fn` gets them with the ticket a new line goes to (null for Charting) and
469 * the map. Outside a Run nothing lands: a map followed by hand keeps no Spine.
470 */
471const record = async ($: EngineInterface, fn: (lines: Line[], ticket: string | null, map: MapView | null) => Line[]) => {
472 const run = await runOf($)
473 if (!run) return
474 const ticket = run.ticket?.id ?? null
475 await update($, way, w => ({ ...w, lines: capped(fn(w.lines, ticket, w.map), w.map, ticket) }))
476 await saveLines($)
477}
478
479const mapLine = (lines: Line[], id: string, fn: (l: Line) => Line) => lines.map(l => (l.id === id ? fn(l) : l))
480
481/** The newest line, when it is the given ticket's: what a read or a term folds into. */
482const lastOf = (lines: Line[], ticket: string | null) => {
483 const last = lines.at(-1)
484 return last?.ticket === ticket ? last : undefined
485}
486
487/** Once the map has a ticket, Charting keeps no lookups: those are a session reading the map to choose one. */
488const keepsLookups = (ticket: string | null, map: MapView | null) => ticket !== null || !map?.tickets.length
489
490// A new Level takes the focus: every Level unfolded by hand folds again.
491const addLevel = async ($: EngineInterface, round: RoundStep) => {
492 await record($, (lines, ticket) => [...lines, { ...round, ticket }])
493 await update($, way, w => ({ ...w, turned: [] }))
494 await syncStatus($)
495 void $.ui.open({ id: PANE, title: TITLE })
496}
497
498const fetchAnswer = async ($: EngineInterface, id: string) => {
499 const { target, map } = await current($)
500 const t = map?.tickets.find(x => x.id === id)
501 if (target?.source !== 'github' || !t?.isClosed || t.answer) return
502 const view = JSON.parse(await gh($, ['issue', 'view', id, '--json', 'comments'])) as { comments: { body: string }[] }
503 const answer = plain(view.comments.at(-1)?.body ?? '') || 'Closed with no comment.'
504 await update($, way, w =>
505 w.map ? { ...w, map: { ...w.map, tickets: w.map.tickets.map(x => (x.id === id ? { ...x, answer } : x)) } } : w,
506 )
507}
508
509export const register: Register = on => {
510 on('session.start', async ($, e, next) => {
511 await $.command.register({
512 name: 'waymap',
513 description: 'Show the wayfinder map pane (/waymap <map>, /waymap refresh, /waymap clear)',
514 argumentHint: '[<issue> | <.scratch/effort> | refresh | clear]',
515 })
516 $.clock.every(POLL_MS, () => void refresh($))
517 const { target, lines } = await current($)
518 if (target || lines.length) {
519 void $.ui.open({ id: PANE, title: TITLE })
520 void refresh($)
521 }
522 shownStatus = undefined
523 await quietly(() => syncStatus($))
524 return next(e)
525 })
526
527 // `/clear` ends the conversation, and the Run with it; compact, resume and fork keep it.
528 on('session.end', async ($, e, next) => {
529 if (e.reason === 'clear') await quietly(() => endRun($))
530 return next(e)
531 })
532
533 on('command.run', { command: 'waymap' }, async ($, e) => {
534 const arg = e.args.trim()
535 if (arg === 'clear') {
536 await update($, way, () => EMPTY)
537 await endRun($)
538 return { text: 'Wayfinder pane cleared.' }
539 }
540 await $.ui.open({ id: PANE, title: TITLE })
541 if (arg === 'refresh' || arg === '') {
542 const { target } = await current($)
543 if (target) {
544 await refresh($)
545 return { text: 'Wayfinder pane opened.' }
546 }
547 const found = await discover($)
548 if (!found) return { text: 'No wayfinder map found here. /waymap <issue> or /waymap .scratch/<effort> names one.' }
549 await follow($, found)
550 return { text: `Following the map at ${found.source === 'github' ? `#${found.ref}` : found.ref}.` }
551 }
552 const named = targetIn(arg)
553 if (!named) return { text: 'Usage: /waymap [<issue number or URL> | .scratch/<effort> | refresh | clear]' }
554 await follow($, named)
555 return { text: `Following the map at ${named.source === 'github' ? `#${named.ref}` : named.ref}.` }
556 })
557
558 // /wayfinder starts a Run: with a map it works through it; with a loose idea it charts one, found once it is created.
559 // A grill the person asks for by name ends it.
560 on('skill.prompt', async ($, e, next) => {
561 const name = e.skill.replace(/^.*:/, '')
562 if (name === 'wayfinder')
563 await quietly(async () => {
564 await update($, running, r => r ?? { ticket: null, claimedAt: null })
565 const args = /ARGUMENTS:\s*(.*)$/m.exec(e.text)?.[1] ?? ''
566 const named = targetIn(args)
567 if (named) await follow($, named)
568 else void $.ui.open({ id: PANE, title: TITLE })
569 await syncStatus($)
570 })
571 if (name === 'grill-me' || name === 'grill-with-docs') await quietly(() => endRun($))
572 return next(e)
573 })
574
575 // The person's reply: the answers to the newest Level still waiting, or their word on the Wrap-up.
576 on('prompt.submit', async ($, e, next) => {
577 if (!e.command && !e.isMeta && e.text.trim())
578 await quietly(async () => {
579 const text = e.text.trim()
580 await record($, (lines, ticket) => {
581 const last = lastTurn(linesOf(lines, ticket))
582 if (last?.kind === 'round' && last.reply === undefined)
583 return mapLine(lines, last.id, l => ({ ...applyReply(last, text), ticket: l.ticket }))
584 if (last?.kind === 'wrap' && last.reply === undefined)
585 return mapLine(lines, last.id, l => ({ ...settleWrap(last, text), ticket: l.ticket }))
586 return lines
587 })
588 await syncStatus($)
589 })
590 return next(e)
591 })
592
593 // A Level is a main-loop turn that ends on questions; one that ends without them, once answered, is the Wrap-up.
594 on('turn.complete', async ($, e, next) => {
595 const done = await next(e)
596 if (!e.agentId && e.reason === 'answer')
597 await quietly(async () => {
598 const questions = parseRound(e.answer)
599 if (questions.length)
600 return addLevel($, { kind: 'round', id: newId('r'), at: Date.now(), via: 'grilling', questions })
601 await record($, (lines, ticket) =>
602 isWrapDue(linesOf(lines, ticket))
603 ? [...lines, { kind: 'wrap', id: newId('w'), at: Date.now(), text: cut(plain(e.answer), 600), ticket }]
604 : lines,
605 )
606 await syncStatus($)
607 })
608 return done
609 })
610
611 on('tool.call', async ($, e, next) => {
612 if (e.agentId) return next(e)
613 const isRun = (await runOf($)) !== null
614
615 // A fact the agent sends a sub-agent for: one Trace, running until the sub-agent reports.
616 let agent: string | null = null
617 if (isRun && e.tool === 'Agent') {
618 const id = newId('l')
619 agent = id
620 await quietly(() =>
621 record($, (lines, ticket, map) =>
622 keepsLookups(ticket, map)
623 ? [...lines, { kind: 'lookup', id, at: Date.now(), label: plain(e.description), isAgent: true, count: 1, isDone: false, ticket }]
624 : lines,
625 ),
626 )
627 }
628
629 const path = e.tool === 'Write' || e.tool === 'Edit' ? e.file_path : ''
630 const isDoc = isRun && isDocPath(path)
631 const before = e.tool === 'Write' && isDoc ? await $.fs.read(path).catch(() => null) : null
632 const wasClaimed = new Set(((await current($)).map?.tickets ?? []).filter(t => t.isClaimed).map(t => t.id))
633
634 const ran = await next(e)
635 if (agent) {
636 const id = agent
637 await quietly(() => record($, lines => mapLine(lines, id, l => (l.kind === 'lookup' ? { ...l, isDone: true } : l))))
638 }
639 if (('deny' in ran && ran.deny) || ran.isError) return ran
640
641 if (isRun && e.tool === 'AskUserQuestion')
642 await quietly(() => addLevel($, dialogRound(e.questions, ran.result, newId('r'), Date.now())))
643
644 // Reads and searches between Levels fold into one lookup.
645 const looked = isRun ? lookupOf(e.tool, e as unknown as Record<string, unknown>) : null
646 if (looked !== null)
647 await quietly(() =>
648 record($, (lines, ticket, map) => {
649 if (!keepsLookups(ticket, map)) return lines
650 const last = lastOf(lines, ticket)
651 const label = cut(looked, 60)
652 return foldsInto(last)
653 ? mapLine(lines, last.id, l => (l.kind === 'lookup' ? { ...l, count: l.count + 1, label } : l))
654 : [...lines, { kind: 'lookup', id: newId('l'), at: Date.now(), label, isAgent: false, count: 1, isDone: true, ticket }]
655 }),
656 )
657
658 // The glossary's resolved terms and the ADRs, as they are written.
659 if (isDoc && (e.tool === 'Write' || e.tool === 'Edit'))
660 await quietly(async () => {
661 const trace =
662 e.tool === 'Write'
663 ? docTraceOf(path, e.content, before, false, newId('d'), Date.now())
664 : docTraceOf(path, e.new_string, e.old_string, true, newId('d'), Date.now())
665 if (!trace) return
666 await record($, (lines, ticket) => {
667 const last = lastOf(lines, ticket)
668 return trace.kind === 'term' && termsFoldInto(last, path)
669 ? mapLine(lines, last.id, l => (l.kind === 'term' ? { ...l, terms: [...new Set([...l.terms, ...trace.terms])] } : l))
670 : [...lines, { ...trace, ticket }]
671 })
672 })
673
674 if (e.tool === 'Write' || e.tool === 'Edit')
675 await quietly(async () => {
676 const m = SCRATCH.exec(e.file_path)
677 if (!m) return
678 const text = e.tool === 'Write' ? e.content : e.new_string
679 const id = m[2] ? String(Number(m[2])) : null
680 await follow($, { source: 'local', ref: m[1] ?? '' })
681 if (id && /^\s*\**status\**:?\**\s*claimed/im.test(text)) await claim($, id)
682 })
683
684 if (e.tool === 'Bash')
685 await quietly(async () => {
686 const cmd = e.command
687 const out = JSON.stringify(ran.result ?? '')
688 const { target } = await current($)
689 if (/wayfinder:map/.test(cmd) && /gh issue create/.test(cmd)) {
690 const made = /issues\/(\d+)/.exec(out)?.[1]
691 if (made) return follow($, { source: 'github', ref: made })
692 }
693 const claimed = /gh issue edit\s+#?(\d+)[^|;&]*--add-assignee/.exec(cmd)?.[1]
694 const touches =
695 target?.source === 'github'
696 ? /\bgh (issue|api)\b/.test(cmd) && !/gh issue (view|list)\b/.test(cmd)
697 : target?.source === 'local' && /\.scratch\//.test(cmd)
698 if (!touches) return
699 if (target?.source === 'github' && !claimed) return void refresh($)
700 await refresh($)
701 if (claimed) return claim($, claimed)
702 // A local claim made from the shell: a ticket file the command named, claimed now and not before.
703 if (target?.source === 'local') {
704 const named = new Set([...cmd.matchAll(/issues\/(\d+)/g)].map(n => String(Number(n[1]))))
705 const now = (await current($)).map?.tickets.find(t => named.has(t.id) && t.isClaimed && !t.isClosed && !wasClaimed.has(t.id))
706 if (now) await claim($, now.id)
707 }
708 })
709
710 return ran
711 })
712
713 // The graph moved its selection: the ticket's card follows it.
714 on('ui.message', { element: 'graph' }, async ($, e) => {
715 const data = e.data as Partial<GraphMessage> | null
716 if (data && typeof data === 'object' && 'focus' in data) {
717 const focus = typeof data.focus === 'string' ? data.focus : null
718 await update($, way, w => ({ ...w, focus }))
719 if (focus) await quietly(() => fetchAnswer($, focus))
720 }
721 return {}
722 })
723
724 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
725 const elements = $.ui.resolve(e)
726 const { Box, Text, Button, Link } = elements
727 // The graph needs a surface module's region: the terminal and the desktop draw one.
728 const Client =
729 (e.surface === 'terminal' || e.surface === 'desktop') && 'Client' in elements ? elements.Client : undefined
730 const el = { Box, Text, Button }
731 const w = await current($)
732 const run = await runOf($)
733 const mine = run?.ticket?.id ?? null
734 const now = Date.now()
735 const inner = Math.max(24, e.props.bodyColumns - 4)
736 const map = w.map
737 const all = map?.tickets ?? []
738 const tickets = all.filter(t => !t.isOutOfScope)
739 const byId = new Map(all.map(t => [t.id, t]))
740 const name = (id: string) => byId.get(id)?.title ?? `#${id}`
741 const isOpen = (id: string) => byId.has(id) && !byId.get(id)?.isClosed
742
743 const stateOf = (t: Ticket): State =>
744 t.isClosed ? 'resolved' : t.isClaimed ? 'claimed' : t.blockedBy.some(isOpen) ? 'blocked' : 'frontier'
745 // The skill takes the first frontier ticket in map order.
746 const next = tickets.find(t => stateOf(t) === 'frontier')
747 const count = (s: State) => tickets.filter(t => stateOf(t) === s).length
748
749 const pill = (text: string, bg: string, fg = INK, bold = false) => (
750 <Text backgroundColor={bg} color={fg} bold={bold}>
751 {` ${text} `}
752 </Text>
753 )
754 const heading = (title: string, right?: RenderChildren) => (
755 <Box flexDirection="row" justifyContent="space-between">
756 <Text>
757 <Text color={ACCENT}>◆ </Text>
758 <Text bold>{title}</Text>
759 </Text>
760 {right}
761 </Box>
762 )
763 const card = (key: string, children: RenderChildren) => (
764 <Box key={key} flexDirection="column" borderStyle="round" borderColor={EDGE} paddingX={1}>
765 {children}
766 </Box>
767 )
768 const typeChip = (t: Ticket) =>
769 t.type ? <Text color={TYPE_COLOR[t.type]}>{t.type}</Text> : <Text dimColor>untyped</Text>
770
771 const bar = () => {
772 if (!tickets.length) return <Text backgroundColor={TRACK}>{' '.repeat(inner)}</Text>
773 let left = inner
774 const cells = LEGEND.map(({ state }, i) => {
775 const width = i === LEGEND.length - 1 ? left : Math.round((count(state) / tickets.length) * inner)
776 left -= width
777 return { state, width: Math.max(0, width) }
778 })
779 return (
780 <Box flexDirection="row">
781 {cells
782 .filter(c => c.width > 0)
783 .map(c => (
784 <Text key={`bar-${c.state}`} backgroundColor={STATE_COLOR[c.state]}>
785 {' '.repeat(c.width)}
786 </Text>
787 ))}
788 </Box>
789 )
790 }
791
792 // ── route: the tickets as a tree, each under the deepest ticket that blocks it ──
793 const depth = new Map<string, number>()
794 const depthOf = (t: Ticket, seen = new Set<string>()): number => {
795 const known = depth.get(t.id)
796 if (known !== undefined) return known
797 if (seen.has(t.id)) return 0
798 seen.add(t.id)
799 const parents = t.blockedBy.map(id => byId.get(id)).filter((p): p is Ticket => !!p && !p.isOutOfScope)
800 const d = parents.length ? 1 + Math.max(...parents.map(p => depthOf(p, seen))) : 0
801 depth.set(t.id, d)
802 return d
803 }
804 const parentOf = new Map<string, string>()
805 for (const t of tickets) {
806 const parents = t.blockedBy.map(id => byId.get(id)).filter((p): p is Ticket => !!p && !p.isOutOfScope)
807 const deepest = parents.sort((a, b) => depthOf(b) - depthOf(a))[0]
808 if (deepest && depthOf(deepest) < depthOf(t)) parentOf.set(t.id, deepest.id)
809 }
810 const childrenOf = (id: string | null) => tickets.filter(t => (parentOf.get(t.id) ?? null) === id)
811 const rows: { t: Ticket; prefix: string }[] = []
812 const walk = (id: string | null, lead: string) => {
813 const kids = childrenOf(id)
814 kids.forEach((t, i) => {
815 const isLast = i === kids.length - 1
816 rows.push({ t, prefix: `${lead}${isLast ? '└─ ' : '├─ '}` })
817 walk(t.id, `${lead}${isLast ? ' ' : '│ '}`)
818 })
819 }
820 walk(null, '')
821
822 // ── The Spines: each ticket's in its card, Charting's in the map card ──
823 const turn = (id: string) =>
824 update($, way, g => ({ ...g, turned: g.turned.includes(id) ? g.turned.filter(k => k !== id) : [...g.turned, id] }))
825 const spineBlock = (title: string, steps: Step[], keyPrefix: string, isClosed: boolean, isLive: boolean) => (
826 <Box flexDirection="column" marginTop={1}>
827 {spineHeading(el, title, steps, isClosed)}
828 {spineRows(el, {
829 steps,
830 keyPrefix,
831 isClosed,
832 isLive,
833 isAllUnfolded: w.isAllUnfolded,
834 turned: w.turned,
835 onTurn: turn,
836 width: inner,
837 now,
838 })}
839 </Box>
840 )
841 // Once the map has tickets, Charting is folded as a closed ticket's, unless a Level came since the last claim.
842 const charting = linesOf(w.lines, null)
843 const chartingLevel = charting.filter(l => l.kind === 'round').at(-1)?.at ?? 0
844 const isChartingLive = !!run && !run.ticket && (!tickets.length || chartingLevel > (run.claimedAt ?? 0))
845
846 const toggleFocus = async (id: string) => {
847 await update($, way, g => ({ ...g, focus: g.focus === id ? null : id }))
848 await quietly(() => fetchAnswer($, id))
849 }
850 const noteOf = (t: Ticket, s: State) => {
851 if (s === 'claimed') return t.id === mine ? 'you' : 'claimed'
852 if (s === 'blocked') {
853 const open = t.blockedBy.filter(isOpen)
854 return `after ${cut(name(open[0] ?? ''), 18)}${open.length > 1 ? ` +${open.length - 1}` : ''}`
855 }
856 if (s === 'frontier') return t === next ? 'next' : 'ready'
857 return ''
858 }
859
860 const routeRow = ({ t, prefix }: { t: Ticket; prefix: string }, i: number) => {
861 const s = stateOf(t)
862 const note = noteOf(t, s)
863 const isFocused = w.focus === t.id
864 const digit = i < 9 ? String(i + 1) : undefined
865 const extra = t.blockedBy.filter(id => id !== parentOf.get(t.id) && byId.has(id)).length
866 return (
867 <Box key={`row-${t.id}`} flexDirection="row" justifyContent="space-between">
868 <Box flexShrink={1}>
869 <Button key={`focus-${t.id}`} plain {...(digit ? { hotkey: digit } : {})} onPress={() => toggleFocus(t.id)}>
870 <Text wrap="truncate-end" bold={isFocused} dimColor={s === 'resolved' || s === 'blocked'}>
871 <Text color={EDGE}>{prefix}</Text>
872 <Text color={STATE_COLOR[s]}>{isFocused ? '◉' : STATE_MARK[s]} </Text>
873 {t.title}
874 {extra > 0 ? <Text dimColor> ⋯{extra}</Text> : ''}
875 </Text>
876 </Button>
877 </Box>
878 <Box flexShrink={0} marginLeft={1}>
879 <Text>
880 {typeChip(t)}
881 {note === 'next' || note === 'you' ? (
882 <Text> {pill(note, STATE_COLOR[s])}</Text>
883 ) : note ? (
884 <Text dimColor> · {note}</Text>
885 ) : (
886 ''
887 )}
888 </Text>
889 </Box>
890 </Box>
891 )
892 }
893
894 const ticketCard = (t: Ticket) => {
895 const s = stateOf(t)
896 const blocks = tickets.filter(x => x.blockedBy.includes(t.id))
897 const steps = linesOf(w.lines, t.id)
898 return (
899 <Box flexDirection="column">
900 {heading(
901 cut(t.title, inner - 18),
902 <Text>
903 {typeChip(t)}
904 {t.type ? <Text dimColor> · {MODE[t.type]}</Text> : ''}
905 </Text>,
906 )}
907 <Text wrap="truncate-end">
908 <Text color={STATE_COLOR[s]}>{STATE_MARK[s]} {s}</Text>
909 {t.blockedBy.length ? <Text dimColor> · after {t.blockedBy.map(name).join(', ')}</Text> : ''}
910 </Text>
911 {blocks.length > 0 && (
912 <Text dimColor wrap="truncate-end">
913 unblocks {blocks.map(b => b.title).join(', ')}
914 </Text>
915 )}
916 {t.question && (
917 <Box flexDirection="column" marginTop={1}>
918 <Text bold>Question</Text>
919 <Text wrap="wrap">{cut(t.question, inner * 5)}</Text>
920 </Box>
921 )}
922 {t.isClosed && (
923 <Box flexDirection="column" marginTop={1}>
924 <Text bold color={STATE_COLOR.resolved}>
925 Answer
926 </Text>
927 <Text wrap="wrap">{t.answer ? cut(t.answer, inner * 6) : 'Reading the resolution…'}</Text>
928 </Box>
929 )}
930 {steps.length > 0 && spineBlock('Spine', steps, `${t.id}-`, t.isClosed, !t.isClosed && t.id === mine)}
931 <Box flexDirection="row" columnGap={1} marginTop={1} justifyContent="space-between">
932 <Box flexShrink={1}>
933 {/^https?:/.test(t.link) ? (
934 <Link href={t.link} label={`#${t.id}`} />
935 ) : (
936 <Text dimColor wrap="truncate-start">
937 {t.link}
938 </Text>
939 )}
940 </Box>
941 <Button key="unfocus" label="Close" hotkey="x" onPress={() => toggleFocus(t.id)} />
942 </Box>
943 </Box>
944 )
945 }
946
947 const focused = w.focus ? byId.get(w.focus) : undefined
948 const tree = (
949 <Box flexDirection="column">
950 {heading('route', <Text dimColor>{next ? `next: ${cut(next.title, 24)}` : tickets.length ? 'no frontier' : ''}</Text>)}
951 <Text wrap="truncate-end">
952 <Text color={ACCENT}>◎ </Text>
953 <Text bold>{map?.destination ? map.destination : 'destination'}</Text>
954 </Text>
955 {rows.length === 0 && <Text dimColor>No tickets yet.</Text>}
956 {rows.map(routeRow)}
957 {rows.length > 0 && !focused && <Text dimColor>1–{Math.min(9, rows.length)} opens a ticket</Text>}
958 {(map?.fog.length ?? 0) > 0 && (
959 <Box flexDirection="column" marginTop={1}>
960 <Text color={FOG} bold>
961 ≈ not yet specified
962 </Text>
963 {map?.fog.map((f, i) => (
964 <Text key={`fog-${i}`} color={FOG} italic wrap="truncate-end">
965 {' '}≈ {f}
966 </Text>
967 ))}
968 </Box>
969 )}
970 </Box>
971 )
972
973 // Steps through the tickets row by row, without giving the graph the keyboard.
974 const order = [...tickets].sort((a, b) => depthOf(a) - depthOf(b))
975 const step = async (by: number) => {
976 const at = order.findIndex(t => t.id === w.focus)
977 const t = order[at < 0 ? 0 : Math.min(order.length - 1, Math.max(0, at + by))]
978 if (!t) return
979 await update($, way, g => ({ ...g, focus: t.id }))
980 await quietly(() => fetchAnswer($, t.id))
981 }
982 const graphProps: GraphProps = {
983 nodes: tickets.map(t => ({
984 id: t.id,
985 title: t.title,
986 type: t.type,
987 state: stateOf(t),
988 layer: depthOf(t) + 1,
989 isNext: t === next,
990 isMine: t.id === mine,
991 })),
992 edges: tickets.flatMap(t =>
993 t.blockedBy
994 .filter(id => byId.has(id) && !byId.get(id)?.isOutOfScope)
995 .map(id => [id, t.id] as [string, string]),
996 ),
997 start: map?.title ?? 'A foggy idea, with no route to it yet',
998 destination: map?.destination || 'Not named yet',
999 selected: w.focus,
1000 columns: inner,
1001 }
1002 const fog =
1003 (map?.fog.length ?? 0) > 0 ? (
1004 <Box flexDirection="column" marginTop={1}>
1005 <Text color={FOG} bold>
1006 ≈ not yet specified
1007 </Text>
1008 {map?.fog.map((f, i) => (
1009 <Text key={`fog-${i}`} color={FOG} italic wrap="truncate-end">
1010 {' '}≈ {f}
1011 </Text>
1012 ))}
1013 </Box>
1014 ) : null
1015 const route = Client ? (
1016 <Box flexDirection="column">
1017 {heading('map', <Text dimColor>{next ? `next: ${cut(next.title, 24)}` : tickets.length ? 'no frontier' : ''}</Text>)}
1018 <Client key="graph" module="./graph.tsx" props={graphProps} width={inner} />
1019 {fog}
1020 {tickets.length > 0 && (
1021 <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
1022 <Text dimColor wrap="truncate-end">
1023 click the map, then ←↑↓→ · ⏎ card
1024 </Text>
1025 <Box flexDirection="row" columnGap={1} flexShrink={0}>
1026 <Button key="prev" label="‹ prev" hotkey="p" onPress={() => step(-1)} />
1027 <Button key="next" label="next ›" hotkey="n" onPress={() => step(1)} />
1028 </Box>
1029 </Box>
1030 )}
1031 </Box>
1032 ) : (
1033 tree
1034 )
1035
1036 const decisions = (
1037 <Box flexDirection="column">
1038 {heading('decisions so far', <Text dimColor>{map?.decisions.length ?? 0}</Text>)}
1039 {!map?.decisions.length && <Text dimColor>None yet: each resolved ticket adds one.</Text>}
1040 {map?.decisions.map((d, i) => (
1041 <Box key={`dec-${i}`} flexDirection="column">
1042 <Text wrap="truncate-end">
1043 <Text color={STATE_COLOR.resolved}>✓ </Text>
1044 <Text bold>{d.title}</Text>
1045 </Text>
1046 {d.gist && (
1047 <Text wrap="wrap" dimColor>
1048 {' '}
1049 {d.gist}
1050 </Text>
1051 )}
1052 </Box>
1053 ))}
1054 {(map?.outOfScope.length ?? 0) > 0 && (
1055 <Box flexDirection="column" marginTop={1}>
1056 {heading('out of scope', <Text dimColor>{map?.outOfScope.length}</Text>)}
1057 {map?.outOfScope.map((d, i) => (
1058 <Text key={`oos-${i}`} dimColor wrap="truncate-end">
1059 ⊘ <Text strikethrough>{d.title}</Text>
1060 {d.gist ? ` · ${d.gist}` : ''}
1061 </Text>
1062 ))}
1063 </Box>
1064 )}
1065 {map?.notes && (
1066 <Box flexDirection="column" marginTop={1}>
1067 {heading('notes')}
1068 <Text wrap="wrap" dimColor>
1069 {cut(map.notes, inner * 4)}
1070 </Text>
1071 </Box>
1072 )}
1073 </Box>
1074 )
1075
1076 const tab = (key: Tab, label: string, hotkey: string, n?: number) => (
1077 <Button key={`tab-${key}`} plain hotkey={hotkey} onPress={() => update($, way, g => ({ ...g, tab: key }))}>
1078 {w.tab === key ? (
1079 pill(`${label}${n ? ` ${n}` : ''}`, ACCENT, INK, true)
1080 ) : (
1081 <Text dimColor>{` ${label}${n ? ` ${n}` : ''} `}</Text>
1082 )}
1083 </Button>
1084 )
1085 const resolved = count('resolved')
1086
1087 return (
1088 <Box flexDirection="column">
1089 {header(el, PANE, e.props.bodyColumns)}
1090 {card(
1091 'map',
1092 <Box flexDirection="column">
1093 {heading(
1094 'wayfinder',
1095 <Text>
1096 <Text dimColor>
1097 {w.target ? (w.target.source === 'github' ? `map #${w.target.ref}` : w.target.ref.split('/').pop()) : 'no map'}{' '}
1098 </Text>
1099 {tickets.length > 0 &&
1100 pill(
1101 `${resolved}/${tickets.length}`,
1102 resolved === tickets.length ? STATE_COLOR.resolved : TRACK,
1103 resolved === tickets.length ? INK : 'white',
1104 true,
1105 )}
1106 </Text>,
1107 )}
1108 {map ? (
1109 <Text bold wrap="truncate-end">
1110 {map.title}
1111 </Text>
1112 ) : (
1113 <Text dimColor wrap="wrap">
1114 {charting.length
1115 ? 'Charting: the map is followed once it is created.'
1116 : '/waymap finds the map here, or /waymap <issue | .scratch/effort>'}
1117 </Text>
1118 )}
1119 {map?.destination && (
1120 <Text wrap="wrap">
1121 <Text color={ACCENT}>◎ </Text>
1122 {cut(map.destination, inner * 2)}
1123 </Text>
1124 )}
1125 {charting.length > 0 &&
1126 spineBlock('Spine · charting', charting, 'charting-', tickets.length > 0 && !isChartingLive, !!run && !run.ticket)}
1127 {tickets.length > 0 && (
1128 <Box flexDirection="column" marginTop={1}>
1129 {bar()}
1130 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1131 {LEGEND.filter(l => count(l.state) > 0).map(l => (
1132 <Text key={`legend-${l.state}`}>
1133 <Text color={STATE_COLOR[l.state]}>{STATE_MARK[l.state]} </Text>
1134 {l.label} <Text bold>{count(l.state)}</Text>
1135 </Text>
1136 ))}
1137 {(map?.fog.length ?? 0) > 0 && (
1138 <Text>
1139 <Text color={FOG}>≈ </Text>
1140 fog <Text bold>{map?.fog.length}</Text>
1141 </Text>
1142 )}
1143 </Box>
1144 </Box>
1145 )}
1146 <Box flexDirection="row" columnGap={1} marginTop={1}>
1147 {tab('route', 'map', 'm')}
1148 {tab('decisions', 'decisions', 'd', map?.decisions.length)}
1149 </Box>
1150 </Box>,
1151 )}
1152
1153 {card(w.tab, w.tab === 'route' ? route : decisions)}
1154 {w.tab === 'route' && focused && card('ticket', ticketCard(focused))}
1155
1156 {w.error && <Text color="#e5737b"> {w.target?.source === 'github' ? 'gh' : 'read'}: {w.error}</Text>}
1157 <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
1158 <Box flexDirection="row" columnGap={1}>
1159 {(charting.length > 0 || (w.tab === 'route' && focused && linesOf(w.lines, focused.id).length > 0)) &&
1160 foldAllButton(el, w.isAllUnfolded, () => update($, way, g => ({ ...g, isAllUnfolded: !g.isAllUnfolded, turned: [] })))}
1161 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($)} />
1162 <Button
1163 key="clear"
1164 label="Clear"
1165 hotkey="c"
1166 onPress={async () => {
1167 await update($, way, () => EMPTY)
1168 await endRun($)
1169 }}
1170 />
1171 </Box>
1172 {w.refreshedAt !== null && <Text dimColor>↻ {since(w.refreshedAt, now)} ago</Text>}
1173 </Box>
1174 </Box>
1175 )
1176 })
1177}
1178
1179hooks/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}
415hooks/graph.tsx 455 lines1import type { ClientSurface, RenderElement } from 'claude-code'
2
3import type { GraphNode, GraphProps, NodeState, TicketType } from '../types'
4
5// The map drawn as the wayfinder article draws it: Start above, Destination below,
6// each ticket a box under the tickets that block it, arrows along the blocking edges.
7
8type GraphState = { sel: string | null; propSel: string | null; scroll: number }
9
10const ACCENT = '#d97757'
11const ARROW = '#a8a8a8'
12const DIM = '#5a5a5a'
13const TYPE_COLOR: Record<TicketType, string> = {
14 grilling: '#e5737b',
15 prototype: '#7fb4f5',
16 research: '#b79cf5',
17 task: '#e8a05c',
18}
19// One cell each, so the boxes line up in every terminal.
20const ICON: Record<TicketType, string> = { grilling: '?', prototype: '◫', research: '⌕', task: '≡' }
21const STATE_COLOR: Record<NodeState, string> = {
22 resolved: '#5ccf9f',
23 claimed: ACCENT,
24 frontier: '#e2b867',
25 blocked: DIM,
26}
27const MARK: Record<NodeState, string> = { resolved: '✓', claimed: '●', frontier: '◇', blocked: '○' }
28
29const ROUND = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', tj: '┬', bj: '┴', out: '┬' }
30const DOUBLE = { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║', tj: '╦', bj: '╩', out: '╤' }
31
32const GAP = 2
33const NODE_ROWS = 3
34
35type Kind = 'node' | 'dummy' | 'start' | 'dest'
36type Item = { key: string; kind: Kind; node: GraphNode | null; w: number; x: number; c: number }
37type Seg = { from: Item; to: Item; isLit: boolean }
38type Cell = { ch: string; color: string; bold: boolean; dim: boolean }
39
40// Line directions a cell joins, merged into one box-drawing character.
41const U = 1
42const D = 2
43const L = 4
44const R = 8
45const JOIN: Record<number, string> = {
46 [U]: '│',
47 [D]: '│',
48 [U | D]: '│',
49 [L]: '─',
50 [R]: '─',
51 [L | R]: '─',
52 [D | R]: '╭',
53 [D | L]: '╮',
54 [U | R]: '╰',
55 [U | L]: '╯',
56 [U | D | R]: '├',
57 [U | D | L]: '┤',
58 [L | R | D]: '┬',
59 [L | R | U]: '┴',
60 [U | D | L | R]: '┼',
61}
62
63const wrapText = (text: string, room: number, max: number) => {
64 const lines: string[] = []
65 let line = ''
66 for (const word of text.split(/\s+/).filter(Boolean)) {
67 if (line && line.length + 1 + word.length > room) {
68 lines.push(line)
69 line = ''
70 }
71 line = line ? `${line} ${word}` : word
72 }
73 if (line) lines.push(line)
74 if (lines.length > max) {
75 const kept = lines.slice(0, max)
76 kept[max - 1] = `${(kept[max - 1] ?? '').slice(0, room - 1)}…`
77 return kept
78 }
79 return lines.map(l => (l.length > room ? `${l.slice(0, room - 1)}…` : l))
80}
81
82export type Layout = {
83 width: number
84 rows: Cell[][]
85 boxes: { id: string; x: number; y: number; w: number; layer: number; c: number }[]
86}
87
88export const layout = (props: GraphProps, sel: string | null, columns: number): Layout => {
89 const nodes = props.nodes
90 const byId = new Map(nodes.map(n => [n.id, n]))
91 const edges = props.edges.filter(([a, b]) => byId.has(a) && byId.has(b) && a !== b)
92 const destLayer = Math.max(0, ...nodes.map(n => n.layer)) + 1
93 const hasIn = new Set(edges.map(([, b]) => b))
94 const hasOut = new Set(edges.map(([a]) => a))
95
96 const start: Item = { key: 'start', kind: 'start', node: null, w: 9, x: 0, c: 0 }
97 const dest: Item = { key: 'dest', kind: 'dest', node: null, w: 15, x: 0, c: 0 }
98 const layers: Item[][] = Array.from({ length: destLayer + 1 }, () => [])
99 layers[0] = [start]
100 layers[destLayer] = [dest]
101 const items = new Map<string, Item>([
102 ['start', start],
103 ['dest', dest],
104 ])
105 for (const n of nodes) {
106 const item: Item = { key: n.id, kind: 'node', node: n, w: 0, x: 0, c: 0 }
107 items.set(n.id, item)
108 layers[n.layer]?.push(item)
109 }
110 const layerOf = (key: string) => (key === 'start' ? 0 : key === 'dest' ? destLayer : (byId.get(key)?.layer ?? 0))
111 const lanes = new Map<string, number>()
112 const layerIndex = (it: Item) => (it.kind === 'dummy' ? (lanes.get(it.key) ?? 0) : layerOf(it.key))
113
114 // Start leads to every ticket nothing blocks; every ticket nothing waits on leads to the destination.
115 const full: [string, string][] = [
116 ...nodes.filter(n => !hasIn.has(n.id)).map(n => ['start', n.id] as [string, string]),
117 ...edges,
118 ...nodes.filter(n => !hasOut.has(n.id)).map(n => [n.id, 'dest'] as [string, string]),
119 ]
120 if (!nodes.length) full.push(['start', 'dest'])
121
122 // An edge across several rows runs through a one-cell lane in each row between.
123 const segs: { from: string; to: string; isLit: boolean }[] = []
124 for (const [a, b] of full) {
125 const isLit = sel !== null && (a === sel || b === sel)
126 let prev = a
127 for (let l = layerOf(a) + 1; l < layerOf(b); l++) {
128 const key = `lane:${a}>${b}:${l}`
129 const lane: Item = { key, kind: 'dummy', node: null, w: 1, x: 0, c: 0 }
130 items.set(key, lane)
131 lanes.set(key, l)
132 layers[l]?.push(lane)
133 segs.push({ from: prev, to: key, isLit })
134 prev = key
135 }
136 segs.push({ from: prev, to: b, isLit })
137 }
138
139 // Order each row by where its predecessors sit, then by where its successors sit, twice.
140 const preds = new Map<string, string[]>()
141 const succs = new Map<string, string[]>()
142 for (const s of segs) {
143 preds.set(s.to, [...(preds.get(s.to) ?? []), s.from])
144 succs.set(s.from, [...(succs.get(s.from) ?? []), s.to])
145 }
146 const place = new Map<string, number>()
147 const mark = (row: Item[]) => row.forEach((it, i) => place.set(it.key, (i + 0.5) / row.length))
148 layers.forEach(mark)
149 const sweep = (l: number, near: Map<string, string[]>) => {
150 const row = layers[l] ?? []
151 const at = (it: Item) => {
152 const ps = (near.get(it.key) ?? []).map(k => place.get(k) ?? 0.5)
153 return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : (place.get(it.key) ?? 0.5)
154 }
155 const ranked = row.map((it, i) => ({ it, i, at: at(it) }))
156 ranked.sort((a, b) => a.at - b.at || a.i - b.i)
157 layers[l] = ranked.map(r => r.it)
158 mark(layers[l] ?? [])
159 }
160 for (let pass = 0; pass < 2; pass++) {
161 for (let l = 1; l <= destLayer; l++) sweep(l, preds)
162 for (let l = destLayer - 1; l >= 1; l--) sweep(l, succs)
163 }
164
165 // As wide as the widest row lets every box be, and never narrower than a readable box.
166 let w = 28
167 for (const row of layers) {
168 const boxes = row.filter(it => it.kind === 'node').length
169 if (!boxes) continue
170 const lanes = row.length - boxes
171 w = Math.min(w, Math.floor((columns - (row.length - 1) * GAP - lanes) / boxes))
172 }
173 w = Math.max(14, w)
174 for (const it of items.values()) if (it.kind === 'node') it.w = w
175 const rowWidth = (row: Item[]) => row.reduce((sum, it) => sum + it.w, 0) + GAP * Math.max(0, row.length - 1)
176 const width = Math.max(columns, ...layers.map(rowWidth))
177 // Each box as near as its row allows to under its predecessors, the row kept in order and in the canvas.
178 const mid = Math.floor(width / 2)
179 for (const row of layers) {
180 const wanted = row.map((it, i) => {
181 if (it.kind === 'start' || it.kind === 'dest') return mid
182 const ps = (preds.get(it.key) ?? []).map(k => items.get(k)?.c ?? mid)
183 const even = Math.floor((width - rowWidth(row)) / 2) + rowWidth(row.slice(0, i)) + (i ? GAP : 0) + Math.floor(it.w / 2)
184 return ps.length ? Math.round(ps.reduce((a, b) => a + b, 0) / ps.length) : even
185 })
186 let right = -GAP
187 const xs = row.map((it, i) => {
188 const x = Math.max((wanted[i] ?? mid) - Math.floor(it.w / 2), right + GAP)
189 right = x + it.w
190 return x
191 })
192 // Shift the row by how far it sits from where it wanted to be, as far as the canvas allows.
193 const off = Math.round(row.reduce((sum, it, i) => sum + ((wanted[i] ?? mid) - Math.floor(it.w / 2) - (xs[i] ?? 0)), 0) / Math.max(1, row.length))
194 const lo = -(xs[0] ?? 0)
195 const hi = width - right
196 const shift = Math.max(lo, Math.min(hi, off))
197 row.forEach((it, i) => {
198 it.x = (xs[i] ?? 0) + shift
199 it.c = it.kind === 'start' || it.kind === 'dest' ? mid : it.x + Math.floor(it.w / 2)
200 })
201 }
202
203 // The two ends' words, under their flags.
204 const room = Math.max(16, Math.min(width - 4, 56))
205 const startLines = wrapText(props.start, room, 2)
206 const destLines = wrapText(props.destination, room, 3)
207 const heightOf = (l: number) => (l === 0 ? 1 + startLines.length : l === destLayer ? 1 + destLines.length : NODE_ROWS)
208
209 // Each gap gets one track per fan-out whose span overlaps another's.
210 const gaps: { top: number; tracks: Map<string, number>; rows: number }[] = []
211 let y = 0
212 const tops: number[] = []
213 for (let l = 0; l <= destLayer; l++) {
214 tops[l] = y
215 y += heightOf(l)
216 if (l === destLayer) break
217 const here = segs.filter(s => {
218 const from = items.get(s.from)
219 return from !== undefined && layerIndex(from) === l
220 })
221 const spans = new Map<string, [number, number]>()
222 for (const s of here) {
223 const a = items.get(s.from)
224 const b = items.get(s.to)
225 if (!a || !b || a.c === b.c) continue
226 const was = spans.get(s.from) ?? [a.c, a.c]
227 spans.set(s.from, [Math.min(was[0], b.c), Math.max(was[1], b.c)])
228 }
229 const tracks = new Map<string, number>()
230 const ends: number[] = []
231 for (const [key, [lo, hi]] of [...spans.entries()].sort((p, q) => p[1][0] - q[1][0])) {
232 let t = ends.findIndex(end => end < lo - 1)
233 if (t < 0) {
234 t = ends.length
235 ends.push(hi)
236 } else ends[t] = hi
237 tracks.set(key, t + 1)
238 }
239 gaps[l] = { top: y, tracks, rows: ends.length + 2 }
240 y += ends.length + 2
241 }
242 const height = y
243
244 // The canvas: line joins per cell, then the boxes and words drawn over them.
245 const dirs: number[][] = Array.from({ length: height }, () => new Array<number>(width).fill(0))
246 const lit: boolean[][] = Array.from({ length: height }, () => new Array<boolean>(width).fill(false))
247 const fixed: (Cell | null)[][] = Array.from({ length: height }, () => new Array<Cell | null>(width).fill(null))
248 const join = (row: number, col: number, d: number, isLit: boolean) => {
249 if (row < 0 || row >= height || col < 0 || col >= width) return
250 const line = dirs[row]
251 if (line) line[col] = (line[col] ?? 0) | d
252 if (isLit) {
253 const l2 = lit[row]
254 if (l2) l2[col] = true
255 }
256 }
257 const put = (row: number, col: number, cell: Cell) => {
258 const line = fixed[row]
259 if (line && col >= 0 && col < width) line[col] = cell
260 }
261 const text = (row: number, col: number, s: string, style: Omit<Cell, 'ch'>) =>
262 [...s].forEach((ch, i) => put(row, col + i, { ch, ...style }))
263 const vertical = (col: number, from: number, to: number, isLit: boolean) => {
264 for (let r = from; r <= to; r++) join(r, col, (r > from ? U : 0) | (r < to ? D : 0) | (from === to ? U | D : 0), isLit)
265 }
266
267 const arrows: { row: number; col: number; isLit: boolean }[] = []
268 for (const s of segs) {
269 const a = items.get(s.from)
270 const b = items.get(s.to)
271 if (!a || !b) continue
272 const gap = gaps[layerIndex(a)]
273 if (!gap) continue
274 const first = gap.top
275 const last = gap.top + gap.rows - 1
276 const t = gap.tracks.get(s.from)
277 if (t === undefined || a.c === b.c) {
278 vertical(a.c, first, last, s.isLit)
279 join(first, a.c, U, s.isLit)
280 } else {
281 const track = first + t
282 vertical(a.c, first, track, s.isLit)
283 join(first, a.c, U, s.isLit)
284 const step = b.c > a.c ? 1 : -1
285 join(track, a.c, step > 0 ? R : L, s.isLit)
286 for (let col = a.c + step; col !== b.c; col += step) join(track, col, L | R, s.isLit)
287 join(track, b.c, (step > 0 ? L : R) | D, s.isLit)
288 vertical(b.c, track, last, s.isLit)
289 join(track, b.c, D, s.isLit)
290 }
291 join(last, b.c, D, s.isLit)
292 if (b.kind !== 'dummy') arrows.push({ row: last, col: b.c, isLit: s.isLit })
293 }
294
295 // A lane runs straight through its row.
296 for (const it of items.values()) {
297 if (it.kind !== 'dummy') continue
298 const l = layerIndex(it)
299 const top = tops[l] ?? 0
300 const isLit = segs.some(s => (s.from === it.key || s.to === it.key) && s.isLit)
301 for (let r = top; r < top + heightOf(l); r++) join(r, it.c, U | D, isLit)
302 }
303
304 const boxes: Layout['boxes'] = []
305 for (const row of layers)
306 for (const it of row) {
307 const l = layerIndex(it)
308 const top = tops[l] ?? 0
309 if (it.kind === 'start' || it.kind === 'dest') {
310 const label = it.kind === 'start' ? '⚑ Start' : '⚑ Destination'
311 text(top, it.c - Math.floor(label.length / 2), label, { color: 'white', bold: true, dim: false })
312 const lines = it.kind === 'start' ? startLines : destLines
313 lines.forEach((line, i) =>
314 text(top + 1 + i, Math.floor((width - line.length) / 2), line, { color: ARROW, bold: false, dim: true }),
315 )
316 continue
317 }
318 const n = it.node
319 if (it.kind !== 'node' || !n) continue
320 boxes.push({ id: n.id, x: it.x, y: top, w: it.w, layer: l, c: it.c })
321 const isSel = n.id === sel
322 const color = n.state === 'blocked' ? DIM : n.type ? TYPE_COLOR[n.type] : ARROW
323 const frame = { color: isSel ? 'white' : color, bold: isSel, dim: n.state === 'resolved' && !isSel }
324 const k = isSel ? DOUBLE : ROUND
325 const inner = it.w - 2
326 text(top, it.x, `${k.tl}${k.h.repeat(3)}${k.tj}${k.h.repeat(inner - 4)}${k.tr}`, frame)
327 text(top + 2, it.x, `${k.bl}${k.h.repeat(3)}${k.bj}${k.h.repeat(inner - 4)}${k.br}`, frame)
328 if (segs.some(s => s.from === n.id)) put(top + 2, it.c, { ch: k.out, ...frame })
329 for (const col of [it.x, it.x + 4, it.x + it.w - 1]) put(top + 1, col, { ch: k.v, ...frame })
330 text(top + 1, it.x + 1, ` ${n.type ? ICON[n.type] : '·'} `, { color, bold: true, dim: n.state === 'resolved' && !isSel })
331 const room2 = it.w - 9
332 const mark2 = n.isNext ? '▶' : MARK[n.state]
333 const title = n.title.length > room2 ? `${n.title.slice(0, room2 - 1)}…` : n.title
334 put(top + 1, it.x + 5, { ch: ' ', color, bold: false, dim: false })
335 put(top + 1, it.x + 6, { ch: mark2, color: STATE_COLOR[n.state], bold: true, dim: false })
336 text(top + 1, it.x + 7, ` ${title.padEnd(room2)}`, {
337 color: n.state === 'blocked' ? ARROW : 'white',
338 bold: isSel || n.isMine || n.isNext,
339 dim: n.state === 'resolved' || n.state === 'blocked',
340 })
341 }
342
343 const rows: Cell[][] = []
344 for (let r = 0; r < height; r++) {
345 const line: Cell[] = []
346 for (let col = 0; col < width; col++) {
347 const f = fixed[r]?.[col]
348 if (f) {
349 line.push(f)
350 continue
351 }
352 const d = dirs[r]?.[col] ?? 0
353 const isLit = lit[r]?.[col] ?? false
354 line.push({ ch: d ? (JOIN[d] ?? '┼') : ' ', color: isLit ? ACCENT : ARROW, bold: isLit, dim: false })
355 }
356 rows.push(line)
357 }
358 for (const a of arrows) {
359 const line = rows[a.row]
360 if (line && !fixed[a.row]?.[a.col]) line[a.col] = { ch: '▼', color: a.isLit ? ACCENT : ARROW, bold: a.isLit, dim: false }
361 }
362 return { width, rows, boxes }
363}
364
365/** Where the arrows go from a box: along its edges first, else to the nearest box in that direction. */
366const move = (props: GraphProps, boxes: Layout['boxes'], from: string | null, key: string): string | null => {
367 if (!boxes.length) return null
368 const here = boxes.find(b => b.id === from)
369 if (!here) {
370 const mine = props.nodes.find(n => n.isMine) ?? props.nodes.find(n => n.isNext)
371 return mine?.id ?? boxes[0]?.id ?? null
372 }
373 const nearest = (list: typeof boxes) =>
374 [...list].sort((a, b) => Math.abs(a.c - here.c) - Math.abs(b.c - here.c))[0]?.id ?? here.id
375 if (key === 'left' || key === 'right') {
376 const row = boxes.filter(b => b.layer === here.layer).sort((a, b) => a.x - b.x)
377 const i = row.findIndex(b => b.id === here.id)
378 return row[i + (key === 'left' ? -1 : 1)]?.id ?? here.id
379 }
380 const isDown = key === 'down'
381 const linked = props.edges
382 .filter(([a, b]) => (isDown ? a === here.id : b === here.id))
383 .map(([a, b]) => (isDown ? b : a))
384 const along = boxes.filter(b => linked.includes(b.id))
385 if (along.length) return nearest(along)
386 const layers = [...new Set(boxes.map(b => b.layer))].sort((a, b) => a - b)
387 const next = isDown ? layers.find(l => l > here.layer) : [...layers].reverse().find(l => l < here.layer)
388 return next === undefined ? here.id : nearest(boxes.filter(b => b.layer === next))
389}
390
391const KEYS: Record<string, string> = { left: 'left', right: 'right', up: 'up', down: 'down', h: 'left', l: 'right', k: 'up', j: 'down' }
392
393export default function Graph(props: GraphProps, surface: ClientSurface<GraphState>): RenderElement {
394 const { Box, Text } = surface.elements
395 const st = surface.state
396 // The card the hooks module opened wins; a card closed keeps the box this graph selected.
397 const sel = st && (st.propSel === props.selected || props.selected === null) ? st.sel : props.selected
398 const columns = Math.max(20, surface.columns || props.columns)
399 const { width, rows, boxes } = layout(props, sel, columns)
400
401 // Keep the selected box in view when the map is wider than the pane.
402 const box = boxes.find(b => b.id === sel)
403 let scroll = Math.min(Math.max(0, st?.scroll ?? 0), Math.max(0, width - columns))
404 if (box && box.x < scroll) scroll = box.x
405 if (box && box.x + box.w > scroll + columns) scroll = box.x + box.w - columns
406
407 const choose = (next: string | null, focus: string | null) => {
408 surface.setState({ sel: next, propSel: props.selected, scroll })
409 surface.post({ focus })
410 }
411 surface.onKey(e => {
412 const dir = KEYS[e.key]
413 if (dir) {
414 const next = move(props, boxes, sel, dir)
415 choose(next, next)
416 } else if ((e.key === 'return' || e.key === ' ') && sel) choose(sel, props.selected === sel ? null : sel)
417 })
418 surface.onPointer(e => {
419 if (e.type !== 'down' || e.button !== 'left') return
420 const x = e.x + scroll
421 const hit = boxes.find(b => x >= b.x && x < b.x + b.w && e.y >= b.y && e.y < b.y + NODE_ROWS)
422 if (hit) choose(hit.id, props.selected === hit.id ? null : hit.id)
423 })
424
425 return (
426 <Box flexDirection="column">
427 {rows.map((line, r) => {
428 const cells = line.slice(scroll, scroll + columns)
429 const runs: Cell[] = []
430 for (const cell of cells) {
431 const last = runs[runs.length - 1]
432 if (last && last.color === cell.color && last.bold === cell.bold && last.dim === cell.dim) last.ch += cell.ch
433 else runs.push({ ...cell })
434 }
435 return (
436 <Text key={`r${r}`} wrap="truncate-end">
437 {runs.map((run, i) => (
438 <Text key={`r${r}-${i}`} color={run.color} bold={run.bold} dimColor={run.dim}>
439 {run.ch}
440 </Text>
441 ))}
442 </Text>
443 )
444 })}
445 {width > columns && (
446 <Text dimColor>
447 {scroll > 0 ? '◂ ' : ' '}
448 {Math.round((scroll / Math.max(1, width - columns)) * 100)}% of a wider map
449 {scroll < width - columns ? ' ▸' : ''}
450 </Text>
451 )}
452 </Box>
453 )
454}
455types/index.d.ts 154 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
52export type TicketType = 'research' | 'prototype' | 'grilling' | 'task'
53
54export type Source = 'github' | 'local'
55
56/** Where the map lives: an issue number on GitHub, or a `.scratch/<effort>` folder. */
57export type Target = { source: Source; ref: string }
58
59/** One decision ticket: a child issue of the map, or a `.scratch/<effort>/issues/NN-*.md` file. */
60export type Ticket = {
61 /** The issue number, or the file's `NN`. */
62 id: string
63 title: string
64 type: TicketType | null
65 isClosed: boolean
66 isClaimed: boolean
67 isOutOfScope: boolean
68 /** The ids of the tickets that block it, open or closed. */
69 blockedBy: string[]
70 /** The issue's URL, or the file's path. */
71 link: string
72 /** The ticket's `## Question`. */
73 question: string
74 /** The resolution: a local `## Answer`, or the closing comment fetched on focus. */
75 answer?: string
76}
77
78/** One line of the map's Decisions so far or Out of scope: a gist, and the ticket it points at. */
79export type Entry = { title: string; gist: string; ticket: string | null }
80
81export type MapView = {
82 title: string
83 link: string
84 destination: string
85 notes: string
86 decisions: Entry[]
87 fog: string[]
88 outOfScope: Entry[]
89 tickets: Ticket[]
90}
91
92export type Tab = 'route' | 'decisions'
93
94export type NodeState = 'resolved' | 'claimed' | 'frontier' | 'blocked'
95
96/** One ticket as the map's graph draws it: a box in the row of its depth. */
97export type GraphNode = {
98 id: string
99 title: string
100 type: TicketType | null
101 state: NodeState
102 /** 1 for a ticket nothing blocks, one more than its deepest blocker otherwise. */
103 layer: number
104 isNext: boolean
105 isMine: boolean
106}
107
108/** What the graph's surface module draws from: the tickets, their blocking edges, the two ends. */
109export type GraphProps = {
110 nodes: GraphNode[]
111 /** `[blocker, blocked]` pairs. */
112 edges: [string, string][]
113 start: string
114 destination: string
115 /** The ticket whose card is open, which the graph selects. */
116 selected: string | null
117 columns: number
118}
119
120/** What the graph posts to the hooks module as its selection moves. */
121export type GraphMessage = { focus: string | null }
122
123/** One line of a map's Spines: a step, and the ticket it was taken under; null for Charting. */
124export type Line = Step & { ticket: string | null }
125
126export type Wayfinder = {
127 target: Target | null
128 map: MapView | null
129 /** Every ticket's Spine and Charting's, in the order the lines came; the map's store keeps them. */
130 lines: Line[]
131 tab: Tab
132 /** The ticket whose card is open on the route. */
133 focus: string | null
134 /** Levels a press turned from how they are drawn by default. */
135 turned: string[]
136 isAllUnfolded: boolean
137 error: string | null
138 refreshedAt: number | null
139}
140
141/** A Run: the stretch of a conversation that works a map, from `/wayfinder` to `/clear`, a separate grill or `/waymap clear`. */
142export type Run = {
143 /** The ticket claimed in the Run, null while charting. */
144 ticket: { id: string; title: string } | null
145 /** When the last claim was seen. */
146 claimedAt: number | null
147}
148
149declare module 'claude-code' {
150 interface PluginState {
151 'wayfinder-map': { way: Wayfinder; run: Run | null }
152 }
153}
154