- Book: The TypeScript Library — the 5-book collection
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You split "partial refunds" into three tasks and start three agents at once. One builds the store, one the HTTP route, one the client. All three report done, and all three are green in their own worktree.
Then you merge. The store agent added type Refund = { amount: number } to src/types.ts. The route agent added interface RefundDto { amountCents: string } to the same file. The client agent decided a refund has a status field, and nobody else did. Git reports a conflict in src/types.ts, and that is the easy part. The hard part is what git can't see: each agent guessed the shape of the thing all three share, and each guess hangs together on its own branch.
Separate worktrees stop agents from writing over each other's files on disk. They do nothing about this. The fix is older than coding agents: agree on the interface before anyone builds against it. Here that means one small commit with the types and a contract test suite, landed on main before any agent starts. Every task then branches from that commit and owns one side of it.
Every git step below works with any agent, in any terminal. I build Hermes IDE, so weigh the Hermes parts with that in mind.
Step 1: write the contract, not the feature
The contract is two files. The types come first:
// src/refunds/contract.ts export interface RefundRequest { orderId: string; amountCents: number; reason: string; } export type RefundResult = | { ok: true; refundId: string; remainingCents: number } | { ok: false; error: "not_found" | "exceeds_paid" }; export interface RefundStore { request(req: RefundRequest): Promise<RefundResult>; } The decisions live in the types. Money is integer cents. A refund that can't happen comes back as a value with one of two error codes, not as a thrown exception. Left alone, three agents would make those calls three times.
Types don't pin down behaviour, so the second file is a Vitest suite that any implementation can run:
// src/refunds/refund-store.contract.ts import { describe, it, expect } from "vitest"; import type { RefundStore } from "./contract"; // make() must return a store where order "o-1" // was paid 5000 cents and nothing is refunded yet. export function refundStoreContract( name: string, make: () => Promise<RefundStore>, ) { describe(`${name} honours RefundStore`, () => { it("refunds part of a paid order", async () => { const store = await make(); const r = await store.request({ orderId: "o-1", amountCents: 2000, reason: "damaged", }); expect(r).toMatchObject({ ok: true, remainingCents: 3000 }); }); it("never refunds more than was paid", async () => { const store = await make(); const req = { orderId: "o-1", reason: "duplicate" }; await store.request({ ...req, amountCents: 3000 }); const r = await store.request({ ...req, amountCents: 3000 }); expect(r).toEqual({ ok: false, error: "exceeds_paid" }); }); it("reports an unknown order", async () => { const store = await make(); const r = await store.request({ orderId: "nope", amountCents: 1, reason: "test", }); expect(r).toEqual({ ok: false, error: "not_found" }); }); }); } The second test is the one to keep. Each refund fits on its own, and together they don't. An implementation that only checks the request in front of it passes the first test and fails this one.
The file isn't named *.test.ts, so Vitest never runs it directly. Vitest's default include only matches *.test.* and *.spec.* files. It runs when an implementation calls it. A suite with nothing to run against proves nothing, so the contract commit also carries a small in-memory store that passes it:
// src/refunds/memory-store.ts import type { RefundStore } from "./contract"; export function memoryStore( paid: Record<string, number>, ): RefundStore { const left = new Map(Object.entries(paid)); return { async request({ orderId, amountCents }) { const before = left.get(orderId); if (before === undefined) { return { ok: false, error: "not_found" }; } if (amountCents > before) { return { ok: false, error: "exceeds_paid" }; } left.set(orderId, before - amountCents); return { ok: true, refundId: crypto.randomUUID(), remainingCents: before - amountCents, }; }, }; } // src/refunds/memory-store.test.ts import { refundStoreContract } from "./refund-store.contract"; import { memoryStore } from "./memory-store"; refundStoreContract("memoryStore", async () => memoryStore({ "o-1": 5000 }), ); The fake does a second job. The route and client agents can test against it without waiting for the real store. The real store starts with no passing tests at all: the store agent's first move is to call refundStoreContract("sqlStore", ...), watch it go red, and work until it's green.

One shared plan, pinned down before anyone starts building against it.
If you use Hermes, your own checks can decide when a task is done, and per-repository worktree setup is described in the worktree recipes doc. Either way, land the contract before any agent exists:
npx vitest run src/refunds git add src/refunds git commit -m "refunds: RefundStore contract + memory store" Step 2: three tasks from that commit, one owner each
With plain git, each task gets a branch and a worktree cut from the new main:
git worktree add -b hermes/refund-store ../refund-store main git worktree add -b hermes/refund-route ../refund-route main git worktree add -b hermes/refund-client ../refund-client main The prompts matter as much as the branches. Each one names the files the agent owns and the files it must leave alone:
Store: Implement RefundStore in src/refunds/sql-store.ts. First add src/refunds/sql-store.test.ts that calls refundStoreContract("sqlStore", ...). Watch it fail, then make it pass. You own src/refunds/sql-store*.ts. Route: Add POST /orders/:id/refunds in src/http/refunds-route.ts. Take a RefundStore as a parameter and test with memoryStore. Map not_found to 404 and exceeds_paid to 422. You own src/http/refunds-route*.ts. Client: Add requestRefund() in src/client/refunds.ts, returning RefundResult. Test it against a stubbed fetch. You own src/client/refunds*.ts. All three: commit when your tests pass. Do not edit src/refunds/contract.ts or src/refunds/refund-store.contract.ts. If the contract looks wrong, stop and tell me why instead of changing it. That last paragraph carries the whole method. A change to the contract is your decision, made once on main. The branches pick it up from there.
In Hermes, ⌘N (Ctrl+Shift+N on Windows and Linux) replaces the three worktree add lines. Paste one prompt and pick the agent, and the task starts on its own hermes/<name> branch in its own worktree, with the prompt as the agent's first message. Start all three from main, now that it holds the contract, and put the branch names Hermes gives them into the scripts below. Nothing stops you from mixing vendors: Claude Code on the store, Codex on the route.

The ⌘N New task sheet: a refund task for orders-api, Claude Code, a new hermes/ worktree, Accept edits, model and effort chips, and the "Hermes will run" preview.
Step 3: catch overlapping files before anything lands
These checks read what's committed on each branch. If an agent leaves its work uncommitted, the loops print nothing and the dry run in Step 4 reports a clean merge, so have each agent commit when its tests pass, or commit in each worktree yourself before you run them.
Ownership written in a prompt is a request. Agents still wander, so check it. For two branches, list the files each one changed and keep the names they share:
comm -12 \ <(git diff --name-only main...hermes/refund-store | sort) \ <(git diff --name-only main...hermes/refund-route | sort) The three dots matter. git diff main...branch shows the branch's changes since it split from main, not what main did in the meantime. comm compares two sorted lists; -12 hides the lines unique to each side and leaves the ones in both. Empty output means no shared files. The <(...) form is process substitution, so run it in bash or zsh.
For three branches or more, count names instead:
for b in hermes/refund-store hermes/refund-route \ hermes/refund-client; do git diff --name-only "main...$b" done | sort | uniq -d uniq -d prints each repeated line once, and only spots repeats that sit next to each other, hence the sort. A common hit is package.json and the lockfile, when two agents each install something. Pick one branch to add the dependency, or add it on main and rebase both.
Then check the rule that matters most, that nobody touched the contract:
for b in hermes/refund-store hermes/refund-route \ hermes/refund-client; do git diff --quiet "main...$b" -- \ src/refunds/contract.ts \ src/refunds/refund-store.contract.ts \ || echo "$b edited the contract" done --quiet turns off the output and makes git diff exit with 1 when there are differences, so the echo fires only for a branch that changed one of those files.
Put the same loops in CI so they run on every push.
Step 4: dry-run the merges in landing order
An empty overlap list still doesn't prove the branches merge cleanly in sequence. git merge-tree --write-tree does a full merge of two branches without making a commit and "does not read from or write to either the working tree or index." The mode arrived in Git 2.38. Exit status 0 means a clean merge and the output is one tree ID. Exit status 1 means conflicts, and the output adds the conflicted files and messages. Anything else is an error.
Checking each branch against main alone isn't enough, because main moves every time you land something. This script lands the branches in order onto a throwaway commit chain, the same way you'll land them for real:
#!/usr/bin/env bash # land-check.sh main hermes/a hermes/b ... set -u base=$1; shift tip=$(git rev-parse "$base") for b in "$@"; do if ! out=$(git merge-tree --write-tree --name-only \ "$tip" "$b"); then echo "CONFLICT landing $b:" printf '%s\n' "$out" | sed 1d exit 1 fi tip=$(git commit-tree -p "$tip" -p "$b" \ -m "dry run: $b" "$out") echo "clean $b" done echo "dry-run tip: $tip" ./land-check.sh main hermes/refund-store \ hermes/refund-route hermes/refund-client git commit-tree turns each merged tree into a commit with both parents and prints its ID, which becomes the base for the next branch. No branch points at those commits, your checkout and index stay as they were, and the order you pass is the order being tested. With --name-only, the conflict section lists just the file names, followed by git's conflict messages; sed 1d drops the tree ID line.
A clean merge only means git found no clashing edits. Whether the three pieces work together is a test question, so run the suite on the dry-run tip:
git worktree add --detach ../land-dry <dry-run-tip> cd ../land-dry && npm ci && npx vitest run This is where the contract pays off. The route was tested against memoryStore, which passes the same suite the real store has to pass, and the client was written against the same RefundResult. The pieces were shaped to fit before any agent wrote a line.
Step 5: land one at a time, and change the contract only on main
Land the store first, then rerun land-check.sh for the two that are left, because main just moved. With plain git that's git merge hermes/refund-store on main, and the same again for each branch.
In Hermes, the Review Desk (⌘G, or Ctrl+Shift+G on Windows and Linux) shows each task's changes by turn or by file. Land then commits, opens a pull request or merges locally in one step, into the branch the task started from, which here is main, and a landing can be undone.

The Review Desk (⌘G) by turn for a Claude Code task in orders-api: two turns, turn 1's diff of src/db.ts adding a refunded status, the changed files, and a commit message drafted from the turns.
Sooner or later an agent stops and says the contract is wrong. Maybe refunds need a currency, or the client needs the refund's creation time. That's the prompt working as written. Make the change yourself on main, update the memory store so the suite stays green, land it, then bring each open task onto it from inside its worktree:
cd ../refund-route && git rebase main If the rebase stops on a file the task owns, have that agent resolve it on its branch. Then rerun land-check.sh before you land the next one.
If this was useful
The whole method rests on a TypeScript interface and a discriminated union small enough to agree on before any agent starts. If writing contracts like RefundResult still feels like guesswork, the collection below covers it, from first types to the build and library setup that ships them. Start with the first two books; swap in a bridge book if you come from the JVM or PHP, and add the production book if you ship TypeScript at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
Hermes IDE is free to download, source-available under BSL 1.1, runs on macOS, Windows and Linux, and isn't affiliated with any agent vendor; if the ⌘N-per-task flow saves you a merge, a star on GitHub helps others find it.
All five books ship in ebook, paperback, and hardcover.

