The hooks weren’t working. Not “broken” in the dramatic sense — no stack traces, no crashes. Worse: they were silently doing nothing. Agents would finish a session, skip documentation, and nobody noticed because the echo-and-grep pipeline that was supposed to remind them just… printed to stdout where Claude Code quietly consumed it and moved on.

This is the kind of bug that makes you question your assumptions about how tools communicate. The old hooks used plain-text output — echo "Hey, you have pending commits" — which is perfectly reasonable if you’re talking to a human terminal. But Claude Code doesn’t enforce plain text. It enforces structured JSON with specific fields like permissionDecision and deny. Everything else is decoration. So the hooks had been decorating for weeks, and every session ended with undocumented commits.

The fix was a full rewrite of the hook layer. PreToolUse would intercept git commit and deny it via JSON if there were pending ledger entries. Stop would block session end the same way. All the logic moved into the timbers binary itself — no more shell pipelines, no more hoping stdout goes somewhere useful. And it worked. Agents started getting blocked, started documenting. The enforcement loop closed.

Then it immediately opened again.

HasPendingCommits — the fast-path check that made the Stop hook responsive — had a design note that read, almost cheerfully: “May false-positive on ledger-only commits; acceptable trade-off for ~15ms.” Turns out “acceptable” was doing a lot of heavy lifting in that sentence. Every time timbers log auto-committed a ledger entry, it changed HEAD. The naive HEAD-vs-anchor comparison couldn’t tell the difference between “you committed code” and “timbers committed a receipt.” So the Stop hook fired on every single session end, including ones that were fully documented. The ~15ms fast path saved a subprocess call but made the whole mechanism cry wolf. One more git call per session — filtering out ledger-only commits — was the obvious fix once the real-world behavior made the trade-off concrete.

Three patch releases in rapid succession cleaned up the debris. The chained pre-commit hook wasn’t propagating exit codes — timbers would say “no, don’t commit” and then unconditionally exec the backup hook, which would say “sure, go ahead.” The Stop hook’s reason string said timbers log without the --why and --how flags, so agents that did get blocked couldn’t figure out the correct command to unblock themselves. Small things, individually. Collectively, they’re a reminder that enforcement mechanisms have to be correct end-to-end or they’re worse than absent — a hook that blocks sometimes and leaks sometimes teaches agents that blocking is optional.

But the deeper problem was territorial. Timbers wanted to own the pre-commit hook. Beads — the issue tracker sharing the same repo — also wanted to own the pre-commit hook, via core.hooksPath. And core.hooksPath is winner-take-all: git reads hooks from exactly one directory. Timbers was hardcoding .git/hooks/, so when beads redirected git to .beads/hooks/, timbers installed its hook where git would never look. The band-aid was to read core.hooksPath and install there. But that just moved the conflict — now two tools were writing to the same file in the same directory.

The real fix required stepping back and asking: what kind of neighbor is timbers? The answer came through a four-tier environment classification. An uncontested repo has no other hook tooling — timbers can do whatever it wants. An existing repo already has a pre-commit hook — timbers should append a section, not replace it. A known-override like beads’ core.hooksPath gets specific integration behavior. An unknown-override gets a polite refusal with guidance. The old backup-and-chain strategy — rename the existing hook, write yours, call the old one — was replaced by append-section: just add your block to the existing file. No renames, no backups, no second code path for restoring originals. Symlinks and binaries get refused outright with a message explaining why.

Pre-commit blocking is universal. It works with every git client, every IDE, every agent. Claude Code’s JSON protocol is powerful but specific. Betting on the universal mechanism and using the specific one as a backstop turned out to be the right layering.

Along the way, the PreToolUse hook got dropped entirely. It had been intercepting git commit calls before they happened, which sounds clever until you realize pre-commit hooks already do exactly that, for every git client, not just Claude Code. The structured JSON hook was solving a problem that git solved decades ago. Removing it meant less code, fewer protocol-specific assumptions, and one fewer thing to break when Claude Code’s hook format evolves. The Stop hook stayed as a backstop — a “hey, you’re about to leave and there’s undocumented work” check that catches anything the pre-commit hook missed.

There was also a performance footnote worth mentioning: the doctor command was taking sixteen seconds in repos with a couple thousand commits because it spawned a subprocess per commit to check file lists. Batching those into a single git diff-tree --stdin call collapsed it to one process. The kind of fix that’s boring to describe and viscerally satisfying to experience.

The takeaway from this whole arc is something like the good neighbor principle for developer tools: don’t assume you’re the only thing in the room. The backup-and-chain hook strategy worked fine in isolation and fell apart the moment another tool showed up with the same assumption. Append-section, tiered classification, graceful refusal — these aren’t technically harder, they just require admitting that your tool is a guest in someone else’s environment. Most tools never make that admission, and most multi-tool setups are a mess as a result.

The hook layer went from silently broken to reliably enforcing in about a dozen commits. It’s not elegant — it’s plumbing. But it’s plumbing that works in shared pipes now, which is more than it could say before.


Written with AI assistance.