Ship git-agent/hooks/scope-guard.py, a PreToolUse hook that blocks repo-wide formatter runs and index-less git stash pop before they execute, and document that the existing lint gate's .claude/lint-gate.json can name a test command.
One npm run fix:all reformatted about 190 untouched files and needed a guarded revert; one bare git stash pop restored an unrelated stash and created conflicts. Both are prevented by a rule that lives only in a personal CLAUDE.md. This ships the rule as a PreToolUse hook in git-agent — resolving npm scripts so fix:all is actually caught — and documents the existing lint gate's config as a test gate.
Read and implement all steps in the plan at docs/plans/add-scope-guard-hook.md — Add a scope-guard hook for repo-wide formatters and bare stash pops. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/add-scope-guard-hook.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
More ways to run this plan — goal & workflow prompts, file path
Achieve this goal: Add a scope-guard hook for repo-wide formatters and bare stash pops. The plan at docs/plans/add-scope-guard-hook.md describes one approach — use it as reference, but optimize for the outcome. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/add-scope-guard-hook.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
add-scope-guard-hook.html
docs/plans/add-scope-guard-hook.html
docs/plans/add-scope-guard-hook.md
Context
The story behind this plan — what prompted the work and why it matters now.
The 2026-08-14 usage report records the two most expensive single incidents in
the period, both from commands whose blast radius exceeded their intent: a
repo-wide npm run fix:all that "reformatted ~190 untouched files, requiring
a guarded revert", and "a bare git stash pop [that] restored an unrelated
stash and created conflicts requiring recovery." It also notes 47user_rejected_action events clustering on destructive commands — meaning the
enforcement mechanism today is a human at the permission prompt.
The rule exists but is not shipped. Both constraints are already written in
this user's global CLAUDE.md, under Formatting & Scope and Git. That governs
this user and nobody who installs these plugins, and it is advice a model can
weigh rather than a gate it cannot pass. A hook is the difference between a
prompt and a program.
A user-level hook already covers part of this, with three gaps.~/.claude/hooks/block-repo-wide-format.py was written on 2026-08-14 and
blocks three literal patterns: <runner> run fix:all, prettier|biome --write .,
and eslint|biome --fix .. It is the right idea, and it settles the yarn run
question — its own pattern already spells the runner alternation with an
explicit run. Three gaps remain, and they are why this plan is still worth
landing:
1. No script resolution. It matches the literal string fix:all, sonpm run format where format is prettier --write . passes untouched.
The incident command happened to be named fix:all; the next one will not
be.
2. run is mandatory in its first pattern. yarn fix:all — the bare form
every runner accepts — does not match. Stripping an optional run token
covers both spellings without enumerating either.
3. It fires on non-executing mentions. It inspects the raw command text, sogit commit -m "...fix:all..." is blocked even though nothing runs. This
was observed while authoring this plan: a commit whose message described
the pattern was refused. A guard that blocks talking about a command is a
guard that gets disabled.
It is also user-level, so it does not travel to anyone installing git-agent —
the original reason for shipping it in a plugin stands unchanged.
It belongs in git-agent because the wiring already exists. git-agent
registers a PreToolUse hook on Bash for lint-before-commit.py, and its
manifest carries the explicit "hooks": "./hooks.json" key added in 4.14.0
after a controlled A/B showed the root-level file is otherwise never read. A
second command in the same matcher costs one new file and no new registration.
The alternative — a new plugin for two guards — is a marketplace entry, a
README, a version line, and a CHANGELOG for 80 lines of Python.
A literal-text match would miss the actual incident. The report's own
suggested hook greps the raw command for fix:all. The command that caused the
damage was npm run fix:all, whose expansion lives in package.json — and the
general case, npm run format where the script is prettier --write ., has no
matching text at all. The hook therefore resolves npm/pnpm/yarn/bun
script invocations against the nearest manifest before matching, reusing the
upward walk lint-before-commit.py already implements. Without that step the
guard would pass the exact command it exists to stop.
Resolution strips an optional run token rather than enumerating invocation
spellings. Every runner accepts both — yarn fix:all and yarn run fix:all
are the same command, and lint-before-commit.py already normalizes Yarn toyarn run at its RUNNERS table — so a list of literal forms leaves a bypass
for whichever spelling it omitted.
Two patterns, not five. Only the two with recorded incidents are blocked:
- a formatter or linter invoked with --write or --fix and no path argument,
or with . as its path, whether typed directly or reached through a package
script;
- git stash pop or git stash apply with no explicit stash reference.
rm, curl, git reset --hard, and git checkout -- . are deliberately
excluded. rm and curl are already denied outright at the permission layer
for this user, and the rest have no measured incident here. A guard that fires
on safe commands gets disabled within a week, which costs more than the two it
was catching.
The desktop app will not run it. Plugin hooks.json files are not
registered in Claude Code desktop sessions — measured across 627 hook
executions, where zero came from any plugin. This guard is therefore CLI-only
enforcement, and the CLAUDE.md rules remain the desktop fallback rather than
being retired when it ships. That belongs in the README next to the hook, so
nobody debugs a hook that was never wired.
The lint gate is already a test gate and nobody knows. Separately,lint-before-commit.py accepts arbitrary commands through.claude/lint-gate.json {"commands": [...]}, compares against HEAD so
pre-existing failures never block, and is documented in the README only as a
lint mechanism. The report's "defects caught by CI and review bots instead of
locally" finding — 100 buggy_code frictions against 95 code_review_response
sessions — needs no new machinery to address, only a documented example. It
ships here because it edits the same README section as the new hook.
Files that change
Every file this plan touches, and what happens to each one.
kit/plugins/git-agent/hooks/scope-guard.pynew the PreToolUse guard- kit/plugins/git-agent/
hooks.jsonmodified second command in the existing Bash matcherREADME.mdmodified the guard, its opt-out, the desktop caveat, and the lint-gate test-command exampleCHANGELOG.mdmodified version entry
.claude-plugin/marketplace.jsonmodified git-agent version bumptests/plugins/test-scope-guard.shnew block, pass, and fast-bail assertions
Steps
The step-by-step work, in order — each step says what to do, why it matters, and how to check it worked.
hooks/scope-guard.py reading the PreToolUse payload from stdin and exiting 0 immediately for any payload that is not a Bash tool call, for any command whose text contains none of the trigger tokens (--write, --fix, stash, or a package-runner prefix), and for any command whose first token is not itself a runner, formatter, or git — so a pattern appearing inside a git commit -m message, a grep, or an echo never matches.
ls -la each exit 0 having opened no file; git commit -m "fixes npm run fix:all" and grep -r "prettier --write ." docs/ both exit 0.npm, pnpm, yarn, or bun, strip an optional run token and treat the next token as the script name, then walk up from the payload's cwd to the git root, read the first manifest declaring that script, and match against the script's value rather than the typed command. A missing manifest, unreadable JSON, or absent script resolves to the typed text and never blocks on its own.
npm run fix:all is the command that caused the incident and carries none of the dangerous text itself, and every runner accepts both spellings — yarn fix:all and yarn run fix:all are the same invocation, and this repo's own lint-before-commit.py uses the yarn run form — so enumerating spellings leaves a bypass for whichever ones the list missed.package.json defines "fix:all": "prettier --write ." blocks on all eight forms (npm run, pnpm run, yarn run, bun run and each without run), and the same fixture with that script removed exits 0 for all eight.--write or --fix and either no path operand or . as the operand. A command naming any other path — prettier --write src/, eslint --fix kit/plugins/git-agent — passes.
prettier --write . and eslint --fix block; prettier --write src/app.ts and npx prettier --write kit/ exit 0.git stash pop and git stash apply with no stash reference, and pass when an explicit stash@{N} or index is given. The block message quotes git stash list as the first step.
git stash pop blocks and the message contains git stash list; git stash pop stash@{2} exits 0..claude/no-scope-guard opt-out checked at the repo root before any rule runs.
.claude/no-lint-gate so a user who knows both files knows both escape hatches..claude/no-scope-guard present exits 0 silently.PreToolUse Bash matcher in hooks.json with a short timeout, leaving lint-before-commit.py and its 480s budget unchanged.
hooks key are already correct, so this is one array entry, and the guard must not inherit a timeout sized for a lint baseline run.claude plugin details git-agent reports the same hook events as before with the added command present, and a git commit payload still reaches the lint gate.tests/plugins/test-scope-guard.sh covering: both blocked patterns, each pattern's passing counterpart, script resolution both ways, the opt-out, the non-Bash payload, and the no-trigger-token fast bail — reusing the fixture helpers from test-lint-before-commit.sh.
bash tests/plugins/test-scope-guard.sh reports zero failures, and commenting out either rule turns exactly its own checks red..claude/no-scope-guard opt-out, the desktop-app caveat that plugin hooks do not register there, and a .claude/lint-gate.json example naming a test command ({"commands": ["npm run lint", "npm test"]}) with a note that the gate compares against HEAD so a pre-existing failure never blocks.
.claude-plugin/marketplace.json — 4.18.0 if harden-ship-preflight has landed at 4.17.0, otherwise 4.17.0 — and add the CHANGELOG entry.
git fetch origin && BASE_REF=main node scripts/check-plugin-versions.mjs exits 0.Tests
The tests that prove the change does what it promises.
git stash pop, are both blocked before executing, while their scoped equivalents run untouched. File: tests/plugins/test-scope-guard.sh; Type: smoke; Asserts: npm run fix:all resolving to prettier --write . exits 2, prettier --write src/app.ts exits 0, git stash pop exits 2 with git stash list in the message, and git stash pop stash@{1} exits 0; Run: bash tests/plugins/test-scope-guard.shgit commit -m and echo/grep carrying a blocked pattern in their text exit 0, and none of these read a manifest or the opt-out filerun strip; Key cases: all eight runner spellings (npm/pnpm/yarn/bun, each with and without run) resolve the same script, nearest manifest wins over the root, a missing script resolves to the typed text, malformed JSON never blocks, and the walk stops at the git root. blocks, no operand blocks, an explicit path passes, a --check-only invocation passes, and a path containing a dot in its name is not mistaken for ..claude/no-scope-guard disables every rule, and each block writes the rule and its safe alternative to stderr with exit 2Definition of done
The plan counts as done when every statement below is true — check each one off as you verify it.
Final check
One last pass to confirm the whole change works end to end.
Run bash tests/plugins/test-scope-guard.sh andbash tests/plugins/test-lint-before-commit.sh, confirming zero failures in
both — the second proves the new array entry did not disturb the existing gate.
Then git fetch origin && BASE_REF=main node scripts/check-plugin-versions.mjs
and confirm exit 0.
End-to-end, confirm registration directly rather than inferring it from a side
effect: run claude plugin details git-agent and read the component inventory,
confirming the PreToolUse entry now carries two commands. Then, in a terminal
CLI session with the plugin loaded, run npm run fix:all in a scratch repo
whose script is prettier --write . and confirm the tool call is refused with
the guard's message rather than executing — and that npx prettier --write in the same repo runs normally.
src/
Do not use a desktop session as the test surface: plugin hooks are not
registered there, so a silent pass proves nothing about this hook.
Wrapping up
Three gates that must all pass before this plan is marked completed.
Completion Report
No items to report — all requirements met.