There’s a particular flavor of bug that only shows up when a tool starts eating its own cooking. You can test against synthetic repos all day, but the moment your development ledger is running on its own repo, recording its own commits, interacting with its own hooks — that’s when the interesting failures crawl out.
This round started with a user report from Noam: after a git pull --rebase, timbers was showing pending commits that had already been documented. Ghost work. Phantom entries cluttering the ledger. The kind of thing that makes you distrust your own tooling at exactly the moment you need to trust it most.
The root cause was subtle enough to be satisfying in retrospect and maddening in the moment. When you rebase, Git rewrites commit SHAs — but the old objects don’t vanish. They linger in the object store for about two weeks, waiting for garbage collection. Timbers was using git log to find commits after the stored anchor SHA, and git log was perfectly happy to traverse from a dangling object. It would succeed, return results, and those results were phantoms — commits that existed in the object store but weren’t reachable from HEAD’s history. The stale anchor detection only fired when a SHA was completely gone. A reasonable assumption that turned out to be wrong in exactly the way rebases make things wrong.
The fix was a git merge-base --is-ancestor check before walking the log. If the anchor commit isn’t an ancestor of HEAD, the anchor is stale — full stop, regardless of whether the object still exists. A new IsAncestorOf method on the GitOps interface, a guard clause in GetPendingCommits, and the phantoms disappeared. That shipped as v0.16.4 alongside a worktree hook skip for good measure.
Then the second bug arrived, and this one was pure self-inflicted comedy. We upgraded Beads — the issue tracking layer — to a version that auto-flushes its state to .beads/issues.jsonl and auto-stages that file on every commit. Simpler, more git-native, a clear improvement. Except: timbers has a filter that asks “is this commit ledger-only?” to avoid infinite loops where documenting a commit creates a commit that needs documenting. That filter knew about .timbers/ files. It did not know about .beads/ files. So every timbers entry commit now included a .beads/issues.jsonl change, which made it look like a “real” commit, which meant timbers wanted to document it, which created another commit with another .beads/ change, which…
The loop didn’t actually go infinite — it converged because the content eventually stabilized — but it was generating junk entries and making the ledger noisy.
The fix was a conceptual upgrade disguised as a small code change. isLedgerOnlyCommit became isInfrastructureOnlyCommit with a configurable list of path prefixes — .timbers/, .beads/, whatever comes next. The name change matters more than it looks. “Ledger-only” was a hardcoded assumption about which files are infrastructure. The moment a second tool entered the picture, that assumption broke. The new framing — infrastructure paths are a category, not a singleton — is the kind of generalization that prevents the next version of this exact bug.
The pattern here is one I keep relearning: tools that operate on their own substrate need explicit self-awareness boundaries. A pre-commit hook that modifies tracked files, a CI system that commits to the repo it’s building, a ledger that records its own commits — these are all the same shape of problem. The system needs to know what counts as “its own metabolism” versus “real work,” and that boundary has to be maintained as the ecosystem of tooling grows. Hardcoding it to one path prefix was fine until it wasn’t.
Both fixes shipped quickly — v0.16.4 for the phantom anchors, v0.16.5 for the infrastructure loop. The codebase is a little more honest about the messiness of running git-native tooling in a real git workflow, where rebases happen and hooks proliferate. It’s solid ground, for now — with the explicit acknowledgment that every new tool we wire into the commit cycle is another prefix that needs to know its place.
This post was drafted with AI assistance from development log entries.