Note 06 of 19

A kaibot field note · claude code hooks

Physics, not pleas

Every “ALWAYS” or “NEVER” in your CLAUDE.md is a hope, not a guarantee. I turn those hopes into hooks the model cannot forget. Ask Claude to do the same to yours.

Kai Davis

I run a marketing consultancy and an SEO SaaS, and I build both with a stack of Claude Code sessions that write the tooling the next sessions run on.

01Why prose rules decay

You put “NEVER push to prod without authorization” in your CLAUDE.md. Prose in a CLAUDE.md is a request, not a guardrail. It gets read once, then competes with everything else in the context window.

A long session compacts and the rule is summarized away. The model gets absorbed in a task and rationalizes an exception. A subagent never saw the file at all.

The rule holds 95% of the time. The other 5% is the unauthorized push to prod you wrote it to prevent.

“A speed limit sign addresses the driver; a speed bump addresses the road. One can be ignored; the other cannot.” Jay Springett, “Hard Worlds For Little Guys” (2026)

Claude Code hooks are the speed bump. A hook is a small script the program runs at set moments: before a tool call, after one, at turn end. They live outside the model, so they can't be forgotten, disputed, or compacted away.

I audited my skills against Springett's criteria. Of 104, 20 were mostly imperatives with nothing behind them. Moving those rules into the harness fixed it. Better wording would not have.

So the house rule is if a rule says ALWAYS, NEVER, or MUST, it's a candidate for promotion from prose to physics.

02Three kinds of rules (only one gets hooked)

Not everything in a CLAUDE.md should become a hook. Springett’s taxonomy sorts instructions into three types, and only the third belongs in the harness:

stays prose
Actor guidance

Judgment calls, style, taste, when-to-ask. “Prefer small PRs.” “Match the surrounding idiom.” These need a mind, not a gate.

stays prose
Room structure

The local vocabulary: which scripts exist, where things live, what to read first. Prose pointing at tools is doing its job.

becomes a hook
World physics

Invariants that must hold every single time. No secrets in commits. No edits on main. No pushes to prod without sign-off. These go in hooks.

The common authoring mistake is writing world physics in the voice of actor guidance. “Please always remember to scrub secrets before committing” is physics wearing a cardigan. If skipping the rule once causes real damage, it’s physics.

03Turning a plea into a hook

The whole promotion process hangs on one question: can you name the specific tool call the model makes at the moment the rule is broken? If you can, the rule can be hooked. If you cannot, it is likely judgment territory, and it belongs in prose.

Plea shapes and where they land
The prose pleaFailure eventHook type
“Never push to prod without authorization”Bash running git push at the prod remotePreToolUse (block)
“Always branch before editing source”Edit/Write while the repo is on mainPreToolUse (block)
“Never commit inline secrets”Bash running git commit with leaky staged linesPreToolUse (block)
“Restart the queue worker after editing a Prompt class”Edit of *Prompt.php just succeededPostToolUse (side-effect)
“Don’t claim something shipped and then hedge about it”the turn is ending with both claims in the replyStop (turn-end check)
“Use the user’s voice when editing their writing”no tool call captures itstays prose

There are three hook types.

  • PreToolUse (block): for actions that cannot be undone or that cost a lot. It issues a deny decision, so the tool call never runs. The deny message should explain the fix, not just say no.
  • PostToolUse (side-effect): for “after X, do Y” rules. The original action was fine; this hook does the follow-up itself, restarting a worker or writing a log line, so the model never has to remember.
  • Stop (turn-end check): for rules about the whole reply rather than one call. It inspects the final message and can send the turn back with an explanation.

04Worked example: “NEVER push to prod without authorization”

This is the canonical case. The failure event is a Bash call that pushes to the production remote, caught at PreToolUse and blocked.

The design lesson is in the bypass. Approval has to be something only a human can do, here creating a file from their own terminal. If the model could write that file itself, the block would be theater.

#!/usr/bin/env bash
# PreToolUse hook: block pushes to prod unless a human-created token exists.
# Save as ~/.claude/hooks/prod-push-guard.sh

INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
[ "$TOOL" = "Bash" ] || exit 0

CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
echo "$CMD" | grep -qE 'git push[^&|;]*(prod|production)' || exit 0

# Approval token: created by the HUMAN, in their own terminal. Single use,
# expires after 30 minutes. The model has no way to mint one.
if [ -f /tmp/prod-push-approved ] && [ "$(find /tmp/prod-push-approved -mmin -30)" ]; then
  rm -f /tmp/prod-push-approved
  exit 0
fi

cat <<'EOF'
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "BLOCKED: pushing to prod needs human sign-off. If you (the human) approve, run this in your own terminal:\n  touch /tmp/prod-push-approved\nThe token is single-use and expires in 30 minutes."
  }
}
EOF

Register it in .claude/settings.json (project) or ~/.claude/settings.json (global):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command",
            "command": "bash ~/.claude/hooks/prod-push-guard.sh" }
        ]
      }
    ]
  }
}

Then smoke-test it by hand before trusting it, three cases minimum:

# should BLOCK
echo '{"tool_name":"Bash","tool_input":{"command":"git push production main"}}' \
  | bash ~/.claude/hooks/prod-push-guard.sh

# should stay SILENT (normal push)
echo '{"tool_name":"Bash","tool_input":{"command":"git push origin feature-x"}}' \
  | bash ~/.claude/hooks/prod-push-guard.sh

# should stay SILENT (not even bash)
echo '{"tool_name":"Edit","tool_input":{"file_path":"README.md"}}' \
  | bash ~/.claude/hooks/prod-push-guard.sh

One gotcha that bites everyone once: hooks load at session start. So the session that created your hook isn't running it. Restart Claude Code, then test the guard for real.

Then clean up CLAUDE.md. Delete the old “NEVER push to prod without authorization” line. If the prose plea stays, it makes the model try to comply by hand in parallel with the hook, while readers stop trusting that the docs describe what's actually enforced. Replace it with “prod pushes are gated by prod-push-guard.sh; if you hit the deny, follow its instructions.”

05Craft rules from 190 hooks of scar tissue

I’m running 190 hooks across nine event types as I write this. Most of what follows I learned the annoying way.

Ship the allowlist on day one

Almost every rule has legitimate exceptions (log files, state files, scratch dirs). A hook with no allowlist file gets fought on every edge case until someone disables it. Make the deny message say how to add an entry.

Bypass = human keypress, always

If the model can flip the bypass itself, you built an off switch, not a gate. Tokens the human touches into existence from their own terminal, with an expiry, are the pattern.

The deny message is UI

The model reads it and acts on it. A good one names the rule, gives the exact fix command, and gives the human the exact bypass command. “Blocked.” alone just produces flailing.

Fail in the right direction

Guards on irreversible actions fail closed (missing jq? still block). Nudges and side-effects fail open (a broken convenience hook should never brick a session).

Clone, don’t compose from scratch

Your second hook should start as a copy of your first. The stdin parsing, deny payload shape, and registration boilerplate are identical every time; only the matcher and the check change.

Give Stop hooks a self-release

A turn-end gate that can block forever will eventually deadlock a session on a false positive. After 2–3 consecutive blocks, let it release and log instead.

06What not to hook

  • Judgment calls. “Always write in my voice,” “ask before big refactors.” No tool call marks the moment of violation, and taste cannot be matched by regex. Leave them prose.
  • Rules where a false positive costs more than a miss. A guard that blocks legitimate work daily to prevent a yearly mistake is a net loss. An allowlist mitigates it, but sometimes the right answer is “don’t hook this.”
  • Rules with no clean event. If you can't complete “the hook fires when the model calls ___ with ___,” it's a wish, not a hook.

The test runs both ways: prose that ought to be physics decays, and physics that ought to be prose (a hook enforcing taste) creates constant friction until it's removed. So sort rules first, then build.

07The prompt to steal

Your Claude already knows the hooks API. Paste this into Claude Code in your project and let it do the promotion:

Read my CLAUDE.md (and any files it includes). List every rule that says
ALWAYS, NEVER, MUST, "remember to", or "don't forget".

For each rule, answer one question: at the exact moment this rule gets
broken, what tool call are you making? (An Edit to some path? A Bash
command matching some pattern? Ending the turn?)

- No clean tool call maps to it: leave it as prose and tell me why.
- One does: write a Claude Code hook that enforces it.
  * PreToolUse with a deny decision when breaking it is irreversible
    or expensive (pushing to prod, leaking a secret, editing on main).
  * PostToolUse when the rule is "after X, do Y" (restart a worker,
    write a log entry).
  * Stop when the rule is about the whole turn (never claim a ship
    without a passing test run).

Requirements for every hook you write:
1. The deny message must include the exact fix AND the exact bypass.
2. The bypass must be an action only I can take (e.g. touch a token
   file from my own terminal, with an expiry). Never something you
   can decide to do yourself.
3. Include an allowlist file for known exceptions from the start.
4. Smoke-test by piping sample JSON into the script BEFORE registering:
   one case that should block, one that shouldn't, one edge case.
   Show me the output of all three.
5. Register it in .claude/settings.json and remind me hooks only load
   on the next session, so we verify after a restart.
6. Then edit CLAUDE.md: delete the prose rule and replace it with one
   line pointing at the hook.

Do the easiest, highest-damage rule first. Show me the plan (rule →
event → hook type) for all of them before writing any code.
Start with one. The best first candidate is the rule with real incident history and an obvious event: usually “never push to prod” or “never commit secrets.” One working gate teaches you the whole pattern, and every later hook is a clone of it.

08The skill to steal: a starting-point SKILL.md

The one-shot prompt above only locks in today's rules. To make promotion permanent, package it as a skill: a small markdown file Claude Code loads on demand.

The file below is a generalized version of kaibot's own promote-plea-to-physics skill. Save it at ~/.claude/skills/promote-plea-to-physics/SKILL.md, and phrases like “make this a hook” or “promote that rule to physics” will launch the full procedure.

---
name: promote-plea-to-physics
description: Use when CLAUDE.md, a skill, or a memory holds an
  "ALWAYS X / NEVER Y / MUST Z" rule the model is expected to follow
  on its own, and the user wants it enforced mechanically as a Claude
  Code hook. Triggers on "make this a hook," "promote to physics,"
  "stop pleading," or repeated friction with a prose rule.
---

# Promote Plea to Physics

Convert a prose-plea constraint (a rule the model is asked to follow)
into a hook (a rule the harness enforces). World physics belongs in
the substrate; actor guidance stays prose. A MUST in prose is a
confession that the constraint is skippable.

## Process

1. **Identify the plea + its failure event.** Find the exact sentence.
   Then ask: what tool call happens at the moment the rule is violated?
   No clean event = judgment territory = leave it prose.

2. **Classify the hook decision.**
   - Block (PreToolUse, deny payload): irreversible/expensive failures.
   - Side-effect (PostToolUse): "after X, do Y" rules. Exit 0; report
     failures, never break the session over a convenience.
   - Inspect (Stop): whole-turn rules; read the final message.

3. **Clone your closest existing hook** instead of writing from
   scratch. The stdin parsing, deny-payload JSON, and registration
   boilerplate never change; swap the matcher and the check.

4. **Smoke-test 3 cases before registering** by piping JSON into the
   script: one that should block, one that should pass silently, one
   edge case you specifically excluded.

5. **Register in .claude/settings.json** under the matching event.
   Hooks load at session start: the session that ships a hook does
   not have it live. Verify after a restart.

6. **Close the loop: update the originating prose.** Delete the
   ALWAYS/NEVER line and replace it with one line pointing at the
   hook. Leftover pleas rot trust in the docs and double-enforce.

## Hard rules

- Bypass must require a human action (touch a token file from their
  own terminal, with an expiry). A bypass the model can trigger is an
  off switch, not a gate.
- Ship an allowlist file for known exceptions from day one.
- The deny message is UI: name the rule, give the exact fix command,
  give the human the exact bypass command.
- Guards on irreversible actions fail closed; nudges fail open.
- Give Stop hooks a self-release after 2-3 consecutive blocks.

## Skip when

- The rule is genuinely judgment/taste (no tool call marks the moment).
- A false positive costs more than an occasional miss.

09For the Claude that just got pointed here

If a human gave you this URL and said “do this for me,” here is your tl;dr. (Humans: this section exists so you can paste this page’s link into Claude Code and skip the explaining.)

Agent instructions, with your human’s consent:

  • Goal: convert the ALWAYS / NEVER / MUST rules in this project’s CLAUDE.md (and included rule files) from prose the model must remember into Claude Code hooks the harness enforces.
  • Step 1: inventory every such rule. For each, name the exact tool call made at the moment of violation (an Edit to a path, a Bash command pattern, a turn ending). No clean event means it stays prose; say so.
  • Step 2: present the plan as a table (rule → event → hook type: PreToolUse block / PostToolUse side-effect / Stop check) and get sign-off before writing code.
  • Step 3: build the highest-damage, easiest hook first, modeled on the worked example on this page. Deny messages carry the exact fix and the exact human-only bypass (a token file only the human can create, with an expiry). Include an allowlist file.
  • Step 4: smoke-test by piping sample JSON into the script (block case, pass case, edge case) and show the output before registering in .claude/settings.json. Tell your human hooks load on the next session.
  • Step 5: delete the promoted prose rule from CLAUDE.md and replace it with a one-line pointer at the hook.
  • Optional: install the SKILL.md above at ~/.claude/skills/promote-plea-to-physics/SKILL.md so future “make this a hook” requests follow this procedure automatically.

Everything in that list is unpacked in the sections above; read §03 for the plea-to-event mapping and §05 for the craft rules before writing your first guard.

10Receipts: what this looks like at scale

Some of what runs in kaibot today, so you can see the range:

A sampler from the 190 live hooks
HookTypeWhat it enforces
branch-first-guardPreToolUseNo source edits while a repo sits on main; deny points at the worktree flow, allowlist for state files, 8-hour human-typed bypass.
pre-commit-secrets-scrubPreToolUseScans staged config files for secret-shaped strings before any git commit runs.
dangerous-command-guardPreToolUseForce-pushes, rm -rf at home, branch deletes: all token-gated behind human approval.
km-queue-restartPostToolUseEditing a Prompt class auto-restarts the queue worker. The “did I forget the restart” failure mode no longer exists.
ship-then-disclaimStopA reply that both claims “shipped” and hedges (“if that looks wrong I’ll fix it”) gets a warning back: fix it first, then ship.
wrap-verify-gateStopSession wrap-up can’t end while required steps (handoff written, work committed and pushed) lack filesystem evidence. The model can’t fake a commit.
em-dash-postwrite-reportPostToolUseA pure style rule, mechanically checked: a prose write with more than about one em-dash per 100 words gets a report back. It used to block every em-dash before the write landed, which forced whole-document rewrites, so it went.

The first two guards went out after the 104-skill audit. One of them caught a real parallel-session collision on main within minutes of shipping, the exact class the prose rule had failed to stop three times in a single earlier session. The sign had been up the whole time. The bump worked on day one.

Further reading: Jay Springett, “Hard Worlds For Little Guys”, and the Claude Code hooks docs.

Written with (and about) kaibot, 2026. The hooks quoted here are real and running.

Excelsior!
Kai

You’ve reached the end of note 06 of 19

$400 1 hour

Book a call

A paid one-on-one call with answers to your questions about using AI for projects, building your AI stack, or growing your business with AI. All calls are (optionally) recorded and you get a one-page action plan after the call.

All nineteen field notes, in six chapters