pm plan steps above the prompt; steps, backlog and dependency graph in a pane

Personal configuration files managed via ~/.config/ and synced with git.
| Directory | Tool | Key files |
|---|---|---|
ai/ | Shared (Claude + Codex) | PERSONAL.md, guardrails.md, rules/, memory/, skills/, AGENTS.manifest |
claude/ | Claude Code | CLAUDE.md, DEVGUARD.md, settings.json, hooks/, agents/, commands/, skills/ (Claude-only) |
codex/ | Codex CLI | config.toml.template, skills/ (Codex-only), scripts/ |
notifier/ | Shared AI notifier | macOS Swift app, Linux Go daemon, Go sender, shared icon |
ghostty/ | Ghostty | config, shaders/ (cursor animation) |
aerospace/ | AeroSpace (macOS) | aerospace.toml (shipped default trimmed to digit workspaces so Option stays free for terminal Meta keys; float rules, Omarchy gaps) |
brave/ | Brave Origin | vimium-options.json (Vimium export; restore by hand from its Options page when Brave Sync has not carried it over) |
nvim/ | Neovim | init.lua, lua/plugins/ |
tmux/ | tmux | tmux.conf, scripts/, status/ |
zsh/ | Zsh | .zshrc, custom/plugins/ |
homebrew/ | Homebrew Bundle | Brewfile, bootstrap.sh (macOS package set + shell env) |
arch/ | Arch Linux / Omarchy | packages.sh (pacman + AUR, the Brewfile mapping), bootstrap.sh (packages + mise + shell env) |
lib/ | Shared bootstrap | shell-env.sh (~/.zshenv ZDOTDIR + oh-my-zsh, used by both Unix package layers) |
windows/ | Native Windows | bootstrap.ps1, packages.ps1 (winget), profile.ps1, starship.toml, terminal/, notifier/, Cowork skill packaging |
.ideavimrc | IdeaVim (JetBrains) | Standalone file |
ai/ is the single source of truth for tool-agnostic content (rules, memory, most skills). claude/ and codex/ hold per-tool deploy logic and tool-only files. notifier/ is shared runtime infrastructure for Claude/Codex desktop notifications. The orchestrator at ai/scripts/bootstrap.sh calls each tool's bootstrap. See ai/README.md, claude/README.md, codex/README.md.
Everything else under
~/.config/is gitignored. See.gitignorefor the allowlist.
Anything true of one machine and not another stays out of the tracked files. Each config loads an optional private file last, so it overrides without being edited into the shared one:
| Config | Hook in the tracked file | Machine-local file |
|---|---|---|
| zsh | source $ZDOTDIR/private.sh | zsh/private.sh |
| ghostty | config-file = ?"…/private.conf" | ghostty/private.conf |
| tmux | if-shell '[ -r … ]' 'source-file …' | tmux/private.conf |
| nvim | plugins/99_private.lua → require("private.plugins") | nvim/lua/private/plugins.lua |
All four are absent by default and silent when missing, so a fresh clone works with none of them. On Omarchy they are where the theme wiring lives, which is why macOS keeps Catppuccin while that machine follows the system theme.
One case the seam cannot cover: Omarchy's display text size slider rewrites font-size in ghostty/config at a path it hardcodes, so moving the line out would make the slider silently do nothing. That one line is normalized on its way into the index by the clean filter in lib/git-filter-machine-local.sh (wired in .gitattributes, registered by ai/scripts/bootstrap.sh) — the working tree keeps this monitor's size and git never sees it. The same filter drops a # >>> machine-local … # <<< machine-local block whole; aerospace/aerospace.toml keeps its work-machine float rules there, since AeroSpace has no include to put them in a private file.
| Path | Repo |
|---|---|
zsh/custom/plugins/zsh-autosuggestions | zsh-users/zsh-autosuggestions |
zsh/custom/plugins/zsh-history-substring-search | zsh-users/zsh-history-substring-search |
zsh/custom/plugins/zsh-syntax-highlighting | zsh-users/zsh-syntax-highlighting |
zsh/custom/plugins/fzf-tab | aloxaf/fzf-tab |
ghostty/shaders | sahaj-b/ghostty-cursor-shaders |
git clone --recurse-submodules <repo> ~/.config
~/.config/bootstrap.sh # packages + shell env, then deploys Claude + Codex config
bootstrap.sh picks the package layer from the machine, then hands off to the same ai/scripts/bootstrap.sh in every case:
| Machine | Package layer | Source of claude / codex |
|---|---|---|
| macOS | homebrew/bootstrap.sh → brew bundle | Homebrew casks |
| Arch / Omarchy | arch/bootstrap.sh → pacman + AUR | mise (~/.config/mise/config.toml, machine-local) |
| Linux with Homebrew, no pacman | homebrew/bootstrap.sh | Homebrew |
On Omarchy the package step uses omarchy-pkg-add / omarchy-pkg-aur-add when they exist, so packages installed here look the same as ones added through the Omarchy menu. arch/packages.sh --dry-run prints the plan without installing; --skip-optional leaves out desktop apps and AUR.
~/.configis usually not empty on a fresh Omarchy install —ghostty/,nvim/andtmux/already exist. The tracked versions here replace them; move the originals aside first if you want them back.
winget install --id Git.Git --exact
git clone --recurse-submodules <repo> $HOME\.config
pwsh -File $HOME\.config\windows\bootstrap.ps1 # winget packages + shell/terminal, then the SAME ai/scripts/bootstrap.sh under Git Bash
Requires Developer Mode (Settings > System > For developers) so the deploy can create symlinks unelevated. windows/bootstrap.ps1 replaces bootstrap.sh only at the package layer — the Claude/Codex deploy is delegated to ai/scripts/bootstrap.sh verbatim, so there is one implementation of the 3-tier merge, not two. See windows/README.md.
hooks/register.tsx 461 lines1// pm-band: the session's pm plan as one line above the prompt, and a /pm pane with
2// its steps, the backlog and the backlog's dependency graph. Read-only: every
3// fact comes from the pm CLIs; nothing here writes a plan, a task or a pointer.
4//
5// I/O happens in events and timers only; ui.render reads $.state and nothing else.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface as Engine, On, RenderElement, RenderInput } from 'claude-code'
9
10import type { BacklogState, BandState, PlanView, Tab } from '../types'
11import type { GraphProps } from './graph'
12import {
13 currentOf,
14 graphOf,
15 groupsOf,
16 listingOf,
17 planFile,
18 resolvedOf,
19 stepsOf,
20} from './plan'
21import type { Listing, Run } from './plan'
22
23export const PANE = 'pm'
24const REFRESH_DEBOUNCE_MS = 300
25const DOTS_MAX = 20
26// A row is cut at the pane edge anyway; the cap keeps a big backlog inside the
27// engine's 100,000 characters of text per drawing.
28const TITLE_CHARS = 200
29
30const band = atom({ plugin: 'pm-band', key: 'band' } as const, {
31 sessionId: '',
32 view: null,
33 error: null,
34})
35const backlog = atom({ plugin: 'pm-band', key: 'backlog' } as const, {
36 sessionId: '',
37 listing: null,
38 error: null,
39})
40const tab = atom({ plugin: 'pm-band', key: 'tab' } as const, 'steps')
41const selected = atom({ plugin: 'pm-band', key: 'selected' } as const, null)
42
43type Paths = { resolver: string; tsx: string; pm: string }
44
45async function pathsOf($: Engine): Promise<Paths> {
46 // $.process.run takes argv, no shell: `~` would reach the child unexpanded.
47 const home = (await $.env.get('HOME')) ?? ''
48 const pm = `${home}/.config/ai/skills/pm-roadmap`
49 return {
50 resolver: `${home}/.config/ai/lib/worktree.mjs`,
51 tsx: `${pm}/node_modules/.bin/tsx`,
52 pm: `${pm}/pm-roadmap.ts`,
53 }
54}
55
56async function run(
57 $: Engine,
58 argv: string[],
59 init: { cwd?: string; env?: Record<string, string>; timeoutMs: number },
60): Promise<Run> {
61 try {
62 return await $.process.run(argv, init)
63 } catch (error) {
64 return { exitCode: -1, stdout: '', stderr: error instanceof Error ? error.message : String(error) }
65 }
66}
67
68// Module state: a reload starts it over, as it does $.clock timers.
69// Bumped whenever the session id changes under the process (/clear, /resume,
70// /branch): a refresh that started under an older generation is dropped.
71let generation = 0
72let isRefreshing = false
73let isDirty = false
74let pending: { cancel: () => void } | null = null
75
76async function refreshOnce($: Engine): Promise<void> {
77 const started = generation
78 const sessionId = await $.session.id()
79 const cwd = await $.session.cwd()
80 const paths = await pathsOf($)
81 const resolved = resolvedOf(
82 await run($, ['node', paths.resolver, 'resolve-session', '--root', cwd, '--tool', 'claude'], {
83 env: { PM_SESSION_TOOL: 'claude', PM_SESSION_ID: sessionId },
84 timeoutMs: 10_000,
85 }),
86 )
87 let view: PlanView | null = null
88 let error: string | null = null
89 let mainRoot = ''
90 if (resolved.kind === 'error') error = resolved.reason
91 if (resolved.kind === 'none') {
92 view = { kind: 'none' }
93 mainRoot = resolved.mainRoot
94 }
95 if (resolved.kind === 'plan') {
96 mainRoot = resolved.mainRoot
97 try {
98 const content = await $.fs.read(planFile(resolved.mainRoot, resolved.plan))
99 view = {
100 kind: 'plan',
101 status: resolved.status,
102 id: resolved.id,
103 title: resolved.title,
104 plan: resolved.plan,
105 steps: stepsOf(content),
106 }
107 } catch (failure) {
108 error = `plan unreadable: ${failure instanceof Error ? failure.message : String(failure)}`
109 }
110 }
111 const isPaneOpen = (await $.ui.panes()).some(one => one.id === PANE)
112 let listing: Listing | { error: string } | null = null
113 if (isPaneOpen && mainRoot) {
114 listing = listingOf(
115 await run($, [paths.tsx, paths.pm, 'list', '--json', '--all'], {
116 cwd: mainRoot,
117 env: { PM_ROOT: mainRoot },
118 timeoutMs: 15_000,
119 }),
120 )
121 }
122 if (started !== generation || sessionId !== (await $.session.id())) return
123 await update($, band, (prev): BandState =>
124 error === null
125 ? { sessionId, view, error: null }
126 : { sessionId, view: prev.sessionId === sessionId ? prev.view : null, error },
127 )
128 if (listing !== null) {
129 const next = listing
130 await update($, backlog, (prev): BacklogState =>
131 'error' in next
132 ? { sessionId, listing: prev.sessionId === sessionId ? prev.listing : null, error: next.error }
133 : { sessionId, listing: next, error: null },
134 )
135 }
136}
137
138async function refresh($: Engine): Promise<void> {
139 if (isRefreshing) {
140 isDirty = true
141 return
142 }
143 isRefreshing = true
144 try {
145 do {
146 isDirty = false
147 await refreshOnce($)
148 } while (isDirty)
149 } finally {
150 isRefreshing = false
151 }
152}
153
154function soon($: Engine): void {
155 pending?.cancel()
156 pending = $.clock.after(REFRESH_DEBOUNCE_MS, () => {
157 pending = null
158 void refresh($)
159 })
160}
161
162/** /pm and the band's controls: open the pane (on `shown`, when given) and read the backlog. */
163async function openPane($: Engine, shown?: Tab): Promise<void> {
164 if (shown !== undefined) await update($, tab, () => shown)
165 await $.ui.open({ id: PANE, title: 'pm', focus: true })
166 soon($)
167}
168
169async function switched($: Engine): Promise<void> {
170 generation += 1
171 await update($, band, () => ({ sessionId: '', view: null, error: null }))
172 await update($, backlog, () => ({ sessionId: '', listing: null, error: null }))
173 await update($, selected, () => null)
174}
175
176export function register(on: On): void {
177 on('session.start', async ($, e, next) => {
178 await $.command.register({
179 name: PANE,
180 description: 'Show the plan steps, the backlog and its dependency graph',
181 immediate: true,
182 })
183 const result = await next(e)
184 soon($)
185 return result
186 })
187
188 // The hooks below only observe; if one fails, .catch hands back what next settled
189 // to (or runs it once when it had not run), so a failure never blocks or repeats work.
190 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
191 const result = await next(e)
192 await switched($)
193 soon($)
194 return result
195 }).catch(($, e, next) => next(e))
196
197 on('classic.SessionEnd', async ($, e, next) => {
198 generation += 1
199 return next(e)
200 }).catch(($, e, next) => next(e))
201
202 on('tool.call', async ($, e, next) => {
203 const result = await next(e)
204 soon($)
205 return result
206 }).catch(($, e, next) => next(e))
207
208 on('turn.complete', async ($, e, next) => {
209 const result = await next(e)
210 soon($)
211 return result
212 })
213
214 on('command.run', { command: PANE }, async $ => {
215 await openPane($)
216 return {}
217 }).catch(($, e, next) => next(e))
218
219 on('ui.message', { requestId: PANE }, async ($, e) => {
220 const data = e.data as { selected?: unknown } | null
221 const id = typeof data?.selected === 'string' ? data.selected : null
222 await update($, selected, () => id)
223 return {}
224 })
225
226 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
227 if (e.props.hasSurvey) return next(e)
228 const state = await read($, band)
229 const below = await next(e)
230 const line = bandLine($, e, state)
231 if (line === null) return below
232 const { Box } = $.ui.resolve(e)
233 return (
234 <Box flexDirection="column">
235 {line}
236 {below}
237 </Box>
238 )
239 })
240
241 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
242 const state = await read($, band)
243 const list = await read($, backlog)
244 const shown = await read($, tab)
245 const pick = await read($, selected)
246 return paneOf($, e, { state, list, shown, pick })
247 })
248}
249
250function bandLine($: Engine, e: RenderInput<'AbovePrompt'>, state: BandState): RenderElement | null {
251 const { Text } = $.ui.resolve(e)
252 const view = state.view
253 if (state.error !== null && view === null) {
254 return (
255 <Text dimColor wrap="truncate-end">
256 ⚠ plan: {state.error}
257 </Text>
258 )
259 }
260 if (view === null) return null
261 if (view.kind === 'none') {
262 return (
263 <Text dimColor wrap="truncate-end">
264 ○ no plan
265 </Text>
266 )
267 }
268 const done = view.steps.filter(step => step.isDone).length
269 const current = currentOf(view.steps)
270 const dots =
271 view.steps.length > 0 && view.steps.length <= DOTS_MAX
272 ? `${'●'.repeat(done)}${'○'.repeat(view.steps.length - done)} `
273 : ''
274 const { Box, Button } = $.ui.resolve(e)
275 return (
276 <Box flexDirection="row">
277 <Box flexShrink={1}>
278 <Text wrap="truncate-end">
279 <Text color={view.status === 'active' ? 'warning' : 'inactive'}>
280 {view.status === 'active' ? '▶' : '⚙'} {view.id}
281 </Text>
282 {' '}
283 <Text color="success">{dots}</Text>
284 <Text dimColor>
285 {done}/{view.steps.length}
286 </Text>
287 {current ? ` ${current.text}` : ''}
288 {state.error !== null ? <Text dimColor> ⚠</Text> : ''}
289 </Text>
290 </Box>
291 {/* The way into /pm from the band: each opens the pane on its tab. */}
292 <Box flexDirection="row" flexShrink={0} columnGap={2} marginLeft={2}>
293 <Button key="band-steps" label="steps" plain dimColor onPress={() => openPane($, 'steps')} />
294 <Button key="band-graph" label="graph" plain dimColor onPress={() => openPane($, 'graph')} />
295 </Box>
296 </Box>
297 )
298}
299
300const TABS: { tab: Tab; label: string; hotkey: string }[] = [
301 { tab: 'steps', label: 'steps', hotkey: '1' },
302 { tab: 'backlog', label: 'backlog', hotkey: '2' },
303 { tab: 'graph', label: 'graph', hotkey: '3' },
304]
305
306function paneOf(
307 $: Engine,
308 e: RenderInput<'Pane'>,
309 { state, list, shown, pick }: { state: BandState; list: BacklogState; shown: Tab; pick: string | null },
310): RenderElement {
311 const { Box, Text, Button } = $.ui.resolve(e)
312 const tabs = (
313 <Box flexDirection="row" columnGap={3}>
314 {TABS.map(one => (
315 <Button
316 key={`tab-${one.tab}`}
317 label={one.label}
318 hotkey={one.hotkey}
319 plain
320 dimColor={one.tab !== shown}
321 onPress={() => update($, tab, () => one.tab)}
322 />
323 ))}
324 </Box>
325 )
326 const body =
327 shown === 'steps'
328 ? stepsBody($, e, state)
329 : shown === 'backlog' || !hasClient(e)
330 ? backlogBody($, e, list, state)
331 : graphBody($, e, list, state, pick)
332 return (
333 <Box flexDirection="column">
334 {tabs}
335 {body}
336 </Box>
337 )
338}
339
340/** The graph is a Client; VS Code and mobile have none, so they get the list. */
341function hasClient(e: RenderInput<'Pane'>): boolean {
342 return e.surface === 'terminal' || e.surface === 'desktop'
343}
344
345function stepsBody($: Engine, e: RenderInput<'Pane'>, state: BandState): RenderElement {
346 const { Box, Text } = $.ui.resolve(e)
347 const view = state.view
348 if (view === null || view.kind === 'none') {
349 return <Text dimColor>{state.error !== null ? `⚠ plan: ${state.error}` : 'No plan is bound to this session.'}</Text>
350 }
351 const done = view.steps.filter(step => step.isDone).length
352 const current = currentOf(view.steps)
353 return (
354 <Box flexDirection="column">
355 <Text wrap="truncate-end">
356 <Text bold color="claude">
357 {view.id}
358 </Text>
359 <Text dimColor>
360 {' · '}
361 {view.status} · {done}/{view.steps.length}
362 </Text>
363 </Text>
364 <Text dimColor wrap="truncate-end">
365 {view.title}
366 </Text>
367 <Text> </Text>
368 {view.steps.map(step => (
369 <Text
370 wrap="truncate-end"
371 dimColor={step.isDone}
372 bold={step === current}
373 color={step === current ? 'warning' : undefined}
374 >
375 {step.isDone ? '✓' : step === current ? '▶' : '○'} {step.n}. {step.text}
376 </Text>
377 ))}
378 </Box>
379 )
380}
381
382function currentPlanOf(state: BandState): string | undefined {
383 return state.view?.kind === 'plan' ? state.view.plan : undefined
384}
385
386function backlogBody($: Engine, e: RenderInput<'Pane'>, list: BacklogState, state: BandState): RenderElement {
387 const { Box, Text } = $.ui.resolve(e)
388 if (list.listing === null) {
389 return <Text dimColor>{list.error !== null ? `⚠ backlog: ${list.error}` : 'loading…'}</Text>
390 }
391 const groups = groupsOf(list.listing, currentPlanOf(state))
392 if (groups.length === 0) return <Text dimColor>The backlog is empty.</Text>
393 return (
394 <Box flexDirection="column">
395 {groups.map(group => (
396 <Box flexDirection="column">
397 <Text>
398 <Text bold color="claude">
399 {group.key}
400 </Text>
401 <Text dimColor> · {group.rows.length}</Text>
402 </Text>
403 {group.rows.map(row => (
404 <Text wrap="truncate-end" bold={row.isCurrent} dimColor={row.isBlocked && !row.isCurrent}>
405 {' '}
406 {row.isCurrent ? '▶' : row.isBlocked ? '◌' : '·'} [{row.priority}] {row.id}
407 <Text dimColor> — {row.title.length > TITLE_CHARS ? `${row.title.slice(0, TITLE_CHARS - 1)}…` : row.title}</Text>
408 {row.blockedBy !== undefined ? (
409 <Text color="warning">
410 {row.blockedByReason === 'order' ? ` ⤷ after ${row.blockedBy}` : ` ⤷ needs ${row.blockedBy}`}
411 </Text>
412 ) : (
413 ''
414 )}
415 </Text>
416 ))}
417 </Box>
418 ))}
419 {list.listing.inbox > 0 ? <Text dimColor>inbox: {list.listing.inbox} awaiting triage</Text> : ''}
420 {list.error !== null ? <Text dimColor>⚠ {list.error}</Text> : ''}
421 </Box>
422 )
423}
424
425/** The picked node's detail line: the item's task, priority, title and needs, or the task. */
426function detailOf(list: BacklogState, pick: string | null): string | null {
427 if (pick === null || list.listing === null) return null
428 const chosen = [...list.listing.eligible, ...list.listing.blocked].find(item => item.id === pick)
429 if (!chosen) return `task ${pick.replace(/^#/, '')}`
430 const needs = chosen.dependsOn.length > 0 ? ` · needs ${chosen.dependsOn.join(', ')}` : ''
431 return `${chosen.key}/${chosen.id} · [${chosen.priority}] ${chosen.title}${needs}`
432}
433
434function graphBody(
435 $: Engine,
436 e: RenderInput<'Pane'>,
437 list: BacklogState,
438 state: BandState,
439 pick: string | null,
440): RenderElement {
441 const { Text } = $.ui.resolve(e)
442 if (list.listing === null) {
443 return <Text dimColor>{list.error !== null ? `⚠ backlog: ${list.error}` : 'loading…'}</Text>
444 }
445 const graph: GraphProps = graphOf(list.listing, currentPlanOf(state))
446 const detail = detailOf(list, pick)
447 const { Box, Client } = $.ui.resolve(e as RenderInput<'Pane'> & { surface: 'terminal' })
448 const height = Math.max(5, e.props.scroll.bodyRows - 3)
449 // The desktop sets text in a proportional face: the Client places each run at
450 // its cell instead of drawing rows of spaced text (see graph.tsx).
451 const props: GraphProps = e.surface === 'terminal' ? graph : { ...graph, isPlaced: true, rows: height }
452 return (
453 <Box flexDirection="column">
454 <Client key="graph" module="./graph.tsx" props={props} width="100%" height={height} />
455 <Text dimColor wrap="truncate-end">
456 {detail ?? 'click a node · drag it to move · drag empty space or shift+arrows to pan · + − zoom · 0 fit'}
457 </Text>
458 </Box>
459 )
460}
461hooks/graph.tsx 411 lines1// The /pm graph tab's surface module: lays the backlog graph out on the drawing
2// thread, frame by frame, and takes the pointer and keys. It has no `$`; a pick
3// reaches the hooks module as `ui.message` data ({ selected }).
4//
5// The layout is unbounded: nodes take the room their forces give them, and the
6// region is a camera over it (`camera` is the world point at its top-left,
7// `zoom` the spacing scale). `world` only sets the spacing and the centre.
8// Until the person moves the camera it fits the settled graph (zoom at most 1).
9// Drag a node to move it, drag empty space to pan, shift+arrows pan, + and -
10// zoom, 0 fits everything. A drag moves the node's neighbours only, so the rest
11// of the graph holds still.
12//
13// Two ways to put the cells on screen. The terminal's text is a cell grid, so
14// each row is one Text. Elsewhere (the desktop) text is set in a proportional
15// face and a row of spaces does not line up; there `isPlaced` puts every run of
16// glyphs in its own Box at its cell (`position: absolute`), so positions hold
17// whatever the face.
18
19import type { ClientModule, ClientSurface, Color } from 'claude-code'
20
21import { isSettled, placed, startTemperature, tick } from './layout'
22import type { Layout, Point, Positions, Size } from './layout'
23import type { Graph, GraphNode } from './plan'
24import { nodeAt, rasterOf } from './raster'
25import type { Tone } from './raster'
26
27/**
28 * `rows`, with `isPlaced`: the rows to lay out in. The desktop sizes a Client's
29 * region to what it draws rather than to its `height`, so the region it reports
30 * starts at the one row of `loading…` and stays there; the hooks module names
31 * the rows instead.
32 */
33export type GraphProps = Graph & { isPlaced?: boolean; rows?: number }
34
35type GraphState = {
36 /** The graph as last handed in, and a key of its shape to spot a new one. */
37 graph: GraphProps
38 shape: string
39 /** The region on screen; 0 by 0 until the first layout. */
40 size: Size
41 /** The space the layout runs in: the region, or more for a big graph. */
42 world: Size
43 layout: Layout
44 camera: Point
45 zoom: number
46 isRunning: boolean
47 /** The nodes the running layout may move; null: all of them. */
48 active: string[] | null
49 /** Whether the person has moved the camera; until then it follows the graph. */
50 isCameraSet: boolean
51 pinned: string[]
52 drag: string | null
53 /** Where a pan began: the pointer's cell and the camera then. */
54 pan: { from: Point; camera: Point } | null
55 selected: string | null
56 /** The node under a resting pointer, drawn underlined. */
57 hovered: string | null
58 /** The surface the last call was handed, for the frame timer to read. */
59 box: { surface: ClientSurface<GraphState> }
60}
61
62const FRAME_MS = 33
63const ZOOMS = [0.25, 0.5, 0.75, 1, 1.5, 2] as const
64/** Cells of world each node asks for, across and down, before the world outgrows the region. */
65const ROOM_ACROSS = 24
66const ROOM_DOWN = 6
67const PAN_STEP = { x: 8, y: 3 }
68
69const TONE: Record<Tone, { color?: Color; dimColor?: boolean; bold?: boolean; inverse?: boolean; underline?: boolean }> = {
70 blank: {},
71 'task-edge': { color: 'subtle', dimColor: true },
72 'dependency-edge': { color: 'warning' },
73 'order-edge': { color: 'inactive' },
74 task: { color: 'claude', bold: true },
75 eligible: { color: 'success' },
76 blocked: { color: 'warning' },
77 current: { color: 'suggestion', bold: true },
78 hovered: { bold: true, underline: true },
79 selected: { inverse: true, bold: true },
80}
81
82const shapeOf = (graph: Graph) =>
83 `${graph.nodes.map(node => `${node.id}:${node.state}`).join(',')}|${graph.edges
84 .map(edge => `${edge.from}>${edge.to}:${edge.kind}`)
85 .join(',')}`
86
87// Named rows apply once the region has columns: an unmeasured (0 by 0) mount
88// still waits on `loading…` rather than laying out in no width.
89const sizeOf = (surface: ClientSurface<GraphState>, graph: GraphProps): Size => ({
90 columns: surface.columns,
91 rows: Math.max(
92 0,
93 (graph.isPlaced && graph.rows !== undefined && surface.columns > 0 ? graph.rows : surface.rows) -
94 (graph.more > 0 ? 1 : 0),
95 ),
96})
97
98const isEmpty = (size: Size) => size.columns <= 0 || size.rows <= 0
99
100/** The world for `graph` seen through `region`: never smaller, grown with the node count. */
101function worldOf(region: Size, graph: Graph): Size {
102 if (isEmpty(region)) return { columns: 0, rows: 0 }
103 const side = Math.sqrt(graph.nodes.length)
104 return {
105 columns: Math.max(region.columns, Math.ceil(side * ROOM_ACROSS)),
106 rows: Math.max(region.rows, Math.ceil(side * ROOM_DOWN)),
107 }
108}
109
110/** The camera that shows the middle of `world` in `region` at `zoom`. */
111function centred(world: Size, region: Size, zoom: number): Point {
112 return {
113 x: (world.columns - region.columns / zoom) / 2,
114 y: (world.rows - region.rows / zoom) / 2,
115 }
116}
117
118/** World positions as the region shows them. */
119function viewed(pos: Positions, camera: Point, zoom: number): Positions {
120 return Object.fromEntries(
121 Object.entries(pos).map(([id, p]) => [id, { x: (p.x - camera.x) * zoom, y: (p.y - camera.y) * zoom }]),
122 )
123}
124
125const toWorld = (state: GraphState, x: number, y: number): Point => ({
126 x: x / state.zoom + state.camera.x,
127 y: y / state.zoom + state.camera.y,
128})
129
130/** `state` with `id` at world point `at`, its neighbours free to follow. */
131function draggedTo(state: GraphState, id: string, at: Point): GraphState {
132 return {
133 ...state,
134 // A node the person moved is theirs: the camera stops following the graph.
135 isCameraSet: true,
136 layout: {
137 pos: { ...state.layout.pos, [id]: at },
138 temperature: Math.max(state.layout.temperature, startTemperature(state.world) / 8),
139 },
140 isRunning: true,
141 }
142}
143
144/** The nodes linked to `id`: what a drag of it lets move. */
145function neighboursOf(graph: Graph, id: string): string[] {
146 const linked = graph.edges.flatMap(edge => (edge.from === id ? [edge.to] : edge.to === id ? [edge.from] : []))
147 return [...new Set(linked)]
148}
149
150/** A fresh layout for `graph` in `world`. */
151function fitted(graph: Graph, world: Size): Layout {
152 return { pos: placed(graph.nodes, graph.edges, world, {}, false), temperature: startTemperature(world) }
153}
154
155/** Zoom by `by` levels (0 keeps it), keeping the region's middle where it is. */
156function zoomed(state: GraphState, by: number): GraphState {
157 const at = ZOOMS.findIndex(level => level >= state.zoom)
158 const zoom = ZOOMS[Math.min(ZOOMS.length - 1, Math.max(0, (at < 0 ? ZOOMS.indexOf(1) : at) + by))]!
159 const middle = {
160 x: state.camera.x + state.size.columns / state.zoom / 2,
161 y: state.camera.y + state.size.rows / state.zoom / 2,
162 }
163 return {
164 ...state,
165 zoom,
166 camera: { x: middle.x - state.size.columns / zoom / 2, y: middle.y - state.size.rows / zoom / 2 },
167 }
168}
169
170/** The largest zoom level (at most `cap`) at which every node fits the region, centred on them. */
171function fit(state: GraphState, cap = 2): GraphState {
172 const points = Object.values(state.layout.pos)
173 if (points.length === 0) return state
174 const left = Math.min(...points.map(p => p.x))
175 const right = Math.max(...points.map(p => p.x)) + 12
176 const top = Math.min(...points.map(p => p.y))
177 const bottom = Math.max(...points.map(p => p.y)) + 1
178 const room = Math.min(state.size.columns / Math.max(1, right - left), state.size.rows / Math.max(1, bottom - top))
179 const zoom = [...ZOOMS].reverse().find(level => level <= Math.min(room, cap)) ?? ZOOMS[0]
180 return {
181 ...state,
182 zoom,
183 camera: {
184 x: (left + right) / 2 - state.size.columns / zoom / 2,
185 y: (top + bottom) / 2 - state.size.rows / zoom / 2,
186 },
187 }
188}
189
190function frame(box: GraphState['box']): void {
191 const surface = box.surface
192 const state = surface.state
193 if (!state) return
194 const size = sizeOf(surface, state.graph)
195 const isResized = size.columns !== state.size.columns || size.rows !== state.size.rows
196 if (isResized) {
197 if (isEmpty(size)) return
198 // A resize moves the camera's view, never the nodes.
199 if (!isEmpty(state.size)) {
200 surface.setState(state.isCameraSet ? { ...state, size } : fit({ ...state, size }, 1))
201 return
202 }
203 const world = worldOf(size, state.graph)
204 surface.setState({
205 ...state,
206 size,
207 world,
208 layout: fitted(state.graph, world),
209 camera: centred(world, size, state.zoom),
210 active: null,
211 isRunning: true,
212 })
213 return
214 }
215 if (!state.isRunning || isEmpty(size)) return
216 const result = tick(state.graph.nodes, state.graph.edges, state.layout, state.world, new Set(state.pinned), {
217 active: state.active === null ? undefined : new Set(state.active),
218 isBounded: false,
219 })
220 const isDone = state.drag === null && isSettled(result.layout, result.energy, state.graph.nodes.length)
221 const next = { ...state, layout: result.layout, isRunning: !isDone, active: isDone ? null : state.active }
222 surface.setState(isDone && !state.isCameraSet ? fit(next, 1) : next)
223}
224
225function pick(surface: ClientSurface<GraphState>, state: GraphState, id: string | null): GraphState {
226 if (id !== state.selected) surface.post({ selected: id })
227 return { ...state, selected: id }
228}
229
230function step(nodes: readonly GraphNode[], current: string | null, by: number): string | null {
231 if (nodes.length === 0) return null
232 const at = current === null ? -1 : nodes.findIndex(node => node.id === current)
233 const next = at < 0 ? (by > 0 ? 0 : nodes.length - 1) : (at + by + nodes.length) % nodes.length
234 return nodes[next]!.id
235}
236
237/** The node under a region cell, through the camera. */
238const hitAt = (state: GraphState, x: number, y: number) =>
239 nodeAt(state.graph.nodes, viewed(state.layout.pos, state.camera, state.zoom), state.size, x, y) ?? null
240
241function started(graph: GraphProps, surface: ClientSurface<GraphState>): GraphState {
242 const box = { surface }
243 const size = sizeOf(surface, graph)
244 const world = worldOf(size, graph)
245 const state: GraphState = {
246 graph,
247 shape: shapeOf(graph),
248 size: isEmpty(size) ? { columns: 0, rows: 0 } : size,
249 world,
250 layout: isEmpty(size) ? { pos: {}, temperature: 0 } : fitted(graph, world),
251 camera: isEmpty(size) ? { x: 0, y: 0 } : centred(world, size, 1),
252 zoom: 1,
253 isRunning: !isEmpty(size),
254 active: null,
255 isCameraSet: false,
256 pinned: [],
257 drag: null,
258 pan: null,
259 selected: null,
260 hovered: null,
261 box,
262 }
263 surface.every(FRAME_MS, () => frame(box))
264 surface.onPointer(event => {
265 const now = box.surface.state
266 if (!now) return
267 if (event.type === 'down' && event.button === 'left') {
268 const id = hitAt(now, event.x, event.y)
269 const picked = pick(box.surface, now, id)
270 box.surface.setState(
271 id === null
272 ? { ...picked, pan: { from: { x: event.x, y: event.y }, camera: now.camera } }
273 : {
274 ...picked,
275 drag: id,
276 pinned: [...new Set([...now.pinned, id])],
277 active: neighboursOf(now.graph, id).filter(one => !now.pinned.includes(one)),
278 isRunning: true,
279 },
280 )
281 return
282 }
283 if (event.type === 'move' && now.drag !== null) {
284 box.surface.setState(draggedTo(now, now.drag, toWorld(now, event.x, event.y)))
285 return
286 }
287 if (event.type === 'move' && now.pan !== null) {
288 box.surface.setState({
289 ...now,
290 isCameraSet: true,
291 camera: {
292 x: now.pan.camera.x - (event.x - now.pan.from.x) / now.zoom,
293 y: now.pan.camera.y - (event.y - now.pan.from.y) / now.zoom,
294 },
295 })
296 return
297 }
298 if (event.type === 'up' && (now.drag !== null || now.pan !== null)) {
299 box.surface.setState({ ...now, drag: null, pan: null })
300 return
301 }
302 const hovered =
303 event.type === 'leave' ? null : event.type === 'move' && event.button === undefined ? hitAt(now, event.x, event.y) : now.hovered
304 if (hovered !== now.hovered) box.surface.setState({ ...now, hovered })
305 })
306 surface.onKey(event => {
307 const now = box.surface.state
308 if (!now) return
309 const dx = event.key === 'right' ? 1 : event.key === 'left' ? -1 : 0
310 const dy = event.key === 'down' ? 1 : event.key === 'up' ? -1 : 0
311 if (event.shift && (dx !== 0 || dy !== 0)) {
312 box.surface.setState({
313 ...now,
314 isCameraSet: true,
315 camera: {
316 x: now.camera.x + (dx * PAN_STEP.x) / now.zoom,
317 y: now.camera.y + (dy * PAN_STEP.y) / now.zoom,
318 },
319 })
320 return
321 }
322 if (event.key === '+' || event.key === '=') return box.surface.setState({ ...zoomed(now, 1), isCameraSet: true })
323 if (event.key === '-' || event.key === '_') return box.surface.setState({ ...zoomed(now, -1), isCameraSet: true })
324 if (event.key === '0') return box.surface.setState({ ...fit(now), isCameraSet: true })
325 const by = dx + dy
326 if (by !== 0) box.surface.setState(pick(box.surface, now, step(now.graph.nodes, now.selected, by)))
327 })
328 return state
329}
330
331const GraphView: ClientModule<GraphProps, GraphState> = (graph, surface) => {
332 const { Box, Text } = surface.elements
333 let state = surface.state
334 if (!state) {
335 state = started(graph, surface)
336 surface.setState(state)
337 } else {
338 state.box.surface = surface
339 const shape = shapeOf(graph)
340 if (shape !== state.shape) {
341 const world = isEmpty(state.size) ? state.world : worldOf(state.size, graph)
342 const next: GraphState = {
343 ...state,
344 graph,
345 shape,
346 world,
347 layout: isEmpty(state.size)
348 ? state.layout
349 : {
350 pos: placed(graph.nodes, graph.edges, world, state.layout.pos, false),
351 temperature: startTemperature(world),
352 },
353 active: null,
354 isRunning: !isEmpty(state.size),
355 pinned: state.pinned.filter(id => graph.nodes.some(node => node.id === id)),
356 selected: graph.nodes.some(node => node.id === state!.selected) ? state.selected : null,
357 hovered: null,
358 }
359 surface.setState(next)
360 state = next
361 }
362 }
363 if (isEmpty(state.size)) return <Text dimColor>loading…</Text>
364 const rows = rasterOf(
365 state.graph.nodes,
366 state.graph.edges,
367 viewed(state.layout.pos, state.camera, state.zoom),
368 state.size,
369 state.selected,
370 state.hovered,
371 )
372 if (graph.isPlaced) {
373 const runs = rows.flatMap((line, y) => {
374 let x = 0
375 return line.map(run => {
376 const at = x
377 x += run.text.length
378 return { run, x: at, y }
379 })
380 })
381 return (
382 <Box flexDirection="column">
383 <Box position="relative" width={state.size.columns} height={state.size.rows}>
384 {runs
385 .filter(({ run }) => run.tone !== 'blank')
386 .map(({ run, x, y }) => (
387 <Box position="absolute" left={x} top={y}>
388 <Text wrap="truncate-end" {...TONE[run.tone]}>
389 {run.text}
390 </Text>
391 </Box>
392 ))}
393 </Box>
394 {state.graph.more > 0 && <Text dimColor>+{state.graph.more} more</Text>}
395 </Box>
396 )
397 }
398 return (
399 <Box flexDirection="column">
400 {rows.map(runs => (
401 <Text wrap="truncate-end">
402 {runs.map(run => (run.tone === 'blank' ? run.text : <Text {...TONE[run.tone]}>{run.text}</Text>))}
403 </Text>
404 ))}
405 {state.graph.more > 0 && <Text dimColor>+{state.graph.more} more</Text>}
406 </Box>
407 )
408}
409
410export default GraphView
411hooks/plan.ts 242 lines1// Pure readers for pm-band: what the pm CLIs print, turned into what the band, the
2// /pm pane and the graph draw. Nothing here touches `$`; register.tsx does the I/O.
3
4import type { Item, Listing, Step } from '../types'
5
6export type { Item, Listing, Step }
7
8/** What one `$.process.run` answered, the fields these readers look at. */
9export type Run = {
10 exitCode: number
11 stdout: string
12 stderr: string
13 isStdoutTruncated?: boolean
14}
15
16// The step grammar pm owns: ai/skills/pm-roadmap/ops.ts planStep counts
17// /^- \[[ x]\] (\d+)\./gm over the whole file. Keep the two in step.
18const STEP_LINE = /^- \[([ x])\] (\d+)\.[ \t]*(.*)$/gm
19
20export function stepsOf(content: string): Step[] {
21 return [...content.matchAll(STEP_LINE)].map(m => ({
22 n: Number(m[2]),
23 text: (m[3] ?? '').trim(),
24 isDone: m[1] === 'x',
25 }))
26}
27
28/** The first step not done yet, which is the one being worked on. */
29export function currentOf(steps: readonly Step[]): Step | undefined {
30 return steps.find(step => !step.isDone)
31}
32
33/** The resolver's `plan` is relative to `main_root`; an absolute one is kept. */
34export function planFile(mainRoot: string, plan: string): string {
35 if (plan.startsWith('/')) return plan
36 return `${mainRoot.replace(/\/+$/, '')}/${plan.replace(/^\.\//, '')}`
37}
38
39export type Resolved =
40 | {
41 kind: 'plan'
42 status: 'draft' | 'active'
43 id: string
44 title: string
45 plan: string
46 mainRoot: string
47 }
48 | { kind: 'none'; mainRoot: string }
49 | { kind: 'hidden' }
50 | { kind: 'error'; reason: string }
51
52function failureOf(run: Run): string | undefined {
53 if (run.isStdoutTruncated) return 'output truncated'
54 if (run.exitCode !== 0) {
55 const line = run.stderr.trim().split('\n').at(-1) ?? ''
56 return line || `exit ${run.exitCode}`
57 }
58 return undefined
59}
60
61/** `worktree.mjs resolve-session`: ok → plan, unbound → none, terminal or outside git → hidden. */
62export function resolvedOf(run: Run): Resolved {
63 if (run.exitCode !== 0 && /not a git repository/.test(run.stderr)) {
64 return { kind: 'hidden' }
65 }
66 const failure = failureOf(run)
67 if (failure) return { kind: 'error', reason: failure }
68 let json: Record<string, unknown>
69 try {
70 json = JSON.parse(run.stdout) as Record<string, unknown>
71 } catch {
72 return { kind: 'error', reason: 'unreadable resolver output' }
73 }
74 const text = (key: string) =>
75 typeof json[key] === 'string' ? (json[key] as string) : ''
76 const status = text('status')
77 if (status === 'unbound') return { kind: 'none', mainRoot: text('main_root') }
78 if (status === 'terminal') return { kind: 'hidden' }
79 if (status !== 'ok') return { kind: 'error', reason: status || 'no status' }
80 const planStatus = text('plan_status')
81 if (planStatus !== 'draft' && planStatus !== 'active') {
82 return { kind: 'hidden' }
83 }
84 return {
85 kind: 'plan',
86 status: planStatus,
87 id: text('id'),
88 title: text('title') || text('id'),
89 plan: text('plan'),
90 mainRoot: text('main_root'),
91 }
92}
93
94export function listingOf(run: Run): Listing | { error: string } {
95 const failure = failureOf(run)
96 if (failure) return { error: failure }
97 try {
98 const json = JSON.parse(run.stdout) as Partial<Listing>
99 if (!Array.isArray(json.eligible) || !Array.isArray(json.blocked)) {
100 return { error: 'unexpected list output' }
101 }
102 return {
103 eligible: json.eligible,
104 blocked: json.blocked,
105 inbox: typeof json.inbox === 'number' ? json.inbox : 0,
106 }
107 } catch {
108 return { error: 'unreadable list output' }
109 }
110}
111
112const PRIORITY: Record<string, number> = { P0: 0, P1: 1, P2: 2, P3: 3 }
113
114function byRank(a: Item, b: Item): number {
115 const priority = (PRIORITY[a.priority] ?? 9) - (PRIORITY[b.priority] ?? 9)
116 if (priority !== 0) return priority
117 const order = (a.order || Infinity) - (b.order || Infinity)
118 if (order !== 0 && !Number.isNaN(order)) return order
119 return a.id.localeCompare(b.id)
120}
121
122export type Row = Item & { isBlocked: boolean; isCurrent: boolean }
123export type Group = { key: string; rows: Row[] }
124
125/** The backlog tab: tasks in key order, items by priority then order. */
126export function groupsOf(listing: Listing, currentPlan?: string): Group[] {
127 const rows: Row[] = [
128 ...listing.eligible.map(item => ({ ...item, isBlocked: false })),
129 ...listing.blocked.map(item => ({ ...item, isBlocked: true })),
130 ].map(row => ({ ...row, isCurrent: !!currentPlan && row.plan === currentPlan }))
131 const keys = [...new Set(rows.map(row => row.key))].sort()
132 return keys.map(key => ({
133 key,
134 rows: rows.filter(row => row.key === key).sort(byRank),
135 }))
136}
137
138export type NodeState = 'task' | 'eligible' | 'blocked' | 'current'
139
140export type GraphNode = {
141 id: string
142 label: string
143 title: string
144 task: string
145 state: NodeState
146}
147
148export type GraphEdge = {
149 from: string
150 to: string
151 kind: 'task' | 'dependency' | 'order'
152}
153
154export type Graph = { nodes: GraphNode[]; edges: GraphEdge[]; more: number }
155
156export const GRAPH_NODE_CAP = 120
157export const GRAPH_PROPS_BUDGET = 60_000
158const TITLE_CHARS = 40
159
160export const hubOf = (key: string) => `#${key}`
161
162function clip(text: string, chars: number): string {
163 return text.length > chars ? `${text.slice(0, chars - 1)}…` : text
164}
165
166function build(items: readonly Row[], total: number): Graph {
167 const keys = [...new Set(items.map(item => item.key))].sort()
168 const ids = new Set(items.map(item => item.id))
169 const nodes: GraphNode[] = [
170 ...keys.map(key => ({
171 id: hubOf(key),
172 label: key,
173 title: key,
174 task: key,
175 state: 'task' as const,
176 })),
177 ...items.map(item => ({
178 id: item.id,
179 label: item.id,
180 title: clip(item.title, TITLE_CHARS),
181 task: item.key,
182 state: item.isCurrent
183 ? ('current' as const)
184 : item.isBlocked
185 ? ('blocked' as const)
186 : ('eligible' as const),
187 })),
188 ]
189 const edges: GraphEdge[] = items.map(item => ({
190 from: item.id,
191 to: hubOf(item.key),
192 kind: 'task',
193 }))
194 for (const item of items) {
195 for (const dep of item.dependsOn) {
196 // A dependency no longer in the backlog is resolved: pm stops blocking on it.
197 if (dep !== item.id && ids.has(dep)) {
198 edges.push({ from: item.id, to: dep, kind: 'dependency' })
199 }
200 }
201 }
202 for (const key of keys) {
203 const chain = items
204 .filter(item => item.key === key && item.order > 0)
205 .sort((a, b) => a.order - b.order)
206 for (let i = 1; i < chain.length; i++) {
207 edges.push({ from: chain[i]!.id, to: chain[i - 1]!.id, kind: 'order' })
208 }
209 }
210 return { nodes, edges, more: total - items.length }
211}
212
213/**
214 * The graph tab's data: the current plan's task when there is one, every task
215 * otherwise; at most `cap` nodes (hubs included) and `budget` characters of
216 * JSON, so the Client's props stay inside the engine's bounds.
217 */
218export function graphOf(
219 listing: Listing,
220 currentPlan?: string,
221 cap = GRAPH_NODE_CAP,
222 budget = GRAPH_PROPS_BUDGET,
223): Graph {
224 const rows = groupsOf(listing, currentPlan).flatMap(group => group.rows)
225 const current = rows.find(row => row.isCurrent)
226 const scope = (current ? rows.filter(row => row.key === current.key) : rows).sort(
227 byRank,
228 )
229 let picked: Row[] = []
230 for (const row of scope) {
231 const hubs = new Set([...picked, row].map(one => one.key)).size
232 if (picked.length + 1 + hubs > cap) break
233 picked.push(row)
234 }
235 let graph = build(picked, scope.length)
236 while (picked.length > 0 && JSON.stringify(graph).length > budget) {
237 picked = picked.slice(0, -1)
238 graph = build(picked, scope.length)
239 }
240 return graph
241}
242hooks/layout.ts 196 lines1// A force-directed layout (Fruchterman–Reingold) in terminal cells, deterministic
2// for a given graph and size so a test can pin its result. The graph Client runs
3// one `tick` per frame until `isSettled`.
4
5import type { GraphEdge, GraphNode } from './plan'
6
7export type Point = { x: number; y: number }
8export type Positions = Record<string, Point>
9export type Size = { columns: number; rows: number }
10export type Layout = { pos: Positions; temperature: number }
11
12/** A terminal cell is about twice as tall as it is wide; forces work in square space. */
13const ASPECT = 2
14const COOLING = 0.9
15const COLD = 0.15
16const QUIET_PER_NODE = 0.02
17/** The ideal distance between linked nodes, in square cells, when unbounded. */
18const SPACING = 12
19
20/** mulberry32: a small seeded generator, so placement never depends on Math.random. */
21export function seeded(seed: number): () => number {
22 let a = seed >>> 0
23 return () => {
24 a = (a + 0x6d2b79f5) >>> 0
25 let t = a
26 t = Math.imul(t ^ (t >>> 15), t | 1)
27 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
28 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
29 }
30}
31
32function hashOf(text: string): number {
33 let h = 2166136261
34 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
35 return h >>> 0
36}
37
38const clamp = (value: number, low: number, high: number) =>
39 Math.min(high, Math.max(low, value))
40
41function inside(point: Point, size: Size): Point {
42 return {
43 x: clamp(point.x, 0, Math.max(0, size.columns - 1)),
44 y: clamp(point.y, 0, Math.max(0, size.rows - 1)),
45 }
46}
47
48export function startTemperature(size: Size): number {
49 return Math.max(size.columns, size.rows * ASPECT) / 6
50}
51
52/**
53 * Where each node starts: where it already was, else beside a neighbour that
54 * has a place, else a spot its id seeds. Nodes no longer in the graph drop out.
55 */
56export function placed(
57 nodes: readonly GraphNode[],
58 edges: readonly GraphEdge[],
59 size: Size,
60 prev: Positions = {},
61 isBounded = true,
62): Positions {
63 const pos: Positions = {}
64 for (const node of nodes) {
65 const kept = prev[node.id]
66 if (kept) pos[node.id] = isBounded ? inside(kept, size) : kept
67 }
68 for (const node of nodes) {
69 if (pos[node.id]) continue
70 const rand = seeded(hashOf(node.id))
71 const neighbour = edges
72 .map(edge => (edge.from === node.id ? edge.to : edge.to === node.id ? edge.from : null))
73 .find(id => id !== null && pos[id] !== undefined)
74 const near = neighbour ? pos[neighbour] : undefined
75 pos[node.id] = inside(
76 near
77 ? { x: near.x + (rand() - 0.5) * 6, y: near.y + (rand() - 0.5) * 3 }
78 : { x: rand() * (size.columns - 1), y: rand() * (size.rows - 1) },
79 size,
80 )
81 }
82 return pos
83}
84
85/**
86 * How a tick may move nodes. `active`: only these move (the rest hold still,
87 * though they still push and pull), absent all do. `isBounded` false: nodes may
88 * leave `size`, which then only sets the spacing and the centre.
89 */
90export type TickOptions = { active?: ReadonlySet<string>; isBounded?: boolean }
91
92/** One step of the simulation; pinned nodes (being dragged) hold still. */
93export function tick(
94 nodes: readonly GraphNode[],
95 edges: readonly GraphEdge[],
96 layout: Layout,
97 size: Size,
98 pinned: ReadonlySet<string> = new Set(),
99 { active, isBounded = true }: TickOptions = {},
100): { layout: Layout; energy: number } {
101 const n = nodes.length
102 if (n === 0) return { layout: { ...layout, temperature: 0 }, energy: 0 }
103 const area = size.columns * size.rows * ASPECT
104 // Unbounded, the spacing is fixed: the graph's size follows its node count,
105 // never the region it happens to be seen through.
106 const k = isBounded ? 0.75 * Math.sqrt(area / n) : SPACING
107 const disp = new Map(nodes.map(node => [node.id, { x: 0, y: 0 }]))
108 const at = (id: string) => {
109 const p = layout.pos[id] ?? { x: 0, y: 0 }
110 return { x: p.x, y: p.y * ASPECT }
111 }
112 for (let i = 0; i < n; i++) {
113 const a = nodes[i]!
114 const pa = at(a.id)
115 for (let j = i + 1; j < n; j++) {
116 const b = nodes[j]!
117 const pb = at(b.id)
118 let dx = pa.x - pb.x
119 let dy = pa.y - pb.y
120 if (dx === 0 && dy === 0) {
121 // Two nodes on one spot: part them along a direction their indices fix.
122 dx = ((i - j) % 3) * 0.1 + 0.05
123 dy = ((i + j) % 2) * 0.1 - 0.05
124 }
125 const d = Math.max(0.01, Math.hypot(dx, dy))
126 const force = (k * k) / d
127 const da = disp.get(a.id)!
128 const db = disp.get(b.id)!
129 da.x += (dx / d) * force
130 da.y += (dy / d) * force
131 db.x -= (dx / d) * force
132 db.y -= (dy / d) * force
133 }
134 }
135 for (const edge of edges) {
136 const da = disp.get(edge.from)
137 const db = disp.get(edge.to)
138 if (!da || !db) continue
139 const pa = at(edge.from)
140 const pb = at(edge.to)
141 const dx = pa.x - pb.x
142 const dy = pa.y - pb.y
143 const d = Math.max(0.01, Math.hypot(dx, dy))
144 const weight = edge.kind === 'order' ? 0.5 : 1
145 const force = ((d * d) / k) * weight
146 da.x -= (dx / d) * force
147 da.y -= (dy / d) * force
148 db.x += (dx / d) * force
149 db.y += (dy / d) * force
150 }
151 const centre = { x: (size.columns - 1) / 2, y: ((size.rows - 1) / 2) * ASPECT }
152 const pos: Positions = {}
153 let energy = 0
154 for (const node of nodes) {
155 const p = at(node.id)
156 if (pinned.has(node.id) || (active !== undefined && !active.has(node.id))) {
157 pos[node.id] = layout.pos[node.id] ?? inside({ x: 0, y: 0 }, size)
158 continue
159 }
160 const d = disp.get(node.id)!
161 // A weak pull to the centre keeps separate components together; weaker
162 // when unbounded, so the graph takes the room it needs.
163 const pull = isBounded ? 0.05 : 0.015
164 d.x += (centre.x - p.x) * pull * k
165 d.y += (centre.y - p.y) * pull * k
166 const length = Math.hypot(d.x, d.y)
167 const step = length > 0 ? Math.min(length, layout.temperature) / length : 0
168 const moved = { x: p.x + d.x * step, y: (p.y + d.y * step) / ASPECT }
169 const next = isBounded ? inside(moved, size) : moved
170 const old = layout.pos[node.id] ?? next
171 energy += Math.hypot(next.x - old.x, next.y - old.y)
172 pos[node.id] = next
173 }
174 return { layout: { pos, temperature: layout.temperature * COOLING }, energy }
175}
176
177export function isSettled(layout: Layout, energy: number, nodes: number): boolean {
178 return layout.temperature < COLD || energy / Math.max(1, nodes) < QUIET_PER_NODE
179}
180
181/** Runs ticks until the layout settles or `limit` ticks pass: what a test pins. */
182export function settle(
183 nodes: readonly GraphNode[],
184 edges: readonly GraphEdge[],
185 size: Size,
186 limit = 400,
187): { layout: Layout; ticks: number } {
188 let layout: Layout = { pos: placed(nodes, edges, size), temperature: startTemperature(size) }
189 for (let ticks = 1; ticks <= limit; ticks++) {
190 const result = tick(nodes, edges, layout, size)
191 layout = result.layout
192 if (isSettled(layout, result.energy, nodes.length)) return { layout, ticks }
193 }
194 return { layout, ticks: limit }
195}
196hooks/raster.ts 183 lines1// Laid-out graph → rows of styled runs the graph Client draws as Text. Edges first,
2// then nodes over them, so a node's marker and label always win a shared cell.
3
4import type { Positions, Size } from './layout'
5import type { GraphEdge, GraphNode } from './plan'
6
7export type Tone =
8 | 'blank'
9 | 'task-edge'
10 | 'dependency-edge'
11 | 'order-edge'
12 | 'task'
13 | 'eligible'
14 | 'blocked'
15 | 'current'
16 | 'hovered'
17 | 'selected'
18
19export type Run = { text: string; tone: Tone }
20
21const EDGE: Record<GraphEdge['kind'], { ch: string; tone: Tone }> = {
22 task: { ch: '·', tone: 'task-edge' },
23 dependency: { ch: '•', tone: 'dependency-edge' },
24 order: { ch: '∙', tone: 'order-edge' },
25}
26
27function cellOf(point: { x: number; y: number }) {
28 return { x: Math.round(point.x), y: Math.round(point.y) }
29}
30
31function markerOf(node: GraphNode, isSelected: boolean): string {
32 if (isSelected) return '◉'
33 return node.state === 'task' ? '◆' : '●'
34}
35
36/** Where a node's marker sits and which cells its label took (`from`..`to`, inclusive). */
37export type Placement = { x: number; y: number; from: number; to: number; text: string }
38
39/**
40 * Markers first, then each label in node order: right of its marker, else left,
41 * else cut with `…` into the free cells on the right. A label keeps one blank
42 * cell from any other marker or label, so two never read as one.
43 */
44export function placementsOf(
45 nodes: readonly GraphNode[],
46 pos: Positions,
47 size: Size,
48): Map<string, Placement> {
49 const { columns, rows } = size
50 const taken: boolean[][] = Array.from({ length: rows }, () => Array(columns).fill(false))
51 const isFree = (y: number, from: number, to: number) => {
52 if (from < 0 || to >= columns) return false
53 for (let cx = Math.max(0, from - 1); cx <= Math.min(columns - 1, to + 1); cx++) {
54 if (taken[y]![cx]) return false
55 }
56 return true
57 }
58 const placed = new Map<string, Placement>()
59 for (const node of nodes) {
60 const p = pos[node.id]
61 if (!p) continue
62 const { x, y } = cellOf(p)
63 if (x < 0 || y < 0 || x >= columns || y >= rows) continue
64 taken[y]![x] = true
65 placed.set(node.id, { x, y, from: x, to: x - 1, text: '' })
66 }
67 for (const node of nodes) {
68 const place = placed.get(node.id)
69 if (!place) continue
70 const { x, y } = place
71 const length = node.label.length
72 // The marker's own cell is not an obstacle to its label's gap.
73 taken[y]![x] = false
74 let chosen: Placement | null = null
75 if (isFree(y, x + 2, x + 1 + length)) {
76 chosen = { x, y, from: x + 2, to: x + 1 + length, text: node.label }
77 } else if (isFree(y, x - 1 - length, x - 2)) {
78 chosen = { x, y, from: x - 1 - length, to: x - 2, text: node.label }
79 } else {
80 let room = 0
81 while (x + 2 + room < columns && isFree(y, x + 2, x + 2 + room)) room += 1
82 if (room >= 2) {
83 chosen = { x, y, from: x + 2, to: x + 1 + room, text: `${node.label.slice(0, room - 1)}…` }
84 }
85 }
86 taken[y]![x] = true
87 if (!chosen) continue
88 for (let cx = chosen.from; cx <= chosen.to; cx++) taken[y]![cx] = true
89 placed.set(node.id, chosen)
90 }
91 return placed
92}
93
94export function rasterOf(
95 nodes: readonly GraphNode[],
96 edges: readonly GraphEdge[],
97 pos: Positions,
98 size: Size,
99 selected: string | null = null,
100 hovered: string | null = null,
101): Run[][] {
102 const { columns, rows } = size
103 const ch: string[][] = Array.from({ length: rows }, () => Array(columns).fill(' '))
104 const tone: Tone[][] = Array.from({ length: rows }, () => Array(columns).fill('blank'))
105 const put = (x: number, y: number, c: string, t: Tone) => {
106 if (x < 0 || y < 0 || x >= columns || y >= rows) return
107 ch[y]![x] = c
108 tone[y]![x] = t
109 }
110 for (const edge of edges) {
111 const a = pos[edge.from]
112 const b = pos[edge.to]
113 if (!a || !b) continue
114 const style = EDGE[edge.kind]
115 let { x: x0, y: y0 } = cellOf(a)
116 const { x: x1, y: y1 } = cellOf(b)
117 const dx = Math.abs(x1 - x0)
118 const dy = -Math.abs(y1 - y0)
119 const sx = x0 < x1 ? 1 : -1
120 const sy = y0 < y1 ? 1 : -1
121 let err = dx + dy
122 for (;;) {
123 put(x0, y0, style.ch, style.tone)
124 if (x0 === x1 && y0 === y1) break
125 const e2 = 2 * err
126 if (e2 >= dy) {
127 err += dy
128 x0 += sx
129 }
130 if (e2 <= dx) {
131 err += dx
132 y0 += sy
133 }
134 }
135 }
136 const placed = placementsOf(nodes, pos, size)
137 for (const node of nodes) {
138 const place = placed.get(node.id)
139 if (!place) continue
140 const t: Tone = node.id === selected ? 'selected' : node.id === hovered ? 'hovered' : node.state
141 put(place.x, place.y, markerOf(node, node.id === selected), t)
142 if (place.text === '') continue
143 // The gap cell between marker and label takes the node's tone, so a picked
144 // node inverts as one piece.
145 const from = Math.min(place.from, place.x + 1)
146 const to = Math.max(place.to, place.x - 1)
147 for (let cx = from; cx <= to; cx++) {
148 const at = cx - place.from
149 const c = cx >= place.from && cx <= place.to ? place.text[at]! : ' '
150 if (cx !== place.x) put(cx, place.y, c, t)
151 }
152 }
153 return ch.map((line, y) => {
154 const runs: Run[] = []
155 for (let x = 0; x < line.length; x++) {
156 const t = tone[y]![x]!
157 const last = runs.at(-1)
158 if (last && last.tone === t) last.text += line[x]
159 else runs.push({ text: line[x]!, tone: t })
160 }
161 return runs
162 })
163}
164
165/** The node whose marker or placed label covers a cell, the last drawn first. */
166export function nodeAt(
167 nodes: readonly GraphNode[],
168 pos: Positions,
169 size: Size,
170 x: number,
171 y: number,
172): string | undefined {
173 const placed = placementsOf(nodes, pos, size)
174 for (let i = nodes.length - 1; i >= 0; i--) {
175 const node = nodes[i]!
176 const place = placed.get(node.id)
177 if (!place || place.y !== y) continue
178 if (x === place.x) return node.id
179 if (place.text !== '' && x >= Math.min(place.from, place.x) && x <= Math.max(place.to, place.x)) return node.id
180 }
181 return undefined
182}
183types/index.d.ts 45 lines1export type Step = { n: number; text: string; isDone: boolean }
2
3/** One backlog item as `pm list --json` prints it (ai/skills/pm-roadmap/join.ts Candidate). */
4export type Item = {
5 key: string
6 id: string
7 title: string
8 priority: string
9 order: number
10 plan: string | null
11 status: string
12 dependsOn: string[]
13 blockedBy?: string
14 blockedByReason?: 'dependency' | 'order'
15}
16
17export type Listing = { eligible: Item[]; blocked: Item[]; inbox: number }
18
19/** What the band and the steps tab draw: a bound plan, no plan, or nothing. */
20export type PlanView =
21 | { kind: 'plan'; status: 'draft' | 'active'; id: string; title: string; plan: string; steps: Step[] }
22 | { kind: 'none' }
23
24/**
25 * The band's state. `view` is the last good read for `sessionId` (null: draw
26 * nothing); `error` says the latest refresh failed and why.
27 */
28export type BandState = { sessionId: string; view: PlanView | null; error: string | null }
29
30/** The backlog tab's state, read only while the /pm pane is open. */
31export type BacklogState = { sessionId: string; listing: Listing | null; error: string | null }
32
33export type Tab = 'steps' | 'backlog' | 'graph'
34
35declare module 'claude-code' {
36 interface PluginState {
37 'pm-band': {
38 band: BandState
39 backlog: BacklogState
40 tab: Tab
41 selected: string | null
42 }
43 }
44}
45