SLOPSHOPPER

Tamir's Superpowers Guard

In-process guard for tamirs-superpowers: holds a destructive shell command (force push, rm -rf, git reset --hard, ...) until you answer Proceed or Cancel, and…

guardcommand
★ 2v5.0.4MITupdated 2026-10-24Tamircohen28/tamirs-superpowers/plugins/guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tamirs-superpowers-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by tamirs-superpowers-guard: tamirs-superpowers-guard: rm -rf outside a temp directory cancelled at ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ tamirs-superpowers-guard: tamirs-superpowers-guard — in-process holds on top of the bash guards (which stay canonical). ⎿ tamirs-superpowers-guard: ⎿ tamirs-superpowers-guard: hold_destructive_commands: on ⎿ tamirs-superpowers-guard: A Bash call that force-pushes, deletes a branch with -D, removes a worktree with --force, resets --hard, cle ⎿ tamirs-superpowers-guard: This session: 1 hold, 1 cancelled; last: rm -rf outside a temp directory → cancel (rm -rf build && git push ⎿ tamirs-superpowers-guard: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tamirs-superpowers-guard

The in-process guard for tamirs-superpowers: a plugin of Claude Code function hooks ("mods", Claude Code 2.1.287+) that holds a destructive shell command until you answer, and refuses a file write in the main checkout while an orchestration objective is active. Claude Code and the Claude Desktop Code tab only.

What it does

Option (/config, on by default)What happens
hold_destructive_commandsA Bash call whose command is a force push (git push --force, -f, --force-with-lease), git branch -D, git worktree remove --force, git reset --hard, git clean -f, rm -rf on anything that is not clearly a temp path, git checkout -- . or git restore . pauses in Claude Code's own question dialog, showing the command with Proceed and Cancel. Proceed runs it; Cancel refuses it with the guard's reason. Where nothing can draw the dialog (claude -p, the Agent SDK) the call goes through unheld and the debug log says so — a hold nobody can answer must not become a silent deny.
deny_main_checkout_writesWhile an objective is active (SUPERPOWERS_OBJECTIVE_ID, or an objective.json with status: active under .dev-files/objectives/) and the session sits in the main checkout rather than a worktree, an Edit/Write/MultiEdit/NotebookEdit of a file inside the repository is refused, naming the objective's worktrees (.agent-worktrees/<objective>/task-NNN or .../integration). Writes under .dev-files/ and inside any agent worktree pass. The same rule as the core plugin's hooks/enforce-worktree-edits.sh, which stays canonical.

/guard answers with both options' current values, what each does, and what the guard has held or refused this session.

Why it is a separate plugin

The core plugin ships a mod too (mod/register.tsx), and keeps it to a footprint Anthropic's plugin directory lists without review: it holds no tool call and denies nothing. A hook that can refuse a tool call reads to the directory as MOD_HOOKS_POLICY_EVENT, and one that answers for the person as MOD_ANSWERS_PERMISSION — both are holds on the listing. This plugin does exactly those two things, so it lives in its own folder (plugins/guard), is listed by the repository's own marketplace, and never enters the Claude-only distribution the directory reads. Install it only if you want the in-process holds; the bash guards in the core plugin run either way. Design and the directory's rules: docs/engineering/architecture/guard-plugin.md.

Install

At the prompt of a terminal session, with the core plugin already installed:

/plugin install tamirs-superpowers-guard --marketplace Tamircohen28/tamirs-superpowers

From a shell:

claude plugin install tamirs-superpowers-guard --marketplace Tamircohen28/tamirs-superpowers

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Tamircohen28/tamirs-superpowers, then Add plugin.

A plugin installed from a terminal at the user scope loads in the Desktop Code tab too. Both options can be set from the shell with claude plugin configure tamirs-superpowers-guard.

Develop and test

claude --plugin-dir plugins/guard                        # run it from this folder, hot-reloaded
claude plugin validate plugins/guard/.claude-plugin/plugin.json   # what the engine sees: hooks, calls, gating hooks
claude plugin test plugins/guard                         # hooks/guard.test.tsx against the engine
bash tests/test-guard-plugin.sh                          # shape, footprint, and both of the above when claude is on PATH

claude plugin validate lists both tool.call hooks as gating hooks with .catch. That is expected and is the point: each carries .catch(($, e, next) => next(e)), so a hook that throws or outruns its budget lets the call through rather than refusing it — the bash guards are the binding layer, this one is the convenience on top.

Files

PathWhat it is
.claude-plugin/plugin.jsonManifest: name, the two userConfig booleans, types
hooks/hooks.json{ "modules": ["./register.tsx"] } — the one module, no bash hook
hooks/register.tsxThe hooks module
hooks/guard.test.tsxIts tests (claude plugin test)
types/index.d.tsThe $.state contract

The version is the core plugin's, synced from plugin-version.json by scripts/check-version-truth.sh --sync; never edit it here.

Source 2 files
hooks/register.tsx 398 lines
1// tamirs-superpowers-guard — Claude Code >= 2.1.287 function hooks.
2//
3// WHAT THIS IS, AND WHY IT IS A PLUGIN OF ITS OWN
4//   The core plugin's mod (mod/register.tsx) keeps a footprint Anthropic's plugin
5//   directory lists without review: it holds no tool call and denies nothing. A
6//   hook that can refuse a tool call reads to the directory as
7//   MOD_HOOKS_POLICY_EVENT, and one that answers for the person as
8//   MOD_ANSWERS_PERMISSION — both are holds on the listing. The guard below does
9//   exactly those two things, so it ships as a SEPARATE plugin from the in-repo
10//   marketplace, where the directory's holds do not apply, and the core plugin's
11//   `calls:` line stays clean. Install it only if you want the in-process holds.
12//
13// WHAT IT DOES
14//   1. Destructive-command hold (`hold_destructive_commands`, on by default) —
15//      a Bash call whose command matches a conservative vocabulary (a force
16//      push, `git branch -D`, `git worktree remove --force`, `git reset --hard`,
17//      `git clean -f`, `rm -rf` outside a temp directory, `git checkout -- .`,
18//      `git restore .`) pauses in the engine's own question dialog, showing the
19//      command with Proceed and Cancel. Proceed runs it; Cancel refuses it with
20//      this guard's reason. A bash PreToolUse hook can deny, but it cannot hold:
21//      the pause is what only an in-process hook can do.
22//   2. Main-checkout write deny (`deny_main_checkout_writes`, on by default) —
23//      while an orchestration objective is active and the session sits in the
24//      MAIN checkout (not a worktree), an Edit/Write/MultiEdit/NotebookEdit of a
25//      file inside the repository is refused, naming the objective's worktrees.
26//      The same rule as hooks/enforce-worktree-edits.sh, which stays canonical
27//      (it runs on Codex and Cursor too); this is the in-process mirror.
28//   3. `/guard` — answers with both options' current values, what each does and
29//      what the guard has held or refused this session.
30//
31// WHAT IT NEVER DOES
32//   A hold nobody can answer must not become a silent deny. Where nothing draws
33//   the dialog (`claude -p`, the SDK, a session with no surface) the destructive
34//   command goes through unheld, and the debug log says so. Every gating hook
35//   carries `.catch(($, e, next) => next(e))`: a hook that throws or outruns its
36//   budget lets the call through rather than refusing it, because the bash
37//   guards are the binding layer and this one is the convenience on top.
38//
39// HOW IT IS WRITTEN
40//   Every call on `$` is written out in full inside the hook that makes it; `$`
41//   is handed to no helper. The helpers below are pure: a command in, a rule
42//   name out; a path in, a path out. No process runs, no network call is made,
43//   no prompt is submitted. The main working tree comes from `$.session.repo()`,
44//   the objective from reading `.dev-files/objectives/*/objective.json` with
45//   `$.fs`, and where a path lands from `$.fs.stat(path, { resolve: true })`.
46//
47// BUDGET
48//   Every hook has 10 s of its own time per dispatch; `next` and `$` calls do
49//   not count, so a person may take as long as they like over the dialog.
50//   Nothing here sleeps. The objective scan reads a handful of small JSON files.
51import { atom, read, update } from 'claude-code'
52import type { Register } from 'claude-code'
53
54import type { GuardDeniedWrite, GuardHold } from '../types'
55
56const PLUGIN = 'tamirs-superpowers-guard'
57const COMMAND_PREVIEW = 160
58const MAX_CLIMB = 64
59
60// $.state references: literal plugin/key pairs, declared in types/index.d.ts.
61const holds = atom({ plugin: 'tamirs-superpowers-guard', key: 'holds' } as const, 0)
62const cancelled = atom({ plugin: 'tamirs-superpowers-guard', key: 'cancelled' } as const, 0)
63const lastHold = atom({ plugin: 'tamirs-superpowers-guard', key: 'lastHold' } as const, null)
64const deniedWrites = atom({ plugin: 'tamirs-superpowers-guard', key: 'deniedWrites' } as const, 0)
65const lastDeniedWrite = atom({ plugin: 'tamirs-superpowers-guard', key: 'lastDeniedWrite' } as const, null)
66
67// ------------------------------------------------------------ pure helpers
68
69const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
70
71// JSON text to an object, or null for anything that is not one.
72function parseObject(text: string | null): Record<string, unknown> | null {
73  if (text === null) return null
74  try {
75    const v: unknown = JSON.parse(text)
76    return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
77  } catch {
78    return null
79  }
80}
81
82// One line of the command for a dialog or a reason, cut to COMMAND_PREVIEW.
83function preview(command: string): string {
84  const one = command.replace(/\s+/g, ' ').trim()
85  return one.length > COMMAND_PREVIEW ? `${one.slice(0, COMMAND_PREVIEW - 1)}…` : one
86}
87
88// A shell command split at its list and pipe operators: each piece is judged on
89// its own, so `cd x && git push --force` is held for the push. Quoting is not
90// modelled: a command text inside quotes (`bash -c '...'`, a `trap` string) is
91// read as one word and is not held — the same gap guard-sensitive-files.sh
92// records for interpreters, stated rather than papered over.
93function segments(command: string): string[] {
94  return command
95    .split(/\s*(?:&&|\|\||;|\||\n)\s*/)
96    .map(s => s.trim())
97    .filter(Boolean)
98}
99
100// The words of one segment: whitespace-split, surrounding quotes stripped, with
101// redirections, `&`, grouping braces and leading `sudo`/`command`/`exec`/`env`/
102// `nohup`/`time` and VAR=value assignments dropped, so `sudo rm -rf x` reads as
103// `rm -rf x`.
104function words(segment: string): string[] {
105  const out: string[] = []
106  for (const raw of segment.split(/\s+/)) {
107    let w = raw
108    if (w === '' || w === '&' || w === '(' || w === ')' || w === '{' || w === '}') continue
109    if (/^\d*[<>]/.test(w)) continue
110    w = w.replace(/^\(+/, '')
111    if ((w.startsWith('"') && w.endsWith('"') && w.length > 1) || (w.startsWith("'") && w.endsWith("'") && w.length > 1)) w = w.slice(1, -1)
112    out.push(w)
113  }
114  const WRAPPERS = new Set(['sudo', 'command', 'exec', 'env', 'nohup', 'time', 'builtin'])
115  while (out.length > 0 && (WRAPPERS.has(out[0]!) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(out[0]!))) out.shift()
116  return out
117}
118
119// `git [global options] <subcommand> <rest...>` → the subcommand and its words,
120// or null for anything that is not a git command.
121function gitCommand(w: string[]): { sub: string; rest: string[] } | null {
122  if (w[0] !== 'git') return null
123  let i = 1
124  while (i < w.length && w[i]!.startsWith('-')) {
125    const opt = w[i]!
126    i += 1
127    // Options that take their value as the next word.
128    if (opt === '-C' || opt === '-c' || opt === '--git-dir' || opt === '--work-tree' || opt === '--namespace') i += 1
129  }
130  const sub = w[i]
131  return sub ? { sub, rest: w.slice(i + 1) } : null
132}
133
134// A short-flag cluster (`-rf`, `-xdf`) carrying `letter`, or the long form.
135function hasFlag(rest: string[], letter: string, long: string | null): boolean {
136  return rest.some(t => (long !== null && (t === long || t.startsWith(`${long}=`))) || (/^-[A-Za-z]+$/.test(t) && t.includes(letter)))
137}
138
139// `rm -rf` is let through only where every operand is clearly a temp path: under
140// a system temp root (the same list hooks/enforce-worktree-edits.sh calls a
141// throwaway root), or a variable whose name says temp (`$TMPDIR`, `"$tmp"`,
142// `${TMPROOT}`). Anything else — a bare name, a repo path, `$HOME`, a glob —
143// is not clearly temp and the command is held.
144function isClearlyTemp(operand: string): boolean {
145  if (/^(\/private)?\/tmp\/.+/.test(operand)) return true
146  if (/^(\/private)?\/var\/folders\/.+/.test(operand)) return true
147  if (/^\$\{?[A-Za-z_]*(TMP|TEMP|tmp|temp)[A-Za-z0-9_]*\}?(\/.*)?$/.test(operand)) return true
148  return false
149}
150
151// The vocabulary. Each entry names the fact it read; the names are what the
152// dialog, the deny reason and `/guard` show. Conservative on purpose: a command
153// not listed here is never held, and a listed one is held only in the spelling
154// that destroys (`git clean -n` is a dry run, `git restore --staged .` only
155// unstages, `rm -rf /tmp/x` is a temp directory).
156function destructiveRule(command: string): string | null {
157  for (const segment of segments(command)) {
158    const w = words(segment)
159    if (w.length === 0) continue
160    if (w[0] === 'rm') {
161      const rest = w.slice(1)
162      const recursive = hasFlag(rest, 'r', '--recursive') || hasFlag(rest, 'R', null)
163      const force = hasFlag(rest, 'f', '--force')
164      const operands = rest.filter(t => !t.startsWith('-') || t === '-')
165      if (recursive && force && operands.length > 0 && !operands.every(isClearlyTemp)) return 'rm -rf outside a temp directory'
166      continue
167    }
168    const git = gitCommand(w)
169    if (!git) continue
170    const { sub, rest } = git
171    if (sub === 'push' && (hasFlag(rest, 'f', '--force') || rest.some(t => t.startsWith('--force-with-lease') || t === '--force-if-includes'))) return 'force push'
172    if (sub === 'branch') {
173      const hardDelete = hasFlag(rest, 'D', null)
174      const softDelete = hasFlag(rest, 'd', '--delete')
175      if (hardDelete || (softDelete && hasFlag(rest, 'f', '--force'))) return 'git branch -D'
176    }
177    if (sub === 'worktree' && rest[0] === 'remove' && hasFlag(rest.slice(1), 'f', '--force')) return 'git worktree remove --force'
178    if (sub === 'reset' && rest.includes('--hard')) return 'git reset --hard'
179    if (sub === 'clean' && hasFlag(rest, 'f', '--force') && !hasFlag(rest, 'n', '--dry-run')) return 'git clean -f'
180    if (sub === 'checkout') {
181      const operands = rest.filter(t => !t.startsWith('-'))
182      if (operands.includes('.')) return 'git checkout -- .'
183    }
184    if (sub === 'restore') {
185      const operands = rest.filter(t => !t.startsWith('-'))
186      const stagedOnly = (hasFlag(rest, 'S', '--staged')) && !(hasFlag(rest, 'W', '--worktree'))
187      if (operands.includes('.') && !stagedOnly) return 'git restore .'
188    }
189  }
190  return null
191}
192
193// Path helpers: separator '/', which is what $.session.root() and $.fs.stat's
194// realPath answer on the surfaces this plugin runs on.
195const trimSlash = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, '') : p)
196const parentOf = (p: string): string => {
197  const cut = p.lastIndexOf('/')
198  if (cut < 0) return ''
199  return cut === 0 ? '/' : p.slice(0, cut)
200}
201const baseOf = (p: string): string => p.slice(p.lastIndexOf('/') + 1)
202const joinPath = (dir: string, name: string): string => (dir === '/' ? `/${name}` : `${trimSlash(dir)}/${name}`)
203const isUnder = (path: string, root: string): boolean => path === root || path.startsWith(`${trimSlash(root)}/`)
204
205// An agent worktree inside the repository is a legitimate place to write even
206// though its path sits under the repo root: the objective shapes
207// (`.agent-worktrees/<objective>/...`) and every legacy shape
208// hooks/lib/objective-common.sh recognises.
209function isAgentWorkspacePath(path: string, repoRoot: string, agentDirname: string, agentRoot: string | undefined): boolean {
210  if (isUnder(path, joinPath(repoRoot, agentDirname))) return true
211  if (agentRoot && isUnder(path, trimSlash(agentRoot))) return true
212  return /(^|\/)\.(claude|cursor|codex)\/\.?worktrees\//.test(path)
213}
214
215function reasonFor(repoRoot: string, objectiveId: string, agentDirname: string, agentRoot: string | undefined): string {
216  const base = agentRoot ? trimSlash(agentRoot) : joinPath(repoRoot, agentDirname)
217  return `${PLUGIN}: Repo edits must happen in an objective worktree, not the main checkout (${repoRoot}). Objective '${objectiveId}' is active: work in ${base}/${objectiveId}/task-NNN (worker) or .../integration (integrator). Ask the orchestrator which one is yours — do not create a new worktree.`
218}
219
220const onOff = (v: boolean): string => (v ? 'on' : 'off')
221
222function guardText(holdOn: boolean, denyOn: boolean, h: number, c: number, last: GuardHold | null, d: number, lastWrite: GuardDeniedWrite | null): string {
223  const lines = [
224    `${PLUGIN} — in-process holds on top of the bash guards (which stay canonical).`,
225    '',
226    `hold_destructive_commands: ${onOff(holdOn)}`,
227    '  A Bash call that force-pushes, deletes a branch with -D, removes a worktree with --force, resets --hard, cleans with -f, runs rm -rf outside a temp directory, or discards the working tree (git checkout -- . / git restore .) pauses in a Proceed/Cancel dialog. Cancel refuses it. Where nothing can draw the dialog (claude -p, the SDK) the call goes through unheld.',
228    `  This session: ${h} hold${h === 1 ? '' : 's'}, ${c} cancelled${last ? `; last: ${last.rule} → ${last.answer} (${last.command})` : ''}.`,
229    '',
230    `deny_main_checkout_writes: ${onOff(denyOn)}`,
231    '  While an objective is active (SUPERPOWERS_OBJECTIVE_ID, or an objective.json with status active under .dev-files/objectives/) and this session sits in the main checkout rather than a worktree, an Edit/Write/MultiEdit/NotebookEdit inside the repository is refused with the objective\'s worktrees named. Writes under .dev-files/ and inside any agent worktree pass.',
232    `  This session: ${d} write${d === 1 ? '' : 's'} refused${lastWrite ? `; last: ${lastWrite.path} (objective ${lastWrite.objectiveId})` : ''}.`,
233    '',
234    `Change either in /config (${PLUGIN}) or with \`claude plugin configure ${PLUGIN}\`.`,
235  ]
236  return lines.join('\n')
237}
238
239export const register: Register = (on, options) => {
240  const holdOn = options.hold_destructive_commands !== false
241  const denyOn = options.deny_main_checkout_writes !== false
242
243  // ---------------------------------------------------------------- startup
244  on('session.start', async ($, e, next) => {
245    await $.command.register({
246      name: 'guard',
247      description: `Show what ${PLUGIN} holds and refuses, and both options' current values`,
248      immediate: true,
249    })
250    return next(e)
251  })
252
253  // ------------------------------------------- 1. destructive-command hold
254  // Judged before `next`: a hold is a pause in front of the call, never an
255  // undo after it. The `.catch` lets the call through when this hook fails —
256  // the bash guards are the binding layer; this is the convenience on top.
257  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
258    if (!holdOn) return next(e)
259    const rule = destructiveRule(e.command)
260    if (rule === null) return next(e)
261    const shown = preview(e.command)
262
263    // Nothing to draw the dialog on: a hold the person cannot answer must not
264    // become a silent deny, so the call goes through and the debug log says so.
265    const surfaces = await $.session.surfaces()
266    if (surfaces.length === 0) {
267      const at = await $.clock.now()
268      await update($, holds, n => (n ?? 0) + 1)
269      await update($, lastHold, () => ({ command: shown, rule, answer: 'unasked', at }))
270      $.ui.log(`${PLUGIN}: ${rule} let through unheld — no surface to ask on: ${shown}`, { to: 'debug' })
271      return next(e)
272    }
273
274    let answer: 'proceed' | 'cancel' | 'dismissed'
275    try {
276      const chosen = await $.ui.ask(`${PLUGIN}: this is a ${rule}. Run \`${shown}\`?`, { options: ['Proceed', 'Cancel'], header: 'Guard' })
277      answer = chosen === 'Proceed' ? 'proceed' : 'cancel'
278    } catch {
279      // The dialog was dismissed (Esc) on a surface that drew it: the person was
280      // asked and did not say Proceed.
281      answer = 'dismissed'
282    }
283    const at = await $.clock.now()
284    await update($, holds, n => (n ?? 0) + 1)
285    await update($, lastHold, () => ({ command: shown, rule, answer, at }))
286    if (answer === 'proceed') return next(e)
287    await update($, cancelled, n => (n ?? 0) + 1)
288    return { deny: `${PLUGIN}: ${rule} ${answer === 'dismissed' ? 'dismissed at the hold (Esc; answer Proceed to run it)' : 'cancelled at the hold'}: ${shown}` }
289  }).catch(($, e, next) => next(e))
290
291  // ----------------------------------------- 2. main-checkout write deny
292  // The rule of hooks/enforce-worktree-edits.sh for the one case this hook
293  // judges: the session sits in the main checkout, an objective is active, and
294  // the target resolves inside the repository (not under .dev-files/, not in an
295  // agent worktree). Everything else passes to the bash hook, which stays
296  // canonical. The `.catch` lets the write through when this hook fails.
297  //
298  // The matcher is a RegExp rather than a list because `MultiEdit` is not in
299  // this build's built-in tool table (2.1.302 folds it into `Edit`), and the
300  // list form types against that table; the RegExp keeps the name for a build
301  // that still registers it. `e` is therefore the whole union and the target
302  // is read by key: `file_path` for Edit/Write/MultiEdit, `notebook_path` for
303  // NotebookEdit.
304  on('tool.call', { tool: /^(Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
305    if (!denyOn) return next(e)
306    const target = 'notebook_path' in e ? e.notebook_path : 'file_path' in e ? e.file_path : undefined
307    if (typeof target !== 'string' || target === '') return next(e)
308
309    // The main checkout only: in a linked worktree $.session.root() is the
310    // worktree and $.session.repo().root the main working tree, and they differ.
311    const repo = await $.session.repo()
312    if (!repo?.root) return next(e)
313    const root = await $.session.root()
314    if (trimSlash(root) !== trimSlash(repo.root)) return next(e)
315
316    // Where the repository and the target really are: symlinks and `..` folded,
317    // both sides resolved the same way. A target not there yet is placed by its
318    // nearest existing ancestor. A path that cannot be placed is passed on —
319    // the bash hook judges it; this mirror refuses only what it can show.
320    let repoReal = trimSlash(repo.root)
321    try {
322      repoReal = trimSlash((await $.fs.stat(repo.root, { resolve: true })).realPath ?? repo.root)
323    } catch {
324      // The repo root always exists; keep its own spelling if resolution fails.
325    }
326    let real: string | undefined
327    try {
328      real = (await $.fs.stat(target, { resolve: true })).realPath
329    } catch {
330      real = undefined
331    }
332    if (real === undefined) {
333      let dir = parentOf(target)
334      let tail = baseOf(target)
335      for (let climb = 0; climb < MAX_CLIMB && real === undefined; climb += 1) {
336        const probe = dir === '' ? '.' : dir
337        try {
338          const found = (await $.fs.stat(probe, { resolve: true })).realPath
339          if (found !== undefined) real = joinPath(found, tail)
340        } catch {
341          // not there either: climb one more
342        }
343        if (real !== undefined || dir === '' || dir === '/') break
344        tail = `${baseOf(dir)}/${tail}`
345        dir = parentOf(dir)
346      }
347    }
348    if (real === undefined || !isUnder(real, repoReal)) return next(e)
349
350    // The Claude config directory is version-controlled for backup, not a
351    // project checkout (enforce-worktree-edits.sh's own carve-out).
352    const configRoot = trimSlash((await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`)
353    if (configRoot !== '/.claude' && isUnder(repoReal, configRoot)) return next(e)
354
355    const stateDirname = (await $.env.get('SUPERPOWERS_OBJECTIVE_STATE_DIRNAME')) || '.dev-files/objectives'
356    const agentDirname = (await $.env.get('SUPERPOWERS_AGENT_WORKTREE_DIRNAME')) || '.agent-worktrees'
357    const agentRoot = await $.env.get('SUPERPOWERS_AGENT_WORKTREE_ROOT')
358    if (isUnder(real, joinPath(repoReal, '.dev-files'))) return next(e)
359    if (isAgentWorkspacePath(real, repoReal, agentDirname, agentRoot)) return next(e)
360
361    // Is an objective active? SUPERPOWERS_OBJECTIVE_ID wins; else the first
362    // objective.json under the state directory whose status is `active` — the
363    // same reading as hooks/lib/objective-common.sh active_objective_id.
364    let objectiveId = (await $.env.get('SUPERPOWERS_OBJECTIVE_ID')) ?? ''
365    if (objectiveId === '') {
366      const stateDir = joinPath(repoReal, stateDirname)
367      if (await $.fs.exists(stateDir)) {
368        const ids = (await $.fs.list(stateDir)).filter(d => d.kind === 'dir').map(d => d.name).sort()
369        for (const id of ids) {
370          const path = `${stateDir}/${id}/objective.json`
371          if (!(await $.fs.exists(path))) continue
372          const obj = parseObject(await $.fs.read(path))
373          if (obj && obj.status === 'active') {
374            objectiveId = str(obj.id) || id
375            break
376          }
377        }
378      }
379    }
380    if (objectiveId === '') return next(e)
381
382    const at = await $.clock.now()
383    await update($, deniedWrites, n => (n ?? 0) + 1)
384    await update($, lastDeniedWrite, () => ({ path: target, objectiveId, at }))
385    return { deny: reasonFor(repoReal, objectiveId, agentDirname, agentRoot) }
386  }).catch(($, e, next) => next(e))
387
388  // ------------------------------------------------------------ 3. /guard
389  on('command.run', { command: 'guard' }, async $ => {
390    const h = await read($, holds)
391    const c = await read($, cancelled)
392    const last = await read($, lastHold)
393    const d = await read($, deniedWrites)
394    const lastWrite = await read($, lastDeniedWrite)
395    return { text: guardText(holdOn, denyOn, h, c, last, d, lastWrite) }
396  })
397}
398
types/index.d.ts 48 lines
1// The guard plugin'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 plugins/guard/hooks/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 counts restart with the session, and
7// `/guard` reads them back so the person can see what the guard has done.
8
9/** The last destructive-command hold the guard raised, as `/guard` reports it. */
10export type GuardHold = {
11  /** The shell command the hold was raised on, cut to one line. */
12  command: string
13  /** Which vocabulary entry matched (`force push`, `rm -rf`, ...). */
14  rule: string
15  /**
16   * How the hold ended: the person pressed Proceed or Cancel, dismissed the
17   * dialog (read as Cancel), or nothing could draw it and the call went through.
18   */
19  answer: 'proceed' | 'cancel' | 'dismissed' | 'unasked'
20  /** `$.clock.now()` milliseconds when the hold ended. */
21  at: number
22}
23
24/** The last main-checkout write the guard refused, as `/guard` reports it. */
25export type GuardDeniedWrite = {
26  /** The path the tool named. */
27  path: string
28  /** The objective that was active. */
29  objectiveId: string
30  /** `$.clock.now()` milliseconds when it was refused. */
31  at: number
32}
33
34declare module 'claude-code' {
35  interface PluginState {
36    'tamirs-superpowers-guard': {
37      /** Destructive-command holds raised this session. */
38      holds: number
39      /** Of those, cancelled or dismissed (the call was refused). */
40      cancelled: number
41      lastHold: GuardHold | null
42      /** Main-checkout writes refused this session. */
43      deniedWrites: number
44      lastDeniedWrite: GuardDeniedWrite | null
45    }
46  }
47}
48