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…

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.
Option (/config, on by default) | What happens |
|---|---|
hold_destructive_commands | A 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_writes | While 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.
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.
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.
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.
| Path | What it is |
|---|---|
.claude-plugin/plugin.json | Manifest: name, the two userConfig booleans, types |
hooks/hooks.json | { "modules": ["./register.tsx"] } — the one module, no bash hook |
hooks/register.tsx | The hooks module |
hooks/guard.test.tsx | Its tests (claude plugin test) |
types/index.d.ts | The $.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.
hooks/register.tsx 398 lines1// 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}
398types/index.d.ts 48 lines1// 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