Skip to content
This repository was archived by the owner on Jun 27, 2026. It is now read-only.

derekbreden/claude-code-hooks

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

28 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

claude-code-hooks

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.

Who this is for

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.json by 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/tac and other shell quirks).
  • You want fine-grained control over which sessions a hook applies to (these run on every relevant event globally).

The hooks

Stop hooks (regex + Haiku two-stage)

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.

PreToolUse hooks

These run before specific tool calls.

  • block-memory-write.sh — catches Write / Edit / MultiEdit / NotebookEdit calls 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. (Bash writes to memory paths via echo > are intentionally not blocked — the hook would otherwise gate every shell command for a threat that hasn't materialized.)

  • block-residue.sh — catches Write / Edit / MultiEdit / NotebookEdit calls 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 — catches Write / Edit / MultiEdit / NotebookEdit calls 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 like block-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/.scad are reduced to their comment text so code constants like boss_annulus = 3.0 don'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's tools/docgen and 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 carries tools/docgen (found by walking up from the target file); bails silently anywhere else.

  • block-branch.sh — catches Bash calls 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, and git push -u/--set-upstream to a non-main branch. Work happens directly on main in 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 pushing main pass through. Hard deny on every match — no Haiku stage, no per-session passthrough; the deny message states the convention.

  • block-flash-before-commit.sh — catches Bash calls that execute a firmware flash — tools/flash.sh <env> (without the build-only build arg) or pio/platformio run … -t/--target upload — while the working tree is dirty, and denies them. pre_build.py stamps FW_VERSION into every build from the git rev, so an uncommitted flash bakes <sha>-dirty into 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 same git status --porcelain test pre_build.py uses, 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 — catches WebFetch and WebSearch calls 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.

UserPromptSubmit hooks

  • 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 injects additionalContext instead of blocking anything. The note points the agent at the format's home (web/public/js/viewer/pick-format.js, with verbatim samples in web/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 the file: 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's hardware/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.

How the Stop hooks work

Each Stop hook follows the same shape:

  1. Read the assistant's last turn from the session transcript JSONL.
  2. Strip backtick-delimited spans (so docs that quote the hook's own trigger patterns don't fire the hook on itself).
  3. Run a cheap regex pre-filter against the last turn. If nothing matches, exit silently.
  4. If the regex matches, extract a ±800-char window of context around the match.
  5. 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).
  6. If Haiku classifies as the targeted pattern, emit a block decision with a reason.

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).

Logging

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.sh only)
  • skipped_filetype / no_docgen_repo / empty_after_strip — file filtered out: not .md/.py/.scad, not inside a tools/docgen repo, or no prose left after stripping comments and markers (block-underived-measurement.sh only)
  • already_warned_this_session — session marker exists from a prior nudge in the same session; hook passes through (block-residue.sh and block-underived-measurement.sh)
  • regex_no_match — pre-filter didn't match; Stop-hook log lines include last_400_chars of the response so you can see what slipped through
  • no_api_key~/.claude/anthropic_api_key is missing
  • haiku_no_response — Haiku call made but empty response (timeout, network failure, etc.)
  • allowed — Haiku classified as the look-alike; no block emitted
  • blocked — 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 | tail

Identify 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.

Installing

  1. Clone this repo somewhere on your machine.
  2. chmod +x hooks/*.sh.
  3. Drop your Anthropic API key into ~/.claude/anthropic_api_key (for the Stop hooks; plain text, one line, no quotes).
  4. Wire the hooks up in ~/.claude/settings.json — see examples/settings.json for 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.

Tuning

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.

Files

  • 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 --porcelain dirty 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.json snippet wiring all ten hooks

About

Merged into derekbreden/claude-code-setup (see hooks/).

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages