# branch-guard

A Claude Code plugin that adds a `PreToolUse` hook for `Bash`, `Edit`, `Write`, `MultiEdit`, and `NotebookEdit`. Its job is to **minimize routine git/branch approval prompts** (especially in `acceptEdits` and non-interactive modes) while keeping a human in the loop for protected-branch and destructive operations. For Bash it classifies each `git`/`gh` command and auto-approves the safe ones (read-only git/gh, staging, branch creation, fetch, a commit/push of a feature/worktree branch), asks before protected-branch or destructive ones (`reset --hard`, `clean -f`, a `branch -D` that would orphan commits, …), **denies** the two cases whose cause is a fact the session can check for itself rather than a judgement (a clean-worktree `reset --hard` onto a tip proved unreachable, in `classify_reset`, and a push onto a base that has moved into this branch's own lines, in `classify_git` — see `DENY_ROUTES`), and defers on anything it can't classify. For edits it asks when the file's repo is on a protected branch, unless the path is gitignored. See `README.md` for the user-facing overview and the decision tables.

The load-bearing piece is `hooks/branch-guard.py` — a stdlib-only Python hook that reads the `PreToolUse` JSON from stdin and emits a decision against the protected-branch set (`protected_patterns`/`is_protected`). For Bash it lexes the command with `shlex` (matching workspace-guard's parsing model) via `tokenize` into a flat raw token list, splits that into simple-command segments with `command_segments`, and parses each with `parse_invocation` (handles `git` *and* `gh`, env prefixes, global flags, combined short flags) rather than substring matching. `classify_segment` returns `('allow'|'ask'|'ask-shared'|'deny-rebase'|'deny-unreachable'|'defer'|'nongit', reason)` per segment via `classify_git`/`classify_gh`; `main()` combines them — **any `ask` wins; else every segment must be `allow` (or a `'filter'`/`'benign'` segment); else defer** — with `ask-shared` answered ahead of the break-glass and either `DENY_ROUTES` verdict outranking a plain `ask` behind it, so a human can't approve past a denial that rode in beside a prompt. Two relaxations of the all-git rule let a non-git segment ride along after a git/gh segment: (1) `is_safe_read_filter` — a pure read-only pager/formatter from `SAFE_READ_FILTERS` (`head`/`tail`/`wc`/…) with **no file positional and no write option** — tagged `'filter'` (`git log | head`); and (2) `is_benign_segment` — a side-effect-free no-op/label from `BENIGN_COMMANDS` (`echo`/`printf`/`true`/`false`/`:`) — tagged `'benign'` (`git log … ; echo "---" ; git log …`). The allow-check accepts `{'allow','filter','benign'}`. `not any(invs)` still requires ≥1 git/gh segment (so `head -5` or `echo hi` alone defers); `FILTER_VALUE_OPTS` keeps a value token (`tail -n 5`) from looking like a file positional, and `FILTER_WRITE_OPT_RE` rejects writers (`sort -o`). `sed`/`awk` are excluded (write via `-i`/`>` or run code). Two downgrade-only checks weaken a would-be `allow` to defer (never an `ask` — same discipline as `GIT_ESCAPE_HATCHES`): `has_shell_substitution` over the *raw* token list (before redirect targets are stripped) when a token hides command/process substitution (`` `…` ``, `$(…)`, `<(…)`/`>(…)`) or an unrecognized operator run (`|&`); and `command_segments`' per-segment `writes_file` flag (`redirect_writes_file`) when a segment has an output redirect to a real file (`git log > f`, `echo x > f` — but **not** `/dev/null`/std-streams in `DISCARD_TARGETS`, nor fd-dups like `2>&1`). The write flag is both a hardening (a redirected `git log --format=…` can't silently write attacker-influenced content) and the gate that stops a `'benign'`/`'filter'` segment from riding a write through. For edits it resolves the branch of **the file's own repository** (`git -C <dir-of-file>`), not the session cwd — reading the path from `notebook_path` for `NotebookEdit` and `file_path` for the others, and deferring if neither is present. That directory need not exist: an edit names where the file *will be*, so `nearest_existing_dir` walks up to the closest ancestor that is on disk before probing. Without it `git -C` failed on the missing directory before it ever looked for a repo, and "no directory" read as "unresolvable branch" — a `Write src/newdir/f.py` on `main` went unguarded and silently, since a defer emits nothing. The walk stops at the filesystem root, so a path under no repo still resolves to no branch; when an ancestor *is* a repo the walk finds it, which is right because that is the worktree the new file lands in. A would-be `ask` is withdrawn when the path is gitignored (`path_is_ignored`), because there are no branch contents to protect — the decision would be identical on a feature branch, so the prompt carries no signal. The probe runs only inside the `is_protected` arm, so a feature-branch edit still costs no subprocess, and it returns True *only* on `check-ignore` exit 0: every other answer (not ignored, outside the worktree, not a repo, git missing) keeps the `ask`. It probes `os.path.realpath(path)`, not the path as given — a symlink inside an ignored directory is itself ignored while the write lands on its target, so probing the link exempted edits to tracked files (fixed after 1.4.2); resolving also fails safe, since a link out of the worktree reports not-ignored and keeps the `ask`. `check-ignore` consults the **index** by default, so a tracked file that also matches an ignore rule (`git add -f`) reports not-ignored and keeps prompting — that is what makes one probe sufficient and why `--no-index` must never be added, since it would read the pattern alone and drop the guard on a file whose edits do land on the branch.

The git/gh classifier is the contract: `READONLY_GIT` (allow on any branch), `READONLY_GH` (allow), per-subcommand rules in `classify_git` (safe mutations like `add`/`switch -c`/`worktree add`; `_feature()` for branch-sensitive mutations — allow on non-protected, ask on protected; destructive → ask, or deny where the cause is a checkable fact; unknown → defer). `GIT_ESCAPE_HATCHES` (`-c`/`--config-env`) downgrade a would-be `allow` to defer (but never weaken an `ask`). `short_flag_letters` decomposes bundled flags (`-fd`).

`git branch` is the one subcommand classified by **ownership of the target ref** rather than by the verb (`classify_branch`). A target is in bounds when it is *recoverable* — its tip is reachable from a ref matching `RECOVERY_REF_PATTERNS` (any remote-tracking branch, or local `main`/`master`), so the worst case is `git reset --hard <sha>` — and *private*, meaning not `is_protected`. The probes (`branch_exists`, `tip_is_recoverable`, both on the shared `run_git` runner with its 5s cap) may only ever relax a would-be `ask` into an `allow`, and only on a positive answer: `None` means "couldn't tell" and keeps the `ask`, so an unreachable git, a branch that won't resolve, and a `git -C`/`--git-dir` pointing at another repo (`targets_other_repo` clears the `probe` flag, since the probes read the *session* cwd) all land on the pre-existing behavior. The non-force spellings are allowed without any probe because git already enforces the same check — measured on git 2.55: `-d` refuses unmerged work (exit 1), `-m`/`-c` refuse an existing destination (exit 128) — while `-D`/`-M`/`-C` and `-d --force` all lose commits silently. Read force from the whole flag set, never from one letter: `git branch -d --force x` is `-D` spelled long, and treating the letter `d` as "safe delete" would auto-approve it. **Shared** and **recoverable** are independent questions, and git only enforces the second. The shared one is answered once for every form by `protected_targets` — which maps a form to the refs it would remove, rename, or overwrite, each with its reason phrase — and `classify_branch` runs that check before any verb branch, so no verb can return `allow` above it. Keep it that way: while each verb made the check itself, three ordered it before their allow-return and one ordered it after, and `git branch -d main` auto-approved with the configured protected set bypassable by lowercasing a flag. A new gated form is added by giving it targets in `protected_targets`, not by adding another `is_protected` call.

Recoverability is a property of the *tip*, and force-delete cares about a slightly wider thing: what the delete would orphan. The two come apart for exactly one shape — a scratch branch that merged an integration ref to check something (`switch -c tmp; merge origin/main`), whose tip is unreachable *because* it merged, while the only commit nothing else names is that merge. `orphans_only_reproducible_merges` is the second chance the `-D` path gets when `tip_is_recoverable` says False: `rev-list --parents --ignore-missing <b> --not RECOVERY_REV_ARGS` lists what would be orphaned, and every entry must be a two-parent merge whose recorded tree `git merge-tree --write-tree` reproduces from those parents. "It is only a merge" is *not* the property being established — a hand-resolved conflict lives in the merge's tree and nowhere else, so re-running the merge is what separates a derivable commit from authored work. It returns True only on that proof and False on every other answer (a non-merge or octopus orphan, a conflicting or hand-edited merge, an empty orphan list — which `--ignore-missing` also produces for a name that won't resolve — a list longer than `MAX_EXAMINED_ORPHANS`, a git predating `--write-tree`, a read-only object store, a timeout), mirroring `path_is_ignored` rather than the tri-state probes. Note `--write-tree` leaves an unreferenced tree in the object store, which gc prunes. `overwrite_verdict` takes the same second chance on the same argument, so the force move/copy forms are not tip-only either: `-f`/`-M`/`-C` reach it through the ref they overwrite, and the proof is over what losing that whole ref would orphan, which over-approximates an overwrite whose start-point never reaches the classifier — the same conservatism `classify_reset` makes.

`git reset --hard` (and `--merge`/`--keep`) is the one other verb the ownership model reaches, via `classify_reset`, and only partly: the command both moves the current branch pointer (a ref operation) and discards uncommitted changes to tracked files (reachable from no ref, so unprovable). A dirty worktree therefore always asks. With a clean one the command reduces to the pointer move, and the usual pair applies — shared first (`is_protected` on the current branch), then `tip_is_recoverable`, whose `False` and `None` are split here rather than collapsed: a proved-unreachable tip falls through to the second question, while a probe that could not answer keeps the `ask`, the same way the `-D` path already separates the two. That second question is `orphans_only_reproducible_merges`, shared with `classify_branch`'s `-D` path: what it proves is about a ref *move*, so the verb it covered was an accident of its one caller, and `testmerge` in one state used to answer `allow` to `git branch -D` and `deny` to `git reset --hard`. It is taken over what deleting the ref would orphan, because the reset's target revision belongs to the command and `classify_reset` never sees it — conservative, since a reset orphans a subset of what a delete would. Only a branch failing both returns `deny-unreachable`, and the probe runs only where the verdict was already a deny, so no auto-approved path pays for it (measured on a four-commit repo, git 2.55: 18ms on the merge case it converts, 6ms on a non-merge orphan, against 13ms for the probes above it). The deny is the routing call, not a severity one — the missing input is a fact the model can settle with a `gh pr view`, and the commonest way to reach it is a squash merge, which leaves a spent branch's tip unreachable by construction while its content sits on `main` under another object name; a scratch branch that never opened a pull request is the other half of the surviving set, so the reason names what an empty `gh pr view` means and the `git log` that lists the loss. `refuse()` writes that check into the reason; `confirm()` is untouched, so the other three `ask` returns here are unaffected. Dirtiness is `worktree_is_clean`'s `git status --porcelain -uno`: `reset` never deletes untracked or ignored files (measured on git 2.55), so counting an untracked scratch file as dirty would prompt for something the command cannot destroy — `test/run.sh` pins that with an untracked-file-present case that fails if `-uno` is dropped. The remaining destructive verbs stay verb-gated because the model has nothing to bite on: `git clean -f` deletes untracked files, which exist in no ref, and a dropped stash survives only in the reflog.

The push guard (`push_decision`/`push_policy`) is driven by the `BRANCH_GUARD_PUSH_POLICY` env var: `strict` (default — **allow** a push of the worktree's own branch including force pushes, **ask** for anything else, tag publishes included: `refs/tags/…`, a bare tag name, and `--tags` all ask, while `--follow-tags` is a documented exception), `protected` (ask only when a push targets `main`/`master`, never auto-approve), or `off`. One cross-name push is auto-approved under `strict`: a *leased rewrite* — an explicit `--force-with-lease=<dst>[:<sha>]` (`lease_target`) naming the destination, with the worktree branch as the source. It rests on the same argument as the non-force `git branch` spellings, that git enforces the check itself: the push aborts unless the remote is still at the named commit, so it can't clobber unseen work. What the lease does **not** establish is that the destination is unshared — that stays `is_protected(dst_b)`, which is checked ahead of the whole `strict` block so no lease can reach past it. Deletions are excluded (`--delete` and the empty-source `origin :other`), a bare `--force-with-lease` names no ref so it doesn't qualify, and `--no-force-with-lease` clears every lease collected so far, as it does in git. On top of the policy, a would-be push `allow` gets one more question: is the branch still built on what it thinks it is? `push_overlap` diffs `fork..HEAD` and `fork..base` (both from the merge-base, so both sets of line numbers are counted in that shared ancestor and are comparable at all), parses each into pre-image ranges with `parse_hunks`, widens every hunk by `CONTEXT_LINES` in `hunk_range`, and reports the paths where `ranges_meet` — sharing a *file* is not sharing an edit, and an overlap reported where there is none sends a session off to rebase a branch that didn't need it. Ported from pipe-guard's PR #15 (issue #91), whose `gh pr create` half stays there. Its verdict is tagged `deny-rebase` rather than `ask` — the tag carries "this cause is a command to run, not a judgement to make" through the pipeline the way `ask-shared` carries "the cause is a protected branch", and the combine site hands it to `refuse()` along with its `DENY_ROUTES` entry. Denying is what gets the cause to the model at all: a reason on an `ask` is the prompt's text and reaches nobody else, so an approved push landed on the stale base the prompt had just named it for. The break-glass lifts it, keyed to the *tag* rather than reached through `OVERRIDABLE_GIT` — `push` has to stay outside that set, since every other push ask must remain unliftable, while this verdict is minted only after `push_decision` returned `allow`, so lifting it reinstates an approval the policy had already given rather than granting a new one. `is_overridable` is the only place that exception lives; adding `push` to the set instead would lift all of them. It runs **only** on a would-be `allow`, so `protected`/`off` never reach it and no existing `ask` is disturbed; `probe` gates it for the same reason the `git branch` probes are gated, since these read the session cwd. Note the one place it *adds* friction rather than withdrawing an approval: a protective verdict beats the all-segments rule, so `git push && rm -rf x` denies where it used to defer — deliberate, because the overlap is a property of the push and a defer would lose the catch outright in a session that has allowlisted `git push`. Everything here fails silent (`[]` = no opinion, never "the push is fine"), so a shallow clone, a stale fetch, an unresolvable `BASE_REF_ENV`, or a git predating `merge-tree --write-tree` costs a missed catch. `is_release_branch` skips a branch whose *name* says it is diverged from the base on purpose — nothing in the commit graph separates one from a stale topic branch — and `overlap_ignored` discounts a merge-driver-owned path only **conditionally**, since `merge_conflicts` can still report the discounted path as a real collision. `glob_list` is the one comma-separated-globs reader behind `protected_patterns`, the release list, and the ignore list, all extend-only. The `fork_sha == tip_sha` early return is a short-circuit saving two `git diff` processes, *not* a correctness guard — its mutant survives the suite, which the code says out loud so nobody writes a fixture pretending otherwise. `BRANCH_GUARD_PUSH_OVERLAP_ENABLED` disables it only on a `FALSE_VALUES` spelling: anything else leaves it on, so a typo costs an answerable prompt rather than a guard that silently stopped running.

A command is auto-approved only when *every* segment is recognized-safe — a git/gh `allow`, a `'filter'` read-only pager, or a `'benign'` label/no-op (`echo`/`printf`/`true`) piped or chained after a git/gh segment — so a non-git, non-filter, non-benign command (`git push && rm …`) can never ride along into an allow. A benign/filter segment that writes a file (`echo … > f`) is gated back out by the `writes_file` flag, so it can't ride a write through either.

The protected-branch set is `is_protected`/`protected_patterns`: `DEFAULT_PROTECTED_BRANCHES` (`main`/`master`) plus the comma-separated `fnmatch` globs in the `BRANCH_GUARD_PROTECTED_BRANCHES` env var, read at runtime like the push policy so it can be set from `settings.json` instead of by editing the cached plugin file. It is **extend-only on purpose** — there is no replace mode, so no value can unprotect `main`/`master` and "bad input fails safe" is structural rather than something the parser has to get right (an empty, whitespace-only, or nonsensical entry simply matches nothing). Globs rather than regexes because `fnmatch` can't raise on a malformed pattern the way `re.compile` can, and `*` spans `/` so one `release/*` covers `release/2.0/rc`. Matching is `fnmatchcase`, not `fnmatch`, so the same config protects the same set on every platform — plain `fnmatch` folds case on Windows only.

`confirm()` converts a would-be `ask` into a `deny` when `permission_mode` is one of `NON_INTERACTIVE_MODES` (`dontAsk`/`bypassPermissions`) — no human is present to answer, so the guard fails safe. **`auto` is deliberately not in that set**, though it was until #33's real complaint was fixed: the name reads as unattended and the mode isn't, since an `ask` there reaches a prompt a human answers (workspace-guard, measured on 1.10.0, treats `bypassPermissions` alone as human-free). Converting it removed the human rather than protecting them — a release could create an annotated tag and never publish it, so tagging always finished in the user's terminal, and the same dead end sent a session hand-editing a file back to its `HEAD` content rather than running the denied `git restore` (#78). Adding a mode to the set is a claim that nothing can answer a prompt there; measure it before making it. Every `ask` (the single Bash combine-site and the edit path) goes through `confirm()`; `allow` and defer are never downgraded. Classifier verdicts carry only the *cause* ("Push targets 'v1.3.0', not the worktree branch 'x'") with no closing clause — `confirm()` appends one per path, so an `ask` invites a confirmation and a `deny` states there is none, names the mode, and says retrying won't help. Keep that split: a confirm-shaped denial makes an agent retry a command it can never get approved. `main()` is the one place that emits. Both an `ask` and a `deny` open with `GUARD_PREFIX` (`branch-guard: `), applied in `emit()` rather than by the caller, so the attribution is a property of the wire format and a verdict added at a second site cannot omit it — which is why `confirm()`'s own wording never names the guard. Claude Code attributes neither verdict to the plugin behind it, and the two need the opener for different reasons. A deny leaves no record in the decision stream (Claude Code keeps a hook's stdout only for a call it goes on to run), so the error text handed back to the agent is its only trace, and foreground-guard 0.5.1 keys its `--plugin all` friction report on `^(?:Error:\s*)?([a-z0-9-]+-guard):\s` — a guard wording it differently under-counts every one of its own denies there. An ask *does* leave a record, but the human it is addressed to is reading the permission prompt rather than the record, and the prompt is the reason text alone; #101 shipped the opener on denies only, reasoning that `hookName` and the hook `command` already attribute an ask — true of the record, false of the reader, and retired. `allow` stays unprefixed — it surfaces as neither prose channel, so its only reader already holds the record that attributes it, and that exclusion is the control without which the suite would pass with the prefix on every decision.

**The reason reaches a different reader on each path, and that asymmetry picks the verdict rather than the wording.** Measured on Claude Code 2.1.220, an `ask` uses `permissionDecisionReason` as the permission prompt's text and delivers it nowhere else, so a session whose command is approved learns only that the call went through — a census of 837 local transcripts found the reason absent from the tool result in 2,232 of 2,245 `ask` verdicts, the exceptions being non-interactive runs where the ask had become a refusal. Only `deny` routes it to the model, via the bundle's `blockingError`. So a cause naming work for the model to do cannot be carried on an ask at all, and the two `DENY_ROUTES` verdicts exist for exactly that class: the `gh pr view` and the `git rebase` reach the model because the verdict is a denial, not because a field was bolted onto a prompt. There was such a field. `confirm()` took a `context` argument and emitted it as `hookSpecificOutput.additionalContext`, on the push-overlap ask alone (#96), and it did land — 7 real `ask-rebase` invocations across 1,097 local transcripts, 6 of them carrying both the raw hook JSON and the separately-recorded delivered text, correlated on `toolUseID`. That measurement is what settled the overlap as a pure verdict change rather than a fix for a context nobody received, and the field went with the ask it rode. Reintroducing one is two claims, not one: that some cause outlives its prompt, *and* that it cannot be a denial instead.

The break-glass (`OVERRIDE_VAR`/`override_reason`/`is_overridable`) is the one route past that denial, and it exists because a denial with no answer does not stop the work — it reroutes it. The measured case (#78) is a session that could not run `git restore file.txt` and hand-edited the file back to its `HEAD` content instead: same end state, no atomicity, nothing to review, and the gate had blocked the safe mechanism while permitting the unsafe one. `BRANCH_GUARD_OVERRIDE=<reason> <command>` is read from the **command string**, never `os.environ` — a `PreToolUse` hook inherits Claude Code's environment rather than the one the Bash tool is about to build, so an env-var override is only settable session-wide by hand (workspace-guard's `WORKSPACE_GUARD_OVERRIDE` has exactly that shape, and additionally downgrades `deny`→`ask`, which `confirm()` converts straight back in the auto mode the issue is about — copying it would have shipped a no-op). Only the leading assignment run of a segment counts, so the name as a positional or inside an `echo` disarms nothing, and an empty reason lifts nothing. **Two independent locks bound it, and both are load-bearing:** the subcommand must be in `OVERRIDABLE_GIT` — a set whose entries can lose only state this machine holds, so every `push` form and every `gh` form is outside it — and the verdict must be liftable — a plain `ask`, or a `deny-unreachable`, whose loss is the same local one from a subcommand (`reset`) already in the set; never `ask-shared`, the tag every `is_protected`-caused ask now carries (`_feature`, `push_decision`, `classify_branch`'s `protected_targets` loop, `classify_reset`). Adding a subcommand to `OVERRIDABLE_GIT` that calls `_feature` would be safe only because of the second lock; that is the point of having it. `is_overridable` additionally refuses a segment that writes a file, carries a `GIT_ESCAPE_HATCHES` inline config (which can run arbitrary code), or aims at another repo via `targets_other_repo` — each would let the override reach past the subcommand it was granted for. The all-segments rule applies unchanged, so nothing rides an override in. `confirm()`'s `liftable` argument names the prefix in a denial the override *would* lift and stays silent otherwise, because a hint that fails a second time is the dead end the wording exists to avoid; the interactive `ask` never advertises it.

`git worktree add` is the one verdict that moves on config rather than on the command: with `BRANCH_GUARD_WORKTREE_GRANTS=1` it asks instead of allowing, and the `PostToolUse` branch in `main()` records the created checkout's resolved path into `~/.claude/bouncer/session-grants/` — a namespace shared with workspace-guard, which reads it and never writes it. The ask is not a safety verdict: creating a checkout is safe, and this is the only moment that carries *ownership* of the new tree, which is what workspace-guard cannot otherwise establish. It is skipped in `NON_INTERACTIVE_MODES`, since `confirm()` would turn it into a deny and denying a safe command to capture an approval nobody can give blocks the work and records nothing. The store is `lib/bouncer_grants.py`; edit the root copy and run `make sync`.

## Development philosophy

Build the right thing AND build it well. Before writing any code, state the goal in one sentence and the approach in two or three. If the goal is unclear, ask one focused question rather than guessing.

Make the smallest change that achieves the goal. If you notice problems outside the current task's scope, flag them rather than fixing them — mention them at the end of the turn or open a separate PR.

Before introducing a new pattern or abstraction, check whether the existing tool dispatch in `main()` and `is_protected` already solve the problem with a small edit. The lexing/parsing pipeline (`tokenize` → `command_segments` → `parse_invocation`) is deliberately shared in spirit with workspace-guard — reuse that model rather than inventing a parallel one.

## Workflow

1. **At session start, check whether the worktree is stale.** New worktrees are branched from `main` at creation time, but `main` may have advanced since then — particularly if a previous session merged a PR. Run `git fetch origin main` and compare with `git log --oneline HEAD..origin/main`; if `origin/main` has new commits, rebase with `git rebase origin/main` before doing any other work.
2. **Before making changes** — read `README.md` and the whole of `hooks/branch-guard.py` so the proposed change matches the existing dispatch and tokenization model. If picking the next task, run `gh pr list` first and skip anything already covered by an open PR.
   - **Read the issues an issue cross-references before scoping the fix, and check how the closed ones were closed.** An issue argues for a fix from what its author knew when they wrote it, and the neighbours it links are where that has already moved. A closed one is the trap: `stateReason` distinguishes `COMPLETED` from `NOT_PLANNED`, and #49 — the ownership model that is `classify_branch`/`classify_reset` today — is `COMPLETED`, while #79 cites it as an argument that was dropped. #80 then offered three fixes and said any one would do, of which one was #49's already-shipped condition and one was #78's issue, leaving a single unfiled cause; scoping from the issue alone would have rebuilt a model that exists or landed someone else's design under the wrong number. A cited precedent is a claim as much as a mechanism is — #78 proposes an override "in the workspace-guard shape", and workspace-guard's reads the hook process env and downgrades `deny` to `ask`, which is inert in the auto mode the issue is about. Read the source it names, not the description of it.
   - **Verify behavioral claims end-to-end, not just by source-reading.** Shell tokenization is full of surprises that only show up when you exec the thing. If a change depends on "command X parses as a git commit" or "this branch resolves to Y," actually run `./test/run.sh` (or a targeted reproduction) and confirm.
   - **A claim about coverage or mechanism is load-bearing too.** A sentence in `CLAUDE.md`/`README.md` saying what runs where, or which CI job covers which code path, is what the next session trusts instead of re-deriving — a wrong one reads as "already tested" and strands the thing it names. Measure it before writing it, and prefer pinning it as a fixture over asserting it in prose (`test/run.sh` checks the launcher's error-path *path form*, so "Windows runs the cmd.exe half" fails loudly if it ever stops being true). Contradicting evidence outranks a plausible mechanism: `test -x` failing on Windows was the signal that the `.cmd` routing needed measuring, not a detail to explain away.
   - **Reporting a step as done is a claim too — measure it before writing the summary.** The verification rules above are about command output; this one is about your own account of the work. A summary saying a rebase happened, a suite is at N cases, or a PR carries a fix is what the user acts on, and it is written from memory of intent at exactly the moment the work feels finished. State only what a command just showed you: `git log --oneline -1` for the rebase, the suite's own tail for the count, `gh pr view` for what landed. Reconstructing a status from what you meant to do produces a claim that is confident, specific, and occasionally false — and the more routine the step, the less likely anyone re-checks it.
3. **After making changes** — review the diff and update docs proactively:
   - **Changed the decision logic, the git-commit detection, or the protected-branch set** → update the behavior table and "Known limitation" section in `README.md`.
   - **New configuration or hook surface** → `README.md`, `hooks/hooks.json`, and `.claude-plugin/plugin.json` keywords/description.
4. **Commit when done** — small, focused, Conventional Commits.

Work is tracked in the repo-root backlog, [`docs/queue/`](../../docs/queue/README.md),
shared with the other four guards. File items with the `branch-guard` label. The
issue numbers cited throughout this file are `karlkfi/claude-branch-guard`'s and
are history — that repository is retired and takes no new issues.

## Code standards

### Python (`hooks/branch-guard.py`)

- Stdlib only — no third-party deps. `hooks/hooks.json` launches the hook through `hooks/run-python-hook.cmd`, a bash/cmd polyglot that resolves a working Python 3 by *executing* candidates rather than testing for their presence — on Windows `python3` is usually the Microsoft Store alias stub, which any presence check finds and which exits 9009 when run. The launcher must stay executable (mode `100755`): `hooks.json` execs it directly, so a non-executable blob means every hook invocation fails on macOS/Linux, and a failed `PreToolUse` hook is non-blocking — the guard would silently stop enforcing. `git` must be on PATH; the hook no longer shells out to `jq`.
- The contract is explicit data + dispatch: the tool dispatch in `main()`, `DEFAULT_PROTECTED_BRANCHES`, `PUSH_POLICIES`, the classifier sets (`READONLY_GIT`, `READONLY_GH`, `GIT_ESCAPE_HATCHES`), and the per-subcommand rules in `classify_git`. Adding/guarding a subcommand, protected branch, or push policy means an explicit edit there — don't infer behavior at runtime.
- When classifying a subcommand, decide its tier deliberately: read-only → `allow` any branch; staging/branch-create → `allow` any branch; branch-sensitive mutation → `_feature()` (allow non-protected, ask protected); destructive → `ask`, unless the target ref can be *proved* recoverable and private, which is the `classify_branch` ownership tier, or the *cause* is a fact the model can establish for itself, which is the `deny-unreachable` tier below; **unknown or ambiguous → `defer`, never `allow`**. A subcommand that's read-only by default but mutating with a flag (`branch`, `tag`, `config`, `restore`, `clean`, `reflog`, `remote`, `worktree`, `stash`) needs its flags checked — use `short_flag_letters` for bundled short flags (`-fd`). When unsure whether a form is safe, defer.
- **When adding an exemption, enumerate what its predicate does *not* answer.** An exemption claims some property makes a gate unnecessary, and a predicate that establishes that one property reads as a finished thought — `if not force: return allow` looks complete — while the second question goes unasked. Write down what the property leaves open: recoverability does not establish that a ref is unshared, and an ignored path does not establish where the write lands. Each omission is either a second check or a fixture pinning why it doesn't matter, and the fixture has to cross the axes (protected × non-force, ignored × symlink) — a suite written from the same mental model as the code asks the same half of the question and passes green with the hole open, as 289 and then 299 cases did.
- Tokenize Bash commands with `shlex` via `tokenize` → `command_segments` → `parse_invocation`; never go back to substring/regex matching on the raw command — that's the exact gap the python port closed. `GIT_VALUE_OPTS`/`GH_VALUE_OPTS`/`PUSH_VALUE_OPTS` list the options that consume a following value token, and `PUSH_MANY_FLAGS` the ones that push more than one branch; extend these explicitly rather than guessing at parse time. `shlex` doesn't model every construct that runs a command (command/process substitution, `|&`), so `has_shell_substitution` over the raw `tokenize` output downgrades a would-be `allow` to defer — run that check on the raw tokens (pre-redirect-stripping), and only to weaken an `allow`, never an `ask`.
- The push guard leans toward asking (`strict`) / deferring (`protected`) on refspec forms it can't classify — never toward silently allowing. `ref_to_branch` returns a third element for a fully-qualified ref that isn't a branch (`refs/tags/…`, `refs/notes/…`): it must be reported, not collapsed into `None`, or it reads as "no branch involved" and falls through every branch check into the strict auto-approve. Any new ref shape gets the same treatment — a side the guard can't map to a branch is something to object to, not something to ignore. Hard guarantees belong in a git `pre-push` hook or server-side branch protection; keep `README.md`'s "best-effort" framing honest.
- On any uncertainty — not a git repo, detached HEAD, empty/missing input, unbalanced quotes (`shlex` raises `ValueError`), unresolvable branch — the hook **defers silently** (returns, emits nothing) so normal permissions apply. Never fail closed without an explicit reason.
- The interactive decision for a protected branch is `ask`, not `deny`. The one exception is a permission mode where no prompt can be shown (`dontAsk`/`bypassPermissions`), where `confirm()` deliberately upgrades `ask` → `deny` because no human can answer — that's failing *safe*, not hard-blocking by default. Don't add a blanket `deny` for interactive modes without sign-off, and note `auto` is an interactive one. `deny-unreachable` and `deny-rebase` are not that blanket and do not weaken this: neither touches a protected branch (`ask-shared` is returned first), they are two named verdicts rather than a tier, and each denies because the *cause* is checkable rather than because the command is dangerous. A third needs the same test — can the fix be written into the reason such that the model, not the human, resolves it? — and the break-glass has to reach it, or the denial is the dead end #78 measured.
- Emit decisions through `emit()` (`json.dumps`), and route every `ask` through `confirm()` so the non-interactive fail-safe applies uniformly — don't hand-build decision JSON or call `emit('ask', …)` directly.

## Security principles

**Secure by default, not opt-in.** This plugin exists to add a guardrail; its defaults must never trade away a security property for convenience. If a proposed change weakens any property — even partially, even with mitigations — the more secure behavior stays the default. The looser behavior may be offered as an explicit opt-in (env var, config, local edit) but must be documented as a trade-off.

Examples of regressions that must not silently become defaults:
- Flipping the protected-branch decision from `ask` to `allow`.
- Removing `main` or `master` from `DEFAULT_PROTECTED_BRANCHES` because it was "noisy", or letting `BRANCH_GUARD_PROTECTED_BRANCHES` *replace* the defaults rather than extend them — config must only ever be able to protect more.
- Treating an unresolvable branch or unparseable input as `allow` rather than deferring.
- Auto-approving a command that contains a non-recognized-safe segment (e.g. `git status && rm -rf foo`). Allow fires only when *every* segment is recognized-safe — a git/gh `allow`, a read-only `'filter'`, or a side-effect-free `'benign'` no-op (`echo`/`printf`/`true`), each gated so it can neither write a file (the `writes_file`/`redirect_writes_file` check) nor run hidden code (`has_shell_substitution`). Widening that set to anything that can write or execute lets a trailing command ride along into a silent approval — the exact gap the Python port closed. Adding a new `BENIGN_COMMANDS`/`SAFE_READ_FILTERS` entry needs the same scrutiny: it must be provably side-effect-free under those two gates.
- Moving a subcommand into the `allow` tiers (`READONLY_GIT`, the safe-mutation cases) without checking its mutating flags — e.g. allowing `git restore` (discards the worktree) or `git checkout <name>` (ambiguous branch-vs-file discard). Default ambiguous/unknown forms to `defer`.
- Letting an ownership probe's "couldn't tell" answer read as "in bounds". `branch_exists`/`tip_is_recoverable` return `None` for every failure — git missing, timed out, not a repo, ref won't resolve — and `classify_branch` must keep asking on `None`, never allow. The same holds for the `probe` flag: when a `git -C`/`--git-dir` global points the command at another repository, the probes are answering about the wrong repo, so the form asks rather than trusting them.
- Letting a `git -c …`/`--config-env` escape hatch reach an `allow` (it must downgrade to defer), or weakening the push-policy default — `strict` must stay the default; `protected`/`off` are looser and opt-in only.
- Downgrading the non-interactive fail-safe: in `dontAsk`/`bypassPermissions` an `ask` must stay a `deny`. Letting it fall back to `allow` means an unattended session runs a destructive command or pushes/commits to `main` with nobody to stop it. Falling back to `ask` is only sound where a prompt genuinely reaches somebody, which is why `auto` left the set and why moving another mode out needs the same measurement rather than the same reasoning. The break-glass is the bounded exception, not a loosening of this: it is self-served (the agent writes its own reason and the guard checks only that one exists), so what keeps it honest is scope alone. Widening `OVERRIDABLE_GIT` to anything that can reach past this machine — a push, a `gh` deletion, a remote ref — hands an unattended session a self-approval on exactly the operations a human was there for, and no reason string compensates.
- Resolving the edit branch from the session cwd instead of the file's own repo (`git -C <dir-of-file>`), so edits through a checkout sitting on `main` are no longer caught.

When in doubt, ask before shipping. The hook's job is to add friction at the protected-branch boundary; removing friction is the change that needs sign-off, not adding it.

## Testing

Tests live in `test/run.sh`. Run with:

```bash
./test/run.sh
```

It spins up a throwaway git repo under `tmp/` and asserts the emitted `permissionDecision` across the matrix: commits and all-git/mixed chains, env-prefixed/global-flag commits, the read-only git allowlist, safe mutations (`add`/`switch -c`/`worktree add`/`worktree remove` non-force/`restore --staged`), destructive commands (`reset --hard`/`clean -fd`/`config --global`/…), the ownership model for `git branch` (non-force `-d`/`-m`/`-c` allow; `-D`/`-M`/`-C`/`-f` allow on a recoverable or not-yet-existing target and ask on an orphaning, protected, unresolvable, or foreign-repo one, `-d --force` included), the reproducible-merge tier `-D`, `reset --hard` and the `-f`/`-M`/`-C` overwrite share (a clean two-parent test-merge of refs that survive it allows, under `dontAsk` too — an unanswerable mode is what the relaxation exists for — while the same branch carrying a commit of its own, a hand-resolved conflict merge, an octopus merge, and a configured-protected name all ask, the last of these paired with the unset control that makes it mean something; the `reset --hard` side mirrors all of it on the same fixtures, where the negatives deny rather than ask, and asserts that the surviving deny's reason routes the no-pull-request half as well as the squash half; the overwrite side mirrors it on the same fixtures again, crossed once over the shared arm rather than once per spelling, since `overwrite_verdict` serves all three from one body -- all three spellings on the positive, because `-M`/`-C` probe their DESTINATION and a rename onto a free name never reaches the probe, then `-f` alone for the three negatives, the configured-protected crossing and the `dontAsk` allow, plus the surviving ask's wording, which stays the tip-reachability fragment the `-D` arm prints), branch-sensitive mutations on feature vs protected (`rebase`/`merge`/`pull`), the inline-config escape hatch, read-only vs mutating `gh`, destructive gh deletes/disables → ask (an unmerged branch via `gh pr close --delete-branch`/`-d`, or `gh api -X DELETE …/git/refs/…` — while `gh pr merge --delete-branch` *defers* with `gh pr merge`, since the merge lands the work before the delete runs; repo via `gh repo delete` or `gh api -X DELETE repos/{o}/{r}`; label via `gh label delete` or `gh api -X DELETE …/labels/…`; release/secret/variable/gist/cache via `gh <sub> delete`/`remove` (plus `gh release delete-asset`); workflow via `gh workflow disable`), read-only filters piped after a git segment, benign label/no-op segments (`echo`/`printf`/`true`) riding along, the redirect-write downgrade (`git log > f` defers; `2>/dev/null`/`2>&1` still allow), edits (including the gitignored-path skip: an ignored path defers on `main` by absolute and by relative-plus-`cwd` path and under a non-interactive mode, while a non-ignored sibling, a tracked-but-`add -f`'d ignored file, and a symlink in an ignored dir aimed at a tracked file all still ask; and the not-yet-existing directory: a new file in one asks on `main` — nested arbitrarily deep — defers on a feature branch, defers under a gitignored dir, and defers in a directory that sits in no repo, whose not-a-repo precondition is asserted rather than assumed), all three push policies, the leased cross-name rewrite under `strict` (allowed with the destination named by `--force-with-lease`, with or without a sha, from `HEAD` or the branch name, short or fully-qualified — and still asking without a lease, on a bare `--force-with-lease`, on a lease naming a different branch, on a protected destination, on a foreign source, on either deletion spelling, after `--no-force-with-lease`, on a tag ref, and everywhere under `protected`; plus the `dontAsk` pair the relaxation exists for, where the leased form allows and the unleased one denies), the push overlap check (`make_overlap_repo` builds a repo whose `origin/main` moved after the branch forked, writing the ref straight into `refs/remotes` so nothing touches a network — the same-line pair with its base-hasn't-moved control, the range axis at 4 lines apart vs 7 vs a different file, `--dry-run`/`-n`/`--delete` skipping, `protected`/`off` staying untouched, a protected target answered ahead of it, `dontAsk` and `auto` landing on the same deny with the reason pinned not to blame the permission mode, the `ENABLED` opt-out with both a true and a garbled control, release branches by default name and by configured glob with the extend-only case, `BASE_REF` unresolvable vs configured, `OVERLAP_IGNORE` crossed against whether the merge actually conflicts, a `git -C` skipping the probe, the chained `push && rm` that asks with its no-overlap control, the break-glass, which this verdict alone among push forms takes — the lift, the all-segments rule holding over it, and a cross-name push, a tag publish and a protected target in the same repo all still refusing, which is what stops the exception reading as "the override reaches pushes now" — the deny outranking a plain ask chained ahead of it, the reason's ordering (the rebase before the break-glass, asserted by truncating the reason at the prefix rather than by two `has` checks that would pass in either order), and the `--- ` parser case, which needs a *removed* line beginning with `--` plus a second hunk after it or the misfiling can't show — that one was a mutation-run survivor before it was a fixture), configurable protected branches via `BRANCH_GUARD_PROTECTED_BRANCHES` (glob spanning `/`, exact entry, case-sensitivity, extend-only so `main` survives any value, garbled input, the same set reaching the edit and push paths, and the `git branch` ownership tier withdrawing its recoverable-and-private auto-approve for a configured branch), tag publishes under `strict` (bare name / `refs/tags/…` / `--tags` → ask; `refs/heads/…` and `--follow-tags` still allow; both defer under `protected`), the non-interactive-mode `ask`→`deny` conversion under `dontAsk`/`bypassPermissions` — each paired with the `auto` control that asks instead, across the push, commit, edit, destructive and break-glass paths, since a suite that only pinned the deny would pass with `auto` back in the set — and the `BRANCH_GUARD_OVERRIDE` break-glass (the `dontAsk` pair it exists for, where the bare `git restore` denies and the prefixed one allows; the whole local-loss tier lifting; a required non-empty reason; the name as a positional or in an `echo` disarming nothing; both locks crossed — every `gh` form and every push the policy gated on its own account refusing to lift, and `reset --hard`/`branch -D` refusing on a protected *and* a configured branch while an overridable subcommand on a private one allows; the three `is_overridable` exclusions; the all-segments rule holding for an overridden allow; and the reason wording, where the allow keeps the cause and echoes the reason, a liftable deny names the prefix, and an unliftable deny and every interactive ask do not). Reason *wording* is asserted too (`reason_for` + `check_text`): the shared cause survives both paths, the `ask` ends in "confirm before proceeding", and the `deny` never does. A third helper, `check_prefix`, asserts by *position* what `check_text` cannot: every `deny` and every `ask` opens `branch-guard: ` — both across the push, commit and edit call sites, since the prefix is a property of `emit()` rather than of one of them — while `allow` carries no prefix at all, asserted on both the safe-op and break-glass allows. Both halves are load-bearing, and the second is the one a suite is likely to skip: without it the same fixtures pass with the prefix on every decision, which is a different contract. Position is the whole assertion, because the deny wording this replaced also contained the guard's name, just not at the front — a `has` check would have passed on it unchanged, and on the ask side a `has` check cannot tell an attributed reason from an unattributed one either, since the cause may quote a branch called anything. The unprefixed `allow` is the control against "prefix everything". `decision_for`/`reason_for` pass any trailing `NAME=value` args through `env "$@"`, so a case can set several vars at once and a value may contain a glob (`release/*`) without the shell expanding it; the harness sets `BRANCH_GUARD_PUSH_POLICY`/`BRANCH_GUARD_PROTECTED_BRANCHES`/`permission_mode` per case (and `unset`s the policy up top for hermeticity), and parses the hook's JSON with `jq`, so `jq` is a test-only dependency.

Every fixture reaches the hook through `hooks/run-python-hook.cmd branch-guard.py` — the exact command `hooks/hooks.json` registers — rather than through a bare interpreter, so the launcher is covered by every case instead of by nothing. That is asserted directly: one fixture greps the suite for any invocation of the hook outside `$LAUNCHER` and another pins that both helpers (`decision_for`/`reason_for`) go through it, so a new fixture that shells out to the interpreter fails loudly and names its own line. This replaced an exact case count written in this sentence, which was worse on both counts — it raced (the suite's size is global, but every branch validated it against its own base, so two fixture-adding PRs that each bumped correctly left `main` wrong by the second one's delta, which is how `main` shipped 316 cases documented as 310), and it never checked launcher coverage at all, since a fixture calling the interpreter directly increments a count just as happily. What survives is a `CASE_FLOOR` in `test/run.sh`: a lower bound catches the one thing the count genuinely caught — a suite that silently collapses because setup failed or a section exited early — and conflicts with nobody. Keep it that way: a launcher regression (a stray CRLF, a batch-label typo, a dropped exec bit) otherwise passes every CI job while every real hook invocation fails, and Claude Code treats a failed `PreToolUse` hook as non-blocking — the guard would look installed and enforce nothing. The harness no longer probes for an interpreter itself; the launcher does that, and reports loudly on stderr when nothing works. A startup check aborts the run early when the launcher's mode is wrong, because a lost exec bit would otherwise surface as the majority of the suite failing (a launcher that dies emits nothing, which the harness reads as a legitimate defer — so the `none`-expecting cases still "pass"). It asserts the mode **git records** (`git ls-files -s`), not the filesystem bit: `test -x` fails on Windows, which checks out under `core.filemode=false` and does not mark a `.cmd` executable, so it would flag a problem that cannot exist on the platforms where mode is load-bearing. The recorded mode is what a fresh clone inherits and is checkable from anywhere. A second startup check reads `hooks.json`'s `timeout` and requires a plausible seconds value (1..600, the command-hook default being 600): Claude Code reads that field in seconds, so the `10000` the file shipped with was a 2.7-hour ceiling that read as 10 seconds, and nothing in the file names the unit. The hook shells out to `git`, so a wedged repo is a real hang — `run_git`'s 5s per-probe cap is the inner bound and this is the outer one.

When changing the classifier (`classify_git`/`classify_gh`, the allowlist sets), the push logic, or the protected-branch set, add the case that motivated the change as a fixture, and hand-exercise the behavior table in `README.md` against the change before committing. New SPEC-style rules are easy to get subtly wrong — actually run `./test/run.sh`.

**When a decision turns on more than one question, cross them — covering each axis separately proves nothing about the cells.** `classify_branch` answers two (is the target shared, is it recoverable), and the suite covered both: every protected assertion used a force spelling, every non-force case named an unprotected branch. Neither axis had a hole, the uncrossed cell did, and `git branch -d main` auto-approved through it with 268 cases green. Enumerate the product — each gated form × protected/unprotected × recoverable/irrecoverable — and write the cells that look too obvious to bother with, because that is exactly where the last one hid. The same rule applies to any classifier here that reads two independent signals (a push's target × policy, an edit's branch × gitignored status).

**A fixture that depends on config needs its unset control right beside it.** `[configured] git branch -d release/1.2 -> ask` on its own isolates nothing: a form that asked unconditionally would satisfy it, and so would an `ask` arriving from anywhere other than `BRANCH_GUARD_PROTECTED_BRANCHES`. The pair is what pins the config as the cause, which is why `commit`, the edit path, `push`, and `branch -D` all carry both halves.

CI runs the suite on the Linux Python matrix and once on `windows-latest` under Git Bash (`.github/workflows/tests.yml`). Windows is where path handling diverges — `ntpath` reads a leading slash as drive-relative, so an MSYS-shaped path (`/d/a/repo/…`) resolves onto the hook process's drive and `git -C` misses, turning a protected-branch `ask` into no decision at all. Two harness rules keep the fixtures honest there, and a new fixture that names a path must follow both: build the payload with `edit_payload`/`bash_payload` so `jq` json-encodes the path (a raw `printf` would let a backslash read as a JSON escape), and pass the path through `nat` (`cygpath -w` on Windows, identity elsewhere) so the hook sees the native form Claude Code actually sends. A native path interpolated into a *command* string is also single-quoted, because `shlex` eats an unquoted backslash exactly as bash does. Windows is also the only ground truth for the cmd.exe half of the launcher: Git Bash hands a `.cmd` to the Windows command processor, so the batch branch is what runs there and the POSIX tail is what runs on the Linux matrix — between the two jobs every fixture covers both halves. That split is asserted rather than assumed: a fixture feeds the launcher a missing script name and checks both that it fails loudly (exit 1, "script not found" — silence would read as a legitimate defer) and which path form comes back, since `%~dp0` yields backslashes and the POSIX `pwd` yields forward slashes. If the routing ever flips, the cmd.exe half would lose its only coverage without a single decision fixture going red.

## Commits

- Commit after each task is complete and validated.
- Use small, focused commits following the Conventional Commits standard.
- Amending an unpushed commit is fine — fix up the message or staged changes before pushing without asking. Once a commit is pushed, prefer a follow-up commit; only amend + force-push (always `--force-with-lease`, never on `main`/`master`) when the user asks for it.
- After pushing, check whether a PR exists (`gh pr view`). If one does, update its description with `gh pr edit` to reflect any new commits.
- If a change doesn't belong in the current PR, open a separate PR for it. Working multiple PRs in parallel is fine and preferable to bundling unrelated concerns.

## Documentation conventions

Human-facing docs (`README.md` and anything user-facing) must never link to `CLAUDE.md` or `AGENTS.md`. This file is the entrypoint for Claude/agents only; humans start at `README.md`. The dependency direction is one-way: `CLAUDE.md` may link out to `README.md` and other docs, but nothing user-facing may link back to it.

## Agent reference docs

| Task | Reference |
|---|---|
| Changing which git/gh commands auto-allow vs ask (the classifier) | `hooks/branch-guard.py` (`classify_git`/`classify_gh`, `READONLY_GIT`/`READONLY_GH`) + `README.md` behavior table |
| Changing the decision logic, git/push detection, or protected-branch set | `hooks/branch-guard.py` + `README.md` behavior & push-guard tables |
| Changing which branches are protected or the `BRANCH_GUARD_PROTECTED_BRANCHES` config | `hooks/branch-guard.py` (`protected_patterns`/`is_protected`/`DEFAULT_PROTECTED_BRANCHES`) + `README.md` "Configuration" section |
| Changing push-guard policies or the `BRANCH_GUARD_PUSH_POLICY` config | `hooks/branch-guard.py` (`push_decision`/`push_policy`/`PUSH_POLICIES`) + `README.md` "Push guard" section |
| Changing the push overlap check or its config | `hooks/branch-guard.py` (`push_overlap`/`parse_hunks`/`ranges_meet`/`is_release_branch`/`overlap_ignored`/`base_ref`) + `README.md` "Push guard" and "Configuration" sections |
| Changing what the `BRANCH_GUARD_OVERRIDE` break-glass can lift | `hooks/branch-guard.py` (`OVERRIDABLE_GIT`/`override_reason`/`is_overridable`, the verdict tags `is_overridable` accepts, and the `ask-shared` tag on every `is_protected` ask) + `README.md` "Break-glass" section |
| Changing what a denial tells the caller to run instead | `hooks/branch-guard.py` (`DENY_ROUTES`, keyed by verdict, plus `refuse()` for the break-glass clause) + `README.md` "What it does" outcomes and the section owning that verdict |
| Hook registration / matcher | `hooks/hooks.json` |
| Plugin packaging / marketplace listing | `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` |
| Testing the decision matrix | `test/run.sh` |
| Rendering or regenerating brand images (social preview, favicon) | `../../docs/development/rendering-images.md` — one procedure for all six asset directories |
| Cutting a release (version bump, tag, GitHub Release) | `docs/development/release-process.md` |
