Claude Code hooks. Three Stop hooks block specific outputs from the assistant: effort estimates, hedges that don't name a concern, disagreement framed as a question. Three PreToolUse hooks block specific writes: project memory files, content containing residue (justification, defense, decision narrative — the author going beyond describing what is), and underived measurements (bare dimensional literals that should be docgen markers fed from a source constant). A fourth PreToolUse hook runs on Bash and blocks branch creation, so work stays on main in the one shared worktree; a fifth, also on Bash, nudges once per session against flashing firmware with a dirty tree, so flashed binaries map to commits by default. A sixth runs on WebFetch and WebSearch and denies the first call of each (per tool, per session) with a redirect to Chrome MCP, which is far more reliable. One UserPromptSubmit hook injects context instead of blocking: when a prompt carries step-viewer pick text, it points the agent (once per session) at the format's home and at the fact the channel is two-way.
This is a personal tool, put on GitHub in case it helps someone running similar configurations. It is not a polished, configurable, cross-platform library — read the next section before assuming it'll work for you.
You'll get value from this if all of the following are true:
- You're using Claude Code and want it to block specific patterns in the assistant's turns and tool calls.
- For the Stop hooks: you're OK with Anthropic API charges for the Haiku second-stage classifications (one small Haiku call per Stop event when the regex pre-filter matches — usually a tiny fraction of turns).
- You're comfortable with bash + jq + curl + perl in your hook scripts and editing
~/.claude/settings.jsonby hand.
You will not get value from this if:
- You want a UI for configuring patterns.
- You want portable, OS-agnostic hooks (these use
tail -r/tacand other shell quirks). - You want fine-grained control over which sessions a hook applies to (these run on every relevant event globally).
These run after each assistant turn. Each one runs a cheap regex pre-filter against the last assistant message; if it matches, a windowed snippet goes to Claude Haiku for disambiguation; if Haiku confirms the targeted pattern, the turn is blocked with a reason returned to the assistant.
-
block-effort-estimate.sh— catches phrasings like "this'll take a day", "maybe a few hours", "weeks not months", "a couple of weeks". An effort estimate from an LLM is not tied to reality: it is pattern-matched from training data, where humans wrote estimates of work they were doing — work the LLM will do entirely differently. The block message asks the assistant to rewrite without one. -
block-unexplained-hedge.sh— catches "I'm not sure", "I might be wrong", "this could be off" when the assistant doesn't name the underlying concern. The block message asks the assistant to explain the concern rather than remove the hedge. Substantive hedges (where the concern is named) pass through; social/habitual hedges get blocked. -
block-question-as-disagreement.sh— catches "I notice X — was that intended?" / "Did you mean to Y?" / "Is that on purpose?" when the assistant frames a structural disagreement as a question. The block message asks the assistant to state the disagreement directly. Genuine information-gathering questions pass through; disagreement framed as a question gets blocked.
These run before specific tool calls.
-
block-memory-write.sh— catchesWrite/Edit/MultiEdit/NotebookEditcalls whose target path is under any~/.claude/projects/*/memory/directory. The deny message asks the assistant to encode the lesson by example in the work it's doing rather than as a memory note. (Bashwrites to memory paths viaecho >are intentionally not blocked — the hook would otherwise gate every shell command for a threat that hasn't materialized.) -
block-residue.sh— catchesWrite/Edit/MultiEdit/NotebookEditcalls whose new content contains residue (justification, defense, decision narrative — the author going beyond describing what is). Two-stage like the Stop hooks: regex pre-filter on the new content, Haiku adjudication on a ±600-char window around the first match. The deny message points the assistant at three calibration files (Principle.md,You.md,Framing.md) in~/Developer/homesodamachine/calibration/and asks them to read those before looking at what they wrote. Fires once per session, not twice — once an agent has been pointed at the calibration, subsequent residue writes in the same session pass through. A marker file at~/.claude/hooks/state/residue-warned-<session-id>records the warning; markers older than 7 days are garbage-collected on each invocation. Skips binary/structured files (.dxf,.json,.yaml, etc.) and the calibration files themselves. Fires only when the calibration files exist at the expected path; bails silently otherwise. -
block-underived-measurement.sh— catchesWrite/Edit/MultiEdit/NotebookEditcalls that introduce a bare dimensional literal (a value in mm, degrees, or a⌀/ødiameter) where the dimension is one this project fabricates and so should be a docgen marker fed from a source constant. Two-stage likeblock-residue.sh: a lenient regex pre-filter for measurement-shaped literals, then Haiku adjudication on a ±600-char window that splits a derivable project dimension (a wall, bore, boss, fillet, angle of the project's own geometry → nudge) from an external value that is correctly a literal (a fastener size, imperial equivalent, vendor spec, raw caliper measurement → pass). Scopes by extension — Markdown is judged whole,.py/.scadare reduced to their comment text so code constants likeboss_annulus = 3.0don't fire — and strips existing[value](TAG)markers first so already-pinned values don't fire. The deny message points the assistant at the repo'stools/docgenand the[value](TAG)marker syntax. Fires once per session, not twice, via~/.claude/hooks/state/measurement-warned-<session-id>(same 7-day GC). Applies only inside a repo that carriestools/docgen(found by walking up from the target file); bails silently anywhere else. -
block-branch.sh— catchesBashcalls whose command creates or publishes a git branch:git checkout -b/-B,git switch -c/-C,git branch <newname>,git worktree add,gh pr create, andgit push -u/--set-upstreamto a non-mainbranch. Work happens directly onmainin the one shared worktree, where simultaneous agents surface conflicts in real time; a branch gives no isolation there and just hides work from the other agents on the same checkout. Listing/deleting/renaming branches (git branch,-d/-D,-m) and pushingmainpass through. Hard deny on every match — no Haiku stage, no per-session passthrough; the deny message states the convention. -
block-flash-before-commit.sh— catchesBashcalls that execute a firmware flash —tools/flash.sh <env>(without the build-onlybuildarg) orpio/platformio run … -t/--target upload— while the working tree is dirty, and denies them.pre_build.pystampsFW_VERSIONinto every build from the git rev, so an uncommitted flash bakes<sha>-dirtyinto the binary: a build that maps to no commit and can't be traced back to the source running on the chip. Dirtiness is the samegit status --porcelaintestpre_build.pyuses, so it denies exactly the flashes that would stamp-dirty; build-only runs, clean-tree flashes, and mere mentions of the script (cat/ls/grep tools/flash.sh) pass. Detection is anchored to command position, so the path appearing as an argument doesn't trip it. Fires once per session, not twice — no Haiku stage; after the first nudge, a deliberate retry in the same session passes through (a dirty test-flash on hardware is sometimes the point, unlike branch creation), via a~/.claude/hooks/state/flash-warned-<session-id>marker with the same 7-day GC as the other once-per-session hooks. The deny message states the convention and lists what's uncommitted. -
block-web.sh— catchesWebFetchandWebSearchcalls and denies the first of each per session with a message redirecting to Chrome MCP, which is far more reliable: WebFetch hits cert failures and stale page caches Chrome doesn't, and WebSearch is not Google (its results are weak). Fires once per session per tool, not twice — after the nudge, subsequent calls of that tool in the same session pass through, so a session where Chrome MCP genuinely isn't connected can still fall back. Marker files at~/.claude/hooks/state/webfetch-warned-<session-id>/websearch-warned-<session-id>record the warning; markers older than 7 days are garbage-collected on each invocation. No Haiku stage and no logging — the tool name is unambiguous, nothing to adjudicate.
note-pick-text.sh— catches user prompts carrying step-viewer pick text (the STEP viewer's copy blobs:file:/solid:/edge:/faceA:/faceB:/click:lines, recognized by their three-decimal coordinate triples) and injectsadditionalContextinstead of blocking anything. The note points the agent at the format's home (web/public/js/viewer/pick-format.js, with verbatim samples inweb/tests/pick-format.test.js), asks it to echo its decoded identification of each pick before changing geometry, and tells it the part nothing in a pasted blob reveals: the channel is two-way — the viewer's Find box accepts the same format pasted back, opens thefile:line's file, and highlights every pick, so the agent should emit pick lines when pointing the user at geometry, composed from CadQuery geometry with the viewer repo'shardware/scripts/pick_text.py(round-trip tested against the parser). Fires once per session, not twice, via~/.claude/hooks/state/pick-noted-<session-id>(same 7-day GC). Applies only inside a repo that carries the viewer (found by walking up from cwd); bails silently anywhere else. No Haiku stage and no logging — the coordinate-triple signature is unambiguous, nothing to adjudicate.
Each Stop hook follows the same shape:
- Read the assistant's last turn from the session transcript JSONL.
- Strip backtick-delimited spans (so docs that quote the hook's own trigger patterns don't fire the hook on itself).
- Run a cheap regex pre-filter against the last turn. If nothing matches, exit silently.
- If the regex matches, extract a ±800-char window of context around the match.
- Send the window to Claude Haiku 4.5 with a classification prompt that distinguishes the targeted pattern from the look-alike (effort vs projection; substantive vs social hedge; genuine question vs disagreement-framed-as-question).
- If Haiku classifies as the targeted pattern, emit a
blockdecision with areason.
The two-stage design keeps API cost down (most turns never reach Haiku) while keeping the catch precise (Haiku sees real context, not just the matched fragment).
The three Stop hooks, block-residue.sh, and block-underived-measurement.sh each append one JSONL line per event to ~/.claude/hooks/logs/<hook-name>.jsonl with a status field identifying which code path was taken:
loop_guard— re-entry from a revision attempt, skipped (Stop hooks only)no_transcript/no_assistant_message/empty_or_short_text/empty_after_strip— nothing to check (Stop hooks)wrong_tool/skipped_calibration/skipped_non_prose/empty_or_short/no_calibration_files— file or tool filtered out (block-residue.shonly)skipped_filetype/no_docgen_repo/empty_after_strip— file filtered out: not.md/.py/.scad, not inside atools/docgenrepo, or no prose left after stripping comments and markers (block-underived-measurement.shonly)already_warned_this_session— session marker exists from a prior nudge in the same session; hook passes through (block-residue.shandblock-underived-measurement.sh)regex_no_match— pre-filter didn't match; Stop-hook log lines includelast_400_charsof the response so you can see what slipped throughno_api_key—~/.claude/anthropic_api_keyis missinghaiku_no_response— Haiku call made but empty response (timeout, network failure, etc.)allowed— Haiku classified as the look-alike; no block emittedblocked— Haiku classified as the targeted pattern; block was emitted
The regex_no_match lines are the diagnostic surface for tuning. If a pattern slips through in normal use, grep the log:
grep regex_no_match ~/.claude/hooks/logs/effort-estimate.jsonl | tailIdentify the shape that got past, add it to the regex pattern in the script.
block-memory-write.sh, block-web.sh, and note-pick-text.sh do not log. They are structurally much simpler (a path comparison and a tool-name match, respectively) and have no two-stage decision to diagnose.
- Clone this repo somewhere on your machine.
chmod +x hooks/*.sh.- Drop your Anthropic API key into
~/.claude/anthropic_api_key(for the Stop hooks; plain text, one line, no quotes). - Wire the hooks up in
~/.claude/settings.json— seeexamples/settings.jsonfor the shape.
For paths in settings.json: the example uses $HOME/.claude/hooks/... which assumes you've copied the scripts into that directory. An alternative is to point settings.json directly at your clone (e.g. $HOME/path/to/claude-code-hooks/hooks/...). That keeps a single source of truth on disk: edit in the clone, run from the clone, commit and push from the clone.
The regex pattern is one line near the top of each Stop-hook script. Extend it as you find slips in the log. The Haiku stage filters out matches that don't fit the pattern definition: a regex that matches widely costs an API call per match but does not block on the look-alike.
The classification prompts are also in each script. If Haiku classifies in a direction other than what you want, the prompt is where you'd adjust the examples or definitions.
The reason message — what the assistant sees when blocked — is a jq -n literal near the bottom of each script. Rewrite it however you want it to read.
hooks/block-effort-estimate.sh— effort-estimate hook (Stop, regex + Haiku two-stage)hooks/block-unexplained-hedge.sh— hedge hook (Stop, regex + Haiku two-stage)hooks/block-question-as-disagreement.sh— question-as-disagreement hook (Stop, regex + Haiku two-stage)hooks/block-memory-write.sh— memory-write hook (PreToolUse, path comparison only)hooks/block-residue.sh— residue hook (PreToolUse, regex + Haiku two-stage)hooks/block-underived-measurement.sh— underived-measurement hook (PreToolUse, regex + Haiku two-stage)hooks/block-branch.sh— branch-creation hook (PreToolUse on Bash, command-pattern match)hooks/block-flash-before-commit.sh— flash-guard hook (PreToolUse on Bash, command-pattern match +git status --porcelaindirty check)hooks/block-web.sh— web-tool-redirect hook (PreToolUse on WebFetch|WebSearch, once-per-session-per-tool nudge to Chrome MCP)hooks/note-pick-text.sh— step-viewer pick-text note (UserPromptSubmit, once-per-session context injection)examples/settings.json— example~/.claude/settings.jsonsnippet wiring all ten hooks