The Work(tree) Incident
The little guys in our family love Minecraft. But they want a story, and different monsters, and custom weapons they keep dreaming up, and worlds that look more “pretty.” The list goes on and on. This summer we decided to actually build the thing: our own video game, from scratch. Story, lore, combat systems, the whole shebang.
Last Thursday evening was the kickoff. The goal: build Battlemoon (working name) from an empty repo into something approaching a playable first milestone by Monday.
But a game has a lot of systems. Audio, assets, game engine, voxel engine, shaders, menus, inventory. To get a basic sandbox on screen before Monday, I needed more than one agent working at a time: multiple Claude sessions pulling stories from the backlog, each building a different subsystem in parallel.
The issue tracker handled concurrency exactly as designed. Append-only JSONL, no conflicts, both agents’ status updates and comments interleaving cleanly in the event stream.
Then the second agent called xpo start.
Dodged a bullet
xpo start does two things: transitions the issue to DOING and creates a git branch. Creating a branch means git checkout -b, which means switching the primary checkout. If the first agent had uncommitted work (which it would have, five minutes later), that checkout would have yanked the working tree out from under it.
It didn’t. The primary checkout happened to be idle on main at that moment. Timing luck, not design.
I stared at it for a minute and realized the issue tracker had solved concurrency at the data layer but was still doing its git operations in the shared checkout like it was 2019. One git checkout in a shared working directory, and the whole multi-agent story falls apart.
Hubs and Spokes
The answer as to stop thinking about it as a branching problem and start thinking about it as a workspace problem. Git worktrees exist for exactly this reason: isolated working directories that share a single .git store.
The design I landed on:
Hub. The primary checkout stays parked on main permanently. It never checks out a feature branch. It’s the integration point: merges happen here, and the issue database lives here. A natural git trunk for all story-based code work. The web board watches the hub’s issues.db file, so any event from any agent shows up in real time.
Spokes. Each xpo start creates a git worktree at .xpo/worktrees/<branch>/. The agent gets back the absolute path and can cd into it. The hub doesn’t move. Two agents, two worktrees, no interference.
Events serialize through the hub. This was the subtlest part. Today, issues.db mutations happen wherever the process runs. If you’re on a feature branch, the event lands on that branch’s copy. Under the hub model, the storage layer auto-discovers the primary checkout via git rev-parse --git-common-dir and routes all reads and writes there. Feature branches carry only code. Events are always on main.
This means the board reflects real-time state from all agents, always. An agent in a worktree calls xpo comment and the board updates instantly because the event went to the hub’s file, not the worktree’s stale copy.
Building it
I broke it into three pieces and built them bottom-up.
First, the hub-rooted storage layer. A HubRoot() function that discovers the primary checkout from any git context, cached per-process. Every filepath.Join(".xpo", "issues.db") in the codebase, fourteen production files, replaced with filepath.Join(storage.XpoDir(), "issues.db"). Every git add .xpo/issues.db replaced with git -C <hub> add .xpo/issues.db. All existing tests passed without changes because HubRoot() falls back to "." in non-git contexts.
Second, the start rewrite. The worktree path is the default; --no-wt falls back to the old checkout behavior. A worktree_setup hook in the config lets projects run build steps after creation. In battlemoon, that’s the Godot import cache. --force with worktrees removes the existing worktree and creates a fresh one, which makes sense: if you’re force-claiming an issue, you want a clean slate.
Third, the merge changes. xpo merge skips the CheckoutBranch(main) step entirely when worktrees are enabled, since the hub is already on main. After a successful merge it removes the worktree and prunes. The working-tree-clean check got relaxed to ignore .xpo/ changes, because under per-merge event cadence, uncommitted events on the hub are expected, not dirt.
The test
I set up a scratch repo, created two issues, started both. Two worktrees appeared, hub stayed on main. Made commits in both worktrees. Added comments from inside each worktree. Both comments landed on the hub’s issues.db, visible in the board immediately. Merged story A, worktree cleaned up, story B’s worktree untouched. Merged story B, everything clean.

The moment that got me was watching the board while running all of this. Every status change, every comment, every merge: it all showed up live. Branch badges appeared next to issues as agents started work. Commit counts ticked up in real time. I could open the merge view on any story and follow the diff as it grew. Not because I built some special sync mechanism. Because all events route through one file, on one branch, watched by one file watcher. The architecture collapsed a hard problem into a trivial one.
What this unlocks
This is the piece that was missing for xpo drive --loop to work with parallel agents. Before, you’d need to run agents sequentially to avoid checkout conflicts. Now you can point three agents at the backlog and they’ll each grab a story, create their own worktree, build, and merge, without any coordination beyond the git lock that serializes the merge step itself.
Concurrent agent work goes from “technically possible if you’re careful” to “safe by default.” No lasting cost either, since xpo merge cleans up the worktree when it’s done.

And the game? By Monday morning we’d pushed 202 story points through Exponential, hitting Milestone 1 and were greeted with a beautiful sunrise on our castle.