A side drawer that shows every wayfinder map and spec in the project (markdown or GitHub issues), their tickets, statuses and dependencies.

Mods for Claude Code, built on the function-hooks plugin API: live panes, bands and hooks that run inside the terminal and the desktop Code tab.
This repo is a plugin marketplace. Add it once, then install any mod from it:
claude plugin marketplace add Yuvalz19500/claude-mods
claude plugin install wayfinder-maps@claude-mods
To update to the latest release:
claude plugin marketplace update claude-mods
claude plugin update wayfinder-maps@claude-mods
What changed in each version is on the Releases page.
A side drawer for projects planned with the /wayfinder skill. Run /wayfinder-maps to open it. It works in the desktop Code tab and in the terminal.
The Work buttons run Matt Pocock's skills, so install his mattpocock-skills plugin alongside it; the drawer itself reads your maps without it.
The drawer
spec.md beside numbered implementation tickets) appear in their own section and are drawn the same way.after 17 ✓ 16 ◐ line names what it waits on. Cards fill the drawer's width and stack when it is narrow; a click anywhere on a card opens its ticket. Tree shows the same tickets as an indented list instead.All, To do, ⚑ Frontier, ◐ Claimed, ◷ Waiting on you, ◌ Blocked, ✓ Done: each counts its tickets and, clicked, shows only them.Working the tickets
Every Work button works in a new chat with no context, so free tickets can be worked in parallel. In the desktop app it copies the command, says so in a toast, and opens a new session in the project's folder: paste (Ctrl+V, ⌘V on a Mac) and press Enter. The app won't let anything run a command in a new chat for you, so the paste is yours. In the terminal, a terminal window of its own runs the command at once. ↗ on a button means it opens a new chat; set workIn to here to work in the current chat instead (the buttons then show →).
| Where | Button | What it does |
|---|---|---|
| Map | Work next: 07 ↗ | A new chat on the next ticket ready to take (/wayfinder <map> <ticket>). The card then reads new chat ↗ and the button moves on to the next free ticket, so you fan out one click at a time. With nothing free it becomes Work the map. |
| Spec | Implement frontier · 5 ↗ | A new chat on this plugin's /wayfinder-maps:implement-frontier: one wave of the spec, the tickets ready now, then it stops. |
| Spec | Implement spec ↗ | A new chat on Matt Pocock's /implement-spec: wave after wave until the whole spec is done. |
| Ticket page | Work this ticket ↗ | A new chat on that one ticket (/wayfinder on a map ticket, /implement on a spec ticket). |
The implement-frontier skill
Shipped with this plugin, for implementing a spec one wave at a time, so you can check each wave and answer what waits on you before starting the next. Run it from the drawer or as /wayfinder-maps:implement-frontier <spec>. It:
/implement does (TDD, typecheck, tests, code review, commit)./code-review on the wave branch and fixes what it raises.Where it looks
| Tracker | What it reads |
|---|---|
| Local markdown | Any folder (up to 4 levels deep) holding map.md or spec.md beside an issues/ or tickets/ folder of NN-slug.md files: .scratch/<effort>/, docs/wayfinder/<effort>/ and the like. Each ticket's Type:, Status:, Assignee: and Blocked by: lines (plain or bold, inline or as a list). |
| GitHub | Issues labelled wayfinder:map in the session's repo, their sub-issues as tickets, and GitHub's native blocked-by dependencies. Falls back to a task list in the map body and Blocked by: #n lines. Needs the gh CLI, signed in. |
Status words are folded into five states. resolved, done and closed issues are done. claimed, or any assignee on an open ticket, is claimed. awaiting-design and ready-for-human are waiting on you. An open ticket with an unfinished blocker is blocked. Everything else open (open, ready-for-agent) is frontier.
The drawer refreshes after every turn and every 30 seconds while it's open. GitHub is fetched at most once a minute; press Refresh to fetch now.
Settings
Where the Work buttons work, and the prompts they send, can be changed under /config (or pluginConfigs in settings):
| Option | Default |
|---|---|
workIn | new-chat: a new chat with no context (on the desktop, the command copied and a new session opened in the project; in the terminal, a window of its own). here: this chat. |
mapPrompt | /mattpocock-skills:wayfinder {map} |
mapTicketPrompt | /mattpocock-skills:wayfinder {map} {ticket} |
specTicketPrompt | /mattpocock-skills:implement {ticket} |
specPrompt | /mattpocock-skills:implement-spec {map} |
frontierPrompt | /wayfinder-maps:implement-frontier {map} |
{map} is the map's (or spec's) path or issue URL, {ticket} the ticket's path or issue URL, {title} its title. A prompt starting with / runs as that slash command; anything else is sent as a message.
Each mod is a folder under plugins/ with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module. Check one with:
claude plugin validate plugins/<mod>
Run its tests with claude plugin test plugins/<mod>. Load a working copy into a session with claude --plugin-dir plugins/<mod>; it hot-reloads on save. Add the mod to .claude-plugin/marketplace.json to publish it. CLAUDE.md has the full build-and-release checklist and the engine's gotchas.
hooks/register.tsx 904 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WfCopied, WfData, WfDetail, WfFilter, WfLayout, WfMap, WfTicket, WfView } from '../types'
5import type { CardProps } from './card'
6import { ClickGate } from './clicks'
7import { githubIssueBody, githubRepo, loadGithubMaps } from './github'
8import { STATUS_STYLE, cardLayout, cardRows, dependents, layers, treeRows } from './graph'
9import { appChat, clipboardArgv, pasteKeys, terminalChat } from './launch'
10import type { Launch, Platform } from './launch'
11import { loadMarkdownMaps, readText } from './markdown'
12import { counts } from './parse'
13import type { Io } from './parse'
14import { absolutizeLinks, fileUrl, stripHeaderFields, wrapLines } from './text'
15
16const PANE = 'wayfinder'
17const TITLE = 'Wayfinder'
18const GITHUB_EVERY_MS = 60_000
19const POLL_MS = 30_000
20
21const data = atom({ plugin: 'wayfinder-maps', key: 'data' } as const, null)
22const isLoading = atom({ plugin: 'wayfinder-maps', key: 'isLoading' } as const, false)
23const isPaneOpen = atom({ plugin: 'wayfinder-maps', key: 'isPaneOpen' } as const, false)
24const view = atom(
25 { plugin: 'wayfinder-maps', key: 'view' } as const,
26 { mapId: null, ticketId: null, filter: 'all', layout: 'steps' } as WfView,
27)
28const detail = atom({ plugin: 'wayfinder-maps', key: 'detail' } as const, null as WfDetail)
29const started = atom({ plugin: 'wayfinder-maps', key: 'started' } as const, {} as Record<string, number>)
30
31/** How long a ticket opened in a new chat stays out of "Work next", for its chat to claim it. */
32const STARTED_MS = 15 * 60_000
33/** The command a Work button last copied for the person to paste in a new chat. */
34const copied = atom({ plugin: 'wayfinder-maps', key: 'copied' } as const, null as WfCopied)
35/** How long the toast shows before the app moves to the new chat. */
36const OPEN_AFTER_MS = 1_500
37
38const MAP_STATUS_LABEL: Record<WfMap['status'], { text: string; color: string }> = {
39 open: { text: 'open', color: STATUS_STYLE.open.fill },
40 active: { text: 'in progress', color: STATUS_STYLE.claimed.fill },
41 done: { text: 'done', color: STATUS_STYLE.done.fill },
42 graduated: { text: 'graduated → spec', color: STATUS_STYLE.done.fill },
43}
44
45const prompts = {
46 map: '/mattpocock-skills:wayfinder {map}',
47 mapTicket: '/mattpocock-skills:wayfinder {map} {ticket}',
48 specTicket: '/mattpocock-skills:implement {ticket}',
49 /** The whole spec: the skill takes the frontier and runs a subagent per ticket, in parallel. */
50 spec: '/mattpocock-skills:implement-spec {map}',
51 /** One wave: the spec's frontier tickets in parallel, then stop (this plugin's own skill). */
52 frontier: '/wayfinder-maps:implement-frontier {map}',
53}
54/** Where Work buttons work: a new chat (the default), always a terminal window, or this chat. */
55let workIn: 'new-chat' | 'here' = 'new-chat'
56let platform: Platform | undefined
57let lastGithubAt = 0
58let githubMaps: WfMap[] = []
59let githubError: string | undefined
60let isRefreshing = false
61/** A refresh asked for while one runs: runs after it; true when it should fetch GitHub. */
62let queued: boolean | null = null
63
64// Button actions by key, so a focus move (desktop) can run the same action a press does.
65const handlers = new Map<string, () => void>()
66const gate = new ClickGate()
67let drawnOn: string | undefined
68
69function bind(key: string, fn: () => unknown): () => void {
70 const handler = () => void fn()
71 handlers.set(key, handler)
72 return handler
73}
74
75const bar = (done: number, total: number, width = 16) => {
76 const filled = total === 0 ? 0 : Math.round((done / total) * width)
77 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
78}
79
80const relative = (root: string, path: string) => {
81 const r = root.replace(/\\/g, '/').replace(/\/$/, '')
82 const p = path.replace(/\\/g, '/')
83 return p.startsWith(r + '/') ? p.slice(r.length + 1) : p
84}
85
86const fill = (template: string, values: Record<string, string>) =>
87 template.replace(/\{(\w+)\}/g, (whole, k: string) => values[k] ?? whole)
88
89const norm = (path: string) => path.replace(/\\/g, '/').replace(/\/{2,}/g, '/').toLowerCase()
90
91const isClosed = (t: WfTicket) => t.status === 'done' || t.status === 'dropped'
92
93function visible(tickets: WfTicket[], filter: WfFilter): WfTicket[] {
94 if (filter === 'all') return tickets
95 if (filter === 'frontier') return tickets.filter(t => t.isFrontier)
96 if (filter === 'unresolved') return tickets.filter(t => !isClosed(t))
97 if (filter === 'done') return tickets.filter(isClosed)
98 return tickets.filter(t => t.status === filter)
99}
100
101/** What a status reads as on a card: the assignee rides along when claimed. */
102const statusText = (t: WfTicket, isOpened = false) =>
103 t.status === 'claimed' && t.assignee
104 ? `claimed · ${t.assignee.split(/[\s(]/)[0]}`
105 : isOpened && t.isFrontier
106 ? 'new chat ↗'
107 : STATUS_STYLE[t.status].label
108
109/** True for a ticket a Work button opened a new chat on a moment ago: its chat has not claimed it yet. */
110const isOpened = (opened: Record<string, number>, t: WfTicket, now: number) => (opened[t.id] ?? -Infinity) > now - STARTED_MS
111
112/** The frontier tickets no new chat was opened on yet: what "Work next" and "Start all" take. */
113const takeable = (map: WfMap, opened: Record<string, number>, now: number) =>
114 map.tickets.filter(t => t.isFrontier && !isOpened(opened, t, now))
115
116/** Where a Work button works, from the setting and the surface it was pressed on. */
117const chatFor = (): 'app' | 'terminal' | 'here' =>
118 workIn === 'here' || drawnOn === 'mobile' ? 'here' : drawnOn === 'desktop' ? 'app' : 'terminal'
119
120function ioFor($: EngineInterface): Io {
121 return {
122 list: path => $.fs.list(path),
123 read: async path => String(await $.fs.read(path)),
124 run: (argv, cwd) => $.process.run(argv, { cwd, timeoutMs: 30_000 }),
125 }
126}
127
128/**
129 * Rescans the project. A quiet refresh (the background polls) draws nothing
130 * while it runs, and nothing at all when the maps did not change.
131 */
132async function refresh($: EngineInterface, github: boolean, isQuiet: boolean) {
133 if (isRefreshing) {
134 queued = (queued ?? false) || github
135 return
136 }
137 isRefreshing = true
138 if (!isQuiet) await update($, isLoading, () => true)
139 try {
140 const io = ioFor($)
141 const root = await $.session.root()
142 const errors: string[] = []
143 const mdPromise = loadMarkdownMaps(io, root).catch(error => {
144 errors.push(`Markdown: ${String(error)}`)
145 return [] as WfMap[]
146 })
147 if (github || lastGithubAt === 0) {
148 lastGithubAt = await $.clock.now()
149 const repo = await githubRepo(io, root)
150 if (repo) {
151 const r = await loadGithubMaps(io, root, repo).catch(error => ({ maps: [] as WfMap[], error: String(error) }))
152 githubMaps = r.maps
153 githubError = r.error
154 } else {
155 githubMaps = []
156 githubError = undefined
157 }
158 }
159 if (githubError) errors.push(githubError)
160 const isFinished = (m: WfMap) => Number(m.status === 'done' || m.status === 'graduated')
161 const all = [...githubMaps, ...(await mdPromise)].sort(
162 (a, b) =>
163 Number(a.kind === 'spec') - Number(b.kind === 'spec') || isFinished(a) - isFinished(b) || a.title.localeCompare(b.title),
164 )
165 const next: WfData = { root, maps: all, loadedAt: await $.clock.now(), errors }
166 const prev = await read($, data)
167 const isSame = prev !== null && JSON.stringify({ ...prev, loadedAt: 0 }) === JSON.stringify({ ...next, loadedAt: 0 })
168 if (!isSame) await update($, data, () => next)
169 } finally {
170 isRefreshing = false
171 if (!isQuiet) await update($, isLoading, () => false)
172 }
173 if (queued !== null) {
174 const fetchGithub = queued
175 queued = null
176 await refresh($, fetchGithub, true)
177 }
178}
179
180async function openDrawer($: EngineInterface) {
181 await $.ui.open({ id: PANE, title: TITLE })
182 await update($, isPaneOpen, () => true)
183 void refresh($, true, false)
184}
185
186async function pollIfOpen($: EngineInterface, github: boolean) {
187 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
188 if (isOpen) await refresh($, github, true)
189}
190
191/** Brings the drawer's window back to its top, or to one element; best effort. */
192async function scrollTo($: EngineInterface, to: 'start' | { key: string }) {
193 try {
194 await $.ui.scroll({ to, in: PANE, block: to === 'start' ? 'start' : 'center' })
195 } catch {
196 // The pane may not be placed (a narrow terminal); nothing to scroll.
197 }
198}
199
200/** Shows one ticket on a page of its own: its body is read first, so the page draws once. */
201async function openTicket($: EngineInterface, map: WfMap, ticket: WfTicket) {
202 await update($, copied, () => null)
203 const io = ioFor($)
204 const root = (await read($, data))?.root ?? (await $.session.root())
205 let body: string
206 if (map.source === 'github' && ticket.url) body = await githubIssueBody(io, root, ticket.url)
207 else body = ticket.path ? await readText(io, ticket.path) : ''
208 body = stripHeaderFields(body.replace(/^\uFEFF?# .*\r?\n/, '')).trim()
209 let links: { href: string; path: string }[] = []
210 if (ticket.path) ({ text: body, links } = absolutizeLinks(body, ticket.path))
211 if (body.length > 9000) body = body.slice(0, 9000) + '\n\n_…truncated; open the file for the rest._'
212 await update($, detail, () => ({ id: ticket.id, body: body || '_This ticket has no body yet._', links }))
213 await update($, view, v => ({ ...v, mapId: map.id, ticketId: ticket.id }))
214 await scrollTo($, 'start')
215}
216
217/** Back from a ticket's page to its map, with that ticket's card in view. */
218async function backToMap($: EngineInterface) {
219 const from = (await read($, view)).ticketId
220 await update($, view, v => ({ ...v, ticketId: null }))
221 if (from) await scrollTo($, { key: `t:${from}` })
222}
223
224async function openMap($: EngineInterface, mapId: string | null) {
225 await update($, copied, () => null)
226 await update($, view, v => ({ ...v, mapId, ticketId: null }))
227 await scrollTo($, 'start')
228}
229
230/** The prompt a Work button sends: the configured template, filled in for the map or spec, or one ticket. */
231function promptFor(root: string, map: WfMap, ticket: WfTicket | null, isWave = false) {
232 const mapRef = map.source === 'github' ? (map.url ?? map.ref) : map.ref
233 const ticketRef = ticket ? (ticket.path ? relative(root, ticket.path) : (ticket.url ?? ticket.ref)) : ''
234 const isSpec = map.kind === 'spec'
235 const template = isWave
236 ? prompts.frontier
237 : !ticket
238 ? isSpec
239 ? prompts.spec
240 : prompts.map
241 : isSpec
242 ? prompts.specTicket
243 : prompts.mapTicket
244 return fill(template, { map: mapRef, ticket: ticketRef, title: ticket?.title ?? map.title }).trim()
245}
246
247async function platformOf($: EngineInterface): Promise<Platform> {
248 if (platform) return platform
249 if ((await $.env.get('OS')) === 'Windows_NT') platform = 'windows'
250 else {
251 const r = await $.process.run(['uname', '-s'], { timeoutMs: 5_000 }).catch(() => null)
252 platform = r?.stdout.trim() === 'Darwin' ? 'mac' : 'linux'
253 }
254 return platform
255}
256
257/**
258 * Opens one new chat: an empty desktop-app session in the project, or a
259 * terminal window on `prompt`. What was run, or null when the host could not run it.
260 */
261async function openChat($: EngineInterface, how: 'app' | 'terminal', prompt: string, root: string, title: string) {
262 const p = await platformOf($)
263 const launch: Launch = how === 'app' ? appChat(root, p) : terminalChat(prompt, root, title, p)
264 const r = await $.process.run(launch.argv, { cwd: launch.cwd ?? root, timeoutMs: 15_000 }).catch(() => null)
265 return r?.exitCode === 0 ? launch : null
266}
267
268/** Puts `text` on the clipboard: through the surface, else the host's own tool. True when it took. */
269async function copyText($: EngineInterface, text: string): Promise<boolean> {
270 const surface = drawnOn === 'desktop' || drawnOn === 'terminal' || drawnOn === 'vscode' ? drawnOn : undefined
271 const viaSurface = await $.ui.copy({ text, surface }).catch(() => null)
272 if (viaSurface?.isCopied) return true
273 const r = await $.process.run(clipboardArgv(await platformOf($)), { stdin: text, timeoutMs: 5_000 }).catch(() => null)
274 return r?.exitCode === 0
275}
276
277/** Keeps the tickets just opened in new chats out of "Work next" until their chats claim them. */
278async function markOpened($: EngineInterface, ids: string[]) {
279 if (ids.length === 0) return
280 const now = await $.clock.now()
281 await update($, started, prev => {
282 const kept = Object.fromEntries(Object.entries(prev ?? {}).filter(([, at]) => at > now - STARTED_MS))
283 for (const id of ids) kept[id] = now
284 return kept
285 })
286}
287
288/**
289 * Works the map or spec, one ticket, or (`wave`) a spec's frontier tickets
290 * at once: in a new chat with no context, or here (the `workIn` setting).
291 */
292async function work($: EngineInterface, map: WfMap, ticket: WfTicket | null, wave: WfTicket[] = []) {
293 const root = (await read($, data))?.root ?? (await $.session.root())
294 const text = promptFor(root, map, ticket, wave.length > 0)
295 const how = chatFor()
296 if (how === 'here') {
297 // A slash command (a skill) runs as one; anything else is sent as a prompt.
298 const slash = text.match(/^\/(\S+)\s*([\s\S]*)$/)
299 if (slash) await $.command.run({ command: slash[1]!, args: slash[2] ?? '' })
300 else await $.prompt.submit({ text, asUser: true })
301 $.ui.toast(`Started: ${text}`)
302 return
303 }
304 const name = ticket ? ticket.ref : wave.length > 0 ? `the frontier (${wave.map(t => t.ref).join(' ')})` : `the ${map.kind}`
305 if (how === 'app') {
306 // The app opens a new chat but never runs a command for it: the person pastes the copied one.
307 const isCopied = await copyText($, text)
308 const keys = pasteKeys(await platformOf($))
309 await update($, copied, () => ({ text, isCopied, keys }))
310 $.ui.toast(
311 isCopied
312 ? `Copied ${text}. Opening a new chat in this project: paste it (${keys}) and press Enter.`
313 : `Could not copy. Opening a new chat in this project: type ${text} there.`,
314 { timeoutMs: 10_000 },
315 )
316 // Long enough to read the toast before the app moves to the new chat.
317 await $.clock.sleep(OPEN_AFTER_MS)
318 if (!(await openChat($, 'app', text, root, ''))) {
319 $.ui.toast(`Could not open a new chat. Open one in this project and paste ${text}.`, { timeoutMs: 10_000 })
320 return
321 }
322 await markOpened($, ticket ? [ticket.id] : wave.map(t => t.id))
323 return
324 }
325 const opened = await openChat($, how, text, root, `Wayfinder ${ticket?.ref ?? map.kind}`)
326 if (!opened) {
327 $.ui.toast(`Could not open a new chat. Its prompt: ${text}`, { timeoutMs: 10_000 })
328 return
329 }
330 await markOpened($, ticket ? [ticket.id] : wave.map(t => t.id))
331 $.ui.toast(opened.startsAtOnce ? `New chat working on ${name}` : `New chat on ${name}: press Enter there to start`)
332}
333
334
335// ── Drawing ──────────────────────────────────────────────────────────
336// Native elements only: the surface's own type, colors and theme. The one
337// color the drawer adds is each status's, on its glyph and its card's edge.
338
339type Els = ReturnType<EngineInterface['ui']['resolve']>
340
341/**
342 * The mod's mark: a trail blaze, the painted bar a hiker follows, before the
343 * name. One text cell, so the terminal and the desktop draw it alike.
344 */
345function mark(el: Els) {
346 const { Box, Text } = el
347 return (
348 <Box flexDirection="row" flexShrink={0}>
349 <Text color={STATUS_STYLE.open.fill}>▍</Text>
350 <Text bold>Wayfinder</Text>
351 </Box>
352 )
353}
354
355function header($: EngineInterface, el: Els, loading: boolean) {
356 const { Box, Text, Button } = el
357 return (
358 <Box flexDirection="row" justifyContent="space-between">
359 {mark(el)}
360 <Box flexDirection="row" gap={1}>
361 {loading && <Text dimColor>refreshing…</Text>}
362 <Button key="refresh" label="Refresh" hotkey="r" plain onPress={bind('refresh', () => refresh($, true, false))} />
363 </Box>
364 </Box>
365 )
366}
367
368function mapList($: EngineInterface, el: Els, d: WfData) {
369 const { Box, Text, Button } = el
370 const maps = d.maps.filter(m => m.kind === 'map')
371 const specs = d.maps.filter(m => m.kind === 'spec')
372 const row = (m: WfMap) => {
373 const c = counts(m.tickets)
374 const st = MAP_STATUS_LABEL[m.status]
375 return (
376 <Box key={`row:${m.id}`} flexDirection="column" marginBottom={1}>
377 <Button key={`open:${m.id}`} label={m.title} plain onPress={bind(`open:${m.id}`, () => openMap($, m.id))} />
378 <Box flexDirection="row" gap={1} flexWrap="wrap">
379 <Text color={st.color}>● {st.text}</Text>
380 <Text dimColor>{m.source === 'github' ? `GitHub ${m.ref}` : m.dir}</Text>
381 </Box>
382 <Box flexDirection="row" gap={1} flexWrap="wrap">
383 <Text color={STATUS_STYLE.done.fill}>{bar(c.done, c.total)}</Text>
384 <Text dimColor>
385 {c.done}/{c.total} done
386 {c.frontier ? ` · ${c.frontier} frontier` : ''}
387 {c.claimed ? ` · ${c.claimed} claimed` : ''}
388 {c.waiting ? ` · ${c.waiting} waiting` : ''}
389 {c.blocked ? ` · ${c.blocked} blocked` : ''}
390 </Text>
391 </Box>
392 </Box>
393 )
394 }
395 return (
396 <Box flexDirection="column" gap={1}>
397 {d.maps.length === 0 && (
398 <Text dimColor>
399 No wayfinder maps here. A map is a folder with a map.md beside issues/ or tickets/, or a GitHub issue labelled
400 wayfinder:map.
401 </Text>
402 )}
403 {maps.length > 0 && <Text bold>Maps · {maps.length}</Text>}
404 {maps.map(row)}
405 {specs.length > 0 && <Text bold>Specs · {specs.length}</Text>}
406 {specs.map(row)}
407 {d.errors.map(err => (
408 <Text color={STATUS_STYLE.waiting.fill}>{err}</Text>
409 ))}
410 </Box>
411 )
412}
413
414/**
415 * One ticket as a card, its status's color on the edge. Where the surface
416 * runs surface modules (terminal, desktop) the card is a `Client`, so the
417 * whole card is one click target (see card.tsx); elsewhere its title lines
418 * are the buttons.
419 */
420function ticketCard(
421 $: EngineInterface,
422 el: Els,
423 map: WfMap,
424 t: WfTicket,
425 width: number,
426 byNum: Map<number, WfTicket>,
427 isNewChat: boolean,
428) {
429 const { Box, Text, Button } = el
430 const st = STATUS_STYLE[t.status]
431 const closed = isClosed(t)
432 const status = statusText(t, isNewChat)
433 const after = t.blockedBy.map(n => byNum.get(n)).filter((x): x is WfTicket => !!x)
434 const afterText = after.length > 0 ? `after ${after.map(b => `${b.ref} ${STATUS_STYLE[b.status].glyph}`).join(' ')}` : ''
435 if ('Client' in el) {
436 const { Client } = el
437 const props: CardProps = {
438 id: t.id,
439 ref: t.ref,
440 type: (t.type ?? '').toUpperCase(),
441 lines: wrapLines(t.title, width - 4, 3),
442 status,
443 color: st.fill,
444 glyph: st.glyph,
445 after: afterText,
446 isClosed: closed,
447 width,
448 }
449 // An explicit height: left to the module, the pane measures before it draws and cuts the last card.
450 return <Client key={`card:${t.id}`} module="./card.tsx" props={props} width={width} height={cardRows(props)} />
451 }
452 const open = bind(`t:${t.id}`, () => openTicket($, map, t))
453 // A Button draws on one line, so the title is wrapped here, a button per line.
454 const lines = wrapLines(t.title, width - 4, 3)
455 return (
456 <Box
457 key={`card:${t.id}`}
458 flexDirection="column"
459 width={width}
460 borderStyle="round"
461 borderColor={st.fill}
462 borderDimColor={closed}
463 paddingX={1}
464 >
465 <Box flexDirection="row" justifyContent="space-between" gap={1}>
466 <Box flexDirection="row" gap={1} flexShrink={0}>
467 <Text color={st.fill}>{st.glyph}</Text>
468 <Text bold dimColor={closed}>
469 {t.ref}
470 </Text>
471 {t.type && <Text dimColor>{t.type.toUpperCase()}</Text>}
472 </Box>
473 <Text color={closed ? undefined : st.fill} dimColor={closed}>
474 {status}
475 </Text>
476 </Box>
477 {lines.map((line, i) => (
478 <Button key={i === 0 ? `t:${t.id}` : `t:${t.id}#${i + 1}`} label={line} plain dimColor={closed} onPress={i === 0 ? open : bind(`t:${t.id}#${i + 1}`, () => openTicket($, map, t))} />
479 ))}
480 {afterText && <Text dimColor>{afterText}</Text>}
481 </Box>
482 )
483}
484
485/** Steps, top to bottom: a ticket sits one step below the last of its blockers; cards flow across each step. */
486function stepsView($: EngineInterface, el: Els, map: WfMap, shown: WfTicket[], columns: number, newChat: Set<string>) {
487 const { Box, Text } = el
488 const byNum = new Map(map.tickets.map(t => [t.num, t]))
489 const layerOf = layers(shown)
490 const steps = new Map<number, WfTicket[]>()
491 for (const t of shown) {
492 const l = layerOf.get(t.num) ?? 0
493 steps.set(l, [...(steps.get(l) ?? []), t])
494 }
495 const { cardWidth, gap } = cardLayout(columns)
496 return (
497 <Box flexDirection="column" gap={1}>
498 {[...steps.keys()]
499 .sort((a, b) => a - b)
500 .map((l, i) => {
501 const tickets = steps.get(l)!
502 const done = tickets.filter(isClosed).length
503 return (
504 <Box key={`step:${l}`} flexDirection="column" gap={1}>
505 <Box flexDirection="row" gap={1}>
506 <Text bold dimColor>
507 STEP {i + 1}
508 </Text>
509 <Text dimColor>· {done === tickets.length ? 'all done' : `${done} of ${tickets.length} done`}</Text>
510 </Box>
511 <Box flexDirection="row" flexWrap="wrap" columnGap={gap} rowGap={1}>
512 {tickets.map(t => ticketCard($, el, map, t, cardWidth, byNum, newChat.has(t.id)))}
513 </Box>
514 </Box>
515 )
516 })}
517 </Box>
518 )
519}
520
521/** The same tickets as an indented tree: each under the first ticket that blocks it. */
522function treeView($: EngineInterface, el: Els, map: WfMap, shown: WfTicket[]) {
523 const { Box, Text, Button } = el
524 return (
525 <Box flexDirection="column">
526 {treeRows(shown).map(({ ticket: t, depth, isRepeat, extraParents }, i) => {
527 const st = STATUS_STYLE[t.status]
528 return (
529 <Box key={`r:${i}`} flexDirection="row" marginLeft={depth * 2}>
530 <Text color={st.fill}>
531 {depth > 0 ? '└ ' : ''}
532 {st.glyph}{' '}
533 </Text>
534 {isRepeat ? (
535 <Text dimColor>
536 {t.ref} {t.title} ↑
537 </Text>
538 ) : (
539 <Button
540 key={`t:${t.id}`}
541 label={`${t.ref} ${t.title}`}
542 plain
543 dimColor={isClosed(t)}
544 onPress={bind(`t:${t.id}`, () => openTicket($, map, t))}
545 />
546 )}
547 {!isRepeat && t.type && <Text dimColor> · {t.type}</Text>}
548 {!isRepeat && extraParents.length > 0 && <Text dimColor> · also after {extraParents.join(', ')}</Text>}
549 </Box>
550 )
551 })}
552 </Box>
553 )
554}
555
556/** The status chips: each one both counts its tickets and shows only them. */
557const CHIPS: { filter: WfFilter; label: string }[] = [
558 { filter: 'all', label: 'All' },
559 { filter: 'unresolved', label: 'To do' },
560 { filter: 'frontier', label: `${STATUS_STYLE.open.glyph} Frontier` },
561 { filter: 'claimed', label: `${STATUS_STYLE.claimed.glyph} Claimed` },
562 { filter: 'waiting', label: `${STATUS_STYLE.waiting.glyph} Waiting on you` },
563 { filter: 'blocked', label: `${STATUS_STYLE.blocked.glyph} Blocked` },
564 { filter: 'done', label: `${STATUS_STYLE.done.glyph} Done` },
565]
566
567type Launches = { opened: Record<string, number>; now: number; copied: WfCopied }
568
569/** What the last Work button copied, for the person coming back from the new chat. */
570function pasteNote(el: Els, c: WfCopied) {
571 const { Text } = el
572 if (!c) return ''
573 return (
574 <Text dimColor wrap="wrap">
575 {c.isCopied
576 ? `✓ Copied ${c.text} · paste it in the new chat (${c.keys}) and press Enter`
577 : `Could not copy · type ${c.text} in the new chat`}
578 </Text>
579 )
580}
581
582function mapView($: EngineInterface, el: Els, map: WfMap, v: WfView, columns: number, launches: Launches) {
583 const { Box, Text, Button, Markdown, Link } = el
584 const c = counts(map.tickets)
585 const st = MAP_STATUS_LABEL[map.status]
586 const filter: WfFilter = CHIPS.some(x => x.filter === v.filter) ? v.filter : 'all'
587 const shown = visible(map.tickets, filter)
588 const ready = takeable(map, launches.opened, launches.now)
589 const nextUp = ready[0]
590 const newChat = new Set(map.tickets.filter(t => isOpened(launches.opened, t, launches.now)).map(t => t.id))
591 const isLive = map.status !== 'done' && map.status !== 'graduated'
592 // ↗ where the work opens in another chat, → where it runs in this one.
593 const arrow = chatFor() === 'here' ? '→' : '↗'
594 const countOf: Record<WfFilter, number> = {
595 all: c.total,
596 unresolved: c.total - c.done,
597 frontier: c.frontier,
598 claimed: c.claimed,
599 waiting: c.waiting,
600 blocked: c.blocked,
601 done: c.done,
602 }
603 const layoutButton = (layout: WfLayout, label: string) => (
604 <Button
605 key={`layout:${layout}`}
606 label={label}
607 variant={(v.layout ?? 'steps') === layout ? 'primary' : 'secondary'}
608 onPress={bind(`layout:${layout}`, () => update($, view, x => ({ ...x, layout })))}
609 />
610 )
611 return (
612 <Box flexDirection="column" gap={1}>
613 <Box flexDirection="row" justifyContent="space-between" gap={1}>
614 <Button key="back" label="← All maps" plain hotkey="b" onPress={bind('back', () => openMap($, null))} />
615 <Box flexDirection="row" gap={1} flexShrink={0}>
616 <Text dimColor>View</Text>
617 {layoutButton('steps', 'Steps')}
618 {layoutButton('tree', 'Tree')}
619 </Box>
620 </Box>
621 <Box flexDirection="column">
622 <Text bold wrap="wrap">
623 {map.title}
624 </Text>
625 <Box flexDirection="row" gap={1} flexWrap="wrap">
626 <Text color={st.color}>● {st.text}</Text>
627 <Text dimColor>· {map.kind} · {map.source === 'github' ? `GitHub ${map.ref}` : map.dir} ·</Text>
628 {map.url ? <Link href={map.url} label="Open map ↗" /> : map.path ? <Markdown text={`[Open ${map.kind} ↗](${fileUrl(map.path)})`} /> : ''}
629 </Box>
630 </Box>
631 <Box flexDirection="row" gap={2} flexWrap="wrap" alignItems="center">
632 <Box flexDirection="row" gap={1} flexShrink={0}>
633 <Text color={STATUS_STYLE.done.fill}>{bar(c.done, c.total, 20)}</Text>
634 <Text>
635 {c.done} of {c.total} done
636 </Text>
637 </Box>
638 {isLive && map.kind === 'map' && (
639 <Button
640 key="work-next"
641 label={nextUp ? `Work next: ${nextUp.ref} ${arrow}` : `Work the map ${arrow}`}
642 variant="primary"
643 onPress={bind('work-next', () => work($, map, nextUp ?? null))}
644 />
645 )}
646 {/* A spec is implemented in waves (its frontier, then stop) or whole; each runs a subagent per ticket. */}
647 {isLive && map.kind === 'spec' && ready.length > 0 && (
648 <Button
649 key="work-frontier"
650 label={`Implement frontier · ${ready.length} ${arrow}`}
651 variant="primary"
652 onPress={bind('work-frontier', () => work($, map, null, ready))}
653 />
654 )}
655 {isLive && map.kind === 'spec' && c.done < c.total && (
656 <Button key="work-spec" label={`Implement spec ${arrow}`} variant="secondary" onPress={bind('work-spec', () => work($, map, null))} />
657 )}
658 </Box>
659 {pasteNote(el, launches.copied)}
660 <Box flexDirection="row" gap={1} flexWrap="wrap">
661 {CHIPS.filter(x => x.filter === 'all' || x.filter === 'unresolved' || x.filter === 'frontier' || countOf[x.filter] > 0).map(x => (
662 <Button
663 key={`filter:${x.filter}`}
664 label={`${x.label} ${countOf[x.filter]}`}
665 variant={filter === x.filter ? 'primary' : 'secondary'}
666 onPress={bind(`filter:${x.filter}`, () => update($, view, y => ({ ...y, filter: x.filter })))}
667 />
668 ))}
669 </Box>
670 {shown.length === 0 && (
671 <Text dimColor>
672 {filter === 'frontier' ? 'Nothing is ready to take: every open ticket is blocked or claimed.' : 'No tickets match this filter.'}
673 </Text>
674 )}
675 {shown.length > 0 && (v.layout === 'tree' ? treeView($, el, map, shown) : stepsView($, el, map, shown, columns, newChat))}
676 </Box>
677 )
678}
679
680/** A ticket on a page of its own: what it waits on, what waits on it, its body and actions. */
681function ticketPage($: EngineInterface, el: Els, map: WfMap, t: WfTicket, det: WfDetail, launches: Launches) {
682 const { Box, Text, Button, Markdown, Link } = el
683 const st = STATUS_STYLE[t.status]
684 const isNewChat = isOpened(launches.opened, t, launches.now)
685 const byNum = new Map(map.tickets.map(x => [x.num, x]))
686 const after = t.blockedBy.map(n => byNum.get(n)).filter((x): x is WfTicket => !!x)
687 const unblocks = dependents(map.tickets, t.num)
688 const link = (x: WfTicket, prefix: string) => (
689 <Box key={`${prefix}:${x.id}`} flexDirection="row" gap={1}>
690 <Text color={STATUS_STYLE[x.status].fill}>{STATUS_STYLE[x.status].glyph}</Text>
691 <Button
692 key={`${prefix}:${x.id}`}
693 label={`${x.ref} ${x.title}`}
694 plain
695 dimColor={isClosed(x)}
696 onPress={bind(`${prefix}:${x.id}`, () => openTicket($, map, x))}
697 />
698 <Text dimColor>{statusText(x)}</Text>
699 </Box>
700 )
701 // A link in the body to another ticket of this map opens it here (where the surface hands link clicks over).
702 const byPath = new Map(map.tickets.filter(x => x.path).map(x => [norm(x.path!), x]))
703 const ticketLinks = (det?.id === t.id ? (det.links ?? []) : []).filter(l => byPath.has(norm(l.path)))
704 return (
705 <Box flexDirection="column" gap={1}>
706 <Button key="back-map" label="← Back to map" plain hotkey="b" onPress={bind('back-map', () => backToMap($))} />
707 <Text dimColor wrap="truncate-end">
708 {map.title}
709 </Text>
710 <Box flexDirection="column" borderStyle="round" borderColor={st.fill} paddingX={1}>
711 <Box flexDirection="row" gap={1} flexWrap="wrap">
712 <Text color={st.fill}>{st.glyph}</Text>
713 <Text bold>{t.ref}</Text>
714 {t.type && <Text dimColor>{t.type.toUpperCase()}</Text>}
715 <Text color={st.fill}>{statusText(t, isNewChat)}</Text>
716 {t.rawStatus && !['open', 'closed', st.label].includes(t.rawStatus.toLowerCase()) && <Text dimColor>({t.rawStatus})</Text>}
717 </Box>
718 <Text bold wrap="wrap">
719 {t.title}
720 </Text>
721 </Box>
722 <Box flexDirection="row" gap={2} flexWrap="wrap" alignItems="center">
723 {!isClosed(t) && (
724 <Button
725 key={`work:${t.id}`}
726 label={`Work this ticket ${chatFor() === 'here' ? '→' : '↗'}`}
727 variant="primary"
728 onPress={bind(`work:${t.id}`, () => work($, map, t))}
729 />
730 )}
731 {t.url ? <Link href={t.url} label="Open issue ↗" /> : t.path ? <Markdown text={`[Open file ↗](${fileUrl(t.path)})`} /> : ''}
732 </Box>
733 {pasteNote(el, launches.copied)}
734 {after.length > 0 && (
735 <Box flexDirection="column">
736 <Text dimColor bold>
737 WAITS ON
738 </Text>
739 {after.map(x => link(x, 'dep'))}
740 </Box>
741 )}
742 {unblocks.length > 0 && (
743 <Box flexDirection="column">
744 <Text dimColor bold>
745 UNBLOCKS
746 </Text>
747 {unblocks.map(x => link(x, 'next'))}
748 </Box>
749 )}
750 {ticketLinks.length > 0 ? (
751 <Markdown
752 key="body"
753 text={det!.body}
754 pressableLinks={ticketLinks.map(l => l.href)}
755 onLinkPress={link => {
756 const target = ticketLinks.find(l => l.href === link.href)
757 const x = target ? byPath.get(norm(target.path)) : undefined
758 if (x) void openTicket($, map, x)
759 }}
760 />
761 ) : (
762 <Markdown text={det?.id === t.id ? det.body : '_Loading…_'} />
763 )}
764 </Box>
765 )
766}
767
768export const register: Register = (on, options) => {
769 if (options.workIn === 'new-chat' || options.workIn === 'here') workIn = options.workIn
770 if (typeof options.mapPrompt === 'string') prompts.map = options.mapPrompt
771 if (typeof options.mapTicketPrompt === 'string') prompts.mapTicket = options.mapTicketPrompt
772 if (typeof options.specTicketPrompt === 'string') prompts.specTicket = options.specTicketPrompt
773 if (typeof options.specPrompt === 'string') prompts.spec = options.specPrompt
774 if (typeof options.frontierPrompt === 'string') prompts.frontier = options.frontierPrompt
775
776 on('session.start', async ($, e, next) => {
777 await $.command.register({
778 name: 'wayfinder-maps',
779 description: 'Open the Wayfinder drawer: every map and spec in this project, their tickets and dependencies',
780 })
781 $.clock.every(POLL_MS, () => void pollIfOpen($, false))
782 // A first scan in the background, so the band can offer the drawer in a wayfinder project.
783 $.clock.after(500, () => void refresh($, false, true).then(() => platformOf($)))
784 return next(e)
785 })
786
787 on('command.run', { command: 'wayfinder-maps' }, async $ => {
788 await openDrawer($)
789 return { text: 'Wayfinder drawer opened.' }
790 })
791
792 // Desktop: the click that brings the focus onto a button is the press (see ClickGate).
793 on('ui.focus', async ($, e, next) => {
794 const result = await next(e)
795 const isOurs = (e.requestId === PANE || e.component === 'AbovePrompt') && (!e.plugin || e.plugin === 'wayfinder-maps')
796 if (!isOurs || 'deny' in result) return result
797 const handler = e.element ? handlers.get(e.element) : undefined
798 const now = await $.clock.now()
799 const isPerson = e.origin.kind === 'person'
800 if (gate.focus({ key: e.element, isPerson, isDesktop: drawnOn === 'desktop', now }) && handler) handler()
801 return result
802 }).catch(($, e, next) => next(e))
803
804 on('ui.press', async ($, e, next) => {
805 if (!handlers.has(e.element)) return next(e)
806 if (!gate.press(e.element, await $.clock.now())) return { element: e.element }
807 return next(e)
808 })
809
810 // A ticket card (card.tsx) posts `{ open: id }` when clicked anywhere.
811 on('ui.message', async ($, e, next) => {
812 const open = (e.data as { open?: unknown } | null)?.open
813 if (e.requestId !== PANE || typeof open !== 'string') return next(e)
814 const d = await read($, data)
815 const v = await read($, view)
816 const map = d?.maps.find(m => m.id === v.mapId)
817 const ticket = map?.tickets.find(t => t.id === open)
818 if (map && ticket) await openTicket($, map, ticket)
819 return {}
820 })
821
822 on('ui.close', async ($, e, next) => {
823 const result = await next(e)
824 if (e.id === PANE) await update($, isPaneOpen, () => false)
825 return result
826 }).catch(($, e, next) => next(e))
827
828 // The band above the prompt: shown only where the project has maps, and only while the drawer is closed.
829 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
830 const d = await read($, data)
831 const open = await read($, isPaneOpen)
832 if (e.props.hasSurvey || open || !d || d.maps.length === 0) return next(e)
833 drawnOn = e.surface
834 const el = $.ui.resolve(e)
835 const { Box, Text, Button } = el
836 const live = d.maps.filter(m => m.status !== 'done' && m.status !== 'graduated')
837 const all = live.flatMap(m => m.tickets)
838 const frontier = all.filter(t => t.isFrontier).length
839 const waiting = all.filter(t => t.status === 'waiting').length
840 const maps = d.maps.filter(m => m.kind === 'map').length
841 const specs = d.maps.length - maps
842 const summary = [
843 maps ? `${maps} map${maps === 1 ? '' : 's'}` : '',
844 specs ? `${specs} spec${specs === 1 ? '' : 's'}` : '',
845 frontier ? `${frontier} ready to take` : '',
846 waiting ? `${waiting} waiting on you` : '',
847 ]
848 .filter(Boolean)
849 .join(' · ')
850 return (
851 <Box flexDirection="row" gap={1}>
852 {mark(el)}
853 <Text dimColor>·</Text>
854 <Text dimColor>{summary}</Text>
855 <Button key="open-drawer" label="Open map drawer" plain onPress={bind('open-drawer', () => openDrawer($))} />
856 </Box>
857 )
858 })
859
860 on('turn.complete', async ($, e, next) => {
861 const result = await next(e)
862 if (e.agentId === undefined) {
863 const now = await $.clock.now()
864 void pollIfOpen($, now - lastGithubAt > GITHUB_EVERY_MS)
865 }
866 return result
867 })
868
869 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
870 drawnOn = e.surface
871 const el = $.ui.resolve(e)
872 const { Box, Text } = el
873 const d = await read($, data)
874 const loading = await read($, isLoading)
875 const v = await read($, view)
876 const columns = Math.max(20, (e.props.bodyColumns ?? 60) - 2)
877 const top = header($, el, loading)
878
879 if (!d) {
880 return (
881 <Box flexDirection="column" gap={1}>
882 {top}
883 <Text dimColor>{loading ? 'Looking for maps…' : 'Press Refresh to scan this project.'}</Text>
884 </Box>
885 )
886 }
887 const map = v.mapId ? d.maps.find(m => m.id === v.mapId) : undefined
888 const ticket = map && v.ticketId ? map.tickets.find(t => t.id === v.ticketId) : undefined
889 // `started` may predate a hot reload that added it.
890 const launches: Launches = { opened: (await read($, started)) ?? {}, now: await $.clock.now(), copied: (await read($, copied)) ?? null }
891 const body = !map
892 ? mapList($, el, d)
893 : ticket
894 ? ticketPage($, el, map, ticket, await read($, detail), launches)
895 : mapView($, el, map, v, columns, launches)
896 return (
897 <Box flexDirection="column" gap={1} paddingBottom={1}>
898 {top}
899 {body}
900 </Box>
901 )
902 })
903}
904hooks/card.tsx 68 lines1import type { ClientModule } from 'claude-code'
2
3/** What the hooks module hands a ticket card: everything it draws, as plain data. */
4export type CardProps = {
5 id: string
6 ref: string
7 type: string
8 /** The title, already wrapped to the card's width: the card's height is known before it draws. */
9 lines: string[]
10 status: string
11 color: string
12 glyph: string
13 after: string
14 isClosed: boolean
15 width: number
16}
17
18type CardState = { isHover: boolean }
19
20/**
21 * One ticket card, drawn by a surface module so the whole card is one target:
22 * a click anywhere on it, or Enter while it has the focus, posts `{ open }`
23 * to the hooks module, and the pointer over it lights the whole card.
24 */
25const TicketCard: ClientModule<CardProps, CardState> = (p, surface) => {
26 const { Box, Text } = surface.elements
27 const isHover = surface.state?.isHover ?? false
28
29 // Set on every draw (each call replaces the last), so a handler never holds stale props.
30 surface.onPointer(e => {
31 if (e.type === 'enter' && !isHover) surface.setState({ isHover: true })
32 if (e.type === 'leave' && isHover) surface.setState({ isHover: false })
33 // Before the first layout the region's size is 0: a click then is taken as inside.
34 const isLaidOut = surface.columns > 0 && surface.rows > 0
35 const isInside = e.x >= 0 && e.y >= 0 && (!isLaidOut || (e.x < surface.columns && e.y < surface.rows))
36 if (e.type === 'up' && e.button === 'left' && isInside) surface.post({ open: p.id })
37 })
38 surface.onKey(e => {
39 if (e.key === 'return' || e.key === ' ') surface.post({ open: p.id })
40 })
41
42 const isDim = p.isClosed && !isHover
43 return (
44 <Box flexDirection="column" width={p.width} borderStyle="round" borderColor={p.color} borderDimColor={isDim} paddingX={1}>
45 <Box flexDirection="row" justifyContent="space-between" gap={1}>
46 <Box flexDirection="row" gap={1} flexShrink={0}>
47 <Text color={p.color}>{p.glyph}</Text>
48 <Text bold dimColor={isDim}>
49 {p.ref}
50 </Text>
51 {p.type ? <Text dimColor>{p.type}</Text> : ''}
52 </Box>
53 <Text color={p.isClosed ? undefined : p.color} dimColor={p.isClosed}>
54 {p.status}
55 </Text>
56 </Box>
57 {p.lines.map(line => (
58 <Text dimColor={isDim} underline={isHover} wrap="truncate-end">
59 {line}
60 </Text>
61 ))}
62 {p.after ? <Text dimColor>{p.after}</Text> : ''}
63 </Box>
64 )
65}
66
67export default TicketCard
68hooks/clicks.ts 44 lines1/**
2 * Decides which focus moves and presses run a Button's action.
3 *
4 * The desktop app gives a Button the focus on the first click and presses it
5 * only on a second one. So on desktop a person's focus move onto one of our
6 * buttons runs its action, and the gate keeps each click to one action:
7 *
8 * - focus landing again on the button that already holds the ring (a redraw
9 * re-seating it) runs nothing;
10 * - a press, or a repeat focus, on a button whose action ran in the last
11 * ECHO_MS runs nothing (one click raising both events).
12 */
13export const ECHO_MS = 600
14
15export type GateFocus = { key: string | undefined; isPerson: boolean; isDesktop: boolean; now: number }
16
17export class ClickGate {
18 private ring: string | undefined
19 private last = { key: '', at: -Infinity }
20
21 /** True when this focus move should run the button's action. */
22 focus({ key, isPerson, isDesktop, now }: GateFocus): boolean {
23 const previous = this.ring
24 this.ring = key
25 if (!key || !isPerson || !isDesktop) return false
26 if (key === previous) return false
27 if (this.isEcho(key, now)) return false
28 this.last = { key, at: now }
29 return true
30 }
31
32 /** True when this press should run the button's action. */
33 press(key: string, now: number): boolean {
34 this.ring = key
35 if (this.isEcho(key, now)) return false
36 this.last = { key, at: now }
37 return true
38 }
39
40 private isEcho(key: string, now: number): boolean {
41 return this.last.key === key && now - this.last.at < ECHO_MS
42 }
43}
44hooks/github.ts 126 lines1
2import type { WfMap, WfStatus } from '../types'
3import { finishTickets, mapStatus } from './parse'
4import type { Io } from './parse'
5import type { ParsedTicketFile } from './parse'
6
7type GhIssue = {
8 number: number
9 title: string
10 state: 'OPEN' | 'CLOSED'
11 stateReason?: string | null
12 url: string
13 body?: string
14 assignees?: { nodes: { login: string }[] }
15 labels?: { nodes: { name: string }[] }
16 blockedBy?: { nodes: { number: number }[] }
17 subIssues?: { nodes: GhIssue[] }
18}
19
20const CHILD = `number title state stateReason url body
21 assignees(first: 3) { nodes { login } }
22 labels(first: 10) { nodes { name } }
23 blockedBy(first: 30) { nodes { number } }`
24
25const MAPS_QUERY = `query($owner: String!, $name: String!) {
26 repository(owner: $owner, name: $name) {
27 issues(first: 50, labels: ["wayfinder:map"], states: [OPEN, CLOSED], orderBy: { field: CREATED_AT, direction: DESC }) {
28 nodes { number title state stateReason url body subIssues(first: 100) { nodes { ${CHILD} } } }
29 }
30 }
31}`
32
33async function gh(io: Io, root: string, args: string[]): Promise<{ ok: boolean; out: string; err: string }> {
34 try {
35 const r = await io.run(['gh', ...args], root)
36 return { ok: r.exitCode === 0, out: r.stdout, err: r.stderr }
37 } catch (error) {
38 return { ok: false, out: '', err: String(error) }
39 }
40}
41
42/** `owner/name` of the repo's GitHub remote, or null when it has none (or gh is missing). */
43export async function githubRepo(io: Io, root: string): Promise<string | null> {
44 const r = await gh(io, root, ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'])
45 return r.ok && r.out.trim() ? r.out.trim() : null
46}
47
48/** Without native links: `- [ ] #12` task-list children on the map, `Blocked by: #3` lines on the child. */
49function taskListNumbers(body: string): number[] {
50 return [...body.matchAll(/^\s*[-*]\s+\[[ xX]\]\s+.*?#(\d+)/gm)].map(m => Number(m[1]))
51}
52function bodyBlockers(body: string): number[] {
53 const line = body.split(/\r?\n/).slice(0, 15).find(l => /^\**blocked by\**\s*:/i.test(l))
54 return line ? [...line.matchAll(/#(\d+)/g)].map(m => Number(m[1])) : []
55}
56
57async function fetchByNumber(io: Io, root: string, owner: string, name: string, numbers: number[]): Promise<GhIssue[]> {
58 if (numbers.length === 0) return []
59 const fields = numbers.map(n => `i${n}: issue(number: ${n}) { ${CHILD} }`).join('\n')
60 const q = `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields} } }`
61 const r = await gh(io, root, ['api', 'graphql', '-f', `query=${q}`, '-F', `owner=${owner}`, '-F', `name=${name}`])
62 if (!r.ok) return []
63 const repo = JSON.parse(r.out).data?.repository ?? {}
64 return Object.values(repo).filter(Boolean) as GhIssue[]
65}
66
67function toTicket(issue: GhIssue): ParsedTicketFile & { closedAs?: WfStatus } {
68 const labels = issue.labels?.nodes.map(l => l.name) ?? []
69 const type = labels.find(l => l.startsWith('wayfinder:'))?.slice('wayfinder:'.length)
70 const native = issue.blockedBy?.nodes.map(n => n.number) ?? []
71 const closedAs: WfStatus | undefined =
72 issue.state === 'CLOSED' ? (issue.stateReason === 'NOT_PLANNED' ? 'dropped' : 'done') : undefined
73 const awaiting = labels.some(l => /awaiting|ready-for-human|needs-human/.test(l))
74 return {
75 id: issue.url,
76 num: issue.number,
77 ref: `#${issue.number}`,
78 title: issue.title,
79 rawStatus: closedAs ? 'closed' : awaiting ? 'awaiting' : 'open',
80 closedAs,
81 type: type && type !== 'map' ? type : undefined,
82 assignee: issue.assignees?.nodes[0]?.login,
83 blockedBy: native.length > 0 ? native : bodyBlockers(issue.body ?? ''),
84 url: issue.url,
85 }
86}
87
88/** Loads every `wayfinder:map` issue of the repo with its sub-issues and blocking edges. */
89export async function loadGithubMaps(io: Io, root: string, repo: string): Promise<{ maps: WfMap[]; error?: string }> {
90 const [owner = '', name = ''] = repo.split('/')
91 const r = await gh(io, root, ['api', 'graphql', '-f', `query=${MAPS_QUERY}`, '-F', `owner=${owner}`, '-F', `name=${name}`])
92 if (!r.ok) return { maps: [], error: `GitHub: ${(r.err || r.out).trim().split('\n')[0]}` }
93 const issues: GhIssue[] = JSON.parse(r.out).data?.repository?.issues?.nodes ?? []
94
95 const maps = await Promise.all(
96 issues.map(async (mapIssue): Promise<WfMap> => {
97 let children = mapIssue.subIssues?.nodes ?? []
98 if (children.length === 0) {
99 children = await fetchByNumber(io, root, owner, name, taskListNumbers(mapIssue.body ?? ''))
100 }
101 const tickets = finishTickets(children.map(toTicket))
102 const isClosed = mapIssue.state === 'CLOSED'
103 return {
104 id: `gh:${repo}#${mapIssue.number}`,
105 kind: 'map',
106 source: 'github',
107 title: mapIssue.title,
108 ref: `#${mapIssue.number}`,
109 url: mapIssue.url,
110 status: mapStatus('map', tickets, isClosed ? 'closed' : undefined, false),
111 tickets,
112 }
113 }),
114 )
115 return { maps }
116}
117
118/** A ticket's full body and comments, for the details view. */
119export async function githubIssueBody(io: Io, root: string, url: string): Promise<string> {
120 const r = await gh(io, root, ['issue', 'view', url, '--json', 'body,comments', '-q', '{body: .body, comments: [.comments[] | {author: .author.login, body: .body}]}'])
121 if (!r.ok) return `_Could not load the issue: ${r.err.trim()}_`
122 const data = JSON.parse(r.out) as { body: string; comments: { author: string; body: string }[] }
123 const comments = data.comments.map(c => `**${c.author}:**\n\n${c.body}`).join('\n\n---\n\n')
124 return comments ? `${data.body}\n\n## Comments\n\n${comments}` : data.body
125}
126hooks/graph.ts 87 lines1import type { WfStatus, WfTicket } from '../types'
2
3/**
4 * Each status's color and glyph: the only color the drawer adds to the
5 * surface's own theme. Mid-tone hues, legible on light and dark alike.
6 */
7export const STATUS_STYLE: Record<WfStatus, { fill: string; label: string; glyph: string }> = {
8 open: { fill: '#e8702a', label: 'frontier', glyph: '⚑' },
9 claimed: { fill: '#4a86e8', label: 'claimed', glyph: '◐' },
10 waiting: { fill: '#d4a017', label: 'waiting on you', glyph: '◷' },
11 blocked: { fill: '#8a8f98', label: 'blocked', glyph: '◌' },
12 done: { fill: '#3fa36b', label: 'done', glyph: '✓' },
13 dropped: { fill: '#8a8f98', label: 'out of scope', glyph: '✕' },
14}
15
16/** Layer = longest chain of blockers above it, within the shown set. */
17export function layers(tickets: WfTicket[]): Map<number, number> {
18 const byNum = new Map(tickets.map(t => [t.num, t]))
19 const memo = new Map<number, number>()
20 const visit = (num: number, seen: Set<number>): number => {
21 if (memo.has(num)) return memo.get(num)!
22 if (seen.has(num)) return 0
23 seen.add(num)
24 const t = byNum.get(num)!
25 const parents = t.blockedBy.filter(n => byNum.has(n))
26 const layer = parents.length === 0 ? 0 : 1 + Math.max(...parents.map(p => visit(p, seen)))
27 seen.delete(num)
28 memo.set(num, layer)
29 return layer
30 }
31 for (const t of tickets) visit(t.num, new Set())
32 return memo
33}
34
35/** The tickets a ticket blocks: what opens up once it is done. */
36export function dependents(tickets: WfTicket[], num: number): WfTicket[] {
37 return tickets.filter(t => t.blockedBy.includes(num))
38}
39
40export const CARD_MIN = 30
41export const CARD_MAX = 52
42export const CARD_GAP = 2
43
44/**
45 * How many cards fit across `columns` cells and how wide each is: as many as
46 * fit at CARD_MIN, widened to share the row, one full-width card when narrow.
47 */
48export function cardLayout(columns: number): { perRow: number; cardWidth: number; gap: number } {
49 const perRow = Math.max(1, Math.floor((columns + CARD_GAP) / (CARD_MIN + CARD_GAP)))
50 const cardWidth = Math.min(CARD_MAX, Math.floor((columns - CARD_GAP * (perRow - 1)) / perRow))
51 return { perRow, cardWidth: Math.max(Math.min(columns, CARD_MIN), cardWidth), gap: CARD_GAP }
52}
53
54/** Rows a ticket card takes: its border, the header, the title lines and the "after" line. */
55export const cardRows = (card: { lines: readonly string[]; after: string }) => 2 + 1 + card.lines.length + (card.after ? 1 : 0)
56
57/**
58 * The same graph as an indented tree: each ticket under the first ticket that
59 * blocks it, later mentions as a pointer back. Rows in display order.
60 */
61export function treeRows(tickets: WfTicket[]): { ticket: WfTicket; depth: number; isRepeat: boolean; extraParents: number[] }[] {
62 const shown = new Set(tickets.map(t => t.num))
63 const children = new Map<number, WfTicket[]>()
64 const roots: WfTicket[] = []
65 for (const t of tickets) {
66 const parents = t.blockedBy.filter(n => shown.has(n))
67 if (parents.length === 0) roots.push(t)
68 else for (const p of parents) children.set(p, [...(children.get(p) ?? []), t])
69 }
70 const rows: { ticket: WfTicket; depth: number; isRepeat: boolean; extraParents: number[] }[] = []
71 const placed = new Set<number>()
72 const walk = (t: WfTicket, depth: number, parent: number | null) => {
73 if (placed.has(t.num)) {
74 rows.push({ ticket: t, depth, isRepeat: true, extraParents: [] })
75 return
76 }
77 placed.add(t.num)
78 const extraParents = t.blockedBy.filter(n => shown.has(n) && n !== parent)
79 rows.push({ ticket: t, depth, isRepeat: false, extraParents })
80 for (const c of children.get(t.num) ?? []) walk(c, depth + 1, t.num)
81 }
82 for (const r of roots) walk(r, 0, null)
83 // Cycles leave tickets unplaced: show them at the top level.
84 for (const t of tickets) if (!placed.has(t.num)) walk(t, 0, null)
85 return rows
86}
87hooks/launch.ts 65 lines1/**
2 * How a Work button opens a new chat on a ticket: the command line the host
3 * runs (no shell), built here so tests reach it without the engine.
4 */
5export type Platform = 'windows' | 'mac' | 'linux'
6
7export type Launch = {
8 argv: string[]
9 /** Where to run it; absent, the project root. */
10 cwd?: string
11 /** True when the chat starts working at once; false when its prompt waits for Enter. */
12 startsAtOnce: boolean
13}
14
15/**
16 * The desktop app's link to a new Code session in `folder`. It carries no
17 * prompt: the app turns the `/` of a slash command in one into `/`, so a
18 * link can never run a skill. The person pastes the copied command instead.
19 */
20export const appLink = (folder: string) => `claude://code/new?folder=${encodeURIComponent(folder)}`
21
22/** Claude Code's link to a new terminal session in `cwd`, the prompt filled in (not sent). */
23export const cliLink = (prompt: string, cwd: string) =>
24 `claude-cli://open?q=${encodeURIComponent(prompt)}&cwd=${encodeURIComponent(cwd)}`
25
26/** Opens a link with the system's handler for its scheme. */
27export function openLink(url: string, platform: Platform): string[] {
28 // rundll32 hands the link to the shell as is; `start` would read the `&` in it.
29 if (platform === 'windows') return ['rundll32.exe', 'url.dll,FileProtocolHandler', url]
30 return [platform === 'mac' ? 'open' : 'xdg-open', url]
31}
32
33/** A new, empty session in the desktop app, in the project's folder. */
34export const appChat = (root: string, platform: Platform): Launch => ({
35 argv: openLink(appLink(root), platform),
36 startsAtOnce: false,
37})
38
39/** The host's own clipboard tool, reading the text on stdin: where the surface takes no copy. */
40export function clipboardArgv(platform: Platform): string[] {
41 if (platform === 'windows') return ['clip.exe']
42 if (platform === 'mac') return ['pbcopy']
43 return ['sh', '-c', 'wl-copy 2>/dev/null || xclip -selection clipboard']
44}
45
46/** The paste shortcut, as the person's keyboard spells it. */
47export const pasteKeys = (platform: Platform) => (platform === 'mac' ? '⌘V' : 'Ctrl+V')
48
49// cmd.exe reads its own command line: these end or change it, quoted or not.
50const CMD_UNSAFE = /["%^&|<>!\r\n]/
51
52/**
53 * A terminal window of its own. On Windows `start` opens one running
54 * `claude <prompt>`, so the chat starts at once; elsewhere, or for a prompt
55 * cmd.exe would misread, Claude Code's link opens the person's terminal with
56 * the prompt filled in.
57 */
58export function terminalChat(prompt: string, root: string, title: string, platform: Platform): Launch {
59 if (platform === 'windows' && !CMD_UNSAFE.test(prompt) && !CMD_UNSAFE.test(title) && title.includes(' ')) {
60 // `start` takes its first quoted argument as the window's title; the space gets it quoted.
61 return { argv: ['cmd.exe', '/d', '/c', 'start', title, 'claude', prompt], cwd: root, startsAtOnce: true }
62 }
63 return { argv: openLink(cliLink(prompt, root), platform), startsAtOnce: false }
64}
65hooks/markdown.ts 103 lines1import type { WfMap } from '../types'
2import { finishTickets, mapStatus, parseHeadDoc, parseTicketFile } from './parse'
3import type { Io } from './parse'
4import type { ParsedTicketFile } from './parse'
5
6/** Folders never worth descending into. */
7const NEVER = new Set(['node_modules', '.git', '.pnpm-store', '.venv', 'venv', '__pycache__', 'worktrees'])
8/** Code and build folders, skipped outside planning folders (an effort may well be named build). */
9const CODE = new Set(['dist', 'build', 'out', 'coverage', 'target', 'vendor', '.next', '.turbo', '.cache', 'src', 'assets', 'designs', 'research', 'spikes'])
10/** Where efforts live: inside these, every folder name is fair game. */
11const PLANNING = /(^|\/)(\.scratch|\.wayfinder|\.planning|docs|planning|wayfinder)(\/|$)/
12const TICKET_DIRS = ['issues', 'tickets']
13const MAX_DEPTH = 4
14
15const join = (a: string, b: string) => (a.endsWith('/') || a.endsWith('\\') ? a + b : `${a}/${b}`)
16
17async function list(io: Io, path: string) {
18 try {
19 return await io.list(path)
20 } catch {
21 return []
22 }
23}
24
25async function readText(io: Io, path: string): Promise<string> {
26 try {
27 return await io.read(path)
28 } catch {
29 return ''
30 }
31}
32
33/** Every folder holding a `map.md` / `spec.md` beside an `issues/` or `tickets/` folder, to MAX_DEPTH. */
34async function findEffortDirs(io: Io, root: string): Promise<{ dir: string; rel: string; names: Set<string> }[]> {
35 const found: { dir: string; rel: string; names: Set<string> }[] = []
36 let frontier: { dir: string; rel: string }[] = [{ dir: root, rel: '' }]
37 for (let depth = 0; depth <= MAX_DEPTH && frontier.length > 0; depth++) {
38 const next: { dir: string; rel: string }[] = []
39 await Promise.all(
40 frontier.map(async ({ dir, rel }) => {
41 const entries = await list(io, dir)
42 const names = new Set(entries.map(e => e.name))
43 const ticketDir = TICKET_DIRS.find(d => entries.some(e => e.name === d && e.kind === 'dir'))
44 if (ticketDir && rel !== '') found.push({ dir, rel, names })
45 for (const e of entries) {
46 if (e.kind !== 'dir' || NEVER.has(e.name) || TICKET_DIRS.includes(e.name)) continue
47 if (CODE.has(e.name) && !PLANNING.test(rel)) continue
48 if (e.name.startsWith('.') && !['.scratch', '.wayfinder', '.planning'].includes(e.name)) continue
49 next.push({ dir: join(dir, e.name), rel: rel ? `${rel}/${e.name}` : e.name })
50 }
51 }),
52 )
53 frontier = next
54 }
55 return found
56}
57
58/** Loads every markdown map and spec under the project root. */
59export async function loadMarkdownMaps(io: Io, root: string): Promise<WfMap[]> {
60 const dirs = await findEffortDirs(io, root)
61 const maps = await Promise.all(
62 dirs.map(async ({ dir, rel, names }): Promise<WfMap | null> => {
63 const ticketDirName = TICKET_DIRS.find(d => names.has(d))!
64 const ticketDir = join(dir, ticketDirName)
65 const files = (await list(io, ticketDir)).filter(e => e.kind === 'file' && /^\d+-.*\.md$/.test(e.name))
66 const parsed = (
67 await Promise.all(
68 files.map(async f => {
69 const path = join(ticketDir, f.name)
70 return parseTicketFile(f.name, path, await readText(io, path))
71 }),
72 )
73 ).filter((t): t is ParsedTicketFile => t !== null)
74
75 const hasMap = names.has('map.md')
76 const hasSpec = names.has('spec.md')
77 // A plain issues folder only counts when its files look like tracker tickets.
78 if (!hasMap && !hasSpec && !parsed.some(t => t.rawStatus !== '')) return null
79 if (parsed.length === 0 && !hasMap) return null
80
81 const kind: WfMap['kind'] = hasMap ? 'map' : 'spec'
82 const headPath = join(dir, hasMap ? 'map.md' : hasSpec ? 'spec.md' : '')
83 const head = hasMap || hasSpec ? parseHeadDoc(await readText(io, headPath)) : {}
84 const tickets = finishTickets(parsed)
85 const folder = rel.split('/').pop() ?? rel
86 return {
87 id: `md:${rel}`,
88 kind,
89 source: 'md',
90 title: head.title ?? folder,
91 ref: hasMap || hasSpec ? `${rel}/${hasMap ? 'map.md' : 'spec.md'}` : rel,
92 dir: rel,
93 path: hasMap || hasSpec ? headPath : dir,
94 status: mapStatus(kind, tickets, head.status, hasSpec),
95 tickets,
96 }
97 }),
98 )
99 return maps.filter((m): m is WfMap => m !== null)
100}
101
102export { readText }
103hooks/parse.ts 147 lines1import type { WfMap, WfMapStatus, WfStatus, WfTicket } from '../types'
2
3const HEADER_FIELD = /^\*{0,2}(type|status|assignee|blocked by)\s*:\s*\*{0,2}\s*(.*)$/i
4
5/** Folds the many status words the trackers use into five buckets. */
6export function normalizeStatus(raw: string): WfStatus {
7 const s = raw.trim().toLowerCase()
8 if (/^(resolved|done|closed|complete|completed|shipped|merged)\b/.test(s)) return 'done'
9 if (/^(claimed|in[- ]progress|wip|doing|active)\b/.test(s)) return 'claimed'
10 if (/^(awaiting|ready-for-human|needs-human|waiting|blocked-on-human)/.test(s)) return 'waiting'
11 if (/^(out[- ]of[- ]scope|dropped|wontfix|won't fix|not[- ]planned)/.test(s)) return 'dropped'
12 return 'open'
13}
14
15/** Leading ticket numbers out of `03, 07`, `#12 #13`, `106 — Versions`. */
16function numbersIn(text: string): number[] {
17 if (/^\s*(none|n\/a|-|—|nothing)?\s*$/i.test(text)) return []
18 return text
19 .split(/[,;]|\band\b/)
20 .map(part => part.trim().match(/^#?(\d+)/))
21 .filter((m): m is RegExpMatchArray => m !== null)
22 .map(m => Number(m[1]))
23}
24
25function cleanTitle(title: string, num: number): string {
26 return title
27 .replace(/^#+\s*/, '')
28 .replace(new RegExp(`^0*${num}\\s*[—–·:.\\-]\\s*`), '')
29 .replace(/^map\s*[·:—-]\s*/i, '')
30 .trim()
31}
32
33export type ParsedTicketFile = Omit<WfTicket, 'status' | 'isFrontier'> & { rawStatus: string }
34
35/** Reads one ticket file: `NN-slug.md` with `Type:` / `Status:` / `Blocked by:` lines up top. */
36export function parseTicketFile(fileName: string, path: string, text: string): ParsedTicketFile | null {
37 const numMatch = fileName.match(/^(\d+)-.*\.md$/)
38 if (!numMatch || /\.brief\.md$/.test(fileName)) return null
39 const num = Number(numMatch[1])
40 const lines = text.split(/\r?\n/)
41 let title = fileName.replace(/^\d+-/, '').replace(/\.md$/, '').replace(/-/g, ' ')
42 let rawStatus = ''
43 let type: string | undefined
44 let assignee: string | undefined
45 const blockedBy: number[] = []
46 let inBlockedList = false
47
48 for (const line of lines) {
49 if (/^## /.test(line) || /^- \[[ x]\]/i.test(line)) break
50 if (/^# /.test(line)) {
51 title = cleanTitle(line, num)
52 continue
53 }
54 const field = line.match(HEADER_FIELD)
55 if (field) {
56 inBlockedList = false
57 const [, key = '', value = ''] = field
58 const name = key.toLowerCase()
59 if (name === 'status') rawStatus = value.replace(/\*+/g, '').trim()
60 else if (name === 'type') type = value.replace(/\*+/g, '').trim() || undefined
61 else if (name === 'assignee') assignee = value.replace(/\*+/g, '').trim() || undefined
62 else if (name === 'blocked by') {
63 blockedBy.push(...numbersIn(value))
64 inBlockedList = value.trim() === ''
65 }
66 continue
67 }
68 if (inBlockedList) {
69 const item = line.match(/^\s*[-*]\s+#?(\d+)/)
70 if (item) blockedBy.push(Number(item[1]))
71 else if (line.trim() !== '') inBlockedList = false
72 }
73 }
74
75 return {
76 id: path,
77 num,
78 ref: String(numMatch[1]),
79 title,
80 rawStatus,
81 type,
82 assignee,
83 blockedBy: [...new Set(blockedBy)].filter(n => n !== num),
84 path,
85 }
86}
87
88/** The map's or spec's own title and any `Status:` line it carries. */
89export function parseHeadDoc(text: string): { title?: string; status?: string } {
90 let title: string | undefined
91 let status: string | undefined
92 for (const line of text.split(/\r?\n/).slice(0, 30)) {
93 if (!title && /^# /.test(line)) title = cleanTitle(line, -1)
94 const field = line.match(HEADER_FIELD)
95 if (field && field[1]?.toLowerCase() === 'status') status = (field[2] ?? '').replace(/\*+/g, '').trim()
96 }
97 return { title, status }
98}
99
100/** Finalises tickets: blocked when any blocker inside the map is unfinished; frontier = open, unblocked, unclaimed. */
101export function finishTickets(parsed: (ParsedTicketFile & { closedAs?: WfStatus })[]): WfTicket[] {
102 const base = new Map(parsed.map(t => [t.num, t.closedAs ?? normalizeStatus(t.rawStatus)]))
103 return parsed
104 .map(t => {
105 let status = base.get(t.num) as WfStatus
106 const openBlockers = t.blockedBy.filter(n => {
107 const s = base.get(n)
108 return s !== undefined && s !== 'done' && s !== 'dropped'
109 })
110 if (status === 'open' && t.assignee && !/^(none|-|—)$/i.test(t.assignee)) status = 'claimed'
111 if (status === 'open' && openBlockers.length > 0) status = 'blocked'
112 const { closedAs: _closedAs, ...rest } = t
113 return { ...rest, status, isFrontier: status === 'open' }
114 })
115 .sort((a, b) => a.num - b.num)
116}
117
118export function mapStatus(kind: WfMap['kind'], tickets: WfTicket[], headStatus: string | undefined, hasSpec: boolean): WfMapStatus {
119 if (headStatus && normalizeStatus(headStatus) === 'done') return 'done'
120 if (kind === 'map' && hasSpec) return 'graduated'
121 const live = tickets.filter(t => t.status !== 'dropped')
122 if (live.length > 0 && live.every(t => t.status === 'done')) return 'done'
123 if (live.some(t => t.status === 'claimed' || t.status === 'done')) return 'active'
124 return 'open'
125}
126
127export function counts(tickets: WfTicket[]) {
128 const c = { total: 0, done: 0, claimed: 0, waiting: 0, blocked: 0, frontier: 0 }
129 for (const t of tickets) {
130 if (t.status === 'dropped') continue
131 c.total += 1
132 if (t.status === 'done') c.done += 1
133 else if (t.status === 'claimed') c.claimed += 1
134 else if (t.status === 'waiting') c.waiting += 1
135 else if (t.status === 'blocked') c.blocked += 1
136 else c.frontier += 1
137 }
138 return c
139}
140
141/** What the loaders need from the host, handed in by the hooks module. */
142export type Io = {
143 list: (path: string) => Promise<readonly { name: string; kind: string }[]>
144 read: (path: string) => Promise<string>
145 run: (argv: string[], cwd: string) => Promise<{ exitCode: number | null; stdout: string; stderr: string }>
146}
147hooks/text.ts 94 lines1/** Text shaping for the drawer: ticket bodies made readable there, titles fitted to cards. */
2
3const FIELD = /^\*{0,2}(type|status|assignee|blocked by|label|charted)\s*:\s*\*{0,2}/i
4
5/**
6 * Drops the tracker's header lines (`Type:`, `Status:`, `Blocked by:` and its
7 * list) from above a ticket's first section: the drawer shows them already.
8 */
9export function stripHeaderFields(body: string): string {
10 const lines = body.split(/\r?\n/)
11 const out: string[] = []
12 let inSections = false
13 let inBlockedList = false
14 for (const line of lines) {
15 if (!inSections && /^## /.test(line)) inSections = true
16 if (inSections) {
17 out.push(line)
18 continue
19 }
20 if (FIELD.test(line)) {
21 inBlockedList = /^\*{0,2}blocked by\s*:\s*\*{0,2}\s*$/i.test(line)
22 continue
23 }
24 if (inBlockedList && /^\s*[-*]\s+/.test(line)) continue
25 if (line.trim() !== '') inBlockedList = false
26 out.push(line)
27 }
28 return out.join('\n').replace(/^\s+/, '').replace(/\n{3,}/g, '\n\n')
29}
30
31/** Joins a relative link onto a folder, resolving `.` and `..`, with forward slashes. */
32export function resolvePath(dir: string, rel: string): string {
33 const parts = dir.replace(/\\/g, '/').split('/')
34 for (const seg of rel.replace(/\\/g, '/').split('/')) {
35 if (seg === '' || seg === '.') continue
36 if (seg === '..') parts.length > 1 && parts.pop()
37 else parts.push(seg)
38 }
39 return parts.join('/').replace(/\/{2,}/g, '/')
40}
41
42export const fileUrl = (path: string) =>
43 'file:///' + encodeURI(path.replace(/\\/g, '/').replace(/^\/+/, '')).replace(/#/g, '%23').replace(/\?/g, '%3F')
44
45/**
46 * Makes a markdown file's relative links work outside its folder: each
47 * becomes an absolute `file:` link. Returns the new text and every link it
48 * rewrote, with the file it points at.
49 */
50export function absolutizeLinks(body: string, filePath: string): { text: string; links: { href: string; path: string }[] } {
51 const dir = filePath.replace(/\\/g, '/').replace(/\/[^/]*$/, '')
52 const links: { href: string; path: string }[] = []
53 const text = body.replace(/(\]\()([^)\s]+)((?:\s+"[^"]*")?\))/g, (whole, open: string, target: string, close: string) => {
54 if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#') || target.startsWith('/')) return whole
55 const [pathPart, anchor] = target.split('#') as [string, string | undefined]
56 let decoded = pathPart
57 try {
58 decoded = decodeURI(pathPart)
59 } catch {
60 // Keep it as written.
61 }
62 const path = resolvePath(dir, decoded)
63 const href = fileUrl(path) + (anchor ? `#${anchor}` : '')
64 links.push({ href, path })
65 return `${open}${href}${close}`
66 })
67 return { text, links }
68}
69
70/**
71 * Breaks a title into lines of at most `width` characters, at most `maxLines`
72 * of them, the last ending in an ellipsis when the title runs on.
73 */
74export function wrapLines(text: string, width: number, maxLines: number): string[] {
75 const w = Math.max(8, width)
76 const lines: string[] = []
77 let cur = ''
78 for (const word of text.split(/\s+/).filter(Boolean)) {
79 const next = cur ? `${cur} ${word}` : word
80 if (next.length <= w) {
81 cur = next
82 continue
83 }
84 if (cur) lines.push(cur)
85 cur = word.length > w ? word.slice(0, w - 1) + '…' : word
86 }
87 if (cur) lines.push(cur)
88 if (lines.length <= maxLines) return lines
89 const kept = lines.slice(0, maxLines)
90 const last = kept[maxLines - 1]!
91 kept[maxLines - 1] = (last.length + 2 > w ? last.slice(0, w - 2) : last) + ' …'
92 return kept
93}
94types/index.d.ts 78 lines1/** A ticket's state, folded from whatever its tracker writes. */
2export type WfStatus = 'done' | 'claimed' | 'waiting' | 'blocked' | 'open' | 'dropped'
3
4export type WfTicket = {
5 id: string
6 num: number
7 /** How a prompt names it: `07` for a file, `#42` for an issue. */
8 ref: string
9 title: string
10 rawStatus: string
11 status: WfStatus
12 /** Open, unblocked and unclaimed: takeable now. */
13 isFrontier: boolean
14 type?: string
15 assignee?: string
16 blockedBy: number[]
17 path?: string
18 url?: string
19}
20
21export type WfMapStatus = 'open' | 'active' | 'done' | 'graduated'
22
23export type WfMap = {
24 id: string
25 /** `map` is a wayfinder map of decision tickets; `spec` a graduated spec of implementation tickets. */
26 kind: 'map' | 'spec'
27 source: 'md' | 'github'
28 title: string
29 /** How a prompt names it: a relative path or `#12`. */
30 ref: string
31 /** Folder (md) relative to the project root. */
32 dir?: string
33 path?: string
34 url?: string
35 status: WfMapStatus
36 tickets: WfTicket[]
37}
38
39export type WfData = {
40 root: string
41 maps: WfMap[]
42 loadedAt: number
43 errors: string[]
44}
45
46export type WfFilter = 'all' | 'unresolved' | 'frontier' | 'claimed' | 'waiting' | 'blocked' | 'done'
47
48export type WfLayout = 'steps' | 'tree'
49
50export type WfView = {
51 mapId: string | null
52 /** The ticket shown on a page of its own, or null for the map. */
53 ticketId: string | null
54 filter: WfFilter
55 layout: WfLayout
56}
57
58/** The command a Work button copied for a new chat, whether it reached the clipboard, and the paste keys. */
59export type WfCopied = { text: string; isCopied: boolean; keys: string } | null
60
61/** The open ticket body as the drawer shows it, and the file links in it. */
62export type WfDetail = { id: string; body: string; links: { href: string; path: string }[] } | null
63
64declare module 'claude-code' {
65 interface PluginState {
66 'wayfinder-maps': {
67 data: WfData | null
68 isLoading: boolean
69 isPaneOpen: boolean
70 view: WfView
71 detail: WfDetail
72 /** Tickets a Work button opened a new chat on, by id: when. */
73 started: Record<string, number>
74 copied: WfCopied
75 }
76 }
77}
78