The bug report read like a riddle: agent A is blocked, by agent B’s commits, on work agent A never touched. Welcome to parallel-agent flows, where .timbers/ is a shared resource and the gate fires on the wrong actor.
The setup: multiple agents collaborate in branches that eventually merge. They share the same git identity. The old gate walked the full DAG looking for undocumented commits, which meant the moment agent B’s untracked work landed via merge, agent A would hit a wall trying to commit its own (perfectly documented) changes. The gate was technically correct and practically useless.
First instinct was author-based attribution — let each agent answer for its own commits. That collapsed immediately: when every agent commits as the same human, the author filter is a no-op. So we pivoted to a first-parent walk. Git-native, identity-agnostic, and it captures the intuition we actually wanted: what landed on this branch’s mainline? Side branches merged in are someone else’s problem until they show up as first-parent work.
That should have been the whole fix. The regression test said otherwise.
git merge --no-ff creates a merge commit M that does sit on the first-parent line. Even though M contributes no new file changes to this branch, it was still tripping the gate. The fix was a gate-only filter — dropEmptyFileChanges — that ignores commits whose combined diff is empty. Clean merges and --allow-empty commits fall through. The timbers pending display path keeps the older conservative rule (empty = unknown, surface it anyway), because awareness and blocking deserve different thresholds. That split — same data, two policies depending on whether we’re informing or enforcing — is the kind of distinction that’s easy to elide and expensive to lose.
For the narrow case where a merge commit itself touched source (conflict resolution that introduces real changes), there’s TIMBERS_SKIP_CROSS_AGENT_DEBT as an escape hatch. The reviewer caught that my first pass at the env-var docs was misleading and one of the test names contradicted what the test actually asserted. Both got fixed before the commit landed. Small saves, but the kind that quietly keep a codebase honest.
The shape of the lesson: gates and displays answer different questions. A display says “here’s what you might want to know.” A gate says “you cannot proceed.” Conservative defaults are right for the first and wrong for the second. When the same predicate drives both, you inherit the worst of both — noisy displays and false blocks. Splitting them costs a few lines and buys back a lot of trust.
Cut as v0.21.0 — new behavior, no breaking changes, fits cleanly into the multi-agent epic. The changelog draft went through claude -p on the way out, which has become routine enough that I barely think about it anymore. Tag pushed, Actions took it from there.
The gate now fires on the right actor. That feels like solid ground.
Written with AI assistance; the missteps and corrections are real.