SLOPSHOPPER

wayfinder-maps

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

newpanebandcommandtoastprocess
★ 1v0.5.0MITupdated 2026-10-06Yuvalz19500/claude-mods/plugins/wayfinder-maps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wayfinder-maps
│ ┃ Wayfinder ✕ › fix the failing auth test and add an audit log call │ ┃ ▍Wayfinder refreshing… r: Refresh │ ┃ ⏺ Read(src/auth.ts) │ ┃ Looking for maps… ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wayfinder-maps │ ⎿ wayfinder-maps: Wayfinder drawer opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Wayfinder
▍Wayfinder refreshing… r: Refresh Looking for maps…
README

claude-mods

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.

Mods

wayfinder-maps

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

  • Shows up when you need it. In a project with wayfinder maps, a slim band above the prompt sums up what is ready to take and what is waiting on you, with an Open map drawer button. It stays out of the way everywhere else.
  • Every map in the project. Each map shows its status (open, in progress, done, graduated to a spec), how many tickets are done, and how many are frontier, claimed, waiting on you or blocked.
  • Maps and specs. Wayfinder maps hold decision tickets. Graduated specs (a spec.md beside numbered implementation tickets) appear in their own section and are drawn the same way.
  • Tickets in steps. Click a map to see its tickets as cards, set out in steps from top to bottom: each ticket sits one step below the last ticket it waits on. A card's edge takes its status's color, and an 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.
  • Status chips. All, To do, ⚑ Frontier, ◐ Claimed, ◷ Waiting on you, ◌ Blocked, ✓ Done: each counts its tickets and, clicked, shows only them.
  • A page per ticket. Click a ticket to open it on its own page: its status, what it waits on and what it unblocks (each one click away), its question and answer (or the issue and its comments), Open file or Open issue, and Work this ticket. ← Back to map returns to the map at that ticket.
  • Follows your theme. Everything is drawn with the app's own elements, light or dark; the only color it adds is each status's.

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 →).

WhereButtonWhat it does
MapWork 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.
SpecImplement 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.
SpecImplement spec ↗A new chat on Matt Pocock's /implement-spec: wave after wave until the whole spec is done.
Ticket pageWork 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:

  1. Fixes the wave: the frontier tickets as they stand when it starts. Tickets that unblock during the run wait for the next wave.
  2. Claims them, opens one branch and one draft PR for the wave, and starts a subagent per ticket, each in its own worktree, doing what /implement does (TDD, typecheck, tests, code review, commit).
  3. Merges each finished ticket into the wave branch, marks it done, and deletes that subagent's worktree and branch (local and remote) right away.
  4. Runs /code-review on the wave branch and fixes what it raises.
  5. Marks the PR ready, confirms no subagent worktree or branch is left (the wave branch is the only one it leaves), and reports what it built, what the wave unblocked, and what waits on you. Then it stops.

Where it looks

TrackerWhat it reads
Local markdownAny 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).
GitHubIssues 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):

OptionDefault
workInnew-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.

Developing a mod

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.

Source 10 files
hooks/register.tsx 904 lines
1import { 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}
904
hooks/card.tsx 68 lines
1import 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
68
hooks/clicks.ts 44 lines
1/**
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}
44
hooks/github.ts 126 lines
1
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}
126
hooks/graph.ts 87 lines
1import 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}
87
hooks/launch.ts 65 lines
1/**
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}
65
hooks/markdown.ts 103 lines
1import 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 }
103
hooks/parse.ts 147 lines
1import 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}
147
hooks/text.ts 94 lines
1/** 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}
94
types/index.d.ts 78 lines
1/** 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