Notes
← All notes
·ai·11 min

MEMORY.md Didn't Stop the Bug. A PreToolUse Hook Did.

I wrote the rule into two feedback files under MEMORY.md three weeks ago and assumed recurrence would go to zero. Thirty days later it had recurred six times. Promoting the same rule to a PreToolUse hook means the LLM has no "skip the hook" option — the tool call is rejected at exit 2. A field note on why prose advisories fail and how to decide when a rule is ready for tool-level enforcement.

#claude-code#hook#codification#retrospective#beginner-friendly

I wrote the rule into two feedback files under MEMORY.md three weeks ago and assumed recurrence would drop to zero — instead the same bug recurred six times in the next thirty days. Promoting the same rule into a PreToolUse hook removes the failure mode at the tool layer, because the LLM has no "skip the hook" option — the same structural reason the existing main-branch block on this same hook has zero violations to date.

This isn't "hooks beat MEMORY.md." It's a field note on why carefully written prose rules fail in a stable, repeatable way — and how, after the third time you trip the same wire, you decide whether to leave the rule in prose or escalate it to a tool-level gate.

The bug

My Claude Code workflow runs every ticket on a dedicated worktree branch; main never receives direct commits. The bug I want to talk about is "session is on worktree A, but Edit lands in worktree B or in the main canonical checkout." Three weeks, six recurrences.

Concrete shape:

  • Session is in worktree claude-design-sysprompt-audit, editing a wiki file.
  • The Edit uses absolute path /Users/yclee/Diary/AI/... — that's the main tree root, not the worktree root.
  • The new wiki file plus the _index.md change land in main. The worktree never sees them.
  • At wrap-up time, opening a PR exposes it: the worktree is clean, but main is dirty. Phase 0 rollback.

There's a subtler variant:

  • Session is in a worktree, editing ~/.claude/projects/-Users-yclee-Diary/memory/MEMORY.md.
  • The path looks like a global MEMORY.md, but it's actually a symlink whose realpath resolves to /Users/yclee/Diary/claude-config/memory/MEMORY.md — the main canonical checkout.
  • The Edit succeeds, the file under MEMORY.md is updated, but the update lands on main, not the worktree.

Same root cause for both: in a worktree session, the Edit's absolute path was never verified to be a descendant of the worktree root.

The first attempt (which didn't hold)

After the first incident (2026-04-15), I did the thing you're "supposed to do." Wrote a feedback file (wired into MEMORY.md) documenting three cases, with a clear rule statement.

**Write/Edit path-root verification (hard rule under worktree session):**
In a worktree session, before Write/Edit, the absolute path prefix MUST be
the current worktree root (known via session env or `pwd`), not the main
tree root.

Days later the symlink variant tripped me, and I wrote a second feedback file:

Before editing any file under `~/.claude/`, first `ls -la <path>`
to confirm whether it is a symlink and the pointed-to location.

Both files were wired into MEMORY.md and loaded at the start of every session. I assumed that was enough.

Why it didn't work: a probabilistic system can't be fixed by "writing it more clearly"

Three weeks later I ran /pattern-mine (a skill I'd written to scan retrospectives across sessions) and a cluster popped: "worktree session writes through symlinked / canonical-checkout paths, N=6, 30 days." Four of those six recurrences happened after I'd written the second feedback file.

I stared at the number for a while. The easy explanations didn't survive contact with the data:

  • "The feedback file was too long to read." No — MEMORY.md is loaded every session start, and both feedback files are explicitly indexed.
  • "It wasn't written clearly enough." It read fine on every re-read. The cases were laid out plainly.
  • "The model was having a bad day." Recurrences happened in afternoon sessions and middle-of-the-night sessions alike. Not a model-state issue.

What I eventually had to admit: an LLM is a probabilistic system, and prose rules can lower the failure rate but can't drive it to zero. One in ten attempts skips a given rule. Across thirty days, "very low" was high enough to surface six.

The contrast that made it click: there was already a hook running on this same script — pre-edit-branch-check.sh — guarding against direct edits on main. Since that hook landed, on-main Edit violations have been zero.

Not because "don't Edit on main" is a clearer rule. Because it's no longer prose. It's an exit 2.

The promoted version

A PreToolUse hook in Claude Code is a shell script. Returning exit 2 plus a stderr message causes Claude Code to surface the message and reject the tool call. The LLM has no "skip the hook" option. That's the structural difference from prose rules.

Two additions to the existing pre-edit-branch-check.sh: realpath resolution and a cross-worktree gate.

# Resolve realpath so symlinks (e.g., ~/.claude/projects/... → Diary canonical
# checkout) collapse before the repo walk. New file (path doesn't exist yet)?
# Resolve parent dir then reattach basename.
file_path_real=""
if [ -e "$file_path" ]; then
  file_path_real="$(realpath "$file_path" 2>/dev/null || true)"
elif [ -e "$(dirname "$file_path")" ]; then
  parent_real="$(realpath "$(dirname "$file_path")" 2>/dev/null || true)"
  [ -n "$parent_real" ] && file_path_real="$parent_real/$(basename "$file_path")"
fi
[ -z "$file_path_real" ] && file_path_real="$file_path"

This collapses symlinked paths. ~/.claude/projects/...memory/MEMORY.md resolves to /Users/yclee/Diary/claude-config/memory/MEMORY.md — the main canonical path — so the existing main-branch check fires.

The second piece is the cross-worktree gate:

# Cross-worktree gate.
# If the session's CWD belongs to a non-main worktree, the target's realpath
# MUST be under that worktree root.
session_cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')"
[ -z "$session_cwd" ] && session_cwd="$PWD"
session_repo="$(git -C "$session_cwd" rev-parse --show-toplevel 2>/dev/null || true)"
session_repo_real="$(realpath "$session_repo" 2>/dev/null || echo "$session_repo")"
session_branch="$(git -C "$session_repo" branch --show-current 2>/dev/null || echo "")"

if [ -n "$session_repo_real" ] && [ -n "$session_branch" ] && [ "$session_branch" != "main" ]; then
  case "$file_path_real" in
    "$session_repo_real"|"$session_repo_real"/*) ;;
    *)
      cat >&2 <<EOF
Edit blocked — target resolves outside the active worktree.
EOF
      exit 2
      ;;
  esac
fi

The logic is short: derive the session's worktree root from its CWD, compare against the target's realpath, exit 2 if the target isn't inside. The gate only fires when the session's own branch isn't main — on-main sessions already get caught by the existing main-branch block.

A first-trigger arrived without waiting: the very first time I tried to Edit a file under MEMORY.md after writing the hook, I used the ~/.claude/projects/... symlink path — and got blocked by my own hook. Had to cd into the worktree and re-issue with a worktree-local path. Even the person who wrote the rule violates it, which tells you something about prose enforcement. The hook doesn't need a promise that I won't violate the rule again — the main-branch gate on this same script has run for an extended period with zero violations, and that's the production evidence that the structure holds.

Deciding when a rule is ready for promotion

Not every rule should be a hook. Too many hooks become noise and a maintenance burden. Three signals I now use to decide:

SignalMeaning
Same rule recurs ≥ 3 times in 30 daysprose has stably failed; the gap isn't "needs better wording"
Violation is 100% detectable from tool inputa hook can be written without semantic inference
Cost of recovering after a violation > 5 minutescatching it upfront beats post-hoc rollback

All three need to hit before I promote. For this one: six recurrences (yes), absolute path plus git rev-parse is enough to decide (yes), every miss meant a Phase 0 rollback or manual cherry-pick into the right worktree (yes). Promote.

Counter-example: "commit messages should explain why, not just what" only hits the first signal. There's no shell expression for "what vs why," so it stays in prose.

What's still open

One honest limit: hooks only catch violations that are derivable from tool input. The "commit messages should explain why, not just what" rule from the table above falls in this bucket — there's no shell expression that decides intent, and rules that need semantic understanding can't be written as a hook. Reviewer agents plus prose rules are still the only option there.

If you run a similar multi-agent workflow, the practical sequencing I'd recommend: write the rule into a feedback file under MEMORY.md first, run for two or three weeks, count recurrences. Promote to a hook only after the third recurrence. Front-loading every rule into hooks makes the tool layer too heavy and harder to maintain than the prose layer it was meant to replace.


The full hook behavior matrix (seven input scenarios × corresponding exit codes) and the reviewer agent's promotion-decision rules live in the paid version → (link TBD).