Multi-repo development, with Claude Code
A piece of work is one thing. Your tools should treat it that way.
A ticket touches three repos, so you end up with three checkouts, three branches and no
single place that knows they belong together. Repolane makes that one object — a
lane — and keeps everything about it, including your AI session, inside it.
The command is lane.
$ lane start PAY-4182 api billing-core worker
worktree ~/repolane/lanes/PAY-4182/api on new branch PAY-4182 (from origin/main)
worktree ~/repolane/lanes/PAY-4182/billing-core on new branch PAY-4182 (from origin/main)
worktree ~/repolane/lanes/PAY-4182/worker on new branch PAY-4182 (from origin/main) Why this exists
Two problems that look separate and are not: your repos are scattered, and your AI session has no idea which of them it is allowed to care about.
Work fragments across repos
Four checkouts of the same repo, a branch you can't place, uncommitted work in a folder you last opened on Thursday. The unit you think in — a ticket — has no home on disk.
A session sees one repo
Point Claude at a single checkout and cross-repo work becomes a run of context-free single-repo sessions, each re-learning what the last one knew.
Or it sees far too much
Give it the whole machine and it wanders: reading repos nobody asked about, filing a passing detail as a permanent preference, committing before you've looked.
From nothing to working
The whole journey, end to end
Every terminal block below is real captured output, not an illustration. This is what the first twenty minutes actually looks like.
Set the machine up
One clone, one command. lane init checks what it needs, creates the folders,
writes the config and wires the Claude Code hooks. It is safe to re-run: it fills in what
is missing and tells you what it kept.
Checking what this needs:
ok git
ok python3
ok gh (signed in)
ok claude
Setting up \~/Desktop/projects/.boardshot/plane for Akshant Jain:
kept registry/lanes/ (already there)
kept memory/ (already there)
kept knowledge/conventions/ (already there)
kept knowledge/domains/ (already there)
made repos/
made lanes/
made .cache/
made registry/config.yml
made registry/repos.yaml
made memory/MEMORY.md
made knowledge/preferences.md
made CLAUDE.md
kept memory linked into Claude's project folder (already there)
statusLine added
made status line wired into .claude/settings.json
Checking the wiring:
ok hooks configured (.claude/settings.json)
miss the guard is not working — run: bash scripts/hooks/test-guard.sh
Nothing is managed yet. Bring your first repo in:
lane add https://github.com/<org>/<repo>.git
Then start a piece of work:
lane start <id> <repo>
claude Bring your repos in
Each repo is cloned once into repos/ as a mirror that stays clean and stays
on its default branch. Pass a checkout you already have instead of a URL and it adopts
the existing .git, keeps the origin, and remembers where it came from.
$ lane add ~/code/api-origin.git api
cloned ~/code/api-origin.git -> ~/repolane/repos/api
registered api (default main); memory -> memory/api. Edit registry/repos.yaml: stack/product.
$ lane add ~/code/worker-origin.git worker
cloned ~/code/worker-origin.git -> ~/repolane/repos/worker
registered worker (default main); memory -> memory/worker. Edit registry/repos.yaml: stack/product.
$ lane add ~/code/billing-core-origin.git billing-core
cloned ~/code/billing-core-origin.git -> ~/repolane/repos/billing-core
registered billing-core (default main); memory -> memory/billing-core. Edit registry/repos.yaml: stack/product.
$ lane add ~/code/web-origin.git web
cloned ~/code/web-origin.git -> ~/repolane/repos/web
registered web (default main); memory -> memory/web. Edit registry/repos.yaml: stack/product.
$ lane add ~/code/mobile-origin.git mobile
cloned ~/code/mobile-origin.git -> ~/repolane/repos/mobile
registered mobile (default main); memory -> memory/mobile. Edit registry/repos.yaml: stack/product. Start a piece of work
One command creates the branch in every repo the work touches and a worktree of each
under lanes/<id>/. Worktrees share the mirror's object store, so this
costs a checkout of files rather than three clones — and switching between lanes is
instant.
$ lane start PAY-4182 api billing-core worker
worktree ~/repolane/lanes/PAY-4182/api on new branch PAY-4182 (from origin/main)
worktree ~/repolane/lanes/PAY-4182/billing-core on new branch PAY-4182 (from origin/main)
worktree ~/repolane/lanes/PAY-4182/worker on new branch PAY-4182 (from origin/main) Work, with Claude inside the lane
You run claude from lanes/<id>/ — the lane itself, never
one of the repos inside it. The session can edit every repo in scope and reach none
outside it. The board gives you the same work in a browser: the diff you are on, staging,
and a commit box.
lane board — one lane, three repos in tabs, the diff you're on, and the commit box. Bound to 127.0.0.1 with a per-run token.See where everything stands
lane status is the whole picture in one screen: every mirror, every lane,
what is dirty, what is unpushed, and any drift. No model call, no waiting.
$ lane status
== repos (primary mirrors)
api: main, behind origin by 0, worktrees=3
billing-core: main, behind origin by 0, worktrees=1
mobile: main, behind origin by 0, worktrees=1
web: main, behind origin by 0, worktrees=2
worker: main, behind origin by 0, worktrees=2
== lanes
INFRA-661 [active] updated 2026-09-18 ticket=INFRA-661
api/INFRA-661: 0 dirty, 0 unpushed
worker/INFRA-661: 0 dirty, 0 unpushed
PAY-4155 [active] updated 2026-09-18 ticket=PAY-4155
web/PAY-4155: 0 dirty, 1 unpushed
PAY-4182 [active] updated 2026-09-18 ticket=PAY-4182
worker/PAY-4182: 1 dirty, 1 unpushed
billing-core/PAY-4182: 0 dirty, 1 unpushed
api/PAY-4182: 2 dirty, 1 unpushed
PAY-4207 [active] updated 2026-09-18 ticket=PAY-4207
mobile/PAY-4207: 1 dirty, 0 unpushed
api/PAY-4207: 0 dirty, 0 unpushed
web/PAY-4207: 2 dirty, 0 unpushed
== drift: checkouts outside the control plane (should be none; work happens here) Pick work back up a week later
lane brief is what makes a lane survivable. The goal, the note you left
yourself, recent commits per repo, what is still uncommitted, the memory index for each
repo, and the findings that only matter while this work is live.
$ lane brief PAY-4182
━━ PAY-4182 [active] ticket: PAY-4182 updated: 2026-09-18
GOAL
Settlement batches post to the ledger as one atomic transaction, and the nightly job reports drift
per account. Done when a partially-failed batch leaves no half-posted entries, refunds post as new
entries rather than edits, and drift is visible as a metric.
RESUME NOTE
_2026-09-18_: the write path is committed in all three repos. Left: refunds that span two batches
need a single transaction (api), and paging on sustained drift (worker). The new index is written
but not applied — it must run CONCURRENTLY, so it goes out with the infra change, not this deploy.
REPO worker @ PAY-4182
worktree: ~/repolane/lanes/PAY-4182/worker
dirty: 1 unpushed: 1
fe6709f Emit a gauge for per-account drift
09bf626 Log drift per account rather than in aggregate
4988e4f Raise the visibility timeout to 300s
9e33484 Initial commit
M src/jobs/reconcile.py
memory index (~/repolane/memory/worker/MEMORY.md):
- [Queues need 300s visibility](queues-need-300s-visibility.md) — Any queue the reconcile job uses needs a visibility timeout of at least 30
REPO billing-core @ PAY-4182
worktree: ~/repolane/lanes/PAY-4182/billing-core
dirty: 0 unpushed: 1
2b04242 Add Drift for the nightly reconcile
4836029 Recompute balance instead of caching it
0b7d8ef Initial commit
memory index (~/repolane/memory/billing-core/MEMORY.md):
- [Ledger is append only](ledger-is-append-only.md) — The ledger is append-only. Corrections are new entries, never edits or deletes.
REPO api @ PAY-4182
worktree: ~/repolane/lanes/PAY-4182/api
dirty: 2 unpushed: 1
3d1ada5 Post partial refunds as new ledger entries
bacffd5 Index ledger entries by account
6d6762c Post a settlement batch in one transaction
c9b562e Key the rate limiter on tenant, not API key
a89b5f8 Initial commit
M src/services/ledger.ts
?? src/db/migrations/015_ledger_account_posted.sql
memory index (~/repolane/memory/api/MEMORY.md):
- [Migrations are a pipeline step](migrations-are-a-pipeline-step.md) — Schema migrations run as their own pipeline step before the deploy,
- [Rate limits key on tenant](rate-limits-key-on-tenant.md) — The public API rate limit keys on tenant, never on API key.
FINDINGS (live only — archived when this lane finishes)
- 2026-09-17 — the ledger index has to be created CONCURRENTLY; the table is 40M rows and a plain
- 2026-09-16 — settlement totals are integer cents in api but Money in billing-core. Convert at the
LOG (last 5)
- 2026-09-18 started Leave it properly
lane park wip-commits anything dirty and records the note, so nothing is
left loose in a worktree. lane done refuses while any repo is dirty or holds
unpushed commits — and checks every repo before removing any of them.
$ lane park INFRA-661 "waiting on infra to provision the second queue"
wip commit in worker The board
Every lane, and what each one needs
lane board opens a local git client scoped to your work. It sorts lanes by what
wants a decision, not by name, and tells you the next useful action per repo — pull, stage,
commit, push, open a PR, finish.
One timeline across every repo of a lane
A piece of work spans repos, so its history should too. Toggle history from this repo to all repos and the commits interleave into one timeline — with the diff of whichever you pick.
worker, billing-core and api in one list, each tagged with the repo it came from.Memory that has to earn its lifetime
Claude never writes a memory silently. It drafts, then asks which of four scopes it belongs in, chosen by how long it stays true: always, every repo, one repo, or only while this work is live. Left alone, everything becomes a permanent global preference.
It refuses things, on purpose
Every rule exists because the alternative went wrong at least once. These are hooks that inspect the actual tool call, not instructions asking nicely.
- A session stays in its lane. No reading other lanes, no reading the mirrors,
nothing outside the folder. Attach outside code once with
lane refand it becomes readable at a path inside the lane, read-only by default. - Secrets are never read. Not
.env, not credential files — not even with permission, because the read itself is the problem: the values end up in a transcript. Denial is the default, socp .env /tmp/x && cat /tmp/xis refused too. - Default branches are protected. Pushing to
mainneeds a marker you can see. Remotes may never be added, changed or removed, and destructiveghcalls are refused outright. - Nobody enters a repo. The session sits at the lane root, where every repo is equally reachable — so multi-repo work cannot quietly collapse back into single-repo work.
- Commits are proposed, not made. And new branches start from the default branch, not from whatever happened to be checked out.
And you can check the rules still hold
A guard that silently stops guarding is worse than none. lane doctor compiles
the hook dispatcher and fires a known-denied probe at it, so a broken rule fails loudly.
There are 163 test cases behind it.
$ lane doctor
the guard compiles and still refuses what it should What you actually get
A folder of scripts. Nothing to run but git and python.
No package, no build step, no service, no account, and nothing that phones home. Clone it, run two commands, and it is yours.
One-word commands
start, resume, park, done,
status, brief, board, merge. Each
also exists as its own executable, so it runs from any directory.
No dependencies
Bash and standard-library Python. The board is plain HTML, CSS and JavaScript with no build. The GitHub CLI is optional; so is Claude Code.
Your data stays yours
Everything lives in one folder you own, in plain text you can read and commit. The board binds to localhost and makes no outbound calls.