Repolane

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.

~/repolane
$ 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.

lane init
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
$ 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
$ 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.

The Repolane board showing an unstaged diff in the api repo, with worker and billing-core in tabs alongside The Repolane board showing an unstaged diff in the api repo, with worker and billing-core in tabs alongside
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
$ 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
$ 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
$ 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.

The Repolane board home screen: four lanes across five repos, with uncommitted and unpushed counts The Repolane board home screen: four lanes across five repos, with uncommitted and unpushed counts
Three active lanes and one parked, across five repos. The summary line at the top is the answer to “what should I do next?”

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.

The board's history view showing commits from worker, billing-core and api interleaved in one timeline The board's history view showing commits from worker, billing-core and api interleaved in one timeline
Commits from 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.

The board's memory browser showing cross-repo and per-repo scopes, and one memory with its reasoning The board's memory browser showing cross-repo and per-repo scopes, and one memory with its reasoning
Each memory carries its why and how to apply — so a fact that turns out to be wrong can be argued with, not just obeyed.

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 ref and 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, so cp .env /tmp/x && cat /tmp/x is refused too.
  • Default branches are protected. Pushing to main needs a marker you can see. Remotes may never be added, changed or removed, and destructive gh calls 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.

Read why each rule exists →

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
$ 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.