Live tracker for obra/superpowers: a stage rail above the prompt, a dashboard pane, resume detection, toasts and optional guards.

A live tracker for obra/superpowers inside Claude Code. It shows a stage rail in the band above the prompt and a dashboard pane, toasts when work lands, and offers to resume unfinished plans. Optional modes add nudges and guards.
It is an observer first. In the default mode it never blocks, rewrites or delays anything. It never writes inside superpowers' own folders (.superpowers/sdd/, .superpowers/brainstorm/, docs/superpowers/, the plugin install). It makes no network calls and sends no telemetry.
Built against superpowers 6.4.2 (commit 8ca22db). Every superpowers format the tracker depends on lives in hooks/patterns.ts, so a new superpowers release means editing that one file.
From the nalyk-skills marketplace, at user scope. It loads in every project and stays dormant wherever superpowers isn't used.
/plugin marketplace add nalyk/nalyk-skills
/plugin install superpowers-tracker@nalyk-skills --scope user
Requirements: Claude Code with mods (function hooks), built and tested on 2.1.289. superpowers itself is optional: without it the tracker loads quietly and shows nothing.
To try a working copy without installing, load the folder for one session:
claude --plugin-dir plugins/superpowers-tracker
You never need to type /sp for it to come alive.
| State | When | What you see |
|---|---|---|
| dormant | No superpowers anywhere | Nothing. No timers, no scan beyond one stat. |
| armed | superpowers is installed, or its bootstrap is in context | ▐ϟ superpowers▌ 6.4.2 · ready, muted, no motion (hidden under band compact) |
| resumable | The startup scan found earlier or still-running work | The resume capsule, a toast, and a ghost-text prompt suggestion |
| live | The first superpowers signal: a skill, an SDD script, a write to docs/superpowers/, a superpowers-shaped subagent | The full rail, with a pulse |
| shipped | Every task is done and the final review is clean | A spark, then a gold ✓ shipped capsule for 30 s |
The startup scan runs on every session start, after /clear, --continue and --resume. It never delays the session. It looks in this checkout and in every git worktree (git worktree list), and finds:
.superpowers/sdd/*/progress.md against the plan's tasks), including orphaned workspaces whose plan no longer exists;◐ live in another session (likely). The tracker follows it read-only and never acts on it;Results are cached in the plugin store, keyed by repo root and file mtimes.
▐ϟ superpowers▌ ARCH ● Brainstorm ━━━ ● Spec ━━━ ● Plan ━━━ ◆ Execute ▕█████▋ ▏ 5/9 ○ Review ╌╌╌ ○ Finish ╌╌╌ ⚖ 2 ⌁ 1 ⚠ 1
⠹ implementer ◈ sonnet · Task 6 · Idempotency keys ●●○○○ 1m12s ✗ ● RED→GREEN ✓ ▂▂▃▃▄▅
Resumable:
▐ϟ resumable▌ Retry Policy · ▕███▍ ▏ 1/3 · Task 2 at fix 2/5 · 3h ago ⚖ 1 r: resume o: open x: dismiss
A critical warning replaces row 2 with a red, round-bordered strip, [a] ack [o] open. It stays until it is acknowledged.
Width tiers:
| Columns | Band |
|---|---|
| under 50 | ϟ E ▕█████▍ ▏ 5/9 ⚠1 |
| 50–89 | Initials (B S P E R F), with the current stage spelled out |
| 90–139 | Everything |
| 140 and up | Also the plan title |
Motion is purposeful only:
/sp config motion off, NO_COLOR or an ANSI theme stops all of it. An idle band runs no timer.
/sp)Seven tabs, switched with 1–7, or n and p:
╭─────────────────────────────────────────────────────────────────────╮
│ ϟ superpowers execute › 2026-10-05-retry-policy … OBSERVE 6.4.2 │
│ 1: Flow 2 Plan 3: Agents 4: Ledger 5: Tests 6: Workspace 7 │
│ T A S K S Retry Policy │
│ ● #1 Backoff calculator ████████████ │
│ ◐ #2 Retry policy ·········· ●●○○○ │
│ ○ #3 HTTP wrapper ············ │
│ k: up j: down v: expand n: next tab p: prev tab e: export q │
╰─────────────────────────────────────────────────────────────────────╯
┃, subagents fork ├━━◐ and rejoin ╰━━●, and compactions break the line with ┊. v opens the selected station.█, review █, fix rounds ▒, now │.RED→GREEN ✓ or ⚠ impl first badge, and the debugging meter.claude --resume <id>.ack and open evidence.Every tab has a designed empty state, a loading skeleton, and a "couldn't parse … showing last good read" card.
On the desktop app, the rail and the Flow map draw as interactive SVG, with gradients, a SMIL shimmer and tooltips. VS Code and mobile get the Box/Text version.
| Mode | Effect |
|---|---|
observe (default) | Records and displays only. Never denies, rewrites or injects. |
assist | Adds a ≤ 3-line ledger note to the model's context. It is added to the next prompt after a compaction, to the first prompt after resuming a plan mid-execution, and to the result of an Agent call that re-dispatches a completed task. The note quotes the ledger and never guesses. |
strict | Assist, plus denials that name the evidence: an implementer for a task the ledger marks complete; a second implementer while one runs; an SDD subagent with no model; a main-loop source write while brainstorming is on the bounded or architectural path without approval. It never blocks reads, tests, git or docs, and never denies because of a session it only inferred. The band shows STRICT. |
| Command | ||||
|---|---|---|---|---|
/sp [1-7] | Open the pane (on a tab) | |||
/sp status | Stage, tasks, warnings, rulings, minors | |||
/sp ledger | Rulings I made, parked findings, deferred minors (ready to paste) | |||
/sp export | Markdown report to .superpowers/tracker/<session>.md (self-ignoring folder) | |||
| `/sp ack [id\ | all]` | Acknowledge warnings | ||
| `/sp mode observe\ | assist\ | strict` | See Modes | |
| `/sp band auto\ | always\ | compact\ | off` | off moves a one-line summary to the status line |
/sp config [key [value]] | Show or change settings | |||
/sp reset | Clear this session's tracker state; settings stay | |||
/sp resume [n] | Fill the prompt box with candidate n's resume text. You press Enter. | |||
/sp dismiss [n] [--forget] | Hide a candidate for this session, or for good. It returns if its ledger changes. | |||
/sp scan | Rerun the startup scan | |||
/sp demo | A scripted 60-second run through every state | |||
/sp theme | Token swatches with contrast ratios; preview dark, light, daltonized, ANSI | |||
/sp diagnose | The phrase that triggers diagnosing-superpowers, and the session id; offers to send it |
/sp config)| Key | Default |
|---|---|
motion | on |
theme | auto (follows Claude Code's theme and NO_COLOR), dark, light, daltonized, ansi |
toasts | on. Toasts within 1 s fold into one. |
testRunners | npm, pnpm, yarn and bun test; vitest, jest, pytest, go test, cargo test, rspec, mix test, dotnet test, phpunit, task-done |
testFiles | test/, *.test.*, *_test.*, test_*.py, … |
specDir, planDir | docs/superpowers/specs, docs/superpowers/plans |
pollMs | 2000. The ledger is polled only while this session executes a plan, or follows another session's. |
windowDays | 14 |
autoOpenPane | never, resumable, live |
mode, band | See above |
Settings persist in the plugin store. Session data lives in $.state and survives hot reloads.
Each stage owns a hue, from ideas (violet) to shipped (gold): brainstorm #B48CFF, spec #7AA2FF, plan #5CC8FF, execute #3DDBB5, review #A3E635, finish #FFC857.
hooks/ui/oklch.ts).#38BDF8 and critical to #FB923C.● done, ◐ running, ○ pending, ✗ failed, ⚠ parked; model tiers ◇ ◈ ◆.ϟ (U+03DF, single width). There are no emoji anywhere.Use a terminal at 120 or 160 columns, in fullscreen mode, so the pane docks.
claude --plugin-dir plugins/superpowers-tracker
/sp demo # band and pane play 60 s through every state
Record with your tool of choice, for example vhs, asciinema plus agg, or a screen recorder. /sp theme switches palettes between takes.
claude plugin validate plugins/superpowers-tracker
claude plugin test plugins/superpowers-tracker
From the repository root, make test runs both, with every other plugin's checks. The tests live in tests/engine/.
The 61 tests cover:
.worktrees/, orphan, unexecuted plan, spec without a plan, live and dead companion, live elsewhere, --resume replay;+N pill, suggestions, /sp resume (fills, never submits), dismiss and forget across a restart, the scan cache;claude --debug. Look for ui.render (AbovePrompt): a hook returned a tree that does not validate, and for superpowers-tracker: lines./sp scan. If superpowers isn't installed or enabled and no bootstrap is in context, the tracker stays dormant until the first skill fires.claude plugin test says hooks modules are turned off. Start claude once with network access, then run it again./sp dismiss n --forget, or narrow windowDays.hooks/register.tsx 925 lines1// superpowers-tracker: the one module that holds `$`. It builds the Io every
2// other module works through, owns the $.state atoms, and registers the hooks.
3// An observer first: every hook runs next(e), records, and returns the result.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7import type { Candidate, Settings } from '../types'
8import type { Io, Message } from './io'
9import { safe } from './io'
10import {
11 DEFAULT_SETTINGS,
12 FRESH_DEMO,
13 FRESH_ENV,
14 FRESH_LEDGER,
15 FRESH_RESUME,
16 FRESH_UI,
17 freshRun,
18} from './state'
19import { BOOTSTRAP, BUILT_AGAINST, DOC_FILE, SP_PRIVATE, normalizeSkill, planFile, specFile } from './patterns'
20import { isTestFile, joinPath, note, relTo, warn, type Toaster } from './core'
21import { onSkill } from './signals/skills'
22import { onToolResult, refreshGit } from './signals/tools'
23import { assessSpawn, onAgentDone, recordSpawn } from './signals/agents'
24import { checkBootstrap, maybeShip, onMainAnswer, onPromptSubmitted } from './signals/answers'
25import { refreshLedger, refreshPlan } from './signals/plan'
26import { guardWrite, ledgerNote, ledgerDone } from './modes'
27import { LIVE_WINDOW_MS, projectDirName, scan } from './scan'
28import { dismissKey, leftOffKey, loadSnapshot, mergeSnapshot, rehydrate, saveLeftOff, saveSnapshot, type LeftOff } from './persist'
29import { parseLedger, progressOf } from './parsers'
30import { applyConfig, configText, diagnoseText, exportMarkdown, ledgerText, parseSp } from './commands'
31import { buildBand, statusText, visibleCandidates, SHIP_SPARK_MS, SHIP_HOLD_MS, type World } from './ui/viewModel'
32import { pickTheme, arcRamp, type Tokens } from './ui/theme'
33import { PaneView } from './ui/pane/Pane'
34import type { PaneActions, PaneCtx } from './ui/pane/kit'
35import { BandText } from './ui/band/BandText'
36import { ThemePreview } from './ui/themePreview'
37import { railSvg, flowSvg } from './ui/desktop/svg'
38import { encodeCells, sparkFrame } from './ui/raster'
39import { demoWorld, DEMO_MS } from './ui/demo'
40
41const PANE = 'superpowers-tracker'
42const THEME_PANE = 'sp-theme'
43
44const runA = atom({ plugin: 'superpowers-tracker', key: 'run' } as const, freshRun(0))
45const timelineA = atom({ plugin: 'superpowers-tracker', key: 'timeline' } as const, [])
46const skillsA = atom({ plugin: 'superpowers-tracker', key: 'skills' } as const, [])
47const agentsA = atom({ plugin: 'superpowers-tracker', key: 'agents' } as const, [])
48const ledgerA = atom({ plugin: 'superpowers-tracker', key: 'ledger' } as const, FRESH_LEDGER)
49const testsA = atom({ plugin: 'superpowers-tracker', key: 'tests' } as const, [])
50const warningsA = atom({ plugin: 'superpowers-tracker', key: 'warnings' } as const, [])
51const envA = atom({ plugin: 'superpowers-tracker', key: 'env' } as const, FRESH_ENV)
52const uiA = atom({ plugin: 'superpowers-tracker', key: 'ui' } as const, FRESH_UI)
53const resumeA = atom({ plugin: 'superpowers-tracker', key: 'resume' } as const, FRESH_RESUME)
54const settingsA = atom({ plugin: 'superpowers-tracker', key: 'settings' } as const, DEFAULT_SETTINGS)
55const demoA = atom({ plugin: 'superpowers-tracker', key: 'demo' } as const, FRESH_DEMO)
56
57/** The Io over `$`: the only place the tracker's logic touches the engine. */
58function makeIo($: EngineInterface): Io {
59 return {
60 state: {
61 run: { get: () => read($, runA), set: fn => update($, runA, fn) },
62 timeline: { get: () => read($, timelineA), set: fn => update($, timelineA, fn) },
63 skills: { get: () => read($, skillsA), set: fn => update($, skillsA, fn) },
64 agents: { get: () => read($, agentsA), set: fn => update($, agentsA, fn) },
65 ledger: { get: () => read($, ledgerA), set: fn => update($, ledgerA, fn) },
66 tests: { get: () => read($, testsA), set: fn => update($, testsA, fn) },
67 warnings: { get: () => read($, warningsA), set: fn => update($, warningsA, fn) },
68 env: { get: () => read($, envA), set: fn => update($, envA, fn) },
69 ui: { get: () => read($, uiA), set: fn => update($, uiA, fn) },
70 resume: { get: () => read($, resumeA), set: fn => update($, resumeA, fn) },
71 settings: { get: () => read($, settingsA), set: fn => update($, settingsA, fn) },
72 demo: { get: () => read($, demoA), set: fn => update($, demoA, fn) },
73 },
74 now: () => $.clock.now(),
75 fs: {
76 read: p => $.fs.read(p),
77 list: async p => (await $.fs.list(p)).map(e => ({ name: e.name, kind: e.kind, size: e.size, mtimeMs: e.mtimeMs })),
78 stat: async p => {
79 const s = await $.fs.stat(p)
80 return { kind: s.kind, size: s.size, mtimeMs: s.mtimeMs }
81 },
82 exists: p => $.fs.exists(p),
83 write: (p, t) => $.fs.write(p, t),
84 },
85 run: async (argv, cwd) => {
86 const r = await $.process.run(argv, { timeoutMs: 5000, ...(cwd ? { cwd } : {}) })
87 return { code: r.exitCode, out: r.stdout, err: r.stderr }
88 },
89 store: {
90 get: k => $.store.get(k),
91 set: (k, v) => $.store.set(k, v),
92 del: k => $.store.delete(k),
93 keys: () => $.store.keys(),
94 },
95 ui: {
96 toast: (t, ms) => $.ui.toast(t, ms ? { timeoutMs: ms } : undefined),
97 status: t => $.ui.status(t),
98 notice: (id, t) => {
99 try {
100 $.ui.notice(id, t)
101 } catch {
102 // The call's dialog may already be closed.
103 }
104 },
105 log: t => $.ui.log(t, { to: 'debug' }),
106 redraw: () => $.ui.invalidate('ui.render'),
107 },
108 session: {
109 id: () => $.session.id(),
110 cwd: () => $.session.cwd(),
111 messages: async () => (await $.session.messages()) as unknown as Message[],
112 contextPercent: async () => (await $.session.usage()).context.percent,
113 costUsd: async () => (await $.session.usage()).cost?.usd,
114 },
115 settingsFile: () => $.settings.read() as Promise<Record<string, unknown>>,
116 home: () => $.env.get('HOME'),
117 env: name => (name === 'NO_COLOR' ? $.env.get('NO_COLOR') : name === 'COLORTERM' ? $.env.get('COLORTERM') : $.env.get('TERM')),
118 }
119}
120
121/** Every value the drawing reads, in one World. Called while drawing, so it subscribes. */
122async function readWorld($: EngineInterface): Promise<World> {
123 const [run, timeline, skills, agents, ledger, tests, warnings, env, ui, resume, settings, demo] = await Promise.all([
124 read($, runA),
125 read($, timelineA),
126 read($, skillsA),
127 read($, agentsA),
128 read($, ledgerA),
129 read($, testsA),
130 read($, warningsA),
131 read($, envA),
132 read($, uiA),
133 read($, resumeA),
134 read($, settingsA),
135 read($, demoA),
136 ])
137 return { run, timeline, skills, agents, ledger, tests, warnings, env, ui, resume, settings, demo }
138}
139
140
141/** Working memory of one load of the module, created in register(): timers,
142 * the toast batch, what is followed. Never module-level; a reload starts it over. */
143type Mem = {
144 theme: Tokens
145 version: string
146 root: string
147 poll: { cancel: () => void } | undefined
148 snapTimer: { cancel: () => void } | undefined
149 toastTimer: { cancel: () => void } | undefined
150 toastBuf: { line: string; sev?: string }[]
151 bandRequestId: string | undefined
152 sparkTimer: { cancel: () => void } | undefined
153 demoTimer: { cancel: () => void } | undefined
154 pendingAssist: boolean
155 spectating: { id: string; workspace: string; lastMtime: number; quietSince: number } | undefined
156 redispatched: Set<string>
157}
158
159const newMem = (): Mem => ({
160 theme: pickTheme('auto', undefined, false),
161 version: BUILT_AGAINST,
162 root: '',
163 poll: undefined,
164 snapTimer: undefined,
165 toastTimer: undefined,
166 toastBuf: [],
167 bandRequestId: undefined,
168 sparkTimer: undefined,
169 demoTimer: undefined,
170 pendingAssist: false,
171 spectating: undefined,
172 redispatched: new Set(),
173})
174
175/** One-line toasts; several within 1 s fold into one summary. */
176const makeToaster = ($: EngineInterface, m: Mem): Toaster => (glyph, subject, detail, sev) => {
177 m.toastBuf.push({ line: `${glyph} ${subject}${detail ? ` · ${detail}` : ''}`, ...(sev ? { sev } : {}) })
178 if (m.toastTimer) return
179 m.toastTimer = $.clock.after(1000, () => {
180 m.toastTimer = undefined
181 const batch = m.toastBuf
182 m.toastBuf = []
183 void (async () => {
184 const s = await read($, settingsA)
185 if (!s.toasts || batch.length === 0) return
186 const critical = batch.filter(b => b.sev === 'critical')
187 if (critical.length) for (const c of critical) $.ui.toast(c.line, { timeoutMs: 8000 })
188 const rest = batch.filter(b => b.sev !== 'critical')
189 if (rest.length === 1) $.ui.toast(rest[0]!.line)
190 else if (rest.length > 1) $.ui.toast(`● ${rest.length} updates · ${rest.map(r => r.line.slice(2)).join(' · ').slice(0, 160)}`, { timeoutMs: 6000 })
191 })()
192 })
193}
194
195
196const refreshTheme = async ($: EngineInterface, m: Mem) => {
197 const s = await read($, settingsA)
198 const rows = await safe($.config.list(), [])
199 const engineTheme = rows.find(r => r.key === 'theme')?.value
200 const noColor = (await safe($.env.get('NO_COLOR'), undefined)) !== undefined
201 m.theme = pickTheme(s.theme, typeof engineTheme === 'string' ? engineTheme : undefined, noColor)
202}
203
204
205/** Is superpowers installed and enabled? Version from the installed record. */
206const detectInstall = async ($: EngineInterface, m: Mem, io: Io): Promise<{ installed: boolean; version?: string }> => {
207 const settings = await safe(io.settingsFile(), {} as Record<string, unknown>)
208 const enabled = Object.entries((settings.enabledPlugins ?? {}) as Record<string, unknown>).some(([k, v]) => k.startsWith('superpowers@') && v === true)
209 const home = await safe(io.home(), undefined)
210 if (!home) return { installed: enabled }
211 const record = await safe($.fs.read(`${home}/.claude/plugins/installed_plugins.json`), '')
212 const hit = /"superpowers@[^"]+"\s*:\s*\[\s*\{[^\]]*?"version"\s*:\s*"([^"]+)"/.exec(record)
213 return { installed: enabled || !!hit, ...(hit?.[1] ? { version: hit[1] } : {}) }
214}
215
216
217const loadSettings = async ($: EngineInterface, m: Mem) => {
218 const saved = (await safe($.store.get('settings'), undefined)) as Partial<Settings> | undefined
219 if (saved) await update($, settingsA, s => ({ ...s, ...saved }))
220}
221
222const saveSettings = async ($: EngineInterface, m: Mem) => safe($.store.set('settings', await read($, settingsA)), undefined)
223
224
225/** The ledger poll: only while a plan is active or another session is followed, and only on change. */
226const ensurePoll = async ($: EngineInterface, m: Mem) => {
227 const io = makeIo($)
228 const run = await read($, runA)
229 const s = await read($, settingsA)
230 const wanted = (run.lifecycle === 'live' && !!run.workspace && (run.stage === 'execute' || run.stage === 'review')) || !!m.spectating
231 if (wanted && !m.poll) {
232 m.poll = $.clock.every(Math.max(500, s.pollMs), () => void tickPoll($, m, io))
233 } else if (!wanted && m.poll) {
234 m.poll.cancel()
235 m.poll = undefined
236 }
237}
238
239
240const tickPoll = async ($: EngineInterface, m: Mem, io: Io) => {
241 const toast = makeToaster($, m)
242 const change = await safe(refreshLedger(io, toast), undefined)
243 if (change && (change.completed.length || change.finalReview)) scheduleSnapshot($, m)
244 await safe(maybeShip(io, toast), undefined)
245 if (m.spectating) await followElsewhere($, m, io)
246 await ensurePoll($, m)
247}
248
249
250/** Spectator mode: another session is probably executing; read its ledger, never act on it. */
251const followElsewhere = async ($: EngineInterface, m: Mem, io: Io) => {
252 if (!m.spectating) return
253 const path = joinPath(m.spectating.workspace, 'progress.md')
254 const st = await safe(io.fs.stat(path), undefined)
255 const now = await io.now()
256 if (!st) {
257 m.spectating = undefined
258 return
259 }
260 if (st.mtimeMs === m.spectating.lastMtime) {
261 if (now - m.spectating.quietSince > LIVE_WINDOW_MS * 2) m.spectating = undefined
262 return
263 }
264 const text = await safe(io.fs.read(path), '')
265 const p = progressOf(parseLedger(text).entries)
266 const id = m.spectating.id
267 m.spectating = { ...m.spectating, lastMtime: st.mtimeMs, quietSince: now }
268 await update($, resumeA, r => ({
269 ...r,
270 candidates: r.candidates.map(c =>
271 c.id === id
272 ? { ...c, kind: 'live-elsewhere' as const, done: p.done.length, mtime: st.mtimeMs, ...(p.fixing.size ? { current: [...p.fixing.keys()][0]!, fixRound: [...p.fixing.values()][0]! } : {}) }
273 : c,
274 ),
275 }))
276}
277
278
279const scheduleSnapshot = ($: EngineInterface, m: Mem) => {
280 if (m.snapTimer || !m.root) return
281 m.snapTimer = $.clock.after(30_000, () => {
282 m.snapTimer = undefined
283 void safe(saveSnapshot(makeIo($), m.root), false)
284 })
285}
286
287
288/** The startup scan, in the background: never delays the session. */
289const runScan = async ($: EngineInterface, m: Mem, force = false) => {
290 const io = makeIo($)
291 const toast = makeToaster($, m)
292 if (!m.root) return
293 await update($, resumeA, r => ({ ...r, scanning: true }))
294 const settings = await read($, settingsA)
295 const result = await safe(scan(io, m.root, settings, { force }), undefined)
296 if (!result) {
297 await update($, resumeA, r => ({ ...r, scanning: false }))
298 return
299 }
300 const forgotten = ((await safe($.store.get(dismissKey(m.root)), [])) as { id: string; mtime: number }[]) ?? []
301 let candidates = result.candidates.filter(c => !forgotten.some(f => f.id === c.id && f.mtime >= c.mtime))
302 const left = (await safe($.store.get(leftOffKey(m.root)), undefined)) as LeftOff | undefined
303 if (left) candidates = candidates.map(c => (c.planPath && left.planPath.endsWith(c.planPath) ? { ...c, resumeCmd: left.resume } : c))
304 const now = await io.now()
305 await update($, resumeA, r => ({ ...r, candidates, scannedAt: now, scanning: false }))
306 const top = candidates[0]
307 const run = await read($, runA)
308 if (top && run.lifecycle !== 'live') {
309 await update($, runA, r => (r.lifecycle === 'live' ? r : { ...r, lifecycle: 'resumable' as const, pulseAt: now }))
310 const resume = await read($, resumeA)
311 if (!resume.toasted.includes(top.id)) {
312 await update($, resumeA, r => ({ ...r, toasted: [...r.toasted, top.id] }))
313 const what = top.kind === 'planned' ? 'Found a plan not yet executed' : top.kind === 'spec' ? 'Found a spec awaiting a plan' : top.kind === 'companion' ? 'Visual companion still up' : top.kind === 'live-elsewhere' ? 'Plan running in another session (likely)' : 'Found unfinished plan'
314 toast('●', what, `${top.title}${top.total ? ` · ${top.done} of ${top.total} tasks done` : ''}`)
315 }
316 void safe($.prompt.suggest({ text: top.suggest }), { isShown: false })
317 const s = await read($, settingsA)
318 if (s.mode === 'assist' && (top.kind === 'mid-exec')) m.pendingAssist = true
319 if (s.autoOpenPane === 'resumable') void safe($.ui.open({ id: PANE, title: 'superpowers' }), { isPlaced: false, reason: '' })
320 if (top.kind === 'live-elsewhere' && top.workspace) {
321 m.spectating = { id: top.id, workspace: top.workspace, lastMtime: top.mtime, quietSince: now }
322 await ensurePoll($, m)
323 }
324 // The pane opens on the candidate's plan and ledger, read-only, so a
325 // re-dispatch of a completed task is caught from the first turn on.
326 if ((top.kind === 'mid-exec' || top.kind === 'live-elsewhere') && top.workspace && top.planPath && !run.planPath) {
327 const planAbs = joinPath(top.worktree ?? m.root, top.planPath)
328 await update($, runA, r => ({ ...r, workspace: top.workspace!, spectator: top.kind === 'live-elsewhere' }))
329 await safe(refreshPlan(io, planAbs), undefined)
330 await safe(refreshLedger(io, toast, { quiet: true }), undefined)
331 }
332 if (top.planPath) {
333 const snap = await safe(loadSnapshot(io, m.root, joinPath(m.root, top.planPath)), undefined)
334 if (snap) await safe(mergeSnapshot(io, snap), undefined)
335 }
336 }
337 await lastSuperpowersSession($, m, io)
338 void note
339}
340
341
342/** The last session in this project that used superpowers: tails of transcripts only. */
343const lastSuperpowersSession = async ($: EngineInterface, m: Mem, io: Io) => {
344 const home = await safe(io.home(), undefined)
345 const cwd = await safe(io.session.cwd(), '')
346 const me = await safe(io.session.id(), '')
347 if (!home || !cwd) return
348 const dir = `${home}/.claude/projects/${projectDirName(cwd)}`
349 const files = (await safe(io.fs.list(dir), [])).filter(f => f.kind === 'file' && f.name.endsWith('.jsonl') && !f.name.startsWith(me)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 5)
350 const now = await io.now()
351 for (const f of files) {
352 const tail = await safe(io.run(['tail', '-c', '65536', joinPath(dir, f.name)]), undefined)
353 const skill = tail ? [...tail.out.matchAll(/superpowers:([a-z-]+)/g)].map(m => normalizeSkill(m[1])).filter(Boolean).at(-1) : undefined
354 if (skill) {
355 const id = f.name.replace(/\.jsonl$/, '')
356 await update($, resumeA, r => ({ ...r, lastSession: { id, ago: now - f.mtimeMs, skill } }))
357 return
358 }
359 }
360}
361
362
363const resumeText = async ($: EngineInterface, m: Mem, n: number): Promise<Candidate | undefined> => visibleCandidates(await read($, resumeA))[n - 1]
364
365
366const doResume = async ($: EngineInterface, m: Mem, n: number): Promise<string> => {
367 const c = await resumeText($, m, n)
368 if (!c) return 'No resume candidate. Run /sp scan to look again.'
369 const r = await safe($.prompt.fill({ text: c.suggest, mode: 'replace' }), { isFilled: false, text: '', cursor: 0 })
370 return r.isFilled ? `Prompt filled for "${c.title}". Press Enter to send it.` : `Couldn't fill the prompt box. The text:\n${c.suggest}`
371}
372
373
374const doDismiss = async ($: EngineInterface, m: Mem, n: number, forget: boolean): Promise<string> => {
375 const c = await resumeText($, m, n)
376 if (!c) return 'Nothing to dismiss.'
377 await update($, resumeA, r => ({ ...r, dismissed: [...r.dismissed, c.id] }))
378 if (forget && m.root) {
379 const list = (((await safe($.store.get(dismissKey(m.root)), [])) ?? []) as { id: string; mtime: number }[]).filter(x => x.id !== c.id)
380 await $.store.set(dismissKey(m.root), [...list, { id: c.id, mtime: c.mtime }].slice(-100))
381 }
382 const left = visibleCandidates(await read($, resumeA))
383 if (!left.length) await update($, runA, r => (r.lifecycle === 'resumable' ? { ...r, lifecycle: 'armed' as const } : r))
384 return forget ? `Dismissed "${c.title}" for good; it returns if its ledger changes.` : `Dismissed "${c.title}" for this session.`
385}
386
387
388const ack = async ($: EngineInterface, m: Mem, target: string): Promise<number> => {
389 let n = 0
390 await update($, warningsA, list =>
391 list.map(w => {
392 if (w.acked || (target !== 'all' && w.id !== target && w.code !== target)) return w
393 n += 1
394 return { ...w, acked: true }
395 }),
396 )
397 return n
398}
399
400
401const openPane = async ($: EngineInterface, m: Mem, tab?: number) => {
402 if (tab) await update($, uiA, u => ({ ...u, tab, selection: 0, expanded: -1 }))
403 return safe($.ui.open({ id: PANE, title: 'superpowers', focus: true }), { isPlaced: false, reason: 'unavailable' })
404}
405
406
407const exportRun = async ($: EngineInterface, m: Mem): Promise<string> => {
408 const w = await readWorld($)
409 const id = await safe($.session.id(), 'session')
410 const cwd = await safe($.session.cwd(), '')
411 const base = joinPath(m.root || cwd, '.superpowers/tracker')
412 const path = joinPath(base, `${id}.md`)
413 await $.fs.write(joinPath(base, '.gitignore'), '*\n')
414 await $.fs.write(path, exportMarkdown(w, await $.clock.now(), id))
415 return path
416}
417
418
419/** The ship moment: braille particles in the arc gradient, blitted at ≤12 fps for 1.5 s. */
420const playSpark = ($: EngineInterface, m: Mem, columns: number) => {
421 if (m.sparkTimer || !m.bandRequestId) return
422 const frames = Math.round(SHIP_SPARK_MS / 83)
423 let f = 0
424 const ramp = arcRamp(m.theme, 24)
425 const requestId = m.bandRequestId
426 m.sparkTimer = $.clock.every(83, () => {
427 f += 1
428 if (f >= frames) {
429 m.sparkTimer?.cancel()
430 m.sparkTimer = undefined
431 $.ui.invalidate('ui.render')
432 return
433 }
434 void safe($.ui.blit({ requestId, key: 'spark', cells: encodeCells(sparkFrame(columns, 2, f, frames, ramp), columns, 2) }), {})
435 })
436 $.clock.after(SHIP_HOLD_MS + 50, () => $.ui.invalidate('ui.render'))
437}
438
439
440const paneActions = ($: EngineInterface, m: Mem): PaneActions => ({
441 tab: n => void update($, uiA, u => ({ ...u, tab: n, selection: 0, expanded: -1 })),
442 select: i => void update($, uiA, u => ({ ...u, selection: Math.max(0, i) })),
443 expand: i => void update($, uiA, u => ({ ...u, expanded: i })),
444 ack: id => void ack($, m, id),
445 ackAll: () => void ack($, m, 'all'),
446 exportRun: () => void exportRun($, m).then(p => $.ui.toast(`● Exported · ${p}`)),
447 close: () => void $.ui.close({ id: PANE }),
448 resume: n => void doResume($, m, n).then(t => $.ui.toast(t)),
449 dismiss: n => void doDismiss($, m, n, false),
450 openEvidence: id => void (async () => {
451 const w = (await read($, warningsA)).find(x => x.id === id)
452 if (w) $.ui.toast(`⚠ ${w.evidence}`, { timeoutMs: 8000 })
453 })(),
454})
455
456
457/** Session start (and after /clear): detect, rehydrate, scan. Runs in the background. */
458const startup = async ($: EngineInterface, m: Mem) => {
459 const io = makeIo($)
460 const toast = makeToaster($, m)
461 await loadSettings($, m)
462 await refreshTheme($, m)
463 const cwd = await safe(io.session.cwd(), '')
464 const sessionId = await safe(io.session.id(), '')
465 const install = await detectInstall($, m, io)
466 m.version = install.version ?? BUILT_AGAINST
467 const messages = await safe(io.session.messages(), [] as Message[])
468 const surfaces = await safe($.session.surfaces(), [])
469 await update($, envA, env => ({
470 ...env,
471 installed: install.installed,
472 ...(install.version ? { pluginVersion: install.version } : {}),
473 ...(sessionId ? { sessionId } : {}),
474 ...(surfaces[0] ? { surface: surfaces[0] } : {}),
475 }))
476 const bootstrapped = await checkBootstrap(io, toast, 'startup', false)
477 // Dormant fast path: nothing of superpowers here, one stat and done.
478 const traces = (await safe(io.fs.exists(joinPath(cwd, '.superpowers')), false)) || (await safe(io.fs.exists(joinPath(cwd, 'docs/superpowers')), false))
479 if (!install.installed && !bootstrapped && !traces) return
480 if (install.installed || bootstrapped) await update($, runA, r => (r.lifecycle === 'dormant' ? { ...r, lifecycle: 'armed' as const } : r))
481 await refreshGit(io)
482 m.root = (await read($, envA)).repoRoot ?? cwd
483 if (messages.some(m => m.role === 'assistant' && m.toolUses.length > 0)) {
484 const n = await safe(rehydrate(io, messages), 0)
485 if (n > 0) io.ui.log(`rehydrated ${n} tool uses from the resumed transcript`)
486 }
487 await runScan($, m)
488 await ensurePoll($, m)
489}
490
491
492const handleSkill = async ($: EngineInterface, m: Mem, skill: NonNullable<ReturnType<typeof normalizeSkill>>, via: 'prompt' | 'tool', args?: string) => {
493 const io = makeIo($)
494 const before = await read($, runA)
495 const fresh = await onSkill(io, skill, { via, ...(args ? { args } : {}) })
496 if (!fresh) return
497 if (before.lifecycle !== 'live') {
498 // This session now does the work: whatever it followed becomes its own.
499 if (m.spectating) m.spectating = undefined
500 await update($, runA, r => ({ ...r, spectator: false }))
501 const s = await read($, settingsA)
502 if (s.autoOpenPane === 'live') void safe($.ui.open({ id: PANE, title: 'superpowers' }), { isPlaced: false, reason: '' })
503 if (!m.root) {
504 await refreshGit(io)
505 m.root = (await read($, envA)).repoRoot ?? (await safe(io.session.cwd(), ''))
506 }
507 }
508 await ensurePoll($, m)
509 scheduleSnapshot($, m)
510}
511
512
513/** The demo replaces the world while it plays; the real state is untouched. */
514const viewWorld = async ($: EngineInterface, m: Mem): Promise<{ w: World; now: number }> => {
515 const w0 = await readWorld($)
516 const now = await $.clock.now()
517 if (!w0.demo.active) return { w: w0, now }
518 const w = demoWorld(now - w0.demo.startedAt, w0.demo.startedAt, w0.settings.mode)
519 return { w: { ...w, ui: w0.ui, settings: { ...w0.settings, band: 'always' } }, now }
520}
521
522
523export const register: Register = on => {
524 const m = newMem()
525
526 on('session.start', async ($, e, next) => {
527 const r = await next(e)
528 await safe(
529 $.command.register({
530 name: 'sp',
531 description: 'superpowers-tracker: the pane, status, ledger, resume, modes, demo',
532 argumentHint: '[1-7|status|ledger|export|ack|mode|band|config|reset|resume|dismiss|scan|demo|theme|diagnose]',
533 }),
534 { command: 'sp' },
535 )
536 void startup($, m)
537 return r
538 })
539
540 on('skill.prompt', async ($, e, next) => {
541 const r = await next(e)
542 const skill = normalizeSkill(e.skill)
543 if (skill) await safe(handleSkill($, m, skill, 'prompt'), undefined)
544 return r
545 })
546
547 on('tool.call', async ($, e, next) => {
548 const tool = String(e.tool)
549 const input = e as unknown as Record<string, unknown>
550 const isMain = e.agentId === undefined
551 if ((tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') && typeof input.file_path === 'string') {
552 const [run, settings, env] = await Promise.all([read($, runA), read($, settingsA), read($, envA)])
553 if (settings.mode === 'strict' && run.lifecycle === 'live') {
554 const rel = relTo(env.repoRoot, String(input.file_path))
555 const isSource = !specFile(settings.specDir).test(rel) && !planFile(settings.planDir).test(rel) && !DOC_FILE.test(rel) && !SP_PRIVATE.test(rel) && !isTestFile(rel, settings.testFiles)
556 const deny = guardWrite({ mode: settings.mode, isMain, isSource, run })
557 if (deny) {
558 await warn(makeIo($), makeToaster($, m), 'strict-deny', 'Strict mode denied a write before approval', `${rel} (${e.tool_use_id})`)
559 return { deny }
560 }
561 }
562 }
563 const r = await next(e)
564 const run = await read($, runA)
565 const relevant = run.lifecycle !== 'dormant' || tool === 'Skill' || tool === 'Read' || tool === 'Write' || tool === 'Edit' || tool === 'Bash'
566 if (relevant) {
567 const { tool: _t, tool_use_id: _id, agentId: _a, ...args } = input
568 await safe(
569 onToolResult(makeIo($), makeToaster($, m), {
570 tool,
571 id: e.tool_use_id,
572 input: args,
573 text: r.deny !== undefined ? r.deny : r.text ?? '',
574 isError: r.deny !== undefined || r.isError === true,
575 ...(e.agentId ? { agentId: e.agentId } : {}),
576 }),
577 undefined,
578 )
579 if (tool === 'Bash' || tool === 'Write' || tool === 'Edit') await ensurePoll($, m)
580 }
581 if (tool === 'Agent' && m.redispatched.has(e.tool_use_id) && r.deny === undefined) {
582 m.redispatched.delete(e.tool_use_id)
583 const note = ledgerNote(await read($, ledgerA), run)
584 if (note) return { ...r, context: [...(r.context ?? []), note] }
585 }
586 return r
587 })
588
589 on('agent.spawn', async ($, e, next) => {
590 const io = makeIo($)
591 const toast = makeToaster($, m)
592 const spawn = {
593 tool_use_id: e.tool_use_id,
594 prompt: e.prompt,
595 description: e.description,
596 subagentType: e.subagentType,
597 parentModel: e.parentModel,
598 ...(e.model ? { model: e.model } : {}),
599 ...(e.parentAgentId ? { parentAgentId: e.parentAgentId } : {}),
600 }
601 const a = await safe(assessSpawn(io, toast, spawn), undefined)
602 if (a?.deny) return { deny: a.deny }
603 if (a && a.role === 'implementer' && a.task !== undefined) {
604 const s = await read($, settingsA)
605 if (s.mode !== 'observe' && ledgerDone(await read($, ledgerA)).includes(a.task)) m.redispatched.add(e.tool_use_id)
606 }
607 const r = await next(e)
608 if (a && r.deny === undefined) {
609 const label = await safe(recordSpawn(io, spawn, a, { ...(r.agentId ? { agentId: r.agentId } : {}), ...(r.model ? { model: r.model } : {}) }), undefined)
610 if (label) io.ui.notice(e.tool_use_id, label)
611 await ensurePoll($, m)
612 }
613 return r
614 })
615
616 on('turn.complete', async ($, e, next) => {
617 const r = await next(e)
618 const run = await read($, runA)
619 if (run.lifecycle === 'dormant') return r
620 const io = makeIo($)
621 const toast = makeToaster($, m)
622 if (e.agentId) {
623 await safe(onAgentDone(io, toast, e.agentId, e.answer, e.reason === 'error' || e.isAborted), undefined)
624 return r
625 }
626 await safe(onMainAnswer(io, toast, e.answer, e.turnId), undefined)
627 const [pct, cost] = await Promise.all([safe(io.session.contextPercent(), undefined), safe(io.session.costUsd(), undefined)])
628 await update($, envA, env => ({
629 ...env,
630 ...(pct !== undefined ? { context: [...env.context, pct].slice(-8) } : {}),
631 ...(cost !== undefined ? { costUsd: cost } : {}),
632 }))
633 await safe(refreshLedger(io, toast), undefined)
634 await safe(maybeShip(io, toast), undefined)
635 const s = await read($, settingsA)
636 if (s.band === 'off') {
637 const now = await io.now()
638 const vm = buildBand(await readWorld($), m.theme, { columns: 80, now, working: false, version: m.version })
639 $.ui.status(vm && vm.progress ? `ϟ ${run.stage} ${vm.progress.done}/${vm.progress.total}${vm.pills.warnings ? ` ⚠${vm.pills.warnings}` : ''}` : undefined)
640 }
641 await ensurePoll($, m)
642 scheduleSnapshot($, m)
643 return r
644 })
645
646 on('prompt.submit', async ($, e, next) => {
647 const run = await read($, runA)
648 if (run.lifecycle === 'dormant') return next(e)
649 await safe(onPromptSubmitted(makeIo($), e.origin.kind === 'composer'), undefined)
650 const s = await read($, settingsA)
651 if (s.mode !== 'observe' && m.pendingAssist) {
652 m.pendingAssist = false
653 const note = ledgerNote(await read($, ledgerA), run)
654 if (note) return next({ ...e, context: [...(e.context ?? []), note] })
655 }
656 return next(e)
657 })
658
659 on('session.compact', async ($, e, next) => {
660 const r = await next(e)
661 if (e.agentId || r.skip !== undefined) return r
662 const run = await read($, runA)
663 if (run.lifecycle === 'dormant') return r
664 const io = makeIo($)
665 const now = await io.now()
666 await update($, envA, env => ({ ...env, compactions: [...env.compactions, now].slice(-50) }))
667 await note(io, { kind: 'compact', label: 'compacted', detail: 'checking bootstrap' })
668 if (run.planPath) m.pendingAssist = true
669 const hadBootstrap = (await read($, envA)).bootstrap.some(b => b.ok)
670 $.clock.after(2500, () => void safe(checkBootstrap(io, makeToaster($, m), 'compaction', hadBootstrap), false))
671 return r
672 })
673
674 on('session.append', { door: ['hook-context', 'attachment'] }, async ($, e, next) => {
675 const r = await next(e)
676 if (e.agentId) return r
677 const text = e.message.content.map(b => (typeof b.text === 'string' ? b.text : '')).join('\n')
678 if (BOOTSTRAP.test(text)) {
679 const now = await $.clock.now()
680 await update($, envA, env => ({ ...env, bootstrap: [...env.bootstrap, { at: now, ok: true, why: 'context' }].slice(-12) }))
681 await update($, runA, run => (run.lifecycle === 'dormant' ? { ...run, lifecycle: 'armed' as const } : run))
682 }
683 return r
684 })
685
686 on('session.end', async ($, e, next) => {
687 const io = makeIo($)
688 if (m.root) {
689 await safe(saveSnapshot(io, m.root), false)
690 await safe(saveLeftOff(io, m.root, e.sessionId), undefined)
691 }
692 const r = await next(e)
693 if (e.reason === 'clear') {
694 m.poll?.cancel()
695 m.poll = undefined
696 m.spectating = undefined
697 const now = await safe($.clock.now(), 0)
698 await update($, runA, () => freshRun(now))
699 await update($, timelineA, () => [])
700 await update($, skillsA, () => [])
701 await update($, agentsA, () => [])
702 await update($, ledgerA, () => FRESH_LEDGER)
703 await update($, testsA, () => [])
704 await update($, warningsA, () => [])
705 await update($, resumeA, () => FRESH_RESUME)
706 void startup($, m)
707 }
708 return r
709 })
710
711 on('config.set', { key: 'theme' }, async ($, e, next) => {
712 const r = await next(e)
713 await refreshTheme($, m)
714 $.ui.invalidate('ui.render')
715 return r
716 })
717
718 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
719 if (e.props.hasSurvey) return next(e)
720 const { w, now } = await viewWorld($, m)
721 const vm = buildBand(w, m.theme, { columns: e.props.bodyColumns, now, working: e.props.isWorking, version: m.version })
722 if (!vm) return next(e)
723 m.bandRequestId = e.requestId
724 const actions = {
725 resume: () => void doResume($, m, 1).then(t => $.ui.toast(t)),
726 open: (tab?: number) => void openPane($, m, tab),
727 dismiss: () => void doDismiss($, m, 1, false),
728 ack: (id: string) => void ack($, m, id),
729 }
730 if (e.surface === 'terminal') {
731 const { Box, Client, Raster } = $.ui.resolve(e)
732 const cols = Math.max(10, e.props.bodyColumns)
733 const sparkOn = vm.kind === 'shipped' && vm.motion && w.run.shippedAt !== undefined && now - w.run.shippedAt < SHIP_SPARK_MS
734 if (sparkOn) playSpark($, m, cols)
735 return (
736 <Box flexDirection="column">
737 {sparkOn ? <Raster key="spark" columns={cols} rows={2} cells={encodeCells(sparkFrame(cols, 2, 0, 18, arcRamp(m.theme, 24)), cols, 2)} /> : null}
738 <Client key="band" module="./ui/band/Band.tsx" props={vm} />
739 </Box>
740 )
741 }
742 if (e.surface === 'desktop') {
743 const els = $.ui.resolve(e)
744 const svg = railSvg(vm)
745 return (
746 <els.Box flexDirection="column">
747 <els.Svg source={svg.source} alt={svg.alt} height={svg.height} isInteractive />
748 {vm.kind === 'resume' || vm.critical ? BandText(els, vm, actions) : null}
749 </els.Box>
750 )
751 }
752 return BandText($.ui.resolve(e), vm, actions)
753 })
754
755 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
756 const { w, now } = await viewWorld($, m)
757 const els = $.ui.resolve(e)
758 const ctx: PaneCtx = {
759 els,
760 t: m.theme,
761 w,
762 now,
763 cols: Math.max(30, e.props.bodyColumns - 6),
764 surface: e.surface,
765 focused: e.props.isFocused,
766 loading: w.resume.scanning && w.run.lifecycle !== 'live' && w.timeline.length === 0,
767 act: paneActions($, m),
768 }
769 if (e.surface === 'terminal') return PaneView(ctx)
770 const remote = $.ui.resolve(e) as unknown as { Box: typeof els.Box; Svg?: (p: { source: string; alt: string; isInteractive?: boolean; height?: number }) => never }
771 const vm = buildBand(w, m.theme, { columns: 120, now, working: false, version: m.version })
772 const rail = vm ? railSvg(vm, 640) : undefined
773 const flow = flowSvg(w.timeline, m.theme.stage)
774 const Svg = remote.Svg
775 return (
776 <els.Box flexDirection="column">
777 {Svg && rail ? <Svg source={rail.source} alt={rail.alt} height={rail.height} isInteractive /> : null}
778 {Svg && w.ui.tab === 1 && w.timeline.length ? <Svg source={flow.source} alt={flow.alt} isInteractive /> : null}
779 {PaneView(ctx)}
780 </els.Box>
781 )
782 })
783
784 on('ui.render', { component: 'Pane', requestId: THEME_PANE }, async ($, e) => {
785 const s = await read($, settingsA)
786 void s
787 return ThemePreview($.ui.resolve(e), m.theme, choice => {
788 void (async () => {
789 await update($, settingsA, x => ({ ...x, theme: choice }))
790 await saveSettings($, m)
791 await refreshTheme($, m)
792 $.ui.invalidate('ui.render')
793 })()
794 })
795 })
796
797 on('ui.message', async ($, e, next) => {
798 const r = await next(e)
799 const d = (e.data ?? {}) as { action?: string; id?: string; tab?: number }
800 if (d.action === 'resume') $.ui.toast(await doResume($, m, 1))
801 else if (d.action === 'open') await openPane($, m, typeof d.tab === 'number' ? d.tab : undefined)
802 else if (d.action === 'dismiss') await doDismiss($, m, 1, false)
803 else if (d.action === 'ack' && typeof d.id === 'string') await ack($, m, d.id)
804 return r
805 })
806
807 on('command.run', { command: 'sp' }, async ($, e) => {
808 const intent = parseSp(e.args)
809 const io = makeIo($)
810 const now = await $.clock.now()
811 switch (intent.kind) {
812 case 'open': {
813 const opened = await openPane($, m, intent.tab)
814 return opened.isPlaced ? {} : { text: `The pane waits for room: ${opened.reason}` }
815 }
816 case 'status':
817 return { text: statusText(await readWorld($), now) }
818 case 'ledger':
819 return { text: ledgerText(await readWorld($)) }
820 case 'export':
821 return { text: `Wrote ${await exportRun($, m)}` }
822 case 'ack': {
823 const n = await ack($, m, intent.target)
824 return { text: n ? `Acknowledged ${n} warning${n === 1 ? '' : 's'}.` : 'No open warning matched.' }
825 }
826 case 'mode': {
827 await update($, settingsA, s => ({ ...s, mode: intent.mode }))
828 await saveSettings($, m)
829 const what =
830 intent.mode === 'observe'
831 ? 'records and displays only; never denies, rewrites or injects.'
832 : intent.mode === 'assist'
833 ? 'adds a ledger note (≤3 lines) after a compaction and before a re-dispatch of a completed task.'
834 : 'assist, plus denies: a completed task re-dispatched, a second implementer, an SDD subagent with no model, source writes before brainstorming approval.'
835 return { text: `Mode ${intent.mode}: ${what}` }
836 }
837 case 'band':
838 await update($, settingsA, s => ({ ...s, band: intent.band }))
839 await saveSettings($, m)
840 if (intent.band !== 'off') $.ui.status(undefined)
841 return { text: `Band ${intent.band}.` }
842 case 'config': {
843 const s = await read($, settingsA)
844 if (!intent.key) return { text: configText(s) }
845 if (intent.value === undefined) return { text: configText(s).split('\n').filter(l => l.startsWith(intent.key!)).join('\n') || `Unknown setting "${intent.key}".` }
846 const res = applyConfig(s, intent.key, intent.value)
847 if (res.settings) {
848 await update($, settingsA, () => res.settings!)
849 await saveSettings($, m)
850 await refreshTheme($, m)
851 m.poll?.cancel()
852 m.poll = undefined
853 await ensurePoll($, m)
854 }
855 return { text: res.text }
856 }
857 case 'reset': {
858 m.poll?.cancel()
859 m.poll = undefined
860 m.spectating = undefined
861 await update($, runA, () => ({ ...freshRun(now), lifecycle: 'armed' as const }))
862 await update($, timelineA, () => [])
863 await update($, skillsA, () => [])
864 await update($, agentsA, () => [])
865 await update($, ledgerA, () => FRESH_LEDGER)
866 await update($, testsA, () => [])
867 await update($, warningsA, () => [])
868 await update($, uiA, () => FRESH_UI)
869 await update($, demoA, () => FRESH_DEMO)
870 return { text: 'Tracker state cleared for this session. Settings kept.' }
871 }
872 case 'resume':
873 return { text: await doResume($, m, intent.n) }
874 case 'dismiss':
875 return { text: await doDismiss($, m, intent.n, intent.forget) }
876 case 'scan': {
877 if (!m.root) {
878 await refreshGit(io)
879 m.root = (await read($, envA)).repoRoot ?? (await safe(io.session.cwd(), ''))
880 }
881 await runScan($, m, true)
882 const cands = visibleCandidates(await read($, resumeA))
883 return {
884 text: cands.length
885 ? cands.map((c, i) => `${i + 1}. ${c.kind} · ${c.title}${c.total ? ` · ${c.done}/${c.total}` : ''}${c.worktree ? ` · in ${c.worktree}` : ''}\n ${c.suggest}`).join('\n')
886 : 'Nothing unfinished found in this project or its worktrees.',
887 }
888 }
889 case 'demo': {
890 m.demoTimer?.cancel()
891 await update($, demoA, () => ({ active: true, startedAt: now, step: 0 }))
892 m.demoTimer = $.clock.every(250, () => {
893 void (async () => {
894 const d = await read($, demoA)
895 if (!d.active || (await $.clock.now()) - d.startedAt > DEMO_MS + 2000) {
896 m.demoTimer?.cancel()
897 m.demoTimer = undefined
898 await update($, demoA, () => FRESH_DEMO)
899 }
900 $.ui.invalidate('ui.render')
901 })()
902 })
903 await openPane($, m)
904 return { text: 'Playing the 60-second demo in the band and the pane. Real tracking continues underneath; /sp reset does not affect it.' }
905 }
906 case 'theme':
907 await safe($.ui.open({ id: THEME_PANE, title: 'Arc tokens', focus: true, closeOnEscape: true }), { isPlaced: false, reason: '' })
908 return {}
909 case 'diagnose': {
910 const id = await safe($.session.id(), 'unknown')
911 const text = diagnoseText(id)
912 const answer = await safe($.ui.ask('Send that request to Claude now?', ['Send it', 'Not now']), 'Not now')
913 if (answer === 'Send it') {
914 void $.prompt.submit({ text: 'figure out what went wrong with superpowers in this session', asUser: true })
915 return { text: `${text}\n\nSent.` }
916 }
917 return { text }
918 }
919 case 'error':
920 return { text: intent.text }
921 }
922 })
923
924}
925hooks/io.ts 93 lines1// The one seam between the tracker's logic and the engine. register.tsx
2// builds an Io from `$` (the validator lets `$` cross no import); every
3// other module takes an Io, so it can be driven by the engine or by a test.
4
5import type {
6 Agent,
7 DemoState,
8 Env,
9 Ledger,
10 ResumeState,
11 Run,
12 Settings,
13 SkillHit,
14 TestRun,
15 TimelineRow,
16 UiState,
17 Warning,
18} from '../types'
19
20export type Cell<T> = {
21 get: () => Promise<T>
22 set: (fn: (value: T) => T) => Promise<T>
23}
24
25export type Cells = {
26 run: Cell<Run>
27 timeline: Cell<TimelineRow[]>
28 skills: Cell<SkillHit[]>
29 agents: Cell<Agent[]>
30 ledger: Cell<Ledger>
31 tests: Cell<TestRun[]>
32 warnings: Cell<Warning[]>
33 env: Cell<Env>
34 ui: Cell<UiState>
35 resume: Cell<ResumeState>
36 settings: Cell<Settings>
37 demo: Cell<DemoState>
38}
39
40export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number }
41export type Stat = { kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number }
42export type Ran = { code: number; out: string; err: string }
43export type Message = {
44 role: 'user' | 'assistant'
45 text: string
46 toolUses: { tool_use_id: string; tool: string; input: Record<string, unknown>; text?: string; isError?: true; agentId?: string; durationMs?: number }[]
47}
48
49export type Io = {
50 state: Cells
51 now: () => Promise<number>
52 fs: {
53 read: (path: string) => Promise<string>
54 list: (path: string) => Promise<Entry[]>
55 stat: (path: string) => Promise<Stat>
56 exists: (path: string) => Promise<boolean>
57 write: (path: string, text: string) => Promise<void>
58 }
59 run: (argv: readonly string[], cwd?: string) => Promise<Ran>
60 store: {
61 get: (key: string) => Promise<unknown>
62 set: (key: string, value: unknown) => Promise<void>
63 del: (key: string) => Promise<void>
64 keys: () => Promise<string[]>
65 }
66 ui: {
67 toast: (text: string, timeoutMs?: number) => void
68 status: (text: string | undefined) => void
69 notice: (toolUseId: string, text: string | undefined) => void
70 log: (text: string) => void
71 redraw: () => void
72 }
73 session: {
74 id: () => Promise<string>
75 cwd: () => Promise<string>
76 messages: () => Promise<Message[]>
77 contextPercent: () => Promise<number | undefined>
78 costUsd: () => Promise<number | undefined>
79 }
80 settingsFile: () => Promise<Record<string, unknown>>
81 home: () => Promise<string | undefined>
82 env: (name: 'NO_COLOR' | 'COLORTERM' | 'TERM') => Promise<string | undefined>
83}
84
85/** Swallows a failure, answering the fallback: every IO in the tracker is best effort. */
86export const safe = async <T>(work: Promise<T> | (() => Promise<T>), fallback: T): Promise<T> => {
87 try {
88 return await (typeof work === 'function' ? work() : work)
89 } catch {
90 return fallback
91 }
92}
93hooks/state.ts 94 lines1import type {
2 DemoState,
3 Env,
4 Ledger,
5 ResumeState,
6 Run,
7 Settings,
8 UiState,
9} from '../types'
10
11// Caps on every list kept in $.state; the oldest entries drop first.
12export const CAP = { timeline: 500, skills: 300, agents: 200, tests: 400, warnings: 200, context: 8 } as const
13
14export const DEFAULT_SETTINGS: Settings = {
15 mode: 'observe',
16 band: 'auto',
17 motion: true,
18 theme: 'auto',
19 toasts: true,
20 testRunners: [
21 'npm (run )?test',
22 'pnpm (run )?test',
23 'yarn (run )?test',
24 'bun test',
25 'npx (vitest|jest)',
26 'vitest',
27 'jest',
28 'pytest',
29 'python -m pytest',
30 'go test',
31 'cargo test',
32 'rspec',
33 'bundle exec rspec',
34 'mix test',
35 'dotnet test',
36 'phpunit',
37 'vendor/bin/phpunit',
38 'claude plugin test',
39 'task-done',
40 ],
41 testFiles: [
42 '(^|/)(test|tests|__tests__|spec|specs)/',
43 '\\.(test|spec)\\.[cm]?[jt]sx?$',
44 '_test\\.(go|py|rb|exs)$',
45 '(^|/)test_[^/]+\\.py$',
46 'Tests?\\.cs$',
47 'Test\\.php$',
48 ],
49 specDir: 'docs/superpowers/specs',
50 planDir: 'docs/superpowers/plans',
51 pollMs: 2000,
52 windowDays: 14,
53 autoOpenPane: 'never',
54}
55
56export const freshRun = (now: number): Run => ({
57 lifecycle: 'dormant',
58 stage: 'brainstorm',
59 stageInferred: true,
60 pathInferred: true,
61 approvalSeen: false,
62 brainstormActive: false,
63 debuggingActive: false,
64 specCommitted: false,
65 hasReviewFocus: false,
66 tasks: [],
67 pulseAt: 0,
68 startedAt: now,
69 turn: 0,
70 turnHadVerification: false,
71 debugEditPending: false,
72 boundedDesignShown: false,
73 spectator: false,
74})
75
76export const FRESH_LEDGER: Ledger = { mtime: 0, size: 0, entries: [], unknown: [], readAt: 0 }
77
78export const FRESH_ENV: Env = {
79 installed: false,
80 bootstrap: [],
81 compactions: [],
82 isLinkedWorktree: false,
83 worktreeEvents: 0,
84 context: [],
85 debugFixes: 0,
86}
87
88export const FRESH_RESUME: ResumeState = { candidates: [], dismissed: [], scannedAt: 0, scanning: false, toasted: [] }
89export const FRESH_UI: UiState = { tab: 1, selection: 0, expanded: -1, filter: '' }
90export const FRESH_DEMO: DemoState = { active: false, startedAt: 0, step: 0 }
91
92export const capTail = <T>(list: readonly T[], cap: number): T[] =>
93 list.length > cap ? list.slice(list.length - cap) : [...list]
94hooks/patterns.ts 167 lines1// Every pattern the tracker depends on in obra/superpowers, in one place.
2// Built against superpowers 6.4.2 (commit 8ca22db). A new superpowers
3// release that changes a format means editing this file alone.
4
5export const BUILT_AGAINST = '6.4.2'
6
7export const SKILLS = [
8 'using-superpowers',
9 'brainstorming',
10 'using-git-worktrees',
11 'writing-plans',
12 'subagent-driven-development',
13 'executing-plans',
14 'test-driven-development',
15 'systematic-debugging',
16 'verification-before-completion',
17 'requesting-code-review',
18 'receiving-code-review',
19 'dispatching-parallel-agents',
20 'finishing-a-development-branch',
21 'writing-skills',
22 'diagnosing-superpowers',
23] as const
24export type SkillName = (typeof SKILLS)[number]
25
26const SKILL_SET: ReadonlySet<string> = new Set(SKILLS)
27
28/** `superpowers:brainstorming` → `brainstorming`; undefined for a skill not ours. */
29export const normalizeSkill = (raw: string | undefined): SkillName | undefined => {
30 if (!raw) return undefined
31 const bare = raw.trim().replace(/^\//, '').split(/\s+/)[0] ?? ''
32 const name = bare.includes(':') ? bare.slice(bare.lastIndexOf(':') + 1) : bare
33 return SKILL_SET.has(name) ? (name as SkillName) : undefined
34}
35
36/** A Read of a SKILL.md under a superpowers install. */
37export const SKILL_FILE = /superpowers[^\s]*\/skills\/([a-z-]+)\/SKILL\.md$/
38
39// Bootstrap: hooks/session-start wraps using-superpowers in this block.
40export const BOOTSTRAP = /<EXTREMELY_IMPORTANT>\s*You have superpowers\./
41
42// Brainstorming path announcement ("this looks bounded, so I'll ...").
43export const PATH_ANNOUNCE: readonly [RegExp, 'spike' | 'bounded' | 'architectural'][] = [
44 [/\b(?:this|it)\s+(?:looks|is|seems|reads)\s+(?:like\s+)?(?:an?\s+)?architectural\b/i, 'architectural'],
45 [/\b(?:this|it)\s+(?:looks|is|seems|reads)\s+(?:like\s+)?(?:a\s+)?bounded\b/i, 'bounded'],
46 [/\b(?:this|it)\s+(?:looks|is|seems|reads)\s+(?:like\s+)?(?:a\s+)?spike\b/i, 'spike'],
47 [/\b(?:classif(?:y|ied|ying)\s+(?:this|it)\s+as|taking the)\s+(architectural|bounded|spike)\b/i, 'architectural'],
48]
49
50// Artifacts.
51export const specFile = (specDir: string): RegExp =>
52 new RegExp(`(^|/)${escape(specDir)}/(\\d{4}-\\d{2}-\\d{2}-[^/]+-design\\.md)$`)
53export const planFile = (planDir: string): RegExp =>
54 new RegExp(`(^|/)${escape(planDir)}/([^/]+\\.md)$`)
55export const SUPERPOWERS_DOCS = /(^|\/)docs\/superpowers\//
56export const DOC_FILE = /\.(md|mdx|txt|rst|adoc)$/i
57export const SP_PRIVATE = /(^|\/)\.superpowers\//
58
59// SDD workspace and scripts.
60export const WORKSPACE_DIR = /(\/[^\s'"]*\/\.superpowers\/sdd\/[^/\s'"]+)/
61export const LEDGER_NAME = 'progress.md'
62export const SCRIPT = /\b(sdd-workspace|task-brief|review-package|task-start|task-done)\b/
63export const SCRIPT_ARGS = /\b(sdd-workspace|task-brief|review-package|task-start|task-done)\s+("[^"]+"|'[^']+'|\S+)(?:\s+(\d+))?(?:\s+([0-9a-f]{7,40}))?/
64export const OUT_TASK_BRIEF = /^wrote (.+\/task-(\d+)-brief\.md): \d+ lines$/m
65export const OUT_REVIEW_PKG = /^wrote (.+\/review-([0-9a-f]+)\.\.([0-9a-f]+)\.diff): (\d+) commit\(s\)/m
66export const OUT_TASK_START_BRIEF = /^brief: (.+)$/m
67export const OUT_TASK_START_BASE = /^base: ([0-9a-f]{7,40})$/m
68export const OUT_TASK_DONE_LEDGER = /^ledger: (.+)$/m
69export const OUT_TASK_DONE_FAIL = /task-done: test command exited (\d+); Task (\d+) NOT recorded/
70export const TASK_REPORT = /\/task-(\d+)-report\.md/
71
72// Plan structure. task-brief accepts any heading depth: /^#+[ \t]+Task[ \t]+N/.
73export const FENCE = /^(```|~~~)/
74export const TASK_HEADING = /^#+[ \t]+Task[ \t]+(\d+)\s*[:.\-–—]?\s*(.*)$/
75export const PLAN_TITLE = /^#\s+(.+?)\s*$/
76export const PLAN_GOAL = /^\*\*Goal:\*\*\s*(.+)$/
77export const PLAN_SPEC = /^\*\*Spec:\*\*\s*`?([^`]+?)`?\s*$/
78export const REVIEW_FOCUS = /^##\s+Review Focus\b/
79export const STEP_OPEN = /^\s*[-*]\s+\[ \]/
80export const STEP_DONE = /^\s*[-*]\s+\[[xX]\]/
81
82// Ledger lines (progress.md). Unknown lines are kept, never fatal.
83export const L = {
84 title: /^#\s*SDD ledger\s*[—–-]\s*plan:\s*(.+)$/,
85 preflight: /^Pre-flight:\s*(.+)$/,
86 table: /^\|.*\|\s*$/,
87 complete: /^Task\s+(\d+):\s*complete\s*\((?:commits\s+)?([0-9a-f]{4,40}\.\.[0-9a-f]{4,40})?,?\s*(.*)\)\s*$/,
88 fixRound: /^Task\s+(\d+):\s*fix round\s+(\d+)\s*\/\s*5\s*\((\d+)\s+addressed,\s*(\d+)\s+open(.*)\)\s*$/,
89 minor: /^Task\s+(\d+):\s*minor \(deferred\):\s*(.+)$/,
90 parked: /^Task\s+(\d+):\s*parked\s*[—–-]\s*(.+)$/,
91 taskRuling: /^Task\s+(\d+):\s*Ruling:\s*(.+)$/,
92 ruling: /^Ruling:\s*(.+)$/,
93 finalMinor: /^Final:\s*minor \(deferred\):\s*(.+)$/,
94 finalFixed: /^Final:\s*fixed\s+(.+)$/,
95 finalRuling: /^Final:\s*Ruling:\s*(.+)$/,
96 finalReview: /^Final review:\s*(.+)$/,
97} as const
98
99// Subagent roles, from the dispatch description and prompt.
100export const ROLE: readonly [RegExp, 'implementer' | 'task-reviewer' | 're-reviewer' | 'final-reviewer' | 'investigator' | 'analyst' | 'spec-reviewer'][] = [
101 [/^Re-review\b|scoped re-review|re-reviewing one task's fix round/i, 're-reviewer'],
102 [/^Review Task\s+\d+|reviewing one task's implementation/i, 'task-reviewer'],
103 [/^Implement Task\s+\d+|You are implementing Task\s+\d+/i, 'implementer'],
104 [/whole[- ]branch|final (?:code )?review|Senior Code Reviewer|^Review code changes/i, 'final-reviewer'],
105 [/analyst-common|skill-timeline|plan-adherence|repeated-work|quality-evidence|cost-and-time|request-conflicts|diagnosing-superpowers/i, 'analyst'],
106 [/spec[- ]document[- ]reviewer|review (?:the|this) spec/i, 'spec-reviewer'],
107 [/\binvestigat|independent (?:problem )?domain|in parallel\b/i, 'investigator'],
108]
109export const TASK_N = /\bTask\s+(\d+)\b/
110export const FIX_ROUND_N = /fix round\s+(\d+)/i
111
112// Subagent answers.
113export const A = {
114 status: /\*\*Status:\*\*\s*(DONE_WITH_CONCERNS|DONE|BLOCKED|NEEDS_CONTEXT)/,
115 commit: /\b([0-9a-f]{7,12})\b\s+[-—:]?\s*\S/g,
116 testLine: /(\d+\s*\/\s*\d+\s+passing[^\n]*|\d+\s+passed[^\n]*|\d+\s+tests?[^\n]*(?:pass|fail)[^\n]*)/i,
117 reportPath: /(\S+\/task-\d+-report\.md)/,
118 specOk: /✅\s*Spec compliant/,
119 specBad: /❌\s*Issues found/,
120 specWarn: /⚠️\s*Cannot verify/,
121 quality: /\*\*Task quality:\*\*\s*\[?\s*(Approved|Needs fixes)/i,
122 section: /^#{3,4}\s+(Critical|Important|Minor)\b/i,
123 addressed: /[—–-]\s*ADDRESSED\b/g,
124 notAddressed: /[—–-]\s*NOT ADDRESSED\b/g,
125 roundVerdict: /\*\*Fix round:\*\*\s*\[?\s*(All findings addressed[^\]\n]*|Findings remain open[^\]\n]*)/i,
126 readyToMerge: /\*\*Ready to merge(?:\?\*\*|:)\s*\[?\s*(Yes|No|With fixes)/i,
127} as const
128
129// Main-loop answers.
130export const SUCCESS_CLAIM = /\b(all (?:the )?tests? (?:are )?pass(?:ing|es)?|tests? (?:now )?pass|(?:is|are|now) fixed|it'?s fixed|fixed it|(?:is |are |everything is )?(?:done|complete)\b|ready to merge|works now)\b/i
131export const PERFORMATIVE = /^\s*(?:You'?re absolutely right|Absolutely right|Great point|Excellent (?:feedback|point|catch)|Good catch|Thanks for catching)\b/i
132export const RULINGS_LIST = /Rulings I made/i
133export const MINORS_LIST = /Deferred minors/i
134export const DIAGNOSE_PHRASE = 'figure out what went wrong with superpowers in this session'
135
136// Git and worktree commands seen in Bash.
137export const G = {
138 worktreeAdd: /\bgit\s+worktree\s+add\b/,
139 worktreeRemove: /\bgit\s+worktree\s+remove\b/,
140 commit: /\bgit\s+commit\b/,
141 merge: /\bgit\s+merge\b/,
142 push: /\bgit\s+push\b[^\n]*\s-u\b|\bgit\s+push\s+-u\b|\bgit\s+push\b/,
143 prCreate: /\bgh\s+pr\s+create\b|\bglab\s+mr\s+create\b/,
144 branchDelete: /\bgit\s+branch\s+-[dD]\b/,
145 workspaceDelete: /\brm\s+-r?f?r?\s+[^\n]*\.superpowers\/sdd\//,
146 startServer: /\bstart-server\.sh\b/,
147} as const
148
149// Visual companion.
150export const BRAINSTORM_DIR = '.superpowers/brainstorm'
151
152// Model tiers.
153export const tierOf = (model: string | undefined): 'cheap' | 'mid' | 'top' | 'unknown' => {
154 if (!model) return 'unknown'
155 if (/haiku/i.test(model)) return 'cheap'
156 if (/sonnet/i.test(model)) return 'mid'
157 if (/opus|fable/i.test(model)) return 'top'
158 return 'unknown'
159}
160
161// Verification commands that back a success claim in the same turn.
162export const VERIFY_HINT = /\b(test|tests|pytest|jest|vitest|cargo|go test|rspec|lint|tsc|typecheck|build|check|verify|validate|curl)\b/i
163
164function escape(s: string): string {
165 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/^\/+|\/+$/g, '')
166}
167hooks/core.ts 124 lines1// Shared moves on the tracker's state: timeline rows, warnings, the pulse,
2// the lifecycle and task rows. Every function takes the Io, never `$`.
3
4import type { Run, Severity, StageId, TaskRow, TimelineRow } from '../types'
5import type { Io } from './io'
6import { CAP, capTail } from './state'
7import { advance, goLive } from './stages'
8import { makeWarning, type WarningCode } from './warnings'
9
10export type Toaster = (glyph: string, subject: string, detail: string, sev?: Severity) => void
11
12export const note = async (io: Io, row: Omit<TimelineRow, 'at' | 'stage'> & { stage?: StageId }): Promise<void> => {
13 const at = await io.now()
14 const stage = row.stage ?? (await io.state.run.get()).stage
15 await io.state.timeline.set(list => capTail([...list, { ...row, at, stage }], CAP.timeline))
16}
17
18export const warn = async (
19 io: Io,
20 toast: Toaster,
21 code: WarningCode,
22 text: string,
23 evidence: string,
24 sev?: Severity,
25): Promise<void> => {
26 const at = await io.now()
27 const w = makeWarning(code, text, evidence, at, sev)
28 let isNew = false
29 await io.state.warnings.set(list => {
30 if (list.some(x => x.id === w.id)) return list
31 isNew = true
32 return capTail([...list, w], CAP.warnings)
33 })
34 if (!isNew) return
35 await note(io, { kind: 'warning', label: text, ok: false, detail: evidence })
36 if (w.sev === 'critical') toast('✗', text, evidence, 'critical')
37 else if (w.sev === 'warn') toast('⚠', text, evidence, 'warn')
38}
39
40/** The first superpowers signal of a session: the tracker comes alive, pulsing. */
41export const live = async (io: Io): Promise<Run> => {
42 const now = await io.now()
43 return io.state.run.set(run => goLive(run, now))
44}
45
46export const toStage = async (io: Io, stage: StageId, inferred = false): Promise<Run> => {
47 const now = await io.now()
48 return io.state.run.set(run => advance(goLive(run, now), stage, now, inferred))
49}
50
51export const patchTask = async (io: Io, n: number, fn: (t: TaskRow) => TaskRow): Promise<void> => {
52 await io.state.run.set(run => {
53 const has = run.tasks.some(t => t.n === n)
54 const base: TaskRow = blankTask(n)
55 const tasks = has ? run.tasks.map(t => (t.n === n ? fn(t) : t)) : [...run.tasks, fn(base)].sort((a, b) => a.n - b.n)
56 return { ...run, tasks }
57 })
58}
59
60export const blankTask = (n: number, name = `Task ${n}`): TaskRow => ({
61 n,
62 name,
63 steps: 0,
64 stepsDone: 0,
65 status: 'pending',
66 fixRound: 0,
67 breaker: false,
68 agents: 0,
69 tdd: [],
70 implFirst: false,
71 sawTestEdit: false,
72 ledger: [],
73})
74
75export const pulse = async (io: Io): Promise<void> => {
76 const now = await io.now()
77 await io.state.run.set(run => ({ ...run, pulseAt: now }))
78}
79
80export const isTestFile = (path: string, patterns: readonly string[]): boolean =>
81 patterns.some(p => {
82 try {
83 return new RegExp(p).test(path)
84 } catch {
85 return false
86 }
87 })
88
89export const isTestCommand = (command: string, runners: readonly string[]): boolean =>
90 runners.some(r => {
91 try {
92 return new RegExp(`(^|[\\s;&|(])${r}(\\s|$|;)`).test(command)
93 } catch {
94 return false
95 }
96 })
97
98export const fmtDuration = (ms: number): string => {
99 const s = Math.max(0, Math.round(ms / 1000))
100 if (s < 60) return `${s}s`
101 const m = Math.floor(s / 60)
102 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
103 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
104}
105
106export const fmtAgo = (ms: number): string => {
107 const s = Math.max(0, Math.round(ms / 1000))
108 if (s < 45) return 'just now'
109 const m = Math.round(s / 60)
110 if (m < 60) return `${m}m ago`
111 const h = Math.round(m / 60)
112 if (h < 48) return `${h}h ago`
113 return `${Math.round(h / 24)}d ago`
114}
115
116export const fmtCount = (n: number): string =>
117 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
118
119export const basename = (p: string): string => p.replace(/\/+$/, '').split('/').at(-1) ?? p
120export const dirname = (p: string): string => p.replace(/\/[^/]*$/, '') || '/'
121export const joinPath = (...parts: string[]): string => parts.join('/').replace(/\/{2,}/g, '/')
122export const relTo = (root: string | undefined, p: string): string =>
123 root && p.startsWith(root + '/') ? p.slice(root.length + 1) : p
124hooks/signals/skills.ts 80 lines1// Skill invocations: skill.prompt (/name, the Skill tool, preloads), the
2// Skill tool's args, and a Read of a SKILL.md as the fallback.
3
4import type { SkillHit } from '../../types'
5import type { Io } from '../io'
6import type { SkillName } from '../patterns'
7import { CAP, capTail } from '../state'
8import { stageForSkill, goLive, advance } from '../stages'
9import { note } from '../core'
10
11export type SkillContext = { agentId?: string; args?: string; via: SkillHit['via'] }
12
13/** Records one invocation; skill.prompt and the Skill tool both report the
14 * same call, so a repeat within 5 s merges into the first. */
15export const onSkill = async (io: Io, skill: SkillName, ctx: SkillContext): Promise<boolean> => {
16 const now = await io.now()
17 const run = await io.state.run.get()
18 let isNew = true
19 await io.state.skills.set(list => {
20 const twin = [...list].reverse().find(h => h.skill === skill && h.agentId === ctx.agentId && now - h.at < 5000)
21 if (twin) {
22 isNew = false
23 if (!ctx.args || twin.args) return list
24 return list.map(h => (h === twin ? { ...h, args: ctx.args! } : h))
25 }
26 const hit: SkillHit = {
27 skill,
28 at: now,
29 turn: run.turn,
30 via: ctx.via,
31 ...(ctx.agentId ? { agentId: ctx.agentId } : {}),
32 ...(ctx.args ? { args: ctx.args } : {}),
33 }
34 return capTail([...list, hit], CAP.skills)
35 })
36 if (!isNew) return false
37
38 const isMain = ctx.agentId === undefined
39 await io.state.run.set(r => {
40 let next = goLive(r, now)
41 if (!isMain) return next
42 const stage = stageForSkill(skill)
43 if (stage) next = advance(next, stage, now)
44 switch (skill) {
45 case 'brainstorming':
46 next = { ...next, brainstormActive: true, approvalSeen: false, pathInferred: true, boundedDesignShown: false }
47 break
48 case 'writing-plans':
49 // Architectural approval: the written spec was approved and writing-plans invoked.
50 next = { ...next, brainstormActive: false, approvalSeen: true }
51 break
52 case 'subagent-driven-development':
53 next = { ...next, brainstormActive: false, executor: 'sdd' }
54 break
55 case 'executing-plans':
56 next = { ...next, brainstormActive: false, executor: 'inline' }
57 break
58 case 'systematic-debugging':
59 next = { ...next, debuggingActive: true }
60 break
61 case 'finishing-a-development-branch':
62 next = {
63 ...next,
64 finishing: next.finishing ?? { worktreeRemoved: false, branchDeleted: false, workspaceDeleted: false, rulingsListed: false, minorsListed: false },
65 }
66 break
67 default:
68 break
69 }
70 return next
71 })
72 await note(io, {
73 kind: 'skill',
74 label: skill,
75 ...(ctx.agentId ? { agentId: ctx.agentId } : {}),
76 ...(ctx.args ? { detail: ctx.args.slice(0, 120) } : {}),
77 })
78 return true
79}
80hooks/signals/tools.ts 317 lines1// Tool calls as evidence: Write/Edit to superpowers paths, Bash (SDD
2// scripts, tests, git, the companion), Read of a SKILL.md, the worktree tools.
3
4import type { Companion, TestRun } from '../../types'
5import type { Io } from '../io'
6import { safe } from '../io'
7import {
8 DOC_FILE,
9 G,
10 OUT_TASK_BRIEF,
11 OUT_TASK_DONE_FAIL,
12 OUT_TASK_DONE_LEDGER,
13 OUT_TASK_START_BASE,
14 OUT_TASK_START_BRIEF,
15 SCRIPT_ARGS,
16 SKILL_FILE,
17 SP_PRIVATE,
18 VERIFY_HINT,
19 WORKSPACE_DIR,
20 normalizeSkill,
21 planFile,
22 specFile,
23} from '../patterns'
24import { parseTestOutput } from '../parsers'
25import { CAP, capTail } from '../state'
26import { advance, goLive } from '../stages'
27import {
28 dirname,
29 isTestCommand,
30 isTestFile,
31 joinPath,
32 note,
33 patchTask,
34 relTo,
35 warn,
36 type Toaster,
37} from '../core'
38import { onSkill } from './skills'
39import { refreshLedger, refreshPlan, resolveWorkspace } from './plan'
40
41export type ToolEvent = {
42 tool: string
43 id: string
44 input: Record<string, unknown>
45 agentId?: string
46 text: string
47 isError: boolean
48 replay?: boolean
49}
50
51const str = (v: unknown): string => (typeof v === 'string' ? v : '')
52
53/** Branch, linked-worktree flag and repo root, through git with argv arrays. */
54export const refreshGit = async (io: Io): Promise<void> => {
55 const cwd = await safe(io.session.cwd(), '')
56 const top = await safe(io.run(['git', 'rev-parse', '--show-toplevel'], cwd), undefined)
57 if (!top || top.code !== 0) return
58 const [branch, gitDir, common] = await Promise.all([
59 safe(io.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd), undefined),
60 safe(io.run(['git', 'rev-parse', '--absolute-git-dir'], cwd), undefined),
61 safe(io.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], cwd), undefined),
62 ])
63 const root = top.out.trim()
64 await io.state.env.set(env => ({
65 ...env,
66 repoRoot: root,
67 isLinkedWorktree: !!gitDir && !!common && gitDir.out.trim() !== common.out.trim(),
68 ...(branch && branch.code === 0 ? { branch: branch.out.trim() } : {}),
69 }))
70}
71
72const absolute = (cwd: string, p: string): string => (p.startsWith('/') ? p : joinPath(cwd, p))
73
74export const onToolResult = async (io: Io, toast: Toaster, ev: ToolEvent): Promise<void> => {
75 switch (ev.tool) {
76 case 'Write':
77 case 'Edit':
78 case 'MultiEdit':
79 case 'NotebookEdit':
80 return onWrite(io, toast, ev)
81 case 'Bash':
82 return onBash(io, toast, ev)
83 case 'Read': {
84 const m = SKILL_FILE.exec(str(ev.input.file_path))
85 const skill = normalizeSkill(m?.[1])
86 if (skill) await onSkill(io, skill, { via: 'read', ...(ev.agentId ? { agentId: ev.agentId } : {}) })
87 return
88 }
89 case 'Skill': {
90 const skill = normalizeSkill(str(ev.input.skill))
91 if (skill) await onSkill(io, skill, { via: 'tool', ...(ev.agentId ? { agentId: ev.agentId } : {}), ...(str(ev.input.args) ? { args: str(ev.input.args) } : {}) })
92 return
93 }
94 case 'Agent':
95 if (str(ev.input.isolation) === 'worktree') await io.state.env.set(env => ({ ...env, worktreeEvents: env.worktreeEvents + 1 }))
96 return
97 case 'EnterWorktree':
98 case 'ExitWorktree':
99 await io.state.env.set(env => ({ ...env, worktreeEvents: env.worktreeEvents + 1 }))
100 if (ev.tool === 'ExitWorktree' && str(ev.input.action) === 'remove') {
101 await io.state.run.set(r => (r.finishing ? { ...r, finishing: { ...r.finishing, worktreeRemoved: true } } : r))
102 }
103 if (!ev.replay) await refreshGit(io)
104 return
105 default:
106 return
107 }
108}
109
110const onWrite = async (io: Io, toast: Toaster, ev: ToolEvent): Promise<void> => {
111 const cwd = await safe(io.session.cwd(), '')
112 const raw = str(ev.input.file_path) || str(ev.input.notebook_path)
113 if (!raw || ev.isError) return
114 const path = absolute(cwd, raw)
115 const env = await io.state.env.get()
116 const settings = await io.state.settings.get()
117 const rel = relTo(env.repoRoot ?? cwd, path)
118 const now = await io.now()
119
120 if (specFile(settings.specDir).test(rel)) {
121 await io.state.run.set(r => advance(goLive({ ...r, specPath: rel }, now), 'spec', now))
122 await note(io, { kind: 'artifact', label: `spec ${rel.split('/').at(-1)}`, ok: true })
123 return
124 }
125 if (planFile(settings.planDir).test(rel)) {
126 const before = await io.state.run.get()
127 await io.state.run.set(r => advance(goLive(r, now), 'plan', now))
128 await refreshPlan(io, path)
129 const after = await io.state.run.get()
130 if (before.planPath !== after.planPath && !ev.replay) {
131 toast('◆', 'Plan written', `${after.tasks.length} tasks · ${after.planTitle ?? rel}`)
132 }
133 if (env.repoRoot && !after.workspace) {
134 const ws = await resolveWorkspace(io, env.repoRoot, path)
135 if (ws) await io.state.run.set(r => ({ ...r, workspace: ws }))
136 }
137 await note(io, { kind: 'artifact', label: `plan ${rel.split('/').at(-1)}`, ok: true })
138 return
139 }
140 if (SP_PRIVATE.test(rel) || DOC_FILE.test(rel)) return
141
142 const run = await io.state.run.get()
143 const agents = await io.state.agents.get()
144 const agent = ev.agentId ? agents.find(a => a.agentId === ev.agentId) : undefined
145 const taskN = agent?.task ?? run.currentTask
146
147 if (isTestFile(rel, settings.testFiles)) {
148 if (taskN !== undefined) await patchTask(io, taskN, t => ({ ...t, sawTestEdit: true }))
149 return
150 }
151
152 // Production code from here on.
153 if (taskN !== undefined && run.stage === 'execute') {
154 const task = run.tasks.find(t => t.n === taskN)
155 const sawRed = task?.tdd.some(d => !d.ok) ?? false
156 if (task && !task.sawTestEdit && !sawRed && !task.implFirst) {
157 await patchTask(io, taskN, t => ({ ...t, implFirst: true }))
158 if (!ev.replay) await warn(io, toast, 'impl-before-test', `Task ${taskN}: production code changed before a test or a failing run`, `${rel} (${ev.id})`)
159 }
160 }
161 if (ev.agentId) return
162
163 if (run.debuggingActive) await io.state.run.set(r => ({ ...r, debugEditPending: true }))
164 if (run.brainstormActive && !run.approvalSeen && (run.path === 'bounded' || run.path === 'architectural')) {
165 await warn(io, toast, 'code-before-approval', `Code written before ${run.path} approval`, `${rel} (${ev.id})`)
166 }
167 if (run.lifecycle === 'live' && run.executor === 'sdd' && run.stage === 'execute' && run.workspace) {
168 await warn(io, toast, 'controller-edit', 'The controller edited code itself during SDD', `${rel} (${ev.id})`)
169 }
170}
171
172const onBash = async (io: Io, toast: Toaster, ev: ToolEvent): Promise<void> => {
173 const command = str(ev.input.command)
174 if (!command) return
175 const cwd = await safe(io.session.cwd(), '')
176 const settings = await io.state.settings.get()
177 const now = await io.now()
178 const out = ev.text
179 let touchedLedger = false
180
181 // SDD and executing-plans scripts.
182 const script = SCRIPT_ARGS.exec(command)
183 if (script) {
184 const [, name, planArg, nArg, baseArg] = script
185 const plan = planArg ? absolute(cwd, planArg.replace(/^["']|["']$/g, '')) : undefined
186 const n = nArg ? Number(nArg) : undefined
187 await io.state.run.set(r => goLive(r, now))
188 const wsLine = WORKSPACE_DIR.exec(out)
189 const brief = OUT_TASK_BRIEF.exec(out)
190 const startBrief = OUT_TASK_START_BRIEF.exec(out)
191 const workspace = brief?.[1] ? dirname(brief[1]) : startBrief?.[1] ? dirname(startBrief[1].trim()) : wsLine?.[1]
192 await io.state.run.set(r => ({
193 ...r,
194 ...(workspace ? { workspace } : {}),
195 ...(plan ? { planPath: plan } : {}),
196 ...(name === 'task-start' || name === 'task-done' ? (r.executor ? {} : { executor: 'inline' as const }) : {}),
197 }))
198 if (plan) await refreshPlan(io, plan)
199 if (n !== undefined && (name === 'task-brief' || name === 'task-start')) {
200 const base = OUT_TASK_START_BASE.exec(out)?.[1] ?? baseArg
201 await io.state.run.set(r => advance({ ...r, currentTask: n, ...(base ? { base } : {}) }, 'execute', now))
202 await patchTask(io, n, t => ({
203 ...t,
204 status: t.status === 'done' || t.status === 'parked' ? t.status : t.status === 'fixing' ? 'fixing' : 'running',
205 startedAt: t.startedAt ?? now,
206 ...(brief?.[1] || startBrief?.[1] ? { brief: (brief?.[1] ?? startBrief?.[1] ?? '').trim() } : {}),
207 }))
208 }
209 if (name === 'task-done' && n !== undefined) {
210 const ok = OUT_TASK_DONE_LEDGER.test(out) && !OUT_TASK_DONE_FAIL.test(out + ev.text)
211 await recordTest(io, toast, ev, `task-done ${n}`, ok, n)
212 }
213 touchedLedger = true
214 }
215
216 // Test runs.
217 if (!script && isTestCommand(command, settings.testRunners)) {
218 const failed = ev.isError || /exit(?:ed)? (?:code |with )?[1-9]\d*/i.test(out)
219 await recordTest(io, toast, ev, command, !failed)
220 } else if (!ev.agentId && VERIFY_HINT.test(command)) {
221 await io.state.run.set(r => ({ ...r, turnHadVerification: true }))
222 }
223
224 // git.
225 if (G.commit.test(command) && !ev.isError) {
226 await io.state.run.set(r => (r.specPath && !r.specCommitted ? { ...r, specCommitted: true } : r))
227 }
228 if (G.worktreeAdd.test(command) || G.worktreeRemove.test(command)) {
229 await io.state.env.set(env => ({ ...env, worktreeEvents: env.worktreeEvents + 1 }))
230 }
231 await io.state.run.set(r => {
232 if (!r.finishing) return r
233 const f = { ...r.finishing }
234 if (G.worktreeRemove.test(command) && !ev.isError) f.worktreeRemoved = true
235 if (G.branchDelete.test(command) && !ev.isError) {
236 f.branchDeleted = true
237 if (/\s-D\b/.test(command)) f.option = 'discard'
238 }
239 if (G.merge.test(command) && !f.option) f.option = 'merge'
240 if (G.prCreate.test(command) || /\bgit\s+push\s+(?:-u|--set-upstream)\b/.test(command)) f.option = 'pr'
241 if (G.workspaceDelete.test(command) && !ev.isError) f.workspaceDeleted = true
242 return { ...r, finishing: f }
243 })
244 if (G.workspaceDelete.test(command) && !ev.isError) touchedLedger = true
245
246 // The visual companion.
247 if (G.startServer.test(command)) {
248 const json = /\{"type":"server-started"[^\n]*\}/.exec(out)?.[0]
249 if (json) {
250 const info = safeJson(json)
251 const stateDir = str(info?.state_dir)
252 if (stateDir) {
253 const companion: Companion = {
254 dir: dirname(stateDir),
255 alive: true,
256 stopped: false,
257 clicks: 0,
258 ...(str(info?.url) ? { url: str(info?.url) } : {}),
259 ...(typeof info?.port === 'number' ? { port: info.port } : {}),
260 }
261 await io.state.env.set(env => ({ ...env, companion }))
262 if (!ev.replay) toast('◇', 'Visual companion up', companion.url ?? companion.dir)
263 }
264 }
265 }
266
267 if (!ev.replay) {
268 if (/\bgit\b/.test(command) || touchedLedger) await refreshGit(io)
269 await refreshLedger(io, toast)
270 }
271}
272
273const recordTest = async (io: Io, toast: Toaster, ev: ToolEvent, command: string, ok: boolean, forcedTask?: number): Promise<void> => {
274 const now = await io.now()
275 const run = await io.state.run.get()
276 const agents = await io.state.agents.get()
277 const agent = ev.agentId ? agents.find(a => a.agentId === ev.agentId) : undefined
278 const task = forcedTask ?? agent?.task ?? run.currentTask
279 const counts = parseTestOutput(ev.text)
280 const t: TestRun = {
281 at: now,
282 command: command.slice(0, 200),
283 ok,
284 ref: ev.id,
285 ...(counts.passed !== undefined ? { passed: counts.passed } : {}),
286 ...(counts.failed !== undefined ? { failed: counts.failed } : {}),
287 ...(task !== undefined ? { task } : {}),
288 ...(ev.agentId ? { agentId: ev.agentId } : {}),
289 }
290 await io.state.tests.set(list => capTail([...list, t], CAP.tests))
291 if (task !== undefined) await patchTask(io, task, row => ({ ...row, tdd: capTail([...row.tdd, { ok, at: now }], 24) }))
292 await note(io, { kind: 'test', label: `${ok ? 'pass' : 'fail'} · ${command.slice(0, 60)}`, ok, ...(ev.agentId ? { agentId: ev.agentId } : {}) })
293 if (ev.agentId) return
294 await io.state.run.set(r => ({ ...r, turnHadVerification: true }))
295 // Systematic debugging: an edit followed by a failing re-run is one failed fix.
296 if (run.debuggingActive && run.debugEditPending) {
297 await io.state.run.set(r => ({ ...r, debugEditPending: false }))
298 if (!ok) {
299 const env = await io.state.env.set(e => ({ ...e, debugFixes: e.debugFixes + 1 }))
300 if (env.debugFixes >= 3 && !ev.replay) {
301 await warn(io, toast, 'debug-three-fixes', '3+ fixes failed: question the architecture', `${command.slice(0, 60)} (${ev.id})`)
302 }
303 } else {
304 await io.state.env.set(e => ({ ...e, debugFixes: 0 }))
305 }
306 }
307}
308
309const safeJson = (s: string): Record<string, unknown> | undefined => {
310 try {
311 const v: unknown = JSON.parse(s)
312 return v && typeof v === 'object' ? (v as Record<string, unknown>) : undefined
313 } catch {
314 return undefined
315 }
316}
317hooks/signals/agents.ts 225 lines1// Subagents: classified at spawn, verdicts parsed from their final answers.
2
3import type { Agent, Role } from '../../types'
4import type { Io } from '../io'
5import { tierOf } from '../patterns'
6import {
7 classifyRole,
8 fixRoundOf,
9 parseFinalReview,
10 parseImplementer,
11 parseReReview,
12 parseTaskReview,
13 taskOf,
14} from '../parsers'
15import { CAP, capTail } from '../state'
16import { advance, goLive } from '../stages'
17import { fmtDuration, note, patchTask, warn, type Toaster } from '../core'
18import { guardSpawn, isSddRole, ledgerDone } from '../modes'
19
20export type SpawnInput = {
21 tool_use_id: string
22 prompt: string
23 description: string
24 subagentType: string
25 model?: string
26 parentModel?: string
27 parentAgentId?: string
28}
29
30export type SpawnAssessment = { role: Role; task?: number; deny?: string; shaped: boolean }
31
32const ROLE_LABEL: Record<Role, string> = {
33 implementer: 'implementer',
34 'task-reviewer': 'task reviewer',
35 're-reviewer': 're-reviewer',
36 'final-reviewer': 'final reviewer',
37 investigator: 'investigator',
38 analyst: 'analyst',
39 'spec-reviewer': 'spec reviewer',
40 other: 'subagent',
41}
42export const roleLabel = (r: Role): string => ROLE_LABEL[r]
43
44/** Before the spawn runs: role, warnings, and a strict-mode denial. */
45export const assessSpawn = async (io: Io, toast: Toaster, e: SpawnInput, replay = false): Promise<SpawnAssessment> => {
46 const role = classifyRole(e.description, e.prompt)
47 const task = taskOf(e.description, role === 'other' ? '' : e.prompt)
48 const shaped = role !== 'other'
49 if (!shaped) return { role, ...(task !== undefined ? { task } : {}), shaped }
50
51 const [run, ledger, agents, settings, env] = await Promise.all([
52 io.state.run.get(),
53 io.state.ledger.get(),
54 io.state.agents.get(),
55 io.state.settings.get(),
56 io.state.env.get(),
57 ])
58 const done = ledgerDone(ledger)
59 const running = agents.filter(a => a.role === 'implementer' && a.status === 'running')
60 const parent = e.parentAgentId ? agents.find(a => a.agentId === e.parentAgentId) : undefined
61 const modelOmitted = e.model === undefined || e.model === ''
62 const round = fixRoundOf(e.description)
63 const ev = `${e.description} (${e.tool_use_id})`
64
65 if (!replay) {
66 if (role === 'implementer' && task !== undefined && done.includes(task) && round === undefined) {
67 await warn(io, toast, 'redispatch-complete', `Task ${task} re-dispatched after it completed`, `ledger: Task ${task}: complete · ${ev}`)
68 }
69 if (role === 'implementer' && running.length > 0) {
70 await warn(io, toast, 'parallel-implementers', 'Two implementers running at once', ev)
71 }
72 if (isSddRole(role) && modelOmitted) {
73 await warn(io, toast, 'model-omitted', `${roleLabel(role)} dispatched with no explicit model`, ev)
74 }
75 if (parent && (parent.role === 'implementer' || parent.role.endsWith('reviewer'))) {
76 await warn(io, toast, 'nested-subagent', `A ${roleLabel(parent.role)} spawned a subagent`, ev)
77 }
78 if (role === 'implementer' && /^(main|master)$/.test(env.branch ?? '') && !env.isLinkedWorktree) {
79 await warn(io, toast, 'impl-on-main', `Implementation dispatched on ${env.branch} with no worktree`, ev)
80 }
81 if (role === 'implementer' && task !== undefined && round !== undefined && round > 5) {
82 await warn(io, toast, 'fix-loop-overrun', `Task ${task} fix loop past round 5`, ev)
83 }
84 }
85
86 const deny = replay || run.spectator
87 ? undefined
88 : guardSpawn({
89 mode: settings.mode,
90 role,
91 modelOmitted,
92 ledgerDone: done,
93 runningImplementers: running.length,
94 ...(task !== undefined ? { task } : {}),
95 ...(round !== undefined ? { fixRound: round } : {}),
96 })
97 if (deny) await warn(io, toast, 'strict-deny', `Strict mode denied a ${roleLabel(role)} spawn`, ev)
98 return { role, shaped, ...(task !== undefined ? { task } : {}), ...(deny ? { deny } : {}) }
99}
100
101/** After the spawn started: the agent row, the task row, the transcript label. */
102export const recordSpawn = async (
103 io: Io,
104 e: SpawnInput,
105 a: SpawnAssessment,
106 started: { agentId?: string; model?: string },
107): Promise<string | undefined> => {
108 const now = await io.now()
109 const model = e.model || started.model
110 const agent: Agent = {
111 id: e.tool_use_id,
112 description: e.description.slice(0, 160),
113 subagentType: e.subagentType,
114 modelOmitted: !e.model,
115 tier: tierOf(model ?? e.parentModel),
116 role: a.role,
117 status: 'running',
118 startedAt: now,
119 ...(started.agentId ? { agentId: started.agentId } : {}),
120 ...(e.parentAgentId ? { parentAgentId: e.parentAgentId } : {}),
121 ...(model ? { model } : {}),
122 ...(e.parentModel ? { parentModel: e.parentModel } : {}),
123 ...(a.task !== undefined ? { task: a.task } : {}),
124 }
125 await io.state.agents.set(list => capTail([...list.filter(x => x.id !== agent.id), agent], CAP.agents))
126 if (!a.shaped) return undefined
127 await io.state.run.set(r => {
128 let next = goLive(r, now)
129 if (a.role === 'implementer' || a.role === 'task-reviewer' || a.role === 're-reviewer') next = advance(next, 'execute', now)
130 if (a.role === 'final-reviewer') next = advance(next, 'review', now)
131 if (a.role === 'implementer' && a.task !== undefined) next = { ...next, currentTask: a.task }
132 return next
133 })
134 if (a.task !== undefined && a.role !== 'final-reviewer') {
135 await patchTask(io, a.task, t => ({
136 ...t,
137 agents: t.agents + 1,
138 startedAt: t.startedAt ?? now,
139 status: t.status === 'done' || t.status === 'parked' ? t.status : t.status === 'fixing' ? 'fixing' : 'running',
140 }))
141 }
142 await note(io, {
143 kind: 'agent-start',
144 label: `${roleLabel(a.role)}${a.task !== undefined ? ` · Task ${a.task}` : ''}`,
145 ...(started.agentId ? { agentId: started.agentId } : {}),
146 detail: model ?? 'model inherited',
147 })
148 const tier = model ? model.replace(/^claude-/, '').split('-')[0] : 'inherits'
149 return [roleLabel(a.role), a.task !== undefined ? `Task ${a.task}` : undefined, tier].filter(Boolean).join(' · ')
150}
151
152/** A subagent's turn.complete: duration and the verdict its answer carries. */
153export const onAgentDone = async (io: Io, toast: Toaster, agentId: string, answer: string, failed: boolean, replay = false): Promise<void> => {
154 const now = await io.now()
155 const agents = await io.state.agents.get()
156 const agent = agents.find(a => a.agentId === agentId)
157 if (!agent) return
158 let verdict: string | undefined
159 let summary: string | undefined
160 const n = agent.task
161 switch (agent.role) {
162 case 'implementer': {
163 const r = parseImplementer(answer)
164 verdict = r.status
165 summary = [r.commits.slice(0, 2).join(' '), r.tests].filter(Boolean).join(' · ')
166 if (n !== undefined) {
167 await patchTask(io, n, t => ({
168 ...t,
169 ...(r.status === 'BLOCKED' || r.status === 'NEEDS_CONTEXT' ? { status: 'blocked' as const } : {}),
170 ...(r.report ? { report: r.report } : {}),
171 }))
172 if (!replay && (r.status === 'BLOCKED' || r.status === 'NEEDS_CONTEXT')) {
173 const why = r.status === 'BLOCKED' ? 'blocked' : 'needs context'
174 toast('✗', `Implementer ${why}`, `Task ${n} · the controller will re-dispatch`, 'warn')
175 }
176 }
177 break
178 }
179 case 'task-reviewer': {
180 const r = parseTaskReview(answer)
181 verdict = `${r.spec === 'ok' ? 'Spec ✓' : r.spec === 'bad' ? 'Spec ✗' : r.spec === 'warn' ? 'Spec ⚠' : 'Spec ?'} · ${r.quality ?? '?'}`
182 summary = `${r.critical} critical · ${r.important} important · ${r.minor} minor`
183 if (n !== undefined) await patchTask(io, n, t => ({ ...t, verdict }))
184 if (!replay && n !== undefined) {
185 const clean = r.spec !== 'bad' && /approved/i.test(r.quality ?? '') && r.critical + r.important === 0
186 toast(clean ? '●' : '◐', `Task ${n} review`, clean ? 'clean' : summary)
187 }
188 break
189 }
190 case 're-reviewer': {
191 const r = parseReReview(answer)
192 verdict = r.clean ? 'all addressed' : `${r.notAddressed} open`
193 summary = `${r.addressed} addressed · ${r.notAddressed} not addressed`
194 break
195 }
196 case 'final-reviewer': {
197 const r = parseFinalReview(answer)
198 verdict = `Ready: ${r.ready ?? '?'}`
199 summary = `${r.critical} critical · ${r.important} important · ${r.minor} minor`
200 await io.state.run.set(run => ({
201 ...advance(run, 'review', now),
202 finalReview: { verdict: r.ready ?? '?', critical: r.critical, important: r.important, minor: r.minor, at: now },
203 }))
204 if (!replay) toast(r.ready === 'Yes' ? '●' : '◐', 'Final review', `${verdict} · ${summary}`)
205 break
206 }
207 default:
208 break
209 }
210 await io.state.agents.set(list =>
211 list.map(a =>
212 a.agentId === agentId
213 ? { ...a, status: failed ? 'failed' : 'done', endedAt: now, ...(verdict ? { verdict } : {}), ...(summary ? { summary } : {}) }
214 : a,
215 ),
216 )
217 await note(io, {
218 kind: 'agent-end',
219 label: `${roleLabel(agent.role)}${n !== undefined ? ` · Task ${n}` : ''} ${failed ? '✗' : '✓'}`,
220 agentId,
221 ok: !failed,
222 detail: [verdict, fmtDuration(now - agent.startedAt)].filter(Boolean).join(' · '),
223 })
224}
225hooks/signals/answers.ts 95 lines1// Main-loop turns: the brainstorming path, success claims without
2// verification, performative agreement, the rulings list, the ship moment.
3// Subagent turns never reach here.
4
5import type { Io, Message } from '../io'
6import { BOOTSTRAP, MINORS_LIST, PERFORMATIVE, RULINGS_LIST, SUCCESS_CLAIM } from '../patterns'
7import { detectPath, rulingCount } from '../parsers'
8import { fmtDuration, note, warn, type Toaster } from '../core'
9
10export const onPromptSubmitted = async (io: Io, fromPerson: boolean): Promise<void> => {
11 await io.state.run.set(r => {
12 let next = { ...r, turn: r.turn + 1, turnHadVerification: false }
13 // Bounded approval: the person replied after the in-chat design was shown.
14 if (fromPerson && r.brainstormActive && r.path === 'bounded' && r.boundedDesignShown && !r.approvalSeen) {
15 next = { ...next, approvalSeen: true }
16 }
17 return next
18 })
19}
20
21export const onMainAnswer = async (io: Io, toast: Toaster, answer: string, turnId: string, replay = false): Promise<void> => {
22 const run = await io.state.run.get()
23 if (run.lifecycle === 'dormant') return
24 const path = run.brainstormActive ? detectPath(answer) : undefined
25 if (path && (run.path === undefined || run.pathInferred)) {
26 await io.state.run.set(r => ({ ...r, path, pathInferred: true }))
27 await note(io, { kind: 'note', label: `path: ${path}?`, detail: 'announced; inferred until an artifact confirms it' })
28 }
29 if (run.brainstormActive && (path ?? run.path) === 'bounded' && /design|approach|files? touched|I'?d change/i.test(answer)) {
30 await io.state.run.set(r => ({ ...r, boundedDesignShown: true }))
31 }
32 if (!replay && SUCCESS_CLAIM.test(answer) && !run.turnHadVerification) {
33 const claim = SUCCESS_CLAIM.exec(answer)?.[0] ?? 'done'
34 await warn(io, toast, 'unverified-claim', `"${claim}" claimed with no verification run this turn`, `turn ${turnId}`)
35 }
36 if (!replay && PERFORMATIVE.test(answer)) {
37 await warn(io, toast, 'performative-agreement', 'Review feedback answered with performative agreement', `turn ${turnId}`)
38 }
39 const ledger = await io.state.ledger.get()
40 const rulings = rulingCount(ledger.entries)
41 if (RULINGS_LIST.test(answer) || MINORS_LIST.test(answer)) {
42 await io.state.run.set(r =>
43 r.finishing || r.stage === 'review' || r.stage === 'finish'
44 ? {
45 ...r,
46 finishing: {
47 ...(r.finishing ?? { worktreeRemoved: false, branchDeleted: false, workspaceDeleted: false, rulingsListed: false, minorsListed: false }),
48 rulingsListed: r.finishing?.rulingsListed || RULINGS_LIST.test(answer),
49 minorsListed: r.finishing?.minorsListed || MINORS_LIST.test(answer),
50 },
51 }
52 : r,
53 )
54 }
55 const after = await io.state.run.get()
56 if (!replay && rulings > 0 && after.finishing?.workspaceDeleted && !after.finishing.rulingsListed) {
57 await warn(io, toast, 'rulings-lost', `Workspace deleted; ${rulings} ledger ruling(s) never reached "Rulings I made"`, ledger.path ?? 'progress.md')
58 }
59 await maybeShip(io, toast, replay)
60}
61
62/** The ship moment: every task done and a clean final review, or the
63 * workspace deleted after the final review. */
64export const maybeShip = async (io: Io, toast: Toaster, replay = false): Promise<void> => {
65 const run = await io.state.run.get()
66 if (run.lifecycle === 'shipped' || run.shippedAt !== undefined) return
67 const fr = run.finalReview
68 const allDone = run.tasks.length > 0 && run.tasks.every(t => t.status === 'done' || t.status === 'parked')
69 const clean = fr && (fr.verdict === 'Yes' || fr.critical + fr.important === 0)
70 const deletedAfterReview = fr && run.finishing?.workspaceDeleted
71 if (!((allDone && clean) || deletedAfterReview)) return
72 const now = await io.now()
73 const warnings = await io.state.warnings.get()
74 const critical = warnings.filter(w => w.sev === 'critical').length
75 const summary = `${run.tasks.length} tasks · ${fmtDuration(now - run.startedAt)} · ${critical} critical`
76 await io.state.run.set(r => ({ ...r, lifecycle: 'shipped', shippedAt: now, shipSummary: summary, stage: 'finish', pulseAt: now }))
77 await note(io, { kind: 'note', label: 'shipped', ok: true, detail: summary })
78 if (!replay) toast('✓', 'Shipped!', summary)
79}
80
81/** Whether the session context carries the superpowers bootstrap block. */
82export const hasBootstrap = (messages: readonly Message[]): boolean =>
83 messages.slice(0, 40).some(m => BOOTSTRAP.test(m.text)) || messages.some(m => m.role === 'user' && BOOTSTRAP.test(m.text))
84
85export const checkBootstrap = async (io: Io, toast: Toaster, why: string, warnIfMissing: boolean): Promise<boolean> => {
86 const now = await io.now()
87 const messages = await io.session.messages().catch(() => [] as Message[])
88 const ok = hasBootstrap(messages)
89 await io.state.env.set(env => ({ ...env, bootstrap: [...env.bootstrap, { at: now, ok, why }].slice(-12) }))
90 if (!ok && warnIfMissing) {
91 await warn(io, toast, 'bootstrap-missing', `Bootstrap missing after ${why}: skills may stop triggering`, `${why} at ${new Date(now).toISOString()}`)
92 }
93 return ok
94}
95hooks/signals/plan.ts 168 lines1// The plan file and its ledger: parsed on change, folded into task rows.
2// The ledger is the source of truth for progress.
3
4import type { LedgerEntry, Run, TaskRow } from '../../types'
5import type { Io } from '../io'
6import { safe } from '../io'
7import { LEDGER_NAME } from '../patterns'
8import { parseLedger, parsePlan, progressOf } from '../parsers'
9import { basename, blankTask, dirname, fmtDuration, joinPath, note, type Toaster } from '../core'
10import { advance } from '../stages'
11
12/** Reads the plan, keeping every task's live status; a parse failure keeps the last good read. */
13export const refreshPlan = async (io: Io, planPath: string): Promise<void> => {
14 const text = await safe(io.fs.read(planPath), undefined as string | undefined)
15 if (text === undefined) return
16 const parsed = parsePlan(text)
17 await io.state.run.set(run => {
18 if (parsed.tasks.length === 0) return { ...run, planPath, planParseError: parsed.error ?? 'no tasks' }
19 const tasks: TaskRow[] = parsed.tasks.map(pt => {
20 const had = run.tasks.find(t => t.n === pt.n) ?? blankTask(pt.n, pt.name)
21 return { ...had, name: pt.name, steps: pt.steps, stepsDone: pt.stepsDone }
22 })
23 for (const extra of run.tasks) if (!tasks.some(t => t.n === extra.n)) tasks.push(extra)
24 tasks.sort((a, b) => a.n - b.n)
25 const { planParseError: _e, ...rest } = run
26 return {
27 ...rest,
28 planPath,
29 tasks,
30 hasReviewFocus: parsed.hasReviewFocus,
31 ...(parsed.title ? { planTitle: parsed.title } : {}),
32 ...(parsed.goal ? { planGoal: parsed.goal } : {}),
33 ...(parsed.spec ? { planSpec: parsed.spec } : {}),
34 }
35 })
36}
37
38/** sdd-workspace's resolution: <root>/.superpowers/sdd/<slug>[-<parent>[-N]] whose plan-path marker names the plan. */
39export const resolveWorkspace = async (io: Io, root: string, planPath: string): Promise<string | undefined> => {
40 const planId = planPath.startsWith(root + '/') ? planPath.slice(root.length + 1) : planPath
41 const slug = basename(planPath).replace(/\.md$/, '')
42 const parent = basename(dirname(planId.startsWith('/') ? planId : joinPath(root, planId)))
43 const base = joinPath(root, '.superpowers/sdd')
44 const candidates = [joinPath(base, slug), joinPath(base, `${slug}-${parent}`)]
45 for (let n = 2; n < 10; n += 1) candidates.push(joinPath(base, `${slug}-${parent}-${n}`))
46 for (const dir of candidates) {
47 const marker = await safe(io.fs.read(joinPath(dir, 'plan-path')), undefined as string | undefined)
48 if (marker === undefined) {
49 if (!(await safe(io.fs.exists(dir), false))) return undefined
50 continue
51 }
52 if (marker.trim() === planId || marker.trim() === planPath) return dir
53 }
54 return undefined
55}
56
57export type LedgerChange = { completed: number[]; fixRounds: { n: number; round: number }[]; finalReview: boolean }
58
59/** Reads progress.md when its size or mtime moved, folding it into the run. */
60export const refreshLedger = async (io: Io, toast: Toaster, opts: { force?: boolean; quiet?: boolean } = {}): Promise<LedgerChange | undefined> => {
61 const run = await io.state.run.get()
62 if (!run.workspace) return undefined
63 const path = joinPath(run.workspace, LEDGER_NAME)
64 const stat = await safe(io.fs.stat(path), undefined)
65 const ledger = await io.state.ledger.get()
66 if (!stat) {
67 if (ledger.path === path && ledger.size > 0) {
68 // The workspace is gone: superpowers deletes it after a clean final review.
69 await io.state.run.set(r => ({ ...r, finishing: { ...(r.finishing ?? emptyFinishing()), workspaceDeleted: true } }))
70 }
71 return undefined
72 }
73 if (!opts.force && ledger.path === path && ledger.mtime === stat.mtimeMs && ledger.size === stat.size) return undefined
74 const text = await safe(io.fs.read(path), undefined as string | undefined)
75 const now = await io.now()
76 if (text === undefined) {
77 await io.state.ledger.set(l => ({ ...l, parseError: `couldn't read ${path}; showing last good read` }))
78 return undefined
79 }
80 const parsed = parseLedger(text)
81 const before = new Set(ledger.path === path ? ledger.entries.map(key) : [])
82 const fresh = parsed.entries.filter(e => !before.has(key(e)))
83 await io.state.ledger.set(() => ({
84 path,
85 mtime: stat.mtimeMs,
86 size: stat.size,
87 entries: parsed.entries,
88 unknown: parsed.unknown.slice(-100),
89 readAt: now,
90 ...(parsed.planRef ? { planRef: parsed.planRef } : {}),
91 ...(parsed.error ? { parseError: parsed.error } : {}),
92 }))
93 const change: LedgerChange = { completed: [], fixRounds: [], finalReview: false }
94 for (const e of fresh) {
95 if (e.kind === 'complete' && e.task !== undefined) change.completed.push(e.task)
96 if (e.kind === 'fix-round' && e.task !== undefined) change.fixRounds.push({ n: e.task, round: e.round ?? 0 })
97 if (e.kind.startsWith('final')) change.finalReview = true
98 }
99 await io.state.run.set(r => applyLedger(r, parsed.entries, now))
100 if (opts.quiet) return change
101 const after = await io.state.run.get()
102 for (const n of change.completed) {
103 const t = after.tasks.find(x => x.n === n)
104 const took = t?.startedAt ? ` · ${fmtDuration((t.doneAt ?? now) - t.startedAt)}` : ''
105 const how = fresh.find(e => e.kind === 'complete' && e.task === n)
106 const detail = how?.parked ? `${how.parked} parked` : how?.clean ? 'review clean' : how?.tests ? 'tests green' : 'recorded'
107 toast('●', `Task ${n} complete`, `${detail}${took}`)
108 await note(io, { kind: 'ledger', label: `Task ${n} complete`, ok: true, detail: how?.commits ?? '' })
109 }
110 for (const f of change.fixRounds) {
111 await note(io, { kind: 'ledger', label: `Task ${f.n} fix round ${f.round}/5`, ok: false })
112 }
113 return change
114}
115
116const key = (e: LedgerEntry): string => `${e.kind}|${e.task ?? ''}|${e.text}|${e.round ?? ''}`
117
118const emptyFinishing = () => ({
119 worktreeRemoved: false,
120 branchDeleted: false,
121 workspaceDeleted: false,
122 rulingsListed: false,
123 minorsListed: false,
124})
125
126/** Folds ledger entries into task rows and the stage. Pure. */
127export const applyLedger = (run: Run, entries: readonly LedgerEntry[], now: number): Run => {
128 const p = progressOf(entries)
129 let tasks = run.tasks.map(t => ({ ...t, ledger: [] as string[] }))
130 const ensure = (n: number) => {
131 if (!tasks.some(t => t.n === n)) tasks = [...tasks, blankTask(n)].sort((a, b) => a.n - b.n)
132 }
133 for (const e of entries) {
134 if (e.task === undefined) continue
135 ensure(e.task)
136 tasks = tasks.map(t => (t.n === e.task ? { ...t, ledger: [...t.ledger, `${e.kind}: ${e.text}`] } : t))
137 }
138 let lastDone = run.lastDone
139 tasks = tasks.map(t => {
140 if (p.done.includes(t.n)) {
141 const entry = entries.find(e => e.kind === 'complete' && e.task === t.n)
142 const wasDone = t.status === 'done' || t.status === 'parked'
143 const doneAt = t.doneAt ?? now
144 if (!wasDone) lastDone = { n: t.n, at: now }
145 return {
146 ...t,
147 status: p.parked.has(t.n) ? 'parked' : 'done',
148 doneAt,
149 stepsDone: Math.max(t.stepsDone, t.steps),
150 ...(entry?.commits ? { commits: entry.commits } : {}),
151 }
152 }
153 const round = p.fixing.get(t.n)
154 if (round !== undefined) return { ...t, status: 'fixing', fixRound: round, breaker: p.breaker.has(t.n) }
155 return t
156 })
157 const anyWork = entries.some(e => e.task !== undefined)
158 let next: Run = { ...run, tasks, ...(lastDone ? { lastDone } : {}) }
159 if (anyWork) next = advance(next, 'execute', now)
160 const allDone = tasks.length > 0 && tasks.every(t => t.status === 'done' || t.status === 'parked')
161 if (allDone) next = advance(next, 'review', now, true)
162 if (entries.some(e => e.kind.startsWith('final'))) next = advance(next, 'review', now)
163 if (lastDone && lastDone !== run.lastDone) next = { ...next, pulseAt: now }
164 const current = tasks.find(t => t.status === 'fixing' || t.status === 'running')?.n
165 if (current !== undefined) next = { ...next, currentTask: current }
166 return next
167}
168hooks/modes.ts 81 lines1// observe records; assist adds a ledger note to context; strict also denies.
2// Every decision here is pure: the hooks gather the facts and act on the answer.
3
4import type { Ledger, Mode, Role, Run } from '../types'
5import { progressOf } from './parsers'
6
7const SDD_ROLES: ReadonlySet<Role> = new Set(['implementer', 'task-reviewer', 're-reviewer', 'final-reviewer'])
8export const isSddRole = (role: Role): boolean => SDD_ROLES.has(role)
9
10export type SpawnFacts = {
11 mode: Mode
12 role: Role
13 task?: number
14 modelOmitted: boolean
15 ledgerDone: readonly number[]
16 runningImplementers: number
17 fixRound?: number
18}
19
20/** Strict-mode denial for a subagent spawn, naming the evidence; undefined lets it run. */
21export const guardSpawn = (f: SpawnFacts): string | undefined => {
22 if (f.mode !== 'strict') return undefined
23 if (f.role === 'implementer' && f.task !== undefined && f.ledgerDone.includes(f.task) && f.fixRound === undefined) {
24 return `superpowers-tracker (strict): Task ${f.task} is already complete in the ledger (\`Task ${f.task}: complete\`). Do not re-dispatch it; resume at the first task without a complete line.`
25 }
26 if (f.role === 'implementer' && f.runningImplementers > 0) {
27 return 'superpowers-tracker (strict): an implementer subagent is still running. subagent-driven-development forbids parallel implementers; wait for its report.'
28 }
29 if (isSddRole(f.role) && f.modelOmitted) {
30 return 'superpowers-tracker (strict): this SDD subagent names no model, so it would inherit the session model. Pick one explicitly per the skill\'s Model Selection section and dispatch again.'
31 }
32 return undefined
33}
34
35export type WriteFacts = {
36 mode: Mode
37 isMain: boolean
38 isSource: boolean
39 run: Pick<Run, 'brainstormActive' | 'approvalSeen' | 'path'>
40}
41
42/** Strict-mode denial for a main-loop write of source before brainstorming approval. */
43export const guardWrite = (f: WriteFacts): string | undefined => {
44 if (f.mode !== 'strict' || !f.isMain || !f.isSource) return undefined
45 if (!f.run.brainstormActive || f.run.approvalSeen) return undefined
46 if (f.run.path !== 'bounded' && f.run.path !== 'architectural') return undefined
47 return `superpowers-tracker (strict): brainstorming is on the ${f.run.path} path and its design has not been approved yet. Present the ${f.run.path === 'bounded' ? 'in-chat design' : 'written spec'} and wait for approval before writing code.`
48}
49
50/** "1-4, 6" from [1,2,3,4,6]. */
51export const ranges = (ns: readonly number[]): string => {
52 const sorted = [...new Set(ns)].sort((a, b) => a - b)
53 const parts: string[] = []
54 let i = 0
55 while (i < sorted.length) {
56 let j = i
57 while (j + 1 < sorted.length && sorted[j + 1] === (sorted[j] ?? 0) + 1) j += 1
58 parts.push(i === j ? String(sorted[i]) : `${sorted[i]}–${sorted[j]}`)
59 i = j + 1
60 }
61 return parts.join(', ')
62}
63
64/** The assist nudge: at most 3 lines, quoting the ledger, never guessing. */
65export const ledgerNote = (ledger: Ledger, run: Pick<Run, 'tasks'>): string | undefined => {
66 if (!ledger.path || ledger.entries.length === 0) return undefined
67 const p = progressOf(ledger.entries)
68 const doneText = p.done.length ? `Task${p.done.length > 1 ? 's' : ''} ${ranges(p.done)} complete` : 'no task complete yet'
69 const fixing = [...p.fixing.entries()].map(([n, r]) => `Task ${n} mid fix round ${r}/5`)
70 const next = run.tasks.find(t => !p.done.includes(t.n) && !p.fixing.has(t.n))?.n
71 const resume = fixing.length ? fixing.join(', ') : next !== undefined ? `next is Task ${next}` : 'all listed tasks complete'
72 const lines = [
73 `superpowers-tracker: ledger ${ledger.path} shows ${doneText}; ${resume}.`,
74 `Trust the ledger and git log over memory: do not re-dispatch a task with a complete line.`,
75 ]
76 if (p.rulings > 0) lines.push(`${p.rulings} Ruling: line(s) must reach the final "Rulings I made" list.`)
77 return lines.slice(0, 3).join('\n')
78}
79
80export const ledgerDone = (ledger: Ledger): number[] => progressOf(ledger.entries).done
81hooks/scan.ts 262 lines1// The startup scan: unfinished superpowers work in this project and every
2// worktree of it. Cached in $.store by repo root and directory mtimes.
3
4import type { Candidate, Executor, Settings } from '../types'
5import type { Io } from './io'
6import { safe } from './io'
7import { BRAINSTORM_DIR, LEDGER_NAME } from './patterns'
8import { parseLedger, parsePlan, progressOf, rulingCount } from './parsers'
9import { ranges } from './modes'
10import { basename, joinPath, relTo } from './core'
11
12export const LIVE_WINDOW_MS = 5 * 60 * 1000
13
14const RANK: Record<Candidate['kind'], number> = {
15 'live-elsewhere': 0,
16 'mid-exec': 1,
17 planned: 2,
18 spec: 3,
19 companion: 4,
20 orphan: 5,
21}
22
23export const rankCandidates = (list: readonly Candidate[]): Candidate[] =>
24 [...list].sort((a, b) => RANK[a.kind] - RANK[b.kind] || b.mtime - a.mtime)
25
26export const executorOf = (ledgerText: string): Executor =>
27 /\btests:\s/.test(ledgerText) && !/review clean|fix round/.test(ledgerText) ? 'inline' : 'sdd'
28
29/** The prompt suggestion built from evidence: never typed, never sent. */
30export const suggestionFor = (c: Omit<Candidate, 'suggest'>, doneList: number[] = []): string => {
31 switch (c.kind) {
32 case 'live-elsewhere':
33 case 'mid-exec': {
34 const skill = c.executor === 'inline' ? 'superpowers:executing-plans' : 'superpowers:subagent-driven-development'
35 const done = doneList.length ? `Task${doneList.length > 1 ? 's' : ''} ${ranges(doneList)} complete` : 'no task complete yet'
36 const at = c.current !== undefined ? ` and Task ${c.current}${c.fixRound ? ` at fix round ${c.fixRound}/5` : ' next'}` : ''
37 return `Continue executing ${c.planPath ?? c.title} with ${skill}. The ledger shows ${done}${at}.`
38 }
39 case 'planned':
40 return `Review ${c.planPath ?? c.title} and choose an execution method: superpowers:subagent-driven-development or superpowers:executing-plans.`
41 case 'spec':
42 return `Continue from the spec ${c.specPath ?? c.title}: once it is approved, use superpowers:writing-plans to turn it into an implementation plan.`
43 case 'companion':
44 return `Continue brainstorming with the visual companion${c.url ? ` at ${c.url}` : ''}.`
45 case 'orphan':
46 return `The SDD workspace ${c.workspace ?? c.title} names a plan that no longer exists (${c.planPath ?? '?'}). Review its ledger, then remove the workspace if the work is done.`
47 }
48}
49
50type DirSig = { path: string; mtime: number }
51
52export type ScanResult = { candidates: Candidate[]; fromCache: boolean; roots: string[]; ms: number }
53
54export const worktreeRoots = async (io: Io, root: string): Promise<string[]> => {
55 const r = await safe(io.run(['git', 'worktree', 'list', '--porcelain'], root), undefined)
56 if (!r || r.code !== 0) return [root]
57 const roots = r.out.split('\n').filter(l => l.startsWith('worktree ')).map(l => l.slice(9).trim())
58 return roots.length ? [...new Set([root, ...roots])] : [root]
59}
60
61/** The cache key: every relevant file's name and mtime, by listing and
62 * stat alone; a changed plan, spec or ledger misses the cache, nothing is read. */
63const sigOf = async (io: Io, roots: readonly string[], s: Settings): Promise<DirSig[]> => {
64 const out: DirSig[] = []
65 for (const r of roots) {
66 for (const dir of [joinPath(r, s.planDir), joinPath(r, s.specDir), joinPath(r, BRAINSTORM_DIR)]) {
67 for (const e of await safe(io.fs.list(dir), [])) out.push({ path: joinPath(dir, e.name), mtime: e.mtimeMs })
68 }
69 const sdd = joinPath(r, '.superpowers/sdd')
70 for (const e of await safe(io.fs.list(sdd), [])) {
71 if (e.kind !== 'dir') continue
72 const st = await safe(io.fs.stat(joinPath(sdd, e.name, LEDGER_NAME)), undefined)
73 out.push({ path: joinPath(sdd, e.name), mtime: st?.mtimeMs ?? 0 })
74 }
75 }
76 return out
77}
78
79/** The full walk. `now` and the window decide what counts as recent. */
80export const scan = async (io: Io, root: string, settings: Settings, opts: { force?: boolean } = {}): Promise<ScanResult> => {
81 const t0 = await io.now()
82 const roots = await worktreeRoots(io, root)
83 const sig = await sigOf(io, roots, settings)
84 const cacheKey = `scan:${root}`
85 const cached = (await safe(io.store.get(cacheKey), undefined)) as { sig: DirSig[]; candidates: Candidate[] } | undefined
86 if (!opts.force && cached && JSON.stringify(cached.sig) === JSON.stringify(sig)) {
87 // Directories unchanged: refresh only each ledger's mtime (liveness).
88 const now = await io.now()
89 const candidates = await Promise.all(
90 cached.candidates.map(async c => {
91 if (!c.workspace) return c
92 const st = await safe(io.fs.stat(joinPath(c.workspace, LEDGER_NAME)), undefined)
93 if (!st) return c
94 const kind = c.kind === 'orphan' ? 'orphan' : now - st.mtimeMs < LIVE_WINDOW_MS ? 'live-elsewhere' : 'mid-exec'
95 return { ...c, mtime: st.mtimeMs, kind } as Candidate
96 }),
97 )
98 return { candidates: rankCandidates(candidates), fromCache: true, roots, ms: (await io.now()) - t0 }
99 }
100
101 const now = await io.now()
102 const windowMs = settings.windowDays * 24 * 3600 * 1000
103 const found: Candidate[] = []
104 const executedPlans = new Set<string>()
105 const plannedSpecs = new Set<string>()
106
107 for (const tree of roots) {
108 const isOther = tree !== root
109 // 1. SDD workspaces.
110 const sddDir = joinPath(tree, '.superpowers/sdd')
111 for (const ent of await safe(io.fs.list(sddDir), [])) {
112 if (ent.kind !== 'dir') continue
113 const ws = joinPath(sddDir, ent.name)
114 const marker = (await safe(io.fs.read(joinPath(ws, 'plan-path')), '')).trim()
115 const st = await safe(io.fs.stat(joinPath(ws, LEDGER_NAME)), undefined)
116 if (!marker || !st) continue
117 const planAbs = marker.startsWith('/') ? marker : joinPath(tree, marker)
118 executedPlans.add(planAbs)
119 executedPlans.add(joinPath(root, marker))
120 const ledgerText = await safe(io.fs.read(joinPath(ws, LEDGER_NAME)), '')
121 const parsed = parseLedger(ledgerText)
122 const p = progressOf(parsed.entries)
123 const planText = await safe(io.fs.read(planAbs), undefined as string | undefined)
124 const plan = planText !== undefined ? parsePlan(planText) : undefined
125 const total = plan?.tasks.length ?? Math.max(0, ...p.done, ...p.fixing.keys())
126 const current = [...p.fixing.keys()][0] ?? plan?.tasks.find(t => !p.done.includes(t.n))?.n
127 const executor = executorOf(ledgerText)
128 const kind: Candidate['kind'] = plan === undefined ? 'orphan' : now - st.mtimeMs < LIVE_WINDOW_MS ? 'live-elsewhere' : 'mid-exec'
129 const base = {
130 id: `ws:${ws}`,
131 kind,
132 title: plan?.title ?? basename(marker).replace(/\.md$/, ''),
133 stage: 'execute' as const,
134 planPath: relTo(tree, planAbs),
135 workspace: ws,
136 done: p.done.length,
137 total,
138 rulings: rulingCount(parsed.entries),
139 mtime: st.mtimeMs,
140 executor,
141 dim: kind === 'orphan',
142 ...(isOther ? { worktree: tree } : {}),
143 ...(current !== undefined ? { current } : {}),
144 ...(current !== undefined && p.fixing.get(current) ? { fixRound: p.fixing.get(current)! } : {}),
145 }
146 found.push({ ...base, suggest: suggestionFor(base, p.done) })
147 }
148
149 // 3. Plans written but never executed.
150 const planDir = joinPath(tree, settings.planDir)
151 for (const ent of await safe(io.fs.list(planDir), [])) {
152 if (ent.kind !== 'file' || !ent.name.endsWith('.md')) continue
153 const abs = joinPath(planDir, ent.name)
154 const text = await safe(io.fs.read(abs), '')
155 const plan = parsePlan(text)
156 if (plan.spec) plannedSpecs.add(basename(plan.spec))
157 if (executedPlans.has(abs)) continue
158 // A checkout's mtime is when it was cloned: date the plan by its name,
159 // else by the commit that added it, else by mtime.
160 const committed = await commitTime(io, tree, abs)
161 const written = dateFromName(ent.name) ?? committed ?? ent.mtimeMs
162 if (now - written > windowMs) continue
163 if (await completedInGit(io, tree, abs, committed ?? written, plan.tasks.length)) continue
164 const base = {
165 id: `plan:${abs}`,
166 kind: 'planned' as const,
167 title: plan.title ?? ent.name.replace(/\.md$/, ''),
168 stage: 'plan' as const,
169 planPath: relTo(tree, abs),
170 done: 0,
171 total: plan.tasks.length,
172 rulings: 0,
173 mtime: written,
174 dim: false,
175 ...(isOther ? { worktree: tree } : {}),
176 }
177 found.push({ ...base, suggest: suggestionFor(base) })
178 }
179 }
180
181 for (const tree of roots) {
182 // 4. Specs with no plan naming them.
183 const specDir = joinPath(tree, settings.specDir)
184 for (const ent of await safe(io.fs.list(specDir), [])) {
185 if (ent.kind !== 'file' || !/-design\.md$/.test(ent.name)) continue
186 const specWritten = dateFromName(ent.name) ?? (await commitTime(io, tree, joinPath(specDir, ent.name))) ?? ent.mtimeMs
187 if (plannedSpecs.has(ent.name) || now - specWritten > windowMs) continue
188 const base = {
189 id: `spec:${joinPath(specDir, ent.name)}`,
190 kind: 'spec' as const,
191 title: ent.name.replace(/^\d{4}-\d{2}-\d{2}-/, '').replace(/-design\.md$/, ''),
192 stage: 'spec' as const,
193 specPath: relTo(tree, joinPath(specDir, ent.name)),
194 done: 0,
195 total: 0,
196 rulings: 0,
197 mtime: specWritten,
198 dim: false,
199 ...(tree !== root ? { worktree: tree } : {}),
200 }
201 found.push({ ...base, suggest: suggestionFor(base) })
202 }
203 // 5. A visual companion still up.
204 const bdir = joinPath(tree, BRAINSTORM_DIR)
205 for (const ent of await safe(io.fs.list(bdir), [])) {
206 if (ent.kind !== 'dir') continue
207 const state = joinPath(bdir, ent.name, 'state')
208 const info = await safe(io.fs.read(joinPath(state, 'server-info')), '')
209 if (!info || (await safe(io.fs.exists(joinPath(state, 'server-stopped')), true))) continue
210 const pid = /^(\d+)-/.exec(ent.name)?.[1]
211 const alive = pid ? (await safe(io.run(['kill', '-0', pid]), { code: 1, out: '', err: '' })).code === 0 : false
212 const url = /"url"\s*:\s*"([^"]+)"/.exec(info)?.[1]
213 const st = await safe(io.fs.stat(joinPath(state, 'server-info')), undefined)
214 const base = {
215 id: `companion:${joinPath(bdir, ent.name)}`,
216 kind: 'companion' as const,
217 title: alive ? 'visual companion' : 'visual companion (stale)',
218 stage: 'brainstorm' as const,
219 done: 0,
220 total: 0,
221 rulings: 0,
222 mtime: st?.mtimeMs ?? 0,
223 dim: !alive,
224 ...(alive && url ? { url } : {}),
225 }
226 found.push({ ...base, suggest: suggestionFor(base) })
227 }
228 }
229
230 const candidates = rankCandidates(found)
231 await safe(io.store.set(cacheKey, { sig, candidates }), undefined)
232 return { candidates, fromCache: false, roots, ms: (await io.now()) - t0 }
233}
234
235/** `2026-10-05-retry-policy.md` → that day's end, local time; undefined without a date. */
236export const dateFromName = (name: string): number | undefined => {
237 const m = /^(\d{4})-(\d{2})-(\d{2})-/.exec(name)
238 if (!m) return undefined
239 const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 23, 59, 59).getTime()
240 return Number.isNaN(t) ? undefined : t
241}
242
243/** When git first saw the file, in ms; undefined when it is not committed. */
244const commitTime = async (io: Io, tree: string, file: string): Promise<number | undefined> => {
245 const r = await safe(io.run(['git', 'log', '--diff-filter=A', '--format=%ct', '-1', '--', relTo(tree, file)], tree), undefined)
246 const sec = r && r.code === 0 ? Number(r.out.trim().split('\n')[0]) : NaN
247 return Number.isFinite(sec) && sec > 0 ? sec * 1000 : undefined
248}
249
250/** Completion evidence in git: at least as many commits since the plan was
251 * written as it has tasks, touching more than docs. */
252const completedInGit = async (io: Io, tree: string, plan: string, mtimeMs: number, tasks: number): Promise<boolean> => {
253 if (tasks === 0) return false
254 const since = `@${Math.floor(mtimeMs / 1000)}`
255 const r = await safe(io.run(['git', 'log', `--since=${since}`, '--format=%h', '--', '.', `:(exclude)${relTo(tree, plan)}`, ':(exclude)docs/'], tree), undefined)
256 if (!r || r.code !== 0) return false
257 return r.out.trim().split('\n').filter(Boolean).length >= tasks
258}
259
260/** The Claude Code transcript folder for a project path. */
261export const projectDirName = (cwd: string): string => cwd.replace(/[^A-Za-z0-9]/g, '-')
262