Side panes along the AI Hero skills: grill-map for a grilling's design tree, wayfinder-map for a /wayfinder map, spec-map for a spec from its tickets to the…

A Claude Code mod of three side panes, one for each stretch of the road the AI Hero skills follow:
| Map | What it draws |
|---|---|
| grill-map | /grill-me, /grill-with-docs and /grilling: the design tree read top to bottom, one level per round, with each recommendation, what was chosen, the lookups and the docs written. |
| wayfinder-map | /wayfinder: the map as a navigable graph from start to destination, the decisions so far, the fog, and each ticket's grilling as a design tree. |
| spec-map | A spec from idea to merge: the stage, what each subagent is doing, the tickets and the integration PR. |
Each pane opens with the same header: SKILLMAPS · <MAP> <STATE> with the map in its own color, a legend of the maps turned on (a press on one opens it), and a strip of braille dots each map animates its own way: grill-map's flames, wayfinder-map's traveller in the fog, spec-map's conveyor. It moves every frame while the map works and at a slower beat at rest.
In a Claude Code session, a pane docks beside the transcript. Here grill-map follows a /grill-with-docs: the first round settled, the lookups and docs it led to, and the next round waiting for your answer. While the model works, the header's flames flicker; while the round waits, they rest, dim.
╭────────────────────────────────────────────────────┬─ grill-map ──────────────────────────────────────╮
│ > /grill-with-docs offline sync for the field app │ SKILLMAPS · GRILL-MAP YOUR TURN │
│ │ ■ grill-map ■ wayfinder-map ■ spec-map │
│ ● Q1 - Storage: … ➡ SQLite, with WAL │ ⣄⣀⣠⣄⣴⣀⠀⠀⡀⢀⣠⣴⣤⣄⣤⣦⣶⣴⣤⣤⢀⠀⡀⡀⣄⣀⣀⢨⢀⣠⣤⣶⣾⣴⣬⣠⣨⣤⣄⣄⣀⢀⢀⣠⣠⣴⣤⣴ │
│ ● Q2 - Sync trigger: … ➡ on every change │ │
│ │ ╭──────────────────────────────────────────────╮ │
│ > 1. yes 2. no, every minute │ │ ◆ grill-with-docs 2/3 │ │
│ │ │ Offline sync for the field app │ │
│ ● Read(src/sync/queue.ts) │ │ │ │
│ ⎿ Read 182 lines │ │ ███████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │
│ ● Update(CONTEXT.md) │ │ ✓ took 1 ✎ yours 1 ◌ open 1 ⌕ lookups 3 │ │
│ ⎿ Added 1 line │ ╰──────────────────────────────────────────────╯ │
│ ● Write(docs/adr/0001-sqlite-on-the-device.md) │ ╭──────────────────────────────────────────────╮ │
│ ⎿ Wrote 24 lines │ │ Spine 2 levels · 1 open │ │
│ │ │ ◎ started 6m ago │ │
│ ● Q3 - Conflicts: where does a conflict │ │ ┃ ⌕ agent · Find the current sync code done │ │
│ surface to the person, if anywhere at all? │ │ ┃ ⌕ 2 lookups · queue.ts │ │
│ ➡ Last write wins, with a banner │ │ ┃ │ │
│ │ │ ● L1 · 2/2 settled ▸ 4m │ │
│ │ │ ┃ ✓ Storage → SQLite, with WAL │ │
│ │ │ ┃ ✎ Sync trigger → no, every minute │ │
│ │ │ ┃ ≡ CONTEXT.md · Sync Log │ │
│ │ │ ┃ ⚑ ADR 0001 · SQLite on the device │ │
│ │ │ ┃ │ │
│ │ │ ◆ L2 · waiting for you ▾ 1m │ │
│ │ │ ┃ ◌ Q3 Conflicts │ │
│ │ │ ┃ │ Where does a conflict surface to the │ │
│ │ │ ┃ │ person, if anywhere at all? │ │
│ ╭────────────────────────────────────────────────╮ │ │ ┃ │ ➡ Last write wins, with a banner │ │
│ │ > │ │ │ ┃ │ │
│ ╰────────────────────────────────────────────────╯ │ │ ▼ 1 open · reply by number, 1. yes 2. … │ │
│ ⏵⏵ accept edits on │ ╰──────────────────────────────────────────────╯ │
╰────────────────────────────────────────────────────┴──────────────────────────────────────────────────╯
mattpocock-skills@claude-plugins-official too, from Anthropic's own marketplace. The maps read the skills under that plugin's names (/mattpocock-skills:grill-me) as well as bare (/grill-me).If you already keep those skills in ~/.claude/skills/ or a project's .claude/skills/, the plugin adds them a second time under its prefix. Remove your copies to keep one set, or keep both: the maps follow either.
At the prompt of a Claude Code terminal session, add the marketplace (once), then install the mod:
/plugin marketplace add bardaxx/modsbook
/plugin install skillmaps@modsbook
Pick a scope when asked (Enter takes the user scope). The settings screen comes next: every map is on, turn off the ones you do not want.
| Setting | Default | What it does |
|---|---|---|
grill-map | on | The grill pane and its status line. |
wayfinder-map | on | The wayfinder pane, its Run and its status line. |
spec-map | on | The spec pane. |
spec-map implementer slots | 0 | How many implementers may run at once, drawn as spec-map's slot meter. 0 reads the cap from the implement skill's text (3 slots), or draws none when the skill states none. |
Change them later in /plugin, under skillmaps, or in /config; a change reloads the mod. A map turned off registers none of its hooks.
The three were one mod each before. Uninstall the ones you have, then install skillmaps:
/plugin uninstall grill-map@modsbook
/plugin uninstall wayfinder-map@modsbook
/plugin uninstall spec-map@modsbook
/plugin install skillmaps@modsbook
What they kept for a session does not carry over.
claude plugin update skillmaps@modsbook
The new version loads in the next session, or at once with /reload-plugins. To get updates without asking, turn on auto-update for modsbook in /plugin, under Marketplaces; it is off by default.
To run it from a clone while you change it:
claude --plugin-dir /path/to/modsbook/mods/skillmaps
Each pane opens by itself when its skill runs. One command reaches them all:
| Command | What it does |
|---|---|
/skillmaps | Opens the pane used last, or the first one turned on. |
/skillmaps grill | Opens grill-map. |
/skillmaps wayfinder [<map>] | Opens wayfinder-map, following a map: an issue number or a .scratch/<effort> folder. |
/skillmaps spec [<n>] | Opens spec-map, following spec issue #n. |
Each map's section lists the rest of its words (refresh, clear).
A side pane beside the transcript that draws a grilling session as a map of its choices, read top to bottom: /grill-me, /grill-with-docs, or /grilling itself. Each round is one level of the design tree on a vertical spine, its questions hanging from it with the recommendation and what was chosen, and between the levels the facts the agent looked up and the docs it wrote.
SKILLMAPS · GRILL-MAP YOUR TURN
■ grill-map ■ wayfinder-map ■ spec-map
⣶⣤⣴⣠⣤⣤⣤⣶⣄⡄⡀⠀⠀⣁⣤⣤⣦⣄⣴⣬⣶⣶⣤⣀⡀⢀⣤⣠⣄⣬⢀⠀⢀⣴⣼⣦⣤⣆⢀⣀⣄⣆⣄⣀⡀⠀⣀⣄
╭──────────────────────────────────────────────╮
│ ◆ grill-with-docs 2/3 │
│ Offline sync for the field app │
│ │
│ ███████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ✓ took 1 ✎ yours 1 ◌ open 1 ⌕ lookups 3 │
╰──────────────────────────────────────────────╯
╭──────────────────────────────────────────────╮
│ Spine 2 levels · 1 open │
│ ◎ started 6m ago │
│ ┃ ⌕ agent · Find the current sync code done │
│ ┃ ⌕ 2 lookups · queue.ts │
│ ┃ │
│ ● L1 · 2/2 settled ▸ 4m │
│ ┃ ✓ Storage → SQLite, with WAL │
│ ┃ ✎ Sync trigger → no, every minute │
│ ┃ ≡ CONTEXT.md · Sync Log │
│ ┃ ⚑ ADR 0001 · SQLite on the device │
│ ┃ │
│ ◆ L2 · waiting for you ▾ 1m │
│ ┃ ◌ Q3 Conflicts │
│ ┃ │ Where does a conflict surface to the │
│ ┃ │ person, if anywhere at all? │
│ ┃ │ ➡ Last write wins, with a banner │
│ ┃ │
│ ▼ 1 open · reply by number, 1. yes 2. … │
╰──────────────────────────────────────────────╯
✓ took the recommendation, ✎ answered otherwise, ⏎ answered in a reply that was not split by number, ◌ still open. Beside it, the lookups, the glossary terms (≡) and the ADRs (⚑) of the grill.L1, L2, …: the frontier as it stood, every question whose prerequisites were settled. The newest level is drawn whole: each question's body, the choices it offered, the recommendation (➡, ✓ when the person said yes) and what the person answered instead (✎). An older level folds to one line per decision, title → what was chosen; a click or a tap on its heading unfolds it. Once the grill is confirmed, every level folds. Each new level takes the focus: when it arrives every level before it folds, one unfolded by hand too.●.⌕ marks a fact the agent went to find instead of asking: a sub-agent, running until it reports, or a run of reads and searches folded into one line.≡ marks the terms written to CONTEXT.md or GLOSSARY.md, and ⚑ each ADR written under docs/adr/ (/grill-with-docs, through domain-modeling).◎ wrap-up is the turn that ends without questions once the rounds are answered: the frontier is empty and the summary waits for the person's word. A yes closes the grill, ◉ shared understanding confirmed; a yes with a reserve (sì, ma …, yes, but …) or anything else pushes it back, and the next wrap-up is put to the person again.▼ says where the tree goes next: the questions still open, a lookup running, or the next frontier being worked out.grill L2 · 2 open, or grill · confirm the shared understanding at the wrap-up. It goes once the grill is confirmed or cleared.A session can hold several grills: each new /grill-me or /grill-with-docs starts one, and p / n page through them.
/wayfinderWith wayfinder-map turned on too, a /wayfinder Run is wayfinder-map's: grill-map reads it and follows it.
charting grill for the rounds asked with no ticket claimed./clear, on a /grill-me or /grill-with-docs you ask for, or on /skillmaps wayfinder clear; grill-map then holds grills of its own again.With wayfinder-map turned off, a /wayfinder grill is a grill like any other.
grilling, and the grill-me or grill-with-docs wrapper (grill-with-docs also needs domain-modeling).No command is needed: the pane opens when /grill-me, /grill-with-docs or /grilling runs, or when an answer holds a round in the grilling format.
| Command | What it does |
|---|---|
/skillmaps grill | Opens the pane. |
/skillmaps grill clear | Forgets the grills of this session. |
In the pane, a click or a tap on a level's heading folds or unfolds it, a (or the Unfold / Fold button) unfolds every level or folds them back, p and n show the previous and next grill, and c clears.
| It shows | It reads |
|---|---|
| the grill and its subject | the grill-me, grill-with-docs or grilling skill's prompt and its arguments; with none, the next prompt (a wrapper) or the last one (/grilling the model invoked) |
| a level | each main-loop answer holding ❓ Q1 - Title: … and ➡️ …, the grilling skill's round format |
| what was chosen | the person's next prompt: 1. yes, Q2: no, …, or one answer for all; a yes followed by a reserve (ma, però, tranne, but, unless, …) is the person's own answer |
| lookups | each main-loop Agent call during the grill, and its Read, Grep, Glob, WebFetch, WebSearch and read-only shell calls (rg, grep, cat, ls, find, git log, …) |
| terms | each Write or Edit of a CONTEXT.md or GLOSSARY.md: the Term: lines it adds |
| ADRs | each Write or Edit of docs/adr/NNNN-slug.md, titled by its # heading |
| wrap-up | the first answer without questions after a round is answered, and the person's reply to it |
| dialogs | each AskUserQuestion call and the answers it returned; an option labelled (Recommended) is the recommendation |
yes, ok, sì, lgtm, …) and no reserve follows. The grilling skill words each question so that yes takes its recommendation; a question worded against its recommendation is read the same way.⏎): the reserve cannot be pinned to one question.A side pane beside the transcript that 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.
SKILLMAPS · WAYFINDER-MAP CLAIMED
■ grill-map ■ wayfinder-map ■ spec-map
⠀⠠⠀⠀⠁⢠⠀⠀⠀⠀⠀⠀⡀⠀⢔⠀⠀⠐⠀⠀⠒⠀⠁⠀⠊⠀⠀⠈⠀⠄⠀⠀⠈⠀⡀⠀⠁⠐⠀⠀⠐⠀⠀⠀⡀⠐⠅⠂⠀⠐⢂⢐⠬⣝⠞⢕⡄⠂⡐⡀⠐⢉⠀⠀⠀⠀⡄⠂⠂⠄⠀⠀⠑⠀⠈⠒⠀⡀⠀⠈⠐⠀⠄⠅⠉⠀⠀⠀
⚑ Start
A foggy idea, with no idea how to get there
│
▼
╭───┬─────────────────────╮
│ ? │ ✓ Who is it for │
╰───┴────────┬────────────╯
│
╭────────────────────────────┼────────────────────────────╮
▼ ▼ ▼
╭───┬─────────────────────╮ ╭───┬─────────────────────╮ ╭───┬─────────────────────╮
│ ≡ │ ▶ Stand up the sand…│ │ ? │ ◇ Pricing model │ │ ⌕ │ ◇ Competitor APIs │
╰───┴────────┬────────────╯ ╰───┴────────┬────────────╯ ╰───┴────────┬────────────╯
│ │ │
╰──────────────╮ │ ╭─────────────╯
├─────────────┴──────────────┤
▼ ▼
╭───┬─────────────────────╮ ╭───┬─────────────────────╮
│ ◫ │ ○ Onboarding flow s…│ │ ? │ ○ Launch scope │
╰───┴────────┬────────────╯ ╰───┴────────┬────────────╯
│ │
╰─────────────╮ │
├──────────────╯
▼
⚑ Destination
All tasks on the map are complete.
m): the map as a graph, drawn the way the wayfinder article draws it. Start (the map's title) sits on top and Destination at the bottom. Each ticket is a box in the row of its depth, with arrows from the tickets that block it. A box's border and icon give its type: ? grilling, ◫ prototype, ⌕ research, ≡ task. The mark before its title gives its state: ✓ resolved, ● claimed, ◇ on the frontier (open, unblocked, unclaimed), ○ blocked, ▶ next (the first frontier ticket in map order, the one /wayfinder takes). Out-of-scope tickets stay off the map, and the map's Not yet specified section shows below it as fog.h j k l). Up and down follow the blocking edges first, left and right move along a row. The selected box gets a double border, its edges light up, and its card opens below. ⏎ closes or reopens the card. Without giving the graph the keys, n and p step through the tickets row by row. On a map wider than the pane, the view scrolls to keep the selected box in sight.## Answer of a local ticket, or the last comment of a GitHub one). Under them, its Spine, drawn as grill-map draws a grill, from the same file. A long Spine makes the card longer, and the pane scrolls. x closes the card.L1, L2, …. On an open ticket the newest level is drawn whole: each question's body, the choices, the recommendation (➡, ✓ when taken) and the person's own answer (✎). Older levels fold to one line per decision. A click or a tap on a level's heading folds or unfolds it, and a (the Unfold / Fold button) does every level at once.⌕ a fact looked up (a sub-agent, or a run of reads folded into one line), ≡ the glossary terms written, ⚑ an ADR.◎ wrap-up is the summary put to the person once the rounds are answered, and ◉ marks it confirmed.d): the map's Decisions so far, one gist per resolved ticket, then Out of scope and the map's Notes.Only a Run writes to a Spine. A Run starts with /wayfinder and lasts until /clear, a /grill-me or /grill-with-docs you ask for, or /skillmaps wayfinder clear. A map you open by hand with /skillmaps wayfinder is shown, not grilled into.
Pick the storage engine · L2 · 2 open, or charting · … with no ticket claimed. Outside a Run there is none.Spines are kept per map across sessions, up to 400 lines a map. When a map goes over, the oldest lookups are dropped first, never a level or a wrap-up. After that go the oldest lines of resolved tickets, never those of the open ticket or of charting.
/wayfinder charts one, on either tracker setup-matt-pocock-skills configures:wayfinder:map, its tickets sub-issues (or a task list in its body) labelled wayfinder:<type>, blocked through GitHub's native dependencies or a Blocked by: #n line. Needs the GitHub CLI, signed in, run from a checkout of the repository..scratch/<effort>/map.md and one .scratch/<effort>/issues/NN-<slug>.md per ticket, with Type:, Status: and Blocked by: lines.| Command | What it does |
|---|---|
/skillmaps wayfinder | Opens the pane, following the map it already follows, or else the newest .scratch/*/map.md, or else the newest open wayfinder:map issue. |
/skillmaps wayfinder <n> or /skillmaps wayfinder <issue URL> | Follows the map in GitHub issue #n. |
/skillmaps wayfinder .scratch/<effort> | Follows a local map. |
/skillmaps wayfinder refresh | Reads the map and its tickets again. |
/skillmaps wayfinder clear | Forgets the map followed in this session, and ends the Run. |
In the pane, m and d switch tabs, n and p step through the tickets, x closes a ticket's card, a unfolds or folds every level, r refreshes and c clears. On mobile, which has no region for the graph, the map tab draws the route as an indented tree instead: a digit or a tap opens a ticket, and its card carries the same Spine, levels folded and unfolded by a tap. VS Code draws no mod panes yet.
Mostly no command is needed. The pane opens when /wayfinder runs: with a map as its argument it follows that map, and with a loose idea it waits for the map to be created (the gh issue create --label wayfinder:map call, or the write of .scratch/<effort>/map.md) and follows it from there. It reads the map again after each gh issue or gh api call that changes the tracker, after each write under .scratch/, and every minute.
| It shows | It reads |
|---|---|
| map, destination, decisions, fog, out of scope | the map's body: ## Destination, ## Notes, ## Decisions so far, ## Not yet specified, ## Out of scope |
| tickets, types, claims | gh api …/issues/<map>/sub_issues (labels, assignees, state), or the local issue files' Type: and Status: lines |
| blocking edges | a `Bl
hooks/register.tsx 2739 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, Register, RenderChildren } from 'claude-code'
3
4import type {
5 AgentRow,
6 AgentStep,
7 Entry,
8 Flow,
9 GraphMessage,
10 GraphProps,
11 Grill,
12 GrillBook,
13 GrillSkill,
14 IntegrationPr,
15 Line,
16 MapView,
17 Question,
18 Role,
19 RoundStep,
20 Run,
21 SpecTicket,
22 Stage,
23 Step,
24 Tab,
25 Target,
26 Ticket,
27 TicketType,
28 Wayfinder,
29 WayfinderRun,
30} from '../types'
31import { applyReply, dialogRound, firstLine, isWaiting, isWrapDue, lastTurn, markOf, parseRound, plain, settleWrap } from './reply'
32import type { Mark } from './reply'
33import { FRAME_MS, header, isDrifting, setLegend } from './header'
34import type { HeaderState } from './header'
35import {
36 ACCENT,
37 BLUE,
38 EDGE,
39 FOG,
40 GREEN,
41 MARK,
42 MARK_COLOR,
43 TEAL,
44 cut,
45 docTraceOf,
46 foldAllButton,
47 foldsInto,
48 isDocPath,
49 lookupOf,
50 since,
51 spineHeading,
52 spineRows,
53 termsFoldInto,
54} from './spine'
55
56// skillmaps: one plugin, three panes along the road of the AI Hero skills. grill-map draws a grilling's design tree,
57// wayfinder-map a /wayfinder map, spec-map a spec from its tickets to the merge. Each part's hooks are registered
58// only when its setting is on. The engine follows `$` only into functions declared at the top of this file, so
59// every part lives here, its names prefixed where two parts would clash; what draws without `$` sits beside it.
60
61type Part = 'grill' | 'wayfinder' | 'spec'
62const PARTS: Part[] = ['grill', 'wayfinder', 'spec']
63const PANE_OF: Record<Part, string> = { grill: 'grill-map', wayfinder: 'wayfinder-map', spec: 'spec-map' }
64const PART_OF: Record<string, Part> = { 'grill-map': 'grill', 'wayfinder-map': 'wayfinder', 'spec-map': 'spec' }
65
66/** The parts the settings turned on, as `register` read them. */
67let enabledParts: Part[] = PARTS
68/** The part whose pane opened last: a bare `/skillmaps` opens it again. */
69let lastPart: Part | null = null
70
71/** Whether a part's setting is on. */
72const is = (part: Part) => enabledParts.includes(part)
73
74/** Opens a part's pane, titled with its name. */
75const openPane = ($: EngineInterface, part: Part) => {
76 lastPart = part
77 return $.ui.open({ id: PANE_OF[part], title: PANE_OF[part] })
78}
79
80/** What a press on the header's legend runs: that part's pane opens. */
81const pickPane = ($: EngineInterface) => (name: string) => {
82 const part = PART_OF[name]
83 if (part && enabledParts.includes(part)) void openPane($, part)
84}
85
86/** The `slots` setting: it wins over a cap read from the skill; 0 leaves it to the skill. */
87let specConfiguredCap: number | null = null
88let specCalls = 0
89
90/** The plugin has one status line: a wayfinder Run's, else a grill's of its own. */
91const showStatus = ($: EngineInterface) => $.ui.status(wayShownStatus ?? grillShownStatus)
92
93// ======== grill-map
94const GRILL_PANE = 'grill-map'
95const GRILL_TITLE = 'grill-map'
96const GRILLS_KEPT = 8
97const GRILL_STEPS_KEPT = 200
98
99// The modsbook palette: muted pills on a dark track, dark ink on the pills.
100const GRILL_INK = '#151515'
101const GRILL_TRACK = '#2b2b2b'
102
103const MARKS: Mark[] = ['took', 'own', 'replied', 'open']
104const MARK_LABEL: Record<Mark, string> = { took: 'took', own: 'yours', replied: 'in reply', open: 'open' }
105
106const SKILLS = new Set<string>(['grilling', 'grill-me', 'grill-with-docs'])
107
108const GRILL_EMPTY: GrillBook = { grills: [], shown: null, expanded: [], isAllExpanded: false, lastPrompt: '' }
109const book = atom({ plugin: 'skillmaps', key: 'book' } as const, GRILL_EMPTY)
110
111// State kept from an older version lacks the fields added since.
112const grillCurrent = async ($: EngineInterface): Promise<GrillBook> => ({ ...GRILL_EMPTY, ...(await read($, book)) })
113
114let grillMade = 0
115const grillNewId = (prefix: string) => `${prefix}${Date.now().toString(36)}-${grillMade++}`
116
117// ── Inside /wayfinder: wayfinder-map owns the Run, grill-map reads it ──
118
119const RUN = { plugin: 'skillmaps', key: 'run' } as const
120
121/** The Run wayfinder-map is on, or null: none, or no wayfinder-map. */
122const grillRunOf = async ($: EngineInterface): Promise<WayfinderRun | null> => (await $.state.get(RUN)).value ?? null
123
124/** Which grill a step belongs to: the Run's claimed ticket, `charting`, or none outside a Run. */
125const keyOf = (run: WayfinderRun | null) => (run ? (run.ticket?.id ?? 'charting') : undefined)
126
127// ── The book of grills: the newest one still open is the one being worked ──
128
129const open = ($: EngineInterface) => void openPane($, 'grill')
130
131/** The newest grill, while it is open and belongs where the step goes (the Run's ticket, or no Run). */
132const liveFor = (b: GrillBook, key: string | undefined) => {
133 const g = b.grills.at(-1)
134 return g && !g.isDone && g.run === key ? g : null
135}
136
137const withLive = ($: EngineInterface, key: string | undefined, fn: (g: Grill) => Grill) =>
138 update($, book, b => {
139 const g = liveFor(b, key)
140 return g ? { ...b, grills: [...b.grills.slice(0, -1), fn(g)] } : b
141 })
142
143const push = (g: Grill, step: Step): Grill => ({ ...g, steps: [...g.steps, step].slice(-GRILL_STEPS_KEPT) })
144const mapStep = (g: Grill, id: string, fn: (s: Step) => Step): Grill => ({
145 ...g,
146 steps: g.steps.map(s => (s.id === id ? fn(s) : s)),
147})
148
149const start = async ($: EngineInterface, skill: GrillSkill, subject: string, run?: string) => {
150 const grill: Grill = {
151 id: grillNewId('g'),
152 skill,
153 subject: cut(subject, 200),
154 startedAt: Date.now(),
155 steps: [],
156 isDone: false,
157 ...(run ? { run } : {}),
158 }
159 await update($, book, b => ({ ...b, grills: [...b.grills, grill].slice(-GRILLS_KEPT), shown: null, expanded: [] }))
160 open($)
161}
162
163/**
164 * Where a step lands: the live grill, or one opened for it. Outside a Run only a round opens one;
165 * inside, a round or a dialog opens the ticket's grill (or the charting one), and any step does once
166 * a ticket is claimed. The start of a Run leaves the grill before it behind.
167 */
168const landing = async ($: EngineInterface, step: 'round' | 'dialog' | 'trace') => {
169 const run = await grillRunOf($)
170 const key = keyOf(run)
171 const b = await grillCurrent($)
172 if (liveFor(b, key)) return { key }
173 if (run && (step !== 'trace' || run.ticket)) {
174 await start($, 'grilling', run.ticket?.title ?? 'charting', key)
175 return { key }
176 }
177 if (!run && step === 'round') {
178 await start($, 'grilling', b.lastPrompt)
179 return { key }
180 }
181 return null
182}
183
184// A new level takes the focus: every level drawn whole by hand folds again.
185const addRound = ($: EngineInterface, key: string | undefined, round: RoundStep) =>
186 update($, book, b => {
187 const g = liveFor(b, key)
188 return g ? { ...b, grills: [...b.grills.slice(0, -1), push(g, round)], expanded: [] } : b
189 })
190
191let grillShownStatus: string | undefined
192
193/** The header's word for the grill shown: grilling while the model works, your turn while a round waits. */
194const grillHeaderState = (g: Grill | undefined): HeaderState => {
195 if (!g) return { state: 'idle', isLive: false }
196 if (g.isDone) return { state: 'done', isLive: false }
197 const last = g.steps.filter((s): s is RoundStep => s.kind === 'round').at(-1)
198 const step = lastTurn(g.steps)
199 if ((step?.kind === 'wrap' && step.reply === undefined) || (last && isWaiting(last))) return { state: 'your turn', isLive: false }
200 return { state: 'grilling', isLive: true }
201}
202
203/** The status line while a grill of its own is open; inside a Run, wayfinder-map's carries it. */
204const grillSyncStatus = async ($: EngineInterface) => {
205 const run = await grillRunOf($)
206 const g = run ? null : liveFor(await grillCurrent($), undefined)
207 let text: string | undefined
208 if (g) {
209 const rounds = g.steps.filter((s): s is RoundStep => s.kind === 'round')
210 const last = rounds.at(-1)
211 const step = lastTurn(g.steps)
212 const left = last ? last.questions.filter(q => markOf(q, last) === 'open').length : 0
213 text =
214 step?.kind === 'wrap' && step.reply === undefined
215 ? 'grill · confirm the shared understanding'
216 : last && isWaiting(last)
217 ? `grill L${rounds.length} · ${left} open`
218 : last
219 ? `grill L${rounds.length} · next frontier`
220 : 'grill · starting'
221 }
222 if (text === grillShownStatus) return
223 grillShownStatus = text
224 showStatus($)
225}
226
227// Bookkeeping never fails the call it watches.
228const grillQuietly = async (work: () => Promise<unknown>) => {
229 try {
230 await work()
231 } catch {
232 // the pane misses one update; the session goes on
233 }
234}
235
236/** grill-map: its hooks, registered when its setting is on. */
237const registerGrill = (on: Parameters<Register>[0], options: Parameters<Register>[1]) => {
238
239 on('command.run', { command: 'skillmaps' }, async ($, said, next) => {
240 const [word = '', ...rest] = said.args.trim().split(/\s+/)
241 if (word !== 'grill') return next(said)
242 const e = { ...said, args: rest.join(' ') }
243 if (e.args.trim() === 'clear') {
244 await update($, book, b => ({ ...GRILL_EMPTY, lastPrompt: b.lastPrompt }))
245 await grillQuietly(() => grillSyncStatus($))
246 return { text: 'Grill map cleared.' }
247 }
248 await openPane($, 'grill')
249 const { grills } = await grillCurrent($)
250 return { text: grills.length ? 'Grill map opened.' : 'Grill map opened: it fills once /grill-me, /grill-with-docs or /grilling runs.' }
251 })
252
253
254
255
256
257 on('ui.render', { component: 'Pane', requestId: GRILL_PANE }, async ($, e) => {
258 const { Box, Text, Button } = $.ui.resolve(e)
259 const el = { Box, Text, Button }
260 const b = await grillCurrent($)
261 const now = Date.now()
262 const inner = Math.max(24, e.props.bodyColumns - 4)
263 const g = b.grills.find(x => x.id === b.shown) ?? b.grills.at(-1)
264 const state = grillHeaderState(g)
265
266 const pill = (text: string, bg: string, fg = GRILL_INK, bold = false) => (
267 <Text backgroundColor={bg} color={fg} bold={bold}>
268 {` ${text} `}
269 </Text>
270 )
271 const heading = (title: string, right?: RenderChildren) => (
272 <Box flexDirection="row" justifyContent="space-between">
273 <Text>
274 <Text color={ACCENT}>◆ </Text>
275 <Text bold>{title}</Text>
276 </Text>
277 {right}
278 </Box>
279 )
280 const card = (key: string, children: RenderChildren) => (
281 <Box key={key} flexDirection="column" borderStyle="round" borderColor={EDGE} paddingX={1}>
282 {children}
283 </Box>
284 )
285
286 if (!g)
287 return (
288 <Box flexDirection="column">
289 {header(el, GRILL_PANE, e.props.bodyColumns, state, pickPane($))}
290 {card(
291 'empty',
292 <Box flexDirection="column">
293 {heading('grill map')}
294 <Text dimColor wrap="wrap">
295 /grill-me, /grill-with-docs or /grilling draws its design tree here, one level per round.
296 </Text>
297 </Box>,
298 )}
299 </Box>
300 )
301
302 const rounds = g.steps.filter((s): s is RoundStep => s.kind === 'round')
303 const all = rounds.flatMap(r => r.questions.map(q => markOf(q, r)))
304 const count = (m: Mark) => all.filter(x => x === m).length
305 const settled = all.length - count('open')
306 const terms = g.steps.reduce((n, s) => n + (s.kind === 'term' ? s.terms.length : 0), 0)
307 const adrs = g.steps.filter(s => s.kind === 'adr').length
308 const lookups = g.steps.reduce((n, s) => n + (s.kind === 'lookup' ? s.count : 0), 0)
309 const at = b.grills.indexOf(g)
310
311 const bar = () => {
312 let left = inner
313 const cells = MARKS.map((m, i) => {
314 const width = i === MARKS.length - 1 ? left : Math.round((count(m) / all.length) * inner)
315 left -= width
316 return { m, width: Math.max(0, width) }
317 })
318 return (
319 <Box flexDirection="row">
320 {cells
321 .filter(c => c.width > 0)
322 .map(c => (
323 <Text key={`bar-${c.m}`} backgroundColor={MARK_COLOR[c.m]}>
324 {' '.repeat(c.width)}
325 </Text>
326 ))}
327 </Box>
328 )
329 }
330
331 const head = card(
332 'head',
333 <Box flexDirection="column">
334 {heading(
335 g.skill,
336 <Text>
337 {b.grills.length > 1 && <Text dimColor>{`${at + 1}/${b.grills.length} `}</Text>}
338 {g.isDone
339 ? pill('done', GREEN, GRILL_INK, true)
340 : all.length > 0
341 ? pill(`${settled}/${all.length}`, settled === all.length ? GREEN : GRILL_TRACK, settled === all.length ? GRILL_INK : 'white', true)
342 : ''}
343 </Text>,
344 )}
345 {g.subject ? (
346 <Text bold wrap="truncate-end">
347 {g.subject}
348 </Text>
349 ) : (
350 <Text dimColor>Waiting for what to grill.</Text>
351 )}
352 {all.length > 0 && <Box marginTop={1}>{bar()}</Box>}
353 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
354 {MARKS.filter(m => count(m) > 0).map(m => (
355 <Text key={`legend-${m}`}>
356 <Text color={MARK_COLOR[m]}>{MARK[m]} </Text>
357 {MARK_LABEL[m]} <Text bold>{count(m)}</Text>
358 </Text>
359 ))}
360 {lookups > 0 && (
361 <Text key="legend-lookups">
362 <Text color={FOG}>⌕ </Text>
363 lookups <Text bold>{lookups}</Text>
364 </Text>
365 )}
366 {terms > 0 && (
367 <Text key="legend-terms">
368 <Text color={TEAL}>≡ </Text>
369 terms <Text bold>{terms}</Text>
370 </Text>
371 )}
372 {adrs > 0 && (
373 <Text key="legend-adrs">
374 <Text color={BLUE}>⚑ </Text>
375 ADRs <Text bold>{adrs}</Text>
376 </Text>
377 )}
378 </Box>
379 </Box>,
380 )
381
382 // ── The Spine: the design tree top to bottom, one level per round ──
383 const toggle = (id: string) =>
384 update($, book, x => ({
385 ...x,
386 expanded: x.expanded.includes(id) ? x.expanded.filter(k => k !== id) : [...x.expanded, id],
387 }))
388 const tree = card(
389 'tree',
390 <Box flexDirection="column">
391 {spineHeading(el, 'Spine', g.steps, g.isDone)}
392 {spineRows(el, {
393 steps: g.steps,
394 keyPrefix: '',
395 isClosed: g.isDone,
396 isLive: !g.isDone && g === b.grills.at(-1),
397 isAllUnfolded: b.isAllExpanded,
398 turned: b.expanded,
399 onTurn: toggle,
400 width: inner,
401 now,
402 startedAt: g.startedAt,
403 })}
404 </Box>,
405 )
406
407 const pick = (by: number) =>
408 update($, book, x => {
409 const i = x.grills.findIndex(k => k.id === (x.shown ?? x.grills.at(-1)?.id))
410 const to = x.grills[Math.min(x.grills.length - 1, Math.max(0, i + by))]
411 return { ...x, shown: to && to !== x.grills.at(-1) ? to.id : null, expanded: [] }
412 })
413
414 return (
415 <Box flexDirection="column">
416 {header(el, GRILL_PANE, e.props.bodyColumns, state, pickPane($))}
417 {head}
418 {tree}
419 <Box flexDirection="row" columnGap={1} paddingX={1}>
420 {foldAllButton(el, b.isAllExpanded, () =>
421 update($, book, x => ({ ...x, isAllExpanded: !x.isAllExpanded, expanded: [] })),
422 )}
423 {b.grills.length > 1 && <Button key="prev" label="Prev" hotkey="p" onPress={() => pick(-1)} />}
424 {b.grills.length > 1 && <Button key="next" label="Next" hotkey="n" onPress={() => pick(1)} />}
425 <Button key="clear" label="Clear" hotkey="c" onPress={async () => {
426 await update($, book, x => ({ ...GRILL_EMPTY, lastPrompt: x.lastPrompt }))
427 await grillSyncStatus($)
428 }} />
429 </Box>
430 </Box>
431 )
432 })
433}
434
435const grillSessionStart: Hook<'session.start'> = async ($, e, next) => {
436 if ((await grillCurrent($)).grills.length) open($)
437 grillShownStatus = undefined
438 await grillQuietly(() => grillSyncStatus($))
439 return next(e)
440}
441
442// The person's reply: the answers to the waiting round, the verdict on a wrap-up, or the subject itself.
443const grillPromptSubmit: Hook<'prompt.submit'> = async ($, e, next) => {
444 if (!e.command && !e.isMeta && e.text.trim())
445 await grillQuietly(async () => {
446 const key = keyOf(await grillRunOf($))
447 await update($, book, b => {
448 const text = e.text.trim()
449 const out = { ...b, lastPrompt: cut(firstLine(text), 200) }
450 const g = liveFor(b, key)
451 if (!g) return out
452 const last = lastTurn(g.steps)
453 let now = g
454 if (last?.kind === 'round' && last.reply === undefined) now = mapStep(g, last.id, () => applyReply(last, text))
455 else if (last?.kind === 'wrap' && last.reply === undefined) {
456 const wrap = settleWrap(last, text)
457 now = { ...mapStep(g, last.id, () => wrap), isDone: wrap.isConfirmed === true }
458 } else if (!last && !g.subject) now = { ...g, subject: cut(firstLine(text), 200) }
459 return { ...out, grills: [...b.grills.slice(0, -1), now] }
460 })
461 })
462 await grillQuietly(() => grillSyncStatus($))
463 return next(e)
464}
465
466// The wrappers start a grill of their own; /grilling joins the one they started, or the Run's, or starts its own.
467const grillSkillPrompt: Hook<'skill.prompt'> = async ($, e, next) => {
468 const name = e.skill.replace(/^.*:/, '')
469 // Another skill's argument (`/wayfinder <idea>`) is the subject of a grill it runs.
470 const given = plain(/ARGUMENTS:\s*(.*)$/m.exec(e.text)?.[1] ?? '')
471 if (!SKILLS.has(name) && given) await grillQuietly(() => update($, book, b => ({ ...b, lastPrompt: cut(given, 200) })))
472 if (SKILLS.has(name))
473 await grillQuietly(async () => {
474 if (name !== 'grilling') return start($, name as GrillSkill, given)
475 if (await grillRunOf($)) {
476 await landing($, 'round')
477 return open($)
478 }
479 const b = await grillCurrent($)
480 if (liveFor(b, undefined)) {
481 if (given) await withLive($, undefined, g => (g.subject ? g : { ...g, subject: cut(given, 200) }))
482 return open($)
483 }
484 await start($, 'grilling', given || b.lastPrompt)
485 })
486 // Beneath, wayfinder-map starts or ends its Run: the status line follows it.
487 const done = await next(e)
488 await grillQuietly(() => grillSyncStatus($))
489 return done
490}
491
492const grillToolCall: Hook<'tool.call'> = async ($, e, next) => {
493 if (e.agentId) return next(e)
494 const input = e as unknown as Record<string, unknown>
495 const path = e.tool === 'Write' || e.tool === 'Edit' ? e.file_path : ''
496 const isDoc = isDocPath(path)
497 const looked = lookupOf(e.tool, input)
498 const isDialog = e.tool === 'AskUserQuestion'
499 if (e.tool !== 'Agent' && !isDoc && looked === null && !isDialog) return next(e)
500 const at = await landing($, isDialog ? 'dialog' : 'trace')
501 if (!at) return next(e)
502 const { key } = at
503
504 // A fact the agent sends a sub-agent for: running until the sub-agent reports.
505 if (e.tool === 'Agent') {
506 const id = grillNewId('l')
507 await grillQuietly(() =>
508 withLive($, key, x =>
509 push(x, { kind: 'lookup', id, at: Date.now(), label: plain(e.description), isAgent: true, count: 1, isDone: false }),
510 ),
511 )
512 const ran = await next(e)
513 await grillQuietly(() => withLive($, key, x => mapStep(x, id, s => ({ ...s, isDone: true }))))
514 return ran
515 }
516
517 const before = e.tool === 'Write' && isDoc ? await $.fs.read(path).catch(() => null) : null
518
519 const ran = await next(e)
520 if (('deny' in ran && ran.deny) || ran.isError) return ran
521
522 if (e.tool === 'AskUserQuestion')
523 await grillQuietly(async () => {
524 await addRound($, key, dialogRound(e.questions, ran.result, grillNewId('r'), Date.now()))
525 await grillSyncStatus($)
526 })
527
528 // Reads and searches between rounds fold into one lookup.
529 if (looked !== null)
530 await grillQuietly(() =>
531 withLive($, key, x => {
532 const last = x.steps.at(-1)
533 const label = cut(looked, 60)
534 return foldsInto(last)
535 ? mapStep(x, last.id, s => (s.kind === 'lookup' ? { ...s, count: s.count + 1, label } : s))
536 : push(x, { kind: 'lookup', id: grillNewId('l'), at: Date.now(), label, isAgent: false, count: 1, isDone: true })
537 }),
538 )
539
540 // grill-with-docs: the glossary's resolved terms and the ADRs, as they are written.
541 if (isDoc && (e.tool === 'Write' || e.tool === 'Edit'))
542 await grillQuietly(async () => {
543 const trace =
544 e.tool === 'Write'
545 ? docTraceOf(path, e.content, before, false, grillNewId('d'), Date.now())
546 : docTraceOf(path, e.new_string, e.old_string, true, grillNewId('d'), Date.now())
547 if (!trace) return
548 await withLive($, key, x => {
549 const last = x.steps.at(-1)
550 return trace.kind === 'term' && termsFoldInto(last, path)
551 ? mapStep(x, last.id, s => (s.kind === 'term' ? { ...s, terms: [...new Set([...s.terms, ...trace.terms])] } : s))
552 : push(x, trace)
553 })
554 })
555
556 return ran
557}
558
559// A round is a main-loop turn that ends on questions; one that ends without them, once answered, is the wrap-up.
560const grillTurnComplete: Hook<'turn.complete'> = async ($, e, next) => {
561 const done = await next(e)
562 if (!e.agentId && e.reason === 'answer')
563 await grillQuietly(async () => {
564 const questions = parseRound(e.answer)
565 if (questions.length) {
566 const at = await landing($, 'round')
567 if (!at) return
568 await addRound($, at.key, { kind: 'round', id: grillNewId('r'), at: Date.now(), via: 'grilling', questions })
569 return open($)
570 }
571 const key = keyOf(await grillRunOf($))
572 const g = liveFor(await grillCurrent($), key)
573 if (g && isWrapDue(g.steps))
574 await withLive($, key, x => push(x, { kind: 'wrap', id: grillNewId('w'), at: Date.now(), text: cut(plain(e.answer), 600) }))
575 })
576 await grillQuietly(() => grillSyncStatus($))
577 return done
578}
579
580// ======== wayfinder-map
581const WAY_PANE = 'wayfinder-map'
582const WAY_TITLE = 'wayfinder-map'
583const WAY_POLL_MS = 60_000
584// About 800 KB at worst, of the store's 4 MiB in all.
585const LINES_KEPT = 400
586
587// The modsbook palette: muted pills on a dark track, dark ink on the pills.
588const WAY_INK = '#151515'
589const WAY_TRACK = '#2b2b2b'
590const WAY_EDGE = '#4a4a4a'
591const WAY_ACCENT = '#d97757'
592const WAY_FOG = '#8a93a6'
593type State = 'resolved' | 'claimed' | 'frontier' | 'blocked'
594const WAY_STATE_COLOR: Record<State, string> = {
595 resolved: '#5ccf9f',
596 claimed: WAY_ACCENT,
597 frontier: '#e2b867',
598 blocked: '#5a5a5a',
599}
600const STATE_MARK: Record<State, string> = { resolved: '✓', claimed: '●', frontier: '◇', blocked: '○' }
601const WAY_LEGEND: { state: State; label: string }[] = [
602 { state: 'resolved', label: 'resolved' },
603 { state: 'claimed', label: 'claimed' },
604 { state: 'frontier', label: 'frontier' },
605 { state: 'blocked', label: 'blocked' },
606]
607const TYPE_COLOR: Record<TicketType, string> = {
608 grilling: '#b79cf5',
609 prototype: '#7fb4f5',
610 research: '#6fd3c8',
611 task: '#c9a26b',
612}
613// Who resolves each type (the skill's Ticket Types).
614const MODE: Record<TicketType, string> = { grilling: 'HITL', prototype: 'HITL', research: 'AFK', task: 'either' }
615const TYPES = new Set<string>(['research', 'prototype', 'grilling', 'task'])
616
617const WAY_EMPTY: Wayfinder = {
618 target: null,
619 map: null,
620 lines: [],
621 tab: 'route',
622 focus: null,
623 turned: [],
624 isAllUnfolded: false,
625 error: null,
626 refreshedAt: null,
627}
628const way = atom({ plugin: 'skillmaps', key: 'way' } as const, WAY_EMPTY)
629const running = atom({ plugin: 'skillmaps', key: 'run' } as const, null)
630
631// State kept from an older version lacks the fields added since; its `grill` tab is gone.
632const wayCurrent = async ($: EngineInterface): Promise<Wayfinder> => {
633 const w = { ...WAY_EMPTY, ...(await read($, way)) }
634 return w.tab === 'route' || w.tab === 'decisions' ? w : { ...w, tab: 'route' }
635}
636const wayRunOf = async ($: EngineInterface): Promise<Run | null> => (await read($, running)) ?? null
637
638let wayMade = 0
639const wayNewId = (prefix: string) => `${prefix}${Date.now().toString(36)}-${wayMade++}`
640
641// ── The map and its tickets, as the tracker doc's "Wayfinding operations" lay them out ──
642
643/** `## Heading` → its body, lower-cased headings, HTML comments dropped. */
644const sections = (md: string): Record<string, string> => {
645 const out: Record<string, string> = {}
646 for (const part of md.replace(/<!--[\s\S]*?-->/g, '').split(/^##[ \t]+/m).slice(1)) {
647 const nl = part.indexOf('\n')
648 const head = (nl < 0 ? part : part.slice(0, nl)).trim().toLowerCase()
649 out[head] = nl < 0 ? '' : part.slice(nl + 1).trim()
650 }
651 return out
652}
653
654const items = (body: string) =>
655 body
656 .split('\n')
657 .map(line => line.replace(/^\s*(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?/, '').trim())
658 .filter(Boolean)
659
660const ticketIdIn = (text: string): string | null => {
661 const m = /issues\/(\d+)/.exec(text) ?? /#(\d+)\b/.exec(text)
662 return m ? String(Number(m[1])) : null
663}
664
665/** `- [Title](link): gist`, or `- Title: gist`. */
666const entry = (line: string): Entry => {
667 const linked = /^\[([^\]]+)\]\(([^)]*)\)\s*(?:[:—–-]\s*)?([\s\S]*)$/.exec(line)
668 if (linked) return { title: plain(linked[1] ?? ''), gist: plain(linked[3] ?? ''), ticket: ticketIdIn(linked[2] ?? '') }
669 const split = /^(.+?):\s+([\s\S]+)$/.exec(line)
670 return split
671 ? { title: plain(split[1] ?? ''), gist: plain(split[2] ?? ''), ticket: ticketIdIn(line) }
672 : { title: plain(line), gist: '', ticket: ticketIdIn(line) }
673}
674
675const mapBody = (md: string) => {
676 const s = sections(md)
677 return {
678 destination: plain(s['destination'] ?? ''),
679 notes: plain(s['notes'] ?? ''),
680 decisions: items(s['decisions so far'] ?? '').map(entry),
681 fog: items(s['not yet specified'] ?? '').map(plain),
682 outOfScope: items(s['out of scope'] ?? '').map(entry),
683 }
684}
685
686const blockersIn = (text: string): string[] | null => {
687 const m = /^\s*\**blocked by\**:?\**\s*(.*)$/im.exec(text)
688 return m ? [...(m[1] ?? '').matchAll(/\d+/g)].map(n => String(Number(n[0]))) : null
689}
690
691// A ticket the map's Out of scope section links, or one closed as not planned.
692const scoped = (tickets: Ticket[], outOfScope: Entry[]) => {
693 const out = new Set(outOfScope.map(e => e.ticket).filter(Boolean))
694 return tickets.map(t => (out.has(t.id) ? { ...t, isOutOfScope: true, isClosed: true } : t))
695}
696
697const wayGh = async ($: EngineInterface, args: string[]) => {
698 const run = await $.process.run(['gh', ...args], { timeoutMs: 20_000 })
699 if (run.exitCode !== 0) throw new Error(run.stderr.trim().split('\n')[0] || `gh ${args[0]} failed`)
700 return run.stdout
701}
702
703type WayGhIssue = {
704 number: number
705 title: string
706 state: string
707 state_reason?: string | null
708 stateReason?: string | null
709 body: string | null
710 labels?: { name: string }[]
711 assignees?: { login: string }[]
712 html_url?: string
713 url?: string
714 issue_dependencies_summary?: { total_blocked_by?: number }
715}
716
717const fromGh = async ($: EngineInterface, issue: WayGhIssue): Promise<Ticket> => {
718 const body = issue.body ?? ''
719 const label = (issue.labels ?? []).map(l => l.name).find(n => n.startsWith('wayfinder:'))
720 const type = label?.slice('wayfinder:'.length) ?? ''
721 let blockedBy = blockersIn(body)
722 if (blockedBy === null) {
723 // Native blocking: the canonical edges, one call per ticket that has any.
724 if (issue.issue_dependencies_summary?.total_blocked_by === 0) blockedBy = []
725 else {
726 try {
727 const deps = JSON.parse(
728 await wayGh($, ['api', `repos/{owner}/{repo}/issues/${issue.number}/dependencies/blocked_by`]),
729 ) as { number: number }[]
730 blockedBy = deps.map(d => String(d.number))
731 } catch {
732 blockedBy = []
733 }
734 }
735 }
736 const reason = (issue.state_reason ?? issue.stateReason ?? '').toLowerCase()
737 return {
738 id: String(issue.number),
739 title: issue.title,
740 type: TYPES.has(type) ? (type as TicketType) : null,
741 isClosed: issue.state.toLowerCase() === 'closed',
742 isClaimed: (issue.assignees ?? []).length > 0,
743 isOutOfScope: reason === 'not_planned',
744 blockedBy,
745 link: issue.html_url ?? issue.url ?? '',
746 question: plain(sections(body)['question'] ?? firstLine(body)),
747 }
748}
749
750const readGithub = async ($: EngineInterface, ref: string): Promise<MapView> => {
751 const head = JSON.parse(await wayGh($, ['issue', 'view', ref, '--json', 'number,title,body,url'])) as {
752 title: string
753 body: string
754 url: string
755 }
756 let subs: WayGhIssue[] = []
757 try {
758 subs = JSON.parse(await wayGh($, ['api', `repos/{owner}/{repo}/issues/${ref}/sub_issues?per_page=100`])) as WayGhIssue[]
759 } catch {
760 // no sub-issues on this repository: the map's task list names the children
761 }
762 if (!subs.length) {
763 const refs = [...head.body.matchAll(/^\s*[-*]\s*\[[ xX]\]\s*.*?#(\d+)/gm)].map(m => m[1] ?? '')
764 subs = await Promise.all(
765 refs.map(
766 async n =>
767 JSON.parse(
768 await wayGh($, ['issue', 'view', n, '--json', 'number,title,state,stateReason,body,labels,assignees,url']),
769 ) as WayGhIssue,
770 ),
771 )
772 }
773 const body = mapBody(head.body)
774 const tickets = await Promise.all(subs.map(s => fromGh($, s)))
775 return { title: head.title, link: head.url, ...body, tickets: scoped(tickets, body.outOfScope) }
776}
777
778const readLocal = async ($: EngineInterface, dir: string): Promise<MapView> => {
779 const md = await $.fs.read(`${dir}/map.md`)
780 const body = mapBody(md)
781 const files = (await $.fs.list(`${dir}/issues`).catch(() => []))
782 .filter(f => f.kind === 'file' && /^\d+.*\.md$/.test(f.name))
783 .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
784 const tickets = await Promise.all(
785 files.map(async (f): Promise<Ticket> => {
786 const path = `${dir}/issues/${f.name}`
787 const text = await $.fs.read(path)
788 const s = sections(text)
789 const status = (/^\s*\**status\**:?\**\s*(.+)$/im.exec(text)?.[1] ?? '').toLowerCase()
790 const type = (/^\s*\**type\**:?\**\s*(\w+)/im.exec(text)?.[1] ?? '').toLowerCase()
791 const slug = f.name.replace(/^\d+-?/, '').replace(/\.md$/, '').replace(/[-_]+/g, ' ')
792 const answer = plain(s['answer'] ?? '')
793 return {
794 id: String(Number(/^\d+/.exec(f.name)?.[0] ?? f.name)),
795 title: plain(/^#[ \t]+(.+)$/m.exec(text)?.[1] ?? slug),
796 type: TYPES.has(type) ? (type as TicketType) : null,
797 isClosed: /resolved|closed|done|scope|wontfix|dropped/.test(status),
798 isClaimed: /claimed/.test(status),
799 isOutOfScope: /scope/.test(status),
800 blockedBy: blockersIn(text) ?? [],
801 link: path,
802 question: plain(s['question'] ?? ''),
803 ...(answer ? { answer } : {}),
804 }
805 }),
806 )
807 const effort = dir.split('/').pop() ?? dir
808 return {
809 title: plain(/^#[ \t]+(.+)$/m.exec(md)?.[1] ?? effort.replace(/[-_]+/g, ' ')),
810 link: `${dir}/map.md`,
811 ...body,
812 tickets: scoped(tickets, body.outOfScope),
813 }
814}
815
816// ── The Spines: each ticket's lines and Charting's, kept per map ──
817
818const spineKey = (t: Target) => `spine:${t.source}:${t.ref}`
819// Before the Spine: the last rounds alone, read once and moved over.
820const roundsKey = (t: Target) => `rounds:${t.source}:${t.ref}`
821type OldRound = {
822 id: string
823 ticket: string | null
824 at: number
825 kind: 'grilling' | 'dialog'
826 questions: (Omit<Question, 'body'> & { body?: string })[]
827 reply?: string
828}
829const fromOld = (r: OldRound): Line => ({
830 kind: 'round',
831 id: r.id,
832 at: r.at,
833 via: r.kind,
834 questions: r.questions.map(q => ({ ...q, body: q.body ?? '' })),
835 ...(r.reply !== undefined ? { reply: r.reply } : {}),
836 ticket: r.ticket,
837})
838
839/** A ticket's Spine, or Charting's for `null`. */
840const linesOf = (lines: Line[], ticket: string | null): Step[] => lines.filter(l => l.ticket === ticket)
841
842/** The header's word: charting or on a claimed ticket while a Run goes, your turn while its round waits. */
843const wayHeaderState = (run: Run | null, lines: Line[], hasMap: boolean): HeaderState => {
844 if (!run) return { state: hasMap ? 'mapped' : 'idle', isLive: false }
845 const steps = linesOf(lines, run.ticket?.id ?? null)
846 const last = steps.filter((s): s is RoundStep => s.kind === 'round').at(-1)
847 const step = lastTurn(steps)
848 if ((step?.kind === 'wrap' && step.reply === undefined) || (last && isWaiting(last))) return { state: 'your turn', isLive: false }
849 return { state: run.ticket ? 'claimed' : 'charting', isLive: true }
850}
851
852/**
853 * Past the cap, the oldest lookups go first, never a Level or a Wrap-up; past that, the oldest lines
854 * of closed tickets, never the open ticket's or Charting's.
855 */
856const capped = (lines: Line[], map: MapView | null, open: string | null): Line[] => {
857 const closed = new Set((map?.tickets ?? []).filter(t => t.isClosed).map(t => t.id))
858 const out = [...lines]
859 while (out.length > LINES_KEPT) {
860 let i = out.findIndex(l => l.kind === 'lookup')
861 if (i < 0) i = out.findIndex(l => l.ticket !== null && l.ticket !== open && closed.has(l.ticket))
862 if (i < 0) break
863 out.splice(i, 1)
864 }
865 return out
866}
867
868const saveLines = async ($: EngineInterface) => {
869 const { target, lines } = await wayCurrent($)
870 if (target) await $.store.set(spineKey(target), lines)
871}
872
873/** The lines stored for a map; a map kept before the Spine has its rounds moved over, once. */
874const storedLines = async ($: EngineInterface, target: Target): Promise<Line[]> => {
875 const stored = (await $.store.get(spineKey(target))) as Line[] | undefined
876 if (stored) return stored
877 const old = (await $.store.get(roundsKey(target))) as OldRound[] | undefined
878 if (!old) return []
879 const lines = old.map(fromOld)
880 await $.store.set(spineKey(target), lines)
881 await $.store.delete(roundsKey(target))
882 return lines
883}
884
885// ── The Run: the stretch of a conversation that works a map ──
886
887let wayShownStatus: string | undefined
888
889/** Inside a Run, the line the claimed ticket's Spine (or Charting's) stands at; none outside. */
890const waySyncStatus = async ($: EngineInterface) => {
891 const run = await wayRunOf($)
892 let text: string | undefined
893 if (run) {
894 const steps = linesOf((await wayCurrent($)).lines, run.ticket?.id ?? null)
895 const subject = run.ticket ? cut(run.ticket.title, 32) : 'charting'
896 const rounds = steps.filter((s): s is RoundStep => s.kind === 'round')
897 const last = rounds.at(-1)
898 const step = lastTurn(steps)
899 const left = last ? last.questions.filter(q => markOf(q, last) === 'open').length : 0
900 text =
901 step?.kind === 'wrap' && step.reply === undefined
902 ? `${subject} · confirm the shared understanding`
903 : last && isWaiting(last)
904 ? `${subject} · L${rounds.length} · ${left} open`
905 : last
906 ? `${subject} · L${rounds.length} · next frontier`
907 : `${subject} · starting`
908 }
909 if (text === wayShownStatus) return
910 wayShownStatus = text
911 showStatus($)
912}
913
914const endRun = async ($: EngineInterface) => {
915 await update($, running, () => null)
916 await waySyncStatus($)
917}
918
919/** A claim seen in the Run: its lines go to that ticket until it closes. */
920const claim = async ($: EngineInterface, id: string) => {
921 if (!(await wayRunOf($))) return
922 const { map } = await wayCurrent($)
923 const title = map?.tickets.find(t => t.id === id)?.title ?? `#${id}`
924 await update($, running, r => (r ? { ticket: { id, title }, claimedAt: Date.now() } : r))
925 await waySyncStatus($)
926}
927
928// ── Following a map ──
929
930let refreshing: Promise<void> | null = null
931let isPending = false
932
933const wayRefresh = async ($: EngineInterface): Promise<void> => {
934 if (refreshing) {
935 isPending = true
936 return refreshing
937 }
938 refreshing = (async () => {
939 do {
940 isPending = false
941 const { target } = await wayCurrent($)
942 if (!target) break
943 try {
944 const map = target.source === 'github' ? await readGithub($, target.ref) : await readLocal($, target.ref)
945 await update($, way, w => {
946 // A GitHub answer fetched on focus stays until the ticket reopens.
947 const kept = new Map((w.map?.tickets ?? []).map(t => [t.id, t.answer]))
948 const tickets = map.tickets.map(t => (t.answer || !t.isClosed ? t : { ...t, answer: kept.get(t.id) }))
949 return { ...w, map: { ...map, tickets }, error: null, refreshedAt: Date.now() }
950 })
951 // The ticket worked on closed: later lines land on Charting until the next claim.
952 const run = await wayRunOf($)
953 if (run?.ticket && map.tickets.find(t => t.id === run.ticket?.id)?.isClosed) {
954 await update($, running, r => (r ? { ...r, ticket: null } : r))
955 await waySyncStatus($)
956 }
957 } catch (err) {
958 await update($, way, w => ({ ...w, error: String(err instanceof Error ? err.message : err) }))
959 }
960 } while (isPending)
961 })().finally(() => {
962 refreshing = null
963 })
964 return refreshing
965}
966
967const follow = async ($: EngineInterface, target: Target) => {
968 const was = (await wayCurrent($)).target
969 if (was?.source === target.source && was.ref === target.ref) return wayRefresh($)
970 const stored = await storedLines($, target)
971 await update($, way, w => {
972 // Charting held before the map existed joins it; another map's lines stay with that map.
973 const carried = w.target ? [] : w.lines
974 const ids = new Set(carried.map(l => l.id))
975 const lines = [...stored.filter(l => !ids.has(l.id)), ...carried].sort((a, b) => a.at - b.at)
976 return { ...w, target, map: null, focus: null, lines, turned: [], error: null }
977 })
978 await saveLines($)
979 void openPane($, 'wayfinder')
980 return wayRefresh($)
981}
982
983/** A map named in the skill's or the command's argument: a URL, `#42`, `42`, or a `.scratch/<effort>` path. */
984const targetIn = (text: string): Target | null => {
985 const local = /(\S*\.scratch\/[^/\s]+)/.exec(text)?.[1]
986 if (local) return { source: 'local', ref: local.replace(/\/(map\.md|issues.*)$/, '') }
987 const n = /issues\/(\d+)/.exec(text)?.[1] ?? /^\s*#?(\d+)\s*$/.exec(text)?.[1]
988 return n ? { source: 'github', ref: n } : null
989}
990
991// A map the repo already has: the newest `.scratch/*/map.md`, else the newest open `wayfinder:map` issue.
992const discover = async ($: EngineInterface): Promise<Target | null> => {
993 const dirs = (await $.fs.list('.scratch').catch(() => [])).filter(d => d.kind === 'dir')
994 const maps = await Promise.all(
995 dirs.map(async d => {
996 const stat = await $.fs.stat(`.scratch/${d.name}/map.md`).catch(() => null)
997 return stat ? { ref: `.scratch/${d.name}`, at: stat.mtimeMs } : null
998 }),
999 )
1000 const local = maps.filter(m => m !== null).sort((a, b) => b.at - a.at)[0]
1001 if (local) return { source: 'local', ref: local.ref }
1002 try {
1003 const open = JSON.parse(
1004 await wayGh($, ['issue', 'list', '--label', 'wayfinder:map', '--state', 'open', '--limit', '1', '--json', 'number']),
1005 ) as { number: number }[]
1006 return open[0] ? { source: 'github', ref: String(open[0].number) } : null
1007 } catch {
1008 return null
1009 }
1010}
1011
1012const SCRATCH = /^(.*\.scratch\/[^/]+)\/(?:map\.md|issues\/(\d+)[^/]*\.md)$/
1013
1014// Bookkeeping never fails the call it watches.
1015const wayQuietly = async (work: () => Promise<unknown>) => {
1016 try {
1017 await work()
1018 } catch {
1019 // the pane misses one update; the session goes on
1020 }
1021}
1022
1023/**
1024 * Writes the Run's lines: `fn` gets them with the ticket a new line goes to (null for Charting) and
1025 * the map. Outside a Run nothing lands: a map followed by hand keeps no Spine.
1026 */
1027const record = async ($: EngineInterface, fn: (lines: Line[], ticket: string | null, map: MapView | null) => Line[]) => {
1028 const run = await wayRunOf($)
1029 if (!run) return
1030 const ticket = run.ticket?.id ?? null
1031 await update($, way, w => ({ ...w, lines: capped(fn(w.lines, ticket, w.map), w.map, ticket) }))
1032 await saveLines($)
1033}
1034
1035const mapLine = (lines: Line[], id: string, fn: (l: Line) => Line) => lines.map(l => (l.id === id ? fn(l) : l))
1036
1037/** The newest line, when it is the given ticket's: what a read or a term folds into. */
1038const lastOf = (lines: Line[], ticket: string | null) => {
1039 const last = lines.at(-1)
1040 return last?.ticket === ticket ? last : undefined
1041}
1042
1043/** Once the map has a ticket, Charting keeps no lookups: those are a session reading the map to choose one. */
1044const keepsLookups = (ticket: string | null, map: MapView | null) => ticket !== null || !map?.tickets.length
1045
1046// A new Level takes the focus: every Level unfolded by hand folds again.
1047const addLevel = async ($: EngineInterface, round: RoundStep) => {
1048 await record($, (lines, ticket) => [...lines, { ...round, ticket }])
1049 await update($, way, w => ({ ...w, turned: [] }))
1050 await waySyncStatus($)
1051 void openPane($, 'wayfinder')
1052}
1053
1054const fetchAnswer = async ($: EngineInterface, id: string) => {
1055 const { target, map } = await wayCurrent($)
1056 const t = map?.tickets.find(x => x.id === id)
1057 if (target?.source !== 'github' || !t?.isClosed || t.answer) return
1058 const view = JSON.parse(await wayGh($, ['issue', 'view', id, '--json', 'comments'])) as { comments: { body: string }[] }
1059 const answer = plain(view.comments.at(-1)?.body ?? '') || 'Closed with no comment.'
1060 await update($, way, w =>
1061 w.map ? { ...w, map: { ...w.map, tickets: w.map.tickets.map(x => (x.id === id ? { ...x, answer } : x)) } } : w,
1062 )
1063}
1064
1065/** wayfinder-map: its hooks, registered when its setting is on. */
1066const registerWay = (on: Parameters<Register>[0], options: Parameters<Register>[1]) => {
1067
1068 // `/clear` ends the conversation, and the Run with it; compact, resume and fork keep it.
1069 on('session.end', async ($, e, next) => {
1070 if (e.reason === 'clear') await wayQuietly(() => endRun($))
1071 return next(e)
1072 })
1073
1074 on('command.run', { command: 'skillmaps' }, async ($, said, next) => {
1075 const [word = '', ...rest] = said.args.trim().split(/\s+/)
1076 if (word !== 'wayfinder') return next(said)
1077 const e = { ...said, args: rest.join(' ') }
1078 const arg = e.args.trim()
1079 if (arg === 'clear') {
1080 await update($, way, () => WAY_EMPTY)
1081 await endRun($)
1082 return { text: 'Wayfinder pane cleared.' }
1083 }
1084 await openPane($, 'wayfinder')
1085 if (arg === 'refresh' || arg === '') {
1086 const { target } = await wayCurrent($)
1087 if (target) {
1088 await wayRefresh($)
1089 return { text: 'Wayfinder pane opened.' }
1090 }
1091 const found = await discover($)
1092 if (!found) return { text: 'No wayfinder map found here. /skillmaps wayfinder <issue> or /skillmaps wayfinder .scratch/<effort> names one.' }
1093 await follow($, found)
1094 return { text: `Following the map at ${found.source === 'github' ? `#${found.ref}` : found.ref}.` }
1095 }
1096 const named = targetIn(arg)
1097 if (!named) return { text: 'Usage: /skillmaps wayfinder [<issue number or URL> | .scratch/<effort> | refresh | clear]' }
1098 await follow($, named)
1099 return { text: `Following the map at ${named.source === 'github' ? `#${named.ref}` : named.ref}.` }
1100 })
1101
1102
1103
1104
1105
1106 // The graph moved its selection: the ticket's card follows it.
1107 on('ui.message', { element: 'graph' }, async ($, e) => {
1108 const data = e.data as Partial<GraphMessage> | null
1109 if (data && typeof data === 'object' && 'focus' in data) {
1110 const focus = typeof data.focus === 'string' ? data.focus : null
1111 await update($, way, w => ({ ...w, focus }))
1112 if (focus) await wayQuietly(() => fetchAnswer($, focus))
1113 }
1114 return {}
1115 })
1116
1117 on('ui.render', { component: 'Pane', requestId: WAY_PANE }, async ($, e) => {
1118 const elements = $.ui.resolve(e)
1119 const { Box, Text, Button, Link } = elements
1120 // The graph needs a surface module's region: the terminal and the desktop draw one.
1121 const Client =
1122 (e.surface === 'terminal' || e.surface === 'desktop') && 'Client' in elements ? elements.Client : undefined
1123 const el = { Box, Text, Button }
1124 const w = await wayCurrent($)
1125 const run = await wayRunOf($)
1126 const mine = run?.ticket?.id ?? null
1127 const now = Date.now()
1128 const inner = Math.max(24, e.props.bodyColumns - 4)
1129 const map = w.map
1130 const all = map?.tickets ?? []
1131 const tickets = all.filter(t => !t.isOutOfScope)
1132 const byId = new Map(all.map(t => [t.id, t]))
1133 const name = (id: string) => byId.get(id)?.title ?? `#${id}`
1134 const isOpen = (id: string) => byId.has(id) && !byId.get(id)?.isClosed
1135
1136 const stateOf = (t: Ticket): State =>
1137 t.isClosed ? 'resolved' : t.isClaimed ? 'claimed' : t.blockedBy.some(isOpen) ? 'blocked' : 'frontier'
1138 // The skill takes the first frontier ticket in map order.
1139 const next = tickets.find(t => stateOf(t) === 'frontier')
1140 const count = (s: State) => tickets.filter(t => stateOf(t) === s).length
1141
1142 const pill = (text: string, bg: string, fg = WAY_INK, bold = false) => (
1143 <Text backgroundColor={bg} color={fg} bold={bold}>
1144 {` ${text} `}
1145 </Text>
1146 )
1147 const heading = (title: string, right?: RenderChildren) => (
1148 <Box flexDirection="row" justifyContent="space-between">
1149 <Text>
1150 <Text color={WAY_ACCENT}>◆ </Text>
1151 <Text bold>{title}</Text>
1152 </Text>
1153 {right}
1154 </Box>
1155 )
1156 const card = (key: string, children: RenderChildren) => (
1157 <Box key={key} flexDirection="column" borderStyle="round" borderColor={WAY_EDGE} paddingX={1}>
1158 {children}
1159 </Box>
1160 )
1161 const typeChip = (t: Ticket) =>
1162 t.type ? <Text color={TYPE_COLOR[t.type]}>{t.type}</Text> : <Text dimColor>untyped</Text>
1163
1164 const bar = () => {
1165 if (!tickets.length) return <Text backgroundColor={WAY_TRACK}>{' '.repeat(inner)}</Text>
1166 let left = inner
1167 const cells = WAY_LEGEND.map(({ state }, i) => {
1168 const width = i === WAY_LEGEND.length - 1 ? left : Math.round((count(state) / tickets.length) * inner)
1169 left -= width
1170 return { state, width: Math.max(0, width) }
1171 })
1172 return (
1173 <Box flexDirection="row">
1174 {cells
1175 .filter(c => c.width > 0)
1176 .map(c => (
1177 <Text key={`bar-${c.state}`} backgroundColor={WAY_STATE_COLOR[c.state]}>
1178 {' '.repeat(c.width)}
1179 </Text>
1180 ))}
1181 </Box>
1182 )
1183 }
1184
1185 // ── route: the tickets as a tree, each under the deepest ticket that blocks it ──
1186 const depth = new Map<string, number>()
1187 const depthOf = (t: Ticket, seen = new Set<string>()): number => {
1188 const known = depth.get(t.id)
1189 if (known !== undefined) return known
1190 if (seen.has(t.id)) return 0
1191 seen.add(t.id)
1192 const parents = t.blockedBy.map(id => byId.get(id)).filter((p): p is Ticket => !!p && !p.isOutOfScope)
1193 const d = parents.length ? 1 + Math.max(...parents.map(p => depthOf(p, seen))) : 0
1194 depth.set(t.id, d)
1195 return d
1196 }
1197 const parentOf = new Map<string, string>()
1198 for (const t of tickets) {
1199 const parents = t.blockedBy.map(id => byId.get(id)).filter((p): p is Ticket => !!p && !p.isOutOfScope)
1200 const deepest = parents.sort((a, b) => depthOf(b) - depthOf(a))[0]hooks/reply.ts 142 lines1// How a round is read from the agent's answer, and how the person's reply settles it.
2// Shared by grill-map and wayfinder-map, one identical copy in each; scripts/check.sh fails when they differ.
3
4import type { Question, RoundStep, Step, WrapStep } from '../types'
5
6export const firstLine = (text: string) => text.split('\n').find(line => line.trim())?.trim() ?? ''
7export const plain = (text: string) =>
8 text
9 .replace(/\*\*|__|`/g, '')
10 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
11 .replace(/\s+/g, ' ')
12 .trim()
13
14/** `❓ **Q1** - **Title**: body … ➡ recommended`, once per question of the round. */
15export const parseRound = (text: string): Question[] =>
16 text
17 .split(/^[ \t>]*❓️?\s*/m)
18 .slice(1)
19 .flatMap(chunk => {
20 const head = /^\**\s*Q?(\d+)\s*\**\s*[-–—:.]?\s*(?:\*\*([^*]+)\*\*\s*:?)?\s*([\s\S]*)$/i.exec(chunk)
21 if (!head) return []
22 const rest = (head[3] ?? '').split(/\n\s*---/)[0] ?? ''
23 const arrow = rest.search(/➡/)
24 const before = arrow < 0 ? rest : rest.slice(0, arrow)
25 const recommended = arrow < 0 ? '' : plain(rest.slice(arrow).replace(/^➡️?\s*/, '').split(/\n\s*\n/)[0] ?? '')
26 const option = (line: string) => /^\s*(?:[-*]|\(?[a-zA-Z0-9]\)|[A-Za-z]\.)\s+(.+)$/.exec(line)?.[1]
27 const lines = before.split('\n')
28 const options = lines
29 .map(option)
30 .filter((o): o is string => Boolean(o))
31 .map(plain)
32 .slice(0, 6)
33 const title = plain(head[2] ?? firstLine(before))
34 const body = plain(lines.filter(line => !option(line)).join('\n'))
35 return title ? [{ n: head[1] ?? '', title, body: head[2] ? body : '', options, recommended }] : []
36 })
37
38// ── A yes, and a Reserve ──
39
40// A word ends where no letter or digit follows: `\b` misses one after an accented letter (`sì`).
41const END = '(?=$|[^\\p{L}\\p{N}])'
42const YES = new RegExp(
43 `^(?:y|yes|yep|yeah|ok|okay|sure|agreed|go|lgtm|s[iì]|certo|va bene|confermo|d'accordo|perfetto|esatto|👍)${END}`,
44 'iu',
45)
46const RESERVE = new RegExp(
47 `(?:^|[^\\p{L}\\p{N}])(?:ma|però|tranne|eccetto|salvo|a parte|invece|solo se|but|except|unless|only if|however)${END}`,
48 'iu',
49)
50
51/** A reply that opens with a yes, Reserve or not. */
52export const isYes = (text: string) => YES.test(text.trim())
53
54/** A yes with a Reserve after it: `sì, ma …`, `yes, unless …`. */
55export const hasReserve = (text: string) => {
56 const m = YES.exec(text.trim())
57 return !!m && RESERVE.test(text.trim().slice(m[0].length))
58}
59
60/** A yes with no Reserve: the recommendation taken, the Wrap-up confirmed. */
61export const takes = (text: string) => isYes(text) && !hasReserve(text)
62
63// ── Settling a round ──
64
65/** How a question stands: took the recommendation, answered otherwise, answered in an unsplit reply, or open. */
66export type Mark = 'took' | 'own' | 'replied' | 'open'
67
68export const markOf = (q: Question, r: RoundStep): Mark =>
69 q.answer !== undefined ? (q.isAccepted ? 'took' : 'own') : r.reply !== undefined ? 'replied' : 'open'
70
71export const isWaiting = (r: RoundStep) => r.reply === undefined && r.questions.some(q => q.answer === undefined)
72
73/**
74 * Hands the person's reply out to the round's questions: `1. yes`, `Q2: no, …`, or one answer for all.
75 * A yes said whole to several questions answers them all, unless a Reserve follows it: the Reserve
76 * cannot be pinned to one, so the reply stays unsplit.
77 */
78export const applyReply = (round: RoundStep, reply: string): RoundStep => {
79 const known = new Set(round.questions.map(q => q.n))
80 const parts = new Map<string, string>()
81 let at: string | null = null
82 for (const line of reply.split('\n')) {
83 const m = /^\s*\**Q?(\d+)\**(?:\s*[).:\-–—]|\s+)\s*(.*)$/i.exec(line)
84 if (m && known.has(m[1] ?? '')) {
85 at = m[1] ?? null
86 if (at) parts.set(at, (m[2] ?? '').trim())
87 } else if (at && line.trim()) parts.set(at, `${parts.get(at) ?? ''} ${line.trim()}`.trim())
88 }
89 const whole = reply.trim()
90 const forAll = parts.size === 0 && (round.questions.length === 1 || takes(whole)) ? whole : undefined
91 return {
92 ...round,
93 reply: whole,
94 questions: round.questions.map(q => {
95 const said = parts.get(q.n) ?? forAll
96 return said === undefined ? q : { ...q, answer: said, isAccepted: takes(said) }
97 }),
98 }
99}
100
101/** An AskUserQuestion dialog as a Level: the recommended option is the one labelled "(Recommended)". */
102export const dialogRound = (
103 asked: readonly { question: string; options: readonly { label: string }[] }[],
104 result: unknown,
105 id: string,
106 at: number,
107): RoundStep => {
108 const { answers = {}, response } = (result ?? {}) as { answers?: Record<string, string>; response?: string }
109 const questions: Question[] = asked.map((q, i) => {
110 const answer = answers[q.question]
111 const recommended = q.options.find(o => /\(recommended\)/i.test(o.label))?.label ?? ''
112 return {
113 n: String(i + 1),
114 title: plain(q.question),
115 body: '',
116 options: q.options.map(o => o.label),
117 recommended,
118 ...(answer ? { answer, isAccepted: answer === recommended && !!recommended } : {}),
119 }
120 })
121 return { kind: 'round', id, at, via: 'dialog', questions, reply: response ?? Object.values(answers).join(', ') }
122}
123
124// ── The Wrap-up ──
125
126/** The newest Level or Wrap-up: what the person's next reply answers. */
127export const lastTurn = (steps: Step[]) =>
128 steps.filter((s): s is RoundStep | WrapStep => s.kind === 'round' || s.kind === 'wrap').at(-1)
129
130/**
131 * Whether a turn that ends without questions is a Wrap-up: once the newest Level is answered,
132 * or after a Wrap-up the person pushed back on.
133 */
134export const isWrapDue = (steps: Step[]) => {
135 const last = lastTurn(steps)
136 if (last?.kind === 'round') return last.reply !== undefined
137 return last?.kind === 'wrap' && last.reply !== undefined && !last.isConfirmed
138}
139
140/** The person's word on a Wrap-up: a yes with no Reserve confirms it. */
141export const settleWrap = (wrap: WrapStep, reply: string): WrapStep => ({ ...wrap, reply, isConfirmed: takes(reply) })
142hooks/header.tsx 158 lines1// The head every skillmaps pane opens with: the plugin and the map named in the map's own color with the state it
2// is in, a legend of the maps the settings turned on (a press opens that one), and a strip of braille dots that
3// each map animates its own way: grill-map's flames, wayfinder-map's traveller in the fog, spec-map's conveyor. It
4// moves every frame while the map works and at a slower beat at rest, and stops once the pane is off screen.
5
6import type { BoxProps, ButtonProps, ElementConstructor, TextProps } from 'claude-code'
7
8export type HeaderElements = {
9 Box: ElementConstructor<BoxProps>
10 Text: ElementConstructor<TextProps>
11 Button: ElementConstructor<ButtonProps>
12}
13
14/** What the map is doing: one word, drawn after its name, and whether the strip drifts. */
15export type HeaderState = { state: string; isLive: boolean }
16
17/** The maps, each in the color its title, legend square and strip are drawn in. */
18const COLORS: Record<string, string> = {
19 'grill-map': '#d97757',
20 'wayfinder-map': '#b1b9f9',
21 'spec-map': '#5cc89c',
22}
23
24const BOOK = 'SKILLMAPS'
25
26/** How often the plugin's `session.start` timer asks `isDrifting()`: the frame rate of a working map. */
27export const FRAME_MS = 150
28/** A map at rest still moves, at this slower beat. */
29const IDLE_MS = 600
30/** A pane that drew no header for this long is off screen: its strip stops. */
31const GONE_MS = 2000
32
33/** Each pane's last header: when it was drawn and whether its map was working. */
34const drawn = new Map<string, { at: number; isLive: boolean }>()
35let legend: string[] = Object.keys(COLORS)
36
37/** Whether a pane on screen is due a new frame: every frame while its map works, every beat at rest. */
38export const isDrifting = () => {
39 const now = Date.now()
40 for (const d of drawn.values()) {
41 if (now - d.at > GONE_MS) continue
42 if (d.isLive || Math.floor(now / IDLE_MS) !== Math.floor(d.at / IDLE_MS)) return true
43 }
44 return false
45}
46
47/** The maps the legend lists: the ones the settings turned on. */
48export const setLegend = (names: string[]) => {
49 legend = names
50}
51
52/** A hash of three integers to [0, 1): the same cell and frame always draw the same dots. */
53const noise = (a: number, b: number, c: number) => {
54 let h = Math.imul(a | 0, 374761393) ^ Math.imul(b | 0, 668265263) ^ Math.imul(c | 0, 2147483647)
55 h = Math.imul(h ^ (h >>> 13), 1274126177)
56 return ((h ^ (h >>> 16)) >>> 0) / 4294967296
57}
58
59// A braille cell's dots, bottom to top: its left column, then its right.
60const LEFT = [0x40, 0x04, 0x02, 0x01]
61const RIGHT = [0x80, 0x20, 0x10, 0x08]
62const cell = (bits: number) => String.fromCharCode(0x2800 + bits)
63/** A half-cell filled from the bottom, `h` dots high. */
64const column = (dots: number[], h: number) =>
65 dots.slice(0, Math.max(0, Math.min(4, h))).reduce((bits, dot) => bits | dot, 0)
66/** Each of a cell's eight dots on with chance `p`. */
67const sprinkle = (i: number, t: number, p: number, salt: number) => {
68 let bits = 0
69 for (let k = 0; k < 8; k++) if (noise(i * 8 + k, t, salt) < p) bits |= 1 << k
70 return bits
71}
72
73/** grill-map: flames, each half-cell a flicker of heat rising from the bottom, a spark above now and then. */
74const flames = (width: number, t: number) => {
75 const heat = (x: number) => Math.round(0.6 + 1.6 * Math.sin(x / 3.1 + t / 4) * Math.sin(x / 7.3 - t / 9) + 2 * noise(x, t, 1))
76 let out = ''
77 for (let i = 0; i < width; i++) {
78 let bits = column(LEFT, heat(2 * i)) | column(RIGHT, heat(2 * i + 1))
79 if (noise(i, t, 2) < 0.06) bits |= noise(i, t, 3) < 0.5 ? 0x01 : 0x08
80 out += cell(bits)
81 }
82 return out
83}
84
85/** wayfinder-map: fog drifting slowly left, and a traveller wandering through it at random, its trail fading. */
86const wander = (width: number, t: number) => {
87 // Three slow waves out of step: a walk that turns back and forth and never repeats soon.
88 const at = width * (0.5 + 0.22 * Math.sin(t / 17) + 0.15 * Math.sin(t / 7.3 + 1) + 0.08 * Math.sin(t / 3.1 + 2))
89 let out = ''
90 for (let i = 0; i < width; i++) {
91 const near = Math.min(Math.abs(i - at), width - Math.abs(i - at))
92 const isTrail = near < 6
93 out += cell(sprinkle(i + Math.floor(t / 3), isTrail ? t : Math.floor(t / 2), 0.07 + (isTrail ? 0.55 * (1 - near / 6) : 0), 5))
94 }
95 return out
96}
97
98/** spec-map: a conveyor, packets of dots flowing left to right along a thin track. */
99const conveyor = (width: number, t: number) => {
100 let out = ''
101 for (let i = 0; i < width; i++) {
102 const x = i - t
103 const isPacket = noise(Math.floor(x / 4), 0, 6) < 0.45 && ((x % 4) + 4) % 4 !== 3
104 out += cell(isPacket ? sprinkle(i, t, 0.6, 7) | 0x24 : noise(i, t, 8) < 0.5 ? 0x24 : 0x04)
105 }
106 return out
107}
108
109const STRIPS: Record<string, (width: number, t: number) => string> = {
110 'grill-map': flames,
111 'wayfinder-map': wander,
112 'spec-map': conveyor,
113}
114
115/** Three rows drawn to the pane's width: `SKILLMAPS · <MAP> <STATE>`, the legend, the strip. */
116export const header = (
117 { Box, Text, Button }: HeaderElements,
118 name: string,
119 columns: number,
120 at: HeaderState,
121 onPick: (name: string) => void,
122) => {
123 const now = Date.now()
124 drawn.set(name, { at: now, isLive: at.isLive })
125 const color = COLORS[name] ?? '#d97757'
126 // Working, the strip moves every frame; at rest, every beat.
127 const frame = Math.floor(now / (at.isLive ? FRAME_MS : IDLE_MS))
128 return (
129 <Box key="header" flexDirection="column" marginBottom={1}>
130 <Box justifyContent="center">
131 <Text wrap="truncate-end">
132 <Text bold>{BOOK}</Text>
133 <Text dimColor> · </Text>
134 <Text bold color={color}>
135 {name.toUpperCase()}
136 </Text>
137 <Text bold> {at.state.toUpperCase()}</Text>
138 </Text>
139 </Box>
140 {legend.length > 1 && (
141 <Box justifyContent="center" flexDirection="row">
142 {legend.map((m, i) => (
143 <Box key={`legend-${m}`} flexDirection="row">
144 <Text color={COLORS[m]} dimColor={m !== name}>
145 {i > 0 ? ' ■ ' : '■ '}
146 </Text>
147 <Button key={`map-${m}`} plain dimColor={m !== name} label={m} onPress={() => onPick(m)} />
148 </Box>
149 ))}
150 </Box>
151 )}
152 <Text color={color} dimColor={!at.isLive} wrap="truncate-end">
153 {(STRIPS[name] ?? flames)(Math.max(8, columns), frame)}
154 </Text>
155 </Box>
156 )
157}
158hooks/spine.tsx 413 lines1// The Spine: what a grilling session leaves, top to bottom, and how it is drawn.
2// Shared by grill-map and wayfinder-map, one identical copy in each; scripts/check.sh fails when they differ.
3
4import type { BoxProps, ButtonProps, ElementConstructor, RenderChildren, TextProps } from 'claude-code'
5
6import type { AdrStep, LookupStep, RoundStep, Step, TermStep } from '../types'
7import { isWaiting, markOf } from './reply'
8import type { Mark } from './reply'
9
10// ── Traces: what a tool call leaves on the Spine ──
11
12const GLOSSARY = /(?:^|\/)(?:CONTEXT|GLOSSARY)\.md$/
13const ADR = /(?:^|\/)docs\/adr\/(\d+)-([^/]+)\.md$/
14const TERM = /^\s*\*\*([^*\n]+?)\*\*\s*:/gm
15// Search tools a build may or may not ship; where it has none, the shell's searches count instead.
16const SEARCHES = new Set<string>(['Grep', 'Glob'])
17const SHELL_LOOKUP = /^\s*(rg|grep|cat|ls|find|head|tail|tree|sed -n|git (?:log|show|diff|grep|blame))\b/
18
19const plainText = (text: string) =>
20 text
21 .replace(/\*\*|__|`/g, '')
22 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
23 .replace(/\s+/g, ' ')
24 .trim()
25
26/** What a read or a search looked at, or null for a call that looks nothing up. */
27export const lookupOf = (tool: string, input: Record<string, unknown>): string | null => {
28 const text = (key: string) => String(input[key] ?? '')
29 if (tool === 'Read') return base(text('file_path'))
30 if (SEARCHES.has(tool)) return String(input['pattern'] ?? tool)
31 if (tool === 'WebFetch') return /^https?:\/\/([^/]+)/.exec(text('url'))?.[1] ?? text('url')
32 if (tool === 'WebSearch') return text('query')
33 if (tool === 'Bash' && SHELL_LOOKUP.test(text('command')))
34 return text('command').split('\n').find(line => line.trim())?.trim() ?? null
35 return null
36}
37
38/** Whether a `Write` or `Edit` of this path is a glossary or an ADR. */
39export const isDocPath = (path: string) => GLOSSARY.test(path) || ADR.test(path)
40
41const termsIn = (text: string) => new Set([...text.matchAll(TERM)].map(m => plainText(m[1] ?? '')).filter(Boolean))
42
43/**
44 * The Trace a glossary or ADR write leaves: the ADR, or the terms the write added.
45 * `previous` is the file before a `Write` (null when new), or the `old_string` an `Edit` replaced.
46 */
47export const docTraceOf = (
48 path: string,
49 written: string,
50 previous: string | null,
51 isEdit: boolean,
52 id: string,
53 at: number,
54): AdrStep | TermStep | null => {
55 const adr = ADR.exec(path)
56 if (adr) {
57 const slug = (adr[2] ?? '').replace(/[-_]+/g, ' ')
58 const title = plainText(/^#[ \t]+(.+)$/m.exec(written)?.[1] ?? slug)
59 return { kind: 'adr', id, at, file: path, number: adr[1] ?? '', title, isUpdate: isEdit || previous !== null }
60 }
61 if (!GLOSSARY.test(path)) return null
62 const had = termsIn(previous ?? '')
63 return { kind: 'term', id, at, file: path, terms: [...termsIn(written)].filter(t => !had.has(t)) }
64}
65
66/** A read folds into the lookup just before it; a sub-agent never does. */
67export const foldsInto = (last: Step | undefined): last is LookupStep => last?.kind === 'lookup' && !last.isAgent
68
69/** Terms written to the file the last Trace wrote fold into it. */
70export const termsFoldInto = (last: Step | undefined, path: string): last is TermStep =>
71 last?.kind === 'term' && last.file === path
72
73// ── Drawing ──
74
75// The modsbook palette.
76export const EDGE = '#4a4a4a'
77export const ACCENT = '#d97757'
78export const FOG = '#8a93a6'
79export const GREEN = '#5ccf9f'
80export const AMBER = '#e2b867'
81const VIOLET = '#b79cf5'
82export const TEAL = '#6fd3c8'
83export const BLUE = '#7fb4f5'
84
85export const MARK: Record<Mark, string> = { took: '✓', own: '✎', replied: '⏎', open: '◌' }
86export const MARK_COLOR: Record<Mark, string> = { took: GREEN, own: ACCENT, replied: FOG, open: AMBER }
87
88export const since = (from: number, to = Date.now()) => {
89 const s = Math.max(0, Math.round((to - from) / 1000))
90 if (s < 60) return `${s}s`
91 const m = Math.floor(s / 60)
92 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60}m`
93}
94export const cut = (text: string, room: number) => (text.length > room ? `${text.slice(0, Math.max(1, room - 1))}…` : text)
95export const base = (path: string) => path.split('/').pop() ?? path
96
97/** Words laid into lines of `width`, at most `max` of them, the last cut with `…`. */
98export const wrapLines = (text: string, width: number, max: number): string[] => {
99 const lines: string[] = []
100 let line = ''
101 for (const raw of text.split(/\s+/).filter(Boolean)) {
102 const word = cut(raw, width)
103 if (!line) line = word
104 else if (line.length + 1 + word.length <= width) line += ` ${word}`
105 else {
106 lines.push(line)
107 line = word
108 }
109 }
110 if (line) lines.push(line)
111 if (lines.length <= max) return lines
112 const kept = lines.slice(0, max)
113 const last = kept[max - 1] ?? ''
114 kept[max - 1] = last.length >= width ? `${last.slice(0, width - 1)}…` : `${last}…`
115 return kept
116}
117
118/** The elements a Spine is drawn with, from `$.ui.resolve(e)` on any surface. */
119export type SpineElements = {
120 Box: ElementConstructor<BoxProps>
121 Text: ElementConstructor<TextProps>
122 Button: ElementConstructor<ButtonProps>
123}
124
125export type SpineView = {
126 steps: Step[]
127 /** Put before each Level heading's key, so two Spines in one pane press apart. */
128 keyPrefix: string
129 /** A closed ticket, or a grill whose Wrap-up is confirmed: every Level folded. */
130 isClosed: boolean
131 /** Lines still arrive here: the tail says what the Spine waits for. */
132 isLive: boolean
133 isAllUnfolded: boolean
134 /** Levels a press turned from how they are drawn by default. */
135 turned: string[]
136 onTurn: (id: string) => unknown
137 /** Cells across. */
138 width: number
139 now: number
140 /** Draws a `started` row first. */
141 startedAt?: number
142}
143
144const rounds = (steps: Step[]) => steps.filter((s): s is RoundStep => s.kind === 'round')
145
146/** The open questions of the newest Level. */
147export const openCount = (steps: Step[]) => {
148 const last = rounds(steps).at(-1)
149 return last ? last.questions.filter(q => markOf(q, last) === 'open').length : 0
150}
151
152/** `Spine` with its count of Levels, and of open questions where the Spine is not closed. */
153export const spineHeading = (el: SpineElements, title: string, steps: Step[], isClosed: boolean) => {
154 const { Box, Text } = el
155 const levels = rounds(steps).length
156 const open = isClosed ? 0 : openCount(steps)
157 return (
158 <Box flexDirection="row" justifyContent="space-between">
159 <Text bold>{title}</Text>
160 <Text dimColor>
161 {levels} level{levels === 1 ? '' : 's'}
162 {open ? ` · ${open} open` : ''}
163 </Text>
164 </Box>
165 )
166}
167
168/** `a`: every Level of every Spine in the pane whole, or back to how each is drawn by default. */
169export const foldAllButton = (el: SpineElements, isAllUnfolded: boolean, onPress: () => unknown) => {
170 const { Button } = el
171 return <Button key="fold-all" label={isAllUnfolded ? 'Fold' : 'Unfold'} hotkey="a" onPress={onPress} />
172}
173
174/** The Spine's rows: each Level hanging from it, the Traces between, the Wrap-up where it closes. */
175export const spineRows = (el: SpineElements, v: SpineView): RenderChildren[] => {
176 const { Box, Text, Button } = el
177 /** One row of the spine: what it hangs from on the left, a note on the right. */
178 const row = (key: string, lead: RenderChildren, body: RenderChildren, right?: RenderChildren) => (
179 <Box key={key} flexDirection="row" justifyContent="space-between">
180 <Box flexShrink={1}>
181 <Text wrap="truncate-end">
182 {lead}
183 {body}
184 </Text>
185 </Box>
186 {right !== undefined && (
187 <Box flexShrink={0} marginLeft={1}>
188 {right}
189 </Box>
190 )}
191 </Box>
192 )
193 const spine = (rest = '') => <Text color={EDGE}>┃ {rest}</Text>
194 const lines: RenderChildren[] = []
195 const room = Math.max(12, v.width - 4)
196 const all = rounds(v.steps)
197 const lastRound = all.at(-1)
198
199 if (v.startedAt !== undefined)
200 lines.push(
201 row(
202 'start',
203 <Text color={VIOLET}>◎ </Text>,
204 <Text dimColor>started</Text>,
205 <Text dimColor>{since(v.startedAt, v.now)} ago</Text>,
206 ),
207 )
208
209 let level = 0
210 for (const s of v.steps) {
211 if (s.kind === 'lookup') {
212 lines.push(
213 row(
214 s.id,
215 spine(),
216 <Text dimColor>
217 <Text color={FOG}>⌕ </Text>
218 {s.isAgent ? `agent · ${s.label}` : `${s.count} ${s.count === 1 ? 'lookup' : 'lookups'} · ${s.label}`}
219 </Text>,
220 s.isAgent ? s.isDone ? <Text dimColor>done</Text> : <Text color={AMBER}>running</Text> : undefined,
221 ),
222 )
223 continue
224 }
225 if (s.kind === 'term') {
226 lines.push(
227 row(
228 s.id,
229 spine(),
230 <Text>
231 <Text color={TEAL}>≡ </Text>
232 <Text dimColor>{base(s.file)} · </Text>
233 {s.terms.length ? s.terms.join(', ') : <Text dimColor>updated</Text>}
234 </Text>,
235 ),
236 )
237 continue
238 }
239 if (s.kind === 'adr') {
240 lines.push(
241 row(
242 s.id,
243 spine(),
244 <Text>
245 <Text color={BLUE}>⚑ </Text>
246 <Text dimColor>ADR {s.number} · </Text>
247 {s.title}
248 </Text>,
249 s.isUpdate ? <Text dimColor>updated</Text> : undefined,
250 ),
251 )
252 continue
253 }
254 if (s.kind === 'wrap') {
255 const color = s.isConfirmed ? GREEN : s.reply !== undefined ? FOG : AMBER
256 const label = s.isConfirmed
257 ? 'shared understanding confirmed'
258 : s.reply !== undefined
259 ? 'wrap-up, pushed back'
260 : 'wrap-up · confirm the shared understanding'
261 lines.push(row(`${s.id}-gap`, spine(), ''))
262 lines.push(
263 row(
264 s.id,
265 <Text color={color}>{s.isConfirmed ? '◉ ' : '◎ '}</Text>,
266 <Text bold>{label}</Text>,
267 <Text dimColor>{since(s.at, v.now)}</Text>,
268 ),
269 )
270 const isLast = s === v.steps.at(-1)
271 wrapLines(s.text, room, s.reply !== undefined || v.isClosed ? 1 : 4).forEach((l, i) =>
272 lines.push(row(`${s.id}-t${i}`, isLast ? <Text>{' '}</Text> : spine(), <Text dimColor>{l}</Text>)),
273 )
274 if (s.reply !== undefined && !s.isConfirmed)
275 lines.push(
276 row(`${s.id}-reply`, isLast ? <Text>{' '}</Text> : spine(), <Text>
277 <Text color={ACCENT}>✎ </Text>
278 {s.reply}
279 </Text>),
280 )
281 continue
282 }
283
284 // A Level: its questions hanging from the spine; the newest drawn whole, unless the Spine is closed.
285 level += 1
286 const r = s
287 const isNewest = r === lastRound
288 const isWhole = isNewest && !v.isClosed
289 const isOpen = v.isAllUnfolded || isWhole !== v.turned.includes(r.id)
290 const waiting = isNewest && v.isLive && isWaiting(r)
291 const marks = r.questions.map(q => markOf(q, r))
292 const done = marks.filter(m => m !== 'open').length
293 lines.push(row(`${r.id}-gap`, spine(), ''))
294 lines.push(
295 <Box key={r.id} flexDirection="row" justifyContent="space-between">
296 <Box flexShrink={1} flexDirection="row">
297 <Text color={waiting ? AMBER : done === marks.length ? GREEN : FOG}>{waiting ? '◆ ' : '● '}</Text>
298 <Button key={`${v.keyPrefix}level-${level}`} plain label={`L${level}`} onPress={() => v.onTurn(r.id)} />
299 <Text wrap="truncate-end">
300 <Text dimColor>
301 {' · '}
302 {r.via === 'dialog' ? 'dialog · ' : ''}
303 {waiting ? '' : `${done}/${marks.length} settled`}
304 </Text>
305 {waiting ? <Text color={AMBER}>waiting for you</Text> : ''}
306 <Text color={EDGE}>{isOpen ? ' ▾' : ' ▸'}</Text>
307 </Text>
308 </Box>
309 <Box flexShrink={0} marginLeft={1}>
310 <Text dimColor>{since(r.at, v.now)}</Text>
311 </Box>
312 </Box>,
313 )
314
315 r.questions.forEach((q, qi) => {
316 const m = marks[qi] ?? 'open'
317 const key = `${r.id}-${q.n}-${qi}`
318 const sign = <Text color={MARK_COLOR[m]}>{MARK[m]} </Text>
319 if (!isOpen) {
320 const said =
321 m === 'took'
322 ? q.recommended || q.answer || ''
323 : m === 'own'
324 ? (q.answer ?? '')
325 : m === 'replied'
326 ? 'in the reply'
327 : q.recommended
328 ? `➡ ${q.recommended}`
329 : ''
330 lines.push(
331 row(key, spine(), <Text>
332 {sign}
333 <Text bold={m === 'open'}>{q.title}</Text>
334 {said ? <Text dimColor> → {said}</Text> : ''}
335 </Text>),
336 )
337 return
338 }
339 const sub = (k: string, body: RenderChildren) => lines.push(row(`${key}-${k}`, spine('│ '), body))
340 lines.push(
341 row(key, spine(), <Text>
342 {sign}
343 <Text dimColor>Q{q.n} </Text>
344 <Text bold>{q.title}</Text>
345 </Text>),
346 )
347 wrapLines(q.body, room - 2, 4).forEach((l, i) => sub(`b${i}`, <Text dimColor>{l}</Text>))
348 q.options.forEach((o, oi) => {
349 const isChosen = q.answer !== undefined && (q.answer === o || q.answer.split(/,\s*/).includes(o))
350 sub(
351 `o${oi}`,
352 <Text dimColor={!isChosen} bold={isChosen}>
353 <Text color={isChosen ? GREEN : EDGE}>{isChosen ? '● ' : '○ '}</Text>
354 {o}
355 </Text>,
356 )
357 })
358 if (q.recommended)
359 wrapLines(q.recommended, room - 4, 3).forEach((l, i) =>
360 sub(
361 `rec${i}`,
362 <Text bold={m === 'took'} dimColor={m === 'own'}>
363 <Text color={AMBER}>{i ? ' ' : '➡ '}</Text>
364 {l}
365 {m === 'took' && i === 0 ? <Text color={GREEN}> ✓</Text> : ''}
366 </Text>,
367 ),
368 )
369 if (m === 'own' && q.answer !== undefined && !(r.via === 'dialog' && q.options.includes(q.answer)))
370 wrapLines(q.answer, room - 4, 3).forEach((l, i) =>
371 sub(
372 `ans${i}`,
373 <Text bold>
374 <Text color={ACCENT}>{i ? ' ' : '✎ '}</Text>
375 {l}
376 </Text>,
377 ),
378 )
379 })
380 if (r.reply !== undefined && marks.includes('replied'))
381 wrapLines(r.reply, room - 2, isOpen ? 4 : 1).forEach((l, i) =>
382 lines.push(
383 row(`${r.id}-reply${i}`, spine(), <Text dimColor italic>
384 {i ? ' ' : '⏎ '}
385 {l}
386 </Text>),
387 ),
388 )
389 }
390
391 // Where the Spine goes from here, while lines still arrive.
392 if (!v.isLive || v.isClosed) return lines
393 const last = v.steps.at(-1)
394 const left = openCount(v.steps)
395 const tail =
396 last?.kind === 'wrap' && last.reply === undefined
397 ? null
398 : last?.kind === 'wrap' && last.isConfirmed
399 ? null
400 : lastRound && isWaiting(lastRound)
401 ? { color: AMBER, text: `${left} open · reply by number, 1. yes 2. …` }
402 : last?.kind === 'lookup' && last.isAgent && !last.isDone
403 ? { color: FOG, text: 'looking up a fact…' }
404 : all.length
405 ? { color: FOG, text: 'the next frontier…' }
406 : { color: FOG, text: 'the first round…' }
407 if (tail) {
408 lines.push(row('tail-gap', spine(), ''))
409 lines.push(row('tail', <Text color={tail.color}>▼ </Text>, <Text color={tail.color}>{tail.text}</Text>))
410 }
411 return lines
412}
413hooks/graph.tsx 455 lines1import type { ClientSurface, RenderElement } from 'claude-code'
2
3import type { GraphNode, GraphProps, NodeState, TicketType } from '../types'
4
5// The map drawn as the wayfinder article draws it: Start above, Destination below,
6// each ticket a box under the tickets that block it, arrows along the blocking edges.
7
8type GraphState = { sel: string | null; propSel: string | null; scroll: number }
9
10const ACCENT = '#d97757'
11const ARROW = '#a8a8a8'
12const DIM = '#5a5a5a'
13const TYPE_COLOR: Record<TicketType, string> = {
14 grilling: '#e5737b',
15 prototype: '#7fb4f5',
16 research: '#b79cf5',
17 task: '#e8a05c',
18}
19// One cell each, so the boxes line up in every terminal.
20const ICON: Record<TicketType, string> = { grilling: '?', prototype: '◫', research: '⌕', task: '≡' }
21const STATE_COLOR: Record<NodeState, string> = {
22 resolved: '#5ccf9f',
23 claimed: ACCENT,
24 frontier: '#e2b867',
25 blocked: DIM,
26}
27const MARK: Record<NodeState, string> = { resolved: '✓', claimed: '●', frontier: '◇', blocked: '○' }
28
29const ROUND = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', tj: '┬', bj: '┴', out: '┬' }
30const DOUBLE = { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║', tj: '╦', bj: '╩', out: '╤' }
31
32const GAP = 2
33const NODE_ROWS = 3
34
35type Kind = 'node' | 'dummy' | 'start' | 'dest'
36type Item = { key: string; kind: Kind; node: GraphNode | null; w: number; x: number; c: number }
37type Seg = { from: Item; to: Item; isLit: boolean }
38type Cell = { ch: string; color: string; bold: boolean; dim: boolean }
39
40// Line directions a cell joins, merged into one box-drawing character.
41const U = 1
42const D = 2
43const L = 4
44const R = 8
45const JOIN: Record<number, string> = {
46 [U]: '│',
47 [D]: '│',
48 [U | D]: '│',
49 [L]: '─',
50 [R]: '─',
51 [L | R]: '─',
52 [D | R]: '╭',
53 [D | L]: '╮',
54 [U | R]: '╰',
55 [U | L]: '╯',
56 [U | D | R]: '├',
57 [U | D | L]: '┤',
58 [L | R | D]: '┬',
59 [L | R | U]: '┴',
60 [U | D | L | R]: '┼',
61}
62
63const wrapText = (text: string, room: number, max: number) => {
64 const lines: string[] = []
65 let line = ''
66 for (const word of text.split(/\s+/).filter(Boolean)) {
67 if (line && line.length + 1 + word.length > room) {
68 lines.push(line)
69 line = ''
70 }
71 line = line ? `${line} ${word}` : word
72 }
73 if (line) lines.push(line)
74 if (lines.length > max) {
75 const kept = lines.slice(0, max)
76 kept[max - 1] = `${(kept[max - 1] ?? '').slice(0, room - 1)}…`
77 return kept
78 }
79 return lines.map(l => (l.length > room ? `${l.slice(0, room - 1)}…` : l))
80}
81
82export type Layout = {
83 width: number
84 rows: Cell[][]
85 boxes: { id: string; x: number; y: number; w: number; layer: number; c: number }[]
86}
87
88export const layout = (props: GraphProps, sel: string | null, columns: number): Layout => {
89 const nodes = props.nodes
90 const byId = new Map(nodes.map(n => [n.id, n]))
91 const edges = props.edges.filter(([a, b]) => byId.has(a) && byId.has(b) && a !== b)
92 const destLayer = Math.max(0, ...nodes.map(n => n.layer)) + 1
93 const hasIn = new Set(edges.map(([, b]) => b))
94 const hasOut = new Set(edges.map(([a]) => a))
95
96 const start: Item = { key: 'start', kind: 'start', node: null, w: 9, x: 0, c: 0 }
97 const dest: Item = { key: 'dest', kind: 'dest', node: null, w: 15, x: 0, c: 0 }
98 const layers: Item[][] = Array.from({ length: destLayer + 1 }, () => [])
99 layers[0] = [start]
100 layers[destLayer] = [dest]
101 const items = new Map<string, Item>([
102 ['start', start],
103 ['dest', dest],
104 ])
105 for (const n of nodes) {
106 const item: Item = { key: n.id, kind: 'node', node: n, w: 0, x: 0, c: 0 }
107 items.set(n.id, item)
108 layers[n.layer]?.push(item)
109 }
110 const layerOf = (key: string) => (key === 'start' ? 0 : key === 'dest' ? destLayer : (byId.get(key)?.layer ?? 0))
111 const lanes = new Map<string, number>()
112 const layerIndex = (it: Item) => (it.kind === 'dummy' ? (lanes.get(it.key) ?? 0) : layerOf(it.key))
113
114 // Start leads to every ticket nothing blocks; every ticket nothing waits on leads to the destination.
115 const full: [string, string][] = [
116 ...nodes.filter(n => !hasIn.has(n.id)).map(n => ['start', n.id] as [string, string]),
117 ...edges,
118 ...nodes.filter(n => !hasOut.has(n.id)).map(n => [n.id, 'dest'] as [string, string]),
119 ]
120 if (!nodes.length) full.push(['start', 'dest'])
121
122 // An edge across several rows runs through a one-cell lane in each row between.
123 const segs: { from: string; to: string; isLit: boolean }[] = []
124 for (const [a, b] of full) {
125 const isLit = sel !== null && (a === sel || b === sel)
126 let prev = a
127 for (let l = layerOf(a) + 1; l < layerOf(b); l++) {
128 const key = `lane:${a}>${b}:${l}`
129 const lane: Item = { key, kind: 'dummy', node: null, w: 1, x: 0, c: 0 }
130 items.set(key, lane)
131 lanes.set(key, l)
132 layers[l]?.push(lane)
133 segs.push({ from: prev, to: key, isLit })
134 prev = key
135 }
136 segs.push({ from: prev, to: b, isLit })
137 }
138
139 // Order each row by where its predecessors sit, then by where its successors sit, twice.
140 const preds = new Map<string, string[]>()
141 const succs = new Map<string, string[]>()
142 for (const s of segs) {
143 preds.set(s.to, [...(preds.get(s.to) ?? []), s.from])
144 succs.set(s.from, [...(succs.get(s.from) ?? []), s.to])
145 }
146 const place = new Map<string, number>()
147 const mark = (row: Item[]) => row.forEach((it, i) => place.set(it.key, (i + 0.5) / row.length))
148 layers.forEach(mark)
149 const sweep = (l: number, near: Map<string, string[]>) => {
150 const row = layers[l] ?? []
151 const at = (it: Item) => {
152 const ps = (near.get(it.key) ?? []).map(k => place.get(k) ?? 0.5)
153 return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : (place.get(it.key) ?? 0.5)
154 }
155 const ranked = row.map((it, i) => ({ it, i, at: at(it) }))
156 ranked.sort((a, b) => a.at - b.at || a.i - b.i)
157 layers[l] = ranked.map(r => r.it)
158 mark(layers[l] ?? [])
159 }
160 for (let pass = 0; pass < 2; pass++) {
161 for (let l = 1; l <= destLayer; l++) sweep(l, preds)
162 for (let l = destLayer - 1; l >= 1; l--) sweep(l, succs)
163 }
164
165 // As wide as the widest row lets every box be, and never narrower than a readable box.
166 let w = 28
167 for (const row of layers) {
168 const boxes = row.filter(it => it.kind === 'node').length
169 if (!boxes) continue
170 const lanes = row.length - boxes
171 w = Math.min(w, Math.floor((columns - (row.length - 1) * GAP - lanes) / boxes))
172 }
173 w = Math.max(14, w)
174 for (const it of items.values()) if (it.kind === 'node') it.w = w
175 const rowWidth = (row: Item[]) => row.reduce((sum, it) => sum + it.w, 0) + GAP * Math.max(0, row.length - 1)
176 const width = Math.max(columns, ...layers.map(rowWidth))
177 // Each box as near as its row allows to under its predecessors, the row kept in order and in the canvas.
178 const mid = Math.floor(width / 2)
179 for (const row of layers) {
180 const wanted = row.map((it, i) => {
181 if (it.kind === 'start' || it.kind === 'dest') return mid
182 const ps = (preds.get(it.key) ?? []).map(k => items.get(k)?.c ?? mid)
183 const even = Math.floor((width - rowWidth(row)) / 2) + rowWidth(row.slice(0, i)) + (i ? GAP : 0) + Math.floor(it.w / 2)
184 return ps.length ? Math.round(ps.reduce((a, b) => a + b, 0) / ps.length) : even
185 })
186 let right = -GAP
187 const xs = row.map((it, i) => {
188 const x = Math.max((wanted[i] ?? mid) - Math.floor(it.w / 2), right + GAP)
189 right = x + it.w
190 return x
191 })
192 // Shift the row by how far it sits from where it wanted to be, as far as the canvas allows.
193 const off = Math.round(row.reduce((sum, it, i) => sum + ((wanted[i] ?? mid) - Math.floor(it.w / 2) - (xs[i] ?? 0)), 0) / Math.max(1, row.length))
194 const lo = -(xs[0] ?? 0)
195 const hi = width - right
196 const shift = Math.max(lo, Math.min(hi, off))
197 row.forEach((it, i) => {
198 it.x = (xs[i] ?? 0) + shift
199 it.c = it.kind === 'start' || it.kind === 'dest' ? mid : it.x + Math.floor(it.w / 2)
200 })
201 }
202
203 // The two ends' words, under their flags.
204 const room = Math.max(16, Math.min(width - 4, 56))
205 const startLines = wrapText(props.start, room, 2)
206 const destLines = wrapText(props.destination, room, 3)
207 const heightOf = (l: number) => (l === 0 ? 1 + startLines.length : l === destLayer ? 1 + destLines.length : NODE_ROWS)
208
209 // Each gap gets one track per fan-out whose span overlaps another's.
210 const gaps: { top: number; tracks: Map<string, number>; rows: number }[] = []
211 let y = 0
212 const tops: number[] = []
213 for (let l = 0; l <= destLayer; l++) {
214 tops[l] = y
215 y += heightOf(l)
216 if (l === destLayer) break
217 const here = segs.filter(s => {
218 const from = items.get(s.from)
219 return from !== undefined && layerIndex(from) === l
220 })
221 const spans = new Map<string, [number, number]>()
222 for (const s of here) {
223 const a = items.get(s.from)
224 const b = items.get(s.to)
225 if (!a || !b || a.c === b.c) continue
226 const was = spans.get(s.from) ?? [a.c, a.c]
227 spans.set(s.from, [Math.min(was[0], b.c), Math.max(was[1], b.c)])
228 }
229 const tracks = new Map<string, number>()
230 const ends: number[] = []
231 for (const [key, [lo, hi]] of [...spans.entries()].sort((p, q) => p[1][0] - q[1][0])) {
232 let t = ends.findIndex(end => end < lo - 1)
233 if (t < 0) {
234 t = ends.length
235 ends.push(hi)
236 } else ends[t] = hi
237 tracks.set(key, t + 1)
238 }
239 gaps[l] = { top: y, tracks, rows: ends.length + 2 }
240 y += ends.length + 2
241 }
242 const height = y
243
244 // The canvas: line joins per cell, then the boxes and words drawn over them.
245 const dirs: number[][] = Array.from({ length: height }, () => new Array<number>(width).fill(0))
246 const lit: boolean[][] = Array.from({ length: height }, () => new Array<boolean>(width).fill(false))
247 const fixed: (Cell | null)[][] = Array.from({ length: height }, () => new Array<Cell | null>(width).fill(null))
248 const join = (row: number, col: number, d: number, isLit: boolean) => {
249 if (row < 0 || row >= height || col < 0 || col >= width) return
250 const line = dirs[row]
251 if (line) line[col] = (line[col] ?? 0) | d
252 if (isLit) {
253 const l2 = lit[row]
254 if (l2) l2[col] = true
255 }
256 }
257 const put = (row: number, col: number, cell: Cell) => {
258 const line = fixed[row]
259 if (line && col >= 0 && col < width) line[col] = cell
260 }
261 const text = (row: number, col: number, s: string, style: Omit<Cell, 'ch'>) =>
262 [...s].forEach((ch, i) => put(row, col + i, { ch, ...style }))
263 const vertical = (col: number, from: number, to: number, isLit: boolean) => {
264 for (let r = from; r <= to; r++) join(r, col, (r > from ? U : 0) | (r < to ? D : 0) | (from === to ? U | D : 0), isLit)
265 }
266
267 const arrows: { row: number; col: number; isLit: boolean }[] = []
268 for (const s of segs) {
269 const a = items.get(s.from)
270 const b = items.get(s.to)
271 if (!a || !b) continue
272 const gap = gaps[layerIndex(a)]
273 if (!gap) continue
274 const first = gap.top
275 const last = gap.top + gap.rows - 1
276 const t = gap.tracks.get(s.from)
277 if (t === undefined || a.c === b.c) {
278 vertical(a.c, first, last, s.isLit)
279 join(first, a.c, U, s.isLit)
280 } else {
281 const track = first + t
282 vertical(a.c, first, track, s.isLit)
283 join(first, a.c, U, s.isLit)
284 const step = b.c > a.c ? 1 : -1
285 join(track, a.c, step > 0 ? R : L, s.isLit)
286 for (let col = a.c + step; col !== b.c; col += step) join(track, col, L | R, s.isLit)
287 join(track, b.c, (step > 0 ? L : R) | D, s.isLit)
288 vertical(b.c, track, last, s.isLit)
289 join(track, b.c, D, s.isLit)
290 }
291 join(last, b.c, D, s.isLit)
292 if (b.kind !== 'dummy') arrows.push({ row: last, col: b.c, isLit: s.isLit })
293 }
294
295 // A lane runs straight through its row.
296 for (const it of items.values()) {
297 if (it.kind !== 'dummy') continue
298 const l = layerIndex(it)
299 const top = tops[l] ?? 0
300 const isLit = segs.some(s => (s.from === it.key || s.to === it.key) && s.isLit)
301 for (let r = top; r < top + heightOf(l); r++) join(r, it.c, U | D, isLit)
302 }
303
304 const boxes: Layout['boxes'] = []
305 for (const row of layers)
306 for (const it of row) {
307 const l = layerIndex(it)
308 const top = tops[l] ?? 0
309 if (it.kind === 'start' || it.kind === 'dest') {
310 const label = it.kind === 'start' ? '⚑ Start' : '⚑ Destination'
311 text(top, it.c - Math.floor(label.length / 2), label, { color: 'white', bold: true, dim: false })
312 const lines = it.kind === 'start' ? startLines : destLines
313 lines.forEach((line, i) =>
314 text(top + 1 + i, Math.floor((width - line.length) / 2), line, { color: ARROW, bold: false, dim: true }),
315 )
316 continue
317 }
318 const n = it.node
319 if (it.kind !== 'node' || !n) continue
320 boxes.push({ id: n.id, x: it.x, y: top, w: it.w, layer: l, c: it.c })
321 const isSel = n.id === sel
322 const color = n.state === 'blocked' ? DIM : n.type ? TYPE_COLOR[n.type] : ARROW
323 const frame = { color: isSel ? 'white' : color, bold: isSel, dim: n.state === 'resolved' && !isSel }
324 const k = isSel ? DOUBLE : ROUND
325 const inner = it.w - 2
326 text(top, it.x, `${k.tl}${k.h.repeat(3)}${k.tj}${k.h.repeat(inner - 4)}${k.tr}`, frame)
327 text(top + 2, it.x, `${k.bl}${k.h.repeat(3)}${k.bj}${k.h.repeat(inner - 4)}${k.br}`, frame)
328 if (segs.some(s => s.from === n.id)) put(top + 2, it.c, { ch: k.out, ...frame })
329 for (const col of [it.x, it.x + 4, it.x + it.w - 1]) put(top + 1, col, { ch: k.v, ...frame })
330 text(top + 1, it.x + 1, ` ${n.type ? ICON[n.type] : '·'} `, { color, bold: true, dim: n.state === 'resolved' && !isSel })
331 const room2 = it.w - 9
332 const mark2 = n.isNext ? '▶' : MARK[n.state]
333 const title = n.title.length > room2 ? `${n.title.slice(0, room2 - 1)}…` : n.title
334 put(top + 1, it.x + 5, { ch: ' ', color, bold: false, dim: false })
335 put(top + 1, it.x + 6, { ch: mark2, color: STATE_COLOR[n.state], bold: true, dim: false })
336 text(top + 1, it.x + 7, ` ${title.padEnd(room2)}`, {
337 color: n.state === 'blocked' ? ARROW : 'white',
338 bold: isSel || n.isMine || n.isNext,
339 dim: n.state === 'resolved' || n.state === 'blocked',
340 })
341 }
342
343 const rows: Cell[][] = []
344 for (let r = 0; r < height; r++) {
345 const line: Cell[] = []
346 for (let col = 0; col < width; col++) {
347 const f = fixed[r]?.[col]
348 if (f) {
349 line.push(f)
350 continue
351 }
352 const d = dirs[r]?.[col] ?? 0
353 const isLit = lit[r]?.[col] ?? false
354 line.push({ ch: d ? (JOIN[d] ?? '┼') : ' ', color: isLit ? ACCENT : ARROW, bold: isLit, dim: false })
355 }
356 rows.push(line)
357 }
358 for (const a of arrows) {
359 const line = rows[a.row]
360 if (line && !fixed[a.row]?.[a.col]) line[a.col] = { ch: '▼', color: a.isLit ? ACCENT : ARROW, bold: a.isLit, dim: false }
361 }
362 return { width, rows, boxes }
363}
364
365/** Where the arrows go from a box: along its edges first, else to the nearest box in that direction. */
366const move = (props: GraphProps, boxes: Layout['boxes'], from: string | null, key: string): string | null => {
367 if (!boxes.length) return null
368 const here = boxes.find(b => b.id === from)
369 if (!here) {
370 const mine = props.nodes.find(n => n.isMine) ?? props.nodes.find(n => n.isNext)
371 return mine?.id ?? boxes[0]?.id ?? null
372 }
373 const nearest = (list: typeof boxes) =>
374 [...list].sort((a, b) => Math.abs(a.c - here.c) - Math.abs(b.c - here.c))[0]?.id ?? here.id
375 if (key === 'left' || key === 'right') {
376 const row = boxes.filter(b => b.layer === here.layer).sort((a, b) => a.x - b.x)
377 const i = row.findIndex(b => b.id === here.id)
378 return row[i + (key === 'left' ? -1 : 1)]?.id ?? here.id
379 }
380 const isDown = key === 'down'
381 const linked = props.edges
382 .filter(([a, b]) => (isDown ? a === here.id : b === here.id))
383 .map(([a, b]) => (isDown ? b : a))
384 const along = boxes.filter(b => linked.includes(b.id))
385 if (along.length) return nearest(along)
386 const layers = [...new Set(boxes.map(b => b.layer))].sort((a, b) => a - b)
387 const next = isDown ? layers.find(l => l > here.layer) : [...layers].reverse().find(l => l < here.layer)
388 return next === undefined ? here.id : nearest(boxes.filter(b => b.layer === next))
389}
390
391const KEYS: Record<string, string> = { left: 'left', right: 'right', up: 'up', down: 'down', h: 'left', l: 'right', k: 'up', j: 'down' }
392
393export default function Graph(props: GraphProps, surface: ClientSurface<GraphState>): RenderElement {
394 const { Box, Text } = surface.elements
395 const st = surface.state
396 // The card the hooks module opened wins; a card closed keeps the box this graph selected.
397 const sel = st && (st.propSel === props.selected || props.selected === null) ? st.sel : props.selected
398 const columns = Math.max(20, surface.columns || props.columns)
399 const { width, rows, boxes } = layout(props, sel, columns)
400
401 // Keep the selected box in view when the map is wider than the pane.
402 const box = boxes.find(b => b.id === sel)
403 let scroll = Math.min(Math.max(0, st?.scroll ?? 0), Math.max(0, width - columns))
404 if (box && box.x < scroll) scroll = box.x
405 if (box && box.x + box.w > scroll + columns) scroll = box.x + box.w - columns
406
407 const choose = (next: string | null, focus: string | null) => {
408 surface.setState({ sel: next, propSel: props.selected, scroll })
409 surface.post({ focus })
410 }
411 surface.onKey(e => {
412 const dir = KEYS[e.key]
413 if (dir) {
414 const next = move(props, boxes, sel, dir)
415 choose(next, next)
416 } else if ((e.key === 'return' || e.key === ' ') && sel) choose(sel, props.selected === sel ? null : sel)
417 })
418 surface.onPointer(e => {
419 if (e.type !== 'down' || e.button !== 'left') return
420 const x = e.x + scroll
421 const hit = boxes.find(b => x >= b.x && x < b.x + b.w && e.y >= b.y && e.y < b.y + NODE_ROWS)
422 if (hit) choose(hit.id, props.selected === hit.id ? null : hit.id)
423 })
424
425 return (
426 <Box flexDirection="column">
427 {rows.map((line, r) => {
428 const cells = line.slice(scroll, scroll + columns)
429 const runs: Cell[] = []
430 for (const cell of cells) {
431 const last = runs[runs.length - 1]
432 if (last && last.color === cell.color && last.bold === cell.bold && last.dim === cell.dim) last.ch += cell.ch
433 else runs.push({ ...cell })
434 }
435 return (
436 <Text key={`r${r}`} wrap="truncate-end">
437 {runs.map((run, i) => (
438 <Text key={`r${r}-${i}`} color={run.color} bold={run.bold} dimColor={run.dim}>
439 {run.ch}
440 </Text>
441 ))}
442 </Text>
443 )
444 })}
445 {width > columns && (
446 <Text dimColor>
447 {scroll > 0 ? '◂ ' : ' '}
448 {Math.round((scroll / Math.max(1, width - columns)) * 100)}% of a wider map
449 {scroll < width - columns ? ' ▸' : ''}
450 </Text>
451 )}
452 </Box>
453 )
454}
455types/index.d.ts 254 lines1// skillmaps' state: one value per map, under the one plugin. A contract imports nothing.
2
3// ======== The Spine's steps: a grilling's rounds, lookups, glossary terms, ADRs and Wrap-up, as grill-map and
4// wayfinder-map draw them (hooks/spine.tsx and hooks/reply.ts read them).
5/** One question of a round (`❓ **Q1** - **Title**: body … ➡ recommended`) or of an AskUserQuestion dialog. */
6export type Question = {
7 n: string
8 title: string
9 /** The question's body, the choices left out. */
10 body: string
11 /** The choices the body listed, or the options the dialog offered. */
12 options: string[]
13 /** The recommended answer (`➡ …`), or the dialog's "(Recommended)" option. */
14 recommended: string
15 answer?: string
16 /** The person took the recommendation: a yes with no Reserve, or the recommended option. */
17 isAccepted?: boolean
18}
19
20/** A Level: one round, the frontier as it stood. */
21export type RoundStep = {
22 kind: 'round'
23 id: string
24 at: number
25 via: 'grilling' | 'dialog'
26 questions: Question[]
27 /** What the person wrote back, whole. */
28 reply?: string
29}
30
31/** A fact the agent went to find instead of asking: a sub-agent, or a run of reads and searches. */
32export type LookupStep = {
33 kind: 'lookup'
34 id: string
35 at: number
36 label: string
37 isAgent: boolean
38 /** Reads and searches folded into this step. */
39 count: number
40 isDone: boolean
41}
42
43/** Terms written to the glossary (`CONTEXT.md`, `GLOSSARY.md`). */
44export type TermStep = { kind: 'term'; id: string; at: number; file: string; terms: string[] }
45
46/** An ADR written under `docs/adr/`. */
47export type AdrStep = { kind: 'adr'; id: string; at: number; file: string; number: string; title: string; isUpdate: boolean }
48
49/** The Wrap-up: a turn that ended without questions once rounds were answered, the summary up for confirmation. */
50export type WrapStep = { kind: 'wrap'; id: string; at: number; text: string; reply?: string; isConfirmed?: boolean }
51
52export type Step = RoundStep | LookupStep | TermStep | AdrStep | WrapStep
53
54// ======== wayfinder-map
55
56export type TicketType = 'research' | 'prototype' | 'grilling' | 'task'
57
58export type Source = 'github' | 'local'
59
60/** Where the map lives: an issue number on GitHub, or a `.scratch/<effort>` folder. */
61export type Target = { source: Source; ref: string }
62
63/** One decision ticket: a child issue of the map, or a `.scratch/<effort>/issues/NN-*.md` file. */
64export type Ticket = {
65 /** The issue number, or the file's `NN`. */
66 id: string
67 title: string
68 type: TicketType | null
69 isClosed: boolean
70 isClaimed: boolean
71 isOutOfScope: boolean
72 /** The ids of the tickets that block it, open or closed. */
73 blockedBy: string[]
74 /** The issue's URL, or the file's path. */
75 link: string
76 /** The ticket's `## Question`. */
77 question: string
78 /** The resolution: a local `## Answer`, or the closing comment fetched on focus. */
79 answer?: string
80}
81
82/** One line of the map's Decisions so far or Out of scope: a gist, and the ticket it points at. */
83export type Entry = { title: string; gist: string; ticket: string | null }
84
85export type MapView = {
86 title: string
87 link: string
88 destination: string
89 notes: string
90 decisions: Entry[]
91 fog: string[]
92 outOfScope: Entry[]
93 tickets: Ticket[]
94}
95
96export type Tab = 'route' | 'decisions'
97
98export type NodeState = 'resolved' | 'claimed' | 'frontier' | 'blocked'
99
100/** One ticket as the map's graph draws it: a box in the row of its depth. */
101export type GraphNode = {
102 id: string
103 title: string
104 type: TicketType | null
105 state: NodeState
106 /** 1 for a ticket nothing blocks, one more than its deepest blocker otherwise. */
107 layer: number
108 isNext: boolean
109 isMine: boolean
110}
111
112/** What the graph's surface module draws from: the tickets, their blocking edges, the two ends. */
113export type GraphProps = {
114 nodes: GraphNode[]
115 /** `[blocker, blocked]` pairs. */
116 edges: [string, string][]
117 start: string
118 destination: string
119 /** The ticket whose card is open, which the graph selects. */
120 selected: string | null
121 columns: number
122}
123
124/** What the graph posts to the hooks module as its selection moves. */
125export type GraphMessage = { focus: string | null }
126
127/** One line of a map's Spines: a step, and the ticket it was taken under; null for Charting. */
128export type Line = Step & { ticket: string | null }
129
130export type Wayfinder = {
131 target: Target | null
132 map: MapView | null
133 /** Every ticket's Spine and Charting's, in the order the lines came; the map's store keeps them. */
134 lines: Line[]
135 tab: Tab
136 /** The ticket whose card is open on the route. */
137 focus: string | null
138 /** Levels a press turned from how they are drawn by default. */
139 turned: string[]
140 isAllUnfolded: boolean
141 error: string | null
142 refreshedAt: number | null
143}
144
145/** A Run: the stretch of a conversation that works a map, from `/wayfinder` to `/clear`, a separate grill or `/skillmaps wayfinder clear`. */
146export type Run = {
147 /** The ticket claimed in the Run, null while charting. */
148 ticket: { id: string; title: string } | null
149 /** When the last claim was seen. */
150 claimedAt: number | null
151}
152
153// ======== grill-map
154/** The skill that started the grill: the interview itself, or one of its two wrappers. */
155export type GrillSkill = 'grilling' | 'grill-me' | 'grill-with-docs'
156
157export type Grill = {
158 id: string
159 skill: GrillSkill
160 subject: string
161 startedAt: number
162 steps: Step[]
163 /** The person confirmed the shared understanding. */
164 isDone: boolean
165 /** Inside a wayfinder Run: the claimed ticket's id, or `charting`. Absent for a grill of its own. */
166 run?: string
167}
168
169export type GrillBook = {
170 grills: Grill[]
171 /** The grill the pane shows; null follows the newest. */
172 shown: string | null
173 /** Levels a press turned from how they are drawn by default. */
174 expanded: string[]
175 isAllExpanded: boolean
176 /** The person's last prompt, the subject of a grill the model starts by itself. */
177 lastPrompt: string
178}
179
180/** wayfinder-map's Run, read by grill-map to know a grill runs inside /wayfinder. */
181export type WayfinderRun = Run
182
183// ======== spec-map
184export type Stage = 'grill' | 'spec' | 'tickets' | 'implement' | 'review' | 'ship'
185
186export type Role = 'implementer' | 'merger' | 'reviewer' | 'explorer' | 'agent'
187
188/** One thing an agent did: a tool it ran, or a line it wrote between tools. */
189export type AgentStep = {
190 kind: 'tool' | 'say'
191 id: string
192 text: string
193 status: 'running' | 'ok' | 'error'
194 at: number
195 ms?: number
196 /** The first line of the error the tool answered. */
197 error?: string
198}
199
200export type AgentRow = {
201 id: string
202 role: Role
203 description: string
204 type: string
205 tickets: number[]
206 status: 'running' | 'done' | 'failed'
207 activity: string
208 report: string
209 startedAt: number
210 endedAt?: number
211 /** The newest steps, oldest first. */
212 steps: AgentStep[]
213 tools: number
214 errors: number
215}
216
217export type SpecTicket = {
218 number: number
219 title: string
220 isClosed: boolean
221 /** Merged into the integration branch: a merge commit of the spec's PR names it. */
222 isMerged: boolean
223 blockedBy: number[]
224}
225
226export type IntegrationPr = {
227 number: number
228 branch: string
229 state: 'open' | 'draft' | 'merged' | 'closed'
230 ci: 'pass' | 'fail' | 'running' | 'none'
231}
232
233export type Flow = {
234 stage: Stage | null
235 lastSkill: string | null
236 spec: { number: number; title: string } | null
237 pr: number | null
238 integration: IntegrationPr | null
239 /** The cap on parallel implementers the running skill states, if it states one. */
240 slotCap: number | null
241 tickets: SpecTicket[]
242 agents: AgentRow[]
243 /** The agent whose steps the pane lays out in full. */
244 focus: string | null
245 error: string | null
246 refreshedAt: number | null
247}
248
249declare module 'claude-code' {
250 interface PluginState {
251 skillmaps: { book: GrillBook; way: Wayfinder; run: Run | null; flow: Flow }
252 }
253}
254