SLOPSHOPPER

plan-progress

Where a long build stands, beside the conversation, from a plain Markdown plan that keeps itself current: commits that name a step move it, and when the work…

newpanebandguardtoastprompt
v0.2.1MITupdated 2026-10-21barmoshe/bar-mods/plan-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress
│ ┃ plan-progress ✕ › fix the failing auth test and add an audit log call │ ┃ No plan is pinned │ ┃ Pin a Markdown file that holds a <!-- ⏺ Read(src/auth.ts) │ ┃ plan-progress --> block: /progress pin ⎿ Read 6 lines │ ┃ path/to/plan.md, or the button below. ⏺ Update(src/auth.ts) │ ┃ [ Hide ][ Pin another plan ] ⎿ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · plan-progress
No plan is pinned Pin a Markdown file that holds a <!-- plan-progress --> block: /progress pin path/to/plan.md, or the button below. [ Hide ][ Pin another plan ]
README

plan-progress

Where a long build stands, beside the conversation, and a plan that keeps itself current. I keep builds as a plan of numbered steps in plain Markdown. This mod answers the three questions I ask it, in order: how far are we ("13 of 16 done"), what is happening now (the step, how long it has run, what Claude is doing this minute), and what needs me (only when something does). Every agent's plan view fails the same way, the agent forgets to update it, so this one also reads the evidence: commits that name a step move the plan, and when the work has moved past an open step, it says so.

What it reads and writes.

  • It reads one Markdown file, the plan you pin, and checks it every 2 seconds (a stat; it reads the file only when it changed).
  • It watches this session's tool calls: what Claude is doing (for the live line), Edit and Write on the plan (to redraw at once), and git commit in Bash. From a commit it reads the message in the command line, and only uses the step ids it names.
  • It writes the plan file in three cases, each one line: Claude calls its tool to set or add an item; a commit names a step not yet started, which it marks in progress; or you press "Mark … done" on a lag it shows.
  • It keeps each plan's history (commit ids, when items started and finished, the lags it showed) in its own store, so every session that pins the file reads the same.
  • One line to Claude: when a commit shows the work has moved past an item still in progress, it adds one sentence to that commit's result, which Claude reads (a few tokens, once per lag), and the pane says it did.
  • One process: on the desktop, pressing Open the file runs your system's opener (open, explorer, xdg-open), because the desktop draws only https: links.
  • No network, no model calls.
claude plugin install plan-progress@bar-mods

Then /progress pin path/to/plan.md.

The block

Progress is a block inside any Markdown file. Everything outside the block is never read or changed, and the file stays readable on GitHub, where [~], [?], [!] and [-] show as text.

<!-- plan-progress title="Version 3.1 · trainer studio" -->
- [x] 1 · Days in a template · `5d18be5`
- [~] 2 · Exceptions and "שמור"
  - [x] 2a · Tables, codec, rules · `df82ea1`
  - [~] 2b · The editor's draft
  - [ ] 2c · The checks
- [ ] 3 · An exercise not on the list
- [-] 4 · A step we dropped
- [ ] PR · The one PR, its CI run
- [?] gate · The merge · Bar's word
<!-- /plan-progress -->
  • The markers: <!-- plan-progress title="…" --> opens the block and <!-- /plan-progress --> closes it. The title is optional. One file holds one block; the first is read.
  • A line: - [state] id · title, then optional fields after further · separators: a commit, a PR, a date, a note. Backticks and Markdown links are kept as written; a field that is only a link text is drawn as a link. A line with one field is a title with no id. An item with notes needs an id.
  • States: [ ] to do, [~] in progress, [x] done ([X] too), [?] waits on you (a decision or a gate), [!] blocked by something else, [-] dropped.
  • Nesting: two levels, a step and its sub-steps. A sub-step is indented two spaces (any indent of two or more).
  • Gates: an item whose id is gate is a milestone, not work, drawn as a diamond where it falls and never counted. A gate is due only when every step before it is done: then it needs you. Until then it is ahead, one quiet line, however it is marked. Name a gate by its title.
  • Other lines inside the block (blank lines, prose, comments) are kept and ignored.

Commits that name a step

Write the step's id after "step" or "steps" in the commit message, the way the trainer studio's build does: feat(drafts): an exercise not on the list (3.1 step 3a), (3.1 steps 3b, 3c), (3.1 step 2a, 2b part). Only ids the plan has count, so 3.1 or part never match. When such a commit lands in a session with the mod:

  • the commit is attached to each step it names, and its time is that step's start if it had none;
  • a step it names that was not started becomes in progress (and its step, for a sub-step);
  • when the commit names a later step while an earlier one is still in progress, that earlier step looks done. The pane shows it under "The plan is behind", marked inferred, with Mark done and Not yet. Claude reads one sentence about it in the commit's result. The mod never closes a step by itself.

How it counts

Each top-level step weighs one. A step with sub-steps fills by the share of them done; a step without sub-steps counts only once it is done, so [~] alone adds nothing. Gates and dropped items are not counted. The percentage is rounded down, so 100% means every step is done. The pane leads with how many steps are done ("13 of 16 done") and keeps the percentage small. A position ("step 9 of 16") would mislead: steps finish out of order, and steps can be added.

Pace appears once two steps were seen both start and finish: the minutes each took, a bar per step, and the range a step takes. What is left is that range times the steps left, in hours, always a range ("about 7-10 h"). There is no finish time before there is pace to read.

What you see

The pane (/progress), top to bottom; a section with nothing in it takes no room:

  • the plan's name, 13 of 16 done in big type, the percentage small, and the strip: ink for what is done, halftone at the front with the orange mark, a blueprint outline ahead, a diamond per gate. Hover a segment for its title, state and notes.
  • The plan is behind: a step the commits moved past, with Mark done and Not yet.
  • Needs you now: a decision marked [?], or a gate that is due, with a button that puts "Go ahead with …" in the prompt box for you to send. Otherwise one quiet line: "◇ 3 gates ahead, after PR: …".
  • Now: the step, how long ago it started, its open sub-steps with their notes, its commits, and while Claude works, one live line of what it is doing ("Claude · Run the emulator and browser suites").
  • Next: the next three steps. Done: one line of ids and when the last one closed; Every step shows them all. Pace, when there is one.
  • the file, when it changed, and Every step, Hide, Pin another plan, Open the file.

The band, above the prompt: the strip, "13/16 done · 6 Demo data · 9 h". A second line only when a gate is due ("◆ terraform apply needs your word") or the plan is behind. Press it to open the pane. Nothing when no plan is pinned or it is hidden.

A toast when a step turns done ("Step 3 done · 5 of 15"), and a short moment when the whole plan does. Sub-steps make no noise.

On the desktop the masthead is an SVG carrying its own subsets of Heebo and Cascadia Mono, and it follows the app's light or dark theme. The terminal draws the same brief in characters: blocks for ink, braille dots for halftone, a dashed line for blueprint. The pane opens unasked at session start only when a plan is pinned and you have not hidden it in that project (in the terminal, only from 144 columns).

Hebrew. On the desktop a title that opens in Hebrew is set right to left; a Latin title with a Hebrew word reads as it is. The macOS terminals print text in stored order, so the mod hands them Hebrew in visual order; VS Code's terminal and Windows Terminal order it themselves and get it as written. (The ordering is rtl-kit's.)

Commands

| | | | :- | :- | | /progress | open the pane and bring the band back | | /progress pin <file> | pin the plan in that file, for this project | | /progress hide | hide the band and the pane until /progress | | /progress unpin | pin nothing |

/progress is a command file (commands/progress.md), so the desktop app lists it from the session's start; the mod answers it at once, with no model turn. /plan-progress:progress is the same command. You can also just ask Claude ("show the plan's progress", "pin progress-3-1.md"): the tool does the same.

The tool Claude calls

mcp__plan-progress__progress, one tool with an action:

| action | arguments | what it does | | :- | :- | :- | | read | | the plan, compact: percentage, now, what waits, the gates ahead, every line, and whether it looks behind; and a version | | set | item, state, note?, version? | sets an item's state: todo, doing, done, waits, blocked, dropped. note replaces the fields after the title ("" removes them). | | add | title, id?, state?, note?, after? or under?, version? | a new item after another at its depth (after its sub-steps), or as the last sub-step of a step; at the end with neither | | pin | path | pins the plan file | | show, hide, unpin | | opens the pane, hides the band and the pane, pins nothing |

item is an id (2d) or, for a gate, its title. A write reads the file fresh and changes only that item's line, every other byte kept, line endings included. With the version that read returned, it refuses when the file changed since (a hand edit), and it always refuses when the file changes while the edit is being made. Hand edits are normal: they show within two seconds, or at once when made with Edit or Write.

An agent working a plan calls read when it picks up the build, set … doing as it starts a step or sub-step, set … done with the commit as it finishes one, and names the step in each commit message.

Footprint

From claude plugin validate:

  • hooks: session.start, command.run{command=/^(?:plan-progress:)?progress$/}, prompt.submit, tool.call, turn.complete, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=plan-progress}
  • calls: $.clock.after, $.clock.every, $.clock.now, $.env.get, $.fs.read, $.fs.stat, $.fs.write, $.process.run (Open the file), $.prompt.fill (Give the word), $.session.version, $.state.get, $.state.set, $.store.delete, $.store.get, $.store.set, $.tool.register, $.ui.close, $.ui.open, $.ui.resolve, $.ui.toast
  • env reads: HOME, OS, TERM_PROGRAM, WT_SESSION

The one tool.call hook answers the plan tool, notes what Claude is doing, redraws on an Edit or Write of the plan, and reads git commit results; it passes every call through, adding one sentence of context to a commit's result only when that commit shows the plan is behind. The prompt.submit hook acts only on a prompt that is /progress …: where a command file arrives as a prompt (the desktop app, claude -p), it answers and drops it, so no turn runs. Every other prompt passes untouched. turn.complete clears the live line.

Surfaces

Draws in the terminal and the desktop app's Code tab. In claude -p, the SDK and the VS Code chat panel the tool and the commit reading still work and nothing draws.

Fonts

hooks/lib/fonts.ts holds subsets of Heebo and Cascadia Mono, made by scripts/make-fonts.py, under the SIL Open Font License 1.1 (fonts/OFL.txt). The rest is MIT.

Tested on Claude Code 2.1.301 (CLI) and 2.1.299 (the desktop app's engine).

Source 8 files
hooks/register.tsx 886 lines
1// plan-progress: a plan's progress, read from a plain Markdown block, drawn above the prompt and
2// beside the conversation. It keeps itself current from the evidence: the agent's tool calls, hand
3// edits, and commits whose messages name a step ("(3.1 step 3d)").
4//
5// Only this file touches `$`. The grammar, the history, the writer and the drawing are pure files in lib/.
6
7import { atom, read, update } from 'claude-code'
8import type { Elements, EngineInterface, Register } from 'claude-code'
9
10import type { Env, History, Item, ItemState, Lag, Moment, Plan, Snapshot } from '../types'
11import { bidi } from './lib/bidi'
12import type { Where } from './lib/bidi'
13import { altOf, bandSvg, briefSvg, noteText, paceSvg } from './lib/draw'
14import {
15  addItem,
16  count,
17  COUNT_RULE,
18  fillOf,
19  findNow,
20  gatesAhead,
21  label,
22  parse,
23  setItem,
24  summary,
25  upNext,
26  waiting,
27} from './lib/grammar'
28import type { Edit } from './lib/grammar'
29import { addCommit, addTransitions, commitMessage, emptyHistory, isWipMessage, lagId, lags, mergeHistory, nudgeText, pace, since, stepRefs } from './lib/history'
30import { barRuns } from './lib/term'
31import type { Tone } from './lib/term'
32
33const PANE = 'plan-progress'
34const TITLE = 'Plan progress'
35const POLL_MS = 2000
36const TOOL = 'mcp__plan-progress__progress'
37// a live line older than this is left out: the agent has stopped
38const LIVE_MS = 3 * 60_000
39
40const pinned = atom({ plugin: 'plan-progress', key: 'pinned' } as const, null)
41const snap = atom({ plugin: 'plan-progress', key: 'snap' } as const, null)
42const isHidden = atom({ plugin: 'plan-progress', key: 'isHidden' } as const, false)
43const showAll = atom({ plugin: 'plan-progress', key: 'showAll' } as const, false)
44const isPinning = atom({ plugin: 'plan-progress', key: 'isPinning' } as const, false)
45const minute = atom({ plugin: 'plan-progress', key: 'minute' } as const, 0)
46const env = atom({ plugin: 'plan-progress', key: 'env' } as const, { engine: '', isTermLogical: false, opener: 'open' })
47const moment = atom({ plugin: 'plan-progress', key: 'moment' } as const, null)
48const hist = atom({ plugin: 'plan-progress', key: 'hist' } as const, null)
49const live = atom({ plugin: 'plan-progress', key: 'live' } as const, null)
50
51// The project this session works in; pins and Hide are remembered per project.
52let root = ''
53const pinKey = () => `pin:${root}`
54const hideKey = () => `hidden:${root}`
55const histKey = (path: string) => `hist:${path}`
56const shown = (path: string) => (root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path)
57
58// ---------- history: kept in the store, so every session that pins the file reads the same ----------
59
60function isHistory(v: unknown): v is History {
61  return typeof v === 'object' && v !== null && 'items' in v && 'commits' in v && Array.isArray((v as History).commits)
62}
63
64async function storedHistory($: EngineInterface, path: string): Promise<History> {
65  const v = await $.store.get(histKey(path)).catch(() => undefined)
66  return isHistory(v) ? { ...emptyHistory(), ...v } : emptyHistory()
67}
68
69async function saveHistory($: EngineInterface, path: string, next: History): Promise<void> {
70  await update($, hist, () => next)
71  await $.store.set(histKey(path), next)
72}
73
74// ---------- reading the pinned file ----------
75
76function moved(before: Plan | null, after: Plan): { step: Item | null; isPlanDone: boolean } {
77  if (!before) return { step: null, isPlanDone: false }
78  const key = (it: Item) => (it.id ? `id:${it.id}` : `t:${it.title}`)
79  const was = new Map(before.items.map(it => [key(it), fillOf(it)] as const))
80  const step = after.items.find(it => !it.isGate && fillOf(it) === 1 && (was.get(key(it)) ?? 1) < 1) ?? null
81  const isPlanDone = count(after).pct === 100 && count(before).pct < 100
82  return { step, isPlanDone }
83}
84
85async function load($: EngineInterface): Promise<void> {
86  const path = await read($, pinned)
87  if (!path) {
88    await update($, snap, () => null)
89    await update($, hist, () => null)
90    return
91  }
92  const stat = await $.fs.stat(path).catch(() => null)
93  if (!stat || stat.kind !== 'file') {
94    await update($, snap, () => ({ path, mtimeMs: 0, size: 0, plan: null, error: `${path} is not there.` }))
95    return
96  }
97  const text = await $.fs.read(path).catch(() => null)
98  if (text === null || typeof text !== 'string') {
99    await update($, snap, () => ({ path, mtimeMs: stat.mtimeMs, size: stat.size, plan: null, error: `${path} could not be read.` }))
100    return
101  }
102  const plan = parse(text)
103  const before = await read($, snap)
104  const samePlan = before && before.path === path ? before.plan : null
105  const next: Snapshot = { path, mtimeMs: stat.mtimeMs, size: stat.size, plan, error: plan ? null : `${path} has no <!-- plan-progress --> block.` }
106  await update($, snap, () => next)
107  if (!plan) return
108  // the history this file has, from the store on a first read, and what this read changed
109  const was = samePlan ? ((await read($, hist)) ?? emptyHistory()) : await storedHistory($, path)
110  const now = await $.clock.now()
111  const after = addTransitions(was, samePlan, plan, now)
112  if (after !== was || !samePlan) await saveHistory($, path, after)
113  if (!samePlan) return
114  const { step, isPlanDone } = moved(samePlan, plan)
115  if (!step && !isPlanDone) return
116  const c = count(plan)
117  const m: Moment = isPlanDone ? { kind: 'plan', id: '', until: now + 6000 } : { kind: 'step', id: String(step?.line ?? ''), until: now + 6000 }
118  await update($, moment, () => m)
119  $.clock.after(6500, () => {
120    update($, moment, () => null).catch(() => undefined)
121  })
122  $.ui.toast(isPlanDone ? `${plan.title || 'The plan'}: every step done` : `Step ${step?.id || step?.title} done · ${c.done} of ${c.total}`)
123}
124
125async function poll($: EngineInterface): Promise<void> {
126  const now = await $.clock.now()
127  const m = Math.floor(now / 60000)
128  if ((await read($, minute)) !== m) await update($, minute, () => m)
129  const path = await read($, pinned)
130  if (!path) return
131  const was = await read($, snap)
132  const stat = await $.fs.stat(path).catch(() => null)
133  const isSame = was !== null && stat !== null && was.mtimeMs === stat.mtimeMs && was.size === stat.size
134  if (!isSame) await load($)
135  // commits another session saw arrive through the store
136  const mine = await read($, hist)
137  const theirs = await storedHistory($, path)
138  if (mine && theirs.commits.length + theirs.nudged.length + theirs.dismissed.length !== mine.commits.length + mine.nudged.length + mine.dismissed.length) {
139    await update($, hist, () => mergeHistory(mine, theirs))
140  }
141}
142
143async function pin($: EngineInterface, asked: string): Promise<string> {
144  const path = asked.trim().replace(/^~(?=\/)/, (await $.env.get('HOME')) ?? '~')
145  if (!path) return 'Name the plan file: /progress pin path/to/plan.md'
146  const stat = await $.fs.stat(path, { resolve: true }).catch(() => null)
147  if (!stat || stat.kind !== 'file' || !stat.realPath) return `${asked.trim()} is not a file.`
148  const text = await $.fs.read(stat.realPath).catch(() => '')
149  const plan = typeof text === 'string' ? parse(text) : null
150  if (!plan) return `${asked.trim()} has no <!-- plan-progress --> block. The README says how to write one.`
151  await update($, pinned, () => stat.realPath ?? path)
152  await update($, isPinning, () => false)
153  await update($, isHidden, () => false)
154  await $.store.set(pinKey(), stat.realPath)
155  await $.store.delete(hideKey())
156  await load($)
157  $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
158  const c = count(plan)
159  return `Pinned ${plan.title || stat.realPath}: ${c.done} of ${c.total} done, ${c.pct}%.`
160}
161
162// A remote surface draws only https: links, so the desktop opens the file with the system's opener:
163// the one process this mod starts, and only on a press.
164async function openFile($: EngineInterface, path: string): Promise<void> {
165  const { opener } = await read($, env)
166  const r = await $.process.run([opener, path], { timeoutMs: 10_000 })
167  if (r.exitCode !== 0) $.ui.toast(`Could not open ${path}`)
168}
169
170async function hide($: EngineInterface): Promise<void> {
171  await update($, isHidden, () => true)
172  await $.store.set(hideKey(), true)
173  await $.ui.close({ id: PANE }).catch(() => undefined)
174}
175
176async function show($: EngineInterface): Promise<void> {
177  await update($, isHidden, () => false)
178  await $.store.delete(hideKey())
179  await $.ui.open({ id: PANE, title: TITLE })
180}
181
182// ---------- writing ----------
183
184const STATE_OF: Record<string, ItemState> = {
185  todo: ' ', 'to do': ' ', ' ': ' ', '': ' ',
186  doing: '~', 'in progress': '~', '~': '~',
187  done: 'x', x: 'x',
188  waits: '?', 'waits on you': '?', '?': '?',
189  blocked: '!', '!': '!',
190  dropped: '-', '-': '-',
191}
192
193const answer = (body: string) => ({ result: [{ type: 'text' as const, text: body }] })
194const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
195
196// Write one edit to the pinned file: fresh read, the edit, and no write if the file moved meanwhile.
197async function write($: EngineInterface, edit: (text: string) => Edit, version: number | undefined): Promise<string> {
198  const path = await read($, pinned)
199  if (!path) return 'No plan is pinned. Call this tool with action "pin" and the plan file\'s path first.'
200  const before = await $.fs.stat(path).catch(() => null)
201  if (!before) return `${path} is not there any more.`
202  if (version !== undefined && Math.round(before.mtimeMs) !== Math.round(version)) {
203    return 'The file changed since you read it (someone edited it). Nothing was written. Call read again, then retry.'
204  }
205  const text = await $.fs.read(path)
206  if (typeof text !== 'string') return `${path} could not be read as text.`
207  const r = edit(text)
208  if ('error' in r) return r.error
209  const again = await $.fs.stat(path).catch(() => null)
210  if (!again || again.mtimeMs !== before.mtimeMs || again.size !== before.size) {
211    return 'The file changed while this edit was being made. Nothing was written. Call read again, then retry.'
212  }
213  await $.fs.write(path, r.text)
214  await load($)
215  const s = await read($, snap)
216  const after = await $.fs.stat(path).catch(() => null)
217  const line = r.text.split('\n')[r.line]?.replace(/\r$/, '') ?? ''
218  return `Wrote line ${r.line + 1}: ${line}\n${s?.plan ? summary(s.plan, path).split('\n')[0] : ''}\nversion: ${Math.round(after?.mtimeMs ?? 0)}`
219}
220
221// A commit landed in this session. Attach it to the items its message names, start what it names
222// that had not started, and when the work has moved on past an open item, tell the agent once.
223async function onCommit($: EngineInterface, sha: string, command: string): Promise<string[]> {
224  const s = await read($, snap)
225  if (!s?.plan) return []
226  const message = commitMessage(command) || command
227  const ids = stepRefs(message, s.plan)
228  if (!ids.length) return []
229  const at = await $.clock.now()
230  let hs = addCommit((await read($, hist)) ?? (await storedHistory($, s.path)), { sha: sha.slice(0, 7), at, items: ids, isWip: isWipMessage(message) })
231  await saveHistory($, s.path, hs)
232  const steps = new Map(s.plan.items.flatMap(st => [[st.id, st] as const, ...st.children.map(k => [k.id, k] as const)]))
233  const parentOf = new Map(s.plan.items.flatMap(st => st.children.map(k => [k.id, st] as const)))
234  for (const id of ids) {
235    const it = steps.get(id)
236    const parent = parentOf.get(id)
237    if (parent && parent.state === ' ') await write($, text => setItem(text, parent.id, '~'), undefined)
238    if (it && it.state === ' ') await write($, text => setItem(text, id, '~'), undefined)
239  }
240  const now = await read($, snap)
241  hs = (await read($, hist)) ?? hs
242  const fresh = now?.plan ? lags(now.plan, hs).filter(l => !l.isNudged) : []
243  if (!fresh.length) return []
244  await saveHistory($, s.path, { ...hs, nudged: [...hs.nudged, ...fresh.map(lagId)] })
245  return fresh.map(l => nudgeText(l, shown(s.path)))
246}
247
248async function closeLag($: EngineInterface, l: Lag): Promise<void> {
249  const r = await write($, text => setItem(text, l.item, 'x'), undefined)
250  $.ui.toast(r.startsWith('Wrote') ? `${l.item} marked done` : r)
251}
252
253async function dismissLag($: EngineInterface, l: Lag): Promise<void> {
254  const path = await read($, pinned)
255  const hs = await read($, hist)
256  if (!path || !hs) return
257  await saveHistory($, path, { ...hs, dismissed: [...hs.dismissed, lagId(l)] })
258}
259
260// ---------- the command and the tool ----------
261
262const PROGRESS = /^(?:plan-progress:)?progress$/
263const PROGRESS_LINE = /^\s*\/(?:plan-progress:)?progress(?:\s+([\s\S]*))?$/
264
265// The arguments of a /progress submission, or null when the prompt is anything else.
266function progressArgs(text: string, command: { name: string; args: string } | undefined): string | null {
267  if (command) return PROGRESS.test(command.name) ? command.args : null
268  const m = PROGRESS_LINE.exec(text)
269  return m ? (m[1] ?? '').trim() : null
270}
271
272// /progress [show | pin <file> | hide | unpin], and the tool's show, hide and unpin.
273async function runCommand($: EngineInterface, args: string): Promise<string> {
274  const [verb = '', ...rest] = args.trim().split(/\s+/)
275  if (verb === 'pin') return pin($, rest.join(' '))
276  if (verb === 'hide') {
277    await hide($)
278    return 'Plan progress hidden. /progress brings it back.'
279  }
280  if (verb === 'unpin') {
281    await update($, pinned, () => null)
282    await $.store.delete(pinKey())
283    await load($)
284    await $.ui.close({ id: PANE }).catch(() => undefined)
285    return 'No plan is pinned now.'
286  }
287  if (verb && verb !== 'show') return `/progress takes pin <file>, hide or unpin; "${verb}" is none of them.`
288  await show($)
289  const s = await read($, snap)
290  if (!s) return 'No plan is pinned. /progress pin path/to/plan.md pins one.'
291  return s.plan ? altOf(s.plan) : (s.error ?? 'No plan.')
292}
293
294async function runTool($: EngineInterface, input: Record<string, unknown>): Promise<string> {
295  const action = str(input.action) ?? 'read'
296  if (action === 'pin') return pin($, str(input.path) ?? '')
297  if (action === 'show' || action === 'hide' || action === 'unpin') return runCommand($, action)
298  const version = typeof input.version === 'number' ? input.version : undefined
299  if (action === 'read') {
300    await load($)
301    const s = await read($, snap)
302    if (!s) return 'No plan is pinned. Call this tool with action "pin" and the plan file\'s path.'
303    if (!s.plan) return s.error ?? 'No plan.'
304    const hs = (await read($, hist)) ?? emptyHistory()
305    const behind = lags(s.plan, hs).map(l => `${l.item} is still in progress, but the commits moved on to ${l.movedTo}.`)
306    return `${summary(s.plan, s.path)}${behind.length ? `\n\nThe plan looks behind: ${behind.join(' ')}` : ''}\n\nversion: ${Math.round(s.mtimeMs)}`
307  }
308  if (action === 'set') {
309    const item = str(input.item)
310    const state = STATE_OF[(str(input.state) ?? '').toLowerCase()]
311    if (!item) return 'Name the item: its id (2d), or a gate\'s title.'
312    if (state === undefined) return 'state is one of: todo, doing, done, waits, blocked, dropped.'
313    const note = str(input.note)
314    return write($, text => setItem(text, item, state, note), version)
315  }
316  if (action === 'add') {
317    const title = str(input.title)
318    if (!title) return 'A new item needs a title.'
319    const state = STATE_OF[(str(input.state) ?? 'todo').toLowerCase()] ?? ' '
320    return write($, text => addItem(text, { id: str(input.id), title, state, note: str(input.note), after: str(input.after), under: str(input.under) }), version)
321  }
322  return `No action "${action}". The actions are read, set, add, pin, show, hide and unpin.`
323}
324
325// What the agent is doing, in a few words, from one of its tool calls.
326function describe(tool: string, input: Record<string, unknown>): string {
327  const base = (p: unknown) => (typeof p === 'string' ? (p.split('/').pop() ?? p) : 'a file')
328  const said = typeof input.description === 'string' ? input.description.trim() : ''
329  if (tool === TOOL) return 'updating the plan'
330  if (tool === 'Bash') return said || 'running a command'
331  if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'Write' || tool === 'NotebookEdit') return `editing ${base(input.file_path ?? input.notebook_path)}`
332  if (tool === 'Read') return `reading ${base(input.file_path)}`
333  if (tool === 'Grep' || tool === 'Glob') return 'searching the code'
334  if (tool === 'Agent' || tool === 'Task') return said ? `with a subagent: ${said}` : 'with a subagent'
335  if (tool.startsWith('Web')) return 'reading the web'
336  if (tool.startsWith('mcp__')) return `using ${tool.split('__')[1] ?? tool}`
337  return `using ${tool}`
338}
339
340// ---------- drawing ----------
341
342type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Link'>
343
344const GLYPH: Record<ItemState, string> = { x: '●', '~': '◐', ' ': '○', '?': '◆', '!': '✕', '-': '–' }
345const toneOf = (st: ItemState, isNow: boolean): Tone =>
346  isNow ? 'now' : st === 'x' || st === '~' ? 'ink' : st === ' ' || st === '?' ? 'blue' : 'dim'
347const COLOR: Record<Tone, string> = { ink: 'text', dim: 'inactive', blue: '#4C8DFF', now: '#F2621B' }
348
349function noteEls(el: Els, notes: string[]) {
350  const { Link, Text } = el
351  return notes.map(note => {
352    const link = /^\[([^\]]+)\]\((https?:[^)\s]+)\)$/.exec(note.trim())
353    if (link && link[1] && link[2]) return <Link href={link[2]} label={link[1]} />
354    const isCode = /^(`[^`]+`\s*)+$/.test(note.trim()) || /^(#?\d+|[A-Z]+ #\d+|[0-9a-f]{7,40})$/.test(note.trim())
355    return isCode ? <Text mono dimColor wrap="wrap">{noteText(note)}</Text> : <Text dimColor wrap="wrap">{noteText(note)}</Text>
356  })
357}
358
359function rowOf(el: Els, it: Item, isNow: boolean, where: Where, width: number, extra?: string) {
360  const { Box, Text } = el
361  const tone = toneOf(it.state, isNow)
362  const id = it.isGate ? 'gate' : it.id
363  const idCells = Math.max(3, id.length)
364  // the desktop's dock is about 45 cells: a row's notes go under its title there
365  const isNarrow = width < 72
366  const hasNotes = it.notes.length > 0 || Boolean(extra)
367  const notes = (
368    <Box flexDirection="row" gap={1} flexWrap="wrap">
369      {noteEls(el, it.notes)}
370      {extra ? <Text dimColor>{extra}</Text> : null}
371    </Box>
372  )
373  return (
374    <Box key={`row-${it.line}`} flexDirection="row" gap={1} alignItems="flex-start">
375      <Text color={COLOR[tone]}>{isNow ? '◉' : GLYPH[it.state]}</Text>
376      <Box width={idCells} flexShrink={0}>
377        <Text mono dimColor={!isNow}>{id}</Text>
378      </Box>
379      <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
380        <Text weight={isNow ? 'semibold' : 'regular'} strikethrough={it.state === '-'} dimColor={it.state === '-'} wrap="wrap">
381          {bidi(it.title, where)}
382        </Text>
383        {hasNotes && isNarrow && notes}
384      </Box>
385      {hasNotes && !isNarrow && <Box flexShrink={0}>{notes}</Box>}
386    </Box>
387  )
388}
389
390// A section's label, and a quiet fact on its right.
391function caption(el: Els, text: string, right?: string) {
392  const { Box, Text } = el
393  return (
394    <Box key={`cap-${text}`} flexDirection="row" justifyContent="space-between" gap={1}>
395      <Text size="caption" weight="semibold" dimColor>
396        {text}
397      </Text>
398      {right ? (
399        <Text size="caption" dimColor>
400          {right}
401        </Text>
402      ) : null}
403    </Box>
404  )
405}
406
407type View = {
408  plan: Plan
409  hs: History
410  now: number
411  path: string
412  liveText: string | null
413  where: Where
414  width: number
415  isAll: boolean
416  surface: string
417}
418
419// Needs you now: the strong block, only when something blocks the work. The gates still ahead: one quiet line.
420function needsPart($: EngineInterface, el: Els, v: View) {
421  const { Box, Button, Text } = el
422  const w = waiting(v.plan)
423  const ahead = gatesAhead(v.plan)
424  if (w.length) {
425    return (
426      <Box key="needs" flexDirection="column" gap={0}>
427        {caption(el, `NEEDS YOU NOW · ${w.length}`)}
428        {w.map(it => (
429          <Box key={`need-${it.line}`} flexDirection="column">
430            <Box flexDirection="row" gap={1} alignItems="flex-start">
431              <Text color={COLOR.blue}>◆</Text>
432              <Text weight="semibold" wrap="wrap">
433                {bidi(it.title, v.where)}
434              </Text>
435            </Box>
436            <Box flexDirection="row" gap={1} flexWrap="wrap">
437              {noteEls(el, it.notes)}
438              <Button
439                key={`word-${it.line}`}
440                label={it.isGate ? 'Give the word' : 'Answer'}
441                onPress={() => {
442                  $.prompt.fill({ text: it.isGate ? `Go ahead with ${it.title}.` : `About ${label(it)}: ` }).catch(() => undefined)
443                }}
444              />
445            </Box>
446          </Box>
447        ))}
448      </Box>
449    )
450  }
451  if (!ahead.length) return null
452  const lastWork = v.plan.items.filter(i => !i.isGate).pop()
453  return (
454    <Text key="ahead" dimColor wrap="wrap">
455      {`◇ ${ahead.length} ${ahead.length === 1 ? 'gate' : 'gates'} ahead${lastWork ? `, after ${lastWork.id || 'the last step'}` : ''}: ${ahead.map(g => g.title).join(', ')}`}
456    </Text>
457  )
458}
459
460// The plan is behind: an open item the commits moved past. Inferred, with one press to close it.
461function lagPart($: EngineInterface, el: Els, v: View) {
462  const { Box, Button, Text } = el
463  const ls = lags(v.plan, v.hs).slice(0, 2)
464  if (!ls.length) return null
465  return (
466    <Box key="lag" flexDirection="column">
467      {caption(el, 'THE PLAN IS BEHIND', 'inferred')}
468      {ls.map(l => (
469        <Box key={`lag-${lagId(l)}`} flexDirection="column">
470          <Text wrap="wrap">{`${l.item} looks done: the commits moved on to ${l.movedTo} ${since(l.since, v.now)} ago.${l.isNudged ? ' Claude was told.' : ''}`}</Text>
471          <Box flexDirection="row" gap={1} flexWrap="wrap">
472            <Button key={`close-${lagId(l)}`} label={`Mark ${l.item} done`} variant="primary" onPress={() => closeLag($, l).catch(() => undefined)} />
473            <Button key={`keep-${lagId(l)}`} label="Not yet" onPress={() => dismissLag($, l).catch(() => undefined)} />
474          </Box>
475        </Box>
476      ))}
477    </Box>
478  )
479}
480
481function nowPart(el: Els, v: View) {
482  const { Box, Text } = el
483  const n = findNow(v.plan)
484  if (!n) {
485    return (
486      <Text key="all-done" size="heading" weight="semibold">
487        Every step is done.
488      </Text>
489    )
490  }
491  const s = n.step
492  const t = v.hs.items[s.id]
493  const kids = s.children.filter(k => k.state !== '-')
494  const share = kids.length ? ` · ${kids.filter(k => k.state === 'x').length} OF ${kids.length}` : ''
495  const started = t?.startedAt !== undefined ? `started ${since(t.startedAt, v.now)} ago` : undefined
496  const shas = t?.commits.length ? `${t.commits.length} ${t.commits.length === 1 ? 'commit' : 'commits'}, last ${t.commits[t.commits.length - 1]}` : ''
497  return (
498    <Box key="now" flexDirection="column">
499      {caption(el, `NOW${share}`, started)}
500      <Text size="heading" weight="semibold" wrap="wrap">
501        {`${s.id ? `${s.id} · ` : ''}${bidi(s.title, v.where)}`}
502      </Text>
503      {s.children
504        .filter(k => k.state !== 'x' || k === n.item)
505        .map(k => rowOf(el, k, k === n.item, v.where, v.width))}
506      {!s.children.length && s.notes.length > 0 && (
507        <Box flexDirection="row" gap={1} flexWrap="wrap">
508          {noteEls(el, s.notes)}
509        </Box>
510      )}
511      {shas ? (
512        <Text mono size="caption" dimColor>
513          {shas}
514        </Text>
515      ) : null}
516      {v.liveText ? (
517        <Box flexDirection="row" gap={1}>
518          <Text color={COLOR.now}>●</Text>
519          <Text dimColor wrap="wrap">{`Claude · ${v.liveText}`}</Text>
520        </Box>
521      ) : null}
522    </Box>
523  )
524}
525
526function nextPart(el: Els, v: View) {
527  const { Box } = el
528  const items = v.isAll ? v.plan.items.filter(i => !i.isGate) : upNext(v.plan, 3)
529  if (!items.length) return null
530  return (
531    <Box key="next" flexDirection="column">
532      {caption(el, v.isAll ? 'EVERY STEP' : 'NEXT')}
533      {items.map(it => rowOf(el, it, false, v.where, v.width))}
534    </Box>
535  )
536}
537
538function donePart(el: Els, v: View) {
539  const { Box, Text } = el
540  if (v.isAll) return null
541  const done = v.plan.items.filter(i => !i.isGate && fillOf(i) === 1)
542  if (!done.length) return null
543  const last = Math.max(0, ...done.map(i => v.hs.items[i.id]?.doneAt ?? 0))
544  return (
545    <Box key="done" flexDirection="column">
546      {caption(el, `DONE · ${done.length}`, last ? `last ${since(last, v.now)} ago` : undefined)}
547      <Text mono dimColor wrap="wrap">
548        {done.map(i => i.id || '·').join('  ')}
549      </Text>
550    </Box>
551  )
552}
553
554function pacePart(e: { surface: string }, el: Els, v: View, svg: ReturnType<typeof caption> | null) {
555  const { Box, Text } = el
556  const pc = pace(v.plan, v.hs)
557  if (!pc || pc.stepsLeft === 0) return null
558  const [lo, hi] = pc.perStep
559  const [l1, l2] = pc.left
560  const facts = `a step takes ${lo === hi ? `${lo}` : `${lo}-${hi}`} min · ${pc.stepsLeft} left: about ${l1 === l2 ? `${l1}` : `${l1}-${l2}`} h`
561  const bars = e.surface === 'terminal' ? <Text>{pc.minutes.slice(-8).map(m => '▁▂▃▄▅▆▇█'[Math.min(7, Math.round((7 * m.min) / Math.max(...pc.minutes.map(x => x.min))))]).join('')}</Text> : svg
562  return (
563    <Box key="pace" flexDirection="column">
564      {caption(el, 'PACE', 'from this plan’s history')}
565      <Box flexDirection="row" gap={1} alignItems="flex-end">
566        {bars}
567        <Text dimColor wrap="wrap">
568          {facts}
569        </Text>
570      </Box>
571    </Box>
572  )
573}
574
575function runsEl(el: Els, runs: { text: string; tone: Tone }[]) {
576  const { Box, Text } = el
577  return (
578    <Box flexDirection="row">
579      {runs.map(r => (
580        <Text color={COLOR[r.tone]}>{r.text}</Text>
581      ))}
582    </Box>
583  )
584}
585
586async function whereOf($: EngineInterface, surface: string): Promise<Where> {
587  if (surface !== 'terminal') return 'desktop'
588  return (await read($, env)).isTermLogical ? 'terminal-logical' : 'terminal'
589}
590
591export const register: Register = on => {
592  on('session.start', async ($, e, next) => {
593    root = e.cwd
594    await $.tool.register({
595      name: 'progress',
596      description:
597        'plan-progress: read and update the progress of the pinned plan, a plain Markdown block between <!-- plan-progress --> and <!-- /plan-progress --> whose lines are "- [state] id · title · note". ' +
598        'Call read when you pick up a build. Call set as you start a step or sub-step (state "doing") and as you finish it (state "done", with the commit or PR as its note), and "waits" for a decision or gate that needs the person. ' +
599        'Name the step in each commit message, e.g. "(step 3d)": the mod attaches the commit to it. ' +
600        'add puts a missing item after another (after) or as the last sub-step of one (under). pin names the plan file; show opens the pane beside the conversation, hide hides it and the band, unpin pins nothing. ' +
601        'A write changes only that item\'s line; pass the version read returned and it refuses if the file changed since.',
602      inputSchema: {
603        type: 'object',
604        properties: {
605          action: { type: 'string', enum: ['read', 'set', 'add', 'pin', 'show', 'hide', 'unpin'] },
606          item: { type: 'string', description: 'set: the item\'s id (e.g. "2d"), or a gate\'s title' },
607          state: { type: 'string', enum: ['todo', 'doing', 'done', 'waits', 'blocked', 'dropped'] },
608          note: { type: 'string', description: 'set/add: replaces the fields after the title, e.g. a commit `abc1234` or a PR link; "" removes them' },
609          id: { type: 'string', description: 'add: the new item\'s id' },
610          title: { type: 'string', description: 'add: the new item\'s title' },
611          after: { type: 'string', description: 'add: put it after this item, at its depth' },
612          under: { type: 'string', description: 'add: make it the last sub-step of this step' },
613          path: { type: 'string', description: 'pin: the Markdown file holding the block' },
614          version: { type: 'number', description: 'set/add: the version read returned; the write is refused if the file changed since' },
615        },
616        required: ['action'],
617      },
618    })
619    const version = await $.session.version().catch(() => null)
620    const term = (await $.env.get('TERM_PROGRAM')) ?? ''
621    const wt = (await $.env.get('WT_SESSION')) ?? ''
622    const os = (await $.env.get('OS')) ?? ''
623    const home = (await $.env.get('HOME')) ?? ''
624    const opener = os === 'Windows_NT' ? 'explorer' : home.startsWith('/Users/') ? 'open' : 'xdg-open'
625    const known: Env = { engine: version?.base ?? '', isTermLogical: term === 'vscode' || wt !== '', opener }
626    await update($, env, () => known)
627    const kept = await $.store.get(pinKey())
628    const wasHidden = (await $.store.get(hideKey())) === true
629    if (typeof kept === 'string' && (await read($, pinned)) === null) await update($, pinned, () => kept)
630    await update($, isHidden, () => wasHidden)
631    await load($)
632    $.clock.every(POLL_MS, () => {
633      poll($).catch(() => undefined)
634    })
635    const s = await read($, snap)
636    if (s?.plan && !wasHidden) $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
637    return next(e)
638  })
639
640  // /progress: a command file (commands/progress.md) so every surface lists it, answered here
641  // without a model turn. Either spelling, /progress or /plan-progress:progress.
642  on('command.run', { command: /^(?:plan-progress:)?progress$/ }, async ($, e) => ({ text: await runCommand($, e.args) })).catch(
643    () => ({ text: 'plan-progress could not answer /progress. Ask Claude to use the plan-progress tool instead.' }),
644  )
645
646  // Where a command file arrives as a prompt (headless, the desktop app), answer it here and drop it, so
647  // no turn runs; the drop's text is what the person reads.
648  on('prompt.submit', async ($, e, next) => {
649    const asked = progressArgs(e.text, e.command)
650    if (asked === null) return next(e)
651    return { drop: await runCommand($, asked) }
652  }).catch(($, e, next) => next(e))
653
654  // The agent's tool; what the agent is doing (the live line); an edit to the pinned file shown at
655  // once; and a commit that names a step, attached to it. One hook: the session's tool union does
656  // not name a plugin's own tool at type time.
657  on('tool.call', async ($, e, next) => {
658    const tool: string = e.tool
659    const input = e as unknown as Record<string, unknown>
660    if (e.agentId === undefined) {
661      const at = await $.clock.now()
662      await update($, live, () => ({ text: describe(tool, input), at })).catch(() => undefined)
663    }
664    if (tool === TOOL) return answer(await runTool($, input))
665    const ran = await next(e)
666    if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') {
667      const path = await read($, pinned)
668      const target = input.file_path
669      if (path && typeof target === 'string' && (target === path || path.endsWith(`/${target.replace(/^\.\//, '')}`))) await load($).catch(() => undefined)
670      return ran
671    }
672    if (tool !== 'Bash' || !('result' in ran) || ran.result === undefined) return ran
673    const sha = (ran.result as { gitOperation?: { commit?: { sha?: string } } }).gitOperation?.commit?.sha
674    if (!sha) return ran
675    const told = await onCommit($, sha, typeof input.command === 'string' ? input.command : '').catch(() => [] as string[])
676    return told.length ? { ...ran, context: [...(ran.context ?? []), ...told] } : ran
677  }).catch(($, e, next) => {
678    const tool: string = e.tool
679    return tool === TOOL ? answer('plan-progress failed before writing anything. Read the plan again and retry.') : next(e)
680  })
681
682  // The turn ended: no live line until the agent works again.
683  on('turn.complete', async ($, e, next) => {
684    if (e.agentId === undefined) await update($, live, () => null)
685    return next(e)
686  }).catch(($, e, next) => next(e))
687
688  // The band: where the build is, in one quiet line, and a second only when something needs you.
689  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
690    const s = await read($, snap)
691    if (e.props.hasSurvey || !s?.plan || (await read($, isHidden))) return next(e)
692    const plan = s.plan
693    const n = findNow(plan)
694    const done = count(plan)
695    const hs = (await read($, hist)) ?? emptyHistory()
696    const clock = await $.clock.now()
697    const where = await whereOf($, e.surface)
698    const t = n ? hs.items[n.step.id] : undefined
699    const lead = n ? `${done.done}/${done.total} done` : 'Done'
700    const rest = n ? ` · ${n.step.id ? `${n.step.id} ` : ''}${bidi(n.step.title, where)}${t?.startedAt !== undefined ? ` · ${since(t.startedAt, clock)}` : ''}` : ` · every step of ${bidi(plan.title || 'the plan', where)}`
701    const need = waiting(plan)[0]
702    const lag = lags(plan, hs)[0]
703    const below = await next(e)
704    const openPane = () => {
705      show($).catch(() => undefined)
706    }
707    const els = $.ui.resolve(e)
708    const { Box, Button, Text } = els
709    const extra = (
710      <Box flexDirection="column">
711        {need ? (
712          <Box flexDirection="row" gap={1}>
713            <Text color={COLOR.blue}>◆</Text>
714            <Text weight="semibold" wrap="truncate-end">{`${bidi(need.title, where)} ${need.isGate ? 'needs your word' : 'needs you'}`}</Text>
715          </Box>
716        ) : null}
717        {lag ? <Text dimColor wrap="truncate-end">{`⟳ the plan is behind: ${lag.item} looks done (work moved to ${lag.movedTo})`}</Text> : null}
718      </Box>
719    )
720    if (e.surface === 'desktop') {
721      const { Svg } = $.ui.resolve(e)
722      return (
723        <Box flexDirection="column">
724          <Box flexDirection="row" gap="sm" alignItems="center">
725            <Svg source={bandSvg(plan)} alt={altOf(plan)} width={120} height={12} />
726            <Button key="open" plain onPress={openPane}>
727              <Text weight="semibold">{lead}</Text>
728              <Text dimColor>{rest}</Text>
729            </Button>
730          </Box>
731          {extra}
732          {below}
733        </Box>
734      )
735    }
736    const barCells = Math.max(8, Math.min(30, Math.floor(e.props.bodyColumns / 4)))
737    return (
738      <Box flexDirection="column">
739        <Box flexDirection="row" gap={1}>
740          {runsEl(els, barRuns(plan, barCells))}
741          <Text weight="semibold">{lead}</Text>
742          <Box flexShrink={1}>
743            <Text wrap="truncate-end" dimColor>
744              {rest}
745            </Text>
746          </Box>
747        </Box>
748        {extra}
749        {below}
750      </Box>
751    )
752  })
753
754  // The pane: the brief. Where the build is, what needs you, what is happening now, what comes next.
755  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
756    const s = await read($, snap)
757    const els = $.ui.resolve(e)
758    const { Box, Button, Link, Text } = els
759    const el: Els = { Box, Button, Link, Text }
760    const Input = 'Input' in els ? els.Input : null
761    const isAll = await read($, showAll)
762    const pinningNow = await read($, isPinning)
763    const m = await read($, moment)
764    const hs = (await read($, hist)) ?? emptyHistory()
765    const lv = await read($, live)
766    await read($, minute)
767    const clock = await $.clock.now()
768    const cols = e.props.bodyColumns
769    const where = await whereOf($, e.surface)
770
771    const pinBox = pinningNow && Input ? (
772      <Input
773        key="pin-path"
774        label="Plan file"
775        placeholder="path/to/plan.md"
776        value={s?.path ?? ''}
777        submitLabel="pin"
778        autoFocus
779        onSubmit={(value: string) => {
780          pin($, value)
781            .then(msg => $.ui.toast(msg))
782            .catch(() => undefined)
783        }}
784      />
785    ) : null
786    const buttons = (
787      <Box flexDirection="row" gap="sm" flexWrap="wrap">
788        {s?.plan && (
789          <Button key="all" label={isAll ? 'Fewer steps' : 'Every step'} onPress={() => update($, showAll, v => !v).catch(() => undefined)} />
790        )}
791        <Button key="hide" label="Hide" onPress={() => hide($).catch(() => undefined)} />
792        <Button key="pin" label={pinningNow ? 'Cancel' : 'Pin another plan'} onPress={() => update($, isPinning, v => !v).catch(() => undefined)} />
793        {s && e.surface === 'terminal' && <Link href={`file://${encodeURI(s.path)}`} label="Open the file" />}
794        {s && e.surface !== 'terminal' && <Button key="open-file" label="Open the file" onPress={() => openFile($, s.path).catch(() => undefined)} />}
795      </Box>
796    )
797
798    if (!s || !s.plan) {
799      return (
800        <Box flexDirection="column" gap="md">
801          <Text size="heading" weight="semibold">
802            {s ? 'This file has no plan' : 'No plan is pinned'}
803          </Text>
804          <Text dimColor>{s?.error ?? 'Pin a Markdown file that holds a <!-- plan-progress --> block: /progress pin path/to/plan.md, or the button below.'}</Text>
805          {pinBox}
806          {buttons}
807        </Box>
808      )
809    }
810    const plan = s.plan
811    const v: View = {
812      plan,
813      hs,
814      now: clock,
815      path: s.path,
816      liveText: lv && clock - lv.at < LIVE_MS ? lv.text : null,
817      where,
818      width: cols,
819      isAll,
820      surface: e.surface,
821    }
822    const meta = (
823      <Box key="meta" flexDirection="column">
824        <Text mono size="caption" dimColor wrap="wrap">
825          {shown(s.path)}
826        </Text>
827        <Text size="caption" dimColor>{`changed ${since(s.mtimeMs, clock)}${since(s.mtimeMs, clock) === 'just now' ? '' : ' ago'}`}</Text>
828      </Box>
829    )
830    const n = findNow(plan)
831    const pc = pace(plan, hs)
832
833    if (e.surface === 'desktop') {
834      const { Svg } = $.ui.resolve(e)
835      const width = Math.max(180, Math.min(1100, Math.round(cols * 7)))
836      const isMoment = m !== null && m.until > clock
837      const svg = briefSvg(plan, {
838        width,
839        settleLine: isMoment && m?.kind === 'step' ? Number(m.id) : undefined,
840        isWipe: isMoment && m?.kind === 'plan',
841      })
842      const paceGraph = pc ? <Svg source={paceSvg(pc.minutes, n?.step.id ?? null)} alt={`Minutes per step: ${pc.minutes.map(x => `${x.id} ${x.min}`).join(', ')}`} isInteractive /> : null
843      return (
844        <Box flexDirection="column" gap="md">
845          <Svg source={svg} alt={`${altOf(plan)} ${COUNT_RULE}`} isInteractive />
846          {lagPart($, el, v)}
847          {needsPart($, el, v)}
848          {nowPart(el, v)}
849          {nextPart(el, v)}
850          {donePart(el, v)}
851          {pacePart(e, el, v, paceGraph)}
852          {pinBox}
853          {meta}
854          {buttons}
855        </Box>
856      )
857    }
858
859    // The terminal: the same brief in characters.
860    const c = count(plan)
861    return (
862      <Box flexDirection="column" gap={1}>
863        <Box flexDirection="column">
864          <Text dimColor wrap="truncate-end">
865            {bidi((plan.title || 'Plan').toUpperCase(), where)}
866          </Text>
867          <Box flexDirection="row" justifyContent="space-between">
868            <Text weight="bold">{n ? `${c.done} of ${c.total} done` : `Done · ${c.total} of ${c.total}`}</Text>
869            <Text dimColor>{`${c.pct}%`}</Text>
870          </Box>
871          {runsEl(el, barRuns(plan, Math.max(12, cols - 2)))}
872        </Box>
873        {lagPart($, el, v)}
874        {needsPart($, el, v)}
875        {nowPart(el, v)}
876        {nextPart(el, v)}
877        {donePart(el, v)}
878        {pacePart(e, el, v, null)}
879        {pinBox}
880        {meta}
881        {buttons}
882      </Box>
883    )
884  })
885}
886
hooks/lib/bidi.ts 65 lines
1// Right-to-left text per surface. The ordering is rtl-kit's (bar-mods/rtl-kit 0.1.3, hooks/rtl.tsx),
2// cut down to what plan-progress draws: titles that may hold Hebrew.
3//
4// - desktop: a line laid out left to right reads a Hebrew-first line wrong, so such a line is
5//   wrapped in RLE…PDF; a Latin-first line with a Hebrew word in it is left alone and reads right.
6// - terminal, macOS: characters are printed in stored order, so Hebrew is handed over in visual
7//   order. Where Claude Code reorders itself (VS Code's terminal, Windows Terminal) it is left in
8//   logical order, or it would be reversed twice.
9// - svg: the frame does bidi; a Hebrew-first line gets direction="rtl".
10
11const RTL_CHAR = /[֐-׿؀-ۿݐ-ݿࢠ-ࣿיִ-﷿ﹰ-]/
12const LATIN_CHAR = /[A-Za-zÀ-ɏͰ-ϿЀ-ӿ]/
13const DIGIT = /[0-9٠-٩۰-۹]/
14const MARKS = /[‎‏‪-‮⁦-⁩]/g
15const MIRROR: Record<string, string> = { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<', '«': '»', '»': '«' }
16
17export const hasRtl = (s: string) => RTL_CHAR.test(s)
18
19// True when the first letter is right-to-left.
20export function isRtlFirst(s: string): boolean {
21  const first = Array.from(s).find(c => RTL_CHAR.test(c) || LATIN_CHAR.test(c))
22  return first !== undefined && RTL_CHAR.test(first)
23}
24
25type Dir = 'R' | 'L' | 'N'
26const strong = (ch: string): Dir => (RTL_CHAR.test(ch) ? 'R' : LATIN_CHAR.test(ch) || DIGIT.test(ch) ? 'L' : 'N')
27
28// One line in the order a terminal that does no bidi must print it.
29export function visual(line: string): string {
30  const chars = Array.from(line.replace(MARKS, ''))
31  if (!chars.some(c => RTL_CHAR.test(c))) return chars.join('')
32  const dirs = chars.map(strong)
33  const base: Dir = isRtlFirst(line) ? 'R' : 'L'
34  const resolved = dirs.map((d, i) => {
35    if (d !== 'N') return d
36    let p = i - 1
37    while (p >= 0 && dirs[p] === 'N') p--
38    let q = i + 1
39    while (q < dirs.length && dirs[q] === 'N') q++
40    const before = p >= 0 ? dirs[p] : null
41    const after = q < dirs.length ? dirs[q] : null
42    if (before === after && before !== null) return before
43    return base
44  })
45  const runs: { dir: Dir; text: string[] }[] = []
46  chars.forEach((c, i) => {
47    const d = resolved[i] ?? base
48    const last = runs[runs.length - 1]
49    if (last && last.dir === d) last.text.push(c)
50    else runs.push({ dir: d, text: [c] })
51  })
52  const flip = (r: { dir: Dir; text: string[] }) =>
53    r.dir === 'R' ? [...r.text].reverse().map(c => MIRROR[c] ?? c).join('') : r.text.join('')
54  return (base === 'R' ? [...runs].reverse() : runs).map(flip).join('')
55}
56
57export type Where = 'desktop' | 'terminal' | 'terminal-logical'
58
59export function bidi(s: string, where: Where): string {
60  if (!hasRtl(s)) return s
61  if (where === 'terminal') return s.split('\n').map(visual).join('\n')
62  if (where === 'terminal-logical') return s
63  return isRtlFirst(s) ? `‫${s.replace(MARKS, '')}‬` : s
64}
65
hooks/lib/draw.ts 245 lines
1// The pictures, drawn as SVG for the desktop: the brief's masthead (where the build is, and the
2// strip under it), the band's strip, and the pace. Pure: a plan and a width in, markup out.
3//
4// The strip tells the Bar Builds mark's story, from idea to product: a step not started is a
5// drawing (a blue outline), a step under way is halftone, a step done is solid ink. Orange marks
6// one thing, the item being worked on now. A gate is a diamond where it falls: an outline while
7// it is ahead, filled blue once it is due and waits on you.
8
9import type { Item, Plan } from '../../types'
10import { isRtlFirst } from './bidi'
11import { DISPLAY, FONT_FACES, MONO } from './fonts'
12import { COUNT_RULE, count, fillOf, findNow, gateTiers, label, STATE_WORDS, waiting } from './grammar'
13
14// Light and dark inks; the SVG picks by the frame's colour scheme.
15const LIGHT = { ink: '#1E1D1B', ink2: '#5E5B55', blue: '#1F5FAE', orange: '#F2621B' }
16const DARK = { ink: '#ECE8DD', ink2: '#B3B0A8', blue: '#6FAEF2', orange: '#F2621B' }
17
18const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
19const n = (v: number) => (Math.round(v * 10) / 10).toString()
20export const noteText = (s: string) => s.replace(/`([^`]*)`/g, '$1').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
21
22// Words into at most `max` lines of `px`; the last line is cut with an ellipsis if the rest won't fit.
23export function wrapLines(s: string, px: number, size: number, max: number, k = 0.55): string[] {
24  const room = Math.max(1, Math.floor(px / (size * k)))
25  const out: string[] = []
26  let line = ''
27  const words = s.split(/\s+/).filter(Boolean)
28  for (let i = 0; i < words.length; i++) {
29    const w = words[i] ?? ''
30    const next = line ? `${line} ${w}` : w
31    if (Array.from(next).length <= room || !line) {
32      line = next
33      continue
34    }
35    if (out.length === max - 1) {
36      line = `${line} ${words.slice(i).join(' ')}`
37      break
38    }
39    out.push(line)
40    line = w
41  }
42  if (line) out.push(Array.from(line).length > room ? `${Array.from(line).slice(0, room - 1).join('')}…` : line)
43  return out.slice(0, max)
44}
45
46function style(motion: boolean, fonts = true): string {
47  const set = (p: typeof LIGHT) =>
48    `.i{fill:${p.ink}}.i2{fill:${p.ink2}}.b{fill:${p.blue}}.o{fill:${p.orange}}` +
49    `.si{stroke:${p.ink}}.si2{stroke:${p.ink2}}.sb{stroke:${p.blue}}.so{stroke:${p.orange}}`
50  const anim = motion
51    ? '.pulse{animation:pp-pulse 2.4s ease-in-out infinite}' +
52      '@keyframes pp-pulse{0%,100%{opacity:1}50%{opacity:.45}}' +
53      '.settle{animation:pp-settle 1.2s cubic-bezier(.3,0,.2,1) both;transform-box:fill-box;transform-origin:left}' +
54      '@keyframes pp-settle{from{opacity:.25;transform:scaleX(.4)}to{opacity:1;transform:none}}' +
55      '.wipe{animation:pp-wipe 1.4s cubic-bezier(.6,0,.2,1) both}' +
56      '@keyframes pp-wipe{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}' +
57      '@media (prefers-reduced-motion:reduce){.pulse,.settle,.wipe{animation:none}}'
58    : ''
59  return (
60    `<style>${fonts ? FONT_FACES : ''}` +
61    `.d{font-family:${DISPLAY};font-weight:850}.m{font-family:${MONO}}.n{fill:none}` +
62    set(LIGHT) +
63    `@media (prefers-color-scheme:dark){${set(DARK)}}${anim}</style>`
64  )
65}
66
67// A staggered dot screen; dots grow toward the done side only.
68let clipSeq = 0
69function halftone(x: number, y: number, w: number, h: number, f: number, step = 3.4): string {
70  const id = `pp${++clipSeq}`
71  let dots = ''
72  let row = 0
73  for (let cy = y + step / 2; cy < y + h + step; cy += step * 0.866, row++) {
74    for (let cx = x + (row % 2 ? step / 2 : 0); cx < x + w + step; cx += step) {
75      const t = (cx - x) / w
76      const k = Math.max(0, Math.min(1, (f + 0.24 - t) / 0.48))
77      const r = step * 0.58 * Math.sqrt(k)
78      if (r > 0.3) dots += `<circle cx="${n(cx)}" cy="${n(cy)}" r="${r.toFixed(2)}"/>`
79    }
80  }
81  return `<clipPath id="${id}"><rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}"/></clipPath><g clip-path="url(#${id})" class="i">${dots}</g>`
82}
83
84const diamond = (cx: number, cy: number, r: number, cls: string) =>
85  `<polygon points="${n(cx)},${n(cy - r)} ${n(cx + r)},${n(cy)} ${n(cx)},${n(cy + r)} ${n(cx - r)},${n(cy)}" class="${cls}"/>`
86
87function tip(it: Item, isDue?: boolean): string {
88  const kids = it.children.filter(k => k.state !== '-')
89  const share = kids.length ? `${kids.filter(k => k.state === 'x').length} of ${kids.length} sub-steps done` : ''
90  const word = it.isGate ? (it.state === 'x' ? 'done' : isDue ? 'due: waits on you' : 'ahead') : STATE_WORDS[it.state]
91  const parts = [it.isGate ? `Gate · ${it.title}` : label(it), word, share, ...it.notes.map(noteText)]
92  return `<title>${esc(parts.filter(Boolean).join(' · '))}</title>`
93}
94
95type Seg = { it: Item; x: number; w: number; cx: number; isGate: boolean }
96type Strip = { svg: string; segs: Seg[]; front: number | null }
97
98// The strip: one segment per step, a diamond per gate where it falls.
99function strip(plan: Plan, y: number, W: number, h: number, o: { gap: number; gw: number; step: number; tips: boolean; settle?: string }): Strip {
100  const gates = gateTiers(plan)
101  const work = plan.items.filter(i => !i.isGate)
102  const lead = gates.length ? 5 : 0
103  // every segment is followed by a gap; the last one's becomes the lead-in to the gates
104  const gaps = gates.length ? work.length * o.gap : Math.max(0, work.length - 1) * o.gap
105  const sw = Math.max(2, (W - gates.length * o.gw - gaps - lead) / Math.max(1, work.length))
106  const now = findNow(plan)
107  let x = 0
108  let svg = ''
109  let front: number | null = null
110  const segs: Seg[] = []
111  let prevWasWork = false
112  for (const it of plan.items) {
113    if (it.isGate) {
114      const g = gates.find(z => z.item === it)
115      const cx = x + (prevWasWork ? lead : 0) + o.gw / 2
116      x = cx + o.gw / 2
117      prevWasWork = false
118      const cls = it.state === 'x' ? 'i' : it.state === '-' ? 'n si2' : g?.isDue ? 'b' : 'n sb'
119      svg += `<g>${o.tips ? tip(it, g?.isDue) : ''}${diamond(cx, y + h / 2, Math.min(h / 2, o.gw / 2) - 1, cls)}</g>`
120      segs.push({ it, x: cx - o.gw / 2, w: o.gw, cx, isGate: true })
121      continue
122    }
123    prevWasWork = true
124    let g = ''
125    const f = fillOf(it)
126    const rect = (cls: string, extra = '') => `<rect x="${n(x + 0.5)}" y="${n(y + 0.5)}" width="${n(sw - 1)}" height="${n(h - 1)}" class="${cls}"${extra}/>`
127    if (f === 1) {
128      g = `<rect x="${n(x)}" y="${n(y)}" width="${n(sw)}" height="${n(h)}" class="${o.settle === it.line.toString() ? 'i settle' : 'i'}"/>`
129    } else if (now && it === now.step) {
130      const ff = Math.max(f, 0.15)
131      g = halftone(x, y, sw, h, ff, o.step) + rect('n si', ' stroke-width="1"')
132      front = x + sw * ff
133    } else if (it.state === '~') {
134      g = halftone(x, y, sw, h, Math.max(f, 0.12), o.step) + rect('n si', ' stroke-width="1"')
135    } else if (it.state === '-') {
136      g = rect('n si2', ' stroke-dasharray="2 2" opacity=".5"')
137    } else if (it.state === '!') {
138      g = rect('n si2') + `<path d="M${n(x)} ${n(y + h)} L${n(x + sw)} ${n(y)}" class="si2"/>`
139    } else if (it.state === '?') {
140      g = rect('b sb')
141    } else {
142      g = (f > 0 ? halftone(x, y, sw, h, f, o.step) : '') + rect('n sb', ' stroke-width="1"')
143    }
144    svg += `<g>${o.tips ? tip(it) : ''}${g}</g>`
145    segs.push({ it, x, w: sw, cx: x + sw / 2, isGate: false })
146    x += sw + o.gap
147  }
148  return { svg, segs, front }
149}
150
151// A line of text that reads right whichever script it opens with.
152function text(x: number, y: number, s: string, cls: string, size: number, anchor: 'start' | 'middle' | 'end', extra = ''): string {
153  const isRtl = isRtlFirst(s)
154  const a = isRtl && anchor !== 'middle' ? (anchor === 'start' ? 'end' : 'start') : anchor
155  return `<text x="${n(x)}" y="${n(y)}" class="${cls}" font-size="${size}" text-anchor="${a}"${isRtl ? ' direction="rtl"' : ''}${extra}>${esc(s)}</text>`
156}
157
158export type BriefOptions = {
159  // nominal width in CSS pixels; the surface scales the drawing to its slot
160  width: number
161  // the line of a step that has just turned done, to settle it in
162  settleLine?: number
163  // the whole plan has just turned done
164  isWipe?: boolean
165  motion?: boolean
166}
167
168// The brief's masthead: the plan's name, where the build is ("Step 6 of 15"), and the strip.
169export function briefSvg(plan: Plan, o: BriefOptions): string {
170  clipSeq = 0
171  const W = Math.round(o.width)
172  const c = count(plan)
173  const now = findNow(plan)
174  const motion = o.motion !== false
175  let s = style(motion)
176  const kicker = wrapLines((plan.title || 'Plan').toUpperCase(), W - 50, 10, 2, 0.62)
177  kicker.forEach((line, i) => {
178    s += text(0, 11 + i * 13, line, 'm i2', 10, 'start', ' letter-spacing=".8"')
179  })
180  const hy = 11 + (kicker.length - 1) * 13 + 34
181  // how many are done leads: steps finish out of order, so a position ("step 9") can mislead
182  const head = now
183    ? `<text x="0" y="${hy}" class="d i" font-size="31" letter-spacing="-1">${c.done}<tspan class="i2" font-size="20" dx="6" letter-spacing="-.4">of ${c.total} done</tspan></text>`
184    : `<text x="0" y="${hy}" class="d i" font-size="31" letter-spacing="-1">Done<tspan class="i2" font-size="20" dx="6">${c.total} of ${c.total}</tspan></text>`
185  s += head
186  s += `<g><title>${esc(COUNT_RULE)}</title><text x="${W}" y="${hy}" text-anchor="end" class="m i2" font-size="12">${c.pct}%</text></g>`
187  const Y = hy + 12
188  const H = 14
189  const b = strip(plan, Y, W, H, { gap: 2, gw: 14, step: 2.6, tips: true, settle: o.settleLine?.toString() })
190  s += `<g${o.isWipe && motion ? ' class="wipe"' : ''}>${b.svg}</g>`
191  // ids under the segments, shrunk to fit, left out under 6.5px
192  for (const g of b.segs) {
193    if (g.isGate || !g.it.id) continue
194    const size = Math.min(9, (g.w - 1) / (g.it.id.length * 0.62))
195    if (size < 6.5) continue
196    const isLit = fillOf(g.it) === 1 || g.it.state === '~' || (now && g.it === now.step)
197    const bold = now && g.it === now.step ? ' font-weight="700"' : ''
198    s += `<text x="${n(g.cx)}" y="${Y + H + 11}" text-anchor="middle" class="m ${isLit ? 'i' : g.it.state === '-' ? 'i2' : 'b'}" font-size="${n(size)}"${bold}>${esc(g.it.id)}</text>`
199  }
200  if (now && b.front !== null) {
201    s += `<g class="${motion ? 'pulse' : ''}"><path d="M${n(b.front)} ${Y - 5}V${Y + H + 3}" class="so" stroke-width="2"/></g>`
202  } else if (c.pct === 100) {
203    const last = b.segs.filter(g => !g.isGate).pop()
204    if (last) s += `<path d="M${n(last.x + last.w)} ${Y - 5}V${Y + H + 3}" class="so" stroke-width="2"/>`
205  }
206  const HEIGHT = Y + H + 15
207  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${HEIGHT}" viewBox="0 0 ${W} ${HEIGHT}">${s}</svg>`
208}
209
210// The band's slim strip: no text, no motion.
211export function bandSvg(plan: Plan, width = 120, height = 12): string {
212  clipSeq = 0
213  const b = strip(plan, 1, width, height - 2, { gap: 1.5, gw: 8, step: 2, tips: false })
214  let s = style(false, false) + b.svg
215  if (b.front !== null) s += `<path d="M${n(b.front)} 0V${height}" class="so" stroke-width="1.6"/>`
216  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${s}</svg>`
217}
218
219// The pace: one ink bar per measured step, as tall as its minutes; the step now as an orange outline.
220export function paceSvg(minutes: { id: string; min: number }[], nowId: string | null): string {
221  const bars = minutes.slice(-8)
222  const max = Math.max(...bars.map(b => b.min), 1)
223  const bw = 12
224  const gap = 4
225  let s = style(false, false)
226  bars.forEach((b, i) => {
227    const h = 3 + (20 * b.min) / max
228    s += `<g><title>${esc(`Step ${b.id}: ${b.min} min`)}</title><rect x="${i * (bw + gap)}" y="${n(23 - h)}" width="${bw}" height="${n(h)}" class="i"/></g>`
229  })
230  let w = bars.length * (bw + gap)
231  if (nowId) {
232    s += `<g><title>${esc(`Step ${nowId}: under way`)}</title><rect x="${w + 0.5}" y="7.5" width="${bw - 1}" height="15" class="n so" stroke-dasharray="2 2"/></g>`
233    w += bw
234  }
235  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="24" viewBox="0 0 ${w} 24">${s}</svg>`
236}
237
238// What the pictures say, for a reader that cannot see them.
239export function altOf(plan: Plan): string {
240  const c = count(plan)
241  const now = findNow(plan)
242  const w = waiting(plan).length
243  return `${plan.title || 'Plan'}: ${c.done} of ${c.total} done (${c.pct}%)${now ? `, now ${label(now.item)}` : ', every step done'}${w ? `, ${w} waiting on you` : ''}.`
244}
245
hooks/lib/grammar.ts 299 lines
1// The plan-progress block: reading it, counting it, and changing one line of it.
2// Pure: no `$`, type-only imports, so a node script can run it unchanged.
3//
4// <!-- plan-progress title="Version 3.1" -->
5// - [x] 1 · Days in a template · `5d18be5`
6// - [~] 2 · Exceptions
7//   - [x] 2a · Tables, codec, rules
8//   - [~] 2b · The editor's draft
9// - [?] gate · The merge · Bar's word
10// <!-- /plan-progress -->
11
12import type { Item, ItemState, Plan } from '../../types'
13
14const OPEN = /^\s*<!--\s*plan-progress(?:\s+title="([^"]*)")?\s*-->\s*$/
15const CLOSE = /^\s*<!--\s*\/plan-progress\s*-->\s*$/
16const ITEM = /^( *)[-*] \[([ xX~?!-])\] (.*)$/
17const SEP = /\s+·\s+/g
18export const STATES: readonly ItemState[] = [' ', 'x', '~', '?', '!', '-']
19export const STATE_WORDS: Record<ItemState, string> = {
20  ' ': 'to do',
21  x: 'done',
22  '~': 'in progress',
23  '?': 'waits on you',
24  '!': 'blocked',
25  '-': 'dropped',
26}
27export const COUNT_RULE =
28  'Each step weighs one. A step with sub-steps fills by the share of them done. Gates and dropped items are not counted.'
29
30// The file's lines with any '\r' kept on each, so joining with '\n' gives the bytes back.
31const linesOf = (text: string) => text.split('\n')
32const bare = (line: string) => (line.endsWith('\r') ? line.slice(0, -1) : line)
33
34export type Block = { title: string; open: number; close: number }
35
36export function findBlock(text: string): Block | null {
37  const lines = linesOf(text)
38  for (let i = 0; i < lines.length; i++) {
39    const m = OPEN.exec(bare(lines[i] ?? ''))
40    if (!m) continue
41    for (let j = i + 1; j < lines.length; j++) {
42      if (CLOSE.test(bare(lines[j] ?? ''))) return { title: m[1] ?? '', open: i, close: j }
43    }
44    return null
45  }
46  return null
47}
48
49function fieldsOf(content: string): string[] {
50  return content.split(SEP).map(f => f.trim())
51}
52
53export function parse(text: string): Plan | null {
54  const block = findBlock(text)
55  if (!block) return null
56  const lines = linesOf(text)
57  const items: Item[] = []
58  for (let i = block.open + 1; i < block.close; i++) {
59    const m = ITEM.exec(bare(lines[i] ?? ''))
60    if (!m) continue
61    const indent = (m[1] ?? '').length
62    const raw = m[2] ?? ' '
63    const state: ItemState = raw === 'X' ? 'x' : (raw as ItemState)
64    const fields = fieldsOf(m[3] ?? '')
65    const one = fields.length === 1
66    const id = one ? '' : (fields[0] ?? '')
67    const item: Item = {
68      state,
69      id,
70      title: one ? (fields[0] ?? '') : (fields[1] ?? ''),
71      notes: one ? [] : fields.slice(2),
72      isGate: id.toLowerCase() === 'gate',
73      line: i,
74      children: [],
75    }
76    const parent = items[items.length - 1]
77    if (indent >= 2 && parent) parent.children.push(item)
78    else items.push(item)
79  }
80  return { title: block.title, items }
81}
82
83// ---------- counting ----------
84
85const isLive = (it: Item) => it.state !== '-'
86
87export function fillOf(it: Item): number {
88  if (it.state === 'x') return 1
89  const kids = it.children.filter(isLive)
90  if (kids.length === 0) return 0
91  return kids.filter(k => k.state === 'x').length / kids.length
92}
93
94export type Count = { pct: number; sum: number; done: number; total: number }
95
96export function count(plan: Plan): Count {
97  const work = plan.items.filter(i => !i.isGate && isLive(i))
98  const sum = work.reduce((a, i) => a + fillOf(i), 0)
99  const done = work.filter(i => fillOf(i) === 1).length
100  // floor, so 100% means every step is done
101  const pct = work.length === 0 ? 0 : Math.floor((100 * sum) / work.length + 1e-9)
102  return { pct, sum, done, total: work.length }
103}
104
105export type Now = { item: Item; step: Item }
106
107// The deepest item in progress; with none, the first item not done.
108export function findNow(plan: Plan): Now | null {
109  for (const step of plan.items) {
110    const kid = step.children.find(k => k.state === '~')
111    if (kid) return { item: kid, step }
112    if (step.state === '~') return { item: step, step }
113  }
114  for (const step of plan.items) {
115    if (step.isGate || !isLive(step) || fillOf(step) === 1) continue
116    const kid = step.children.find(k => isLive(k) && k.state !== 'x')
117    return { item: kid ?? step, step }
118  }
119  return null
120}
121
122const everyItem = (plan: Plan) => plan.items.flatMap(i => [i, ...i.children])
123export const blocked = (plan: Plan) => everyItem(plan).filter(i => i.state === '!')
124
125export type Gate = { item: Item; isDue: boolean }
126
127// A gate is due only when every step before it is done; until then it is ahead.
128export function gateTiers(plan: Plan): Gate[] {
129  const out: Gate[] = []
130  let isOpenBefore = false
131  for (const it of plan.items) {
132    if (!it.isGate) {
133      if (it.state !== '-' && fillOf(it) < 1) isOpenBefore = true
134      continue
135    }
136    out.push({ item: it, isDue: !isOpenBefore && it.state !== 'x' && it.state !== '-' })
137  }
138  return out
139}
140
141// What needs the person now: a decision marked [?], and the gates that are due.
142export function waiting(plan: Plan): Item[] {
143  const decisions = everyItem(plan).filter(i => i.state === '?' && !i.isGate)
144  return [...decisions, ...gateTiers(plan).filter(g => g.isDue).map(g => g.item)]
145}
146
147// The gates still ahead, not yet due.
148export const gatesAhead = (plan: Plan) => gateTiers(plan).filter(g => !g.isDue && g.item.state !== 'x' && g.item.state !== '-').map(g => g.item)
149
150// "Step 6 of 15": the step in progress (or the next one) among the steps that count.
151export function position(plan: Plan): { at: number; of: number } {
152  const work = plan.items.filter(i => !i.isGate && i.state !== '-')
153  const now = findNow(plan)
154  const at = now ? work.indexOf(now.step) + 1 : work.length
155  return { at: Math.max(1, at), of: work.length }
156}
157
158// The steps after the current one that are still to do.
159export function upNext(plan: Plan, k: number): Item[] {
160  const now = findNow(plan)
161  return plan.items.filter(i => !i.isGate && i.state === ' ' && i !== now?.step).slice(0, k)
162}
163
164// ---------- finding an item by what an agent or a person calls it ----------
165
166export const label = (it: Item) => (it.id ? `${it.id} · ${it.title}` : it.title)
167
168export function resolve(plan: Plan, ref: string): Item | string {
169  const want = ref.trim()
170  const all = everyItem(plan)
171  const lower = want.toLowerCase()
172  const tries: ((it: Item) => boolean)[] = [
173    it => it.id === want,
174    it => label(it) === want,
175    it => it.title === want,
176    it => it.id.toLowerCase() === lower || it.title.toLowerCase() === lower,
177    it => `${it.id}: ${it.title}`.toLowerCase() === lower,
178    it => it.title.toLowerCase().startsWith(lower) || label(it).toLowerCase().startsWith(lower),
179  ]
180  for (const t of tries) {
181    const hits = all.filter(t)
182    if (hits.length === 1 && hits[0]) return hits[0]
183    if (hits.length > 1) {
184      return `"${want}" matches ${hits.length} items: ${hits.map(label).join('; ')}. Name one by its title.`
185    }
186  }
187  return `No item called "${want}". The items are: ${all.map(label).join('; ')}.`
188}
189
190// ---------- writing: one line at a time, everything else byte for byte ----------
191
192export type Edit = { text: string; line: number } | { error: string }
193
194function replaceLine(text: string, at: number, next: string): string {
195  const lines = linesOf(text)
196  const old = lines[at] ?? ''
197  lines[at] = old.endsWith('\r') ? `${next}\r` : next
198  return lines.join('\n')
199}
200
201function insertLine(text: string, at: number, next: string): string {
202  const lines = linesOf(text)
203  const isCrlf = (lines[0] ?? '').endsWith('\r')
204  lines.splice(at, 0, isCrlf ? `${next}\r` : next)
205  return lines.join('\n')
206}
207
208// A note: plain text, kept on one line and clear of the field separator.
209const cleanNote = (note: string) => note.replace(/[\r\n]+/g, ' ').replace(/\s+·\s+/g, ', ').trim()
210
211// Sets an item's state, and its notes when `note` is given ('' removes them).
212export function setItem(text: string, ref: string, state: ItemState, note?: string): Edit {
213  const plan = parse(text)
214  if (!plan) return { error: 'The file has no plan-progress block.' }
215  const it = resolve(plan, ref)
216  if (typeof it === 'string') return { error: it }
217  const line = bare(linesOf(text)[it.line] ?? '')
218  const m = ITEM.exec(line)
219  if (!m) return { error: `Line ${it.line + 1} is not an item any more; read the file again.` }
220  const head = `${m[1] ?? ''}${line.slice((m[1] ?? '').length, (m[1] ?? '').length + 2)}`
221  let content = m[3] ?? ''
222  if (note !== undefined) {
223    // keep "id · title" exactly as written, then the new note
224    SEP.lastIndex = 0
225    const seps = [...content.matchAll(SEP)]
226    const titleEnds = it.id === '' ? (seps[0]?.index ?? content.length) : (seps[1]?.index ?? content.length)
227    const kept = content.slice(0, titleEnds)
228    const n = cleanNote(note)
229    content = n ? `${kept} · ${n}` : kept
230  }
231  return { text: replaceLine(text, it.line, `${head}[${state}] ${content}`), line: it.line }
232}
233
234export type NewItem = {
235  id?: string
236  title: string
237  state?: ItemState
238  note?: string
239  // place it after this item, at the same depth (after its sub-steps)
240  after?: string
241  // or make it the last sub-step of this item
242  under?: string
243}
244
245export function addItem(text: string, add: NewItem): Edit {
246  const plan = parse(text)
247  const block = findBlock(text)
248  if (!plan || !block) return { error: 'The file has no plan-progress block.' }
249  const title = cleanNote(add.title)
250  if (!title) return { error: 'A new item needs a title.' }
251  const id = cleanNote(add.id ?? '')
252  const state = add.state ?? ' '
253  const note = cleanNote(add.note ?? '')
254  const body = [id, title, note].filter(Boolean).join(' · ')
255  let at = block.close
256  let indent = ''
257  if (add.under !== undefined) {
258    const parent = resolve(plan, add.under)
259    if (typeof parent === 'string') return { error: parent }
260    if (plan.items.indexOf(parent) < 0) return { error: `${label(parent)} is a sub-step; sub-steps have none of their own.` }
261    const last = parent.children[parent.children.length - 1]
262    at = (last ?? parent).line + 1
263    indent = '  '
264  } else if (add.after !== undefined) {
265    const prev = resolve(plan, add.after)
266    if (typeof prev === 'string') return { error: prev }
267    const isChild = plan.items.indexOf(prev) < 0
268    const last = prev.children[prev.children.length - 1]
269    at = (last ?? prev).line + 1
270    indent = isChild ? '  ' : ''
271  } else {
272    const lastTop = plan.items[plan.items.length - 1]
273    const lastLine = lastTop ? (lastTop.children[lastTop.children.length - 1] ?? lastTop).line : block.open
274    at = lastLine + 1
275  }
276  return { text: insertLine(text, at, `${indent}- [${state}] ${body}`), line: at }
277}
278
279// ---------- a compact read for an agent picking up the build ----------
280
281export function summary(plan: Plan, path: string): string {
282  const c = count(plan)
283  const now = findNow(plan)
284  const out: string[] = [`${plan.title || 'Plan'}: ${c.pct}%, ${c.done} of ${c.total} steps done (${path}).`]
285  if (now) out.push(`Now: ${label(now.item)}${now.item !== now.step ? ` (in step ${label(now.step)})` : ''}.`)
286  const w = waiting(plan)
287  if (w.length) out.push(`Waits on the person now: ${w.map(label).join('; ')}.`)
288  const ahead = gatesAhead(plan)
289  if (ahead.length) out.push(`Gates ahead, not yet due: ${ahead.map(g => g.title).join('; ')}.`)
290  const b = blocked(plan)
291  if (b.length) out.push(`Blocked: ${b.map(label).join('; ')}.`)
292  out.push('')
293  for (const it of plan.items) {
294    out.push(`- [${it.state}] ${[label(it), ...it.notes].join(' · ')}`)
295    for (const k of it.children) out.push(`  - [${k.state}] ${[label(k), ...k.notes].join(' · ')}`)
296  }
297  return out.join('\n')
298}
299
hooks/lib/history.ts 168 lines
1// What the plan's history says: commits that name steps, when items started and finished, when
2// the plan lags the work, and the pace. Pure: no `$`, type-only imports.
3
4import type { Commit, History, Item, Lag, Plan } from '../../types'
5import { fillOf } from './grammar'
6
7export const emptyHistory = (): History => ({ items: {}, commits: [], nudged: [], dismissed: [] })
8const MAX_COMMITS = 300
9
10// Every item, steps and sub-steps, in plan order.
11const flat = (plan: Plan): Item[] => plan.items.flatMap(i => [i, ...i.children])
12const keyOf = (it: Item) => it.id
13
14// The ids a commit message names after "step" or "steps": "(3.1 step 3d)", "(3.1 steps 3b, 3c)",
15// "(3.1 step 2a, 2b part)". Only ids the plan has count, so "3.1" or "part" never match.
16export function stepRefs(message: string, plan: Plan): string[] {
17  const ids = new Set(flat(plan).filter(i => i.id && !i.isGate).map(i => i.id))
18  const found: string[] = []
19  for (const m of message.matchAll(/\bsteps?\s+([^)\n]*)/gi)) {
20    for (const token of (m[1] ?? '').split(/[\s,&/+]+|\band\b/)) {
21      const t = token.trim().replace(/[.:;]+$/, '')
22      if (t && ids.has(t) && !found.includes(t)) found.push(t)
23    }
24  }
25  return found
26}
27
28export const isWipMessage = (message: string) => /^\s*wip\b|\bstarted\b|\bwip\(/i.test(message)
29
30// The message of a `git commit` command line: -m/--message values, or a heredoc's body.
31export function commitMessage(command: string): string {
32  const parts: string[] = []
33  for (const m of command.matchAll(/(?:-m|--message)\s+(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))/g)) parts.push(m[1] ?? m[2] ?? m[3] ?? '')
34  const doc = /<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1\b/.exec(command)
35  if (doc?.[2]) parts.push(doc[2])
36  return parts.join('\n')
37}
38
39// Records a commit; the items it names gain it, and started at its time if they had not.
40export function addCommit(h: History, c: Commit): History {
41  if (h.commits.some(x => x.sha === c.sha)) return h
42  const items = { ...h.items }
43  for (const id of c.items) {
44    const was = items[id] ?? { commits: [] }
45    items[id] = { ...was, startedAt: Math.min(was.startedAt ?? c.at, c.at), commits: [...was.commits, c.sha] }
46  }
47  return { ...h, items, commits: [...h.commits, c].slice(-MAX_COMMITS) }
48}
49
50// Records what changed between two reads of the file: first seen under way, first seen done.
51export function addTransitions(h: History, before: Plan | null, after: Plan, now: number): History {
52  const was = new Map((before ? flat(before) : []).map(i => [keyOf(i), i] as const))
53  const items = { ...h.items }
54  let changed = false
55  for (const it of flat(after)) {
56    if (!it.id || it.isGate) continue
57    const old = was.get(it.id)
58    const t = items[it.id] ?? { commits: [] }
59    const isDone = fillOf(it) === 1
60    const isUnderway = !isDone && (it.state === '~' || fillOf(it) > 0)
61    const wasDone = old ? fillOf(old) === 1 : false
62    let next = t
63    // the first read of a file only learns the start of what is under way, never invents a finish
64    if (isUnderway && t.startedAt === undefined && before) next = { ...next, startedAt: now }
65    if (isDone && !wasDone && before && old) next = { ...next, doneAt: now, startedAt: next.startedAt ?? now }
66    if (!isDone && t.doneAt !== undefined) {
67      const { doneAt: _, ...rest } = next
68      next = rest
69    }
70    if (next !== t) {
71      items[it.id] = next
72      changed = true
73    }
74  }
75  return changed ? { ...h, items } : h
76}
77
78// Two copies of one file's history (two sessions) as one: commits by sha, the earliest start.
79export function mergeHistory(a: History, b: History): History {
80  let h: History = { ...a, nudged: [...new Set([...a.nudged, ...b.nudged])], dismissed: [...new Set([...a.dismissed, ...b.dismissed])] }
81  for (const c of b.commits) h = addCommit(h, c)
82  const items = { ...h.items }
83  for (const [id, t] of Object.entries(b.items)) {
84    const mine = items[id] ?? { commits: [] }
85    const startedAt = [mine.startedAt, t.startedAt].filter((v): v is number => v !== undefined)
86    const doneAt = mine.doneAt ?? t.doneAt
87    items[id] = {
88      commits: [...new Set([...mine.commits, ...t.commits])],
89      ...(startedAt.length ? { startedAt: Math.min(...startedAt) } : {}),
90      ...(doneAt !== undefined ? { doneAt } : {}),
91    }
92  }
93  return { ...h, items }
94}
95
96const lagKey = (item: string, movedTo: string) => `${item}>${movedTo}`
97
98// Items still open although a later commit names a later item: the work moved on. Inferred.
99export function lags(plan: Plan, h: History): Lag[] {
100  const order = flat(plan).filter(i => i.id && !i.isGate)
101  const pos = new Map(order.map((it, i) => [it.id, i] as const))
102  const parentOf = new Map(plan.items.flatMap(s => s.children.map(k => [k.id, s.id] as const)))
103  // the item itself, its step or its own sub-steps; a later sibling counts as moving on
104  const related = (a: string, b: string) => a === b || parentOf.get(a) === b || parentOf.get(b) === a
105  const out: Lag[] = []
106  for (const it of order) {
107    // a step with sub-steps lags through its sub-steps, not itself
108    if (it.children.length || it.state !== '~') continue
109    const t = h.items[it.id]
110    const mine = h.commits.filter(c => c.items.includes(it.id))
111    const last = Math.max(t?.startedAt ?? 0, ...mine.map(c => c.at))
112    if (!last) continue
113    const later = h.commits.find(c => c.at > last && c.items.some(x => !related(x, it.id) && (pos.get(x) ?? -1) > (pos.get(it.id) ?? -1)))
114    if (!later) continue
115    const movedTo = later.items.find(x => !related(x, it.id) && (pos.get(x) ?? -1) > (pos.get(it.id) ?? -1)) ?? ''
116    const key = lagKey(it.id, movedTo)
117    if (h.dismissed.includes(key)) continue
118    const lastCommit = mine[mine.length - 1]
119    out.push({ item: it.id, title: it.title, movedTo, lastSha: lastCommit?.sha ?? '', since: later.at, isNudged: h.nudged.includes(key) })
120  }
121  return out
122}
123
124export const lagId = (l: Lag) => lagKey(l.item, l.movedTo)
125
126export function nudgeText(l: Lag, path: string): string {
127  return (
128    `plan-progress: the plan (${path}) still has ${l.item} · ${l.title} in progress, but your commits have moved on to ${l.movedTo}. ` +
129    `If ${l.item} is done, mark it with mcp__plan-progress__progress (action "set", item "${l.item}", state "done"${l.lastSha ? `, note "\`${l.lastSha}\`"` : ''}). If it is not, carry on.`
130  )
131}
132
133export type Pace = {
134  // minutes each measured step took, oldest first
135  minutes: { id: string; min: number }[]
136  // the range a step has taken, and what that range makes of the steps left, in hours
137  perStep: [number, number]
138  left: [number, number]
139  stepsLeft: number
140}
141
142// Pace from steps whose start and finish were both seen; none before two are.
143export function pace(plan: Plan, h: History): Pace | null {
144  const steps = plan.items.filter(i => !i.isGate && i.state !== '-')
145  const minutes = steps
146    .map(s => ({ s, t: h.items[s.id] }))
147    .filter(({ t }) => t?.startedAt !== undefined && t?.doneAt !== undefined && (t.doneAt ?? 0) > (t.startedAt ?? 0))
148    .map(({ s, t }) => ({ id: s.id, min: Math.round(((t?.doneAt ?? 0) - (t?.startedAt ?? 0)) / 60000), at: t?.doneAt ?? 0 }))
149    .sort((a, b) => a.at - b.at)
150    .map(({ id, min }) => ({ id, min }))
151  if (minutes.length < 2) return null
152  const recent = minutes.slice(-4).map(m => m.min)
153  const lo = Math.max(1, Math.min(...recent))
154  const hi = Math.max(lo, Math.max(...recent))
155  const stepsLeft = steps.filter(s => fillOf(s) < 1).length
156  const round = (m: number) => Math.max(1, Math.round(m / 60))
157  return { minutes, perStep: [lo, hi], left: [round(stepsLeft * lo), round(stepsLeft * hi)], stepsLeft }
158}
159
160// "19 min", "2 h", "3 days": how long ago, for a glance.
161export function since(ms: number, now: number): string {
162  const min = Math.max(0, Math.round((now - ms) / 60000))
163  if (min < 1) return 'just now'
164  if (min < 60) return `${min} min`
165  const hours = Math.round(min / 60)
166  return hours < 48 ? `${hours} h` : `${Math.round(hours / 24)} days`
167}
168
hooks/lib/term.ts 67 lines
1// The press line in characters, for the terminal (no SVG there): blocks for ink, braille dots for
2// halftone, open brackets of line for blueprint, diamonds for gates. Pure: runs of text with a
3// tone each, which the hooks module turns into Text elements.
4
5import type { Plan } from '../../types'
6import { fillOf, findNow, gateTiers } from './grammar'
7
8export type Tone = 'ink' | 'dim' | 'blue' | 'now'
9export type Run = { text: string; tone: Tone }
10
11// From full dots to none: the halftone, one character per cell.
12const DOTS = ['⣿', '⣷', '⣶', '⣤', '⣀', ' ']
13
14function push(runs: Run[], text: string, tone: Tone) {
15  const last = runs[runs.length - 1]
16  if (last && last.tone === tone) last.text += text
17  else runs.push({ text, tone })
18}
19
20// `cells`: how wide the bar may be. Each step gets the same whole number of cells (1 to 3).
21export function barRuns(plan: Plan, cells: number): Run[] {
22  const work = plan.items.filter(i => !i.isGate)
23  const gates = plan.items.filter(i => i.isGate)
24  const room = cells - (gates.length ? gates.length + 1 : 0) - 1
25  const per = Math.max(1, Math.min(3, Math.floor(room / Math.max(1, work.length))))
26  const now = findNow(plan)
27  const dueGates = new Set(gateTiers(plan).filter(g => g.isDue).map(g => g.item))
28  const runs: Run[] = []
29  let isAfterWork = false
30  for (const it of plan.items) {
31    if (it.isGate) {
32      if (isAfterWork) push(runs, ' ', 'dim')
33      isAfterWork = false
34      // filled once due (it waits on you), an outline while ahead
35      const isDue = dueGates.has(it)
36      push(runs, it.state === 'x' || isDue ? '◆' : it.state === '-' ? '·' : '◇', it.state === 'x' ? 'ink' : it.state === '-' ? 'dim' : 'blue')
37      continue
38    }
39    isAfterWork = true
40    const f = fillOf(it)
41    if (f === 1) {
42      push(runs, '█'.repeat(per), 'ink')
43    } else if (now && it === now.step) {
44      const ff = Math.max(f, 0.12)
45      let dots = ''
46      for (let i = 0; i < per; i++) {
47        const t = (i + 0.5) / per
48        const k = Math.max(0, Math.min(1, (ff + 0.24 - t) / 0.48))
49        dots += DOTS[Math.min(DOTS.length - 1, Math.round((1 - k) * (DOTS.length - 2)))] ?? '⣀'
50      }
51      push(runs, dots, 'ink')
52      push(runs, '▏', 'now')
53    } else if (it.state === '-') {
54      push(runs, '·'.repeat(per), 'dim')
55    } else if (it.state === '!') {
56      push(runs, '╳'.repeat(per), 'dim')
57    } else if (it.state === '?') {
58      push(runs, '█'.repeat(per), 'blue')
59    } else {
60      push(runs, per === 1 ? '▫' : `╶${'─'.repeat(per - 2)}╴`, 'blue')
61    }
62  }
63  return runs
64}
65
66export const runsText = (runs: Run[]) => runs.map(r => r.text).join('')
67
hooks/lib/fonts.ts 6 lines
1// Generated by scripts/make-fonts.py: Heebo 850 (Latin, Hebrew) and Cascadia Mono 400 (Latin),
2// subset for the pane's SVG. SIL Open Font License 1.1, see fonts/OFL.txt. Do not edit.
3export const FONT_FACES = "@font-face{font-family:'pp-heebo';src:url(data:font/woff2;base64,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) format('woff2');unicode-range:U+0020-007E,U+00A0-00FF,U+2010-2027,U+2030-203A,U+2190-2193,U+2212;font-display:block}@font-face{font-family:'pp-heebo';src:url(data:font/woff2;base64,d09GMgABAAAAABBEAA8AAAAAIhgAAA/oAAMZmgAAAAAAAAAAAAAAAAAAAAAAAAAAGlIbjlYcMAZgP1NUQVQcAIImEQgKpzSdcwE2AiQDgmALgUIABCAFgh4HIBuMG6OREWwcAEjkCTR/kZCNIdkbPiLN2tFF4xEjl2ukXqjhn6uHAY6mPs7/2u93Lvg+XPSbQVUJmVIsURmyT4e5u4tpaJhh1yZnPcxe6DX9E9SupQRKNMAwTNIoaILmwfnPh+fn9ufdeFRPvuKomDLKwlmAMjAYVUqlRViJFVhJmjGrEqz6GFPe1Vz+327obveSFNmpakBQn9yjTaYS0aMwtZ3Kh/7/c682ua+QvhTyivl/zGo2g+MnyW2qQC6wnN/k/RTyShkiCYXsgMB1Fsghuzk372vni98mr8Gkw0oulJwHte3jZ+OCMJv/cEowEdkiOzts74jcufkjDAgD5GCCBwvAzi7Ul5tAyBsBhI4NofXqQ9zOXk/cSf+uOCIAjxy9tlMJAfhBBV3l9NIQop7JFJM0/PnZIICfbpHCXN2tPHAXxMXrJ66cxRJxssGmp+IMMHEeSObvTQ2QG6yA3CNPhmnzirwjX0hTcn8pofrUNFtqLc6jLalD8m60DfWjHdOmKw2h4VRjxWHzFJqBevPoQFrKjZMbSa/Q8XRKNuIMcZ64WFwp0Fnt0kq6NG1W01q6kW5Pbi89RE/Qc00xJ3qF3qIPsqXP2AXaQD/U/Y3+os2MZcsMmTmTMbvknZgH82YB9QbyJNad9Uq+D4tiCax/2mSxAjaYldc7WtSwiWxhx6bxGWxO8svZWlbPNje3k+1jR9gp/IDZBXaNB7E77FHjEwH2gr1hn9iPxqyHvDu/Af8PZH84uMiNAW7JZ3BbLge4C/fiPrw9wFU8iKt5P4DH8CSexnMAXsSH8uF8LMAn8xl8Hl8M8JW8mq/nWwG+mx/gx/gZgF8CAKC6rgI5tiCjMYecYlopCHBhzGRBQ5JFItKNEbDFWIWFVZjcZ4KtFiJEMgsMGZkJGIeFBCwoMPRFIQD2MFIjQR0m1YVoyYwi6EEVPcC8NWyzIyNJGvEg2+kPLdXLBoIoJFFrknSAT9rGMyR7MoPRFiZoa0c3a2EzJpcZeSzBDrn5IxngSs3A0k8suGFhuAtGDGMjUI/JZiz9wmSLSLbDNB9ZTE/Y5ZCFYGG3CJW3iDQBJvlILYDsyXFqBMt9SBUGkX/CcqkEkIWWwimXZEKk1UhO11WMNtEzhuAkFvSxcJNB5mAWZYdApDNhBYMlo8cKQlthAAOBVEd3MTKrCFjuo3p4BBdFehRsRGEz3BRpWNAMRpVIgbYha1SMHtIl1skEbgw7GJ0PWFYIJzIcLMNMgSVPURgKbphOY9I/0P0ZYBomm/ZJRQD8ZVKDwQ6jkaZeAQJUMF++pFmsgCCDRidQg6KfgGK6CkJXcySGpIAhA6zAlOsFA4ElbCGHC7zgg/ZQQWALHtRAaTfYx8wyKBweW2IyvMg7LMQYr9V22sAbSB4un8VfnrFnNAoc4P7sMoDlwb7JZWSR6UBb+ugO/b+VnwDH+HZdhH7G3+o4yaD9wSGfBUYM1p1COLD9AGv4ccmw3LxSgbC+AGjUoSrCBMuylcMcFKb8ED/KT/Cz/B3/oWut89C116l0XXU9dCG6CEkCvp0f4ccV5Br/Us1e56nrWLW7LljXy/uzFp78WYpq3IHbI569hCBAtoMcfjkGL7LJqsHbdu2jHB/nNMmFSFQ6k80TCMVSMvKKSiqq6ppaOvoGRiZmNg7aHI0aM6HewtIKE4hkGoPF4Ysk0rIKahrauobGpta2XIqcnhNCEcwA5AcALoELAiYQlIF+qAIAP0KAIQtHaPNkEUEy3KzO1CxC4gEhMOpMsxRoepNMlSQgfWZ0zKymSCnoLrk/1TnULSKE14mIHtrU5ZK0XT1g2022hUgPEfZZWhh2jhijiyypVQ75Ej10iZoVg2jIRuU1wYSF6wIst1l86dqnRzWPGMoehbFJ1EQr2jz+B2ZyM7MK/NVxsWJVlCDg9AtsPLkooG93RkPrWiDwsTyfSDOyynzQTKRNDoWOnmjwbTG/ByvkWwZRjgR2VTwKvqBIGT+V80jx+MLS3k+UIouxLI1UE9Arm5znxV3URipBysxukODjsSEIo/REmWZr8w1NoCHEMn8eWCx8MZkjw8prdk/YIrXY0J0XSxKFb86buau1cfpnRQ1qJvepeG5u2kjg3CiUOsiS4NvQjZSahqozWJWZo2S1F02/noDxzLZUa8pWVC4WS12lXDLjyvkibO5fdJ+jJ9lOv8pkIjLyVjmo7i4xw+mmm8i+aF0poWkb27AavStKK7q3aTfndF1rVqWngNfHfyRYPDPLCvw4wdvO5LBCH7YemotWnT28ieg7R6mLsWu7ZdZLbY0pkm+TFz0InBjeZmZKHHrvkSGNKxLVAGenI1f3mgz6tmyv5kWe+/9tait58QVywpldNkYs2eRolTxV4qrr0lsMUpvYAMLR95Nv02ztq1ePGQIwOO5BwDljMX/E9mrcOOTBJbMIs9c6eowwpiyiy9M1E6eklMfK7VlXFN4THBCwYcEiCD456azeECbcNJp5HeOvDWHKBGV26xgp05TpWyeSCRKFRZMA+SfadjdeCCYwvH17y2SxzI3npXbKEctiHFHGKOOGZpMJykoWdznjqh3KUa0zalkSc10sN/NmZIYCoGzoUfslNUJ5g8dX2aw63BntsiP02ifPXrdmee2vWiz2X6id71+qXTTx2IQbC/1H75wd6hiwUGtbIjbavb6Bb4u2OQYzbIy+Rh3rOaHuo5EVviKNV7YizdTmRlDvydjkIOQ79lOvu8xkOQOyrNIiKsPULqJ5QtrMuf1AT2k9ZHer8qrews8qh7r66smcGoQXXTBfM6lLiXHrLIOi8LOoqkqVndZCp3dy8UuTgzK5/vcqrIa11vXDjwY8+5wDZ7PQruGdj1XhgG3Nt4opn9cczHmTtlgeGZVRnqYOVYUEWb68Kf8deOiqqj9863CHNRpV98bjJdbulP23F93XgPf4I0uGGI+VVpE7WgywascWtU7HHGFkacl+PdmgBRfn/MYQ0F1VA3LDLGQjvG2tro3KuKU1w2ptK+0uWd8ZfbFIPVU/InBBgUWOOY/Jlj8Koz52JxT3tl65uwNs17SS7cL+wogRak30OMNRPfahrtrYdNnJ/8+2Vl0LTNWmxmH6vXqoeqrFS7K6Tocu3cZm26qfc2ZKZKM+tStvWzA+JNffpi7GOrsH0bQ476wsPScMJ0+nxweeTZN+zal3C4wL8RkZmZ/pVREb3K17nFoxpyAmUjkyLgQK+cBfbtP91KzIQ+veG7PWTJlyKG+Ddukvs9HuAXfyxcnQI/fEtUbRRcPNQ6G21kKMzwTv0VRnHMZ7OScG+11rUEetYZVukbNNTcc6DI4tl2FQq3yde6FbD5bT6kLr3mlCRWnJfpWBSUKAMsj5eaM6ag2tdA9KH+C0wbNc1nf1xdrfloPgt+Znu16673Pdf5StH+FYGHJVLIl4aPfwbj7GtSBu39fO9f0x0nm1YqtY7gwwK2EjJlVM3iZrsWDLnM+G48rrVRNVpSbyUoOi8Gz8ZxKuCdOkykY2srfX/sF+snakxumY1/T0q322aibGJo9bNvrVg8X24bWby6LWazo7+bueNp3b1ynlVnbFgf5oW6v90NTw/HMO/M1Cu4QHLlmqV6bseeTKzT3evfUWWT/7YtEjtKdaz9+u0mCbDAr3pxYLe5D3uPCKqbTC7kCe3TojyyBnxcKnBZ2mj90T1hpHFw43u0bUF/R10d7v+6/NfoYn182UYwLmNTfYla2Yf9k/ohjAwKD1vNyqq5gxN819WGA3hYmrSZJRrtfsAybhkWERJbK5b+iOHW+e++ggtyUufjZL7f3jTbuxbT4q5q6UabpjPY7u0alHoltw1X6j3P9XqRqce6+NLrHWmqFG2/8atOr0FvIZcpSbhEeERR6Q3SlQr539ftulXFhx3Vp/9jmdvEu7Fecqfa3HCFlX/V83vanRmoog1fbwm92wvU+7qv5zYX5S8yj2Sp271E9HKoMCC7vT8PrgdLPqq+wrbAP2hgzTeDV4zSDZZng9TZ6KJqXPGUXJaVSkeDd5eTV5c3XhnVFm9kHBKfphQIpyLXdn1Ts/NyzVKRuINSiZ+7GpxTXyZyNme9ilMU4xJb4GV/6uPNOxKhH7VBZ4Zzoo4byuBx/GVDR6ZhywD2Wh1yr/VN7koe2DdkDudWYhUhl2IxX/D4Hv/FRlgqtUWrRjK+dez22te7zJPveTP7vaiaZN73N6cOWvSlQkKxs8M879Ct1eua9yR/yvfXKv07B6rSHBs1TborkkuBUpOcXeVlZ3+fHZ+zBI3lV3L3/n2C+fV4rS1z4V9zxKbiP6z+Gi1SWewCQ7eUf+DliVaTr6oebunPo2KiYq73jmj/3Ud1BWh9EsC/p9GpTX+RTRDQIAEfGBLDxhdD2kCB7Nfh2ec2LJXwkk9S+C+jqfFBZSiCGcDBrHRKFD0JRimncPFpH4EQixEIUIxxoox5FlI7cxB9mSlJsxwZYsRpRIqiFQa5JyQek4FIamO85WkZfjGkWJjseI9MAKxwukGNuRV7VUzANCvH3SGSnP8RmhqKBf5fZuhgKSImSI7m+2b3ek0ARJUgTEWZUhehoPCRSEwhw2qmZKngCeDECbMZIFlUXBTT+tbCwyoPpFTTUGbrtzbF1SeGL8ExwDkNp8iAcwLVL/1QYiwPlGM81kojEMpK80SuBVVhb46dwyQVouEAOMEFm0RPcCaoETr5Ya2V12WjpIBNJbGBzanEarXe1A6QN9cF2iMToDrK0IpAHsHbsP80gB2akY3EZqZLcpZ3xGgU1Qg8rSK3ZZA29pb6xIU/B7hrYC6ISKd+GeWCFaBr2YFkYUwQ4P8QNYIyo015De5BD3tEIisM0lgnCGUxuT6vbUr3eUuv39/2Tof8sdO05/MiBS9b8+1QcAPC+asAOAhn/F86Xlf3fp+YnWAEQQAIAA/CmRr1ceF36/2AIE+GNp+C9c4cOROqvM8uDrp3tg4GcboPJzW0yXST1CBGl5fQih7dC9Ie6K1bytTJ7ArwHpIOuB0KRyBMYOQecCHq/uko80NENq0ZNGQFXDnLdDG8tN1VWO8VAUNsROwJLd9vNIrPZTCaWDes5YXYEHwqhqBfzI0wh++MZC0FmvHKn8OFTidMwQTRHIj4mX8kENT0ErPV8Y8T4IUXMm9yXoYMMY/Ek+VEwNX4AHEVt8xTd8DQqEip+qM4SNuKryetCAOzj/5QQAaVCBQwV2AigddWFoH07HDAAsBVgtAlMsrWmK9qgvA4HAGI8QjEEYjBEYinzkIg+lcIAffOCLAFgxEp/tqZE9tDLUxAEaFVYB0Uy91wNlzsoTvaEoEahHMV4pBg+nDoQSSuQiXzN6GTLgjUxnFcteBLKGg7PQEyMDbyiUMKv5o/BEFLKRizIUIR1Di9SIZlr2BqOUPG+UMKuIdBM0t/CJe6EP4pG9W4zCd0oqkAUA) format('woff2');unicode-range:U+0590-05FF,U+FB1D-FB4F;font-display:block}@font-face{font-family:'pp-mono';src:url(data:font/woff2;base64,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) format('woff2');unicode-range:U+0020-007E,U+00A0-00FF,U+2010-2027,U+2030-203A,U+2190-2193,U+2212;font-display:block}"
4export const DISPLAY = "'pp-heebo', Heebo, system-ui, sans-serif"
5export const MONO = "'pp-mono', 'Cascadia Mono', ui-monospace, Menlo, monospace"
6
types/index.d.ts 101 lines
1// plan-progress: the state contract, and the plan's shape the pure files share.
2
3// [ ] to do, [x] done, [~] in progress, [?] waits on you, [!] blocked, [-] dropped.
4export type ItemState = ' ' | 'x' | '~' | '?' | '!' | '-'
5
6export type Item = {
7  state: ItemState
8  // '' when the line has a title and no id
9  id: string
10  title: string
11  // the fields after the title: a commit, a PR, a date, as written
12  notes: string[]
13  isGate: boolean
14  // 0-based line in the file
15  line: number
16  children: Item[]
17}
18
19export type Plan = {
20  title: string
21  items: Item[]
22}
23
24// The pinned file as last read. `error` says why there is no plan (missing file, no block).
25export type Snapshot = {
26  path: string
27  mtimeMs: number
28  size: number
29  plan: Plan | null
30  error: string | null
31}
32
33// What the host told us once at session start.
34export type Env = {
35  // the engine's base release, e.g. '2.1.299'
36  engine: string
37  // the terminal reorders right-to-left text itself (VS Code, Windows Terminal)
38  isTermLogical: boolean
39  // what opens a file on this machine: open (macOS), explorer (Windows), xdg-open
40  opener: string
41}
42
43// A short moment after a step or the whole plan is done: the pane draws it while it lasts.
44export type Moment = { kind: 'step' | 'plan'; id: string; until: number }
45
46// A commit seen in a session, and the plan items its message names ("(3.1 step 3d)").
47export type Commit = {
48  sha: string
49  at: number
50  items: string[]
51  // "wip", "started": work began, nothing finished
52  isWip: boolean
53}
54
55// When an item was first seen under way and first seen done, and the commits that name it.
56export type ItemTimes = {
57  startedAt?: number
58  doneAt?: number
59  commits: string[]
60}
61
62// One plan file's history, kept in the mod's store so every session reads the same.
63export type History = {
64  items: Record<string, ItemTimes>
65  commits: Commit[]
66  // lags the agent was told about, and the ones the person waved off ("3d>S")
67  nudged: string[]
68  dismissed: string[]
69}
70
71// An item still open after the commits moved on to a later one: inferred, never written.
72export type Lag = {
73  item: string
74  title: string
75  movedTo: string
76  lastSha: string
77  // when the work moved on
78  since: number
79  isNudged: boolean
80}
81
82// What the agent is doing now, from its own tool calls in this session.
83export type Live = { text: string; at: number }
84
85declare module 'claude-code' {
86  interface PluginState {
87    'plan-progress': {
88      pinned: string | null
89      snap: Snapshot | null
90      isHidden: boolean
91      showAll: boolean
92      isPinning: boolean
93      minute: number
94      env: Env
95      moment: Moment | null
96      hist: History | null
97      live: Live | null
98    }
99  }
100}
101