SLOPSHOPPER

pm-band

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

newpanebandguardcommandprocess
★ 1v0.1.0no licenseupdated 2026-10-07iMUngHee/dotfiles/claude/mods/pm-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pm-band
│ ┃ pm ✕ › fix the failing auth test and add an audit log call │ ┃ 1: steps 2: backlog 3: graph │ ┃ No plan is bound to this session. ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /pm │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pm
1: steps 2: backlog 3: graph No plan is bound to this session.
README

dotfiles

Personal configuration files managed via ~/.config/ and synced with git.

What's tracked

DirectoryToolKey files
ai/Shared (Claude + Codex)PERSONAL.md, guardrails.md, rules/, memory/, skills/, AGENTS.manifest
claude/Claude CodeCLAUDE.md, DEVGUARD.md, settings.json, hooks/, agents/, commands/, skills/ (Claude-only)
codex/Codex CLIconfig.toml.template, skills/ (Codex-only), scripts/
notifier/Shared AI notifiermacOS Swift app, Linux Go daemon, Go sender, shared icon
ghostty/Ghosttyconfig, 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 Originvimium-options.json (Vimium export; restore by hand from its Options page when Brave Sync has not carried it over)
nvim/Neoviminit.lua, lua/plugins/
tmux/tmuxtmux.conf, scripts/, status/
zsh/Zsh.zshrc, custom/plugins/
homebrew/Homebrew BundleBrewfile, bootstrap.sh (macOS package set + shell env)
arch/Arch Linux / Omarchypackages.sh (pacman + AUR, the Brewfile mapping), bootstrap.sh (packages + mise + shell env)
lib/Shared bootstrapshell-env.sh (~/.zshenv ZDOTDIR + oh-my-zsh, used by both Unix package layers)
windows/Native Windowsbootstrap.ps1, packages.ps1 (winget), profile.ps1, starship.toml, terminal/, notifier/, Cowork skill packaging
.ideavimrcIdeaVim (JetBrains)Standalone file

AI assistant 3-tier layout

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 .gitignore for the allowlist.

Machine-local settings

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:

ConfigHook in the tracked fileMachine-local file
zshsource $ZDOTDIR/private.shzsh/private.sh
ghosttyconfig-file = ?"…/private.conf"ghostty/private.conf
tmuxif-shell '[ -r … ]' 'source-file …'tmux/private.conf
nvimplugins/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.

Submodules

PathRepo
zsh/custom/plugins/zsh-autosuggestionszsh-users/zsh-autosuggestions
zsh/custom/plugins/zsh-history-substring-searchzsh-users/zsh-history-substring-search
zsh/custom/plugins/zsh-syntax-highlightingzsh-users/zsh-syntax-highlighting
zsh/custom/plugins/fzf-tabaloxaf/fzf-tab
ghostty/shaderssahaj-b/ghostty-cursor-shaders

Setup

macOS / Linux

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:

MachinePackage layerSource of claude / codex
macOShomebrew/bootstrap.sh → brew bundleHomebrew casks
Arch / Omarchyarch/bootstrap.sh → pacman + AURmise (~/.config/mise/config.toml, machine-local)
Linux with Homebrew, no pacmanhomebrew/bootstrap.shHomebrew

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.

~/.config is usually not empty on a fresh Omarchy install — ghostty/, nvim/ and tmux/ already exist. The tracked versions here replace them; move the originals aside first if you want them back.

Windows

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.

For per-tool details: ai/, claude/, codex/, windows/.

Source 6 files
hooks/register.tsx 461 lines
1// 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}
461
hooks/graph.tsx 411 lines
1// 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
411
hooks/plan.ts 242 lines
1// 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}
242
hooks/layout.ts 196 lines
1// 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}
196
hooks/raster.ts 183 lines
1// 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}
183
types/index.d.ts 45 lines
1export 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