29 bundled skills, 10 specialist agents, smart worktree hooks, statusline, and MCP server stubs — plan, implement, review, debug, multi-agent setup, and audit…

<img src="assets/banner.png" alt="tamirs-superpowers" width="600" />
<a href="https://github.com/Tamircohen28"><img src="https://img.shields.io/badge/author-Tamir%20Cohen-181717?logo=github" alt="Author" /></a> <a href="https://github.com/Tamircohen28/tamirs-superpowers/actions/workflows/ci.yml"><img src="https://github.com/Tamircohen28/tamirs-superpowers/actions/workflows/ci.yml/badge.svg" alt="CI" /></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License" /></a> <a href="plugin-version.json"><img src="https://img.shields.io/badge/version-4.12.1-blue" alt="Version" /></a>
<a href="docs/engineering/build-and-release/platform-targets.json"><img src="https://img.shields.io/badge/Claude%20Code-2.1.287-blueviolet" alt="Claude Code" /></a> <a href="docs/engineering/build-and-release/platform-targets.json"><img src="https://img.shields.io/badge/Cursor-3.22.7-000000" alt="Cursor" /></a> <a href="docs/engineering/build-and-release/platform-targets.json"><img src="https://img.shields.io/badge/Codex-0.156.0-412991" alt="Codex" /></a> <a href="docs/engineering/build-and-release/platform-targets.json"><img src="https://img.shields.io/badge/Gemini%20CLI-0.60.0-4285F4" alt="Gemini CLI" /></a> <a href="docs/engineering/build-and-release/platform-targets.json"><img src="https://img.shields.io/badge/OpenCode-2.0.14-fab283" alt="OpenCode" /></a>
A portable agent toolkit: 29 skills, 10 role-based agents, worktree hooks, and MCP stubs, shipped from one canonical source to six agent surfaces across five platforms.
Agent harnesses disagree about everything — skill frontmatter, subagents, hooks, install mechanics, where global config lives — so multi-platform setups duplicate content until it drifts, or claim features a platform does not have. And one feature request typically lands as five disconnected pull requests with no place the combined diff is ever reviewed.
skills/, core/, and rules/; per-platform files are generated, and drift fails CI.core/capabilities/platforms.json records what each surface actually supports — unknown and unsupported included — and every skill states its fallback instead of pretending.make setup writes the same global rules into all five platforms' own config formats; nothing is hand-copied per platform.Five platforms. Each one has more than one surface — a terminal client, a desktop app, an editor extension — and they do not all behave alike, so the surface is what carries a support status, an install path, and a capability row. Six surfaces are supported: those are the ones this repo installs into and validates. The rest are listed because they are real surfaces users ask about; nothing has been measured on them, in either direction.
One plugin, one marketplace listing, both surfaces.
| Surface | Registry id | Kind | Status | Install |
|---|---|---|---|---|
| Claude Code | claude_code | CLI | ✅ supported — validated 2.1.287 | guide |
| Claude Desktop | claude_desktop | desktop | ✅ supported — same plugin, different runtime surface | guide |
Installed from the plugin marketplace; the CLI is the measured surface.
| Surface | Registry id | Kind | Status | Install |
|---|---|---|---|---|
| Codex CLI | codex | CLI | ✅ supported — validated 0.156.0 | guide |
| Codex IDE extension | codex_ide | IDE | ⚠️ unverified — reads the same AGENTS.md and manifest, but the plugin has never been installed or a skill invoked there | — |
Added as a plugin source in Cursor, then installed from it.
| Surface | Registry id | Kind | Status | Install |
|---|---|---|---|---|
| Cursor IDE | cursor | IDE | ✅ supported — validated 3.22.7 | guide |
| Cursor CLI | cursor_cli | CLI | ⚠️ unverified — shares the plugin manifest with the IDE, but no CLI run has been recorded here | — |
Two commands: the extension carries context and MCP, skills install separately.
| Surface | Registry id | Kind | Status | Install |
|---|---|---|---|---|
| Gemini CLI | gemini_cli | CLI | ✅ supported — validated 0.60.0 | guide |
| Gemini Code Assist | gemini_code_assist | IDE | ⚠️ unverified — a different host that does not install CLI extensions, so the .gemini/ mirror has no established install path there | — |
Installed by path — opencode.json's skills array pointed at this checkout (v2 shape; v1 used skills.paths).
| Surface | Registry id | Kind | Status | Install | |
|---|---|---|---|---|---|
| OpenCode CLI | opencode | CLI | ✅ supported — validated 2.0.14 (v2; @opencode/cli). Config and all 10 agent adapters confirmed live; per-skill discovery unverified — v2 removed debug skill | guide | guide |
| OpenCode desktop app | opencode_desktop | desktop | ⚠️ unverified — whether it reads the same skills.paths this repo installs into has not been checked | — |
⚠️ unverified is not a negative result. Those surfaces carry no capability claims at all; core/capabilities/platforms.json records why each one was never measured rather than guessing from its sibling.
Capabilities differ per surface, sometimes a lot. The honest, registry-generated comparison is docs/user/platform-differences.md.
To use the plugin. git 2.30+ and jq. gh is optional and only used by the PR and issue workflows. Nothing on the user path needs Node, Python, or a build step — the skills are shell and markdown. Each surface's install guide lists anything that surface adds on top; Gemini is the one that needs a second command, because its skills ship as a generated flat mirror installed with --path.
To contribute. make validate is the same gate CI runs, and it needs three things the user path does not:
| Tool | Needed by |
|---|---|
shellcheck | make lint — shellchecks every tracked *.sh, at any depth |
Node 22 (pinned in .nvmrc) | builds the scaffold-plugin-gold contract fixture |
Python 3 + pyyaml>=6.0 | pip install -r scripts/requirements-validate.txt — SKILL.md frontmatter and the portable skill contract |
make plugin-validate and make test-mods (the mod's validator and tests) additionally want the claude CLI (2.1.287+ for the mod); make validate does not run them. Confirm a machine is ready with bash scripts/doctor.sh ..
1. Install the plugin. Pick your platform and surface from the tables above — each supported surface's guide covers install, verify, update, and uninstall. On Claude you can also add it from Anthropic's plugin directory, which tracks a Claude-only distribution this repository builds on every release (how); PRIVACY.md says what the plugin sends, and only when you opt in. Gemini alone takes two commands: the extension carries context and MCP, while skills come from a generated flat mirror at .gemini/skills/ that must be installed with --path. Why.
2. Configure your machine — optional, and now the part that changed. make setup renders this repo's canonical config into the config directory of every agent CLI it detects: ~/.claude, ~/.codex, ~/.cursor, ~/.gemini, ~/.config/opencode. One set of global rules, one permissions policy, in each platform's own format.
git clone https://github.com/Tamircohen28/tamirs-superpowers.git
cd tamirs-superpowers
make setup-plan # detect targets, print every change, write nothing
make setup # diff → confirm → write, one change at a time
It merges into what is already there, shows a diff before each write, defaults to No, and bash scripts/setup.sh remove undoes it. This supersedes make install, which configured only Claude Code, only partially, and rewrote ~/.claude/settings.json wholesale — the old path is now a thin shim over the same engine. Read setup before the first apply: it will switch off plugins the canonical set records as deliberately disabled. The other four platforms: platform setup.
Check the result with bash scripts/doctor.sh .; the requirements are listed under Prerequisites above.
Contributing to this repo is a different setup —
git clone, thenmake validate. See contributor bootstrap.
/orchestrate-dev → task graph → workers (commit + handoff) → integration branch
→ combined-diff review → ONE PR → /pr-dev
| Skill | Does |
|---|---|
/plan-dev | Turn a request into phases and issues |
/orchestrate-dev | Own an objective: task graph, dispatch, integrate, deliver one PR |
/worker-dev | Execute one task; end at commit + handoff, never a PR |
/deliver-dev | Review the integrated diff, run the gates, open the one PR |
/pr-dev | Drive that PR to merge |
/start-dev | Compatibility front door — routes to the above |
Orchestration works with no subagents at all: same task graph, same handoffs, same one PR, run sequentially. See docs/user/orchestration.md.
On Claude Code 2.1.287+ the plugin ships one mod (mod/register.tsx). It makes no network call.
.dev-files/objectives, your project's CLAUDE.md (only to find the commit-trailer line), and .git/HEAD.prompt.submit call.command.run hook: answers /objective with the current objective summary. It changes nothing.agent.spawn hooks (2): count workers in flight for the spinner and the objective pane. The spawn always passes through unchanged.semantic_skill_suggest (off by default): see PRIVACY.md.MIT © Tamir Cohen
mod/register.tsx 511 lines1// tamirs-superpowers mod — Claude Code >= 2.1.287 function hooks.
2//
3// WHAT THIS IS, AND WHAT IT IS NOT
4// A mod runs IN-PROCESS on Claude Code and the Claude Desktop Code tab. It can
5// draw (a pane, the band above the prompt, the spinner), react to state the
6// host PUSHES (`session.measure`), and act before a turn dies. The bash hooks
7// in the hook manifest can do none of those three things.
8//
9// It is ADDITIVE. Every guard, every worktree hook and every reminder in
10// the shell hook scripts stay canonical, because those run on Codex and (via
11// platforms/cursor/hooks.json) Cursor too, and a mod never will. Nothing here
12// denies a tool call, creates a worktree, or replaces a bash hook. Where a
13// feature below overlaps one (rate-limit-handoff.sh, check-done.sh,
14// precompact-snapshot.sh, skill-suggest.sh, notify-pushover.sh), the bash hook
15// is the fallback that still fires when this module cannot load — an org with
16// `allowManagedModsOnly`, `--safe-mode`, a VS Code chat panel.
17//
18// WHAT IT DOES
19// 1. Objective pane + /objective — the orchestration state orchestrate-dev
20// and worker-dev keep in .dev-files/objectives/<id>/ (objective.json,
21// tasks/*.json, handoffs/*.json) drawn live; `/objective` answers at once,
22// with no model turn, even mid-turn (`immediate`). Spinner suffix counts
23// workers in flight; a task-notification row is drawn compact.
24// 2. Rate-limit band — `session.measure` pushes rate-limit windows after every
25// turn. At `rate_limit_warn_percent` the band above the prompt shows a
26// a reminder to type /switch-dev handoff; the mod submits no prompt itself.
27// rate-limit-handoff.sh fires AFTER the turn died; this fires BEFORE.
28// 3. Usage line on Desktop — the figures scripts/statusline.sh draws on the
29// CLI, where Desktop has no status line to draw into.
30// 4. (removed in 4.11.1) A Pushover post on long or failed turns lived here
31// as a network call. The Notification bash hook covers phone alerts, and a
32// mod that both reads the conversation and sends it out is held by the
33// directory for review, so the mod now makes no network call at all.
34// 5. Semantic skill suggestion — opt-in (`semantic_skill_suggest`): a small
35// model classifies a long prompt against the bundled skill names and the
36// match is attached as context to the prompt. skill-suggest.sh's keyword
37// matching keeps running regardless.
38// 6. Definition-of-done line under an answer that wrote files (turn.complete),
39// a working-state snapshot folded into compaction instructions
40// (session.compact), and the repo's Co-Authored-By trailer policy enforced
41// on the commit attribution text when the repo's CLAUDE.md declares one.
42//
43// HOW IT IS WRITTEN
44// Every call on `$` is written out in full inside the hook that makes it:
45// `$` is never handed to a helper. The directory's scanner reads a mod the
46// same way `claude plugin validate` does, and a capability it cannot see at
47// the call site is one it cannot vouch for. The helpers below are pure: they
48// take text or data and give back text or data. No subprocess runs and no
49// network call is made: the main working tree comes from `$.session.repo()`
50// and the branch from reading `.git/HEAD`, both plain `$.fs`/`$.session` calls.
51// What leaves the session is one thing, on one press: the handoff button
52// submits a fixed prompt naming the rate-limit window (see the band below).
53//
54// BUDGET
55// Every hook has 10 s of its own time per dispatch; `next` and `$` calls do
56// not count. Nothing here sleeps. The objective re-read is bounded by the
57// number of tasks (a handful of small JSON files).
58import { atom, read, update } from 'claude-code'
59import type { Register } from 'claude-code'
60
61import type {
62 ModsLimitWarning,
63 ModsObjective,
64 ModsObjectiveTask,
65 ModsUsage,
66} from './types'
67
68const PLUGIN = 'tamirs-superpowers'
69const PANE = 'objective'
70
71// $.state references: literal plugin/key pairs, declared in types/index.d.ts.
72const objective = atom({ plugin: 'tamirs-superpowers', key: 'objective' } as const, null)
73const usage = atom({ plugin: 'tamirs-superpowers', key: 'usage' } as const, null)
74const limitWarning = atom({ plugin: 'tamirs-superpowers', key: 'limitWarning' } as const, null)
75const dismissedAtPercent = atom({ plugin: 'tamirs-superpowers', key: 'dismissedAtPercent' } as const, 0)
76const workers = atom({ plugin: 'tamirs-superpowers', key: 'workers' } as const, 0)
77
78// The skills a prompt is classified against (feature 5). Names only: the
79// classifier reads them as labels, and `none` is the label for "no skill".
80// Internal companions (changelog-review, docs-review, mcp-pagination) are left
81// out — they are not for the person to invoke.
82const SKILL_LABELS = [
83 'plan-dev', 'start-dev', 'orchestrate-dev', 'worker-dev', 'deliver-dev', 'pr-dev', 'switch-dev', 'decision',
84 'targeted-debug', 'diagnose-refusal',
85 'repo-scaffold', 'repo-standards', 'multi-agent-repo', 'github-policy', 'cleanup',
86 'skill-creator', 'find-skill', 'retro', 'session-report', 'notify-setup', 'capture-config', 'usage-capture',
87 'mcp-builder', 'platform-sync', 'field-notebook-ui', 'dark-terminal-doc',
88 'none',
89] as const
90
91const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
92const RATE_WINDOWS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
93const MAX_TASKS = 50
94
95// ------------------------------------------------------------ pure helpers
96
97function asNumber(v: unknown, fallback: number): number {
98 const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN
99 return Number.isFinite(n) ? n : fallback
100}
101
102const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
103
104function wordCount(text: string): number {
105 return text.trim().split(/\s+/).filter(Boolean).length
106}
107
108function resetsIn(resetsAt: string | undefined, now: number): string {
109 if (!resetsAt) return ''
110 const ms = Date.parse(resetsAt) - now
111 if (!Number.isFinite(ms) || ms <= 0) return ''
112 const m = Math.round(ms / 60000)
113 return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
114}
115
116function usageLine(u: ModsUsage, now: number): string {
117 const parts: string[] = []
118 if (u.contextPercent !== undefined) parts.push(`ctx ${u.contextPercent}%`)
119 for (const w of u.rateLimits) {
120 const label = RATE_WINDOWS[w.kind] ?? w.kind
121 const reset = resetsIn(w.resetsAt, now)
122 parts.push(`${label} ${Math.round(w.percentUsed)}%${reset ? ` (resets ${reset})` : ''}`)
123 }
124 if (u.costUsd !== undefined) parts.push(`$${u.costUsd.toFixed(2)}`)
125 return parts.join(' · ')
126}
127
128// JSON text to an object, or null for anything that is not one.
129function parseObject(text: string | null): Record<string, unknown> | null {
130 if (text === null) return null
131 try {
132 const v: unknown = JSON.parse(text)
133 return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
134 } catch {
135 return null
136 }
137}
138
139// Which objective under the state directory is the active one, as hooks/lib/
140// objective-common.sh and skills/dev-workflow/_shared/scripts/objective-state.sh
141// decide it: SUPERPOWERS_OBJECTIVE_ID wins; else the first `active`; else the
142// first not completed/abandoned. `objectives` is id -> parsed objective.json.
143function pickObjective(ids: string[], objectives: Map<string, Record<string, unknown> | null>, preferredId: string | undefined): { id: string; obj: Record<string, unknown> } | null {
144 const ordered = preferredId && ids.includes(preferredId) ? [preferredId, ...ids.filter(i => i !== preferredId)] : ids
145 let fallback: { id: string; obj: Record<string, unknown> } | null = null
146 for (const id of ordered) {
147 const obj = objectives.get(id) ?? null
148 if (!obj) continue
149 const status = str(obj.status) ?? ''
150 if (id === preferredId || status === 'active') return { id, obj }
151 if (!fallback && status !== 'completed' && status !== 'abandoned') fallback = { id, obj }
152 }
153 return fallback
154}
155
156function taskIdsOf(obj: Record<string, unknown>): string[] {
157 const tasks = Array.isArray(obj.tasks) ? obj.tasks.filter((t): t is string => typeof t === 'string') : []
158 return tasks.slice(0, MAX_TASKS)
159}
160
161function taskRow(tid: string, task: Record<string, unknown> | null, handoff: Record<string, unknown> | null): ModsObjectiveTask {
162 return {
163 id: tid,
164 title: str(task?.title) ?? tid,
165 status: str(task?.status) ?? 'pending',
166 role: str(task?.role),
167 branch: str(task?.branch),
168 handoff: str(handoff?.status),
169 }
170}
171
172function objectiveText(o: ModsObjective | null): string {
173 if (!o) return 'No objective is active (nothing under .dev-files/objectives). /plan-dev writes one.'
174 const lines = [`Objective ${o.id} — ${o.title} [${o.status}]`]
175 for (const t of o.tasks) {
176 const tail = [t.role, t.branch, t.handoff ? `handoff: ${t.handoff}` : ''].filter(Boolean).join(' ')
177 lines.push(` ${t.id} ${t.status.padEnd(9)} ${t.title}${tail ? ` (${tail})` : ''}`)
178 }
179 if (o.tasks.length === 0) lines.push(' (no tasks yet)')
180 return lines.join('\n')
181}
182
183// The branch a .git/HEAD names, or the short commit when detached.
184function branchOf(head: string | null): string {
185 if (!head) return ''
186 const ref = head.match(/^ref:\s*refs\/heads\/(\S+)/m)?.[1]
187 if (ref) return ref
188 const sha = head.trim()
189 return /^[0-9a-f]{40}$/.test(sha) ? sha.slice(0, 12) : ''
190}
191
192// The gitdir a linked worktree's `.git` FILE points at, or null when the text
193// is not that (a main checkout has a `.git` directory, which reads as nothing).
194function linkedGitDir(dotGit: string | null): string | null {
195 const m = dotGit?.match(/^gitdir:\s*(.+)$/m)
196 return m?.[1]?.trim() || null
197}
198
199const STATUS_COLOR: Record<string, string> = {
200 running: 'yellow', completed: 'green', failed: 'red', blocked: 'red', cancelled: 'gray', ready: 'cyan', pending: 'gray',
201}
202
203export const register: Register = (on, options) => {
204 const warnAt = Math.min(100, Math.max(50, asNumber(options.rate_limit_warn_percent, 85)))
205 const semanticSuggest = options.semantic_skill_suggest === true
206
207 // Module variables: reset on a hot reload, which is fine for all of them.
208 let root = ''
209 let cwd = ''
210 let mainRoot = ''
211 let objectivesDir = ''
212 let trailerPolicy: string | null = null
213 let writesThisTurn = 0
214 let lastToastedKind = ''
215 const suggested = new Set<string>()
216
217 // ---------------------------------------------------------------- startup
218 on('session.start', async ($, e, next) => {
219 cwd = e.cwd
220 try {
221 root = await $.session.root()
222 } catch {
223 root = e.cwd
224 }
225
226 // Where objective state lives. `.dev-files/objectives` is gitignored and exists
227 // in the MAIN checkout only, while a worker session runs inside a linked
228 // worktree (`.agent-worktrees/<objective>/task-NNN`), so the session's own root
229 // is the wrong place to look from there. Resolution order mirrors the shell
230 // scripts: OBJECTIVES_ROOT (the override handoff.sh/objective-state.sh honour),
231 // else the main working tree ($.session.repo() answers it for a worktree too)
232 // plus SUPERPOWERS_OBJECTIVE_STATE_DIRNAME, else the session's root.
233 mainRoot = root || cwd
234 try {
235 const repo = await $.session.repo()
236 if (repo?.root) mainRoot = repo.root
237 } catch {
238 // Not a git checkout: the session's root stands.
239 }
240 const override = await $.env.get('OBJECTIVES_ROOT')
241 const dirname = (await $.env.get('SUPERPOWERS_OBJECTIVE_STATE_DIRNAME')) || '.dev-files/objectives'
242 objectivesDir = override ? override.replace(/\/$/, '') : `${mainRoot}/${dirname}`
243
244 // The repo's commit-trailer policy, when it declares one (CLAUDE.md
245 // "Commit trailer"). Enforced in attribution.text below; a repo without the
246 // line gets no trailer added by this mod.
247 trailerPolicy = null
248 try {
249 const claudeMd = (await $.fs.exists(`${root}/CLAUDE.md`)) ? await $.fs.read(`${root}/CLAUDE.md`) : ''
250 const m = claudeMd.match(/^\s*(Co-Authored-By:\s*Claude\s*<[^>\n]+>)\s*$/im)
251 if (m?.[1]) trailerPolicy = m[1].trim()
252 } catch {
253 trailerPolicy = null
254 }
255
256 await $.command.register({
257 name: 'objective',
258 description: 'Show the active orchestration objective and its tasks (tamirs-superpowers)',
259 immediate: true,
260 })
261
262 // Re-read the objective from disk into $.state. The pane, /objective and the
263 // compaction snapshot draw from the state, never from disk directly. Runs
264 // once now and every 5 s after (a cheap exists() while there is nothing).
265 const refresh = async (): Promise<void> => {
266 const preferred = await $.env.get('SUPERPOWERS_OBJECTIVE_ID')
267 const now = await $.clock.now()
268 let found: ModsObjective | null = null
269 if (await $.fs.exists(objectivesDir)) {
270 const ids = (await $.fs.list(objectivesDir)).filter(d => d.kind === 'dir').map(d => d.name).sort()
271 const objectives = new Map<string, Record<string, unknown> | null>()
272 for (const id of ids) {
273 const path = `${objectivesDir}/${id}/objective.json`
274 objectives.set(id, parseObject((await $.fs.exists(path)) ? await $.fs.read(path) : null))
275 }
276 const pick = pickObjective(ids, objectives, preferred)
277 if (pick) {
278 const tasks: ModsObjectiveTask[] = []
279 for (const tid of taskIdsOf(pick.obj)) {
280 const taskPath = `${objectivesDir}/${pick.id}/tasks/${tid}.json`
281 const handoffPath = `${objectivesDir}/${pick.id}/handoffs/${tid}.json`
282 const task = parseObject((await $.fs.exists(taskPath)) ? await $.fs.read(taskPath) : null)
283 const handoff = parseObject((await $.fs.exists(handoffPath)) ? await $.fs.read(handoffPath) : null)
284 tasks.push(taskRow(tid, task, handoff))
285 }
286 found = { id: pick.id, title: str(pick.obj.title) ?? pick.id, status: str(pick.obj.status) ?? 'unknown', tasks, readAt: now }
287 }
288 }
289 await update($, objective, () => found)
290 }
291 await refresh()
292 $.clock.every(5000, () => {
293 void refresh()
294 })
295 return next(e)
296 })
297
298 // ------------------------------------------------------- 1. objective pane
299 // Answers from $.state, which the 5 s re-read above keeps current.
300 on('command.run', { command: 'objective' }, async $ => {
301 const o = await read($, objective)
302 if (o) void $.ui.open({ id: PANE, title: `Objective ${o.id}` })
303 return { text: objectiveText(o) }
304 })
305
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const { Box, Text } = $.ui.resolve(e)
308 const o = await read($, objective)
309 const n = await read($, workers)
310 const width = Math.max(20, e.props.bodyColumns)
311 if (!o) {
312 return (
313 <Box flexDirection="column">
314 <Text dimColor>No objective is active.</Text>
315 <Text dimColor>/plan-dev writes one under .dev-files/objectives/.</Text>
316 </Box>
317 )
318 }
319 const done = o.tasks.filter(t => t.status === 'completed').length
320 return (
321 <Box flexDirection="column">
322 <Text bold wrap="truncate-end">{o.id} · {o.title}</Text>
323 <Text dimColor>
324 {o.status} · {done}/{o.tasks.length} tasks done{n > 0 ? ` · ${n} worker${n === 1 ? '' : 's'} running` : ''}
325 </Text>
326 {o.tasks.map(t => (
327 <Box key={t.id} flexDirection="row" gap={1}>
328 <Text dimColor>{t.id}</Text>
329 <Text color={STATUS_COLOR[t.status] ?? 'white'}>{t.status.padEnd(9)}</Text>
330 <Text wrap="truncate-end">{t.title.slice(0, Math.max(8, width - 30))}</Text>
331 {t.handoff ? <Text dimColor>handoff:{t.handoff}</Text> : null}
332 </Box>
333 ))}
334 {o.tasks.length === 0 ? <Text dimColor>(no tasks yet)</Text> : null}
335 </Box>
336 )
337 })
338
339 // Counts a worker in flight for the spinner suffix. Nothing is decided here:
340 // the spawn always goes through unchanged.
341 on('agent.spawn', async ($, e, next) => {
342 await update($, workers, n => (n ?? 0) + 1)
343 $.ui.invalidate('ui.render')
344 return next(e)
345 })
346
347 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
348 const n = await read($, workers)
349 if (n <= 0) return next(e)
350 return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix ?? ''} · ${n} worker${n === 1 ? '' : 's'}` } })
351 })
352
353 // A background task's notification row, compact: the id, how it ended and
354 // how long it took. ctrl+o (isExpanded) still shows the engine's full row.
355 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, async ($, e, next) => {
356 const task = e.props.task
357 if (e.props.isExpanded || !task || (!task.id && !task.status)) return next(e)
358 const { Text } = $.ui.resolve(e)
359 const secs = task.durationMs !== undefined ? ` · ${Math.round(task.durationMs / 1000)}s` : ''
360 const status = task.status ?? 'done'
361 const color = status === 'failed' || status === 'killed' ? 'red' : 'green'
362 return (
363 <Text dimColor>
364 ⚙ task {task.id ?? ''} <Text color={color}>{status}</Text>{secs}
365 </Text>
366 )
367 })
368
369 // ------------------------------------------- 2+3. rate-limit band, usage
370 on('session.measure', async ($, e, next) => {
371 const now = await $.clock.now()
372 const u: ModsUsage = {
373 contextPercent: e.context.percent,
374 rateLimits: e.rateLimits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
375 costUsd: e.cost?.usd,
376 }
377 await update($, usage, () => u)
378
379 const worst = [...u.rateLimits].sort((a, b) => b.percentUsed - a.percentUsed)[0]
380 const dismissedAt = await read($, dismissedAtPercent)
381 if (worst && worst.percentUsed >= warnAt && worst.percentUsed > dismissedAt) {
382 const w: ModsLimitWarning = { kind: worst.kind, percentUsed: worst.percentUsed, resetsAt: worst.resetsAt }
383 await update($, limitWarning, () => w)
384 const key = `${worst.kind}:${Math.floor(worst.percentUsed / 5)}`
385 if (key !== lastToastedKind) {
386 lastToastedKind = key
387 const reset = resetsIn(worst.resetsAt, now)
388 $.ui.toast(`${RATE_WINDOWS[worst.kind] ?? worst.kind} limit at ${Math.round(worst.percentUsed)}%${reset ? `, resets in ${reset}` : ''} — write a handoff now`, { timeoutMs: 8000 })
389 }
390 } else if (!worst || worst.percentUsed < warnAt - 10) {
391 await update($, limitWarning, () => null)
392 await update($, dismissedAtPercent, () => 0)
393 lastToastedKind = ''
394 }
395 return next(e)
396 })
397
398 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
399 if (e.props.hasSurvey) return next(e)
400 const warning = await read($, limitWarning)
401 const u = await read($, usage)
402 const showUsage = e.surface === 'desktop' && u !== null && (u.rateLimits.length > 0 || u.contextPercent !== undefined)
403 if (!warning && !showUsage) return next(e)
404 const { Box, Text, Button } = $.ui.resolve(e)
405 const now = await $.clock.now()
406 return (
407 <Box flexDirection="column">
408 {warning ? (
409 <Box flexDirection="row" gap={1}>
410 <Text color="red" bold>
411 {RATE_WINDOWS[warning.kind] ?? warning.kind} limit {Math.round(warning.percentUsed)}%
412 </Text>
413 <Text dimColor>{resetsIn(warning.resetsAt, now) ? `resets in ${resetsIn(warning.resetsAt, now)} ·` : ''} hand off before the window closes: type /switch-dev handoff</Text>
414 <Button
415 key="dismiss"
416 label="Dismiss"
417 role="dismiss"
418 onPress={async () => {
419 await update($, dismissedAtPercent, () => warning.percentUsed)
420 await update($, limitWarning, () => null)
421 }}
422 />
423 </Box>
424 ) : null}
425 {showUsage && u ? <Text dimColor>{usageLine(u, now)}</Text> : null}
426 </Box>
427 )
428 })
429
430 // ----------------------------------------- 5. semantic skill suggestion
431 on('prompt.submit', async ($, e, next) => {
432 if (!semanticSuggest || e.origin.kind !== 'composer') return next(e)
433 const text = e.text.trim()
434 if (text.startsWith('/') || wordCount(text) < 12) return next(e)
435 let label: string | undefined
436 try {
437 label = await $.model.classify(text.slice(0, 2000), SKILL_LABELS)
438 } catch {
439 return next(e)
440 }
441 if (!label || label === 'none' || !SKILL_LABELS.includes(label as (typeof SKILL_LABELS)[number]) || suggested.has(label)) return next(e)
442 suggested.add(label)
443 const note = `[tamirs-superpowers] The bundled skill \`${label}\` covers what this prompt asks for. Invoke it with the Skill tool (or /${label}) before doing the work by hand.`
444 return next({ ...e, context: [...(e.context ?? []), note] })
445 })
446
447 // ------------------------------------------ 6. DoD, compaction, trailer
448 on('turn.start', ($, e, next) => {
449 writesThisTurn = 0
450 return next(e)
451 })
452
453 on('tool.call', ($, e, next) => {
454 if (!e.agentId && WRITE_TOOLS.has(String(e.tool))) writesThisTurn += 1
455 return next(e)
456 })
457
458 // The module's one turn.complete hook (the engine refuses a second unmatched
459 // registration of an event): a worker's turn frees its spinner slot; a main
460 // turn gets a DoD line when it wrote files.
461 on('turn.complete', async ($, e, next) => {
462 const ran = await next(e)
463 if (e.agentId) {
464 await update($, workers, n => Math.max(0, (n ?? 0) - 1))
465 $.ui.invalidate('ui.render')
466 return ran
467 }
468 if (e.reason !== 'answer' || writesThisTurn === 0) return ran
469 const n = writesThisTurn
470 writesThisTurn = 0
471 return {
472 ...ran,
473 text: `DoD: ${n} file write${n === 1 ? '' : 's'} this turn — before claiming done, confirm the relevant lint/typecheck/tests ran and cite the output (hooks/check-done.sh has the tier wording).`,
474 }
475 })
476
477 on('session.compact', async ($, e, next) => {
478 if (e.agentId) return next(e)
479 const lines: string[] = []
480 // The branch, from .git/HEAD: a linked worktree's `.git` is a file naming
481 // its gitdir, a main checkout's is a directory (reading it throws).
482 try {
483 const dotGit = `${root || cwd}/.git`
484 let gitDir = `${mainRoot || root || cwd}/.git`
485 if (await $.fs.exists(dotGit)) {
486 try {
487 gitDir = linkedGitDir(await $.fs.read(dotGit)) ?? gitDir
488 } catch {
489 // a directory: the main checkout's own .git
490 }
491 }
492 const headPath = `${gitDir}/HEAD`
493 const branch = branchOf((await $.fs.exists(headPath)) ? await $.fs.read(headPath) : null)
494 if (branch) lines.push(`branch: ${branch}`)
495 } catch {
496 // Not a git checkout: the snapshot is just smaller.
497 }
498 const o = await read($, objective)
499 if (o) lines.push(objectiveText(o))
500 if (lines.length === 0) return next(e)
501 const snapshot = `Working state to preserve verbatim in the summary (tamirs-superpowers):\n${lines.join('\n')}`
502 return next({ ...e, instructions: [e.instructions, snapshot].filter(Boolean).join('\n\n') })
503 })
504
505 on('attribution.text', { kind: 'commit' }, async ($, e, next) => {
506 const ran = await next(e)
507 if (!trailerPolicy || /Co-Authored-By:\s*Claude/i.test(ran.text)) return ran
508 return { text: `${ran.text.trimEnd()}\n${trailerPolicy}`.trim() }
509 })
510}
511mod/types/index.d.ts 59 lines1// The mod's $.state contract (Claude Code >= 2.1.287 "mods"). Named by
2// .claude-plugin/plugin.json `types`; `claude plugin validate` holds every
3// $.state key mod/register.tsx names to what is declared here.
4//
5// Every value here is session state the host keeps across a hot reload of the
6// module. Nothing is persisted: the objective is re-read from disk, the usage
7// figures are re-pushed by `session.measure`, the warning re-derives.
8
9/** One task of the active objective, as the pane draws it. */
10export type ModsObjectiveTask = {
11 id: string
12 title: string
13 /** core/workflow/task-schema.json `status`. */
14 status: string
15 role?: string
16 branch?: string
17 /** core/workflow/handoff-schema.json `status`, when a handoff file exists. */
18 handoff?: string
19}
20
21/** The objective `.dev-files/objectives/<id>/objective.json` describes. */
22export type ModsObjective = {
23 id: string
24 title: string
25 /** core/workflow/objective-schema.json `status`. */
26 status: string
27 tasks: ModsObjectiveTask[]
28 /** When the pane last re-read it, `$.clock.now()` milliseconds. */
29 readAt: number
30}
31
32/** `$.session.usage()` figures as `session.measure` last pushed them. */
33export type ModsUsage = {
34 contextPercent?: number
35 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
36 costUsd?: number
37}
38
39/** The rate-limit window that crossed the warning threshold, while it has. */
40export type ModsLimitWarning = {
41 kind: string
42 percentUsed: number
43 resetsAt?: string
44}
45
46declare module 'claude-code' {
47 interface PluginState {
48 'tamirs-superpowers': {
49 objective: ModsObjective | null
50 usage: ModsUsage | null
51 limitWarning: ModsLimitWarning | null
52 /** The person pressed Dismiss on the warning band at this percentage. */
53 dismissedAtPercent: number
54 /** Subagents spawned and not yet completed, for the spinner suffix. */
55 workers: number
56 }
57 }
58}
59