Band above the prompt: progress of a superpowers plan, overall and per phase, read from the plan file and the SDD ledger

A Claude Code mod that draws a progress band above the prompt while you run a superpowers plan. It shows the overall done/total count, one segment per phase, the current task, and (for subagent-driven runs) the stage of that task: brief, implementer, review, fix, gate, PR.
It reads two things from disk and nothing else:
docs/superpowers/plans/*.md;.superpowers/sdd/<plan-slug>/progress.md, with the lines Task N: complete.The band stays hidden until the session has either run a superpowers:* skill or touched a ledger or plan path with a tool call, so it never shows up in projects that don't use superpowers.
In a terminal session:
/plugin install superpowers-progress --marketplace Dave93/superpowers-progress
Answer y to add the marketplace and pick the user scope. The mod then loads in every project, in each session started after the install, and in the current one at once.
/sp-progress with no argument opens a pane with every phase and task. Arguments:
| Argument | Effect |
|---|---|
hide, show | hide or show the band |
status | print the active target and the newest ledgers found |
use <slug-or-path> | pin a ledger; it beats the automatic choice |
use auto | release the pin |
Worktrees that sit beside the session's directory and hold a ledger of the same plan slug are merged: a task done in any of them counts as done.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
.claude-plugin/types/ is written by the engine on load and is git-ignored.
MIT
hooks/register.tsx 475 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot, Target } from '../types'
5import { candidateLine, computeSnapshot, diffToasts, extractRefs, isCurrentSnapshot, isWorkingTool, layoutBand, matchCandidates, mergeRefs, paneLines, parseLedger, parsePlan } from './parse'
6import type { Candidate, Plan, StageSource } from './parse'
7
8// `target` is where this session has seen a ledger or plan worked on; `found` is where discovery guessed one is, and
9// never counts as seen; `pinned` is the person's own choice (`/sp-progress use`) and beats both.
10const target = atom({ plugin: 'superpowers-progress', key: 'target' } as const, null)
11const found = atom({ plugin: 'superpowers-progress', key: 'found' } as const, null)
12const pinned = atom({ plugin: 'superpowers-progress', key: 'pinned' } as const, null)
13const snapshot = atom({ plugin: 'superpowers-progress', key: 'snapshot' } as const, null)
14const isSuperpowers = atom({ plugin: 'superpowers-progress', key: 'isSuperpowers' } as const, false)
15const isHidden = atom({ plugin: 'superpowers-progress', key: 'isHidden' } as const, false)
16const paneToggled = atom({ plugin: 'superpowers-progress', key: 'paneToggled' } as const, [])
17
18const PANE = 'sp-progress'
19
20const REFRESH_MS = 60_000
21const RESCAN_MS = 5 * 60_000
22
23// What a refresh reuses while the files' modification times stand: file texts, the parsed plans, the worktrees that
24// hold a ledger of the slug, and when discovery last came up empty.
25type Cache = {
26 texts: Map<string, { mtimeMs: number; text: string }>
27 plans: Map<string, { mtimeMs: number; parsed: Plan }>
28 holders: { key: string; at: number; names: string[] } | null
29 discoveredAt: number
30}
31
32const join = (a: string, b: string) => (b.startsWith('/') ? b : `${a.replace(/\/$/, '')}/${b}`)
33
34const text = async ($: EngineInterface, path: string): Promise<string | null> => {
35 try {
36 const r = await $.fs.read(path)
37 return typeof r === 'string' ? r : null
38 } catch {
39 return null
40 }
41}
42
43// A file's text, read again only when its modification time (from the folder listing) moved.
44const cachedText = async ($: EngineInterface, cache: Cache, path: string, mtimeMs: number): Promise<string | null> => {
45 const hit = cache.texts.get(path)
46 if (hit && hit.mtimeMs === mtimeMs) return hit.text
47 const t = await text($, path)
48 if (t !== null) cache.texts.set(path, { mtimeMs, text: t })
49 return t
50}
51
52// The newest entry of a folder, by modification time; null when it is missing or empty.
53const newest = async ($: EngineInterface, dir: string, pick: (n: { name: string; kind: string }) => boolean) => {
54 try {
55 const entries = (await $.fs.list(dir)).filter(pick)
56 return [...entries].sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.name ?? null
57 } catch {
58 return null
59 }
60}
61
62// Folder names beside the worktree (itself first) that hold a ledger folder of the slug. Listing ~70 siblings is the
63// costly part, so the answer is kept for RESCAN_MS.
64async function findHolders($: EngineInterface, parent: string, own: string, slug: string): Promise<string[]> {
65 const names = [own]
66 try {
67 for (const e of await $.fs.list(parent)) {
68 if (e.kind !== 'dir' || e.name === own) continue
69 try {
70 await $.fs.list(`${parent}/${e.name}/.superpowers/sdd/${slug}`)
71 names.push(e.name)
72 } catch {
73 // no ledger of this slug there
74 }
75 }
76 } catch {
77 // parent not listable: the worktree's own ledger alone
78 }
79 return names
80}
81
82// Phase 1 may live in another worktree than phase 2 (each phase is its own branch), so the ledgers of the
83// sibling worktrees that hold the same plan slug are merged: a task done in any of them is done.
84async function readLedger($: EngineInterface, cache: Cache, root: string, slug: string): Promise<{ done: Set<string>; planRef: string | null; stage: StageSource }> {
85 const done = new Set<string>()
86 let planRef: string | null = null
87 const parent = root.slice(0, root.lastIndexOf('/')) || '/'
88 const own = root.slice(root.lastIndexOf('/') + 1)
89 const key = `${root}\n${slug}`
90 const now = await $.clock.now()
91 if (cache.holders?.key !== key || now - cache.holders.at > RESCAN_MS) {
92 cache.holders = { key, at: now, names: await findHolders($, parent, own, slug) }
93 }
94 const files: string[] = []
95 const ledgers: string[] = []
96 for (const name of cache.holders.names) {
97 const dir = `${parent}/${name}/.superpowers/sdd/${slug}`
98 let entries: Awaited<ReturnType<EngineInterface['fs']['list']>>
99 try {
100 entries = await $.fs.list(dir)
101 } catch {
102 continue
103 }
104 files.push(...entries.map(e => e.name))
105 const progress = entries.find(e => e.name === 'progress.md')
106 if (!progress) continue
107 const md = await cachedText($, cache, `${dir}/progress.md`, progress.mtimeMs)
108 if (md === null) continue
109 for (const id of parseLedger(md)) done.add(id)
110 ledgers.push(md)
111 const planPath = entries.find(e => e.name === 'plan-path')
112 if (planRef === null && name === own && planPath) planRef = (await cachedText($, cache, `${dir}/plan-path`, planPath.mtimeMs))?.trim() ?? null
113 }
114 return { done, planRef, stage: { files, ledger: ledgers.join('\n') } }
115}
116
117const WEEK_MS = 7 * 24 * 3600 * 1000
118
119// Every ledger folder in the session's directory and beside it (worktrees live beside the main checkout), by the
120// modification time of its progress.md, newest first.
121async function scanLedgers($: EngineInterface, cwd: string): Promise<{ root: string; slug: string; mtimeMs: number }[]> {
122 const parent = cwd.slice(0, cwd.lastIndexOf('/')) || '/'
123 let dirs: string[] = [cwd]
124 try {
125 dirs = [cwd, ...(await $.fs.list(parent)).filter(e => e.kind === 'dir').map(e => `${parent}/${e.name}`).filter(d => d !== cwd)]
126 } catch {
127 // parent not listable: the session's own directory alone
128 }
129 const out: { root: string; slug: string; mtimeMs: number }[] = []
130 for (const root of dirs) {
131 let slugs: { name: string; kind: string }[] = []
132 try {
133 slugs = await $.fs.list(`${root}/.superpowers/sdd`)
134 } catch {
135 continue
136 }
137 for (const sl of slugs.filter(x => x.kind === 'dir')) {
138 try {
139 const progress = (await $.fs.list(`${root}/.superpowers/sdd/${sl.name}`)).find(x => x.name === 'progress.md')
140 if (progress) out.push({ root, slug: sl.name, mtimeMs: progress.mtimeMs })
141 } catch {
142 // no ledger in this folder
143 }
144 }
145 }
146 return out.sort((x, y) => y.mtimeMs - x.mtimeMs)
147}
148
149// With nothing seen yet (the band stays hidden, this only feeds /sp-progress and a superpowers skill's first draw), the
150// worktree being worked on is the one whose SDD ledger changed last within a week.
151async function discoverTarget($: EngineInterface, cwd: string): Promise<Target | null> {
152 const now = await $.clock.now()
153 const best = (await scanLedgers($, cwd)).find(c => now - c.mtimeMs < WEEK_MS)
154 if (best) return { root: best.root, slug: best.slug, plan: null }
155 const plan = await newest($, `${cwd}/docs/superpowers/plans`, n => n.name.endsWith('.md'))
156 return plan ? { root: cwd, slug: null, plan: `docs/superpowers/plans/${plan}` } : null
157}
158
159// The plan, parsed again only when its file changed.
160async function readPlan($: EngineInterface, cache: Cache, path: string): Promise<Plan | null> {
161 let mtimeMs: number
162 try {
163 mtimeMs = (await $.fs.stat(path)).mtimeMs
164 } catch {
165 return null
166 }
167 const hit = cache.plans.get(path)
168 if (hit && hit.mtimeMs === mtimeMs) return hit.parsed
169 const md = await text($, path)
170 if (md === null) return null
171 const parsed = parsePlan(md)
172 cache.plans.set(path, { mtimeMs, parsed })
173 return parsed
174}
175
176// The ledgers `/sp-progress status` lists: the newest few, with the done/total of each from its own ledger alone.
177async function candidates($: EngineInterface, cache: Cache, cwd: string, active: Target | null): Promise<Candidate[]> {
178 const all = await scanLedgers($, cwd)
179 const shown = all.slice(0, 6)
180 const extra = active?.slug ? all.find(c => c.root === active.root && c.slug === active.slug) : undefined
181 if (extra && !shown.includes(extra)) shown.push(extra)
182 const out: Candidate[] = []
183 for (const c of shown) {
184 const dir = `${c.root}/.superpowers/sdd/${c.slug}`
185 let done: number | null = null
186 let total: number | null = null
187 const md = await cachedText($, cache, `${dir}/progress.md`, c.mtimeMs)
188 const ref = (await text($, `${dir}/plan-path`))?.trim()
189 const plan = md !== null && ref ? await readPlan($, cache, join(c.root, ref)) : null
190 const snap = plan && md !== null ? computeSnapshot(plan, parseLedger(md), ref!) : null
191 if (snap) ({ done, total } = snap)
192 out.push({ ...c, done, total })
193 }
194 return out
195}
196
197// The modification time that ranks a target: its ledger's, or its plan's when it has no ledger.
198async function mtimeOf($: EngineInterface, t: Target): Promise<number> {
199 try {
200 const path = t.slug ? `${t.root}/.superpowers/sdd/${t.slug}/progress.md` : t.plan ? join(t.root, t.plan) : null
201 return path ? (await $.fs.stat(path)).mtimeMs : 0
202 } catch {
203 return 0
204 }
205}
206
207// A tool call of the main loop mentioned a ledger or plan. It refines the target, or sets it when there is none; it
208// switches to another worktree or ledger only when `canSwitch` (the call wrote or ran something) and that ledger is
209// the newer one. A pinned target never moves.
210async function followRefs($: EngineInterface, refs: { root: string; slug: string | null; plan: string | null }, canSwitch: boolean): Promise<void> {
211 if ((await read($, pinned)) !== null) return
212 const old = (await read($, target)) as Target | null
213 const { merged, isSwitch } = mergeRefs(old, refs)
214 if (JSON.stringify(old) === JSON.stringify(merged)) return
215 if (isSwitch && !canSwitch) return
216 if (isSwitch && old && (await mtimeOf($, merged)) <= (await mtimeOf($, old))) return
217 await update($, target, () => merged)
218}
219
220// `force` is /sp-progress: refresh and discover even while the band is not shown.
221async function refresh($: EngineInterface, cache: Cache, force: boolean): Promise<void> {
222 let old = (await read($, snapshot)) as Snapshot | null
223 // A snapshot kept by an older load (no tasks) is never drawn: it goes, with what was cached beside it.
224 if (old && !isCurrentSnapshot(old)) {
225 cache.texts.clear()
226 cache.plans.clear()
227 await update($, snapshot, () => null)
228 old = null
229 }
230 const pin = (await read($, pinned)) as Target | null
231 const seen = (await read($, target)) as Target | null
232 const sp = (await read($, isSuperpowers)) as boolean
233 const hidden = (await read($, isHidden)) as boolean
234 // Nothing is drawn until the session worked on a ledger or plan (or ran a superpowers skill), nor while hidden.
235 if (!force && ((pin === null && seen === null && !sp) || hidden)) return
236 // A target that gives no plan must not leave the snapshot of another one on screen.
237 const drop = async () => {
238 if (old) await update($, snapshot, () => null)
239 }
240
241 let t = pin ?? seen ?? ((await read($, found)) as Target | null)
242 if (!t) {
243 const now = await $.clock.now()
244 if (force || now - cache.discoveredAt > RESCAN_MS) {
245 cache.discoveredAt = now
246 t = await discoverTarget($, await $.session.cwd())
247 if (t) await update($, found, () => t)
248 }
249 }
250 if (!t) return drop()
251
252 let ledger = new Set<string>()
253 let planRef = t.plan
254 let stage: StageSource | undefined
255 if (t.slug) {
256 const l = await readLedger($, cache, t.root, t.slug)
257 ledger = l.done
258 planRef = l.planRef ?? planRef
259 stage = l.stage
260 }
261 if (!planRef) {
262 const newestPlan = await newest($, `${t.root}/docs/superpowers/plans`, n => n.name.endsWith('.md'))
263 planRef = newestPlan ? `docs/superpowers/plans/${newestPlan}` : null
264 }
265 if (!planRef) return drop()
266
267 const planPath = join(t.root, planRef)
268 const plan = await readPlan($, cache, planPath)
269 if (plan === null) return drop()
270
271 const next = computeSnapshot(plan, ledger, planPath, stage)
272 if (!next) return drop()
273 if (JSON.stringify(old) !== JSON.stringify(next)) {
274 await update($, snapshot, () => next)
275 if (isPrimed) for (const line of diffToasts(old, next)) $.ui.toast(line)
276 }
277 isPrimed = true
278}
279
280// Refreshes never overlap: one asked for while another runs makes it go round once more.
281const cache: Cache = { texts: new Map(), plans: new Map(), holders: null, discoveredAt: 0 }
282let running: Promise<void> | null = null
283let again = false
284let forced = false
285// The first snapshot after a (re)load is the baseline; only later ones are compared for toasts.
286let isPrimed = false
287
288function kick($: EngineInterface, force: boolean): Promise<void> {
289 forced ||= force
290 if (running) {
291 again = true
292 return running
293 }
294 running = (async () => {
295 try {
296 do {
297 again = false
298 const f = forced
299 forced = false
300 await refresh($, cache, f).catch(() => {})
301 } while (again)
302 } finally {
303 running = null
304 }
305 })()
306 return running
307}
308
309export const register: Register = on => {
310 let timer: { cancel: () => void } | null = null
311
312 on('session.start', async ($, e, next) => {
313 await $.command.register({ name: 'sp-progress', description: 'Open the superpowers plan pane; hide | show the progress band; status prints diagnostics' })
314 timer?.cancel()
315 timer = $.clock.every(REFRESH_MS, () => void kick($, false))
316 void kick($, false)
317 return next(e)
318 })
319
320 on('command.run', { command: 'sp-progress' }, async ($, e) => {
321 const [word = '', ...rest] = e.args.trim().split(/\s+/)
322 const arg = word.toLowerCase()
323 const query = rest.join(' ')
324 const usage = 'Usage: /sp-progress [hide | show | status | use <slug-or-path> | use auto]; no argument opens the pane.'
325 if (arg === '') {
326 await kick($, true)
327 const opened = await $.ui.open({ id: PANE, title: 'Superpowers' })
328 return { text: opened.isPlaced ? 'Superpowers pane opened.' : `Superpowers pane waits: ${opened.reason}` }
329 }
330 if (arg === 'use') {
331 if (query === '') return { text: usage }
332 if (query.toLowerCase() === 'auto') {
333 await update($, pinned, () => null)
334 await kick($, true)
335 return { text: 'Target unpinned: it follows the work of this session again.' }
336 }
337 const hits = matchCandidates(await scanLedgers($, await $.session.cwd()).then(x => x.map(c => ({ ...c, done: null, total: null }))), query)
338 if (hits.length === 0) return { text: `No ledger matches "${query}". /sp-progress status lists the candidates.` }
339 await update($, pinned, () => ({ root: hits[0]!.root, slug: hits[0]!.slug, plan: null }))
340 await update($, snapshot, () => null)
341 await kick($, true)
342 return { text: `Pinned ${hits[0]!.root} (${hits[0]!.slug})${hits.length > 1 ? `, the newest of ${hits.length} matches` : ''}. /sp-progress use auto releases it.` }
343 }
344 if (arg === 'hide' || arg === 'show') await update($, isHidden, () => arg === 'hide')
345 else if (arg !== 'status') return { text: usage }
346 await kick($, true)
347 const hidden = (await read($, isHidden)) as boolean
348 const pin = (await read($, pinned)) as Target | null
349 const seen = (await read($, target)) as Target | null
350 const sp = (await read($, isSuperpowers)) as boolean
351 const t = pin ?? seen ?? ((await read($, found)) as Target | null)
352 const snap = (await read($, snapshot)) as Snapshot | null
353 const how = pin ? 'pinned' : seen ? 'seen in this session' : 'discovered only'
354 const where = t ? `${t.root} (${t.slug ?? t.plan ?? '?'}, ${how})` : 'nothing found'
355 const what = snap ? `${snap.done}/${snap.total} tasks, ${snap.phases.length} phases${snap.stage ? `, task ${snap.stage.id} stage tracked` : ''}` : 'no snapshot yet'
356 const quiet = pin === null && seen === null && !sp ? ' Not drawn yet: no ledger or plan worked on and no superpowers skill run in this session.' : ''
357 const head = `${hidden ? 'Band hidden' : 'Band shown'}. Target: ${where}. ${what}.${quiet}`
358 if (arg !== 'status') return { text: head }
359 const cands = await candidates($, cache, await $.session.cwd(), t)
360 const list = cands.map(c => candidateLine(c, t !== null && c.root === t.root && c.slug === t.slug)).join('\n')
361 return { text: `${head}\nCandidates (newest ledger first, * is active):\n${list || ' none found'}` }
362 })
363
364 on('tool.call', async ($, e, next) => {
365 const input = e as unknown as Record<string, unknown>
366 // A subagent's or teammate's call carries an agentId: it is another thread's work, never this session's.
367 const isMain = input.agentId === undefined
368 let touched = false
369 if (isMain && input.tool === 'Skill' && typeof input.skill === 'string' && input.skill.startsWith('superpowers:')) {
370 await update($, isSuperpowers, () => true)
371 touched = true
372 }
373 const probe = ['file_path', 'path', 'command', 'prompt', 'pattern']
374 .map(k => (typeof input[k] === 'string' ? (input[k] as string) : ''))
375 .join('\n')
376 const refs = isMain ? extractRefs(probe) : null
377 const result = await next(e)
378 // The tool has run: a ledger it wrote is on disk now, and its modification time can rank it. Nothing below may
379 // throw, or the catch beneath would run the tool again.
380 try {
381 if (refs) {
382 touched = true
383 await followRefs($, refs, isWorkingTool(input.tool))
384 }
385 if (touched) void kick($, false)
386 } catch {
387 // the target stays as it was
388 }
389 return result
390 }).catch(($, e, next) => next(e))
391
392 on('turn.complete', async ($, e, next) => {
393 void kick($, false)
394 return next(e)
395 })
396
397 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
398 const snap = (await read($, snapshot)) as Snapshot | null
399 const toggled = (await read($, paneToggled)) as string[]
400 const { Box, Text, Button } = $.ui.resolve(e)
401 if (snap === null) return <Text dimColor>No superpowers plan found yet.</Text>
402 if (!isCurrentSnapshot(snap)) {
403 // Kept by an older load: rebuilt by the refresh this starts, not drawn.
404 $.clock.after(0, () => void kick($, true))
405 return <Text dimColor>Reading the plan…</Text>
406 }
407
408 const colorOf = (state: 'done' | 'active' | 'todo') => (state === 'done' ? 'success' : state === 'active' ? 'warning' : undefined)
409 const flip = (n: string) => update($, paneToggled, (old: string[]) => (old.includes(n) ? old.filter(x => x !== n) : [...old, n]))
410 return (
411 <Box flexDirection="column">
412 {paneLines(snap, toggled, e.props.bodyColumns).map((l, i) =>
413 l.kind === 'title' ? (
414 <Text key={`t${i}`} bold color="claude">{l.text}</Text>
415 ) : l.kind === 'phase' ? (
416 <Button key={`ph-${l.n}`} label={l.label} onPress={() => flip(l.n)} />
417 ) : l.kind === 'stage' ? (
418 <Text key={`s${i}`} dimColor>{l.text}</Text>
419 ) : (
420 <Box key={`k${l.id}`}>
421 <Text bold={l.state === 'active'} color={colorOf(l.state)} dimColor={l.state === 'todo'}>{l.left}</Text>
422 <Box flexGrow={1} />
423 <Text dimColor>{l.right}</Text>
424 </Box>
425 ),
426 )}
427 </Box>
428 )
429 })
430
431 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
432 const snap = (await read($, snapshot)) as Snapshot | null
433 const t = ((await read($, pinned)) ?? (await read($, target))) as Target | null
434 const sp = (await read($, isSuperpowers)) as boolean
435 const hidden = (await read($, isHidden)) as boolean
436 // Quiet unless a superpowers workflow is plainly under way in THIS session: a ledger or plan path was touched by a
437 // tool call, or a superpowers skill ran. A ledger merely lying near the session's directory does not count.
438 if (e.props.hasSurvey || hidden || snap === null || !isCurrentSnapshot(snap) || !(t !== null || sp)) return next(e)
439
440 const { Box, Text, Button } = $.ui.resolve(e)
441 const cols = e.props.bodyColumns ?? e.viewport?.columns ?? 100
442 const band = layoutBand(snap, cols)
443 const colorOf = (state: 'done' | 'active' | 'todo') => (state === 'done' ? 'success' : state === 'active' ? 'warning' : undefined)
444
445 return (
446 <Box flexDirection="column" width={band.width}>
447 <Box>
448 <Text bold color="claude">{band.title.slice(0, 13)}</Text>
449 <Text dimColor>{band.title.slice(13)}</Text>
450 <Box flexGrow={1} />
451 <Text bold>{band.right} </Text>
452 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
453 </Box>
454 <Box>
455 {band.segments.map((g, i) => (
456 <Box key={`b${i}`} marginRight={i === band.segments.length - 1 ? 0 : 1}>
457 <Text color={colorOf(g.state)} dimColor={g.state === 'todo'}>{'▰'.repeat(g.filled)}</Text>
458 <Text dimColor>{'▱'.repeat(g.empty)}</Text>
459 </Box>
460 ))}
461 </Box>
462 <Box>
463 {band.segments.map((g, i) => (
464 <Box key={`l${i}`} width={g.width} marginRight={i === band.segments.length - 1 ? 0 : 1}>
465 <Text bold={g.state === 'active'} color={colorOf(g.state)} dimColor={g.state === 'todo'} wrap="truncate-end">{g.label}</Text>
466 </Box>
467 ))}
468 </Box>
469 <Text dimColor wrap="truncate-end">{band.footer}</Text>
470 {band.stage ? <Text dimColor wrap="truncate-end">{band.stage}</Text> : null}
471 </Box>
472 )
473 })
474}
475hooks/parse.ts 364 lines1import type { PhaseProgress, Snapshot, Stage, StageState, Target, TaskRow } from '../types'
2
3export type PlanTask = { id: string; title: string; steps: number; stepsDone: number }
4export type PlanPhase = { n: string; title: string; tasks: PlanTask[] }
5export type Plan = { title: string; phases: PlanPhase[] }
6
7const PHASE = /^#{1,2}\s+Phase\s+(\d+)[.:]?\s*(.*)$/i
8const TASK = /^#{2,4}\s+Task\s+([0-9A-Za-z][0-9A-Za-z.]*?)[.:]?\s+(.*)$/
9const TASK_BARE = /^#{2,4}\s+Task\s+([0-9A-Za-z][0-9A-Za-z.]*?)[.:]?\s*$/
10const STEP = /^\s*-\s+\[( |x|X)\]\s/
11
12// `Каркас `shell/` (PR `feat/x`)` -> `Каркас shell/`: the trailing parenthetical (branch, PR) and the backticks are noise in a band.
13const cleanTitle = (t: string) => t.replace(/\s*\([^()]*\)\s*$/, '').replace(/`/g, '').trim()
14
15// A plan is markdown: `# Phase N. title` opens a phase, `### Task N.M: title` a task, `- [ ]` / `- [x]` its steps.
16// Headings and boxes inside code fences are examples, not structure.
17export function parsePlan(md: string): Plan {
18 const phases: PlanPhase[] = []
19 let title = ''
20 let phase: PlanPhase | null = null
21 let task: PlanTask | null = null
22 let isFenced = false
23
24 for (const line of md.split('\n')) {
25 if (/^\s*(```|~~~)/.test(line)) {
26 isFenced = !isFenced
27 continue
28 }
29 if (isFenced) continue
30
31 const p = PHASE.exec(line)
32 if (p) {
33 phase = { n: p[1]!, title: cleanTitle(p[2] ?? ''), tasks: [] }
34 phases.push(phase)
35 task = null
36 continue
37 }
38 const t = TASK.exec(line) ?? TASK_BARE.exec(line)
39 if (t) {
40 if (!phase) {
41 phase = { n: '', title: '', tasks: [] }
42 phases.push(phase)
43 }
44 task = { id: t[1]!, title: (t[2] ?? '').trim(), steps: 0, stepsDone: 0 }
45 phase.tasks.push(task)
46 continue
47 }
48 if (!title && /^#\s+\S/.test(line) && !PHASE.test(line)) {
49 title = line.replace(/^#\s+/, '').trim()
50 }
51 const s = STEP.exec(line)
52 if (s && task) {
53 task.steps += 1
54 if (s[1] !== ' ') task.stepsDone += 1
55 }
56 }
57
58 return { title, phases: phases.filter(x => x.tasks.length > 0) }
59}
60
61// The SDD ledger records `Task 2.6: complete (...)` one line per finished task.
62export function parseLedger(text: string): Set<string> {
63 const done = new Set<string>()
64 for (const line of text.split('\n')) {
65 const m = /^Task\s+([0-9A-Za-z][0-9A-Za-z.]*?):\s+complete\b/.exec(line)
66 if (m) done.add(m[1]!)
67 }
68 return done
69}
70
71const isTaskDone = (t: PlanTask, ledger: Set<string>) =>
72 ledger.has(t.id) || (t.steps > 0 && t.stepsDone === t.steps)
73
74export type TaskFacts = { sha: string | null; minors: number }
75
76// Per task, from the ledger's `Task X: ...` lines: the commit of the latest line that names one (`(commits a..b)` gives
77// b; a bare SHA counts on the `complete` line) and how many `minor (deferred)` lines it has.
78export function ledgerFacts(ledger: string): Map<string, TaskFacts> {
79 const facts = new Map<string, TaskFacts>()
80 for (const line of ledger.split('\n')) {
81 const m = /^Task\s+([0-9A-Za-z][0-9A-Za-z.]*?)(?=[:\s,(])/.exec(line)
82 if (!m) continue
83 const f = facts.get(m[1]!) ?? { sha: null, minors: 0 }
84 facts.set(m[1]!, f)
85 if (/^Task\s+\S+?:\s+minor\s*\(deferred\)/i.test(line)) f.minors += 1
86 const range = /commits?\s+([0-9a-f]{6,40})(?:\.{2,3}([0-9a-f]{6,40}))?/i.exec(line)
87 const bare = /:\s+complete\b/.test(line) ? /\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/.exec(line) : null
88 const sha = range ? (range[2] ?? range[1]!) : bare?.[0]
89 if (sha) f.sha = sha.slice(0, 7)
90 }
91 return facts
92}
93
94// Bumped whenever the snapshot's shape grows; v1 had no `tasks`, v2 added them.
95export const SNAPSHOT_VERSION = 2
96
97export const isCurrentSnapshot = (s: Snapshot) => s.v === SNAPSHOT_VERSION && s.phases.every(p => Array.isArray(p.tasks))
98
99// What the current task's stage reads from: the file names of the ledger folder(s) and the ledger text.
100export type StageSource = { files: string[]; ledger: string }
101
102export function computeSnapshot(plan: Plan, ledger: Set<string>, planPath: string, stageSrc?: StageSource): Snapshot | null {
103 if (plan.phases.length === 0) return null
104
105 const next = plan.phases
106 .flatMap(ph => ph.tasks)
107 .find(t => !isTaskDone(t, ledger))
108 const facts = ledgerFacts(stageSrc?.ledger ?? '')
109 const row = (t: PlanTask): TaskRow => ({
110 id: t.id,
111 title: t.title.replace(/`/g, ''),
112 state: isTaskDone(t, ledger) ? 'done' : t.id === next?.id ? 'active' : 'todo',
113 sha: facts.get(t.id)?.sha ?? null,
114 minors: facts.get(t.id)?.minors ?? 0,
115 })
116 const phases: PhaseProgress[] = plan.phases.map(ph => ({
117 n: ph.n,
118 title: ph.title,
119 total: ph.tasks.length,
120 done: ph.tasks.filter(t => isTaskDone(t, ledger)).length,
121 tasks: ph.tasks.map(row),
122 }))
123
124 return {
125 planTitle: plan.title,
126 planPath,
127 phases,
128 done: phases.reduce((n, p) => n + p.done, 0),
129 total: phases.reduce((n, p) => n + p.total, 0),
130 current: next ? { id: next.id, title: next.title } : null,
131 v: SNAPSHOT_VERSION,
132 stage: next && stageSrc ? deriveStage(next.id, stageSrc.files, stageSrc.ledger) : null,
133 }
134}
135
136const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
137
138// The pipeline of one task as the ledger folder shows it: `task-X-brief.md` is the brief written (implementing),
139// `-report.md` the implementer's report (awaiting review), `-review.md` the verdict; the ledger's own lines tell
140// about fix rounds, the gate (the word `gate` followed by PASS or n/n, never `gated`) and the PR (`PR #N`); `Task X: complete` closes all of it. Null when nothing of the task exists yet.
141export function deriveStage(id: string, files: string[], ledger: string): Stage | null {
142 const idRe = escapeRe(id).replace(/\\\./g, '[.-]')
143 const has = (kind: string) => files.some(f => new RegExp(`^task-${idRe}-${kind}\\.md$`).test(f))
144 const lines = ledger.split('\n').filter(l => new RegExp(`^Task\\s+${escapeRe(id)}(?=[:\\s,])`).test(l))
145 const complete = parseLedger(ledger).has(id)
146
147 const brief = has('brief')
148 const report = has('report')
149 const review = has('review')
150 const fixIdx = lines.reduce((at, l, i) => (/needs fixes|with fixes|fix round/i.test(l) ? i : at), has('fix-brief') ? 0 : -1)
151 const fixSettled = fixIdx >= 0 && lines.slice(fixIdx + 1).some(l => /approved|clean|closed|re-?review/i.test(l))
152 const gate = lines.filter(l => /\bgate\b/i.test(l))
153 const gatePassed = gate.some(l => /\bgate\b[^\n]*?(\bPASS(ED)?\b|\b(\d+)\/\3\b)/i.test(l))
154 const pr = lines.some(l => /PR\s*#\d+/.test(l))
155 if (!brief && !report && !review && lines.length === 0) return null
156
157 const pick = (isDone: boolean, isActive: boolean): StageState => (complete || isDone ? 'done' : isActive ? 'active' : 'todo')
158 return {
159 id,
160 steps: [
161 { label: 'brief', state: pick(brief, false) },
162 { label: 'impl', state: pick(report, brief) },
163 { label: 'review', state: pick(review, report) },
164 { label: 'fix', state: pick(fixSettled, fixIdx >= 0 && !fixSettled) },
165 { label: 'gate', state: pick(gatePassed, gate.length > 0) },
166 { label: 'PR', state: pick(pr, false) },
167 ],
168 }
169}
170
171const MARK: Record<StageState, string> = { done: '✓', active: '●', todo: '·' }
172
173export const stageSteps = (s: Stage) => s.steps.map(x => `${x.label} ${MARK[x.state]}`).join(' › ')
174
175// `2.9 brief ✓ › impl ✓ › review ✓ › fix ● › gate · › PR ·`
176export const stageLine = (s: Stage) => `${s.id} ${stageSteps(s)}`
177
178export function bar(done: number, total: number, width: number): string {
179 if (total <= 0) return '▱'.repeat(width)
180 const filled = Math.round((done / total) * width)
181 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
182}
183
184export const shorten = (s: string, max: number) =>
185 s.length <= max ? s : `${s.slice(0, Math.max(0, max - 1))}…`
186
187const SDD = /(\/[^\s'"`]*?)\/\.superpowers\/sdd\/([^/\s'"`]+)\//
188const PLAN = /(\/[^\s'"`]*?)\/(docs\/superpowers\/plans\/[^\s'"`]+?\.md)/
189
190// Paths a tool call touches tell which worktree and which plan are being worked on.
191export function extractRefs(text: string): { root: string; slug: string | null; plan: string | null } | null {
192 const sdd = SDD.exec(text)
193 if (sdd) return { root: sdd[1]!, slug: sdd[2]!, plan: null }
194 const plan = PLAN.exec(text)
195 if (plan) return { root: plan[1]!, slug: null, plan: plan[2]! }
196 return null
197}
198
199export type Segment = {
200 state: 'done' | 'active' | 'todo'
201 filled: number
202 empty: number
203 label: string
204 width: number
205}
206
207export type BandLayout = {
208 width: number
209 title: string
210 right: string
211 segments: Segment[]
212 footer: string
213 stage: string | null
214}
215
216// Splits `total` columns between phases in proportion to their task counts (largest remainder), each at least `min`.
217function widths(weights: number[], total: number, min: number): number[] {
218 const sum = weights.reduce((a, b) => a + b, 0) || 1
219 const w = weights.map(x => Math.max(min, Math.floor((x / sum) * total)))
220 let used = w.reduce((a, b) => a + b, 0)
221 const order = weights.map((x, i) => [x / sum - Math.floor((x / sum) * total) / total, i] as const).sort((a, b) => b[0] - a[0])
222 for (let k = 0; used < total && order.length > 0; k++) {
223 w[order[k % order.length]![1]]! += 1
224 used += 1
225 }
226 while (used > total) {
227 const i = w.indexOf(Math.max(...w))
228 if (w[i]! <= min) break
229 w[i]! -= 1
230 used -= 1
231 }
232 return w
233}
234
235// The band as numbers and strings, sized to `cols`: a title row, a bar cut into one segment per phase (width follows
236// the phase's task count), a label under each segment, and a closing line about what comes next.
237export function layoutBand(snap: Snapshot, cols: number): BandLayout {
238 const width = Math.max(20, Math.min(cols - 2, 110))
239 const pct = snap.total === 0 ? 0 : Math.round((snap.done / snap.total) * 100)
240 const right = `${snap.done}/${snap.total} ${pct}%`
241 const brand = '◆ superpowers'
242 const budget = width - brand.length - right.length - 14
243 const title = budget >= 8 ? ` ${shorten(snap.planTitle || snap.planPath.split('/').pop() || 'plan', budget)}` : ''
244
245 const activeIdx = snap.phases.findIndex(p => p.done < p.total)
246 const gap = 1
247 const barWidth = width - gap * (snap.phases.length - 1)
248 const w = widths(snap.phases.map(p => p.total), barWidth, 4)
249 const segments: Segment[] = snap.phases.map((p, i) => {
250 const seg = w[i]!
251 const state = p.done === p.total ? 'done' : i === activeIdx ? 'active' : 'todo'
252 const filled = p.total === 0 ? 0 : Math.round((p.done / p.total) * seg)
253 const mark = state === 'done' ? '✓' : state === 'active' ? '▸' : ' '
254 const full = `${mark}P${p.n} ${p.done}/${p.total}`
255 const short = `${mark}P${p.n}`
256 const label = full.length <= seg ? full : short.length <= seg ? short : shorten(short, seg)
257 return { state, filled, empty: seg - filled, label, width: seg }
258 })
259
260 const active = snap.phases[activeIdx]
261 const footer = snap.current
262 ? shorten(`${active ? `P${active.n} ${active.title} · ` : ''}next ${snap.current.id} ${snap.current.title}`, width)
263 : '✓ every task of the plan is done'
264
265 return { width, title: `${brand}${title}`, right, segments, footer, stage: snap.stage ? shorten(stageLine(snap.stage), width) : null }
266}
267
268// (Snapshots kept in `$.state` by an older load have no `tasks`; the two functions below read them as empty until
269// the next refresh.)
270// Toasts for what changed between two refreshes of one plan: a line per task that became done, one per phase that
271// reached its total, one for the plan. Nothing without a previous snapshot of the same plan path; past `max` the
272// rest merge into `+N more done`.
273export function diffToasts(prev: Snapshot | null, next: Snapshot, max = 3): string[] {
274 if (!prev || prev.planPath !== next.planPath) return []
275 const wasDone = new Set(prev.phases.flatMap(p => (p.tasks ?? []).filter(t => t.state === 'done').map(t => `${p.n}/${t.id}`)))
276 const lines: string[] = []
277 for (const p of next.phases) {
278 for (const t of p.tasks ?? []) {
279 if (t.state === 'done' && !wasDone.has(`${p.n}/${t.id}`)) lines.push(`✓ Task ${t.id} done · ${p.n ? `P${p.n} ` : ''}${p.done}/${p.total}`)
280 }
281 }
282 for (const p of next.phases) {
283 const before = prev.phases.find(x => x.n === p.n)
284 if (p.total > 0 && p.done === p.total && (!before || before.done < before.total) && lines.length > 0) {
285 lines.push(`◆ Phase ${p.n} complete (${p.done}/${p.total})`)
286 }
287 }
288 if (next.total > 0 && next.done === next.total && prev.done < prev.total) {
289 lines.push(`◆ Plan complete: ${next.planTitle || next.planPath.split('/').pop()} (${next.done}/${next.total})`)
290 }
291 if (lines.length <= max) return lines
292 return [...lines.slice(0, max - 1), `+${lines.length - (max - 1)} more done`]
293}
294
295export type PaneLine =
296 | { kind: 'title'; text: string }
297 | { kind: 'phase'; n: string; label: string; isExpanded: boolean; state: StageState }
298 | { kind: 'task'; id: string; left: string; right: string; state: StageState }
299 | { kind: 'stage'; text: string }
300
301// A phase shows its tasks when it is the current one, flipped by what the person pressed (`toggled`).
302export const isPhaseExpanded = (snap: Snapshot, n: string, toggled: string[]) => {
303 const active = snap.phases.find(p => p.done < p.total)
304 return (active?.n === n) !== toggled.includes(n)
305}
306
307const TASK_MARK: Record<StageState, string> = { done: '✓', active: '▸', todo: '·' }
308
309// The pane as lines sized to `cols`: the plan, then each phase (a header, and while expanded its tasks with the
310// commit and deferred minors at the right, the current task followed by its stage).
311export function paneLines(snap: Snapshot, toggled: string[], cols: number): PaneLine[] {
312 const width = Math.max(20, cols)
313 const pct = snap.total === 0 ? 0 : Math.round((snap.done / snap.total) * 100)
314 const out: PaneLine[] = [
315 { kind: 'title', text: shorten(`${snap.planTitle || snap.planPath.split('/').pop() || 'plan'} · ${snap.done}/${snap.total} ${pct}%`, width) },
316 ]
317 for (const p of snap.phases) {
318 const isExpanded = isPhaseExpanded(snap, p.n, toggled)
319 const state: StageState = p.done === p.total ? 'done' : p.done > 0 || isExpanded ? 'active' : 'todo'
320 out.push({ kind: 'phase', n: p.n, label: shorten(`${isExpanded ? '▾' : '▸'} ${p.n ? `P${p.n}` : 'Tasks'} ${p.title} ${p.done}/${p.total}`, width), isExpanded, state })
321 if (!isExpanded) continue
322 for (const t of p.tasks ?? []) {
323 const right = [t.sha, t.minors > 0 ? `${t.minors} minor` : null].filter(Boolean).join(' ')
324 const room = Math.max(8, width - right.length - (right ? 1 : 0))
325 out.push({ kind: 'task', id: t.id, left: shorten(` ${TASK_MARK[t.state]} ${t.id} ${t.title}`, room), right, state: t.state })
326 if (t.state === 'active' && snap.stage?.id === t.id) out.push({ kind: 'stage', text: shorten(` ${stageSteps(snap.stage)}`, width) })
327 }
328 }
329 return out
330}
331
332// What a tool call that mentions a ledger or plan path does to the target the session works on. The same worktree
333// (and slug, when both name one) only refines it; another one is a switch, which the caller allows by modification time.
334export function mergeRefs(old: Target | null, refs: { root: string; slug: string | null; plan: string | null }): { merged: Target; isSwitch: boolean } {
335 if (!old) return { merged: { root: refs.root, slug: refs.slug, plan: refs.plan }, isSwitch: false }
336 if (refs.root !== old.root || (refs.slug !== null && old.slug !== null && refs.slug !== old.slug)) {
337 return { merged: { root: refs.root, slug: refs.slug, plan: refs.plan }, isSwitch: true }
338 }
339 return { merged: { root: old.root, slug: refs.slug ?? old.slug, plan: refs.plan ?? old.plan }, isSwitch: false }
340}
341
342// Tools that write or run something: only these, in the main loop, may switch the target. A Read, Grep or Glob of
343// another ledger is looking, not working.
344export const isWorkingTool = (tool: unknown) => tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'Bash'
345
346export type Candidate = { root: string; slug: string; mtimeMs: number; done: number | null; total: number | null }
347
348// Candidates a `/sp-progress use <slug-or-path>` names: a slug (whole or part), a worktree path or folder name, or a
349// path inside a ledger folder; newest ledger first.
350export function matchCandidates(cands: Candidate[], query: string): Candidate[] {
351 const q = query.trim().replace(/\/+$/, '')
352 if (q === '') return []
353 const refs = extractRefs(`${q}/`)
354 const hit = (c: Candidate) =>
355 refs ? c.root === refs.root && c.slug === refs.slug : c.slug === q || c.slug.includes(q) || c.root === q || c.root.endsWith(`/${q}`) || c.root.includes(q)
356 return cands.filter(hit).sort((a, b) => b.mtimeMs - a.mtimeMs)
357}
358
359const stamp = (ms: number) => new Date(ms).toISOString().slice(5, 16).replace('T', ' ')
360
361// `* project-feature-a 2026-10-05-storefront-redesign 10-07 21:40 19/47`
362export const candidateLine = (c: Candidate, isActive: boolean) =>
363 `${isActive ? '*' : ' '} ${c.root.split('/').pop()} ${c.slug} ${stamp(c.mtimeMs)} ${c.done === null || c.total === null ? '?' : `${c.done}/${c.total}`}`
364types/index.d.ts 57 lines1export type Target = { root: string; slug: string | null; plan: string | null }
2
3// One task of a phase as the pane lists it: its state, the commit the ledger names and how many minors it deferred.
4export type TaskRow = {
5 id: string
6 title: string
7 state: StageState
8 sha: string | null
9 minors: number
10}
11
12export type PhaseProgress = {
13 n: string
14 title: string
15 done: number
16 total: number
17 tasks: TaskRow[]
18}
19
20export type StageState = 'done' | 'active' | 'todo'
21
22// The pipeline of one task (brief, impl, review, fix, gate, PR) as the ledger folder shows it.
23export type Stage = {
24 id: string
25 steps: { label: string; state: StageState }[]
26}
27
28export type Snapshot = {
29 // The shape's version: a snapshot kept in `$.state` by an older load is rebuilt, never drawn.
30 v: number
31 planTitle: string
32 planPath: string
33 phases: PhaseProgress[]
34 done: number
35 total: number
36 current: { id: string; title: string } | null
37 stage: Stage | null
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'superpowers-progress': {
43 // Seen in this session: a tool call touched a ledger or plan path.
44 target: Target | null
45 // Where discovery found a fresh ledger; never counts as seen.
46 found: Target | null
47 // Set by `/sp-progress use <slug-or-path>`: beats every automatic switch until `use auto`.
48 pinned: Target | null
49 snapshot: Snapshot | null
50 isSuperpowers: boolean
51 isHidden: boolean
52 // Phase numbers whose collapsed/expanded state the person flipped from its default in the pane.
53 paneToggled: string[]
54 }
55 }
56}
57