SLOPSHOPPER

Tamir's Superpowers

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

newpanebandspinnerrowsguard
★ 2v4.12.1MITupdated 2026-10-07Tamircohen28/tamirs-superpowers
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tamirs-superpowers
│ ┃ objective ✕ › fix the failing auth test and add an audit log call │ ┃ No objective is active. │ ┃ /plan-dev writes one under ⏺ Read(src/auth.ts) │ ┃ .dev-files/objectives/. ⎿ 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 │ DoD: 3 file writes this turn — before claiming done, confirm the rele │ │ › /objective │ ⎿ tamirs-superpowers: No objective is active (nothing under .dev-f │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · objective
No objective is active. /plan-dev writes one under .dev-files/objectives/.
README

<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>

tamirs-superpowers

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.

What problem it solves

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.

  • One canonical source, thin adapters. Skills, roles, rules, and policies live once under skills/, core/, and rules/; per-platform files are generated, and drift fails CI.
  • Honest capability degradation. core/capabilities/platforms.json records what each surface actually supports — unknown and unsupported included — and every skill states its fallback instead of pretending.
  • One objective = one pull request. Work is decomposed into tasks that end at commit + handoff, merged onto one integration branch, reviewed as one diff, delivered once.
  • Your machine is rendered from the repo. make setup writes the same global rules into all five platforms' own config formats; nothing is hand-copied per platform.

Supported platforms

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.

Claude

One plugin, one marketplace listing, both surfaces.

SurfaceRegistry idKindStatusInstall
Claude Codeclaude_codeCLI✅ supported — validated 2.1.287guide
Claude Desktopclaude_desktopdesktop✅ supported — same plugin, different runtime surfaceguide

Codex

Installed from the plugin marketplace; the CLI is the measured surface.

SurfaceRegistry idKindStatusInstall
Codex CLIcodexCLI✅ supported — validated 0.156.0guide
Codex IDE extensioncodex_ideIDE⚠️ unverified — reads the same AGENTS.md and manifest, but the plugin has never been installed or a skill invoked there—

Cursor

Added as a plugin source in Cursor, then installed from it.

SurfaceRegistry idKindStatusInstall
Cursor IDEcursorIDE✅ supported — validated 3.22.7guide
Cursor CLIcursor_cliCLI⚠️ unverified — shares the plugin manifest with the IDE, but no CLI run has been recorded here—

Gemini

Two commands: the extension carries context and MCP, skills install separately.

SurfaceRegistry idKindStatusInstall
Gemini CLIgemini_cliCLI✅ supported — validated 0.60.0guide
Gemini Code Assistgemini_code_assistIDE⚠️ unverified — a different host that does not install CLI extensions, so the .gemini/ mirror has no established install path there—

OpenCode

Installed by path — opencode.json's skills array pointed at this checkout (v2 shape; v1 used skills.paths).

SurfaceRegistry idKindStatusInstall
OpenCode CLIopencodeCLI✅ supported — validated 2.0.14 (v2; @opencode/cli). Config and all 10 agent adapters confirmed live; per-skill discovery unverified — v2 removed debug skillguideguide
OpenCode desktop appopencode_desktopdesktop⚠️ 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.

Prerequisites

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:

ToolNeeded by
shellcheckmake lint — shellchecks every tracked *.sh, at any depth
Node 22 (pinned in .nvmrc)builds the scaffold-plugin-gold contract fixture
Python 3 + pyyaml>=6.0pip 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 ..

Install in 5 minutes

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, then make validate. See contributor bootstrap.

Core workflow

/orchestrate-dev  →  task graph  →  workers (commit + handoff)  →  integration branch
                                          →  combined-diff review  →  ONE PR  →  /pr-dev
SkillDoes
/plan-devTurn a request into phases and issues
/orchestrate-devOwn an objective: task graph, dispatch, integrate, deliver one PR
/worker-devExecute one task; end at commit + handoff, never a PR
/deliver-devReview the integrated diff, run the gates, open the one PR
/pr-devDrive that PR to merge
/start-devCompatibility 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.

What the mod does

On Claude Code 2.1.287+ the plugin ships one mod (mod/register.tsx). It makes no network call.

  • Reads (local, never sent anywhere): objective, task and handoff JSON under .dev-files/objectives, your project's CLAUDE.md (only to find the commit-trailer line), and .git/HEAD.
  • Prompts it submits: none. The rate-limit band only displays "type /switch-dev handoff"; the mod makes no 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.
  • Optional semantic_skill_suggest (off by default): see PRIVACY.md.

Links


MIT © Tamir Cohen

Source 2 files
mod/register.tsx 511 lines
1// 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}
511
mod/types/index.d.ts 59 lines
1// 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