Stuck playbook — operational escape-hatches
Load-on-trigger reference for the moments Claude gets stuck on himmel’s own
guardrails: an auto-mode write was denied, a Bash command fell through to the
classifier, a permission prompt denied a command, or a pre-push gate failed.
These are operational/recovery rules — they used to live in the root
CLAUDE.md but were migrated here because they are not session-time frame-shaping:
they only matter at the moment of the symptom, and paying for them on every
session is the bloat the 4 CLAUDE.md rules
warn against. The himmel-ops:stuck-playbook skill surfaces this file when the
stuck condition is detected; it carries zero always-on token cost otherwise.
First principle: prefer a structural fix over a workaround. If a
shape keeps getting blocked, the fix is usually a hook/CLI change (a new
auto-approve-safe-bash case, a CLI flag), not a cleverer command that dodges
the classifier. The cases below are the residual judgment calls after the
structural fixes are exhausted. Never reshape a command to dodge a guardrail —
if a write is denied after following this playbook, defer to the operator.
The one carve-out is a review-thread resolve or reply: it is verifiable
against the PR head, so a worker sends it to the chief as
BLOCKED resolve <pr> <thread ids> and the chief verifies and resolves it
itself, never handing it to the operator.
Symptom: a Bash command is stopped by a permission prompt
Claude Code’s native permission matcher bails out and PROMPTS on any command
containing variable expansion ($t), command substitution $(…), backticks, or
compound operators — it never reads the allow-list. Interactively that prompt
renders and waits. Headless/auto it does not hang: an undocumented ~3 s stdin
watchdog (“no stdin data received in 3s, proceeding without it”) drops the
session to non-interactive, and a prompt that cannot render resolves as a DENY
at rc=0 — the session continues (subtype:success, is_error:false) with the
action silently not performed. 4/4 live probes (2026-08-22) denied
that way, across three shapes: -p --permission-mode manual, -p --permission-mode auto, and a bare claude "<prompt>" with no -p on a
never-delivering stdin pipe (the arm-resume/schtasks shape). The silent no-op —
not a hang — is the failure mode to design for: verify the ARTIFACT, never the
return code (the artifact-check rule).
The auto-approve-safe-bash PreToolUse hook auto-grants read-only/inspection
commands plus the allow-listed Jira CLI (incl. such loops/pipes with $var that
defeat the native matcher) as the structural fix. It deliberately does not
cover other writes, interpreters (sed/awk/bare node/…), or git/gh
write subcommands.
What to do: for anything the hook doesn’t grant, prefer literal single
commands (no $var / $(…) / loops) so the allow-list can match. If a write
genuinely needs them and prompts, that prompt is correct — defer to the
operator, don’t reshape to dodge it.
Symptom: a worker needs a tracked file back at HEAD and git checkout -- <path> is denied
Every TDD RED control needs a way to restore a dirtied tracked file to HEAD
before implementing, but Bash(git checkout -- *) is a deny entry in
.claude/settings.json (a deny beats any allow, so an allow rule cannot fix
this) and git restore matches no rule at all, so it falls to the classifier
and resolves as a silent headless DENY. N158 and N159 each lost
several turns to this on 2026-09-12.
What to do: bash scripts/git/restore-to-head.sh <path> [<path>...] —
one literal command, no cd/$()/compound operators. It refuses globs,
untracked paths, directories, and paths outside the current worktree, and it
saves the outgoing content for every dirty path — a plain copy, not a diff —
to a fresh ${TMPDIR:-/tmp}/restore-to-head.XXXXXX/ directory before
restoring. To recover, use the run directory and file number recorded in MANIFEST:
Regular file: rm -f <path> && cp -p <RUN_DIR>/<n>.worktree <path>
Symlink: cp -RPp <RUN_DIR>/<n>.worktree <path>
Staged content: run from the repository root, replacing the three placeholders
below (path is root-relative). Read the saved mode and blob ID, recreating the
blob from the backup if it is no longer in the object database:
path='<path>'run_dir='<RUN_DIR>'n=<n>read -r mode sha stage saved_path < "$run_dir/$n.index-mode" &&{ git cat-file -e "$sha" 2>/dev/null || sha=$(git hash-object -w --no-filters "$run_dir/$n.index"); } &&case "$mode" in 100644|100755) rm -f -- "$path" && cp -p -- "$run_dir/$n.index" "$path" && chmod -- "${mode#100}" "$path" ;; 120000) : ;;esac &&git update-index --add --cacheinfo "$mode,$sha,$path"Regular entries (100644/100755) recover worktree bytes and permissions too. Symlink entries (120000) recover only the index; the worktree stays unchanged. Do not copy a symlink’s target-text blob into the worktree or run git add after this recipe: that would replace the recovered index type with the worktree type.
Never try bare git checkout -- <path> or git restore yourself to work around this.
Symptom: a Jira write fell through to the classifier and was DENIED
In auto-mode the auto-approve-safe-bash hook grants the Jira CLI wholesale —
reads AND writes (transition/comment/edit) — so ticket writes run
unattended (operator-trusted). The catch is command SHAPE, not
the write itself: the hook auto-approves only when every segment of a compound
command resolves to a recognised-safe binary.
cd … && node …/jira …works (cd/pushd/popdare in the safe-set).- A command-substitution (
$(…)) or an unrecognised leading binary makes the whole command fall through to the auto-mode classifier, which DENIES external-system writes.
What to do: prefer a literal node …/jira … (bare or cd-prefixed). A
shape denial → rewrite as one literal command per write (absolute binary, no
vars, no loops, body via file) and retry once; a classifier denial on a loop
shape is a shape denial (block-jira-compound-write.sh now
bounces loop, $J and xargs shapes itself with that recipe). If the single
literal write is still denied, that is not a shape problem — defer to the
operator (a review-thread write goes to the chief instead, above), do not reshape again to dodge the classifier. See
afk-mode.md § Auto-mode classifier &
attestation. Multi-line bodies: use --comment-file <path> / --desc-file <path> so the shell command stays single-line.
Symptom: a pre-push gate failed on a missing attestation trailer
Pre-push gates need attestation trailers (Platforms tested: <os> on
shell/script diffs; Security reviewed: <token> on non-docs code).
The rule is to put them in the FIRST commit after genuinely testing +
reviewing — never a reactive git commit --amend after a push fails. In
auto-mode the amend is flagged as gate-circumvention and HARD-blocked
(uncleable).
What to do if the branch is already pushed without the trailer: recut it.
The pre-push gate accepts a trailer in any commit of the range or the PR body,
but the chief’s ready-check.sh check 5 reads only the PR’s first
commit — a PR-body or later-commit trailer clears the push gate
and still FAILS ready-check, so the PR can never be merged by a chief. A
typical case: a docs-only first commit (which needs no trailer), then a later
commit adds a non-docs path (which does).
- Cut a fresh branch from
origin/main. - Make ONE ordinary new commit with the whole change, carrying the
Platforms tested:/Security reviewed:trailers, each on its own line. Notgit commit --amend, and not agit commit-treerebuild of the old first commit (both are classifier-vetoed). - Push, open a new PR, and close the old one with a pointer to it.
Proven in practice: #1650 was recut as #1654, which passed ready-check and
merged. test-ready-check.sh § 5e pins check 5’s first-commit-only rule, so
this recovery and the gate agree.
What to do if the FIRST commit carries a trailer whose token doesn’t
conform — a genuine Security reviewed: line whose text doesn’t start with
manual / claude-code-security-review / pr-review-toolkit / ad-hoc
(scripts/hooks/check-security-reviewed.sh’s TOKEN_RE): the
sanctioned recovery is a follow-up commit carrying a conforming trailer
line — never git commit --amend, never a git commit-tree rebuild of the
first commit; both are classifier-vetoed.
Symptom: a PR merge was blocked (--admin / approval gate)
Overnight Phase 9 used to merge with gh pr merge --squash --admin. --admin
exists to bypass branch protection — but this repo has none (the protection
API returns 403 on a free private repo; PR reviewDecision is empty). So
--admin bypasses nothing useful, yet the auto-mode classifier reads it as
“bypassing the approval gate = destructive op outside the worktree” and
HARD-vetoes it. That stalled a run outright and forced an
explicit in-session authorization.
What to do:
- Merge plain.
gh pr merge <N> --squash(no--admin). With no branch protection this just works — verified on PR #225. Thescripts/handover/ pr-merge.shhelper already does plain-first. --adminis a fallback for REAL branch protection only. If (and only if) the plain merge fails because the repo actually has protection, setGH_ADMIN_MERGE_OK=1in the launching shell (GH_ADMIN_MERGE_OK=1 claude— a per-call prefix does not reach the hook) and re-run;pr-merge.shthen retries with--admin.- If still blocked, defer to the operator as a one-action handover (“merge
PR #N”). Do NOT retry the merge via a different command path — the
classifier flags that as evasion and hardens the block (first
principle: structural fix over workaround — here the structural fix is
not emitting
--admin, already shipped inpr-merge.sh+ AFK-mode Phase 9).
The model-based classifier is an Anthropic layer; it cannot be made to read a
repo authorization file. The durable fix is to never emit an unnecessary
--admin so the veto never fires.
Symptom: merge-on-green.sh exits 22 — “main-red gate: main’s latest completed CI run … is failure”
merge-on-green.sh refuses while the newest completed run of the CI
workflow on the default branch is red (cancelled and skipped runs are skipped;
it falls back to the next older completed one), unless the PR is the declared
fix. Eight merges once landed on an already-red main and turned 3 root breaks
into 11 red-main merges. Main CI is a cron/dispatch sweep, so
the run covers a range; the message names it (<previous sweep head>..<red head>). The PR’s own green rollup is not the question here: main is.
What to do:
- Read the named run (
gh run view <id> --repo <owner>/<repo>or--log-failed) and decide whether your PR is the fix or merely unrelated. - Your PR is the fix: add a commit-message line, in any commit of the PR,
Fixes-main-red: run=<red run id>, orFixes-main-red: suite=<name>where<name>(4+ chars of[A-Za-z0-9._-]) is a failed job of that run (the whole job name or its matrix base name, e.g.shell-unit-shard; never a fragment), or any.shscript named on a##[error]line of its failed log. A declaration is read up to the first whitespace, so a job whose name contains spaces cannot be declared bysuite=: declarerun=<red run id>instead. It is verified against the run, never trusted: a wrong run id or an unrelated name is refused with the same exit 22. Trailers belong in the FIRST commit; a late declaration costs a new commit, never--amend. Or the chief writes a fix-GO, when a commit line is not an option:bash <anchor>/scripts/handover/chief-kit/go.sh --fix-main-red <red run id> <pr> <full head sha>, from the chief (a worker cannot; it needs the same ready-check PASS as any GO). The signed file names ONE red run, one PR and one head, and the gate accepts it only while that run is still main’s latest completed red run: after another sweep goes red, or the PR head moves, write a new one. It replaces only this declaration; the ordinary GO file (merge approval) (and a trust-reviewed GO on a trust path) is still required. - Your PR is unrelated: do not merge. Report
MAIN-RED <job> <case> caused-by: #<pr>(caused-by: unknownwhen no PR in the range can be named) to the chief (or confirm one is open), and merge after the fix lands. Main stays “red” until the next cron sweep or a manualgh workflow run ci.yml --ref maincompletes green; dispatching one is the quickest way to clear the gate once the fix is on main. - Exit 22 with “unreadable”:
gh run list, the PR’s commits or the red run’s failed log could not be read (rate limit, outage). Unverified is refused: retry shortly; never edit the gate to skip it.
Decided case: when the window holds no completed non-cancelled run at all
(new repo, or the workflow never ran on main), nothing red was observed and the
merge proceeds with an audit line main-red=no-completed-run.
Symptom: /worktree refuses the branch, or a stale worktree lingers
scripts/worktree.sh (behind /worktree, /clean, /clean_garden) is the
one script behind all three. Two refusals surprise people:
- “PR already MERGED” —
/worktreerefuses to re-create a worktree on a branch whose PR is merged, because the usual cause is a stale resume that would re-open closed work. Deliberate reuse (rare — e.g. a follow-up that must keep the branch name) bypasses with the session env varREUSE_MERGED_BRANCH_OK=1, set in the LAUNCHING shell like every other hook bypass. Prefer a freshtype/slugbranch first; the bypass is the exception. - Lingering merged-PR worktrees —
/himmel-doctorcheck C7 flags them read-only and points at/clean. There is no--fixfor C7 by design: pruning a worktree can discard uncommitted work, so it stays an explicit/cleanrun.
Superseded commands (do not use; they still exist in older transcripts and
handovers): /new-worktree → /worktree, /clean_gone → /clean. The branch
name must be type/slug with type in feat|fix|chore|docs|refactor|test.
Symptom: git commit/git push to main is refused even with --no-verify
A reference-transaction hook (scripts/hooks/check-main-ref-transaction.sh,
installed by scripts/hooks/install-main-ref-transaction.sh) refuses any
update to refs/heads/main whose new commit is not already an ancestor of
refs/remotes/origin/main. It exists because d05ef8ca (and three earlier
instances) landed directly on private main via git commit --no-verify then
git push --no-verify — that skips pre-commit, commit-msg AND pre-push
wholesale, so no hook staged in those events could refuse it.
reference-transaction is not one of the stages --no-verify disables,
so --no-verify no longer works for this class of bypass. The refusal looks
like:
⛔ check-main-ref-transaction: refusing to update refs/heads/main -- the newcommit is NOT already an ancestor of refs/remotes/origin/main: refs/heads/main: <old-oid> -> <new-oid>...fatal: in 'prepared' phase, update aborted by the reference-transaction hookWhat to do: make the commit on a type/slug branch and open a PR instead:
/worktree fix/<slug> # or feat|chore|docs|refactor|testcd .claude/worktrees/<branch-name> # commit thereFast-forwards to already-published history (git pull --ff-only, git fetch
git merge --ff-only,git reset --hard origin/main) are unaffected — only a genuinely NEW local commit on main is refused.
Sanctioned one-off escape (leaves a trail): MAIN_REF_TRANSACTION_OK=1,
set as a per-command prefix (this hook is a direct child of git commit/git push, so — unlike EDIT_ON_MAIN_OK — a per-call prefix DOES
reach it):
MAIN_REF_TRANSACTION_OK=1 git commit --no-verify -m "..."Using it appends one line to <git-common-dir>/main-ref-overrides.log
(shared by every linked worktree) and prints a warning to stderr — use it
deliberately, not to dodge the guard routinely. A repo that commits straight
to main by design (a personal vault, a state repo) opts out permanently the
same way as every other main-branch guard: touch <repo-root>/.single-writer.
Symptom: every Write/Edit under /tmp is refused with “cannot determine branch for ‘/tmp’”
block-edit-on-main walks UP from the edited path looking for a .git to
determine the current branch; a stray .git anywhere in an ancestor of
that path — not the file being edited — makes it treat that ancestor as a
repo root. Incident 2026-09-07: a subagent’s fixture work ran git init
relative to an inherited (unset/broken) cwd and it landed at /tmp, leaving
an EMPTY /tmp/.git behind. Every session’s scratchpad, commit-message file,
and temp fixture on the station then failed closed with:
cannot determine branch for '/tmp' - refusing to evaluatehimmel-doctor’s C36-stray-tmp-git check catches this now
— WARN naming the exact rmdir remedy when the stray .git is empty, or
naming it and stopping (never deleting content) when it is not.
What to do: diagnose by walking up from the target path looking for any
.git:
d=<path-being-edited>; while [ "$d" != / ]; do [ -e "$d/.git" ] && echo "$d/.git"; d=$(dirname "$d"); doneIf the found .git is empty, rmdir it. If it is not empty, stop
and name it — do not delete its contents. Never git init relative to an
inherited cwd for a fixture repo; create fixture repos under a mktemp -d
you own and git init only on that absolute path you just created.
Symptom: an ad-hoc Bash line containing the literal word “enable” is refused in a worktree-pinned session
Once a session is pinned to a worktree (EnterWorktree), the harness’s own
worktree-isolation guard refuses non-trivial Bash commands whose text
contains the literal substring enable — confirmed independent of git
involvement (a plain grep -rn "enable" ... is refused the same way a git grep -n "enable" ... is; a bare echo enable-test is not). This is a
harness-level guard, not a scripts/hooks/*.sh script — git grep -n "enable" scripts/hooks/*.sh finds no himmel-authored match for it.
What to do: this playbook’s own first-principle rule applies as-is here — do NOT reshape the command text to dodge the scan. Rephrase the task to avoid needing the literal word in a non-trivial command where possible (e.g. a narrower search that doesn’t need to name it), or defer to the operator. Two refusals of the same literal shape is this playbook’s own trigger — don’t keep retrying it verbatim. If this guard is genuinely over-broad (it matches on innocuous read-only commands with no connection to enabling/disabling a guardrail), that is a structural bug in the harness worth reporting, not a standing workaround to normalize.
Symptom: every git call is refused — “runs rtk with a git command among its operands” — for the rest of one worker’s session
A worktree-pinned worker that has been running git normally can reach a state
where its git Bash calls are refused. Observed 2026-09-20, worker N198.
The text as recorded — both ... are elisions in the record (the
full message was not preserved), and the worktree path is the worker’s own:
This session is isolated in the worktree .../fix+himmel-3279-durable-launch-context, but this command runs rtk with a git command among its operands: what runs it, and from which directory or root, cannot be read here ... Refusing to run itString-match on runs rtk with a git command among its operands. git grep
finds no himmel-authored match for this text, so it is not one of
scripts/hooks/*.sh: it is Claude Code’s own worktree-isolation screen, which
statically reads each Bash command line of an EnterWorktree-pinned session
(the same screen emits redirects git to the shared checkout via -C, which is a correct refusal and stays one).
Observed refused: git diff -U0 <file>, git diff -U0 -- <file> and a bare
git status --short. Earlier in the same session git grep and git log ran
fine — partial function is not evidence against this symptom: a worker where
git log works while git status / diff / commit are refused has it.
Why it happens. The rtk PreToolUse hook rewrites git … to rtk git …,
and the isolation screen cannot establish the executable behind rtk git.
Measured over the local transcripts: 0 of 8701 rtk-rewritten git
calls were refused in sessions that are not EnterWorktree-pinned, 112 of 391
in pinned ones — and inconsistently within a pinned session (the same command
shapes flip between ok and refused; nothing in the session state, cwd or hook
order separates them). Plain git on the session’s own worktree is never
refused, and neither is git grep/merge-base/rev-parse, which rtk does not
rewrite. The same screen also refuses rtk gh / rtk ssh / rtk shellcheck
when git appears in their text (10 cases).
The fix. scripts/hooks/rtk-hook-guard.sh forwards no rtk
rewrite that names git as a word when the hook payload’s cwd is inside a
.claude/worktrees/ tree (and in the claudex lane), so the
harness sees plain git. Nothing hook-visible marks a session as pinned, hence
the cwd proxy; the cost is that rtk’s git output compression is also lost in
unpinned worktree sessions. It takes effect once the checkout the user-scope
hook runs from has this change. A worker that typed rtk git … or rtk proxy git …
itself is refused by the same screen — type plain git; that removes a wrapper
you added, it does not reshape the operation.
It is session-scoped — established by elimination, not by diagnosis. Two controls, the second the strong one:
- N197b ran a bare
git status --shortat rc=0 in a different worktree and branch, and went on to commit, push and open PR #986. (N197 had committed4b27fcf73fe6f31654f596f8ee70488243f62f28the same hour, same station, same profile.) - N198b ran bare
git(status,log,merge-base,commit,push) at rc=0 in the same worktree, on the same branch, with the same uncommitted tree where N198 was refused everything, and shipped that tree to PR #987 without touching the implementation.
Holding worktree, branch, tree state, profile, station and launcher constant and varying only the session rules all of those out and leaves the session. It does not identify what in the session. One candidate — the auto-mode classifier reads the session narrative, and N198’s had dwelt on launch-record directories and cache roots, the vocabulary the refusal cites (“from which directory or root”) — leads only because the alternatives are gone; it has had no positive test. Do not act on it as if it were the cause.
It is rare. On 2026-09-20 one session was refused against four other native
workers running git unrefused (N197, N197b, N198b, N200) — a day’s sample, not a
rate. A worker that hits it has hit something uncommon, not a routine gate; the
cause is not identified, so do not spend the session diagnosing your own conduct.
What to do — the worker:
- Stop at the first refusal carrying this text. Unlike the entries above, do not wait for a second: N198 tried three spellings and none cleared it, and nothing shows a spelling can.
- This playbook’s first-principle rule applies as-is: do NOT reshape the command, and do not hunt for a spelling, a wrapper or another tool that slips the same operation past the scan. That is not a recovery and must not become a standing recipe.
BLOCKEDto the chief bySendMessage(it needs no git). The work is safe only on disk in the worktree — uncommitted, unpushed, held nowhere else — so touch nothing further and do not prune or remove the worktree.- Preserve what the diff cannot carry in that message before you wrap: the decisions and why, the alternatives you rejected, each suite run with its count and any pasted RED, claims you have not verified, the ship steps still owed, your worktree path, branch and base sha, and your queue-lock doc and release token (in backticks). Your own handover doc is under your lock, so the chief cannot write there — the message is the channel.
What to do — the chief:
- Never run the worker’s refused git command on its behalf. That is permission
laundering, exactly as for
[Out-of-Place Publication]above. - Write the worker’s message to a chief-owned file in the state repo (the
N198 record,
<TICKET>-N198-BLOCKED-state-2026-09-20.md, is the shape) so the reasoning survives the worker’s transcript. - Recover by re-dispatching a fresh worker onto the identical worktree: same path, branch and base, the state file in its brief. Release the stale lock with the worker’s token first, and close the stuck worker’s window (it still holds a fleet slot). The fresh worker faces the same guards, so this is a re-dispatch, not a dodge — a new session is the one thing that varied in the controls. It has been exercised end to end: N198 → N198b → PR #987. Have the fresh worker re-run the verification it will attest to; the prior worker’s greens are a report, not evidence in the new session.
- If a fresh worker on the same worktree is refused with the same text, the session-scoped reading is wrong — that is structural. Stop, note it on the ticket, and take it to the operator.
Settled: the suppression now covers a native worker whose cwd is in
.claude/worktrees/ (see above), so a hook-produced rtk git no longer reaches
the screen there. The session-scoped reading above was the right observation
with the wrong variable: what the “session rules” varied was the EnterWorktree
pin. A worktree outside .claude/worktrees/ (git worktree add ../x) keeps the
rewrite and can still hit this.
Not settled: a blocked ship tail has no recovery cheaper than the full
re-dispatch above — none was found, and a worker still refused after the fix means
the hook is not the source (a stale or unwired rtk-hook-guard.sh, or a
hand-typed rtk), which the chief should check before re-dispatching. The
screen’s inconsistency inside a pinned session is Claude Code’s, not himmel’s,
and is unreported upstream.
Symptom: /pr-check step 0’s canonical fence is refused — “runs bash in a plain command … cannot be shown not to run git”
The worker called EnterWorktree. That refusal comes from the worktree-isolation
screen, which reads only EnterWorktree-pinned sessions: the fence runs the
HIMMEL_REPO anchor’s scripts/cr/pr-check-context.sh, outside the pin, so
the screen refuses it. The operator’s ! in that window is refused too. A worker
whose diff touches scripts/cr/ cannot fall back to the bare relative literal,
because guard-pr-check-literal correctly refuses branch bytes. Before the fix,
N376 and N386 each sat BLOCKED for 10-12 h on this (2026-09-23).
A worker that cds into its worktree without the pin runs the exact fence
unrefused (measured in N418: rc=0, anchor_lane=himmel). The anchor still
decides whether to hand off to the branch’s copy, so nothing about the anchor
trust changes.
The fix. headed-arm-worker.sh writes permissions.deny: ["EnterWorktree"]
into every profiled worker’s generated <name>.leg-settings.json (all lanes,
not the relay). The tool is then absent from the worker’s tool list. A worker works
in its worktree by cd <worktree path> from its launch directory. Writes into
the primary stay fenced without the pin: block-edit-on-main and
block-write-into-main-checkout resolve the target path, and
check-worktree-isolation checks the branch at commit time.
A worker launched before the fix that is already pinned: BLOCKED to the
chief. The chief runs the canonical fence from its own unpinned session
with cwd set to the worker’s worktree, and hands the worker the output.
Symptom: a denial right after a quoted, prefixed, or piped spelling of a ship step
bash "scripts/cr/X.sh" (quoted), bash "$HIMMEL_REPO/scripts/cr/X.sh"
(variable-prefixed), cd <worktree> && bash scripts/..., or
printf ... | bash "..." all miss the allow rule on an otherwise-correct
call — the rule matches text, and none of these shapes is the text it was
written for (design doc §3.1, §3.4). The denial is real, but the command was
never the problem.
What to do: retype the same script call as the bare literal its
allow rule spells out — unquoted, no $VAR, no cd … && prefix, no pipe
into the gate script itself (write the input to a file first). That is the
one permitted retry (docs/handover/worker-sections/shell-and-tests.md). If the
bare literal is still denied, this is not a spelling problem: stop, do not
try a third spelling, and go BLOCKED to the chief.
Symptom: a late fix after pre-commit run gets gated against stale (pre-edit) content
Plain pre-commit run <hook> (no --all-files, no --files) stashes
every unstaged file in the repo, not just the ones it is linting, and
lints the staged index — full detail:
environment-gotchas.md.
A fix made after a gate run but before re-staging is invisible to the next
plain pre-commit run — it re-tests the stale staged blob and reports
against pre-edit content. pre-commit run --all-files does not stash —
it reads the working tree directly, so this staleness trap does not apply to
it.
What to do: with plain pre-commit run, git add the fixed path(s)
again before re-running the gate. Run git status --short first if fixing
several files, to confirm every edit you expect is actually staged before
the re-run.
Symptom: a new test suite’s one-line invocation is refused as a recursive-delete deny
A one-line bash <suite> … | grep … | tail can get refused even though the
line names no delete flag of its own: many suites’ standard cleanup trap
(rm -rf "$TMPDIR" or similar) lives in the suite’s own source, and something
reads that trap text when the suite is unfamiliar and the invocation is
compound (piped/redirected). Confirmed once (N125’s
test-finding-reraise.sh); the same suite ran clean when invoked through
quiet-run.sh. Since #744 this can no longer be
block-destructive-commands.sh itself — that hook anchors its recursive-rm
checks on command position over the actual command string and never reads a
grep pattern or a heredoc BODY, so it cannot see into a suite’s own source at
all; this symptom now names the auto-mode classifier, not
this hook. Tell them apart by the deny text: recursive rm (or another
short, self-named reason) is this hook, deterministic; text quoting script
content, or the literal Stage 2 classifier error, is the classifier.
What to do: run every suite — new or old — only through bash scripts/quiet-run.sh suite -- bash <suite> as one literal command, never
a hand-rolled compound. This is already the sanctioned shape in every worker
brief. The label is the literal suite: only that label is
auto-allowed, so any other label goes to the classifier.
Symptom: a message or log bullet quoting a guarded flag is refused by block-destructive-commands
inbox-send.sh <session> '<text>' and a printf-built log bullet are Bash
commands like any other, so block-destructive-commands inspects the whole
command string, including a quoted TEXT argument — not just the binary
being invoked. Quoting a guarded flag (e.g. naming the force-push flag, or
echoing a suite’s rm -rf cleanup line) gets refused even though the flag
never runs. Confirmed twice: a worker’s inbox-send.sh
call and a chief’s printf log bullet, both denied for quoting text only.
What to do: write the text with the Write tool first, then pass it by
path — inbox-send.sh <session> --file <path> for a message, cat >> from a
Write-tool file for a doc/log bullet — never inline a guarded spelling into a
Bash argv. Treat --file as the default for any inbox-send.sh message
longer than one line or quoting any command, not a fallback for when the bare
form fails.
Symptom: an outward-facing command (gh pr create / gh pr comment / git push) is denied with Stage 2 classifier error
Stage 2 classifier error - blocking based on stage 1 assessment (usually transient — retrying often succeeds) reads like a plain retry hint, but a
verbatim retry sent back-to-back is itself a signal the classifier weighs — a
denied call reads as the user having declined it, not as noise to resend
unchanged — and an outward-facing publish step sits in the strictest bucket,
so an identical immediate retry can escalate rather than clear (see the
[Out-of-Place Publication] row below). This sharpens the two-refusal rule
above for publish steps specifically: the first denial is not free to retry
unconditionally, even once.
This is not the shape-evasion the playbook’s opening principle forbids. That principle is about disguising the SAME content from the classifier by reshaping the command around it — the content here is unchanged (the PR/ comment/push body is not edited to read differently); only the delay and the intervening read change, and those exist to remove the one signal this row documents (an immediate, back-to-back, identical resubmission), not to hide anything from the check. It is also not open-ended: exactly one retry is permitted, a second denial escalates to the operator rather than trying a third shape — the escape valve the opening principle itself names as correct once a denial persists.
What to do: do not retry the identical command back-to-back. End the turn, or do one unrelated read, then retry ONCE, delayed, with the same head:
gh pr create/gh pr comment— pass the body via--body-fileinstead of inline, so the retry is not byte-identical to the denied call. Better still, first choice before any retry: publish withbash scripts/lanes/worker-pr-open.sh <title-file> <body-file>(ruling H2) — the body never enters the command at all, so there is nothing in the invocation for the classifier to react to.git push— there is no body flag to vary; the delay and the intervening read are themselves what makes the retry non-identical (a different point in time, not a reshaped invocation), so retry the exact samegit pushcommand once, not a contrived alternate spelling.
A second denial of any wording, on any of these, → stop, BLOCKED to the
chief with the exact denial text; the chief never runs the denied command
itself (permission laundering) — it routes it to the operator’s own shell or
the worker’s window via !.
Symptom: [Out-of-Place Publication] denial on a publish step
The escalated form of the row above: a harder denial that follows an
identical, immediate retry of a Stage 2 classifier error denial on an
outward-facing command. The PR/comment/push body itself may be entirely clean
(no private paths, tokens, or session ids) — the trigger is the retry
pattern, not the content.
What to do: unlike the row above, this string earns no retry at all —
whether you are seeing it after your own delayed retry (the usual path) or as
the very first denial on this attempt. Stop immediately: BLOCKED to the
chief with the exact denial text; the chief never runs the denied command
itself (permission laundering) — it routes it to the operator’s own shell or
the worker’s window via !.
A worker’s writes to its own handover doc are a common source of
this symptom, since the resolved handover root sits outside the worker’s working
directories. headed-arm-worker.sh --profile now grants every generated worker
settings file (native and claudex) only the worker doc’s own directory as a
permissions.additionalDirectories entry — never the whole handover root —
so a Results write no longer falls to the classifier on a fresh launch. The
grant is scoped this narrowly because chief-kit/go.sh’s merge GO is
authenticated only by the existence of a file under
<handover_root>/.locks/go/<pr>.<sha> (go-gate.sh, merge-on-green.sh),
with no hook guarding that path: widening the grant to the root would let a
worker’s own Write auto-approve into .locks/, minting its own merge GO. As
defense in depth, the same profile block also denies
Edit/Write/MultiEdit/NotebookEdit on <handover_root>/.locks/** outright
(the relay half of a split chief is exempt, matching its existing
no-worktree-deny settings). A worker already running before its next launch
does not pick this up — the interim above still applies to it.
Symptom: a worker’s queue-lock.sh release is denied [Merge Without Review] right after its merge
HANDOVER_DIR=<root> bash scripts/handover/queue-lock.sh release <doc> <token>
acquired the lock at session start, but the same shape is denied after the
worker’s GO’d merge landed. The command is not a merge — it writes one lock dir
under the handover root and touches no git ref — but the auto-mode classifier
reads the session narrative (a merge just happened), not only the payload.
What to do:
auto-approve-safe-bashnow approves exactly this shape (any of acquire / release / heartbeat / status, absolute doc underHANDOVER_DIR, literal args) when it is the whole command. A denial therefore means the spelling drifted: a compound (&&/;/ a pipe in or out), an env prefix other thanHANDOVER_DIR=, a$VARin an argument, or a relative doc path — or, for the relativescripts/handover/queue-lock.shform, a session cwd that is not the root of a real checkout (a sub-directory or a lookalike dir: run it from the worktree root). Re-issue the one bare literal — do not add a second spelling.- Still denied (or a permission prompt) → stop and escalate:
BLOCKEDto the chief with the doc path and your release token. The chief releases with your token as ordinary lock administration. Never reshape the command, touch the lock file directly, or force-release yourself. - Verify with
status, not the return code:releasesucceeds silently (no output, rc 0).bash scripts/handover/queue-lock.sh status <doc>must report the lock free.
Symptom: check-ci.sh --pr N prints usage and stops — no gate ran
scripts/check-ci.sh takes the PR number positionally; an unknown flag
(--pr, --watch) makes it print usage and exit 64 (EX_USAGE —
it used to share exit 2 with a genuine cannot-evaluate, so a caller reading only
$? recorded a gate result for a gate that never ran), and it prints the
check-ci: verdict exit=64 marker line. A caller that swallows the rc (a pipe
gives the pipeline’s rc) must read that marker, not the usage text, before
calling it a gate that ran. The watcher flag is --max-wait, not --watch. A genuine
exit 0 also does not mean the
CodeRabbit App reviewed the head — the artifact is a review object with
bodylen>0 at the head SHA (see the CodeRabbit gate in
enforcement.md).
What to do: bash scripts/check-ci.sh <pr> and read its verdict exit=
line; a fast, quiet return from a command you expected to block is the red
flag, not the reassurance.
Symptom: a large heredoc is denied by the chokepoint guard, and nothing ran
A ~15 KB marker-dense heredoc (a doc body, a PR body) can exhaust the hook’s
evaluation budget; the guard fails closed, so the deny means nothing ran,
not that part of it did. A related shape: cd <worktree> && sed -i <relative>
resolves the relative path against the primary checkout, because the guard
reads the payload cwd, not the cd.
What to do: write the content to a file with the Write tool and run ONE
short literal command against it (--body-file, python3 <script>); for
edits, use absolute paths or a script file, never cd && with a relative path.
Symptom: wrap-subtree-check.sh prints WITHHELD: although every task is stopped
WITHHELD: lists live pids under the session. When the only survivors are the
harness’s own MCP servers (qmd, browser bridges) the subtree is clean — those
are not yours to TaskStop and they do not block WRAPPED. Re-run and paste the
CLOSABLE: line once nothing of yours remains. Related: headed-arm.sh polls
only for the handover doc’s existence — a placeholder file satisfies it,
so an arm that “went through” is not evidence the brief was in place.
Symptom: a background task “stopped because the system is running low on memory” at zero pressure
Observed only in workers: the same command exits 0 from the chief and
dies in the worker. All-zero /proc/pressure/memory (including
total=) and oom_kill 0 in the session cgroup’s memory.events mean there
is no evidence of contention; the kill is unexplained, not explained by
memory. The worker system prompt carries the exact reading recipe.
What to do: stop retrying in the background, run the suite in the
foreground, report BLOCKED with those numbers, and never name a cause you
have not controlled for.
Symptom: worker-burn.sh is denied [Session Transcript Tampering]
bash scripts/lanes/worker-burn.sh <session> only READS the session’s
transcript JSONL to price its token usage for the PR body’s worker-burn: line,
but the auto-mode classifier can read “a script opening session transcripts”
as tampering and deny it, twice in a row for the same worker (2026-09-24).
What to do: do not reshape the command or read the JSONL another way.
Report BLOCKED to the chief, which routes it to the operator, who runs
! bash scripts/lanes/worker-burn.sh <session> in the worker’s tab; paste the
printed line into the PR body. The rest of the ship flow is not blocked by
it, so keep working while you wait.
Symptom: vm-runner.sh on|off|status is denied by the classifier (HIMMEL-5037)
The VM CI runner’s only control entry point is
bash scripts/handover/chief-kit/vm-runner.sh on|off|status (optionally
on --vms 1 or on --vms 2), each pre-approved as one exact literal in
.claude/settings.json. A denial means that allow rule is missing or does not
match the text you typed (a quote, a VAR= prefix, an absolute path, an extra
flag, a cd … &&, a pipe). Never reshape the command, never call
scripts/vm/ci-runner.sh, VBoxManage or systemctl directly to get the same
effect, and never widen the rule to a wildcard.
What to do: retype the bare relative literal once from the worktree root.
If it is still denied, report BLOCKED to the chief naming the exact text
that was refused; the operator runs ! bash scripts/handover/chief-kit/vm-runner.sh <on|off|status>
at the station. off is the kill switch and is always safe to ask for.
Symptom: a commit failed and files I never touched came back changed
pre-commit install’s generated pre-commit hook stashes every unstaged
change to a tracked file to a patch (~/.cache/pre-commit/patch<N>-<pid>)
before hooks run, then reapplies it after. In a vault with Obsidian open,
Obsidian’s autosave can rewrite a tracked .obsidian/plugins/*/data.json
during that window, so the reapply no longer matches and fails; pre-commit
then rolls back — silently reverting OTHER unstaged files on disk, including
ones the committing session never touched. It is deterministic, not a race:
it reproduces on the immediate retry, and the commit itself reports exit 0.
A vault scaffolded from templates/luna-second-brain/ on or after 0.4.55
installs templates/luna-second-brain/scripts/hooks/install-nostash-hooks.sh’s wrappers instead, which
never stash (pre-commit run --files <staged> never invokes the stash at
all — pre_commit/commands/run.py’s stash = not args.all_files and not args.files). --files only selects which PATHNAMES run hooks; it does not
isolate staged CONTENT — a hook can still read or rewrite the unstaged
portion of a partially-staged file. So this specific stash/reapply race
cannot happen there; an older vault, or any other stashing
pre-commit install checkout, is still exposed.
The wrapper preserves the concurrent write, but it does not make the racing
commit succeed: pre-commit’s own whole-tree modified-files check
(pre_commit/commands/run.py) still fails the hook with “files were
modified by this hook” whenever any tracked file changes during the hook’s
run, stash or no stash. Nothing is lost — git status --short still shows
every unstaged edit — just re-stage (if the failed hook needs it) and retry
the commit.
Always run, before and after a failed commit: git status --short | wc -l.
A shrinking count means work was reverted. An unchanged count is not proof
there was none — a concurrent write (e.g. Obsidian autosave) can add a dirty
line while the rollback removes one, netting to the same count; run step 2
to actually confirm.
What to do:
- Recover the reverted files from the retained patch, excluding the volatile
plugin state (which the rollback already left at its latest, correct
content — reapplying it would stomp that):
Terminal window git -c core.autocrlf=false apply --whitespace=nowarn \--exclude='.obsidian/plugins/*/data.json' \~/.cache/pre-commit/patch<N>-<pid> - Prove no loss:
git -c core.autocrlf=false apply --check --reverse --whitespace=nowarn \ --exclude='.obsidian/plugins/*/data.json' <patch>— the same exclusion as step 1, since the plugin files were never reapplied and would show as a mismatch otherwise. A clean check means every OTHER file the patch touches matches its pre-incident content within the patch’s own diff context — not a full-file byte compare, sinceapply --checkonly verifies the hunk context and changed lines, so a stray edit outside that context on an otherwise-clean file would not be caught. This still works after some of those files have been committed, since the reverse-check reads the worktree, not HEAD. Loop with--include=<file>to isolate a single remaining diff. - Do not try to park the volatile files with
git stash— it is refused byblock-git-stash(the stash stack is shared across worktrees).git checkout -- <path>on them may also be denied; copy the file aside into the scratchpad instead if you need to compare it. - Land the commit one of two ways, both needing an operator OK:
stage the volatile files first so pre-commit has nothing left to park for
them — this only closes the race for the files you staged: any OTHER
unstaged tracked file is still stashed by stock
pre-commit install, and a fresh Obsidian write after you stage can still hit one of those, or even the file you just staged if it accepts further mid-hook writes; it is a narrowing, not a guarantee. The reliable fix is installing the no-stash wrapper (above) first. Or:--no-verifyonly after seeing gitleaks + worktree-isolation pass yourself on that exact staged set.
Never re-run the failed commit as-is hoping it will go through — the same race is deterministic and will revert the same files again.
Why this is a playbook, not a CLAUDE.md rule
Root CLAUDE.md is state, not a prompt — frame-shaping invariants only, paid
for on every session. Operational/troubleshooting rules (the above) are
prunable, per-user, and only relevant at the moment of the symptom, so they live
load-on-trigger here instead (decision locked: memory
feedback_no_operational_rules_in_claudemd; layer-selection and
structural>instructional rules). The 9-author convergence behind “short
CLAUDE.md beats long” is synthesized in the luna vault at
Clippings/synthesis/2026-05-26-concept-claude-md-patterns.md.