One ticket. Every repo it touches.
One task-scoped Claude Code session.
A feature spans your frontend, API and worker. Repolane gives that work one lane: coordinated Git worktrees, shared task context, and tested Claude Code hooks (scripts Claude Code runs before a tool call) that deny a defined set of risky ones.
Repolane is built to work with Claude Code across several repos. Its command,
lane, is the control surface for starting, parking, resuming and checking
lanes, and it also works on its own, without Claude.
A lane is one folder holding a Git worktree of every repo a ticket touches, plus a goal and
a resume note. Lanes live in a workspace: any folder where you ran
lane init, with your CLAUDE.md and lanes/ at the top and
everything Repolane keeps under .repolane/.
Alone, or with Claude Code
What lane does by itself, and what Claude adds
Every lane command runs without Claude Code. Claude Code is what turns the same
lanes into something an agent can walk into: it is driven by hooks that run at session start,
on each prompt and before each tool call.
lane on its ownNO CLAUDE NEEDEDWorktrees
lane start makes a branch and a worktree in each repo you name, grouped as one lane.
Notes
lane park WIP-commits what is dirty (leaving secret-named files out) and records a resume note; lane brief prints it back.
Status
lane status shows repos, lanes and drift. lane done checks every repo before it removes anything, and names any secret-named file the removal would delete.
Board
lane board serves a local page of changes, diffs and history, bound to 127.0.0.1, while you have it running.
A dispatcher briefing
At the root, Claude receives the board of lanes and a few rules at session start, then enters a lane by id with EnterWorktree.
Hook guardrails
A PreToolUse hook denies a defined set of tool calls and says what to do instead. A guardrail, not a sandbox.
A STATE block on each prompt
Inside a lane, every prompt carries the branch, ahead, behind and uncommitted counts of every repo in it.
Resumable notes
A session opened in a parked lane starts with its status and resume note already in context.
How a lane is laid out
One lane, a worktree per repo, one guard
Illustrative diagram, built from the demo lane in the terminal output further down. The
mechanics are real: base clones in .repolane/repos/, a worktree per repo under
lanes/, and the hook boundary.
Settlement batches post to the ledger as one transaction; the nightly job reports drift per account.
Write path committed in all three repos; refunds spanning two batches still need a single transaction (api).
Drift gauge and per-account logging in the reconcile job.
Partial refunds as new ledger entries; account index migration.
Drift type and balance recompute for the nightly reconcile.
The agent works from the lane root. A PreToolUse hook inspects Bash, Read, Edit, Write, MultiEdit, NotebookEdit, EnterWorktree, Glob and Grep calls before they run. Glob lists names only, so it gets the path rules but no secret check.
9 tool types matchedThe filename matches the secret-file patterns, so the hook denies the call.
When api/ holds a .env, a search needs a glob that leaves it out ("!.env*") or one that cannot match it ("*.py").
An ordinary edit inside the lane's own worktree: the guard answers allow, so Claude Code does not prompt.
Read the full architecture — mirrors, lifecycle, hooks and memory →
Recorded with Claude Code
What Claude actually sees, from the root to a lane
Five stages of one recorded run, in Claude Code's own terms: what the hooks hand Claude, how it
walks into a lane, how one session works two repos, what the guard says when it refuses, and what
survives a park. Claude's steps were recorded headless on Claude Code 2.1.299 with two small
repos; the hook text, command output and refusals were captured again from a real workspace in
the same state. Text is quoted as captured; the scratch directory's prefix is shown as
… and trimmed output is marked […].
- 01
At the root: the board and the rules
Start Claude Code at the workspace root and, before your first prompt, a SessionStart hook hands it the owner's name, the board of lanes and a short set of dispatcher rules. A second hook adds one line to every prompt typed at the root.
- Injected by the SessionStart hook (excerpt)
- ## lane dispatcher — board at 2026-10-22 22:47 Owner of this workspace: Trial User (REPOLANE.md calls them "the owner"). - **CHG-2** [active, updated 2026-10-22] · api/CHG-2 (0 dirty, 0 unpushed) · web/CHG-2 (0 dirty, 0 unpushed) (written by lane-park: where things stand, next step, blockers) Repos: api web Commands: lane and the lane-* commands are on PATH; call them by name, here and after EnterWorktree (e.g. lane-brief <id>). Workspace data: ~/trial/.repolane (registry, memory, knowledge, mirrors). The rules: ~/trial/.repolane/REPOLANE.md, and LANE.md inside a lane. Dispatcher instructions (details in .repolane/REPOLANE.md "Dispatcher mode"): […] - Fresh session: list the active lanes above, one line each, and ask which one or what new work to start. - Resume: lane-resume <id>, then EnterWorktree name="<lane-id>". New: lane-start <id> <repo>…, then EnterWorktree name="<lane-id>". A name is the only way in (no path, no repo); you land at the lane root and reach each repo at <repo>/… . […] - Leave: lane-park <id> "<note>" then ExitWorktree keep. […] Rules: work only in lanes/, mirrors in .repolane/repos/ stay clean, park before switching, nothing pushed or deleted unasked. See docs/rules.md for why each one exists.
- Added to each prompt typed at the root by the UserPromptSubmit hook
- [lane] at the dispatcher root, no lane entered. If this request is code work on a repo, first offer/enter a lane (EnterWorktree) and say so; do not edit code from here.
Claude Code 2.1.299, recorded 2026-10-16; hook text captured 2026-10-22. Message wording can differ from what you see today; run it to see the current text. The board lines are generated from the lane files. The rules are instructions Claude reads, not something the hook enforces by itself. - 02
Entering a lane by id
Claude runs the resume script, then calls EnterWorktree with the lane id as the name. The session lands at the lane root, with every repo of the lane beside it.
- Resuming lane CHG-2 now. This recreates its worktrees and prints its state.
- Bash(lane-resume CHG-2)
- […] --- api [CHG-2] 0 dirty, 0 unpushed 28bddcc init --- web [CHG-2] 0 dirty, 0 unpushed 65423ee init next: cd …/lanes/CHG-2 && claude
- EnterWorktree({"name": "CHG-2"})
- Created worktree at …/lanes/CHG-2. The session is now working in the worktree. Use ExitWorktree to leave mid-session, or exit the session to be prompted.
- Operator note: I entered the lane worktree at `lanes/CHG-2` with `EnterWorktree`. Next I’ll pull the lane brief, which is read-only.
- Bash(lane-brief CHG-2)
- ━━ CHG-2 [active] ticket: updated: 2026-10-22 GOAL (one paragraph: what done looks like) RESUME NOTE (written by lane-park: where things stand, next step, blockers) REPO api @ CHG-2 worktree: …/lanes/CHG-2/api dirty: 0 unpushed: 0 […]
Claude Code 2.1.299, recorded 2026-10-16; hook text captured 2026-10-22. Message wording can differ from what you see today; run it to see the current text. After EnterWorktree the session sits in the lane, and the PostToolUse hook hands it the lane rules (.repolane/LANE.md). The lane commands are the long names the package puts on PATH, so they run from the lane root without a cd. - 03
One session, two repos
Inside the lane, api and web sit side by side. Claude edits both from the same session using absolute paths for files and git -C for git, and never leaves the lane root.
- Read(…/lanes/CHG-2/api/src/server.py)
- 1 def handler(): 2 # TODO: add auth 3 return {"ok": True} 4
- Write(…/lanes/CHG-2/web/src/footer.js)
- File created successfully at: …/lanes/CHG-2/web/src/footer.js (file state is current in your context — no need to Read it back)
- Edit(…/lanes/CHG-2/api/src/server.py)
- The file …/lanes/CHG-2/api/src/server.py has been updated successfully. (file state is current in your context — no need to Read it back)
- Bash(git -C …/lanes/CHG-2/api diff)
- diff --git a/src/server.py b/src/server.py index b956c8e..7a46db3 100644 --- a/src/server.py +++ b/src/server.py @@ -1,3 +1,3 @@ def handler(): - # TODO: add auth + # TODO: add auth (done) return {"ok": True}
- Bash(git -C …/lanes/CHG-2/web status)
- On branch CHG-2 Your branch is up to date with 'origin/main'. Untracked files: (use "git add <file>..." to include in what will be committed) src/footer.js nothing added to commit but untracked files present (use "git add" to track)
Claude Code 2.1.299, recorded 2026-10-16; hook text captured 2026-10-22. Message wording can differ from what you see today; run it to see the current text. The per-prompt text inside a lane says not to cd into a repo, and the hook refuses a cd that would leave the lane (the first refusal is in the next stage). The branch is CHG-2 in both repos: one lane, one branch name. - 04
The guard, refusing in a live session
Tool calls pass through a PreToolUse hook before they run. These are four refusals, with the exact text Claude got back. The last three came from a self-test Claude was asked to run.
- Bash(cd …/lanes/CHG-2/api && git status)
- PreToolUse:Bash hook error: Stay in the lane — do not cd into api. Anything that must run inside a repo runs there without moving the session: a command: lane-run api <cmd…> (mvn, yarn, tests, scripts) GitHub: lane-gh api <args…> (pr create, pr checks, workflow run) git: git -C api … files: api/path/to/file (edit and read them directly)
- Bash(cat api/.env)
- PreToolUse:Bash hook error: Secret file. Claude never reads .env/credential files here, even with permission — the read itself is the problem. For structure use `lane-env-keys <path>` (names only) or .env.example; for behaviour, run the app and read its output. (command references api/.env)
- Bash(git -C api push origin main)
- PreToolUse:Bash hook error: That pushes to a protected branch (develop, main, master). Ask Trial User first. If they agree, run it with the marker they can see: ALLOW_DEFAULT_BRANCH_PUSH=1 git push …
- Bash(lane done OTHER-2)
- PreToolUse:Bash hook error: `lane done` targets lane 'OTHER-2' but you are in 'CHG-2'. Lifecycle commands only act on this session's own lane; the dispatcher at the workspace root handles the others.
Claude Code 2.1.299, recorded 2026-10-16; hook text captured 2026-10-22. Message wording can differ from what you see today; run it to see the current text. “Trial User” is the owner name set in the scratch workspace. Each refusal names the way forward, and Claude retried the cd with git -C or lane-run every time. This is a guardrail, not a sandbox: see the security model below for what the hook does not cover. - 05
Parking, and coming back
lane park commits work in progress and saves a resume note. The next session, in the lane or at the root, starts with that note already in Claude’s context.
- Bash(lane-park CHG-2 "api auth stub done; web footer pending")
- wip commit in api left out of the commit (secret file names, still on disk): .env.local id_rsa wip commit in web parked CHG-2
- Next session, opened in the lane: SessionStart context (excerpt)
- ## lane — session in lane 'CHG-2' (CHG-2) --- id: CHG-2 status: parked ticket: […] ## Resume note _2026-10-22_: api auth stub done; web footer pending […] Scope: only lanes/CHG-2/. Park with lane-park CHG-2 "<note>" before they switch away. Lane commands (lane-brief, lane-park, lane-memory, …) are on PATH: call them by name, e.g. lane-brief CHG-2.
- Or opened at the root: the board carries the note (excerpt)
- ## lane dispatcher — board at 2026-10-22 22:47 Owner of this workspace: Trial User (REPOLANE.md calls them "the owner"). - **CHG-2** [parked, updated 2026-10-22] · api/CHG-2 (2 dirty, 1 unpushed) · web/CHG-2 (0 dirty, 1 unpushed) _2026-10-22_: api auth stub done; web footer pending - **OTHER-2** [active, updated 2026-10-22] · web/OTHER-2 (0 dirty, 0 unpushed) (written by lane-park: where things stand, next step, blockers)
- Two lanes exist. **CHG-2** is parked: the api auth stub is done, the web footer is still pending, and api has 2 uncommitted files and 1 unpushed commit. **OTHER-2** is active on web, clean with nothing unpushed.
Claude Code 2.1.299, recorded 2026-10-16; hook text captured 2026-10-22. Message wording can differ from what you see today; run it to see the current text. Files whose names look like secrets (here .env.local and id_rsa) are left out of the WIP commit and stay on disk. lane done names such files, says to move them into .repolane/repos/ / or rerun with --discard-ignored, and does not count them as dirty.
The guard is a guardrail, not a sandbox. A tool call it cannot make sense of, or an internal error while judging one, is denied; other hook events fail open. The security model below says exactly where it stops. The same lifecycle, step by step, is in Working with Claude.
Why this exists
Working across repos without a lane, and with one
Why coding agents lose the thread when forced into a single-repo checkout, and what a task-scoped lane changes about that.
Ad-hoc clone multiplication
Four copies of the same repo in varying states — ~/src/api-1, ~/src/api-refactor. Stashed diffs get lost and disk space evaporates.
Single-repo blind spots
Claude Code opened inside api/ works from that repo's directory. The client code that consumes your payloads lives next door, and it guesses at contracts it should be reading.
Secret ingestion hazards
An agent with an unrestricted shell can read .env files or certificates, putting credentials into a session transcript.
Mental state evaporation
Context is gone after a week away — uncommitted WIP with no note on what was still failing.
Base clones, per-ticket trees
One clone per repo stays in .repolane/repos/, never worked in directly. A lane adds a worktree that shares its objects: no re-clone, though the checked-out files still take disk.
One root above every repo
Claude opens at the lane root, above every repo, with the goal and your last resume note already in its context.
Hook-enforced guardrails
A PreToolUse hook denies reads and edits of files whose names match common secret patterns. A push to a protected branch (develop, main or master) needs an ALLOW_DEFAULT_BRANCH_PUSH=1 marker in the command: a visible flag in the transcript, not a permission check.
A note, not just a branch
lane park WIP-commits anything dirty, untracked files included but secret-named ones (such as .env), and records your resume note. lane brief prints it back, any time later.
Workflow
Start, work, park, resume
The everyday sequence, from a fresh ticket to coming back after a week away.
Start a coordinated lane
A branch and worktree from each named repo's default branch, grouped as one lane, with an editor workspace file written for the set.
Drive with Claude Code
Run Claude from the lane root, above every repo. The guard hook evaluates the tool calls it covers before they execute.
Park, then pick back up
lane park takes a resume note (10+ characters) and WIP-commits what is dirty. lane resume recreates any missing worktrees and prints the note; lane brief is the read-only view.
What it does
Four mechanisms, each with a command
Task-scoped Git worktrees
Each repo is cloned once into .repolane/repos/. A lane adds a worktree and a branch named after the ticket in every repo it touches, so two tickets never share a working directory.
Shelf-life memory
Four real scopes — pref, cross, repo, lane — decide how long a memory stays true, instead of one markdown file that only grows.
A finish line that checks
lane done inspects every repo before removing anything. It refuses while a worktree is dirty, while commits exist that no origin/* branch has, or while deleting the worktree would destroy a secret-named file (--discard-ignored overrides that last one).
A local board, on demand
lane board starts a standard-library Python server bound to 127.0.0.1 for status, diffs and commits across a lane. It runs only while you have started it.
Real output
lane brief output
Output of lane brief on a demo lane (a fictional three-repo ledger project),
with the username scrubbed from paths and long lines cut by the command itself. The
diagram above is drawn from this same lane.
$ lane brief PAY-4182
━━ PAY-4182 [active] ticket: PAY-4182 updated: 2026-10-22
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-10-22_: 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: ~/work/lanes/PAY-4182/worker
dirty: 1 unpushed: 1
1a229e2 Emit a gauge for per-account drift
e0dbf4c Log drift per account rather than in aggregate
ec1c73c Raise the visibility timeout to 300s
8e8c810 Initial commit
M src/jobs/reconcile.py
memory index (~/work/.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: ~/work/lanes/PAY-4182/billing-core
dirty: 0 unpushed: 1
822cb6e Add Drift for the nightly reconcile
edc6705 Recompute balance instead of caching it
5cb4e68 Initial commit
memory index (~/work/.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: ~/work/lanes/PAY-4182/api
dirty: 2 unpushed: 1
ad4886c Post partial refunds as new ledger entries
b1d534d Index ledger entries by account
3248c88 Post a settlement batch in one transaction
aa8b033 Key the rate limiter on tenant, not API key
3036592 Initial commit
M src/services/ledger.ts
?? src/db/migrations/
memory index (~/work/.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-10-22 — the ledger index has to be created CONCURRENTLY
- 2026-10-22 — settlement totals are integer cents in api but Money in billing-core
LOG (last 5)
- 2026-10-22 started
- 2026-10-22 parked: the write path is committed in all three repos. Left: refunds that span two batches The full lifecycle this comes from — starting, working across repos, and parking for later — is recorded step by step in Working with Claude.
Tool-call inspection, not an OS sandbox
The hook inspects Bash, Read, Edit, Write, MultiEdit, NotebookEdit, EnterWorktree, Glob and
Grep calls before they run (nine tool types) and denies a defined set of them
with an explanation. Glob only lists names, so it gets the path rules but not the secret
check. WebFetch, MCP tools and the Task/Agent call itself are not in the matcher, so the
hook never sees them, and the secret rule matches a fixed set of filename shapes. This is a
guard against agent carelessness, not an operating-system sandbox: it does not claim
to prevent prompt injection. If a PreToolUse input is malformed, or the guard hits
an internal error while judging a call, it denies the call; the other hook events (prompt
context, compaction, session end, file-edit bookkeeping) fail open. It also cannot see inside a
script run by name, and a lane created earlier keeps the tool matcher it was created with until
lane start, resume, done, merge or ref refreshes it. For a determined adversary, run the whole thing inside a VM or
container and treat the guard as one more layer inside it.
scripts/hooks/guard.py in the package, standard-library Python.env* (not .env.example, .sample, .template, .dist), id_rsa and other id_* keys, .netrc, .npmrc, *.pem, *.key, *.p12, *.pfx, *credentials*.json, service-account*.json, secret(s).json/yaml/yml/toml and a few morelane doctor -v runs the full suite (over 1,800 cases); plain lane doctor fires eleven named probesRead the full security model →, including exactly where the mechanism stops.
Use with care. Claude and other AI models can make mistakes: they can edit the wrong file, run the wrong command or misread a rule. Repolane's hooks reduce that risk but cannot remove it, and they are a guardrail, not a sandbox. Review what Claude changes, push only what you have read, and keep credentials you cannot afford to lose out of reach. You are responsible for what runs on your machine and in your repositories. Repolane is provided “as is”, without warranty, under the Apache License 2.0.
Install
Install, then start your first lane
Before you run this: you need git, python3 and
bash. The test suites pass on macOS and on Ubuntu 24.04; live Claude Code sessions were run on macOS only. Windows is untried (the README
suggests WSL). gh is optional, and so is Claude Code: every lane command works
without it, and the hook guardrails only matter once Claude Code is driving. Each repo must be added
with lane add before lane start can name it.
- 1.pipx install repolane
- 2.mkdir ~/work && cd ~/work
- 3.lane init
- 4.lane add https://github.com/octocat/Hello-World.git hello
- 5.lane start TRY-1 hello
- 6.cd lanes/TRY-1 && claude
Step 1 is once per machine (uv tool install repolane works too). Any folder where
lane init ran is a workspace; it asks for your name, and asks before it edits a
CLAUDE.md you already have or links Claude Code's memory under ~/.claude.
Steps 4 and 5 use GitHub's public octocat/Hello-World demo repo so they run exactly
as written; for real work, swap in your own repo's URL (or a path to a checkout you already
have) and repeat lane add for every repo a lane will name. The PAY-4182
lane in the diagram and terminal above is a separate, fictional demo. Set up from a git clone?
Move it to a workspace with lane migrate.
Going deeper: Architecture · Security · Troubleshooting · Contributing
Public source, tagged release v0.11.0.
Apache 2.0. The scripts contain no telemetry or analytics code (checked for outbound HTTP), but they do
use the network when something needs it: git remotes (including a fetch in every base clone each time
you run lane status), PyPI when you run lane upgrade, gh if you
have it (the board polls it while open). The board binds to 127.0.0.1.