SLOPSHOPPER

wayfinder-map

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…

newpaneguardcommandstatusprompt
v0.4.0MITupdated 2026-10-16bardaxx/modsbook/mods/wayfinder-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wayfinder-map
│ ┃ Wayfinder ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─ modsbook │ ┃ ───────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ ❯ wayfinder-map ▌ ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ ╰────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ mattpocock/skills ─╯ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ ◆ wayfinder no map │ │ ┃ │ /waymap finds the map here, or /waymap │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ <issue | .scratch/effort> │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ m: tab-route d: tab-decisions │ │ ┃ ╰──────────────────────────────────────────╯ › /waymap │ ┃ ╭──────────────────────────────────────────╮ ⎿ wayfinder-map: No wayfinder map found here. /waymap <issue> or / │ ┃ │ ◆ map │ │ ┃ │ ▣ client module ./graph.tsx │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ [ Refresh ] [ Clear ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Wayfinder
╭─ modsbook ───────────────────────────────────────────╮ │ ❯ wayfinder-map ▌ │ ╰────────────────────────────────── mattpocock/skills ─╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ wayfinder no map │ │ /waymap finds the map here, or /waymap <issue | │ │ .scratch/effort> │ │ │ │ m: tab-route d: tab-decisions │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ map │ │ ▣ client module ./graph.tsx │ ╰──────────────────────────────────────────────────────────╯ [ Refresh ] [ Clear ]
README

wayfinder-map

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.

What it shows

  • header: the map's title, its destination, and one bar split by ticket state: resolved, claimed, on the frontier (open, unblocked, unclaimed), or blocked. Fog counts beside it. Under the destination, Spine · charting: what was asked and written with no ticket claimed. It is drawn open while the map has no tickets; once it has some, every level is folded, until a round comes in between one claim and the next.
  • map (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.
  • navigating it: click the graph (or a box), then use the arrows (or 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.
  • ticket: the card of the selected ticket: its question, what it waits for and what it unblocks, whether it is worked with a human (HITL) or by the agent alone (AFK), and once resolved its answer (the ## 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.
  • Each round is a level, 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.
  • Between the levels: ⌕ 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.
  • A resolved ticket's card shows its Spine with every level folded and the confirmed wrap-up last.
  • decisions (d): the map's Decisions so far, one gist per resolved ticket, then Out of scope and the map's Notes.

What lands on a Spine

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.

  • Inside a Run, each line goes to the ticket claimed in this session. With none claimed, it goes to charting.
  • Once a ticket closes, later lines go to charting until the next claim.
  • Once the map has tickets, charting keeps no lookups: those are a session reading the map to choose a ticket.
  • The status line names the ticket being worked and where its Spine stands, 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.

Requirements

  • Claude Code with function-hook mods (2.1.292 or later).
  • A map laid out the way /wayfinder charts one, on either tracker setup-matt-pocock-skills configures:
  • GitHub: the map is an issue labelled 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.
  • Local markdown: .scratch/<effort>/map.md and one .scratch/<effort>/issues/NN-<slug>.md per ticket, with Type:, Status: and Blocked by: lines.

Install

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

Use

CommandWhat it does
/waymapOpens 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 refreshReads the map and its tickets again.
/waymap clearForgets 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.

How it knows

It showsIt reads
map, destination, decisions, fog, out of scopethe map's body: ## Destination, ## Notes, ## Decisions so far, ## Not yet specified, ## Out of scope
tickets, types, claimsgh api …/issues/<map>/sub_issues (labels, assignees, state), or the local issue files' Type: and Status: lines
blocking edgesa Blocked by: line, else …/issues/<n>/dependencies/blocked_by
out of scopea ticket the map's Out of scope section links, or one closed as not planned
the ticket this session worksgh issue edit <n> --add-assignee; Status: claimed written to a local ticket, by a write or by a shell command naming its file
levelseach 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)
dialogseach AskUserQuestion call and the answers it returned; an option labelled (Recommended) is the recommendation
lookups, terms, ADRseach main-loop Agent call, read and search; each Write or Edit of a GLOSSARY.md, CONTEXT.md or docs/adr/NNNN-slug.md
wrap-upthe first answer without questions after a level is answered, and the person's reply to it

Limits

  • A reply that answers a round in prose, without numbering, is kept whole under the round rather than split across its questions.
  • Arrows that share a column merge, as on any line drawing: where two blockers feed the same ticket, the card lists them by name.
  • Keys reach the graph once a click has given it the focus; n and p work without it.
  • A GitHub ticket's answer is read on demand, when its card opens.

Develop

From this folder:

claude plugin validate .
claude plugin test .

Claude Code writes the API's types into .claude-plugin/types/ when it loads the mod, and tsconfig.json extends them, so tsc -p . type-checks it after the first load.

License

MIT, see the repository's license.

Source 6 files
hooks/register.tsx 1179 lines
1import { 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
1179
hooks/reply.ts 142 lines
1// How a round is read from the agent's answer, and how the person's reply settles it.
2// Shared by grill-map and wayfinder-map, one identical copy in each; scripts/check.sh fails when they differ.
3
4import type { Question, RoundStep, Step, WrapStep } from '../types'
5
6export const firstLine = (text: string) => text.split('\n').find(line => line.trim())?.trim() ?? ''
7export const plain = (text: string) =>
8  text
9    .replace(/\*\*|__|`/g, '')
10    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
11    .replace(/\s+/g, ' ')
12    .trim()
13
14/** `❓ **Q1** - **Title**: body … ➡ recommended`, once per question of the round. */
15export const parseRound = (text: string): Question[] =>
16  text
17    .split(/^[ \t>]*❓️?\s*/m)
18    .slice(1)
19    .flatMap(chunk => {
20      const head = /^\**\s*Q?(\d+)\s*\**\s*[-–—:.]?\s*(?:\*\*([^*]+)\*\*\s*:?)?\s*([\s\S]*)$/i.exec(chunk)
21      if (!head) return []
22      const rest = (head[3] ?? '').split(/\n\s*---/)[0] ?? ''
23      const arrow = rest.search(/➡/)
24      const before = arrow < 0 ? rest : rest.slice(0, arrow)
25      const recommended = arrow < 0 ? '' : plain(rest.slice(arrow).replace(/^➡️?\s*/, '').split(/\n\s*\n/)[0] ?? '')
26      const option = (line: string) => /^\s*(?:[-*]|\(?[a-zA-Z0-9]\)|[A-Za-z]\.)\s+(.+)$/.exec(line)?.[1]
27      const lines = before.split('\n')
28      const options = lines
29        .map(option)
30        .filter((o): o is string => Boolean(o))
31        .map(plain)
32        .slice(0, 6)
33      const title = plain(head[2] ?? firstLine(before))
34      const body = plain(lines.filter(line => !option(line)).join('\n'))
35      return title ? [{ n: head[1] ?? '', title, body: head[2] ? body : '', options, recommended }] : []
36    })
37
38// ── A yes, and a Reserve ──
39
40// A word ends where no letter or digit follows: `\b` misses one after an accented letter (`sì`).
41const END = '(?=$|[^\\p{L}\\p{N}])'
42const YES = new RegExp(
43  `^(?:y|yes|yep|yeah|ok|okay|sure|agreed|go|lgtm|s[iì]|certo|va bene|confermo|d'accordo|perfetto|esatto|👍)${END}`,
44  'iu',
45)
46const RESERVE = new RegExp(
47  `(?:^|[^\\p{L}\\p{N}])(?:ma|però|tranne|eccetto|salvo|a parte|invece|solo se|but|except|unless|only if|however)${END}`,
48  'iu',
49)
50
51/** A reply that opens with a yes, Reserve or not. */
52export const isYes = (text: string) => YES.test(text.trim())
53
54/** A yes with a Reserve after it: `sì, ma …`, `yes, unless …`. */
55export const hasReserve = (text: string) => {
56  const m = YES.exec(text.trim())
57  return !!m && RESERVE.test(text.trim().slice(m[0].length))
58}
59
60/** A yes with no Reserve: the recommendation taken, the Wrap-up confirmed. */
61export const takes = (text: string) => isYes(text) && !hasReserve(text)
62
63// ── Settling a round ──
64
65/** How a question stands: took the recommendation, answered otherwise, answered in an unsplit reply, or open. */
66export type Mark = 'took' | 'own' | 'replied' | 'open'
67
68export const markOf = (q: Question, r: RoundStep): Mark =>
69  q.answer !== undefined ? (q.isAccepted ? 'took' : 'own') : r.reply !== undefined ? 'replied' : 'open'
70
71export const isWaiting = (r: RoundStep) => r.reply === undefined && r.questions.some(q => q.answer === undefined)
72
73/**
74 * Hands the person's reply out to the round's questions: `1. yes`, `Q2: no, …`, or one answer for all.
75 * A yes said whole to several questions answers them all, unless a Reserve follows it: the Reserve
76 * cannot be pinned to one, so the reply stays unsplit.
77 */
78export const applyReply = (round: RoundStep, reply: string): RoundStep => {
79  const known = new Set(round.questions.map(q => q.n))
80  const parts = new Map<string, string>()
81  let at: string | null = null
82  for (const line of reply.split('\n')) {
83    const m = /^\s*\**Q?(\d+)\**(?:\s*[).:\-–—]|\s+)\s*(.*)$/i.exec(line)
84    if (m && known.has(m[1] ?? '')) {
85      at = m[1] ?? null
86      if (at) parts.set(at, (m[2] ?? '').trim())
87    } else if (at && line.trim()) parts.set(at, `${parts.get(at) ?? ''} ${line.trim()}`.trim())
88  }
89  const whole = reply.trim()
90  const forAll = parts.size === 0 && (round.questions.length === 1 || takes(whole)) ? whole : undefined
91  return {
92    ...round,
93    reply: whole,
94    questions: round.questions.map(q => {
95      const said = parts.get(q.n) ?? forAll
96      return said === undefined ? q : { ...q, answer: said, isAccepted: takes(said) }
97    }),
98  }
99}
100
101/** An AskUserQuestion dialog as a Level: the recommended option is the one labelled "(Recommended)". */
102export const dialogRound = (
103  asked: readonly { question: string; options: readonly { label: string }[] }[],
104  result: unknown,
105  id: string,
106  at: number,
107): RoundStep => {
108  const { answers = {}, response } = (result ?? {}) as { answers?: Record<string, string>; response?: string }
109  const questions: Question[] = asked.map((q, i) => {
110    const answer = answers[q.question]
111    const recommended = q.options.find(o => /\(recommended\)/i.test(o.label))?.label ?? ''
112    return {
113      n: String(i + 1),
114      title: plain(q.question),
115      body: '',
116      options: q.options.map(o => o.label),
117      recommended,
118      ...(answer ? { answer, isAccepted: answer === recommended && !!recommended } : {}),
119    }
120  })
121  return { kind: 'round', id, at, via: 'dialog', questions, reply: response ?? Object.values(answers).join(', ') }
122}
123
124// ── The Wrap-up ──
125
126/** The newest Level or Wrap-up: what the person's next reply answers. */
127export const lastTurn = (steps: Step[]) =>
128  steps.filter((s): s is RoundStep | WrapStep => s.kind === 'round' || s.kind === 'wrap').at(-1)
129
130/**
131 * Whether a turn that ends without questions is a Wrap-up: once the newest Level is answered,
132 * or after a Wrap-up the person pushed back on.
133 */
134export const isWrapDue = (steps: Step[]) => {
135  const last = lastTurn(steps)
136  if (last?.kind === 'round') return last.reply !== undefined
137  return last?.kind === 'wrap' && last.reply !== undefined && !last.isConfirmed
138}
139
140/** The person's word on a Wrap-up: a yes with no Reserve confirms it. */
141export const settleWrap = (wrap: WrapStep, reply: string): WrapStep => ({ ...wrap, reply, isConfirmed: takes(reply) })
142
hooks/header.tsx 47 lines
1// The head every modsbook pane opens with: a terminal panel, its titles set in the border as lazygit and tmux
2// set theirs, a prompt naming the mod, and a nod to mattpocock/skills, the skills the mods follow.
3// The same in each mod, only the name changes. One identical copy in each mod; scripts/check.sh fails when they differ.
4
5import type { BoxProps, ElementConstructor, TextProps } from 'claude-code'
6
7export type HeaderElements = {
8  Box: ElementConstructor<BoxProps>
9  Text: ElementConstructor<TextProps>
10}
11
12const EDGE = '#4a4a4a'
13const ACCENT = '#d97757'
14const TOP = 'modsbook'
15const NOD = 'mattpocock/skills'
16
17/** Three rows drawn to the pane's width: `modsbook` in the top border, `❯ <name> ▌`, the nod in the bottom one. */
18export const header = ({ Box, Text }: HeaderElements, name: string, columns: number) => {
19  const width = Math.max(TOP.length + NOD.length + 10, columns)
20  const rule = (n: number) => '─'.repeat(Math.max(0, n))
21  const room = width - 4
22  const prompt = name.length + 4 > room ? `${name.slice(0, Math.max(1, room - 5))}…` : name
23  return (
24    <Box key="header" flexDirection="column">
25      <Text>
26        <Text color={EDGE}>╭─ </Text>
27        <Text dimColor>{TOP}</Text>
28        <Text color={EDGE}>{` ${rule(width - TOP.length - 5)}╮`}</Text>
29      </Text>
30      <Text>
31        <Text color={EDGE}>│ </Text>
32        <Text color={ACCENT}>❯ </Text>
33        <Text bold>{prompt}</Text>
34        <Text color={ACCENT}> ▌</Text>
35        <Text color={EDGE}>{`${' '.repeat(Math.max(0, room - prompt.length - 4))} │`}</Text>
36      </Text>
37      <Text>
38        <Text color={EDGE}>{`╰${rule(width - NOD.length - 5)} `}</Text>
39        <Text dimColor italic>
40          {NOD}
41        </Text>
42        <Text color={EDGE}> ─╯</Text>
43      </Text>
44    </Box>
45  )
46}
47
hooks/spine.tsx 415 lines
1// The Spine: what a grilling session leaves, top to bottom, and how it is drawn.
2// Shared by grill-map and wayfinder-map, one identical copy in each; scripts/check.sh fails when they differ.
3
4import type { BoxProps, ButtonProps, ElementConstructor, RenderChildren, TextProps } from 'claude-code'
5
6import type { AdrStep, LookupStep, RoundStep, Step, TermStep } from '../types'
7import { isWaiting, markOf } from './reply'
8import type { Mark } from './reply'
9
10// ── Traces: what a tool call leaves on the Spine ──
11
12const GLOSSARY = /(?:^|\/)(?:CONTEXT|GLOSSARY)\.md$/
13const ADR = /(?:^|\/)docs\/adr\/(\d+)-([^/]+)\.md$/
14const TERM = /^\s*\*\*([^*\n]+?)\*\*\s*:/gm
15// Search tools a build may or may not ship; where it has none, the shell's searches count instead.
16const SEARCHES = new Set<string>(['Grep', 'Glob'])
17const SHELL_LOOKUP = /^\s*(rg|grep|cat|ls|find|head|tail|tree|sed -n|git (?:log|show|diff|grep|blame))\b/
18
19const plainText = (text: string) =>
20  text
21    .replace(/\*\*|__|`/g, '')
22    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
23    .replace(/\s+/g, ' ')
24    .trim()
25
26/** What a read or a search looked at, or null for a call that looks nothing up. */
27export const lookupOf = (tool: string, input: Record<string, unknown>): string | null => {
28  const text = (key: string) => String(input[key] ?? '')
29  if (tool === 'Read') return base(text('file_path'))
30  if (SEARCHES.has(tool)) return String(input['pattern'] ?? tool)
31  if (tool === 'WebFetch') return /^https?:\/\/([^/]+)/.exec(text('url'))?.[1] ?? text('url')
32  if (tool === 'WebSearch') return text('query')
33  if (tool === 'Bash' && SHELL_LOOKUP.test(text('command')))
34    return text('command').split('\n').find(line => line.trim())?.trim() ?? null
35  return null
36}
37
38/** Whether a `Write` or `Edit` of this path is a glossary or an ADR. */
39export const isDocPath = (path: string) => GLOSSARY.test(path) || ADR.test(path)
40
41const termsIn = (text: string) => new Set([...text.matchAll(TERM)].map(m => plainText(m[1] ?? '')).filter(Boolean))
42
43/**
44 * The Trace a glossary or ADR write leaves: the ADR, or the terms the write added.
45 * `previous` is the file before a `Write` (null when new), or the `old_string` an `Edit` replaced.
46 */
47export const docTraceOf = (
48  path: string,
49  written: string,
50  previous: string | null,
51  isEdit: boolean,
52  id: string,
53  at: number,
54): AdrStep | TermStep | null => {
55  const adr = ADR.exec(path)
56  if (adr) {
57    const slug = (adr[2] ?? '').replace(/[-_]+/g, ' ')
58    const title = plainText(/^#[ \t]+(.+)$/m.exec(written)?.[1] ?? slug)
59    return { kind: 'adr', id, at, file: path, number: adr[1] ?? '', title, isUpdate: isEdit || previous !== null }
60  }
61  if (!GLOSSARY.test(path)) return null
62  const had = termsIn(previous ?? '')
63  return { kind: 'term', id, at, file: path, terms: [...termsIn(written)].filter(t => !had.has(t)) }
64}
65
66/** A read folds into the lookup just before it; a sub-agent never does. */
67export const foldsInto = (last: Step | undefined): last is LookupStep => last?.kind === 'lookup' && !last.isAgent
68
69/** Terms written to the file the last Trace wrote fold into it. */
70export const termsFoldInto = (last: Step | undefined, path: string): last is TermStep =>
71  last?.kind === 'term' && last.file === path
72
73// ── Drawing ──
74
75// The modsbook palette.
76export const EDGE = '#4a4a4a'
77export const ACCENT = '#d97757'
78export const FOG = '#8a93a6'
79export const GREEN = '#5ccf9f'
80export const AMBER = '#e2b867'
81const VIOLET = '#b79cf5'
82export const TEAL = '#6fd3c8'
83export const BLUE = '#7fb4f5'
84
85export const MARK: Record<Mark, string> = { took: '✓', own: '✎', replied: '⏎', open: '◌' }
86export const MARK_COLOR: Record<Mark, string> = { took: GREEN, own: ACCENT, replied: FOG, open: AMBER }
87
88export const since = (from: number, to = Date.now()) => {
89  const s = Math.max(0, Math.round((to - from) / 1000))
90  if (s < 60) return `${s}s`
91  const m = Math.floor(s / 60)
92  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60}m`
93}
94export const cut = (text: string, room: number) => (text.length > room ? `${text.slice(0, Math.max(1, room - 1))}…` : text)
95export const base = (path: string) => path.split('/').pop() ?? path
96
97/** Words laid into lines of `width`, at most `max` of them, the last cut with `…`. */
98export const wrapLines = (text: string, width: number, max: number): string[] => {
99  const lines: string[] = []
100  let line = ''
101  for (const raw of text.split(/\s+/).filter(Boolean)) {
102    const word = cut(raw, width)
103    if (!line) line = word
104    else if (line.length + 1 + word.length <= width) line += ` ${word}`
105    else {
106      lines.push(line)
107      line = word
108    }
109  }
110  if (line) lines.push(line)
111  if (lines.length <= max) return lines
112  const kept = lines.slice(0, max)
113  const last = kept[max - 1] ?? ''
114  kept[max - 1] = last.length >= width ? `${last.slice(0, width - 1)}…` : `${last}…`
115  return kept
116}
117
118/** The elements a Spine is drawn with, from `$.ui.resolve(e)` on any surface. */
119export type SpineElements = {
120  Box: ElementConstructor<BoxProps>
121  Text: ElementConstructor<TextProps>
122  Button: ElementConstructor<ButtonProps>
123}
124
125export type SpineView = {
126  steps: Step[]
127  /** Put before each Level heading's key, so two Spines in one pane press apart. */
128  keyPrefix: string
129  /** A closed ticket, or a grill whose Wrap-up is confirmed: every Level folded. */
130  isClosed: boolean
131  /** Lines still arrive here: the tail says what the Spine waits for. */
132  isLive: boolean
133  isAllUnfolded: boolean
134  /** Levels a press turned from how they are drawn by default. */
135  turned: string[]
136  onTurn: (id: string) => unknown
137  /** Cells across. */
138  width: number
139  now: number
140  /** Draws a `started` row first. */
141  startedAt?: number
142}
143
144const rounds = (steps: Step[]) => steps.filter((s): s is RoundStep => s.kind === 'round')
145
146/** The open questions of the newest Level. */
147export const openCount = (steps: Step[]) => {
148  const last = rounds(steps).at(-1)
149  return last ? last.questions.filter(q => markOf(q, last) === 'open').length : 0
150}
151
152/** `Spine` with its count of Levels, and of open questions where the Spine is not closed. */
153export const spineHeading = (el: SpineElements, title: string, steps: Step[], isClosed: boolean) => {
154  const { Box, Text } = el
155  const levels = rounds(steps).length
156  const open = isClosed ? 0 : openCount(steps)
157  return (
158    <Box flexDirection="row" justifyContent="space-between">
159      <Text bold>{title}</Text>
160      <Text dimColor>
161        {levels} level{levels === 1 ? '' : 's'}
162        {open ? ` · ${open} open` : ''}
163      </Text>
164    </Box>
165  )
166}
167
168/** `a`: every Level of every Spine in the pane whole, or back to how each is drawn by default. */
169export const foldAllButton = (el: SpineElements, isAllUnfolded: boolean, onPress: () => unknown) => {
170  const { Button } = el
171  return <Button key="fold-all" label={isAllUnfolded ? 'Fold' : 'Unfold'} hotkey="a" onPress={onPress} />
172}
173
174/** The Spine's rows: each Level hanging from it, the Traces between, the Wrap-up where it closes. */
175export const spineRows = (el: SpineElements, v: SpineView): RenderChildren[] => {
176  const { Box, Text, Button } = el
177  /** One row of the spine: what it hangs from on the left, a note on the right. */
178  const row = (key: string, lead: RenderChildren, body: RenderChildren, right?: RenderChildren) => (
179    <Box key={key} flexDirection="row" justifyContent="space-between">
180      <Box flexShrink={1}>
181        <Text wrap="truncate-end">
182          {lead}
183          {body}
184        </Text>
185      </Box>
186      {right !== undefined && (
187        <Box flexShrink={0} marginLeft={1}>
188          {right}
189        </Box>
190      )}
191    </Box>
192  )
193  const spine = (rest = '') => <Text color={EDGE}>┃ {rest}</Text>
194  const lines: RenderChildren[] = []
195  const room = Math.max(12, v.width - 4)
196  const all = rounds(v.steps)
197  const lastRound = all.at(-1)
198
199  if (v.startedAt !== undefined)
200    lines.push(
201      row(
202        'start',
203        <Text color={VIOLET}>◎ </Text>,
204        <Text dimColor>started</Text>,
205        <Text dimColor>{since(v.startedAt, v.now)} ago</Text>,
206      ),
207    )
208
209  let level = 0
210  for (const s of v.steps) {
211    if (s.kind === 'lookup') {
212      lines.push(
213        row(
214          s.id,
215          spine(),
216          <Text dimColor>
217            <Text color={FOG}>⌕ </Text>
218            {s.isAgent ? `agent · ${s.label}` : `${s.count} ${s.count === 1 ? 'lookup' : 'lookups'} · ${s.label}`}
219          </Text>,
220          s.isAgent ? s.isDone ? <Text dimColor>done</Text> : <Text color={AMBER}>running</Text> : undefined,
221        ),
222      )
223      continue
224    }
225    if (s.kind === 'term') {
226      lines.push(
227        row(
228          s.id,
229          spine(),
230          <Text>
231            <Text color={TEAL}>≡ </Text>
232            <Text dimColor>{base(s.file)} · </Text>
233            {s.terms.length ? s.terms.join(', ') : <Text dimColor>updated</Text>}
234          </Text>,
235        ),
236      )
237      continue
238    }
239    if (s.kind === 'adr') {
240      lines.push(
241        row(
242          s.id,
243          spine(),
244          <Text>
245            <Text color={BLUE}>⚑ </Text>
246            <Text dimColor>ADR {s.number} · </Text>
247            {s.title}
248          </Text>,
249          s.isUpdate ? <Text dimColor>updated</Text> : undefined,
250        ),
251      )
252      continue
253    }
254    if (s.kind === 'wrap') {
255      const color = s.isConfirmed ? GREEN : s.reply !== undefined ? FOG : AMBER
256      const label = s.isConfirmed
257        ? 'shared understanding confirmed'
258        : s.reply !== undefined
259          ? 'wrap-up, pushed back'
260          : 'wrap-up · confirm the shared understanding'
261      lines.push(row(`${s.id}-gap`, spine(), ''))
262      lines.push(
263        row(
264          s.id,
265          <Text color={color}>{s.isConfirmed ? '◉ ' : '◎ '}</Text>,
266          <Text bold>{label}</Text>,
267          <Text dimColor>{since(s.at, v.now)}</Text>,
268        ),
269      )
270      const isLast = s === v.steps.at(-1)
271      wrapLines(s.text, room, s.reply !== undefined || v.isClosed ? 1 : 4).forEach((l, i) =>
272        lines.push(row(`${s.id}-t${i}`, isLast ? <Text>{'  '}</Text> : spine(), <Text dimColor>{l}</Text>)),
273      )
274      if (s.reply !== undefined && !s.isConfirmed)
275        lines.push(
276          row(`${s.id}-reply`, isLast ? <Text>{'  '}</Text> : spine(), <Text>
277            <Text color={ACCENT}>✎ </Text>
278            {s.reply}
279          </Text>),
280        )
281      continue
282    }
283
284    // A Level: its questions hanging from the spine; the newest drawn whole, unless the Spine is closed.
285    level += 1
286    const r = s
287    const isNewest = r === lastRound
288    const isWhole = isNewest && !v.isClosed
289    const isOpen = v.isAllUnfolded || isWhole !== v.turned.includes(r.id)
290    const waiting = isNewest && v.isLive && isWaiting(r)
291    const marks = r.questions.map(q => markOf(q, r))
292    const done = marks.filter(m => m !== 'open').length
293    lines.push(row(`${r.id}-gap`, spine(), ''))
294    lines.push(
295      <Box key={r.id} flexDirection="row" justifyContent="space-between">
296        <Box flexShrink={1}>
297          <Button key={`${v.keyPrefix}level-${level}`} plain onPress={() => v.onTurn(r.id)}>
298            <Text wrap="truncate-end">
299              <Text color={waiting ? AMBER : done === marks.length ? GREEN : FOG}>{waiting ? '◆ ' : '● '}</Text>
300              <Text bold>L{level}</Text>
301              <Text dimColor>
302                {' · '}
303                {r.via === 'dialog' ? 'dialog · ' : ''}
304                {waiting ? '' : `${done}/${marks.length} settled`}
305              </Text>
306              {waiting ? <Text color={AMBER}>waiting for you</Text> : ''}
307              <Text color={EDGE}>{isOpen ? ' ▾' : ' ▸'}</Text>
308            </Text>
309          </Button>
310        </Box>
311        <Box flexShrink={0} marginLeft={1}>
312          <Text dimColor>{since(r.at, v.now)}</Text>
313        </Box>
314      </Box>,
315    )
316
317    r.questions.forEach((q, qi) => {
318      const m = marks[qi] ?? 'open'
319      const key = `${r.id}-${q.n}-${qi}`
320      const sign = <Text color={MARK_COLOR[m]}>{MARK[m]} </Text>
321      if (!isOpen) {
322        const said =
323          m === 'took'
324            ? q.recommended || q.answer || ''
325            : m === 'own'
326              ? (q.answer ?? '')
327              : m === 'replied'
328                ? 'in the reply'
329                : q.recommended
330                  ? `➡ ${q.recommended}`
331                  : ''
332        lines.push(
333          row(key, spine(), <Text>
334            {sign}
335            <Text bold={m === 'open'}>{q.title}</Text>
336            {said ? <Text dimColor> → {said}</Text> : ''}
337          </Text>),
338        )
339        return
340      }
341      const sub = (k: string, body: RenderChildren) => lines.push(row(`${key}-${k}`, spine('│ '), body))
342      lines.push(
343        row(key, spine(), <Text>
344          {sign}
345          <Text dimColor>Q{q.n} </Text>
346          <Text bold>{q.title}</Text>
347        </Text>),
348      )
349      wrapLines(q.body, room - 2, 4).forEach((l, i) => sub(`b${i}`, <Text dimColor>{l}</Text>))
350      q.options.forEach((o, oi) => {
351        const isChosen = q.answer !== undefined && (q.answer === o || q.answer.split(/,\s*/).includes(o))
352        sub(
353          `o${oi}`,
354          <Text dimColor={!isChosen} bold={isChosen}>
355            <Text color={isChosen ? GREEN : EDGE}>{isChosen ? '● ' : '○ '}</Text>
356            {o}
357          </Text>,
358        )
359      })
360      if (q.recommended)
361        wrapLines(q.recommended, room - 4, 3).forEach((l, i) =>
362          sub(
363            `rec${i}`,
364            <Text bold={m === 'took'} dimColor={m === 'own'}>
365              <Text color={AMBER}>{i ? '  ' : '➡ '}</Text>
366              {l}
367              {m === 'took' && i === 0 ? <Text color={GREEN}> ✓</Text> : ''}
368            </Text>,
369          ),
370        )
371      if (m === 'own' && q.answer !== undefined && !(r.via === 'dialog' && q.options.includes(q.answer)))
372        wrapLines(q.answer, room - 4, 3).forEach((l, i) =>
373          sub(
374            `ans${i}`,
375            <Text bold>
376              <Text color={ACCENT}>{i ? '  ' : '✎ '}</Text>
377              {l}
378            </Text>,
379          ),
380        )
381    })
382    if (r.reply !== undefined && marks.includes('replied'))
383      wrapLines(r.reply, room - 2, isOpen ? 4 : 1).forEach((l, i) =>
384        lines.push(
385          row(`${r.id}-reply${i}`, spine(), <Text dimColor italic>
386            {i ? '  ' : '⏎ '}
387            {l}
388          </Text>),
389        ),
390      )
391  }
392
393  // Where the Spine goes from here, while lines still arrive.
394  if (!v.isLive || v.isClosed) return lines
395  const last = v.steps.at(-1)
396  const left = openCount(v.steps)
397  const tail =
398    last?.kind === 'wrap' && last.reply === undefined
399      ? null
400      : last?.kind === 'wrap' && last.isConfirmed
401        ? null
402        : lastRound && isWaiting(lastRound)
403          ? { color: AMBER, text: `${left} open · reply by number, 1. yes  2. …` }
404          : last?.kind === 'lookup' && last.isAgent && !last.isDone
405            ? { color: FOG, text: 'looking up a fact…' }
406            : all.length
407              ? { color: FOG, text: 'the next frontier…' }
408              : { color: FOG, text: 'the first round…' }
409  if (tail) {
410    lines.push(row('tail-gap', spine(), ''))
411    lines.push(row('tail', <Text color={tail.color}>▼ </Text>, <Text color={tail.color}>{tail.text}</Text>))
412  }
413  return lines
414}
415
hooks/graph.tsx 455 lines
1import 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}
455
types/index.d.ts 154 lines
1// The Spine's steps, declared alike in both mods' contracts (a contract imports nothing): hooks/spine.tsx and
2// hooks/reply.ts read them, and scripts/check.sh compares the two declarations, down to `export type Step`.
3/** One question of a round (`❓ **Q1** - **Title**: body … ➡ recommended`) or of an AskUserQuestion dialog. */
4export type Question = {
5  n: string
6  title: string
7  /** The question's body, the choices left out. */
8  body: string
9  /** The choices the body listed, or the options the dialog offered. */
10  options: string[]
11  /** The recommended answer (`➡ …`), or the dialog's "(Recommended)" option. */
12  recommended: string
13  answer?: string
14  /** The person took the recommendation: a yes with no Reserve, or the recommended option. */
15  isAccepted?: boolean
16}
17
18/** A Level: one round, the frontier as it stood. */
19export type RoundStep = {
20  kind: 'round'
21  id: string
22  at: number
23  via: 'grilling' | 'dialog'
24  questions: Question[]
25  /** What the person wrote back, whole. */
26  reply?: string
27}
28
29/** A fact the agent went to find instead of asking: a sub-agent, or a run of reads and searches. */
30export type LookupStep = {
31  kind: 'lookup'
32  id: string
33  at: number
34  label: string
35  isAgent: boolean
36  /** Reads and searches folded into this step. */
37  count: number
38  isDone: boolean
39}
40
41/** Terms written to the glossary (`CONTEXT.md`, `GLOSSARY.md`). */
42export type TermStep = { kind: 'term'; id: string; at: number; file: string; terms: string[] }
43
44/** An ADR written under `docs/adr/`. */
45export type AdrStep = { kind: 'adr'; id: string; at: number; file: string; number: string; title: string; isUpdate: boolean }
46
47/** The Wrap-up: a turn that ended without questions once rounds were answered, the summary up for confirmation. */
48export type WrapStep = { kind: 'wrap'; id: string; at: number; text: string; reply?: string; isConfirmed?: boolean }
49
50export type Step = RoundStep | LookupStep | TermStep | AdrStep | WrapStep
51
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