SLOPSHOPPER

superpowers-progress

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-07Dave93/superpowers-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · superpowers-progress
│ ┃ Superpowers ✕ › fix the failing auth test and add an audit log call │ ┃ No superpowers plan found yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sp-progress │ ⎿ superpowers-progress: Superpowers pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Superpowers
No superpowers plan found yet.
README

superpowers-progress

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:

  • the plan file, docs/superpowers/plans/*.md;
  • the SDD ledger of the run, .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.

Install

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.

Command

/sp-progress with no argument opens a pane with every phase and task. Arguments:

ArgumentEffect
hide, showhide or show the band
statusprint the active target and the newest ledgers found
use <slug-or-path>pin a ledger; it beats the automatic choice
use autorelease 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.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

.claude-plugin/types/ is written by the engine on load and is git-ignored.

License

MIT

Source 3 files
hooks/register.tsx 475 lines
1import { 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}
475
hooks/parse.ts 364 lines
1import 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}`}`
364
types/index.d.ts 57 lines
1export 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