SLOPSHOPPER

overclock

Agenti, pipeline Superpowers, piano e sessioni in un pannello fisso

panebandspinnerguardcommandtoast
v0.1.0no licenseupdated 2026-10-19rh363/overclock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · overclock
│ ┃ Overclock ✕ › fix the failing auth test and add an audit log call │ ┃ [ Agenti ][ Pipeline ][ Piano ][ Sessioni ] │ ┃ Sessione main ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /overclock │ ⎿ overclock: Pannello aperto. │ │ ⟨Claude Code's own drawing⟩ ▣ app ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▣ app
Pane · Overclock
[ Agenti ][ Pipeline ][ Piano ][ Sessioni ] Sessione main
README

Overclock

Mod di Claude Code che tiene un pannello fisso con tutto quello che sta succedendo nella sessione: agenti, pipeline Superpowers, piano e altre sessioni. Sola osservazione: non modifica mai gli eventi (unica eccezione, il suffisso dello spinner).

Cosa fa

  • Agenti. Grafo testuale (nel terminale) o Svg (in Desktop) dei subagent lanciati: ● Explore mentre lavora, ✓ a fine lavoro. Lo spinner mostra · N agenti.
  • Pipeline Superpowers. Segue le 6 fasi (Brainstorming, Worktree, Piano, Esecuzione, Code review, Chiusura branch) in base alle skill invocate, con badge per TDD, Debug e Verifica. La fase sopravvive a /clear.
  • Banda spec/piano. Sopra il prompt: caso di sessione, spec e piano rilevati (Spec: <file>, Piano 7/12, Fase 4/6). Si possono fissare a mano con /overclock pin.
  • Tab Sessioni. Le sessioni vive sullo stesso computer, raggruppate per repo, con branch, fase e agenti attivi. Avviso ⚠ se due sessioni scrivono lo stesso file entro 5 minuti.

I tre casi di sessione, mostrati con colore e simbolo (leggibile anche senza colori):

CasoQuandoBanda
Astesso repo, worktree diversiverde ⎇
Brepo diversiblu ▣
Cstesso repo e stesso branch, più sessioni vivearancione ⇄ (ha la precedenza su A)

Il pannello si apre da solo solo con terminale di almeno 144 colonne.

Installazione

Al prompt di una sessione nel terminale:

/plugin install overclock --marketplace rh363/overclock

Rispondi y per aggiungere il marketplace, poi scegli lo scope (user è il primo). Da shell:

claude plugin install overclock --marketplace rh363/overclock

Nell'app desktop, dove /plugin non funziona: clicca + accanto al box del prompt, poi Plugins, Add marketplace con rh363/overclock, poi Add plugin. Un mod installato dal terminale a livello user si carica anche nelle sessioni della tab Code.

Sviluppo locale

Terminale:

claude --plugin-dir /percorso/di/overclock

Desktop: imposta CLAUDE_CODE_PLUGIN_DIRS nel blocco env di ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/percorso/di/overclock" } }

Comandi

/overclock senza argomenti apre il pannello se è chiuso e lo chiude se è aperto.

ComandoEffetto
/overclockapre/chiude il pannello
`/overclock agents\pipeline\plan\sessions`apre il pannello su quella tab
/overclock pin spec <path>fissa la spec (path relativo al worktree)
/overclock pin plan <path>fissa il piano
/overclock unpintorna al rilevamento automatico
/overclock phase <0-6>imposta la fase a mano
/overclock resetazzera la pipeline

Superficie d'accesso

Usa: $.fs.read/list/exists, $.store, $.state, $.ui, $.command, $.prompt.fill, $.session, $.agent.list, $.clock. Non usa: $.http, $.process, $.env, $.model, $.fs.write, tool.check.

Aggiungere una skill alla mappa delle fasi

La mappa skill → fase (o badge) sta in src/core/phases.ts (DEFAULT_PHASE_MAP), ad esempio:

'superpowers:brainstorming': { phase: 1 },
'superpowers:test-driven-development': { badge: 'TDD' },

In alternativa, senza toccare il codice, l'opzione phaseMapPath (file JSON, path assoluto o relativo alla cartella della sessione) si unisce alla mappa predefinita: le sue voci si aggiungono a quelle predefinite e, a parità di chiave, le sostituiscono. Stesso formato: { "plugin:skill": { "phase": 1-6 } | { "badge": "TDD" | "Debug" | "Verifica" } }.

Limiti

  • Una skill caricata da un subagente col tool Skill aggiunge il suo badge ma non fa avanzare la fase della sessione (skill.prompt non dice da quale agente arriva: Overclock la riconosce dalla chiamata Skill del subagente in corso). Una skill precaricata nel subagente dal suo frontmatter non passa dal tool Skill e conta ancora come skill della sessione.

Test

claude plugin validate .
claude plugin test .
npx -y -p typescript@5.6.3 tsc -p .

Verifica manuale

Da fare a mano (terminale largo almeno 144 colonne):

  1. Agenti. claude --plugin-dir <path>, chiedi di lanciare un subagent Explore. Atteso: il pannello si apre da solo, il grafo mostra ● Explore che poi diventa ✓; la banda mostra ▣ overclock (o ⎇) e Spec: 2026-10-19-overclock-design.md; lo spinner mostra · 1 agente.
  2. Due sessioni sullo stesso repo. git worktree add ../overclock-wt -b test/oc, apri una seconda sessione lì. Atteso: entro 15 secondi la banda diventa verde ⎇ e la tab Sessioni (4) mostra due righe sotto overclock. Alla fine git worktree remove ../overclock-wt && git branch -D test/oc.
  3. /clear. Nella prima sessione lancia /clear. Atteso: la tab Pipeline mostra ancora la fase di prima.
  4. Desktop. Aggiungi CLAUDE_CODE_PLUGIN_DIRS al blocco env di ~/.claude/settings.json, apri una sessione nella tab Code. Atteso: la tab Agenti disegna il grafo Svg.
Source 22 files
hooks/register.tsx 151 lines
1import type { EngineInterface, Register } from 'claude-code'
2import {
3  carryOverOnClear, configure, dispatchAgent, endSession, guardRender, handleCommand, initSession, logError, maybeAutoOpen, onPaneClosed,
4  inSubagentSkill, onSessionResumed, onSkill, onToolCall, onWrite, refreshGit, takeClearedId, writtenPath,
5} from '../src/app/runtime'
6import { renderBand, renderSpinner } from '../src/ui/band'
7import { renderPane } from '../src/ui/pane'
8
9const GIT_MOVE = /\bgit\s+(checkout|switch|worktree)\b/
10
11// Overclock only observes: a failure of its own must never reach the event chain.
12async function safe($: EngineInterface, work: () => Promise<void>): Promise<void> {
13  try {
14    await work()
15  } catch (err) {
16    logError($, err)
17  }
18}
19
20// Events that can gate (deny, block): if a hook of ours ever fails, the event goes on as if
21// we were not there. `next` is replay-safe inside a catch.
22const passThrough = <E, R>(_$: unknown, e: E, next: (e: E) => R): R => next(e)
23
24export const register: Register = (on, options) => {
25  configure(options)
26
27  on('session.start', async ($, e, next) => {
28    const r = await next(e)
29    await safe($, async () => {
30      await $.command.register({
31        name: 'overclock',
32        description: 'Pannello di Overclock: agenti, pipeline, piano, sessioni',
33        argumentHint: '[agents|pipeline|plan|sessions|pin|unpin|phase|reset]',
34        immediate: true,
35      })
36      await initSession($)
37    })
38    return r
39  })
40
41  on('agent.spawn', async ($, e, next) => {
42    const r = await next(e)
43    const id = r.agentId
44    if (id) {
45      await safe($, async () => {
46        const at = await $.clock.now()
47        await dispatchAgent(
48          $,
49          e.resume
50            ? { kind: 'agent.resumed', id, at }
51            : {
52                kind: 'agent.started', id, at, parentId: e.parentAgentId, toolUseId: e.tool_use_id,
53                type: e.subagentType, description: e.description, background: e.background,
54              },
55        )
56        await maybeAutoOpen($)
57      })
58    }
59    return r
60  }).catch(passThrough)
61
62  on('turn.complete', async ($, e, next) => {
63    const r = await next(e)
64    await safe($, async () => {
65      const usage = e.usage
66      if (e.agentId) {
67        await dispatchAgent($, { kind: 'agent.ended', id: e.agentId, reason: e.reason, usage, at: await $.clock.now() })
68      } else {
69        await dispatchAgent($, { kind: 'main.turn', usage })
70      }
71    })
72    return r
73  })
74
75  on('turn.start', async ($, e, next) => {
76    const r = await next(e)
77    if (!e.agentId) await safe($, () => refreshGit($))
78    return r
79  })
80
81  on('tool.call', async ($, e, next) => {
82    const r = e.tool === 'Skill' && e.agentId && typeof e.skill === 'string'
83      ? await inSubagentSkill(e.skill, () => next(e))
84      : await next(e)
85    await safe($, async () => {
86      await onToolCall($, e.agentId, e.tool)
87      const ok = r.deny === undefined && !r.isError
88      const written = ok ? writtenPath(e) : undefined
89      if (written !== undefined) await onWrite($, written)
90      const moved =
91        (e.tool === 'Bash' && typeof e.command === 'string' && GIT_MOVE.test(e.command)) ||
92        e.tool === 'EnterWorktree' ||
93        e.tool === 'ExitWorktree'
94      // A subagent's cwd is not the session's: only the main session moves branch or worktree.
95      if (moved && !e.agentId) await refreshGit($)
96    })
97    return r
98  }).catch(passThrough)
99
100  on('skill.prompt', async ($, e, next) => {
101    const r = await next(e)
102    // onSkill opens the pane itself when the phase changes.
103    await safe($, () => onSkill($, e.skill, e.pluginName))
104    return r
105  })
106
107  on('session.end', async ($, e, next) => {
108    await safe($, () => endSession($, e.sessionId, e.reason))
109    return next(e)
110  })
111
112  on('classic.SessionStart', async ($, e, next) => {
113    const r = await next(e)
114    // An event since the /clear may already have done the carry-over (see ensureInit).
115    if (e.source === 'clear') {
116      const oldId = takeClearedId()
117      if (oldId !== undefined) await safe($, () => carryOverOnClear($, oldId))
118    } else if (e.source !== 'startup') {
119      await safe($, () => onSessionResumed($))
120    }
121    return r
122  }).catch(passThrough)
123
124  on('command.run', { command: 'overclock' }, async ($, e) => handleCommand($, e.args))
125
126  // Observe-only: keeps paneOpen true to the pane's actual state.
127  on('ui.close', async ($, e, next) => {
128    await safe($, () => onPaneClosed($, e.id))
129    return next(e)
130  }).catch(passThrough)
131
132  // A render failure of ours falls back to the engine's own band and spinner (see guardRender).
133  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => guardRender($, e, next, (n) => renderBand($, e, n)))
134
135  on('ui.render', { component: 'Spinner' }, async ($, e, next) => guardRender($, e, next, (n) => renderSpinner($, e, n)))
136
137  on('ui.render', { component: 'Pane', requestId: 'overclock' }, async ($, e, next) => {
138    try {
139      return await renderPane($, e)
140    } catch (err) {
141      logError($, err)
142      try {
143        const { Text } = $.ui.resolve(e)
144        return <Text>Overclock: errore di disegno</Text>
145      } catch {
146        return next(e)
147      }
148    }
149  })
150}
151
src/app/runtime.ts 479 lines
1// Inbound adapter: turns engine events into core reductions, then publishes the result to
2// $.state (what the UI reads) and $.store (what other sessions read).
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, PluginOptions, Timer } from 'claude-code'
5import { reduceAgents, emptyTree } from '../core/agents'
6import { parseCommand } from '../core/commands'
7import type { Tab } from '../core/commands'
8import { DEFAULT_PHASE_MAP, PHASE_NAMES, parsePhaseMap, skillKey } from '../core/phases'
9import { applySkill, emptyPipeline, isEmptyPipeline, resetPipeline, setPhase } from '../core/pipeline'
10import { PLAN_DIR, SPEC_DIR, classifyDocPath, countCheckboxes, pickLatest } from '../core/plan'
11import { isLive, pruneWrites } from '../core/sessions'
12import { basename, joinPath, relativeTo } from '../core/paths'
13import type { AgentEvent, BadgeName, PhaseMap, PipelineState, SessionEntry } from '../core/types'
14import type { FilePort } from '../ports'
15import { storePort } from '../adapters/store'
16import { filePort, resolveGitInfo } from '../adapters/files'
17
18// One atom per key of PluginState['overclock']. The host's module scan only accepts state
19// sources declared in the calling file, so every module that reads state declares its own.
20const agentsAtom = atom({ plugin: 'overclock', key: 'agents' } as const, emptyTree())
21const pipelineAtom = atom({ plugin: 'overclock', key: 'pipeline' } as const, null)
22const selfAtom = atom({ plugin: 'overclock', key: 'self' } as const, null)
23const othersAtom = atom({ plugin: 'overclock', key: 'others' } as const, [])
24const planTextAtom = atom({ plugin: 'overclock', key: 'planText' } as const, '')
25const autoOpenedAtom = atom({ plugin: 'overclock', key: 'autoOpened' } as const, false)
26const toastShownAtom = atom({ plugin: 'overclock', key: 'toastShown' } as const, false)
27const paneOpenAtom = atom({ plugin: 'overclock', key: 'paneOpen' } as const, false)
28const tabAtom = atom({ plugin: 'overclock', key: 'tab' } as const, 'agents')
29
30export const HEARTBEAT_MS = 15_000
31const PANE = { id: 'overclock', title: 'Overclock' }
32const TOAST = 'Overclock: /overclock per aprire il pannello'
33// AgentInfo statuses of a loop that has ended.
34const ENDED = new Set(['completed', 'failed', 'killed'])
35
36// Module variables hold only what may be lost on hot reload: options, caches, the timer.
37let phaseMapPath = ''
38let phaseMap: PhaseMap | undefined
39let timer: Timer | undefined
40// Id of the session a /clear just ended, until its pipeline is carried over.
41let clearedId: string | undefined
42// Id of the last session that ended (any reason): a later init under another id is a new
43// conversation, whose agents start from empty.
44let endedId: string | undefined
45// Skill tool calls in flight in a subagent's loop, by the name the call gave: skill.prompt
46// carries no agentId, but it fires while the Skill call that expands it is in flight.
47const subagentSkills = new Map<string, number>()
48
49/** Runs a subagent's Skill call, marking its skill as a subagent's while it is in flight. */
50export async function inSubagentSkill<R>(skill: string, call: () => Promise<R>): Promise<R> {
51  subagentSkills.set(skill, (subagentSkills.get(skill) ?? 0) + 1)
52  try {
53    return await call()
54  } finally {
55    const n = (subagentSkills.get(skill) ?? 1) - 1
56    if (n > 0) subagentSkills.set(skill, n)
57    else subagentSkills.delete(skill)
58  }
59}
60
61/** The id a /clear ended, if its carry-over is still pending; clears it. */
62export function takeClearedId(): string | undefined {
63  const id = clearedId
64  clearedId = undefined
65  return id
66}
67
68/** Called by register() on every (re)load with the plugin's options. */
69export function configure(options: PluginOptions): void {
70  const p = options.phaseMapPath
71  phaseMapPath = typeof p === 'string' ? p.trim() : ''
72  phaseMap = undefined
73}
74
75async function loadPhaseMap($: EngineInterface): Promise<PhaseMap> {
76  if (phaseMap) return phaseMap
77  if (phaseMapPath === '') return (phaseMap = DEFAULT_PHASE_MAP)
78  const path = phaseMapPath.startsWith('/') ? phaseMapPath : joinPath(await $.session.cwd(), phaseMapPath)
79  const text = await filePort($).read(path)
80  const parsed = text === undefined ? null : parsePhaseMap(text)
81  if (parsed === null) {
82    $.ui.log(`overclock: mappa fasi non valida in ${path}, uso quella predefinita`)
83    return (phaseMap = DEFAULT_PHASE_MAP)
84  }
85  return (phaseMap = { ...DEFAULT_PHASE_MAP, ...parsed })
86}
87
88function summary(p: PipelineState | null) {
89  const badges: BadgeName[] = []
90  for (const b of p?.current.badges ?? []) if (!badges.includes(b.name)) badges.push(b.name)
91  return { phase: p?.current.phase ?? 0, badges, planDone: p?.plan.done ?? 0, planTotal: p?.plan.total ?? 0 }
92}
93
94/** Recomputes self from the atoms (plus `change`), stamps the heartbeat and persists it. */
95async function publishSelf(
96  $: EngineInterface,
97  change: (s: SessionEntry) => SessionEntry = (s) => s,
98  opts: { activity?: boolean } = {},
99): Promise<void> {
100  const now = await $.clock.now()
101  const tree = await read($, agentsAtom)
102  const p = await read($, pipelineAtom)
103  const self = await update($, selfAtom, (cur) => {
104    if (!cur) return cur
105    const s = change(cur)
106    return {
107      ...s,
108      ...summary(p),
109      agents: tree.nodes,
110      recentWrites: pruneWrites(s.recentWrites, now),
111      lastActivity: opts.activity ? now : s.lastActivity,
112      heartbeat: now,
113    }
114  })
115  if (self) await storePort($).putSession(self)
116}
117
118/** An empty pipeline is not persisted, unless it replaces one already stored. */
119async function savePipeline($: EngineInterface, p: PipelineState): Promise<void> {
120  await update($, pipelineAtom, () => p)
121  const id = await $.session.id()
122  const sp = storePort($)
123  if (isEmptyPipeline(p) && (await sp.getPipeline(id)) === undefined) return
124  await sp.putPipeline(id, p)
125}
126
127const absolute = (root: string, path: string) => (path.startsWith('/') ? path : joinPath(root, path))
128
129/** Reads the plan file into planText and its checkbox counts into the PlanRef. */
130async function loadPlan($: EngineInterface, fp: FilePort, root: string, p: PipelineState): Promise<PipelineState> {
131  const text = p.plan.planPath ? await fp.read(absolute(root, p.plan.planPath)) : undefined
132  await update($, planTextAtom, () => text ?? '')
133  if (text === undefined) return p
134  const { done, total } = countCheckboxes(text)
135  return { ...p, plan: { ...p.plan, done, total } }
136}
137
138/** Latest spec and plan under `root`, unless pinned; then the plan's progress. */
139async function detectDocs($: EngineInterface, fp: FilePort, root: string, p: PipelineState): Promise<PipelineState> {
140  const plan = { ...p.plan }
141  if (!plan.pinned.spec) {
142    const name = pickLatest(await fp.list(joinPath(root, SPEC_DIR)))
143    if (name) plan.specPath = `${SPEC_DIR}/${name}`
144  }
145  if (!plan.pinned.plan) {
146    const name = pickLatest(await fp.list(joinPath(root, PLAN_DIR)))
147    if (name) plan.planPath = `${PLAN_DIR}/${name}`
148  }
149  return loadPlan($, fp, root, { ...p, plan })
150}
151
152/** Reports a failure of Overclock's own work without letting it reach the event chain. */
153export function logError($: EngineInterface, err: unknown): void {
154  try {
155    $.ui.log(`overclock: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
156  } catch {
157    // nothing left to report to
158  }
159}
160
161/**
162 * Self for the current session id. Re-initialises when self is missing or belongs to another
163 * id (a /clear whose classic.SessionStart never came), carrying the pipeline over first.
164 */
165async function ensureInit($: EngineInterface): Promise<SessionEntry> {
166  const id = await $.session.id()
167  const self = await read($, selfAtom)
168  if (self && self.sessionId === id) return self
169  const oldId = takeClearedId()
170  if (oldId !== undefined && oldId !== id) await carryOverOnClear($, oldId)
171  else {
172    const prevId = self?.sessionId ?? endedId
173    if (prevId !== undefined && prevId !== id) await update($, agentsAtom, () => emptyTree())
174    await initSession($)
175  }
176  return (await read($, selfAtom))!
177}
178
179function startHeartbeat($: EngineInterface): void {
180  timer?.cancel()
181  timer = $.clock.every(HEARTBEAT_MS, () => {
182    void heartbeat($).catch((err: unknown) => logError($, err))
183  })
184}
185
186export function stopHeartbeat(): void {
187  timer?.cancel()
188  timer = undefined
189}
190
191export async function initSession($: EngineInterface): Promise<void> {
192  const id = await $.session.id()
193  const cwd = await $.session.cwd()
194  const now = await $.clock.now()
195  const fp = filePort($)
196  const git = await resolveGitInfo(fp, cwd)
197  const root = git?.worktreeRoot ?? cwd
198  await loadPhaseMap($)
199  const sp = storePort($)
200  await sp.prunePipelines(now, id)
201
202  let p = (await sp.getPipeline(id)) ?? emptyPipeline(now)
203  p = await detectDocs($, fp, root, p)
204  await savePipeline($, p)
205
206  const self: SessionEntry = {
207    v: 1, sessionId: id,
208    repoRoot: git?.repoRoot ?? null,
209    repoName: basename(git?.repoRoot ?? root),
210    worktreeRoot: root,
211    branch: git?.branch ?? null,
212    agents: [], recentWrites: [], ...summary(p),
213    lastActivity: now, heartbeat: now,
214  }
215  await update($, selfAtom, () => self)
216  await heartbeat($)
217  startHeartbeat($)
218}
219
220export async function dispatchAgent($: EngineInterface, ev: AgentEvent): Promise<void> {
221  await ensureInit($)
222  await update($, agentsAtom, (t) => reduceAgents(t, ev))
223  await publishSelf($, undefined, { activity: ev.kind !== 'agents.reconcile' })
224}
225
226/**
227 * tool.call: in memory only (the subagent's last tool, self's lastActivity); the heartbeat
228 * publishes it. Keeps the store off the tool's return path.
229 */
230export async function onToolCall($: EngineInterface, agentId: string | undefined, tool: string): Promise<void> {
231  await ensureInit($)
232  const now = await $.clock.now()
233  if (agentId) await update($, agentsAtom, (t) => reduceAgents(t, { kind: 'agent.tool', id: agentId, tool }))
234  await update($, selfAtom, (s) => (s ? { ...s, lastActivity: now } : s))
235}
236
237export async function onSkill($: EngineInterface, skill: string, pluginName?: string): Promise<void> {
238  await ensureInit($)
239  const now = await $.clock.now()
240  const map = await loadPhaseMap($)
241  const key = skillKey(skill, pluginName)
242  // A subagent's skill (requesting-code-review inside subagent-driven) is not the session's
243  // phase: only its badge counts.
244  const entry = Object.hasOwn(map, key) ? map[key] : undefined
245  if (entry && 'phase' in entry && (subagentSkills.has(key) || subagentSkills.has(skill))) return
246  const before = (await read($, pipelineAtom)) ?? emptyPipeline(now)
247  const after = applySkill(before, key, map, now)
248  if (after === before) return
249  await savePipeline($, after)
250  await publishSelf($, undefined, { activity: true })
251  if (after.current.phase !== before.current.phase) await maybeAutoOpen($)
252}
253
254// Tools that write a file, by the argument naming it. MultiEdit is not in every build's tool
255// table, so the name is matched as a string.
256const WRITE_ARG: Record<string, string> = { Write: 'file_path', Edit: 'file_path', MultiEdit: 'file_path', NotebookEdit: 'notebook_path' }
257
258/** The file a tool call writes, if it is a write tool's call with that path given. */
259export function writtenPath(call: { tool: string } & Record<string, unknown>): string | undefined {
260  const arg = Object.hasOwn(WRITE_ARG, call.tool) ? WRITE_ARG[call.tool] : undefined
261  const path = arg === undefined ? undefined : call[arg]
262  return typeof path === 'string' ? path : undefined
263}
264
265export async function onWrite($: EngineInterface, absPath: string): Promise<void> {
266  const self = await ensureInit($)
267  const now = await $.clock.now()
268  const root = self.worktreeRoot
269  const rel = relativeTo(root, absPath)
270  const path = rel ?? absPath
271  const kind = rel === null ? null : classifyDocPath(rel)
272  const p = await read($, pipelineAtom)
273  if (kind && p) {
274    const plan = { ...p.plan }
275    if (kind === 'spec' && !plan.pinned.spec) plan.specPath = path
276    if (kind === 'plan' && !plan.pinned.plan) plan.planPath = path
277    let next: PipelineState = { ...p, plan, updatedAt: now }
278    if (kind === 'plan' && plan.planPath === path) next = await loadPlan($, filePort($), root, next)
279    await savePipeline($, next)
280  }
281  await publishSelf(
282    $,
283    (s) => ({ ...s, recentWrites: [...s.recentWrites.filter((w) => w.path !== path), { path, at: now }] }),
284    { activity: true },
285  )
286}
287
288export async function refreshGit($: EngineInterface): Promise<void> {
289  const self = await ensureInit($)
290  const cwd = await $.session.cwd()
291  const fp = filePort($)
292  const git = await resolveGitInfo(fp, cwd)
293  const root = git?.worktreeRoot ?? cwd
294  const repoRoot = git?.repoRoot ?? null
295  const branch = git?.branch ?? null
296  if (root === self.worktreeRoot && repoRoot === self.repoRoot && branch === self.branch) return
297  if (root !== self.worktreeRoot) {
298    const p = await read($, pipelineAtom)
299    if (p) await savePipeline($, await detectDocs($, fp, root, p))
300  }
301  await publishSelf($, (s) => ({ ...s, repoRoot, repoName: basename(repoRoot ?? root), worktreeRoot: root, branch }))
302}
303
304export async function heartbeat($: EngineInterface): Promise<void> {
305  const self = await ensureInit($)
306  const now = await $.clock.now()
307  const live = await $.agent.list()
308  const alive = live.filter((a) => !ENDED.has(a.status))
309  const liveIds = alive.map((a) => a.id)
310  const teammateIds = alive.filter((a) => a.teammateId !== undefined).map((a) => a.id)
311  await update($, agentsAtom, (t) => reduceAgents(t, { kind: 'agents.reconcile', liveIds, teammateIds, at: now }))
312  const all = await storePort($).listSessions(now)
313  const others = all.filter((e) => e.sessionId !== self.sessionId && isLive(e, now))
314  await update($, othersAtom, () => others)
315  await publishSelf($)
316}
317
318export async function maybeAutoOpen($: EngineInterface): Promise<void> {
319  if (await read($, autoOpenedAtom)) return
320  await update($, autoOpenedAtom, () => true)
321  const r = await $.ui.open(PANE)
322  if (r.isPlaced) await update($, paneOpenAtom, () => true)
323  if (!r.isPlaced && !(await read($, toastShownAtom))) {
324    $.ui.toast(TOAST)
325    await update($, toastShownAtom, () => true)
326  }
327}
328
329/**
330 * session.end: drop this session's entry, stop the heartbeat that would rewrite it and forget
331 * self, so the next event re-initialises. After /clear, remember the id for the carry-over.
332 */
333export async function endSession($: EngineInterface, id: string, reason: string): Promise<void> {
334  stopHeartbeat()
335  endedId = id
336  if (reason === 'clear') clearedId = id
337  await update($, selfAtom, () => null)
338  await storePort($).deleteSession(id)
339}
340
341/** After /clear: move the pipeline to the new session id, reset agents, re-init. */
342export async function carryOverOnClear($: EngineInterface, oldId: string): Promise<void> {
343  const newId = await $.session.id()
344  const sp = storePort($)
345  if (newId !== oldId) {
346    const p = await sp.getPipeline(oldId)
347    if (p) await sp.putPipeline(newId, p)
348    await sp.deletePipeline(oldId)
349  }
350  await update($, agentsAtom, () => emptyTree())
351  await update($, selfAtom, () => null)
352  await initSession($)
353}
354
355/**
356 * classic.SessionStart other than startup or clear (a /resume in the same process): when
357 * session.end left self empty, start the new conversation from empty agents.
358 */
359export async function onSessionResumed($: EngineInterface): Promise<void> {
360  if (await read($, selfAtom)) return
361  await update($, agentsAtom, () => emptyTree())
362  await initSession($)
363}
364
365/** ui.close of our pane (by Esc, the close mark or /overclock): remembers it is closed. */
366export async function onPaneClosed($: EngineInterface, id: string): Promise<void> {
367  if (id === PANE.id) await update($, paneOpenAtom, () => false)
368}
369
370async function openPane($: EngineInterface): Promise<boolean> {
371  const r = await $.ui.open({ ...PANE, focus: true })
372  if (r.isPlaced) await update($, paneOpenAtom, () => true)
373  return r.isPlaced
374}
375
376const PIN_LABEL = { spec: 'Spec', plan: 'Piano' } as const
377
378async function pinDoc($: EngineInterface, target: 'spec' | 'plan', path: string): Promise<string> {
379  const self = await ensureInit($)
380  const root = self.worktreeRoot
381  const rel = path.startsWith('/') ? relativeTo(root, path) : path.replace(/^\.\//, '')
382  if (rel === null || rel === '' || rel.split('/').includes('..') || !(await filePort($).exists(joinPath(root, rel)))) {
383    return `File non trovato: ${path}`
384  }
385  const p = (await read($, pipelineAtom)) ?? emptyPipeline(await $.clock.now())
386  const plan = { ...p.plan, pinned: { ...p.plan.pinned, [target]: true } }
387  if (target === 'spec') plan.specPath = rel
388  else plan.planPath = rel
389  const next = await loadPlan($, filePort($), root, { ...p, plan, updatedAt: await $.clock.now() })
390  await savePipeline($, next)
391  await publishSelf($)
392  return `${PIN_LABEL[target]} fissato: ${rel}`
393}
394
395async function runCommand($: EngineInterface, args: string): Promise<string> {
396  const cmd = parseCommand(args)
397  switch (cmd.kind) {
398    case 'error':
399      return cmd.message
400    case 'toggle':
401      if (await read($, paneOpenAtom)) {
402        await $.ui.close(PANE)
403        await update($, paneOpenAtom, () => false)
404        return 'Pannello chiuso.'
405      }
406      await openPane($)
407      return 'Pannello aperto.'
408    case 'open':
409      await update($, tabAtom, () => cmd.tab as Tab)
410      await openPane($)
411      return `Pannello aperto su ${cmd.tab}.`
412    case 'pin':
413      return pinDoc($, cmd.target, cmd.path)
414    case 'unpin': {
415      const self = await ensureInit($)
416      const p = (await read($, pipelineAtom)) ?? emptyPipeline(await $.clock.now())
417      const cleared = { ...p, plan: { ...p.plan, pinned: { spec: false, plan: false } } }
418      await savePipeline($, await detectDocs($, filePort($), self.worktreeRoot, cleared))
419      await publishSelf($)
420      return 'Rilevamento automatico ripristinato.'
421    }
422    case 'phase': {
423      await ensureInit($)
424      const now = await $.clock.now()
425      const p = (await read($, pipelineAtom)) ?? emptyPipeline(now)
426      await savePipeline($, setPhase(p, cmd.phase, now))
427      await publishSelf($, undefined, { activity: true })
428      return `Fase impostata a ${cmd.phase} (${cmd.phase === 0 ? 'nessuna fase' : PHASE_NAMES[cmd.phase - 1]}).`
429    }
430    case 'reset': {
431      await ensureInit($)
432      const now = await $.clock.now()
433      const p = (await read($, pipelineAtom)) ?? emptyPipeline(now)
434      await savePipeline($, resetPipeline(p, now))
435      await publishSelf($, undefined, { activity: true })
436      return 'Pipeline azzerata.'
437    }
438  }
439}
440
441/** /overclock: never throws, so a failure cannot break the command chain. */
442export async function handleCommand($: EngineInterface, args: string): Promise<{ text: string }> {
443  try {
444    return { text: await runCommand($, args) }
445  } catch (err) {
446    logError($, err)
447    return { text: 'Overclock: errore nel comando' }
448  }
449}
450
451// The render gets `next` only: the host does not let `$` be passed to a function parameter.
452type Render<E, R> = (next: (e: E) => Promise<R>) => Promise<R>
453
454/**
455 * Runs a render of ours that calls `next` at most once. A failure of ours falls back to what
456 * the engine drew (`next` is not called twice); a failure of `next` itself is not ours to hide.
457 */
458export async function guardRender<E, R>($: EngineInterface, e: E, next: (e: E) => Promise<R>, render: Render<E, R>): Promise<R> {
459  let below: { value: R } | undefined
460  let nextFailed = false
461  const once = async (x: E): Promise<R> => {
462    try {
463      const value = await next(x)
464      below = { value }
465      return value
466    } catch (err) {
467      nextFailed = true
468      throw err
469    }
470  }
471  try {
472    return await render(once)
473  } catch (err) {
474    if (nextFailed) throw err
475    logError($, err)
476    return below ? below.value : next(e)
477  }
478}
479
src/ui/band.tsx 42 lines
1// Band above the prompt and spinner suffix. Both read state through atoms declared here:
2// the host only accepts state sources declared in the calling file.
3import { atom, read } from 'claude-code'
4import type { EngineInterface } from 'claude-code'
5import { emptyTree } from '../core/agents'
6import { PHASE_NAMES } from '../core/phases'
7import { ORANGE as WARNING_COLOR, bandView, caseDetail, spinnerSuffix } from './view-model'
8
9const agentsAtom = atom({ plugin: 'overclock', key: 'agents' } as const, emptyTree())
10const pipelineAtom = atom({ plugin: 'overclock', key: 'pipeline' } as const, null)
11const selfAtom = atom({ plugin: 'overclock', key: 'self' } as const, null)
12const othersAtom = atom({ plugin: 'overclock', key: 'others' } as const, [])
13
14export async function renderBand($: EngineInterface, e: any, next: (e: any) => any): Promise<any> {
15  if (e.props.hasSurvey) return next(e)
16  const [self, others, pipeline] = await Promise.all([read($, selfAtom), read($, othersAtom), read($, pipelineAtom)])
17  const below = await next(e)
18  if (!self) return below
19  const v = bandView({ self, others, pipeline }, await $.clock.now())
20  const detail = caseDetail(self, others)
21  const { Box, Text } = $.ui.resolve(e)
22  const segments = [
23    <Box key="case"><Text color={v.case.color} bold>{v.case.label}</Text></Box>,
24    detail !== '' && <Box key="detail"><Text dimColor>{detail}</Text></Box>,
25    v.specName !== undefined && <Box key="spec"><Text dimColor>{`Spec: ${v.specName}`}</Text></Box>,
26    v.planName !== undefined && <Box key="plan"><Text>{`Piano ${v.planDone}/${v.planTotal} (${v.planPercent}%)`}</Text></Box>,
27    v.phase > 0 && <Box key="phase"><Text>{`Fase ${v.phase}/${PHASE_NAMES.length}`}</Text></Box>,
28    v.warning !== undefined && <Box key="warning"><Text color={WARNING_COLOR}>{v.warning}</Text></Box>,
29  ].filter((x) => x !== false)
30  return (
31    <Box flexDirection="column">
32      {below}
33      <Box>{segments.flatMap((seg, i) => (i === 0 ? [seg] : [<Text key={`sep${i}`} dimColor>{' · '}</Text>, seg]))}</Box>
34    </Box>
35  )
36}
37
38export async function renderSpinner($: EngineInterface, e: any, next: (e: any) => any): Promise<any> {
39  const suffix = spinnerSuffix(await read($, agentsAtom), e.props.suffix)
40  return next(suffix === undefined ? e : { ...e, props: { ...e.props, suffix } })
41}
42
src/ui/pane.tsx 140 lines
1// The Overclock pane (errors propagate: the hook in register.tsx logs them and draws the fallback): four tabs over the agents, the pipeline, the plan and the sessions.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface } from 'claude-code'
4import { logError } from '../app/runtime'
5import { emptyTree } from '../core/agents'
6import type { Tab } from '../core/commands'
7import { PHASE_NAMES } from '../core/phases'
8import type { SessionEntry } from '../core/types'
9import { GraphText } from './graph-text'
10import { agentGraphSvg } from './graph-svg'
11import { SessionsCards } from './sessions-cards'
12import { SessionsText } from './sessions-text'
13import { agentsView, caseStyle, pipelineView, sessionsView } from './view-model'
14
15// Atoms are declared here: the host refuses state sources declared in another file.
16const tabAtom = atom({ plugin: 'overclock', key: 'tab' } as const, 'agents')
17const agentsAtom = atom({ plugin: 'overclock', key: 'agents' } as const, emptyTree())
18const pipelineAtom = atom({ plugin: 'overclock', key: 'pipeline' } as const, null)
19const selfAtom = atom({ plugin: 'overclock', key: 'self' } as const, null)
20const othersAtom = atom({ plugin: 'overclock', key: 'others' } as const, [])
21const planTextAtom = atom({ plugin: 'overclock', key: 'planText' } as const, '')
22
23const TABS: { tab: Tab; key: string; label: string; hotkey: string }[] = [
24  { tab: 'agents', key: 'tab-agents', label: 'Agenti', hotkey: '1' },
25  { tab: 'pipeline', key: 'tab-pipeline', label: 'Pipeline', hotkey: '2' },
26  { tab: 'plan', key: 'tab-plan', label: 'Piano', hotkey: '3' },
27  { tab: 'sessions', key: 'tab-sessions', label: 'Sessioni', hotkey: '4' },
28]
29const PLAN_DIR_HINT = 'Nessun piano trovato in docs/superpowers/plans/'
30const PIPE_SYMBOL = { done: '✓', current: '●', todo: '○' } as const
31
32const hhmm = (at: number): string => {
33  const d = new Date(at)
34  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
35}
36
37function sessionLine(s: SessionEntry, self: SessionEntry | null): string {
38  const style = caseStyle(s, self ? [self] : [])
39  const symbol = style.symbol !== '' ? style.symbol : '▣'
40  const phase = s.phase > 0 ? `${PHASE_NAMES[s.phase - 1]} ${s.phase}/${PHASE_NAMES.length}` : `— ${s.phase}/${PHASE_NAMES.length}`
41  const running = s.agents.filter((a) => a.status === 'running').length
42  return `${symbol} ${s.branch ?? s.repoName}  ${phase}  ${running} ${running === 1 ? 'agente' : 'agenti'}`
43}
44
45export async function renderPane($: EngineInterface, e: any): Promise<any> {
46  const [tab, tree, pipeline, self, others, planText, now] = await Promise.all([
47    read($, tabAtom), read($, agentsAtom), read($, pipelineAtom), read($, selfAtom), read($, othersAtom),
48    read($, planTextAtom), $.clock.now(),
49  ])
50  const ui = $.ui.resolve(e) as any
51  const { Box, Text, Button, Markdown, Svg } = ui
52  // resolve() hands out an Svg stub even on the terminal, so the surface decides too.
53  const hasSvg = e.surface !== 'terminal' && 'Svg' in ui
54
55  const header = (
56    <Box key="tabs">
57      {TABS.map((t) => (
58        <Button
59          key={t.key}
60          label={t.label}
61          hotkey={t.hotkey}
62          dimColor={tab !== t.tab}
63          onPress={() => update($, tabAtom, () => t.tab)}
64        />
65      ))}
66    </Box>
67  )
68
69  let body: any
70  if (tab === 'pipeline') {
71    const v = pipelineView(pipeline ?? emptyPipelineLike())
72    body = (
73      <Box flexDirection="column">
74        {v.phases.map((p) => (
75          <Box key={`phase-${p.n}`}>
76            <Text dimColor={p.state === 'todo'} bold={p.state === 'current'}>
77              {`${PIPE_SYMBOL[p.state]} ${p.name}${p.enteredAt !== undefined ? `  ${hhmm(p.enteredAt)}` : ''}${p.skill ? `  ${p.skill}` : ''}`}
78            </Text>
79          </Box>
80        ))}
81        {v.badges.length > 0 && <Text>{v.badges.map((b) => `[${b}]`).join(' ')}</Text>}
82        {v.previousCycles > 0 && (
83          <Text dimColor>{`${v.previousCycles} ${v.previousCycles === 1 ? 'ciclo precedente' : 'cicli precedenti'}`}</Text>
84        )}
85      </Box>
86    )
87  } else if (tab === 'plan') {
88    const planPath = pipeline?.plan.planPath
89    body =
90      planPath === undefined || planText === '' ? (
91        <Text dimColor>{PLAN_DIR_HINT}</Text>
92      ) : (
93        <Box flexDirection="column">
94          <Box key="open-plan-row">
95            <Button
96              key="open-plan"
97              label="Apri"
98              onPress={() => void $.prompt.fill({ text: `@${planPath} `, mode: 'insert' }).catch((err: unknown) => logError($, err))}
99            />
100          </Box>
101          <Markdown text={planText} />
102        </Box>
103      )
104  } else if (tab === 'sessions') {
105    if (self === null) {
106      body = <Text dimColor>Nessuna sessione attiva</Text>
107    } else {
108      // The own agents come from the live tree, not from the last published entry.
109      const view = sessionsView({ ...(self as SessionEntry), agents: tree.nodes }, others as SessionEntry[], now, tree.mainTokens)
110      body = hasSvg ? SessionsCards({ view, now, Box, Text }) : SessionsText({ view, now, Box, Text })
111    }
112  } else {
113    const nodes = agentsView(tree, now)
114    const lines = (others as SessionEntry[]).map((s) => sessionLine(s, self))
115    body = (
116      <Box flexDirection="column">
117        {hasSvg ? <Svg source={agentGraphSvg(nodes, 560)} alt="Grafo degli agenti" /> : GraphText({ nodes, Box, Text })}
118        {lines.length > 0 ? (
119          <Box key="others" flexDirection="column">
120            {lines.map((l, i) => <Box key={`o${i}`}><Text>{l}</Text></Box>)}
121          </Box>
122        ) : null}
123      </Box>
124    )
125  }
126  return (
127    <Box flexDirection="column">
128      {header}
129      {body}
130    </Box>
131  )
132}
133
134function emptyPipelineLike() {
135  return {
136    current: { phase: 0 as const, history: [], badges: [] }, cycles: [],
137    plan: { pinned: { spec: false, plan: false }, done: 0, total: 0 }, updatedAt: 0,
138  }
139}
140
src/core/agents.ts 94 lines
1import type { AgentEvent, AgentNode, AgentStatus, AgentTree, Tokens, TurnReason, Usage } from './types'
2
3export const MAX_DONE_AGENTS = 50
4
5export function emptyTree(): AgentTree {
6  return { nodes: [], mainTokens: { input: 0, output: 0 } }
7}
8
9export function activeCount(tree: AgentTree): number {
10  return tree.nodes.filter((n) => n.status === 'running').length
11}
12
13const STATUS_BY_REASON: Record<TurnReason, AgentStatus> = {
14  answer: 'done',
15  error: 'failed',
16  refusal: 'failed',
17  aborted: 'aborted',
18}
19
20// input_tokens is only the uncached part: the cache reads and writes count as input too.
21function addUsage(t: Tokens, u?: Usage): Tokens {
22  if (!u) return t
23  const input = u.input_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
24  return { input: t.input + input, output: t.output + u.output_tokens }
25}
26
27// Keep every running node plus the MAX_DONE_AGENTS most recently ended ones,
28// preserving the original order.
29function prune(nodes: AgentNode[]): AgentNode[] {
30  const finished = nodes.filter((n) => n.status !== 'running')
31  if (finished.length <= MAX_DONE_AGENTS) return nodes
32  const keep = new Set(
33    finished
34      .map((n, i) => ({ n, i }))
35      .sort((a, b) => (b.n.endedAt ?? 0) - (a.n.endedAt ?? 0) || b.i - a.i)
36      .slice(0, MAX_DONE_AGENTS)
37      .map((x) => x.n),
38  )
39  return nodes.filter((n) => n.status === 'running' || keep.has(n))
40}
41
42function patch(tree: AgentTree, id: string, fn: (n: AgentNode) => AgentNode): AgentTree {
43  if (!tree.nodes.some((n) => n.id === id)) return tree
44  return { ...tree, nodes: tree.nodes.map((n) => (n.id === id ? fn(n) : n)) }
45}
46
47export function reduceAgents(tree: AgentTree, ev: AgentEvent): AgentTree {
48  switch (ev.kind) {
49    case 'agent.started': {
50      const node: AgentNode = {
51        id: ev.id,
52        parentId: ev.parentId ?? 'main',
53        toolUseId: ev.toolUseId,
54        type: ev.type,
55        description: ev.description,
56        background: ev.background,
57        status: 'running',
58        startedAt: ev.at,
59        tokens: { input: 0, output: 0 },
60      }
61      const exists = tree.nodes.some((n) => n.id === ev.id)
62      return { ...tree, nodes: exists ? tree.nodes.map((n) => (n.id === ev.id ? node : n)) : [...tree.nodes, node] }
63    }
64    case 'agent.resumed':
65      return patch(tree, ev.id, (n) => ({ ...n, status: 'running', endedAt: undefined }))
66    case 'agent.ended': {
67      const next = patch(tree, ev.id, (n) => ({
68        ...n,
69        status: STATUS_BY_REASON[ev.reason],
70        endedAt: ev.at,
71        tokens: addUsage(n.tokens, ev.usage),
72      }))
73      return next === tree ? tree : { ...next, nodes: prune(next.nodes) }
74    }
75    case 'agent.tool':
76      return patch(tree, ev.id, (n) => ({ ...n, lastTool: ev.tool }))
77    case 'main.turn':
78      return { ...tree, mainTokens: addUsage(tree.mainTokens, ev.usage) }
79    case 'agents.reconcile': {
80      // A running node the list no longer has is done; an ended teammate the roster still
81      // lists (idle between turns) is alive again.
82      const live = new Set(ev.liveIds)
83      const mates = new Set(ev.teammateIds ?? [])
84      const ended = (n: AgentNode) => n.status === 'running' && !live.has(n.id)
85      const revived = (n: AgentNode) => n.status !== 'running' && mates.has(n.id)
86      if (!tree.nodes.some((n) => ended(n) || revived(n))) return tree
87      const nodes = tree.nodes.map((n): AgentNode =>
88        ended(n) ? { ...n, status: 'done', endedAt: ev.at } : revived(n) ? { ...n, status: 'running', endedAt: undefined } : n,
89      )
90      return { ...tree, nodes: prune(nodes) }
91    }
92  }
93}
94
src/core/commands.ts 31 lines
1import type { Phase } from './types'
2
3export type Tab = 'agents' | 'pipeline' | 'plan' | 'sessions'
4export type OverclockCommand =
5  | { kind: 'toggle' } | { kind: 'open'; tab: Tab }
6  | { kind: 'pin'; target: 'spec' | 'plan'; path: string } | { kind: 'unpin' }
7  | { kind: 'phase'; phase: Phase } | { kind: 'reset' } | { kind: 'error'; message: string }
8
9const USAGE = 'Uso: /overclock [agents|pipeline|plan|sessions|pin spec|plan <path>|unpin|phase <0-6>|reset]'
10const TABS: Tab[] = ['agents', 'pipeline', 'plan', 'sessions']
11const fail = (): OverclockCommand => ({ kind: 'error', message: USAGE })
12
13// Parses the argument string of /overclock.
14export function parseCommand(args: string): OverclockCommand {
15  const s = args.trim()
16  if (s === '') return { kind: 'toggle' }
17  if ((TABS as string[]).includes(s)) return { kind: 'open', tab: s as Tab }
18  if (s === 'unpin') return { kind: 'unpin' }
19  if (s === 'reset') return { kind: 'reset' }
20  const m = /^(\S+)\s+(.+)$/.exec(s)
21  if (!m) return fail()
22  const head = m[1]!
23  const rest = m[2]!
24  if (head === 'pin') {
25    const p = /^(spec|plan)\s+(.+)$/.exec(rest)
26    return p ? { kind: 'pin', target: p[1]! as 'spec' | 'plan', path: p[2]!.trim() } : fail()
27  }
28  if (head === 'phase' && /^[0-6]$/.test(rest)) return { kind: 'phase', phase: Number(rest) as Phase }
29  return fail()
30}
31
src/core/phases.ts 50 lines
1import type { BadgeName, Phase, PhaseMap } from './types'
2
3// Index 0 is phase 1.
4export const PHASE_NAMES: readonly string[] = ['Brainstorming', 'Worktree', 'Piano', 'Esecuzione', 'Code review', 'Chiusura branch']
5
6export const DEFAULT_PHASE_MAP: PhaseMap = {
7  'superpowers:brainstorming': { phase: 1 },
8  'superpowers:using-git-worktrees': { phase: 2 },
9  'alex-dev-skills:git-worktrees': { phase: 2 },
10  'superpowers:writing-plans': { phase: 3 },
11  'superpowers:subagent-driven-development': { phase: 4 },
12  'superpowers:executing-plans': { phase: 4 },
13  'superpowers:requesting-code-review': { phase: 5 },
14  'superpowers:finishing-a-development-branch': { phase: 6 },
15  'superpowers:test-driven-development': { badge: 'TDD' },
16  'alex-dev-skills:tdd-prove-it': { badge: 'TDD' },
17  'superpowers:systematic-debugging': { badge: 'Debug' },
18  'alex-dev-skills:systematic-debugging': { badge: 'Debug' },
19  'superpowers:verification-before-completion': { badge: 'Verifica' },
20}
21
22export function skillKey(skill: string, pluginName?: string): string {
23  if (skill.includes(':')) return skill
24  return pluginName ? `${pluginName}:${skill}` : skill
25}
26
27const BADGES: readonly string[] = ['TDD', 'Debug', 'Verifica'] satisfies BadgeName[]
28
29// An own data property even for '__proto__', which plain assignment would turn into the prototype.
30function set(map: PhaseMap, key: string, v: PhaseMap[string]): void {
31  Object.defineProperty(map, key, { value: v, enumerable: true, writable: true, configurable: true })
32}
33
34export function parsePhaseMap(json: string): PhaseMap | null {
35  let raw: unknown
36  try { raw = JSON.parse(json) } catch { return null }
37  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
38  const out: PhaseMap = {}
39  for (const [key, v] of Object.entries(raw)) {
40    if (typeof v !== 'object' || v === null) return null
41    const o = v as { phase?: unknown; badge?: unknown }
42    if (typeof o.phase === 'number' && Number.isInteger(o.phase) && o.phase >= 1 && o.phase <= 6) {
43      set(out, key, { phase: o.phase as Phase })
44    } else if (typeof o.badge === 'string' && BADGES.includes(o.badge)) {
45      set(out, key, { badge: o.badge as BadgeName })
46    } else return null
47  }
48  return out
49}
50
src/core/pipeline.ts 60 lines
1import type { Phase, PhaseMap, PipelineCycle, PipelineState } from './types'
2
3export const MAX_CYCLES = 10
4export const MAX_HISTORY = 50
5
6const emptyCycle = (): PipelineCycle => ({ phase: 0, history: [], badges: [] })
7
8export function emptyPipeline(at: number): PipelineState {
9  return { current: emptyCycle(), cycles: [], plan: { pinned: { spec: false, plan: false }, done: 0, total: 0 }, updatedAt: at }
10}
11
12// Keeps the MAX_HISTORY most recent entries of a cycle's history.
13const capped = <T>(h: T[]): T[] => (h.length > MAX_HISTORY ? h.slice(-MAX_HISTORY) : h)
14
15/** Nothing worth persisting: no phase, history, badge, cycle, pin, spec or plan. */
16export function isEmptyPipeline(p: PipelineState): boolean {
17  const { current: c, plan } = p
18  return (
19    c.phase === 0 && c.history.length === 0 && c.badges.length === 0 && p.cycles.length === 0 &&
20    !plan.pinned.spec && !plan.pinned.plan && plan.specPath === undefined && plan.planPath === undefined
21  )
22}
23
24export function applySkill(p: PipelineState, key: string, map: PhaseMap, at: number): PipelineState {
25  // Own keys only: 'constructor' or 'toString' are not skills.
26  const entry = Object.hasOwn(map, key) ? map[key] : undefined
27  if (!entry) return p
28  const cur = p.current
29  if ('badge' in entry) {
30    if (cur.badges.some((b) => b.name === entry.badge)) return { ...p, updatedAt: at }
31    return { ...p, current: { ...cur, badges: [...cur.badges, { name: entry.badge, at }] }, updatedAt: at }
32  }
33  const phase = entry.phase
34  const hist = { phase, skill: key, at }
35  // A phase-1 skill after phase 2+ closes the cycle and starts a new one.
36  if (phase === 1 && cur.phase >= 2) {
37    const cycles = [...p.cycles, cur].slice(-MAX_CYCLES)
38    return { ...p, cycles, current: { phase: 1, history: [hist], badges: [] }, updatedAt: at }
39  }
40  if (phase > cur.phase) {
41    return { ...p, current: { phase, history: capped([...cur.history, hist]), badges: [] }, updatedAt: at }
42  }
43  // Never go back: record the skill only.
44  return { ...p, current: { ...cur, history: capped([...cur.history, hist]) }, updatedAt: at }
45}
46
47export function setPhase(p: PipelineState, phase: Phase, at: number): PipelineState {
48  const cur = p.current
49  const hist = { phase, skill: '/overclock phase', at }
50  return {
51    ...p,
52    current: { phase, history: capped([...cur.history, hist]), badges: phase === cur.phase ? cur.badges : [] },
53    updatedAt: at,
54  }
55}
56
57export function resetPipeline(p: PipelineState, at: number): PipelineState {
58  return { ...emptyPipeline(at), plan: p.plan }
59}
60
src/core/plan.ts 50 lines
1// Pure helpers for specs and plans: checkbox progress and file selection.
2
3export const SPEC_DIR = 'docs/superpowers/specs'
4export const PLAN_DIR = 'docs/superpowers/plans'
5
6const CHECKBOX = /^\s*[-*] \[( |x|X)\] /
7
8/** Count markdown task-list checkboxes: done (x/X) over total. */
9export function countCheckboxes(markdown: string): { done: number; total: number } {
10  let done = 0
11  let total = 0
12  for (const line of markdown.split('\n')) {
13    const m = CHECKBOX.exec(line)
14    if (!m) continue
15    total++
16    if (m[1] !== ' ') done++
17  }
18  return { done, total }
19}
20
21/** Name of the most recently modified `.md` file, ignoring directories. */
22export function pickLatest(
23  entries: { name: string; kind: string; mtimeMs: number }[],
24): string | undefined {
25  let best: { name: string; mtimeMs: number } | undefined
26  for (const e of entries) {
27    if (e.kind !== 'file' || !e.name.endsWith('.md')) continue
28    if (!best || e.mtimeMs > best.mtimeMs) best = e
29  }
30  return best?.name
31}
32
33/** Classify a repo-relative path (`/` separators); first level only. */
34export function classifyDocPath(relPath: string): 'spec' | 'plan' | null {
35  for (const [dir, kind] of [
36    [SPEC_DIR, 'spec'],
37    [PLAN_DIR, 'plan'],
38  ] as const) {
39    if (!relPath.startsWith(dir + '/')) continue
40    const rest = relPath.slice(dir.length + 1)
41    if (rest.length > 3 && !rest.includes('/') && rest.endsWith('.md')) return kind
42  }
43  return null
44}
45
46/** Rounded integer percentage; 0 when total is 0. */
47export function percent(done: number, total: number): number {
48  return total === 0 ? 0 : Math.round((done / total) * 100)
49}
50
src/core/sessions.ts 118 lines
1import type { SessionCase, SessionEntry } from './types'
2
3export const LIVE_MS = 60_000
4export const STALE_MS = 86_400_000
5export const SHARED_WRITE_MS = 300_000
6export const IDLE_MS = 600_000
7
8export function isLive(e: SessionEntry, now: number): boolean {
9  return now - e.heartbeat <= LIVE_MS
10}
11export function isStale(e: SessionEntry, now: number): boolean {
12  return now - e.heartbeat > STALE_MS
13}
14
15// A null repoRoot never equals anything, so out-of-git sessions are never "same repo".
16const sameRepo = (a: SessionEntry, b: SessionEntry) => a.repoRoot !== null && a.repoRoot === b.repoRoot
17const sameBranchOf = (a: SessionEntry, b: SessionEntry) => sameRepo(a, b) && a.branch === b.branch
18
19// `others` are already filtered to live sessions, without `self`.
20export function classify(
21  self: SessionEntry,
22  others: SessionEntry[],
23): { kind: SessionCase; sameBranch: number; worktrees: number; otherRepos: number } {
24  const sameBranch = others.filter((o) => sameBranchOf(self, o)).length
25  const worktrees = new Set([self, ...others.filter((o) => sameRepo(self, o))].map((e) => e.worktreeRoot)).size
26  const otherRepos = new Set(
27    others.filter((o) => !sameRepo(self, o)).map((o) => o.repoRoot ?? `wt:${o.worktreeRoot}`),
28  ).size
29  let kind: SessionCase = 'none'
30  if (others.length > 0) kind = sameBranch > 0 ? 'C' : worktrees > 1 ? 'A' : 'B'
31  return { kind, sameBranch, worktrees, otherRepos }
32}
33
34export function sharedWrites(
35  self: SessionEntry,
36  others: SessionEntry[],
37  now: number,
38): { path: string; sessionIds: string[]; at: number }[] {
39  const byPath = new Map<string, { ids: string[]; at: number }>()
40  for (const e of [self, ...others.filter((o) => sameBranchOf(self, o))]) {
41    const seen = new Set<string>()
42    for (const w of e.recentWrites) {
43      if (now - w.at > SHARED_WRITE_MS) continue
44      const cur = byPath.get(w.path) ?? { ids: [], at: 0 }
45      if (!seen.has(w.path)) {
46        cur.ids.push(e.sessionId)
47        seen.add(w.path)
48      }
49      cur.at = Math.max(cur.at, w.at)
50      byPath.set(w.path, cur)
51    }
52  }
53  const out: { path: string; sessionIds: string[]; at: number }[] = []
54  for (const [path, v] of byPath) if (v.ids.length >= 2) out.push({ path, sessionIds: v.ids, at: v.at })
55  return out
56}
57
58export function pruneWrites(writes: { path: string; at: number }[], now: number) {
59  return writes.filter((w) => now - w.at <= SHARED_WRITE_MS)
60}
61
62export type SessionGroup = {
63  repoName: string
64  repoRoot: string | null
65  kind: SessionCase
66  branches: { branch: string | null; sessions: (SessionEntry & { isSelf: boolean; isIdle: boolean })[] }[]
67}
68
69export function groupSessions(all: SessionEntry[], selfId: string, now: number): SessionGroup[] {
70  const kept = all.filter((e) => e.sessionId === selfId || isLive(e, now))
71  const self = kept.find((e) => e.sessionId === selfId)
72  const keyOf = (e: SessionEntry) => e.repoRoot ?? e.worktreeRoot
73  const groups = new Map<string, SessionEntry[]>()
74  for (const e of kept) {
75    const k = keyOf(e)
76    groups.set(k, [...(groups.get(k) ?? []), e])
77  }
78  const selfKey = self ? keyOf(self) : undefined
79  const selfBranch = self ? self.branch : undefined
80  const out: SessionGroup[] = []
81  for (const [key, members] of groups) {
82    const isSelfGroup = key === selfKey
83    let kind: SessionCase
84    if (isSelfGroup && self) {
85      kind = classify(self, kept.filter((e) => e !== self)).kind
86    } else {
87      const perBranch = new Map<string | null, number>()
88      for (const m of members) perBranch.set(m.branch, (perBranch.get(m.branch) ?? 0) + 1)
89      const shared = members[0]!.repoRoot !== null && [...perBranch.values()].some((n) => n >= 2)
90      const wts = new Set(members.map((m) => m.worktreeRoot)).size
91      kind = members[0]!.repoRoot === null ? 'B' : shared ? 'C' : wts >= 2 ? 'A' : 'B'
92    }
93    const names = [...new Set(members.map((m) => m.branch))].sort((a, b) => {
94      if (isSelfGroup && a !== b) {
95        if (a === selfBranch) return -1
96        if (b === selfBranch) return 1
97      }
98      return (a ?? '').localeCompare(b ?? '')
99    })
100    const branches = names.map((branch) => {
101      const ss = members
102        .filter((m) => m.branch === branch)
103        .map((m) => ({ ...m, isSelf: m.sessionId === selfId, isIdle: now - m.lastActivity > IDLE_MS }))
104      // Active first, idle last; Array.sort is stable.
105      ss.sort((a, b) => Number(a.isIdle) - Number(b.isIdle))
106      return { branch, sessions: ss }
107    })
108    out.push({ repoName: members[0]!.repoName, repoRoot: members[0]!.repoRoot, kind, branches })
109  }
110  out.sort((a, b) => {
111    const as = a.branches.some((br) => br.sessions.some((s) => s.isSelf))
112    const bs = b.branches.some((br) => br.sessions.some((s) => s.isSelf))
113    if (as !== bs) return as ? -1 : 1
114    return a.repoName.localeCompare(b.repoName)
115  })
116  return out
117}
118
src/core/paths.ts 40 lines
1// Pure POSIX-style path helpers: strings and '/' only, no Node.
2
3function trimTrailing(p: string): string {
4  let end = p.length
5  while (end > 1 && p[end - 1] === '/') end--
6  return p.slice(0, end)
7}
8
9export function joinPath(...parts: string[]): string {
10  let out = ''
11  for (const part of parts) {
12    if (part === '') continue
13    if (out === '') out = part
14    else out = trimTrailing(out).replace(/^\/$/, '') + '/' + part.replace(/^\/+/, '')
15  }
16  return out === '' ? '' : trimTrailing(out)
17}
18
19export function dirname(p: string): string {
20  const t = trimTrailing(p)
21  const i = t.lastIndexOf('/')
22  if (i < 0) return '.'
23  if (i === 0) return '/'
24  return t.slice(0, i)
25}
26
27export function basename(p: string): string {
28  const t = trimTrailing(p)
29  return t.slice(t.lastIndexOf('/') + 1)
30}
31
32/** Path of `p` relative to `root`, or null when `p` is outside `root`. */
33export function relativeTo(root: string, p: string): string | null {
34  const r = trimTrailing(root)
35  const t = trimTrailing(p)
36  if (t === r) return ''
37  const prefix = r === '/' ? '/' : r + '/'
38  return t.startsWith(prefix) ? t.slice(prefix.length) : null
39}
40
src/core/types.ts 37 lines
1// Shared types for the pure core. Flat list of exports.
2
3export type AgentStatus = 'running' | 'done' | 'failed' | 'aborted'
4export type Tokens = { input: number; output: number }
5export interface AgentNode {
6  id: string; parentId: string; toolUseId: string; type: string; description: string
7  background: boolean; status: AgentStatus; startedAt: number; endedAt?: number
8  tokens: Tokens; lastTool?: string
9}
10export interface AgentTree { nodes: AgentNode[]; mainTokens: Tokens }
11export type TurnReason = 'answer' | 'aborted' | 'refusal' | 'error'
12/** Token counts of a turn; the cache counts are optional and absent means 0. */
13export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
14export type AgentEvent =
15  | { kind: 'agent.started'; id: string; parentId?: string; toolUseId: string; type: string; description: string; background: boolean; at: number }
16  | { kind: 'agent.resumed'; id: string; at: number }
17  | { kind: 'agent.ended'; id: string; reason: TurnReason; usage?: Usage; at: number }
18  | { kind: 'agent.tool'; id: string; tool: string }
19  | { kind: 'main.turn'; usage?: Usage }
20  // teammateIds: the live ones that are teammates, whose loop outlives a turn.
21  | { kind: 'agents.reconcile'; liveIds: string[]; teammateIds?: string[]; at: number }
22
23export type Phase = 0 | 1 | 2 | 3 | 4 | 5 | 6
24export type BadgeName = 'TDD' | 'Debug' | 'Verifica'
25export interface PipelineCycle { phase: Phase; history: { phase: Phase; skill: string; at: number }[]; badges: { name: BadgeName; at: number }[] }
26export interface PlanRef { specPath?: string; planPath?: string; pinned: { spec: boolean; plan: boolean }; done: number; total: number }
27export interface PipelineState { current: PipelineCycle; cycles: PipelineCycle[]; plan: PlanRef; updatedAt: number }
28export type PhaseMap = Record<string, { phase: Phase } | { badge: BadgeName }>
29
30export interface SessionEntry {
31  v: 1; sessionId: string; repoRoot: string | null; repoName: string; worktreeRoot: string
32  branch: string | null; agents: AgentNode[]; recentWrites: { path: string; at: number }[]
33  phase: Phase; badges: BadgeName[]; planDone: number; planTotal: number
34  lastActivity: number; heartbeat: number
35}
36export type SessionCase = 'A' | 'B' | 'C' | 'none'
37