SLOPSHOPPER

superpowers-tracker

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

newpanebandguardcommandtoast
★ 2v0.1.0MITupdated 2026-10-05nalyk/nalyk-skills/plugins/superpowers-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · superpowers-tracker
│ ┃ superpowers ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ϟ superpowers brainstorm OBSERVE 6.4.2 ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ 1 Flow 2: Plan 3: Agents 4: Ledger 5: T ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ F L O W 1 stations ⏺ Bash(bun test) │ ┃ │ ┃ ✗ fail · bun test 01:53 ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ k: up j: down v: expand n: next tab p ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ✻ Worked for 42s · done 4:20 PM │ │ › /sp │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · superpowers
╭─────────────────────────────────────────────────────────── │ ϟ superpowers brainstorm OBSERVE 6.4.2 │ │ 1 Flow 2: Plan 3: Agents 4: Ledger 5: Tests 6: Workspac │ │ F L O W 1 stations │ ┃ ✗ fail · bun test 01:53 │ │ k: up j: down v: expand n: next tab p: prev tab e: e ╰───────────────────────────────────────────────────────────
Pane · sp-theme
╭──────────────────────────────────────────────────────────╮ │ ϟ Arc tokens showing dark │ │ │ │ D A R K │ │ ██ stage.brainstorm #B48CFF 7.61:1 ✓ │ │ ██ stage.spec #7AA2FF 7.89:1 ✓ │ │ ██ stage.plan #5CC8FF 10.43:1 ✓ │ │ ██ stage.execute #3DDBB5 11.22:1 ✓ │ │ ██ stage.review #A3E635 13.02:1 ✓ │ │ ██ stage.finish #FFC857 12.76:1 ✓ │ │ ██ ok #4ADE80 11.27:1 ✓ │ │ ██ warn #FBBF24 11.76:1 ✓ │ │ ██ critical #F87171 7.10:1 ✓ │ │ ████████████████████████ brand gradient (OKLCH) │ │ │ │ L I G H T │ │ ██ stage.brainstorm #7C3AED 5.70:1 ✓ │ │ ██ stage.spec #3B5BDB 5.67:1 ✓ │ │ ██ stage.plan #007BBD 4.60:1 ✓ │ │ ██ stage.execute #008663 4.57:1 ✓ │ │ ██ stage.review #4D7C0F 4.99:1 ✓ │ │ ██ stage.finish #B45309 5.02:1 ✓ │ │ ██ ok #15803D 5.02:1 ✓ │ │ ██ warn #B45309 5.02:1 ✓ │ │ ██ critical #DC2626 4.83:1 ✓ │ │ ████████████████████████ brand gradient (OKLCH) │ │ │ │ D A R K - D A L T O N I Z E D │ │ ██ stage.brainstorm #B48CFF 7.61:1 ✓ │ │ ██ stage.spec #7AA2FF 7.89:1 ✓ │ │ ██ stage.plan #5CC8FF 10.43:1 ✓ │ │ ██ stage.execute #3DDBB5 11.22:1 ✓ │ │ ██ stage.review #38BDF8 9.16:1 ✓ │ │ ██ stage.finish #FFC857 12.76:1 ✓ │ │ ██ ok #4ADE80 11.27:1 ✓ │ │ ██ warn #FBBF24 11.76:1 ✓ │ │ ██ critical #FB923C 8.68:1 ✓ │ │ ████████████████████████ brand gradient (OKLCH) │ │ │ │ L I G H T - D A L T O N I Z E D │ │ ██ stage.brainstorm #7C3AED 5.70:1 ✓ │ │ ██ stage.spec #3B5BDB 5.67:1 ✓ │ │ ██ stage.plan #007BBD 4.60:1 ✓ │ │ ██ stage.execute #008663 4.57:1 ✓ │ │ ██ stage.review #0369A1 5.93:1 ✓ │ │ ██ stage.finish #B45309 5.02:1 ✓ │ │ ██ ok #15803D 5.02:1 ✓ │ │ ██ warn #B45309 5.02:1 ✓ │ │ ██ critical #C2410C 5.18:1 ✓ │ │ ████████████████████████ brand gradient (OKLCH) │ │ │ │ A N S I │ │ ██ stage.brainstorm ██ stage.spec ██ stage.plan ██ │ │ stage.execute ██ stage.review ██ stage.finish ██ ok │ │ ██ warn ██ critical │ │ │ │ d: dark l: light z: daltonized a: ansi u: auto │ ╰──────────────────────────────────────────────────────────╯
README

superpowers-tracker

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.

Install

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

It fires on its own

You never need to type /sp for it to come alive.

StateWhenWhat you see
dormantNo superpowers anywhereNothing. No timers, no scan beyond one stat.
armedsuperpowers is installed, or its bootstrap is in context▐ϟ superpowers▌ 6.4.2 · ready, muted, no motion (hidden under band compact)
resumableThe startup scan found earlier or still-running workThe resume capsule, a toast, and a ghost-text prompt suggestion
liveThe first superpowers signal: a skill, an SDD script, a write to docs/superpowers/, a superpowers-shaped subagentThe full rail, with a pulse
shippedEvery task is done and the final review is cleanA 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:

  • SDD and inline plans in progress (.superpowers/sdd/*/progress.md against the plan's tasks), including orphaned workspaces whose plan no longer exists;
  • plans written but never executed, and specs with no plan (14-day window, dated by the file name or the commit that added it);
  • a visual companion still running (its pid is checked; a dead one is marked stale);
  • a ledger that is still moving: ◐ live in another session (likely). The tracker follows it read-only and never acts on it;
  • the tracker's own snapshots, the "left off at" record, and the last session here that used superpowers.

Results are cached in the plugin store, keyed by repo root and file mtimes.

The band

▐ϟ 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:

ColumnsBand
under 50ϟ E ▕█████▍ ▏ 5/9 ⚠1
50–89Initials (B S P E R F), with the current stage spelled out
90–139Everything
140 and upAlso the plan title

Motion is purposeful only:

  • a shimmer on the current segment while a turn runs (≤ 12 fps);
  • a pulse when a node lands;
  • a 400 ms ease on the progress bar;
  • a spinner per running subagent.

/sp config motion off, NO_COLOR or an ANSI theme stops all of it. An idle band runs no timer.

The pane (/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   │
╰─────────────────────────────────────────────────────────────────────╯
  1. Flow: a subway map. Skills sit on ┃, subagents fork ├━━◐ and rejoin ╰━━●, and compactions break the line with ┊. v opens the selected station.
  2. Plan: one row per task (status, steps bar, fix pips, commits, duration) and a detail card. Below it, a Gantt strip drawn as a Raster: build █, review █, fix rounds ▒, now │.
  3. Agents: swimlanes on the session's time axis, then a table with running rows first. Hover a row to see its full description.
  4. Ledger: rulings as cards (decision, why, cost if wrong), parked findings, deferred minors, the pre-flight table.
  5. Tests: each task's chain of red and green dots, a RED→GREEN ✓ or ⚠ impl first badge, and the debugging meter.
  6. Workspace: an environment grid, a braille context gauge, links, every resume candidate with its actions, and the last session id with claude --resume <id>.
  7. Warnings: cards by severity, with 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.

Modes

ModeEffect
observe (default)Records and displays only. Never denies, rewrites or injects.
assistAdds 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.
strictAssist, 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.

Warnings

  • Critical: a completed task re-dispatched.
  • Warn:
  • code before approval;
  • implementation dispatched on main or master with no worktree;
  • an SDD subagent with no model;
  • parallel implementers;
  • a subagent spawned by an implementer or a reviewer;
  • the fix loop past round 5;
  • the controller editing code during SDD;
  • the bootstrap missing after a compaction;
  • 3+ failed fixes while debugging;
  • rulings lost when the workspace was deleted.
  • Info: a success claim with no verification in the same turn; performative agreement; production code before a test.

Commands

Command
/sp [1-7]Open the pane (on a tab)
/sp statusStage, tasks, warnings, rulings, minors
/sp ledgerRulings I made, parked findings, deferred minors (ready to paste)
/sp exportMarkdown 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 resetClear 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 scanRerun the startup scan
/sp demoA scripted 60-second run through every state
/sp themeToken swatches with contrast ratios; preview dark, light, daltonized, ANSI
/sp diagnoseThe phrase that triggers diagnosing-superpowers, and the session id; offers to send it

Settings (/sp config)

KeyDefault
motionon
themeauto (follows Claude Code's theme and NO_COLOR), dark, light, daltonized, ansi
toastson. Toasts within 1 s fold into one.
testRunnersnpm, pnpm, yarn and bun test; vitest, jest, pytest, go test, cargo test, rspec, mix test, dotnet test, phpunit, task-done
testFilestest/, *.test.*, *_test.*, test_*.py, …
specDir, planDirdocs/superpowers/specs, docs/superpowers/plans
pollMs2000. The ledger is polled only while this session executes a plan, or follows another session's.
windowDays14
autoOpenPanenever, resumable, live
mode, bandSee above

Settings persist in the plugin store. Session data lives in $.state and survives hot reloads.

Design: "Arc"

Each stage owns a hue, from ideas (violet) to shipped (gold): brainstorm #B48CFF, spec #7AA2FF, plan #5CC8FF, execute #3DDBB5, review #A3E635, finish #FFC857.

  • Gradients blend in OKLCH (hooks/ui/oklch.ts).
  • Every text token reaches 4.5:1 on its canvas in dark, light and both daltonized themes; a test enforces it. The light theme darkens plan, execute and the brand's cyan end to pass.
  • Daltonized themes shift review to #38BDF8 and critical to #FB923C.
  • Status is always a glyph plus a color: ● done, ◐ running, ○ pending, ✗ failed, ⚠ parked; model tiers ◇ ◈ ◆.
  • The brand mark is ϟ (U+03DF, single width). There are no emoji anywhere.

Recording a demo GIF

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.

Tests

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:

  • the parsers (fenced Task headings, every ledger line form, roles and verdicts);
  • the full happy path, from brainstorming to finishing, with the band asserted at each step;
  • the fix loop to the breaker;
  • re-dispatch after compaction under each mode, missing models and parallel implementers;
  • the unverified-claim warning;
  • every startup-scan fixture: checkout, .worktrees/, orphan, unexecuted plan, spec without a plan, live and dead companion, live elsewhere, --resume replay;
  • ranking, the +N pill, suggestions, /sp resume (fills, never submits), dismiss and forget across a restart, the scan cache;
  • width tiers 48–160, themes, motion, every surface, every pane tab, hotkeys, the not-placed fallback;
  • contrast, OKLCH, glyph widths, Raster code points, render time, an idle band holding still, the demo.

Troubleshooting

  • The band is empty. Run claude --debug. Look for ui.render (AbovePrompt): a hook returned a tree that does not validate, and for superpowers-tracker: lines.
  • Nothing happens in a project that uses superpowers. Run /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.
  • Too many resume candidates. Use /sp dismiss n --forget, or narrow windowDays.
Source 37 files
hooks/register.tsx 925 lines
1// 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}
925
hooks/io.ts 93 lines
1// 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}
93
hooks/state.ts 94 lines
1import 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]
94
hooks/patterns.ts 167 lines
1// 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}
167
hooks/core.ts 124 lines
1// 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
124
hooks/signals/skills.ts 80 lines
1// 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}
80
hooks/signals/tools.ts 317 lines
1// 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}
317
hooks/signals/agents.ts 225 lines
1// 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}
225
hooks/signals/answers.ts 95 lines
1// 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}
95
hooks/signals/plan.ts 168 lines
1// 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}
168
hooks/modes.ts 81 lines
1// 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
81
hooks/scan.ts 262 lines
1// 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