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…

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.
git commit in Bash. From a commit it reads the message in the command line, and only uses the step ids it names.open, explorer, xdg-open), because the desktop draws only https: links.claude plugin install plan-progress@bar-mods
Then /progress pin path/to/plan.md.
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 -->
<!-- plan-progress title="…" --> opens the block and <!-- /plan-progress --> closes it. The title is optional. One file holds one block; the first is read.- [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.[ ] to do, [~] in progress, [x] done ([X] too), [?] waits on you (a decision or a gate), [!] blocked by something else, [-] dropped.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.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:
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.
The pane (/progress), top to bottom; a section with nothing in it takes no room:
[?], 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: …".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.)
| | | | :- | :- | | /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.
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.
From claude plugin validate:
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}$.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.toastHOME, OS, TERM_PROGRAM, WT_SESSIONThe 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.
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.
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).
hooks/register.tsx 886 lines1// 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}
886hooks/lib/bidi.ts 65 lines1// 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}
65hooks/lib/draw.ts 245 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
245hooks/lib/grammar.ts 299 lines1// 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}
299hooks/lib/history.ts 168 lines1// 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}
168hooks/lib/term.ts 67 lines1// 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('')
67hooks/lib/fonts.ts 6 lines1// 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,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) 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"
6types/index.d.ts 101 lines1// 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