SLOPSHOPPER

skillmaps

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…

paneguardcommandtoaststatusprompt
★ 1v0.4.0MITupdated 2026-10-19bardaxx/modsbook/mods/skillmaps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skillmaps
│ ┃ grill-map ✕ › fix the failing auth test and add an audit log call │ ┃ SKILLMAPS · GRILL-MAP IDLE │ ┃ ■ grill-map ■ wayfinder-map ■ spec-map ⏺ Read(src/auth.ts) │ ┃ ⣀⣀⣦⣦⣄⢀⢀⠀⢀⣀⣦⣶⣤⣠⣤⣰⣿⣶⣦⣤⣀⠀⢀⢀⣤⣀⡄⡀⣄⣰⣶⣴⣦⣶⣄⣤⣠⣠⣬⣄⣀⠀⠀… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ ◆ grill map │ ⏺ Bash(bun test) │ ┃ │ /grill-me, /grill-with-docs or /grilling │ ⎿ 3 pass, 1 fail │ ┃ │ draws its design tree here, one level │ │ ┃ │ per round. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ✻ Worked for 42s · done 4:20 PM │ │ › /skillmaps │ ⎿ skillmaps: grill-map opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · grill-map
SKILLMAPS · GRILL-MAP IDLE ■ grill-map ■ wayfinder-map ■ spec-map ⣀⣀⣦⣦⣄⢀⢀⠀⢀⣀⣦⣶⣤⣠⣤⣰⣿⣶⣦⣤⣀⠀⢀⢀⣤⣀⡄⡀⣄⣰⣶⣴⣦⣶⣄⣤⣠⣠⣬⣄⣀⠀⠀⢠⣦⣶⣶⣄⣠⣶⣼⣤⣶⣠⣀⢀ ╭──────────────────────────────────────────────────────────╮ │ ◆ grill map │ │ /grill-me, /grill-with-docs or /grilling draws its │ │ design tree here, one level per round. │ ╰──────────────────────────────────────────────────────────╯
Pane · wayfinder-map
SKILLMAPS · WAYFINDER-MAP IDLE ■ grill-map ■ wayfinder-map ■ spec-map ⠀⠀⡀⠄⠀⢁⠀⢀⠀⢐⠀⠈⠐⠀⠀⠀⠨⠀⠀⠔⣌⣮⣴⠊⠆⠔⢀⠐⠀⠁⠁⠀⠂⠀⠈⠀⠁⠈⠠⢠⠐⠀⠀⠐⠄⠀⡀⠠⠀⠂⡈⠄⡀⠁⠀⠀ ╭──────────────────────────────────────────────────────────╮ │ ◆ wayfinder no map │ │ /skillmaps wayfinder finds the map here, or /skillmaps │ │ wayfinder <issue | .scratch/effort> │ │ │ │ m: map d: decisions │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ map │ │ ▣ client module ./graph.tsx │ ╰──────────────────────────────────────────────────────────╯ [ Refresh ] [ Clear ]
Pane · spec-map
SKILLMAPS · SPEC-MAP IDLE ■ grill-map ■ wayfinder-map ■ spec-map ⠤⠤⠄⠤⣽⠷⣿⠄⣴⣾⢷⠤⠤⠄⠄⠤⣼⣤⠿⠤⠤⠄⠤⠄⠄⠤⠄⠤⣤⡮⣾⠤⡽⣼⣼⠤⠄⠤⠤⠤⠄⠄⠄⠤⡦⡧⠷⠄⠤⠄⠤⠤⠤⠤⠤⠤ ╭──────────────────────────────────────────────────────────╮ │ ◆ flow no spec 0% │ │ /skillmaps spec <n> to follow one │ │ │ │ grill ▸ spec ▸ tickets ▸ implement ▸ review ▸ ship │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ agents none implementing │ │ No agents yet. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ tickets none yet │ │ │ ╰──────────────────────────────────────────────────────────╯ [ Refresh ] [ Clear ]
README

skillmaps

A Claude Code mod of three side panes, one for each stretch of the road the AI Hero skills follow:

MapWhat 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-mapA 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                               │ ╰──────────────────────────────────────────────╯ │
╰────────────────────────────────────────────────────┴──────────────────────────────────────────────────╯

Requirements

  • Claude Code with function-hook mods (2.1.292 or later).
  • The skills each map follows, listed under its section. They come from mattpocock/skills, which skillmaps depends on: installing it installs 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.

Install

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.

Settings

SettingDefaultWhat it does
grill-maponThe grill pane and its status line.
wayfinder-maponThe wayfinder pane, its Run and its status line.
spec-maponThe spec pane.
spec-map implementer slots0How 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.

Coming from grill-map, wayfinder-map or spec-map

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.

Update

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

Use

Each pane opens by itself when its skill runs. One command reaches them all:

CommandWhat it does
/skillmapsOpens the pane used last, or the first one turned on.
/skillmaps grillOpens 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).

grill-map

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

What it shows

  • header: the skill that started the grill, its subject, the questions settled out of those asked, and one bar split by how each stands: ✓ took the recommendation, ✎ answered otherwise, ⏎ answered in a reply that was not split by number, ◌ still open. Beside it, the lookups, the glossary terms (≡) and the ADRs (⚑) of the grill.
  • Spine: the design tree, top to bottom, drawn as wayfinder-map draws each ticket's, from the same file.
  • Each round is a level, L1, L2, …: the frontier as it stood, every question whose prerequisites were settled. The newest level is drawn whole: each question's body, the choices it offered, the recommendation (➡, ✓ when the person said yes) and what the person answered instead (✎). An older level folds to one line per decision, title → what was chosen; a click or a tap on its heading unfolds it. Once the grill is confirmed, every level folds. Each new level takes the focus: when it arrives every level before it folds, one unfolded by hand too.
  • An AskUserQuestion dialog asked during the grill is a level too, the option picked marked ●.
  • ⌕ marks a fact the agent went to find instead of asking: a sub-agent, running until it reports, or a run of reads and searches folded into one line.
  • ≡ marks the terms written to CONTEXT.md or GLOSSARY.md, and ⚑ each ADR written under docs/adr/ (/grill-with-docs, through domain-modeling).
  • ◎ wrap-up is the turn that ends without questions once the rounds are answered: the frontier is empty and the summary waits for the person's word. A yes closes the grill, ◉ shared understanding confirmed; a yes with a reserve (sì, ma …, yes, but …) or anything else pushes it back, and the next wrap-up is put to the person again.
  • ▼ says where the tree goes next: the questions still open, a lookup running, or the next frontier being worked out.
  • status line: while a grill is open, its level and what waits on the person, grill L2 · 2 open, or grill · confirm the shared understanding at the wrap-up. It goes once the grill is confirmed or cleared.

A session can hold several grills: each new /grill-me or /grill-with-docs starts one, and p / n page through them.

Inside /wayfinder

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

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

Requirements

  • The grilling skills from mattpocock/skills: grilling, and the grill-me or grill-with-docs wrapper (grill-with-docs also needs domain-modeling).

Use

No command is needed: the pane opens when /grill-me, /grill-with-docs or /grilling runs, or when an answer holds a round in the grilling format.

CommandWhat it does
/skillmaps grillOpens the pane.
/skillmaps grill clearForgets the grills of this session.

In the pane, a click or a tap on a level's heading folds or unfolds it, a (or the Unfold / Fold button) unfolds every level or folds them back, p and n show the previous and next grill, and c clears.

How it knows

It showsIt reads
the grill and its subjectthe grill-me, grill-with-docs or grilling skill's prompt and its arguments; with none, the next prompt (a wrapper) or the last one (/grilling the model invoked)
a leveleach main-loop answer holding ❓ Q1 - Title: … and ➡️ …, the grilling skill's round format
what was chosenthe person's next prompt: 1. yes, Q2: no, …, or one answer for all; a yes followed by a reserve (ma, però, tranne, but, unless, …) is the person's own answer
lookupseach main-loop Agent call during the grill, and its Read, Grep, Glob, WebFetch, WebSearch and read-only shell calls (rg, grep, cat, ls, find, git log, …)
termseach Write or Edit of a CONTEXT.md or GLOSSARY.md: the Term: lines it adds
ADRseach Write or Edit of docs/adr/NNNN-slug.md, titled by its # heading
wrap-upthe first answer without questions after a round is answered, and the person's reply to it
dialogseach AskUserQuestion call and the answers it returned; an option labelled (Recommended) is the recommendation

Limits

  • A reply that answers a round in prose, without numbering, is kept whole under the level rather than split across its questions.
  • A reply is read as a yes when it opens with one (yes, ok, sì, lgtm, …) and no reserve follows. The grilling skill words each question so that yes takes its recommendation; a question worded against its recommendation is read the same way.
  • A yes with a reserve said whole to a round of several questions stays unsplit (⏎): the reserve cannot be pinned to one question.
  • A level is the round as the agent asked it: when the agent puts dependent questions in the same round, they sit on the same level.
  • The wrap-up is read from the shape of the turn. An answer that ends without questions for another reason, such as a pause to look something up, shows as a wrap-up until the next round arrives.
  • Grills are kept for the session, not across sessions.

wayfinder-map

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.

What it shows

  • header: the map's title, its destination, and one bar split by ticket state: resolved, claimed, on the frontier (open, unblocked, unclaimed), or blocked. Fog counts beside it. Under the destination, Spine · charting: what was asked and written with no ticket claimed. It is drawn open while the map has no tickets; once it has some, every level is folded, until a round comes in between one claim and the next.
  • map (m): the map as a graph, drawn the way the wayfinder article draws it. Start (the map's title) sits on top and Destination at the bottom. Each ticket is a box in the row of its depth, with arrows from the tickets that block it. A box's border and icon give its type: ? grilling, ◫ prototype, ⌕ research, ≡ task. The mark before its title gives its state: ✓ resolved, ● claimed, ◇ on the frontier (open, unblocked, unclaimed), ○ blocked, ▶ next (the first frontier ticket in map order, the one /wayfinder takes). Out-of-scope tickets stay off the map, and the map's Not yet specified section shows below it as fog.
  • navigating it: click the graph (or a box), then use the arrows (or h j k l). Up and down follow the blocking edges first, left and right move along a row. The selected box gets a double border, its edges light up, and its card opens below. ⏎ closes or reopens the card. Without giving the graph the keys, n and p step through the tickets row by row. On a map wider than the pane, the view scrolls to keep the selected box in sight.
  • ticket: the card of the selected ticket: its question, what it waits for and what it unblocks, whether it is worked with a human (HITL) or by the agent alone (AFK), and once resolved its answer (the ## Answer of a local ticket, or the last comment of a GitHub one). Under them, its Spine, drawn as grill-map draws a grill, from the same file. A long Spine makes the card longer, and the pane scrolls. x closes the card.
  • Each round is a level, L1, L2, …. On an open ticket the newest level is drawn whole: each question's body, the choices, the recommendation (➡, ✓ when taken) and the person's own answer (✎). Older levels fold to one line per decision. A click or a tap on a level's heading folds or unfolds it, and a (the Unfold / Fold button) does every level at once.
  • Between the levels: ⌕ a fact looked up (a sub-agent, or a run of reads folded into one line), ≡ the glossary terms written, ⚑ an ADR.
  • ◎ wrap-up is the summary put to the person once the rounds are answered, and ◉ marks it confirmed.
  • A resolved ticket's card shows its Spine with every level folded and the confirmed wrap-up last.
  • decisions (d): the map's Decisions so far, one gist per resolved ticket, then Out of scope and the map's Notes.
What lands on a Spine

Only a Run writes to a Spine. A Run starts with /wayfinder and lasts until /clear, a /grill-me or /grill-with-docs you ask for, or /skillmaps wayfinder clear. A map you open by hand with /skillmaps wayfinder is shown, not grilled into.

  • Inside a Run, each line goes to the ticket claimed in this session. With none claimed, it goes to charting.
  • Once a ticket closes, later lines go to charting until the next claim.
  • Once the map has tickets, charting keeps no lookups: those are a session reading the map to choose a ticket.
  • The status line names the ticket being worked and where its Spine stands, Pick the storage engine · L2 · 2 open, or charting · … with no ticket claimed. Outside a Run there is none.

Spines are kept per map across sessions, up to 400 lines a map. When a map goes over, the oldest lookups are dropped first, never a level or a wrap-up. After that go the oldest lines of resolved tickets, never those of the open ticket or of charting.

Requirements

  • A map laid out the way /wayfinder charts one, on either tracker setup-matt-pocock-skills configures:
  • GitHub: the map is an issue labelled wayfinder:map, its tickets sub-issues (or a task list in its body) labelled wayfinder:<type>, blocked through GitHub's native dependencies or a Blocked by: #n line. Needs the GitHub CLI, signed in, run from a checkout of the repository.
  • Local markdown: .scratch/<effort>/map.md and one .scratch/<effort>/issues/NN-<slug>.md per ticket, with Type:, Status: and Blocked by: lines.

Use

CommandWhat it does
/skillmaps wayfinderOpens 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 refreshReads the map and its tickets again.
/skillmaps wayfinder clearForgets the map followed in this session, and ends the Run.

In the pane, m and d switch tabs, n and p step through the tickets, x closes a ticket's card, a unfolds or folds every level, r refreshes and c clears. On mobile, which has no region for the graph, the map tab draws the route as an indented tree instead: a digit or a tap opens a ticket, and its card carries the same Spine, levels folded and unfolded by a tap. VS Code draws no mod panes yet.

Mostly no command is needed. The pane opens when /wayfinder runs: with a map as its argument it follows that map, and with a loose idea it waits for the map to be created (the gh issue create --label wayfinder:map call, or the write of .scratch/<effort>/map.md) and follows it from there. It reads the map again after each gh issue or gh api call that changes the tracker, after each write under .scratch/, and every minute.

How it knows

It showsIt reads
map, destination, decisions, fog, out of scopethe map's body: ## Destination, ## Notes, ## Decisions so far, ## Not yet specified, ## Out of scope
tickets, types, claimsgh api …/issues/<map>/sub_issues (labels, assignees, state), or the local issue files' Type: and Status: lines

| blocking edges | a `Bl

Source 6 files
hooks/register.tsx 2739 lines
1import { 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 lines
1// How a round is read from the agent's answer, and how the person's reply settles it.
2// Shared by grill-map and wayfinder-map, one identical copy in each; scripts/check.sh fails when they differ.
3
4import type { Question, RoundStep, Step, WrapStep } from '../types'
5
6export const firstLine = (text: string) => text.split('\n').find(line => line.trim())?.trim() ?? ''
7export const plain = (text: string) =>
8  text
9    .replace(/\*\*|__|`/g, '')
10    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
11    .replace(/\s+/g, ' ')
12    .trim()
13
14/** `❓ **Q1** - **Title**: body … ➡ recommended`, once per question of the round. */
15export const parseRound = (text: string): Question[] =>
16  text
17    .split(/^[ \t>]*❓️?\s*/m)
18    .slice(1)
19    .flatMap(chunk => {
20      const head = /^\**\s*Q?(\d+)\s*\**\s*[-–—:.]?\s*(?:\*\*([^*]+)\*\*\s*:?)?\s*([\s\S]*)$/i.exec(chunk)
21      if (!head) return []
22      const rest = (head[3] ?? '').split(/\n\s*---/)[0] ?? ''
23      const arrow = rest.search(/➡/)
24      const before = arrow < 0 ? rest : rest.slice(0, arrow)
25      const recommended = arrow < 0 ? '' : plain(rest.slice(arrow).replace(/^➡️?\s*/, '').split(/\n\s*\n/)[0] ?? '')
26      const option = (line: string) => /^\s*(?:[-*]|\(?[a-zA-Z0-9]\)|[A-Za-z]\.)\s+(.+)$/.exec(line)?.[1]
27      const lines = before.split('\n')
28      const options = lines
29        .map(option)
30        .filter((o): o is string => Boolean(o))
31        .map(plain)
32        .slice(0, 6)
33      const title = plain(head[2] ?? firstLine(before))
34      const body = plain(lines.filter(line => !option(line)).join('\n'))
35      return title ? [{ n: head[1] ?? '', title, body: head[2] ? body : '', options, recommended }] : []
36    })
37
38// ── A yes, and a Reserve ──
39
40// A word ends where no letter or digit follows: `\b` misses one after an accented letter (`sì`).
41const END = '(?=$|[^\\p{L}\\p{N}])'
42const YES = new RegExp(
43  `^(?:y|yes|yep|yeah|ok|okay|sure|agreed|go|lgtm|s[iì]|certo|va bene|confermo|d'accordo|perfetto|esatto|👍)${END}`,
44  'iu',
45)
46const RESERVE = new RegExp(
47  `(?:^|[^\\p{L}\\p{N}])(?:ma|però|tranne|eccetto|salvo|a parte|invece|solo se|but|except|unless|only if|however)${END}`,
48  'iu',
49)
50
51/** A reply that opens with a yes, Reserve or not. */
52export const isYes = (text: string) => YES.test(text.trim())
53
54/** A yes with a Reserve after it: `sì, ma …`, `yes, unless …`. */
55export const hasReserve = (text: string) => {
56  const m = YES.exec(text.trim())
57  return !!m && RESERVE.test(text.trim().slice(m[0].length))
58}
59
60/** A yes with no Reserve: the recommendation taken, the Wrap-up confirmed. */
61export const takes = (text: string) => isYes(text) && !hasReserve(text)
62
63// ── Settling a round ──
64
65/** How a question stands: took the recommendation, answered otherwise, answered in an unsplit reply, or open. */
66export type Mark = 'took' | 'own' | 'replied' | 'open'
67
68export const markOf = (q: Question, r: RoundStep): Mark =>
69  q.answer !== undefined ? (q.isAccepted ? 'took' : 'own') : r.reply !== undefined ? 'replied' : 'open'
70
71export const isWaiting = (r: RoundStep) => r.reply === undefined && r.questions.some(q => q.answer === undefined)
72
73/**
74 * Hands the person's reply out to the round's questions: `1. yes`, `Q2: no, …`, or one answer for all.
75 * A yes said whole to several questions answers them all, unless a Reserve follows it: the Reserve
76 * cannot be pinned to one, so the reply stays unsplit.
77 */
78export const applyReply = (round: RoundStep, reply: string): RoundStep => {
79  const known = new Set(round.questions.map(q => q.n))
80  const parts = new Map<string, string>()
81  let at: string | null = null
82  for (const line of reply.split('\n')) {
83    const m = /^\s*\**Q?(\d+)\**(?:\s*[).:\-–—]|\s+)\s*(.*)$/i.exec(line)
84    if (m && known.has(m[1] ?? '')) {
85      at = m[1] ?? null
86      if (at) parts.set(at, (m[2] ?? '').trim())
87    } else if (at && line.trim()) parts.set(at, `${parts.get(at) ?? ''} ${line.trim()}`.trim())
88  }
89  const whole = reply.trim()
90  const forAll = parts.size === 0 && (round.questions.length === 1 || takes(whole)) ? whole : undefined
91  return {
92    ...round,
93    reply: whole,
94    questions: round.questions.map(q => {
95      const said = parts.get(q.n) ?? forAll
96      return said === undefined ? q : { ...q, answer: said, isAccepted: takes(said) }
97    }),
98  }
99}
100
101/** An AskUserQuestion dialog as a Level: the recommended option is the one labelled "(Recommended)". */
102export const dialogRound = (
103  asked: readonly { question: string; options: readonly { label: string }[] }[],
104  result: unknown,
105  id: string,
106  at: number,
107): RoundStep => {
108  const { answers = {}, response } = (result ?? {}) as { answers?: Record<string, string>; response?: string }
109  const questions: Question[] = asked.map((q, i) => {
110    const answer = answers[q.question]
111    const recommended = q.options.find(o => /\(recommended\)/i.test(o.label))?.label ?? ''
112    return {
113      n: String(i + 1),
114      title: plain(q.question),
115      body: '',
116      options: q.options.map(o => o.label),
117      recommended,
118      ...(answer ? { answer, isAccepted: answer === recommended && !!recommended } : {}),
119    }
120  })
121  return { kind: 'round', id, at, via: 'dialog', questions, reply: response ?? Object.values(answers).join(', ') }
122}
123
124// ── The Wrap-up ──
125
126/** The newest Level or Wrap-up: what the person's next reply answers. */
127export const lastTurn = (steps: Step[]) =>
128  steps.filter((s): s is RoundStep | WrapStep => s.kind === 'round' || s.kind === 'wrap').at(-1)
129
130/**
131 * Whether a turn that ends without questions is a Wrap-up: once the newest Level is answered,
132 * or after a Wrap-up the person pushed back on.
133 */
134export const isWrapDue = (steps: Step[]) => {
135  const last = lastTurn(steps)
136  if (last?.kind === 'round') return last.reply !== undefined
137  return last?.kind === 'wrap' && last.reply !== undefined && !last.isConfirmed
138}
139
140/** The person's word on a Wrap-up: a yes with no Reserve confirms it. */
141export const settleWrap = (wrap: WrapStep, reply: string): WrapStep => ({ ...wrap, reply, isConfirmed: takes(reply) })
142
hooks/header.tsx 158 lines
1// 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}
158
hooks/spine.tsx 413 lines
1// The Spine: what a grilling session leaves, top to bottom, and how it is drawn.
2// Shared by grill-map and wayfinder-map, one identical copy in each; scripts/check.sh fails when they differ.
3
4import type { BoxProps, ButtonProps, ElementConstructor, RenderChildren, TextProps } from 'claude-code'
5
6import type { AdrStep, LookupStep, RoundStep, Step, TermStep } from '../types'
7import { isWaiting, markOf } from './reply'
8import type { Mark } from './reply'
9
10// ── Traces: what a tool call leaves on the Spine ──
11
12const GLOSSARY = /(?:^|\/)(?:CONTEXT|GLOSSARY)\.md$/
13const ADR = /(?:^|\/)docs\/adr\/(\d+)-([^/]+)\.md$/
14const TERM = /^\s*\*\*([^*\n]+?)\*\*\s*:/gm
15// Search tools a build may or may not ship; where it has none, the shell's searches count instead.
16const SEARCHES = new Set<string>(['Grep', 'Glob'])
17const SHELL_LOOKUP = /^\s*(rg|grep|cat|ls|find|head|tail|tree|sed -n|git (?:log|show|diff|grep|blame))\b/
18
19const plainText = (text: string) =>
20  text
21    .replace(/\*\*|__|`/g, '')
22    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
23    .replace(/\s+/g, ' ')
24    .trim()
25
26/** What a read or a search looked at, or null for a call that looks nothing up. */
27export const lookupOf = (tool: string, input: Record<string, unknown>): string | null => {
28  const text = (key: string) => String(input[key] ?? '')
29  if (tool === 'Read') return base(text('file_path'))
30  if (SEARCHES.has(tool)) return String(input['pattern'] ?? tool)
31  if (tool === 'WebFetch') return /^https?:\/\/([^/]+)/.exec(text('url'))?.[1] ?? text('url')
32  if (tool === 'WebSearch') return text('query')
33  if (tool === 'Bash' && SHELL_LOOKUP.test(text('command')))
34    return text('command').split('\n').find(line => line.trim())?.trim() ?? null
35  return null
36}
37
38/** Whether a `Write` or `Edit` of this path is a glossary or an ADR. */
39export const isDocPath = (path: string) => GLOSSARY.test(path) || ADR.test(path)
40
41const termsIn = (text: string) => new Set([...text.matchAll(TERM)].map(m => plainText(m[1] ?? '')).filter(Boolean))
42
43/**
44 * The Trace a glossary or ADR write leaves: the ADR, or the terms the write added.
45 * `previous` is the file before a `Write` (null when new), or the `old_string` an `Edit` replaced.
46 */
47export const docTraceOf = (
48  path: string,
49  written: string,
50  previous: string | null,
51  isEdit: boolean,
52  id: string,
53  at: number,
54): AdrStep | TermStep | null => {
55  const adr = ADR.exec(path)
56  if (adr) {
57    const slug = (adr[2] ?? '').replace(/[-_]+/g, ' ')
58    const title = plainText(/^#[ \t]+(.+)$/m.exec(written)?.[1] ?? slug)
59    return { kind: 'adr', id, at, file: path, number: adr[1] ?? '', title, isUpdate: isEdit || previous !== null }
60  }
61  if (!GLOSSARY.test(path)) return null
62  const had = termsIn(previous ?? '')
63  return { kind: 'term', id, at, file: path, terms: [...termsIn(written)].filter(t => !had.has(t)) }
64}
65
66/** A read folds into the lookup just before it; a sub-agent never does. */
67export const foldsInto = (last: Step | undefined): last is LookupStep => last?.kind === 'lookup' && !last.isAgent
68
69/** Terms written to the file the last Trace wrote fold into it. */
70export const termsFoldInto = (last: Step | undefined, path: string): last is TermStep =>
71  last?.kind === 'term' && last.file === path
72
73// ── Drawing ──
74
75// The modsbook palette.
76export const EDGE = '#4a4a4a'
77export const ACCENT = '#d97757'
78export const FOG = '#8a93a6'
79export const GREEN = '#5ccf9f'
80export const AMBER = '#e2b867'
81const VIOLET = '#b79cf5'
82export const TEAL = '#6fd3c8'
83export const BLUE = '#7fb4f5'
84
85export const MARK: Record<Mark, string> = { took: '✓', own: '✎', replied: '⏎', open: '◌' }
86export const MARK_COLOR: Record<Mark, string> = { took: GREEN, own: ACCENT, replied: FOG, open: AMBER }
87
88export const since = (from: number, to = Date.now()) => {
89  const s = Math.max(0, Math.round((to - from) / 1000))
90  if (s < 60) return `${s}s`
91  const m = Math.floor(s / 60)
92  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60}m`
93}
94export const cut = (text: string, room: number) => (text.length > room ? `${text.slice(0, Math.max(1, room - 1))}…` : text)
95export const base = (path: string) => path.split('/').pop() ?? path
96
97/** Words laid into lines of `width`, at most `max` of them, the last cut with `…`. */
98export const wrapLines = (text: string, width: number, max: number): string[] => {
99  const lines: string[] = []
100  let line = ''
101  for (const raw of text.split(/\s+/).filter(Boolean)) {
102    const word = cut(raw, width)
103    if (!line) line = word
104    else if (line.length + 1 + word.length <= width) line += ` ${word}`
105    else {
106      lines.push(line)
107      line = word
108    }
109  }
110  if (line) lines.push(line)
111  if (lines.length <= max) return lines
112  const kept = lines.slice(0, max)
113  const last = kept[max - 1] ?? ''
114  kept[max - 1] = last.length >= width ? `${last.slice(0, width - 1)}…` : `${last}…`
115  return kept
116}
117
118/** The elements a Spine is drawn with, from `$.ui.resolve(e)` on any surface. */
119export type SpineElements = {
120  Box: ElementConstructor<BoxProps>
121  Text: ElementConstructor<TextProps>
122  Button: ElementConstructor<ButtonProps>
123}
124
125export type SpineView = {
126  steps: Step[]
127  /** Put before each Level heading's key, so two Spines in one pane press apart. */
128  keyPrefix: string
129  /** A closed ticket, or a grill whose Wrap-up is confirmed: every Level folded. */
130  isClosed: boolean
131  /** Lines still arrive here: the tail says what the Spine waits for. */
132  isLive: boolean
133  isAllUnfolded: boolean
134  /** Levels a press turned from how they are drawn by default. */
135  turned: string[]
136  onTurn: (id: string) => unknown
137  /** Cells across. */
138  width: number
139  now: number
140  /** Draws a `started` row first. */
141  startedAt?: number
142}
143
144const rounds = (steps: Step[]) => steps.filter((s): s is RoundStep => s.kind === 'round')
145
146/** The open questions of the newest Level. */
147export const openCount = (steps: Step[]) => {
148  const last = rounds(steps).at(-1)
149  return last ? last.questions.filter(q => markOf(q, last) === 'open').length : 0
150}
151
152/** `Spine` with its count of Levels, and of open questions where the Spine is not closed. */
153export const spineHeading = (el: SpineElements, title: string, steps: Step[], isClosed: boolean) => {
154  const { Box, Text } = el
155  const levels = rounds(steps).length
156  const open = isClosed ? 0 : openCount(steps)
157  return (
158    <Box flexDirection="row" justifyContent="space-between">
159      <Text bold>{title}</Text>
160      <Text dimColor>
161        {levels} level{levels === 1 ? '' : 's'}
162        {open ? ` · ${open} open` : ''}
163      </Text>
164    </Box>
165  )
166}
167
168/** `a`: every Level of every Spine in the pane whole, or back to how each is drawn by default. */
169export const foldAllButton = (el: SpineElements, isAllUnfolded: boolean, onPress: () => unknown) => {
170  const { Button } = el
171  return <Button key="fold-all" label={isAllUnfolded ? 'Fold' : 'Unfold'} hotkey="a" onPress={onPress} />
172}
173
174/** The Spine's rows: each Level hanging from it, the Traces between, the Wrap-up where it closes. */
175export const spineRows = (el: SpineElements, v: SpineView): RenderChildren[] => {
176  const { Box, Text, Button } = el
177  /** One row of the spine: what it hangs from on the left, a note on the right. */
178  const row = (key: string, lead: RenderChildren, body: RenderChildren, right?: RenderChildren) => (
179    <Box key={key} flexDirection="row" justifyContent="space-between">
180      <Box flexShrink={1}>
181        <Text wrap="truncate-end">
182          {lead}
183          {body}
184        </Text>
185      </Box>
186      {right !== undefined && (
187        <Box flexShrink={0} marginLeft={1}>
188          {right}
189        </Box>
190      )}
191    </Box>
192  )
193  const spine = (rest = '') => <Text color={EDGE}>┃ {rest}</Text>
194  const lines: RenderChildren[] = []
195  const room = Math.max(12, v.width - 4)
196  const all = rounds(v.steps)
197  const lastRound = all.at(-1)
198
199  if (v.startedAt !== undefined)
200    lines.push(
201      row(
202        'start',
203        <Text color={VIOLET}>◎ </Text>,
204        <Text dimColor>started</Text>,
205        <Text dimColor>{since(v.startedAt, v.now)} ago</Text>,
206      ),
207    )
208
209  let level = 0
210  for (const s of v.steps) {
211    if (s.kind === 'lookup') {
212      lines.push(
213        row(
214          s.id,
215          spine(),
216          <Text dimColor>
217            <Text color={FOG}>⌕ </Text>
218            {s.isAgent ? `agent · ${s.label}` : `${s.count} ${s.count === 1 ? 'lookup' : 'lookups'} · ${s.label}`}
219          </Text>,
220          s.isAgent ? s.isDone ? <Text dimColor>done</Text> : <Text color={AMBER}>running</Text> : undefined,
221        ),
222      )
223      continue
224    }
225    if (s.kind === 'term') {
226      lines.push(
227        row(
228          s.id,
229          spine(),
230          <Text>
231            <Text color={TEAL}>≡ </Text>
232            <Text dimColor>{base(s.file)} · </Text>
233            {s.terms.length ? s.terms.join(', ') : <Text dimColor>updated</Text>}
234          </Text>,
235        ),
236      )
237      continue
238    }
239    if (s.kind === 'adr') {
240      lines.push(
241        row(
242          s.id,
243          spine(),
244          <Text>
245            <Text color={BLUE}>⚑ </Text>
246            <Text dimColor>ADR {s.number} · </Text>
247            {s.title}
248          </Text>,
249          s.isUpdate ? <Text dimColor>updated</Text> : undefined,
250        ),
251      )
252      continue
253    }
254    if (s.kind === 'wrap') {
255      const color = s.isConfirmed ? GREEN : s.reply !== undefined ? FOG : AMBER
256      const label = s.isConfirmed
257        ? 'shared understanding confirmed'
258        : s.reply !== undefined
259          ? 'wrap-up, pushed back'
260          : 'wrap-up · confirm the shared understanding'
261      lines.push(row(`${s.id}-gap`, spine(), ''))
262      lines.push(
263        row(
264          s.id,
265          <Text color={color}>{s.isConfirmed ? '◉ ' : '◎ '}</Text>,
266          <Text bold>{label}</Text>,
267          <Text dimColor>{since(s.at, v.now)}</Text>,
268        ),
269      )
270      const isLast = s === v.steps.at(-1)
271      wrapLines(s.text, room, s.reply !== undefined || v.isClosed ? 1 : 4).forEach((l, i) =>
272        lines.push(row(`${s.id}-t${i}`, isLast ? <Text>{'  '}</Text> : spine(), <Text dimColor>{l}</Text>)),
273      )
274      if (s.reply !== undefined && !s.isConfirmed)
275        lines.push(
276          row(`${s.id}-reply`, isLast ? <Text>{'  '}</Text> : spine(), <Text>
277            <Text color={ACCENT}>✎ </Text>
278            {s.reply}
279          </Text>),
280        )
281      continue
282    }
283
284    // A Level: its questions hanging from the spine; the newest drawn whole, unless the Spine is closed.
285    level += 1
286    const r = s
287    const isNewest = r === lastRound
288    const isWhole = isNewest && !v.isClosed
289    const isOpen = v.isAllUnfolded || isWhole !== v.turned.includes(r.id)
290    const waiting = isNewest && v.isLive && isWaiting(r)
291    const marks = r.questions.map(q => markOf(q, r))
292    const done = marks.filter(m => m !== 'open').length
293    lines.push(row(`${r.id}-gap`, spine(), ''))
294    lines.push(
295      <Box key={r.id} flexDirection="row" justifyContent="space-between">
296        <Box flexShrink={1} 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}
413
hooks/graph.tsx 455 lines
1import type { ClientSurface, RenderElement } from 'claude-code'
2
3import type { GraphNode, GraphProps, NodeState, TicketType } from '../types'
4
5// The map drawn as the wayfinder article draws it: Start above, Destination below,
6// each ticket a box under the tickets that block it, arrows along the blocking edges.
7
8type GraphState = { sel: string | null; propSel: string | null; scroll: number }
9
10const ACCENT = '#d97757'
11const ARROW = '#a8a8a8'
12const DIM = '#5a5a5a'
13const TYPE_COLOR: Record<TicketType, string> = {
14  grilling: '#e5737b',
15  prototype: '#7fb4f5',
16  research: '#b79cf5',
17  task: '#e8a05c',
18}
19// One cell each, so the boxes line up in every terminal.
20const ICON: Record<TicketType, string> = { grilling: '?', prototype: '◫', research: '⌕', task: '≡' }
21const STATE_COLOR: Record<NodeState, string> = {
22  resolved: '#5ccf9f',
23  claimed: ACCENT,
24  frontier: '#e2b867',
25  blocked: DIM,
26}
27const MARK: Record<NodeState, string> = { resolved: '✓', claimed: '●', frontier: '◇', blocked: '○' }
28
29const ROUND = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', tj: '┬', bj: '┴', out: '┬' }
30const DOUBLE = { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║', tj: '╦', bj: '╩', out: '╤' }
31
32const GAP = 2
33const NODE_ROWS = 3
34
35type Kind = 'node' | 'dummy' | 'start' | 'dest'
36type Item = { key: string; kind: Kind; node: GraphNode | null; w: number; x: number; c: number }
37type Seg = { from: Item; to: Item; isLit: boolean }
38type Cell = { ch: string; color: string; bold: boolean; dim: boolean }
39
40// Line directions a cell joins, merged into one box-drawing character.
41const U = 1
42const D = 2
43const L = 4
44const R = 8
45const JOIN: Record<number, string> = {
46  [U]: '│',
47  [D]: '│',
48  [U | D]: '│',
49  [L]: '─',
50  [R]: '─',
51  [L | R]: '─',
52  [D | R]: '╭',
53  [D | L]: '╮',
54  [U | R]: '╰',
55  [U | L]: '╯',
56  [U | D | R]: '├',
57  [U | D | L]: '┤',
58  [L | R | D]: '┬',
59  [L | R | U]: '┴',
60  [U | D | L | R]: '┼',
61}
62
63const wrapText = (text: string, room: number, max: number) => {
64  const lines: string[] = []
65  let line = ''
66  for (const word of text.split(/\s+/).filter(Boolean)) {
67    if (line && line.length + 1 + word.length > room) {
68      lines.push(line)
69      line = ''
70    }
71    line = line ? `${line} ${word}` : word
72  }
73  if (line) lines.push(line)
74  if (lines.length > max) {
75    const kept = lines.slice(0, max)
76    kept[max - 1] = `${(kept[max - 1] ?? '').slice(0, room - 1)}…`
77    return kept
78  }
79  return lines.map(l => (l.length > room ? `${l.slice(0, room - 1)}…` : l))
80}
81
82export type Layout = {
83  width: number
84  rows: Cell[][]
85  boxes: { id: string; x: number; y: number; w: number; layer: number; c: number }[]
86}
87
88export const layout = (props: GraphProps, sel: string | null, columns: number): Layout => {
89  const nodes = props.nodes
90  const byId = new Map(nodes.map(n => [n.id, n]))
91  const edges = props.edges.filter(([a, b]) => byId.has(a) && byId.has(b) && a !== b)
92  const destLayer = Math.max(0, ...nodes.map(n => n.layer)) + 1
93  const hasIn = new Set(edges.map(([, b]) => b))
94  const hasOut = new Set(edges.map(([a]) => a))
95
96  const start: Item = { key: 'start', kind: 'start', node: null, w: 9, x: 0, c: 0 }
97  const dest: Item = { key: 'dest', kind: 'dest', node: null, w: 15, x: 0, c: 0 }
98  const layers: Item[][] = Array.from({ length: destLayer + 1 }, () => [])
99  layers[0] = [start]
100  layers[destLayer] = [dest]
101  const items = new Map<string, Item>([
102    ['start', start],
103    ['dest', dest],
104  ])
105  for (const n of nodes) {
106    const item: Item = { key: n.id, kind: 'node', node: n, w: 0, x: 0, c: 0 }
107    items.set(n.id, item)
108    layers[n.layer]?.push(item)
109  }
110  const layerOf = (key: string) => (key === 'start' ? 0 : key === 'dest' ? destLayer : (byId.get(key)?.layer ?? 0))
111  const lanes = new Map<string, number>()
112  const layerIndex = (it: Item) => (it.kind === 'dummy' ? (lanes.get(it.key) ?? 0) : layerOf(it.key))
113
114  // Start leads to every ticket nothing blocks; every ticket nothing waits on leads to the destination.
115  const full: [string, string][] = [
116    ...nodes.filter(n => !hasIn.has(n.id)).map(n => ['start', n.id] as [string, string]),
117    ...edges,
118    ...nodes.filter(n => !hasOut.has(n.id)).map(n => [n.id, 'dest'] as [string, string]),
119  ]
120  if (!nodes.length) full.push(['start', 'dest'])
121
122  // An edge across several rows runs through a one-cell lane in each row between.
123  const segs: { from: string; to: string; isLit: boolean }[] = []
124  for (const [a, b] of full) {
125    const isLit = sel !== null && (a === sel || b === sel)
126    let prev = a
127    for (let l = layerOf(a) + 1; l < layerOf(b); l++) {
128      const key = `lane:${a}>${b}:${l}`
129      const lane: Item = { key, kind: 'dummy', node: null, w: 1, x: 0, c: 0 }
130      items.set(key, lane)
131      lanes.set(key, l)
132      layers[l]?.push(lane)
133      segs.push({ from: prev, to: key, isLit })
134      prev = key
135    }
136    segs.push({ from: prev, to: b, isLit })
137  }
138
139  // Order each row by where its predecessors sit, then by where its successors sit, twice.
140  const preds = new Map<string, string[]>()
141  const succs = new Map<string, string[]>()
142  for (const s of segs) {
143    preds.set(s.to, [...(preds.get(s.to) ?? []), s.from])
144    succs.set(s.from, [...(succs.get(s.from) ?? []), s.to])
145  }
146  const place = new Map<string, number>()
147  const mark = (row: Item[]) => row.forEach((it, i) => place.set(it.key, (i + 0.5) / row.length))
148  layers.forEach(mark)
149  const sweep = (l: number, near: Map<string, string[]>) => {
150    const row = layers[l] ?? []
151    const at = (it: Item) => {
152      const ps = (near.get(it.key) ?? []).map(k => place.get(k) ?? 0.5)
153      return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : (place.get(it.key) ?? 0.5)
154    }
155    const ranked = row.map((it, i) => ({ it, i, at: at(it) }))
156    ranked.sort((a, b) => a.at - b.at || a.i - b.i)
157    layers[l] = ranked.map(r => r.it)
158    mark(layers[l] ?? [])
159  }
160  for (let pass = 0; pass < 2; pass++) {
161    for (let l = 1; l <= destLayer; l++) sweep(l, preds)
162    for (let l = destLayer - 1; l >= 1; l--) sweep(l, succs)
163  }
164
165  // As wide as the widest row lets every box be, and never narrower than a readable box.
166  let w = 28
167  for (const row of layers) {
168    const boxes = row.filter(it => it.kind === 'node').length
169    if (!boxes) continue
170    const lanes = row.length - boxes
171    w = Math.min(w, Math.floor((columns - (row.length - 1) * GAP - lanes) / boxes))
172  }
173  w = Math.max(14, w)
174  for (const it of items.values()) if (it.kind === 'node') it.w = w
175  const rowWidth = (row: Item[]) => row.reduce((sum, it) => sum + it.w, 0) + GAP * Math.max(0, row.length - 1)
176  const width = Math.max(columns, ...layers.map(rowWidth))
177  // Each box as near as its row allows to under its predecessors, the row kept in order and in the canvas.
178  const mid = Math.floor(width / 2)
179  for (const row of layers) {
180    const wanted = row.map((it, i) => {
181      if (it.kind === 'start' || it.kind === 'dest') return mid
182      const ps = (preds.get(it.key) ?? []).map(k => items.get(k)?.c ?? mid)
183      const even = Math.floor((width - rowWidth(row)) / 2) + rowWidth(row.slice(0, i)) + (i ? GAP : 0) + Math.floor(it.w / 2)
184      return ps.length ? Math.round(ps.reduce((a, b) => a + b, 0) / ps.length) : even
185    })
186    let right = -GAP
187    const xs = row.map((it, i) => {
188      const x = Math.max((wanted[i] ?? mid) - Math.floor(it.w / 2), right + GAP)
189      right = x + it.w
190      return x
191    })
192    // Shift the row by how far it sits from where it wanted to be, as far as the canvas allows.
193    const off = Math.round(row.reduce((sum, it, i) => sum + ((wanted[i] ?? mid) - Math.floor(it.w / 2) - (xs[i] ?? 0)), 0) / Math.max(1, row.length))
194    const lo = -(xs[0] ?? 0)
195    const hi = width - right
196    const shift = Math.max(lo, Math.min(hi, off))
197    row.forEach((it, i) => {
198      it.x = (xs[i] ?? 0) + shift
199      it.c = it.kind === 'start' || it.kind === 'dest' ? mid : it.x + Math.floor(it.w / 2)
200    })
201  }
202
203  // The two ends' words, under their flags.
204  const room = Math.max(16, Math.min(width - 4, 56))
205  const startLines = wrapText(props.start, room, 2)
206  const destLines = wrapText(props.destination, room, 3)
207  const heightOf = (l: number) => (l === 0 ? 1 + startLines.length : l === destLayer ? 1 + destLines.length : NODE_ROWS)
208
209  // Each gap gets one track per fan-out whose span overlaps another's.
210  const gaps: { top: number; tracks: Map<string, number>; rows: number }[] = []
211  let y = 0
212  const tops: number[] = []
213  for (let l = 0; l <= destLayer; l++) {
214    tops[l] = y
215    y += heightOf(l)
216    if (l === destLayer) break
217    const here = segs.filter(s => {
218      const from = items.get(s.from)
219      return from !== undefined && layerIndex(from) === l
220    })
221    const spans = new Map<string, [number, number]>()
222    for (const s of here) {
223      const a = items.get(s.from)
224      const b = items.get(s.to)
225      if (!a || !b || a.c === b.c) continue
226      const was = spans.get(s.from) ?? [a.c, a.c]
227      spans.set(s.from, [Math.min(was[0], b.c), Math.max(was[1], b.c)])
228    }
229    const tracks = new Map<string, number>()
230    const ends: number[] = []
231    for (const [key, [lo, hi]] of [...spans.entries()].sort((p, q) => p[1][0] - q[1][0])) {
232      let t = ends.findIndex(end => end < lo - 1)
233      if (t < 0) {
234        t = ends.length
235        ends.push(hi)
236      } else ends[t] = hi
237      tracks.set(key, t + 1)
238    }
239    gaps[l] = { top: y, tracks, rows: ends.length + 2 }
240    y += ends.length + 2
241  }
242  const height = y
243
244  // The canvas: line joins per cell, then the boxes and words drawn over them.
245  const dirs: number[][] = Array.from({ length: height }, () => new Array<number>(width).fill(0))
246  const lit: boolean[][] = Array.from({ length: height }, () => new Array<boolean>(width).fill(false))
247  const fixed: (Cell | null)[][] = Array.from({ length: height }, () => new Array<Cell | null>(width).fill(null))
248  const join = (row: number, col: number, d: number, isLit: boolean) => {
249    if (row < 0 || row >= height || col < 0 || col >= width) return
250    const line = dirs[row]
251    if (line) line[col] = (line[col] ?? 0) | d
252    if (isLit) {
253      const l2 = lit[row]
254      if (l2) l2[col] = true
255    }
256  }
257  const put = (row: number, col: number, cell: Cell) => {
258    const line = fixed[row]
259    if (line && col >= 0 && col < width) line[col] = cell
260  }
261  const text = (row: number, col: number, s: string, style: Omit<Cell, 'ch'>) =>
262    [...s].forEach((ch, i) => put(row, col + i, { ch, ...style }))
263  const vertical = (col: number, from: number, to: number, isLit: boolean) => {
264    for (let r = from; r <= to; r++) join(r, col, (r > from ? U : 0) | (r < to ? D : 0) | (from === to ? U | D : 0), isLit)
265  }
266
267  const arrows: { row: number; col: number; isLit: boolean }[] = []
268  for (const s of segs) {
269    const a = items.get(s.from)
270    const b = items.get(s.to)
271    if (!a || !b) continue
272    const gap = gaps[layerIndex(a)]
273    if (!gap) continue
274    const first = gap.top
275    const last = gap.top + gap.rows - 1
276    const t = gap.tracks.get(s.from)
277    if (t === undefined || a.c === b.c) {
278      vertical(a.c, first, last, s.isLit)
279      join(first, a.c, U, s.isLit)
280    } else {
281      const track = first + t
282      vertical(a.c, first, track, s.isLit)
283      join(first, a.c, U, s.isLit)
284      const step = b.c > a.c ? 1 : -1
285      join(track, a.c, step > 0 ? R : L, s.isLit)
286      for (let col = a.c + step; col !== b.c; col += step) join(track, col, L | R, s.isLit)
287      join(track, b.c, (step > 0 ? L : R) | D, s.isLit)
288      vertical(b.c, track, last, s.isLit)
289      join(track, b.c, D, s.isLit)
290    }
291    join(last, b.c, D, s.isLit)
292    if (b.kind !== 'dummy') arrows.push({ row: last, col: b.c, isLit: s.isLit })
293  }
294
295  // A lane runs straight through its row.
296  for (const it of items.values()) {
297    if (it.kind !== 'dummy') continue
298    const l = layerIndex(it)
299    const top = tops[l] ?? 0
300    const isLit = segs.some(s => (s.from === it.key || s.to === it.key) && s.isLit)
301    for (let r = top; r < top + heightOf(l); r++) join(r, it.c, U | D, isLit)
302  }
303
304  const boxes: Layout['boxes'] = []
305  for (const row of layers)
306    for (const it of row) {
307      const l = layerIndex(it)
308      const top = tops[l] ?? 0
309      if (it.kind === 'start' || it.kind === 'dest') {
310        const label = it.kind === 'start' ? '⚑ Start' : '⚑ Destination'
311        text(top, it.c - Math.floor(label.length / 2), label, { color: 'white', bold: true, dim: false })
312        const lines = it.kind === 'start' ? startLines : destLines
313        lines.forEach((line, i) =>
314          text(top + 1 + i, Math.floor((width - line.length) / 2), line, { color: ARROW, bold: false, dim: true }),
315        )
316        continue
317      }
318      const n = it.node
319      if (it.kind !== 'node' || !n) continue
320      boxes.push({ id: n.id, x: it.x, y: top, w: it.w, layer: l, c: it.c })
321      const isSel = n.id === sel
322      const color = n.state === 'blocked' ? DIM : n.type ? TYPE_COLOR[n.type] : ARROW
323      const frame = { color: isSel ? 'white' : color, bold: isSel, dim: n.state === 'resolved' && !isSel }
324      const k = isSel ? DOUBLE : ROUND
325      const inner = it.w - 2
326      text(top, it.x, `${k.tl}${k.h.repeat(3)}${k.tj}${k.h.repeat(inner - 4)}${k.tr}`, frame)
327      text(top + 2, it.x, `${k.bl}${k.h.repeat(3)}${k.bj}${k.h.repeat(inner - 4)}${k.br}`, frame)
328      if (segs.some(s => s.from === n.id)) put(top + 2, it.c, { ch: k.out, ...frame })
329      for (const col of [it.x, it.x + 4, it.x + it.w - 1]) put(top + 1, col, { ch: k.v, ...frame })
330      text(top + 1, it.x + 1, ` ${n.type ? ICON[n.type] : '·'} `, { color, bold: true, dim: n.state === 'resolved' && !isSel })
331      const room2 = it.w - 9
332      const mark2 = n.isNext ? '▶' : MARK[n.state]
333      const title = n.title.length > room2 ? `${n.title.slice(0, room2 - 1)}…` : n.title
334      put(top + 1, it.x + 5, { ch: ' ', color, bold: false, dim: false })
335      put(top + 1, it.x + 6, { ch: mark2, color: STATE_COLOR[n.state], bold: true, dim: false })
336      text(top + 1, it.x + 7, ` ${title.padEnd(room2)}`, {
337        color: n.state === 'blocked' ? ARROW : 'white',
338        bold: isSel || n.isMine || n.isNext,
339        dim: n.state === 'resolved' || n.state === 'blocked',
340      })
341    }
342
343  const rows: Cell[][] = []
344  for (let r = 0; r < height; r++) {
345    const line: Cell[] = []
346    for (let col = 0; col < width; col++) {
347      const f = fixed[r]?.[col]
348      if (f) {
349        line.push(f)
350        continue
351      }
352      const d = dirs[r]?.[col] ?? 0
353      const isLit = lit[r]?.[col] ?? false
354      line.push({ ch: d ? (JOIN[d] ?? '┼') : ' ', color: isLit ? ACCENT : ARROW, bold: isLit, dim: false })
355    }
356    rows.push(line)
357  }
358  for (const a of arrows) {
359    const line = rows[a.row]
360    if (line && !fixed[a.row]?.[a.col]) line[a.col] = { ch: '▼', color: a.isLit ? ACCENT : ARROW, bold: a.isLit, dim: false }
361  }
362  return { width, rows, boxes }
363}
364
365/** Where the arrows go from a box: along its edges first, else to the nearest box in that direction. */
366const move = (props: GraphProps, boxes: Layout['boxes'], from: string | null, key: string): string | null => {
367  if (!boxes.length) return null
368  const here = boxes.find(b => b.id === from)
369  if (!here) {
370    const mine = props.nodes.find(n => n.isMine) ?? props.nodes.find(n => n.isNext)
371    return mine?.id ?? boxes[0]?.id ?? null
372  }
373  const nearest = (list: typeof boxes) =>
374    [...list].sort((a, b) => Math.abs(a.c - here.c) - Math.abs(b.c - here.c))[0]?.id ?? here.id
375  if (key === 'left' || key === 'right') {
376    const row = boxes.filter(b => b.layer === here.layer).sort((a, b) => a.x - b.x)
377    const i = row.findIndex(b => b.id === here.id)
378    return row[i + (key === 'left' ? -1 : 1)]?.id ?? here.id
379  }
380  const isDown = key === 'down'
381  const linked = props.edges
382    .filter(([a, b]) => (isDown ? a === here.id : b === here.id))
383    .map(([a, b]) => (isDown ? b : a))
384  const along = boxes.filter(b => linked.includes(b.id))
385  if (along.length) return nearest(along)
386  const layers = [...new Set(boxes.map(b => b.layer))].sort((a, b) => a - b)
387  const next = isDown ? layers.find(l => l > here.layer) : [...layers].reverse().find(l => l < here.layer)
388  return next === undefined ? here.id : nearest(boxes.filter(b => b.layer === next))
389}
390
391const KEYS: Record<string, string> = { left: 'left', right: 'right', up: 'up', down: 'down', h: 'left', l: 'right', k: 'up', j: 'down' }
392
393export default function Graph(props: GraphProps, surface: ClientSurface<GraphState>): RenderElement {
394  const { Box, Text } = surface.elements
395  const st = surface.state
396  // The card the hooks module opened wins; a card closed keeps the box this graph selected.
397  const sel = st && (st.propSel === props.selected || props.selected === null) ? st.sel : props.selected
398  const columns = Math.max(20, surface.columns || props.columns)
399  const { width, rows, boxes } = layout(props, sel, columns)
400
401  // Keep the selected box in view when the map is wider than the pane.
402  const box = boxes.find(b => b.id === sel)
403  let scroll = Math.min(Math.max(0, st?.scroll ?? 0), Math.max(0, width - columns))
404  if (box && box.x < scroll) scroll = box.x
405  if (box && box.x + box.w > scroll + columns) scroll = box.x + box.w - columns
406
407  const choose = (next: string | null, focus: string | null) => {
408    surface.setState({ sel: next, propSel: props.selected, scroll })
409    surface.post({ focus })
410  }
411  surface.onKey(e => {
412    const dir = KEYS[e.key]
413    if (dir) {
414      const next = move(props, boxes, sel, dir)
415      choose(next, next)
416    } else if ((e.key === 'return' || e.key === ' ') && sel) choose(sel, props.selected === sel ? null : sel)
417  })
418  surface.onPointer(e => {
419    if (e.type !== 'down' || e.button !== 'left') return
420    const x = e.x + scroll
421    const hit = boxes.find(b => x >= b.x && x < b.x + b.w && e.y >= b.y && e.y < b.y + NODE_ROWS)
422    if (hit) choose(hit.id, props.selected === hit.id ? null : hit.id)
423  })
424
425  return (
426    <Box flexDirection="column">
427      {rows.map((line, r) => {
428        const cells = line.slice(scroll, scroll + columns)
429        const runs: Cell[] = []
430        for (const cell of cells) {
431          const last = runs[runs.length - 1]
432          if (last && last.color === cell.color && last.bold === cell.bold && last.dim === cell.dim) last.ch += cell.ch
433          else runs.push({ ...cell })
434        }
435        return (
436          <Text key={`r${r}`} wrap="truncate-end">
437            {runs.map((run, i) => (
438              <Text key={`r${r}-${i}`} color={run.color} bold={run.bold} dimColor={run.dim}>
439                {run.ch}
440              </Text>
441            ))}
442          </Text>
443        )
444      })}
445      {width > columns && (
446        <Text dimColor>
447          {scroll > 0 ? '◂ ' : '  '}
448          {Math.round((scroll / Math.max(1, width - columns)) * 100)}% of a wider map
449          {scroll < width - columns ? ' ▸' : ''}
450        </Text>
451      )}
452    </Box>
453  )
454}
455
types/index.d.ts 254 lines
1// 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