A teammate working in gorewood/vellum pinged me with a small but irritating paper cut: their post-commit hook was nudging them to log commits that timbers log itself refused to accept. Commits touching only .beads/issues.jsonl would trip the hook’s “hey, document this” message, but the moment they tried, log would shrug and say there’s nothing actionable here. The two halves of the same tool were disagreeing in public.

The fix was less interesting than what the bug revealed. pending, log, and the post-commit hook each had their own implicit notion of what counts as undocumented work — overlapping, but not identical. The post-commit path, in particular, had never bothered to ask HasPendingCommits before printing its nudge. Three views of the same question, one of them politely lying.

I considered the narrow fix: inline the missing check in runPostCommitHook and call it a day. But that leaves the divergence in place — the next hook anyone adds (a post-rewrite handler, say) gets to invent its own definition all over again. So instead we pulled the whole gate into a hasActionablePending() helper that bundles the repo check, the interactive-git-op check, the .timbers/ existence check, storage construction, and the pending check into one place. Pre-commit and post-commit now route through the same function. There is exactly one answer to “is there actionable work here?” and both hooks have to live with it.

The tests were where things got mildly sideways. I wanted a table-driven harness covering .beads/-only commits, .timbers/-only commits, lockfile churn, .timbersignore-skipped paths, substantive changes, and the missing-.timbers/ directory case. The trouble: if you add .timbersignore as its own commit in the test setup, that commit becomes actionable, and by the time you get to the vendor-skip scenario, pending is already true for the wrong reason. The harness needed a seedFile escape hatch so .timbersignore could be baked into the initial commit and stay out of the pending ledger. Small thing, but the kind of small thing that quietly invalidates a whole test file if you miss it.

The general lesson — the one I want to remember in six months — is that when multiple code paths answer the same user-visible question, they need to share the function, not just the intent. Comments saying “keep this in sync with X” are wishes. A single helper is a constraint. The bug in vellum existed because three call sites were each implementing “is this worth bothering the user about?” by hand, and one of them quietly forgot a clause.

I cut v0.20.1 as a patch right after. The changelog draft went through the usual pipeline, the site examples got regenerated, the landing-page badge ticked up, and the tag pushed to let Actions handle the binaries. A small release for a small fix, but the kind that immediately stops being annoying in someone else’s repo — which is the whole point.

Written with AI assistance.