The Deterministic/Probabilistic Split
Pattern

The Deterministic/Probabilistic Split

You write “NEVER do X” in CLAUDE.md. Claude follows it.. most of the time. The times it doesn’t are where things get ugly. Here’s the mental model that made it click.

Corey Thomas
March 2026
12 min read
split·mind

“NEVER” Means Almost Never

You’ve seen this. Clear instruction in CLAUDE.md. Claude follows it for days. Then one session it just.. doesn’t. Not on something weird — on the exact thing you told it not to do.

Claude is actually good at following instructions. Really good. But “really good” and “100%” are different numbers, and for certain categories of mistakes, anything less than 100% is a number you don’t want to live with.

★ Insight

The mistake is treating all Claude Code instructions the same way. “Prefer functional components” and “never commit secrets” are both instructions, but they have completely different failure costs. One produces a style inconsistency. The other produces a security incident.

Two Layers, Two Jobs

Claude Code gives you two layers for controlling behavior. Once you see them clearly, a lot of the “where does this go” decisions just sort themselves out.

Instructions Are Vibes

CLAUDE.md and .claude/rules/*.mdfiles are the instruction layer. Claude reads them at session start. Follows them with high reliability — but not certainty. These are suggestions toa language model, not hard constraints it’s bound by. Cultural norms, not laws.

Good for:

Hooks Are Walls

Hooks are shell scripts that fire at specific points in Claude Code’s lifecycle. They don’t suggest anything — they enforce. A hook that blocks writes to .envfiles will block them every single time, no matter how good Claude’s reasoning is for why it should write to one.

bash
Instruction — mostly reliable
# CLAUDE.md (probabilistic)
NEVER write to .env files.
These contain secrets and should
be managed manually.
Hook — 100% reliable
+# Hook (deterministic)
+case "$BASENAME" in
+ .env|.env.*)
+ deny "Blocked: sensitive file"
+esac
Tool Call
Hook Fires
Deny / Ask / Allow
Hooks intercept every tool call — no reasoning, no exceptions

Good for:

CLAUDE.md is for things Claude should know. Hooks are for things that must happen.

Annoying or Dangerous?

Simple question: what happens when it fails?

If Claude ignores the instruction 5% of the time, and that 5% is just.. mildly annoying — a style inconsistency, a less-than-ideal architecture choice — keep it in CLAUDE.md. The occasional miss is something you correct and move on from.

If that 5% means you’re actively cleaning something up — a committed secret, a force-pushed branch, a corrupted config — that’s a hook. Hooks don’t have failure rates. They’re shell scripts. They run or they don’t.

The Decision Filter
# Ask yourself:

If Claude ignores this 5% of the time...

→ CLAUDE.md Is it annoying?
→ Hook Is it dangerous?
→ Hook Is it irreversible?
→ CLAUDE.md Is it a preference?

The practical breakdown:

Keep in CLAUDE.md

Move to Hooks

Hooks I Actually Run

These are from my workspace. Hooks that replaced CLAUDE.md instructions after I learned this the hard way.

Blocking Sensitive File Writes

First one I wrote. Replaced “NEVER write to .env files” in CLAUDE.md. It’s a PreToolUse hook that fires before any Write or Edit.

bash
#!/bin/bash
# Guard: Block Write/Edit to sensitive files
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')

if [ "$TOOL" = "Write" ] || [ "$TOOL" = "Edit" ]; then
    FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
else
    exit 0
fi

BASENAME=$(basename "$FILE")

case "$BASENAME" in
    .env|.env.*|credentials.json|*.pem|*.key)
        echo '{"hookSpecificOutput":{
          "hookEventName":"PreToolUse",
          "permissionDecision":"deny",
          "permissionDecisionReason":"Blocked: sensitive file. Manage manually."
        }}'
        ;;
esac
exit 0

Ten lines of bash. No AI reasoning. No prompt engineering. No hoping Claude remembers. Just works, every time.

Catching Hardcoded Secrets

This one goes further — doesn’t just block writes to sensitive files, it catches sensitive content going into any file. Regex matching against known API key patterns.

bash
#!/bin/bash
# Guard: Block writes containing potential secrets
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')

if [ "$TOOL" = "Write" ]; then
    CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""')
elif [ "$TOOL" = "Edit" ]; then
    CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""')
else
    exit 0
fi

PATTERNS=(
    'sk-[a-zA-Z0-9]{20,}'           # AI provider API keys
    'AKIA[0-9A-Z]{16}'              # AWS access keys
    'ghp_[a-zA-Z0-9]{36}'           # GitHub personal tokens
    'xoxb-[0-9]+-[0-9]+'            # Slack bot tokens
    'sk_live_[a-zA-Z0-9]{24,}'      # Stripe live keys
)

for PATTERN in "${PATTERNS[@]}"; do
    if echo "$CONTENT" | grep -qE "$PATTERN"; then
        MATCH=$(echo "$CONTENT" | grep -oE "$PATTERN" | head -1)
        PREVIEW="${MATCH:0:8}..."
        echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Blocked: hardcoded secret detected ($PREVIEW). Use env vars.\"}}"
        exit 0
    fi
done
exit 0

Catches the mistake at the exact moment Claude tries to write the file. Not after a commit. Not in CI. Not in code review two days later. Right then.

★ Insight

These overlap on purpose: one hook blocks writes to sensitive files, another blocks writes containing sensitivecontent. If Claude somehow writes an API key to a source file instead of .env, the secret scanner still catches it. Layer your hooks.

Enforcing Conventional Commits

This one’s softer — instead of hard-denying, it returns an ask decision that prompts for confirmation. Sometimes you want to break the convention. That should be a conscious choice, not an impossible one.

bash
#!/bin/bash
# Check commit messages for conventional commits format
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')

# Only check git commit commands
if ! echo "$COMMAND" | grep -qE 'git\s+commit\s+.*-m\s'; then
    exit 0
fi

# Extract the commit message (handles direct and heredoc styles)
MSG=$(echo "$COMMAND" | sed -n 's/.*-m[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)

FIRST_LINE=$(echo "$MSG" | head -1)
if echo "$FIRST_LINE" | grep -qE \
    '^(feat|fix|chore|docs|style|refactor|test|perf|ci|build|revert)(\(.+\))?!?: .+'; then
    exit 0  # Matches — allow silently
fi

echo '{"hookSpecificOutput":{
  "hookEventName":"PreToolUse",
  "permissionDecision":"ask",
  "permissionDecisionReason":"Commit message doesn'"'"'t follow conventional commits. Proceed anyway?"
}}'
exit 0

Three permission decisions — deny, ask, and allow— give you a spectrum. Secrets get denied. Commit format gets questioned. Everything else flows through.

Saving Insights Before They Vanish

When Claude’s context window fills up, it “compacts” — summarizes the conversation and throws away details. Necessary, but decisions and insights can just.. disappear. A PreCompact hook grabs them first.

bash
#!/bin/bash
# PreCompact: Extract decisions before memory compaction
INPUT=$(cat)
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // ""')
# Note: field names vary by hook event — check Claude Code docs for your event type

if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then
    exit 0
fi

PATTERNS='decided to|chose |went with|trade-off|because |lesson:|insight:'

MATCHES=$(grep -iE "$PATTERNS" "$TRANSCRIPT" 2>/dev/null \
    | sort -u | head -30)

if [ -z "$MATCHES" ]; then
    exit 0
fi

TIMESTAMP=$(date '+%Y%m%d-%H%M%S')
FILEPATH="notes/compaction-extracts/${TIMESTAMP}.md"
mkdir -p "$(dirname "$FILEPATH")"

{
    echo "# Compaction Extract — $(date '+%Y-%m-%d %H:%M')"
    echo ""
    while IFS= read -r line; do
        [ -n "$line" ] && echo "- $line"
    done <<< "$MATCHES"
} > "$FILEPATH"

echo "Extracted insights to $FILEPATH"
exit 0

Purely additive — doesn’t block anything, just creates a side-channel capture. Hook fires, greps the transcript for decision-language, saves what it finds. Zero cost when there’s nothing to grab.

Hooks That Fix, Not Block

Not every hook is a guard. Some are silent corrections — they intercept a command and hand back a better version. Claude never knows they fired.

One injects --draft into every gh pr create command. PRs always start as drafts, triggering a Vercel preview before anyone reviews. Uses the updatedInput response to swap the command before execution:

bash
# Inject --draft right after "gh pr create"
FIXED=$(echo "$COMMAND" | sed -E 's/(gh[[:space:]]+pr[[:space:]]+create)/\1 --draft/')

echo "{
  \"hookSpecificOutput\": {
    \"permissionDecision\": \"allow\",
    \"updatedInput\": {
      \"command\": $(printf '%s' "$FIXED" | jq -Rs .)
    }
  }
}"

Another watches for rsync commands without --dry-runand asks if you want the flag added. Not a block — an offer. Because rsync to a dev server is recoverable, but rsync to production without a dry run first is how you learn what rsync can delete.

This is a third permission level beyond deny and allow. updatedInputlets a hook say “yes, but differently.” The command runs, just not the way Claude originally wrote it.

The Moment Before Ctrl-C

There’s a gap between “done working” and “session closed” where things slip through. You’ve committed, maybe pushed. You’re reaching for Ctrl-C. This is exactly when hooks earn their keep.

Three hooks fire at session-end. One scans your uncommitted files for TODO and FIXME — only the ones you introduced this session, not the hundreds already in the codebase. Finds any? Blocks with file names, line numbers, the actual text. Fix it or convert it to a tracked issue.

Another runs the linter, but only if you touched source files in the last 5 minutes. A 30-second timeout prevents a slow linter from trapping you in a session you’re trying to leave.

The third is the most interesting — it detects whether meaningful changes happened that haven’t been captured to persistent memory. If real work happened, it writes a marker file. Next session picks it up and kicks off knowledge capture automatically. This one never blocks. Deliberate. Knowledge capture needs a fresh context window and focused attention — forcing it at session-end when you’re trying to leave would produce garbage.

The design principle: blocking hooks catch things you can fix right now. Non-blocking hooks queue things that need a different context. Match the hook to the moment.

Your CLAUDE.md Has a Budget

There’s a practical reason for this split beyond failure costs: instruction fatigue.

Frontier models reliably follow roughly 150–200 discrete instructions. Past that, things start getting dropped. The more you pack into CLAUDE.md, the less reliably any single instruction gets followed.

I learned this the slow way. My CLAUDE.md grew over months — style rules, security rules, workflow rules, project context, tool routing. At some point it crossed a line where Claude would consistently miss instructions near the bottom of the file. Not because they were less important. Because the file was just too long.

★ Insight

Every instruction you move to a hook is one fewer instruction competing for Claude’s attention in CLAUDE.md. Moving security rules to hooks doesn’t just make them deterministic — it also makes your remaining CLAUDE.md instructions more reliable by reducing the total count.

Three-part fix:

The goal is a lean CLAUDE.md — project identity, key context, and the instructions that genuinely need to be ambient. Everything else goes to the layer where it actually works best.

Build Your First Hook

PreToolUse hook from scratch: blocking git push --forceto protected branches. Classic footgun in AI-assisted development.

Write the Config

Hooks live in .claude/settings.json (shared with your team) or .claude/settings.local.json (just you). Each hook specifies when it fires and what script to run.

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

The matcher field filters which tool calls trigger your hook. "Bash" fires on shell commands."Write|Edit" fires on file operations. You can match specific tools or use a regex pattern.

Write the Script

Hook scripts get a JSON payload on stdin with the tool name and input parameters. They talk back by printing JSON to stdout. The contract:

bash
#!/bin/bash
# Guard: Block force pushes to protected branches
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')

# Only care about git push commands
if ! echo "$COMMAND" | grep -qE 'git\s+push'; then
    exit 0
fi

# Check for force flags
if ! echo "$COMMAND" | grep -qE '(--force|-f|\+)'; then
    exit 0
fi

# Check if pushing to a protected branch
PROTECTED="main|master|production|release"
if echo "$COMMAND" | grep -qE "($PROTECTED)"; then
    echo '{"hookSpecificOutput":{
      "hookEventName":"PreToolUse",
      "permissionDecision":"deny",
      "permissionDecisionReason":"Blocked: force push to protected branch."
    }}'
    exit 0
fi

# Force push to feature branches — ask, don't block
echo '{"hookSpecificOutput":{
  "hookEventName":"PreToolUse",
  "permissionDecision":"ask",
  "permissionDecisionReason":"Force pushing to a branch. Are you sure?"
}}'
exit 0

Make It Executable

Terminal
chmod +x .claude/hooks/guard-force-push.sh
# Test it with sample input:
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' | .claude/hooks/guard-force-push.sh
{"hookSpecificOutput":{"permissionDecision":"deny",...}}

That’s it. Shell script reads JSON, checks a condition, prints a decision. No framework, no dependencies beyond jq. Test it from the command line by piping in sample payloads.

★ Insight

Start with one hook. The sensitive file guard is the best first hook because it’s simple, immediately valuable, and teaches you the full pattern: config, script, JSON response. Once you’ve built one, the second takes five minutes.

How It Plays Out

My workspace runs about 30 hooks across all lifecycle events. Sounds like a lot, but most are 10 to 120 lines — pretty short. They cover:

Meanwhile CLAUDE.md stays focused on what it’s actually good at: telling Claude who it is in this project, what the architecture looks like, and how I like to work. Doesn’t carry security rules anymore. Those live in code that can’t be talked around.

Shorter CLAUDE.md. Stronger security posture. Way less time reviewing output for the kind of mistakes that hooks now catch before they happen.

Instructions are culture. Hooks are infrastructure. Build both, but know which is which.

Next time you catch yourself writing “ALWAYS” or “NEVER” in CLAUDE.md, pause. Preference or requirement? If “most of the time” is fine, leave it in instructions. If it isn’t — write a hook. Ten lines of bash beats ten words of emphasis, every time.

Contents