Reference
Changelog
Every release of Repolane, read straight from CHANGELOG.md in the tool's own repository at build time. Current version 0.11.0.
On this page
- Unreleased
- 0.11.0 — 2026-10-25
- 0.10.0 — 2026-10-24
- 0.9.2 — 2026-10-19
- 0.9.1 — 2026-10-18
- 0.9.0 — 2026-10-18
- 0.8.1 — 2026-09-22
- 0.8.0 — 2026-09-22
- 0.7.2 — 2026-09-22
- 0.7.1 — 2026-09-21
- 0.7.0 — 2026-09-21
- 0.6.0 — 2026-09-20
- 0.5.0 — 2026-09-20
- 0.4.0 — 2026-09-20
- 0.3.0 — 2026-09-20
- 0.2.1 — 2026-09-20
- 0.2.0 — 2026-09-19
- 0.1.0 — 2026-09-18
Read from VERSION and
CHANGELOG.md in the
tool's repository when this site was built, on 2026-10-25. Nothing on this page is typed out
here, so it cannot drift from the tool.
Notable changes to Repolane. The format follows Keep a Changelog, and the version numbers follow Semantic Versioning.
lane --version reports the version in VERSION, which is the only place the number is
written down. Inside a git checkout it also says how far past the release tag you are.
Unreleased
Nothing yet.
0.11.0 — 2026-10-25
Changed
- The board has a new look. A left rail (Active lanes, Parked, Memory, and the lanes in flight). Home lists the
active lanes as dense rows: ticket, title, status and one next-action button; the Goal and resume note on one line;
one line per repo with its branch, status chips and last activity. Parked lanes are single lines in a section that
folds away. Home’s right column has what to do next, memory drafts waiting for a scope (File it, Discard) and recent
Claude conversations (click to copy
claude --resume <id>). A lane opens on its repos, the changes split into staged and unstaged, the diff beside them and the commit box underneath; About this lane is one scrolling side panel (Goal, resume note, Findings, repos, references with Reveal and Detach, sessions, the lane file). Dialogs, the palette, toasts and dark mode follow the same design. - The board looks like repolane.dev: the same warm paper and amber palette (and its dark counterpart), Inter, JetBrains Mono and Instrument Serif. The fonts ship in the package and are served by the board, so it makes no outbound requests at all. It is also a little denser than before.
- Every board screen has a real address.
/lanes/<lane>/<repo>(with?file=for an open diff),/lanes/<lane>/<repo>/history,/lanes/<lane>/<repo>/commits/<sha>,/memory/<scope>, and/w/<name>/…for another workspace. Back and Forward step through screens, a reload or a pasted link lands on the same one, and an old/#lane=…link still works. The board’s API moved to the same shape (/api/lanes/<lane>/repos/<repo>/stageand so on); its old query-string endpoints are gone. Nothing outside the board called them.
Added
GET /api/sessionson the board server: the recent conversations it already caches per repo, for Home.
0.10.0 — 2026-10-24
Added
-
Workspaces. A folder with a
.repolane/folder in it is a workspace: your ownCLAUDE.md,.claude/andlanes/stay at the top, and everything else Repolane keeps (registry/,memory/,knowledge/,repos/,cache/,config.yml,env.sh) lives under.repolane/. An installedlanefinds the workspace by looking up from the current folder;LANE_WORKSPACEstill names one directly. A git clone of Repolane keeps its layout and keeps acting on itself, whatever folder you run it from. -
A workspace’s hooks are
lane hook <event>commands (lane hook pre-tool-use,lane hook session-start, …), in.claude/settings.jsonand in every lane’s settings, so no hook names a file inside the installed product. -
.repolane/REPOLANE.mdholds the rules every session gets (yourCLAUDE.mdimports it with@.repolane/REPOLANE.md) and.repolane/LANE.mdthe rules for a session inside a lane. Neither carries your name: the hooks name the owner when a session starts. Both are rewritten on the firstlanecommand or hook after the installed version changes; your data is never overwritten. -
A session that enters a lane with
EnterWorktree, or starts inside one, is handedLANE.mdright away. It says plainly that Edit and Write outside the lane folder are refused, and to save the Goal withlane-memory goal "<text>"and memories withlane-memory draft <slug> "<text>". -
The Python package installs the long command names (
lane-memory,lane-brief,lane-run, …) next tolane. -
lane initfrom the installed package makes a workspace in the current folder, or in the folder you name (lane init ~/work). It asks for your name (--owner, or--yesto ask nothing), and--gitmakes the workspace a git repo with a.gitignorefor what is machine-local (lanes/,.repolane/repos/,.repolane/cache/). It adds Repolane’s hooks to an existing.claude/settings.jsonwithout touching your own keys or hooks, and records the workspace in~/.local/share/lane/planes.jsonsolane upgradecan refresh every workspace. Running it again over a workspace in use changes nothing and says so.lane initin a git clone of Repolane works as before. -
lane doctorin a workspace checks thatlaneis on PATH the way the hooks call it (if it is not, every hook fails and Claude Code runs tools unguarded, and the doctor says so loudly), thatCLAUDE.mdimports.repolane/REPOLANE.md, that.claude/settings.jsonruns the expectedlane hookcommands, that the rule files are the installed version’s, that the workspace is registered, and that every lane’s settings run the current hooks. Each problem comes with its fix, andlane doctor --fixrepairs what it safely can (--yesanswers its question). It exits non-zero while anything is wrong. It reports an outdated workspace instead of refreshing it. -
lane initin a workspace links Claude Code’s memory for the workspace root (~/.claude/projects/<folder>/memory) to.repolane/memory, the way a clone’slane initlinks it tomemory/;lane addlinks each mirror’s to.repolane/memory/<repo>. Nothing under~/.claudeis changed without asking: init shows each link and asks “Make these links under ~/.claude? [Y/n]” (--yesaccepts; with no terminal and no--yesnothing is changed and it prints the commands). Notes already in Claude Code’s own memory folder are copied in first.--claude-home <dir>(orCLAUDE_HOME) names a different Claude Code folder.lane doctorchecks the links of the root and every mirror, andlane doctor --fixrepairs them after asking. When you answer no, that is recorded in.repolane/declined.json:lane initandlane adddo not ask again, andlane doctorshows a note with how to make the link later instead of a failure (lane doctor --fixstill offers it, and a yes clears the record). A missing link nobody declined is still a failure. In a workspacelane addfollows the same rule for the new mirror’s link: it asks,--yesaccepts, and with no terminal it changes nothing under~/.claudeand prints the command. -
A workspace’s status line is
lane statusline, added to.claude/settings.jsonbylane initwhen no status line is set (your own is never replaced).lane doctorreports one that still runs a clone’sscripts/statusline.shby path, and--fixreplaces it; a workspace with no status line gets a note, not a failure. -
lane migrateturns a git-clone install (a 0.9.x control plane) into a workspace, in place. Run it from the installed package:lane migrate [folder](default: the current one). It first shows everything it would do and changes nothing until you confirm (“Migrate now? [y/N]”, or--yes; with no terminal and no--yes, or with--dry-run, it only shows the plan). It refuses while Claude Code is running inside the folder. Before the first change it writes a backup of everything it changes or removes to~/.local/share/lane/backups/<folder>-<time>.tar.gz, andlane migrate --undo <backup>puts the clone install back (keeping what was written in the workspace since).- Your data moves under
.repolane/(registry/,memory/,knowledge/,repos/,.cache/ascache/,registry/config.yml,scripts/env.sh,scripts/local/); the worktrees stay inlanes/and are repaired withgit worktree repair, and their.envlinks follow the mirrors. - Repolane’s own files are removed: only the paths a release shipped (the 0.9.x releases are listed in
scripts/migrate-manifest.json, made from their tags byscripts/release-migrate-manifest; the installed release’s own list is written into the package when it is built), never anything else in the folder. The clone’s.gitis Repolane’s repo, so it is removed too (it is in the backup);--gitstarts a fresh, empty one. - The generated
CLAUDE.mdbecomes a short one that imports.repolane/REPOLANE.md, keeping every line you added. If lines of Repolane’s own text were edited, the old file is also kept asCLAUDE.md.pre-0.10. .claude/settings.jsonrunslane hook …commands andlane statusline, keeping your own keys and hooks, and every lane’ssettings.local.jsonis written again.- Outside the folder, each asked first: the memory links under
~/.claude/projects(and a mirror’s project folder, whose path changed) point into.repolane/memory; the routerlane installwrote in~/.local/binis removed when no other clone install uses it.planes.jsonrecords the workspace instead of the clone. - A migration that stops half way continues when you run it again; running it on a workspace says there is nothing to do and changes nothing.
- Your data moves under
-
A git-clone install that has been set up shows one line on SessionStart and in
lane status: Repolane installs as a package now, andlane migrateconverts the folder.migrate_reminder: falseinregistry/config.ymlturns it off. -
lane upgradefrom the installed package upgrades it the way it was installed (pipx upgrade repolanefor a pipx install,uv tool upgrade repolanefor a uv tool install; for any other install it prints thepipcommand to run), then refreshes every workspace registered inplanes.jsonwith the new version: the rule files and data migrations, Repolane’s hooks in.claude/settings.json(your own keys and hooks kept), and every lane’ssettings.local.jsonandCLAUDE.md. It reports each workspace; nothing you wrote is changed. A registered folder that is gone is skipped and reported, and you are asked whether to drop it from the registry (--drop-missingor--yesdrops it). It exits non-zero when a workspace could not be refreshed. Run inside a workspace that is not registered, it registers it.lane upgrade --checkshows the installed version against the newest on PyPI and which workspaces are behind, and changes nothing (it says so when PyPI cannot be reached);--refresh-onlyskips the package step. -
A workspace gets Repolane’s slash commands (
/status,/lane-brief,/lane-start, …) and agents (lane-manager,context-loader,branch-gardener):lane init, the refresh after an upgrade andlane upgradecopy them into the workspace’s.claude/commands/and.claude/agents/, by name, in versions that run thelane-*commands on PATH and name the.repolane/paths..repolane/claude-files.jsonrecords what Repolane wrote, so only a file that is still exactly that is ever replaced: your own file of the same name, or one of Repolane’s you edited, is kept, and one you delete stays deleted.lane doctorchecks them, andlane doctor --fixbrings them up to date.lane migratereplaces a clone’s (which ranbin/by path) with them. The package no longer carries a clone’s own.claude/agentsand.claude/commands. -
Each release is published to PyPI as
repolane(pipx install repolane,uv tool install repolane) by.github/workflows/publish.yml, with trusted publishing and no stored token, from the wheel the package test has just checked.
Changed
- Repolane’s own files (
lane,scripts/, templates,VERSION) and your data (registry/,lanes/,repos/,memory/,knowledge/) are located through two separate roots, which are the same folder in a clone, so nothing behaves differently. The repo also builds a Python package (repolane) whoselaneruns the bundled product. - Your
CLAUDE.mdis only edited when you say so. A workspace without one gets a smallCLAUDE.mdthat imports.repolane/REPOLANE.md; for an existing one,lane initshows the line and asks “Add it? [Y/n]” (--yesadds it; with no terminal and no--yesit is left alone, and the output says how to add it). Until the line is there, Repolane’s rules do not load, andlane doctor --fixcan add it later. lane upgradein a git clone still fast-forwards the clone when the new version is 0.10 or later, so it keeps working, andlane doctorin such a clone then spells out the two steps that move it over:pipx install repolane(oruv tool install repolane), thenlane migrate <folder>.lane statusin a workspace heads its first section “workspace” instead of describing a clone.lane doctorno longer writes a__pycache__folder into Repolane’s own files, and the guard refuses a tool call (instead of letting it through) if its path resolver is missing.
Fixed
- In a workspace, a resumed conversation started in a mirror is found again: the hooks look for its project folder
under the mirror’s real path (
.repolane/repos/<repo>), not underrepos/<repo>. lane parkno longer deletes a lane’s## Findings: it replaces the Resume note’s own text, up to the next heading, and keeps every other section.lane-memory file … lane,lane merge,lane briefandlane donealso read and write one section up to the next heading, so a section you add to a lane file, or a Findings at the end of it, is kept and shown where it belongs.- In a workspace, what a session is told speaks of the workspace: SessionStart (at the root and in a lane), a resumed
conversation, UserPromptSubmit, PreCompact, the Worktree hooks, the guard’s refusals, the MEMORY SCOPES table, the
menu,
lane-memory fileand the Findings placeholderlane startwrites into a lane file name the real paths (.repolane/registry/…,.repolane/memory/…, the mirrors under.repolane/repos/), the commands by the name on PATH (lane-with,lane sessions), “the workspace”, and.repolane/REPOLANE.mdfor the details, and no longer a clone’sscripts/folder, a relative path that “exits 127” or the product’s own files. A shared project rule’s refusal names the file it came from (registry/rules.shared.yaml), notregistry/rules.yaml. A git clone’s text is unchanged. - In a workspace the editor workspace files (
lane.code-workspace,lanes/<id>/<id>.code-workspace) hide the mirrors and the cache where they are,.repolane/reposand.repolane/cache, instead of a clone’sreposand.cache. - The README, the package’s description and
docs/extending.mddescribe Repolane as a workspace, not a control plane. lane helpfrom the installed package no longer lists a clone’sinstallanduse, andlane install/lane userefuse with what to do instead (lane init [folder],lane migrate <clone-folder>); they wrote a clone’s router into~/.local/binand switched between clones. A git clone keeps both.- A session started at the root that enters a lane with
EnterWorktreecan save the Goal and draft a memory without being asked for approval: the root pre-approveslane-memory goalandlane-memory draft(a lane’s own settings only apply to a session started inside it). A workspace gets them in.claude/settings.jsonfromlane init,lane migrate, the refresh after an upgrade andlane upgrade, andlane doctorreports them missing (--fixadds them); a git clone gets them in the root’s.claude/settings.local.json.lane-memory filestill asks, andlane-memory goalstill refuses another lane’s Goal from inside a lane. - A clone’s
./lane installno longer takes over alanethe installed package put on PATH: when~/.local/bin/laneis the package’s launcher (pipx, uv tool or pip) it refuses, changes nothing and points atlane migrate. It used to write its router over it and register the pipx venv’sbin/as a plane named “bin”; an old link that does not lead to a git clone of Repolane is no longer registered at all.lane migratekeeps the router only for registered entries that are real clone installs, not for a venv or a folder that is gone. - A session inside a lane is refused
lane migrate,lane initandlane upgrade, with a message that the owner runs them from a terminal at the root.
0.9.2 — 2026-10-19
Fixed
- A session that entered a lane through
EnterWorktreecan now save its Goal, draft a memory, and readmemory/andknowledge/. 0.9.1 let such a session read and edit its own lane file by permission rule, but Claude Code has a second check that no setting turns off: once a session is inside a worktree, its Edit and Write tools refuse any path outside that folder (“This session is isolated in the worktree … Edit the worktree copy”), and the lane file and the draft inbox live in the control plane root. Those writes now go throughlane-memory, which runs from inside the lane without asking:lane-memory goal "<text>"sets the lane’s own Goal (and refuses another lane’s), andlane-memory draft <slug> "<draft>"(or-with the text on stdin) fills the inbox. Reads ofmemory/,knowledge/and the inbox are allowed in every lane’s settings (Claude Code applies a Read rule to its search tools too, which the headless test here could not exercise). The control plane’sCLAUDE.mdand the hook’s reminders say to use these commands instead of Edit or Write.tests/t_lanefile.shcovers the commands, the rules and the guard, and a real Claude Code session that entered a worktree saved a Goal and a draft and read both folders. The same trace found thatCLAUDE.mdtells a session to call thelane-*scripts by absolute path while the pre-approved rules named only the bare commands, so following the instructions meant an approval prompt for every call; the rules now cover both forms.lane-status,lane-menuandlane-sessionsalso exist underscripts/now, so the absolute pathCLAUDE.mdgives no longer ends in “No such file” (exit 127). A lane made earlier picks the rules up the next timelane start,with,resume,done,mergeorrefrefreshes it.
0.9.1 — 2026-10-18
Fixed
- A session inside a lane can now save its Goal and Findings. The dispatcher text and the control
plane’s
CLAUDE.mdtell a lane session to write its lane file (registry/lanes/<id>.md), and the guard allows exactly that edit, but the file sits in the control plane root, outside the lane’s folder, and Claude Code refuses a path outside the session’s folder unless a rule allows it: the session could not even read its own lane file (“you haven’t granted it yet”) and had to suggest leaving the lane to write the Goal from the root.lane start,lane withand every refresh now write an allow rule for that one file (ReadandEdit, as an absolute//path, which is the form Claude Code accepts) into the lane’s settings. Nothing else underregistry/is reachable from a lane, and another lane’s file stays refused by both layers;tests/t_lanefile.shcovers the rules, the refresh and the guard, and the edit was checked with real Claude Code. A lane made earlier picks the rule up the next timelane start,resume,done,mergeorrefrefreshes it. TheCLAUDE.mdtemplate now names this exception to “change the control plane from the root” (rule 12); an existing control plane keeps theCLAUDE.mdit already has, so add that sentence by hand if you want it there.
0.9.0 — 2026-10-18
Added
- Test suites that run on their own throwaway control plane, and a CI workflow that runs them.
tests/run-all.shruns the eighttests/t_*.shsuites (dispatcher, memory, upgrade, lifecycle — park / done / brief —, secret names, portability and exit codes, and the second audit’s fixes), each in its own sandbox with a temporaryHOMEand local bare repos as remotes, so nothing needs installing, logging in or reaching the network.scripts/board/test_board.pyis a stdlibunittestsuite that starts the real board server on a scratch plane (smoke.shnow runs it).scripts/hooks/test-guard.shgrew from 184 to well over a thousand cases, and no longer asserts a hard-coded case count (nobody notices when that number is edited to match a suite that lost half its cases): every section must run at least one case, every case must also see the guard exit 0, and the totals must add up. The guard now runs against a fakeHOMEinside the fixture, so the result no longer depends on whether the real$HOMEor$TMPDIRis under/Users,/home,/tmpor macOS’s/var/folders(before, the same suite scored 184, 179 or 157 depending on where it ran)..github/workflows/tests.ymlruns all three on every push and pull request onubuntu-latestand onmacos-latestpinned to/bin/bash(3.2). The three suites pass on macOS (bash 3.2) and in an Ubuntu 24.04 container run as an ordinary user from a clean clone; the workflow file itself has not yet run on GitHub. lane use --remove <name>forgets a registered plane. Only the registry entry goes; the checkout is never touched. If it was the active plane there is none afterwards (the command says so) andlane use <name>picks one: no replacement is guessed, because guessing the wrong plane is how the wrong project’s data got used once. Unknown names exit 1.lane done --discard-ignoredandlane done --pushed-to <remote>, described under Changed.scripts/gitstate.sh, one shared definition of “unpushed”, “dirty”, “secret-named” and a valid lane id, sourced by the lane scripts that used to each count their own way.tests/fails if its secret-name pattern ever differs fromguard.py’s or the board’s.- The board accepts
WS_BOARD_REFRESH(seconds, default 3) for how often it re-reads git state.
Changed
-
The guard (
scripts/hooks/guard.py) was rewritten around a small shell reader instead of regular expressions over the raw command line. It now understands quotes (including$'…'and$"…"),;&|and newlines,$( ), backticks,( ), redirect targets, and followsbash -c,eval,env -Sandsu -c. A heredoc, here-string or whatecho/printfpipe in is data forcatorgit commit -F -and code for a shell or interpreter. The old regexes were quadratic: a ~24 KB command took about 12 seconds and ~96 KB more than 30, past the hook’s timeout; a 96 KB command now costs the same as a short one. What is now denied that passed before:- Secret reads through a substitution or a reader flag:
ls $(cat .env),basename "`cat .env`",file -f .env,wc --files0-from=.env. The allow-list of metadata-only commands (ls,stat,wc,find,lane-env-keys, …) is matched as the exact command word and only without the options that turn them into readers. Names are judged after the shell would have read them: earlier variables in the same command (f=.en; cat ${f}v), brace expansion,$'\x2eenv',$(echo .env). A.env.examplethat is a symlink to.envis treated as the secret. Lane references (lanes/<id>/refs/…) are now secret-checked; they returned “allowed” before the check ran. - Whole-folder reads over a folder that holds a secret:
grep -r,rg --hidden/-uu,tar c,zip -r,cp -r,rsync,scp -r,diff -r,find … -exec <reader>, and the Grep tool (the PreToolUse matcher now includesGlob|Grep;Globlists names only and is left alone). Lists of names piped into a reader (ls .env | xargs cat) are judged as if typed aftercat. Interpreter one-liners (python3 -c,node -e, …) are searched for secret names, common ways of assembling one ('.'+'env',chr(46), base64) and out-of-plane paths. - Pushes to a protected branch in every spelling the old whitespace-sensitive check missed:
git push origin main;,main&&…,+main,'main',HEAD:refs/heads/main,:main,--delete main,--all,--mirror, and a baregit pushwhile a protected branch is checked out, including insidebash -c,eval, heredocs,xargsandenv -S. Agit <word>git does not ship may be a user alias forpush, so one naming a protected branch is refused (git lgandgit co featureare not). - Git config that would run code or change where hooks come from:
-c alias.*,core.hooksPath,include.path,GIT_CONFIG_*,GIT_EXEC_PATH,GIT_TEMPLATE_DIR,git configsetting any of those or any program-valued setting (core.editor,core.pager,core.sshCommand,credential.helper, …), and writes to.git/configor.git/hooks. cdescapes:pushd,builtin cd,\cd,{ cd /; },eval cd /, a barecd. The working directory is now simulated statement by statement, socd ..; cd ..; cd ..leaves a lane on the second step andcd app && cat ../../../../xis judged fromapp.$HOMEitself (~,$HOME,${HOME}), which was never expanded:rm -rf ~,grep -r token ~. Symlinks inside a lane are resolved before the path rules apply (except a lane’s own attached reference).- Writes to the control plane from inside a lane that the old list missed:
lane,VERSION,CLAUDE.md.template,.github/,.cache/(except the memory inbox),registry/(except the lane’s own spec), every.claude/settings*, writes throughbin/*symlinks, andgit -C <plane> checkout|reset|clean|apply. Control-plane commands (lane upgrade,init,install,add,use,rules pull,secrets add|ignore) are for the dispatcher session;lane-done|park|resume|withact only on the lane you are in;lane-ref <other lane> add|rmis refused;lane-ref addrefuses/, the home directory, system and credential directories and secret files. - More
ghwrites:apiwith a body or a non-GET method (including lowercase/quoted-X,--method=PUT, implicit POST),pr merge|close,secret|variable set|delete,alias set,extension install|exec,ssh-key|gpg-key add,release|issue|label|run|cache delete,workflow disable,repo edit --visibility|--default-branch|--name,auth token. - More write verbs treated as writing every path they name:
perl -i,ruby -i,awk -i inplace,patch,ed,ex,vi/vim,sponge,tar,unzip,zip,curl -o,wget -O.
What is now allowed that was falsely denied:
gh repo edit --delete-branch-on-merge; text that merely mentions a secret’s name (echo/printfoutput, a commit message with-m/-am,gh --body/--title, a grep pattern, an--exclude=/-g '!…'/find -namepattern, a heredoc body);git rm --cached,git check-ignoreandgit ls-files;cp .env.example .envinto the lane (only a source that is a template by name and by where it leads);GIT_EDITOR=true git rebase --continue;git lg/git co feature; and a.venv/bin/pythonthat points into a version manager (the program being run is not followed through its links).Things that may surprise you. A recursive
grep -r(orrg,tar,cp -r, the Grep tool) over a lane whose repo has a.envis now denied unless it excludes it: add--exclude='.env*'(and--exclude='*.pem' --exclude='id_*'),rg -g '!.env*', an--include, or search a subfolder. The denial text says so, per tool.rgandagskip hidden files by default, so they trip only on--hidden/-uuor a non-hiddenserver.pem. A folder with more than 100,000 files is refused as too big to vouch for;.git,node_modulesand virtualenvs are not searched for secrets. Acdsequence is judged step by step, and acdto a computed target, in a loop or in a function is refused inside a lane.git -c alias.x=push x origin mainand anything settingcore.hooksPathis denied outright. The guard now fails closed forPreToolUse: a payload it cannot make sense of (non-stringcommand, non-pathcwd) or an unexpected exception while judging a tool call is answered with a deny that says why, because a hook that crashes exits non-zero without a decision and Claude Code then runs the tool. Every other event (UserPromptSubmit,PreCompact,SessionEnd,PostToolUse,Worktree*) still fails open. Denials also read better: writing a secret-named file now gets write wording instead of “never reads”; writing a lane’s.envsymlink says it is a symlink into the read-only mirror and what to do (rmthe link, copy the template, ask the owner for values);rmof just that link is the suggested route. - Secret reads through a substitution or a reader flag:
-
ALLOW_DEFAULT_BRANCH_PUSH=1is now documented as advisory, and works per statement. It records that the human agreed to a push to a protected branch; it never overrides a force-push, and because Claude can type it as easily as a person can, it does not prove consent. A hard version needs something the model cannot produce, for example exporting the variable in the environment Claude Code is launched from. That is not implemented. -
What the guard still does not see is listed in
scripts/hooks/README.md: the contents of a script run by name (bash build.sh,source x), a variable set elsewhere (cat $f), the output of commands other thanecho/printf/cat, interpreter code that builds a name by arbitrary computation, whatgit diff --cached/git log -pprint from history, and whether anrgrun is stopped by a.gitignore. Input it cannot read at all (curl … | sh,bash -c "$(…)", nesting past 4 levels, more than 8 programs in one command) is refused, not ignored. -
lane donecounts “unpushed” againstorigin/*, not against the branch’s upstream.lane startcuts a lane branch fromorigin/<base>, so its upstream is the base branch, and a plaingit push origin <id>(no-u) never changed that:lane donekept saying “unpushed commit(s)” after a push, and counted base-branch commits as pushed work before one. The same definition now driveslane status,lane brief,lane resume,lane doctor, the board, the status line and SessionStart: commits onHEADthat noorigin/*branch has.lane startalso stops treatingorigin/<id>as a lane’s base after agit push -u. -
lane donerewrites, in detail.--pushed-to <remote>checks against that remote’s branches instead oforigin(for a fork); the refusal text suggests it. A repo with nooriginis local-only: it used to makelane donerefuse (it required@{u}); now it is noted (“nothing to push, the branch stays in repos/”) and the lane finishes. A repo with no commits is noted too; a detached HEADcarrying commits no remote has is refused with how to fix it. Refusals name the exactgit pushto run.lane donealso refuses while a worktree holds ignored or untracked secret-named files that deleting the worktree would destroy, naming them, unless you pass--discard-ignored(symlinks thatlane startmade into the mirror are skipped, since deleting them loses nothing, and so is anything insidenode_modules,.venv,vendor,dist,build,.git, etc.). The previous behaviour deleted ignored files silently. For a parked lane, untracked secrets thatlane parkdeliberately left out are no longer reported as “dirty — commit it, or lane park first” (a dead end: park will never commit them); they are named with what to do. The board,lane statusand SessionStart likewise stop counting them as dirty. -
lane parknever commits a file whose name says secret. It usedgit add -A, which committed untracked.env-style files. It now stages by explicit literal path, leaves secret-named files on disk, and says which:left out of the commit (secret file names, still on disk): …. The names are.envand.env.*,id_rsa/id_ed25519/id_ecdsa/id_dsa,.netrc,.npmrc,.pgpass,.git-credentials,.htpasswd,*.pem,*.key,*.p12,*.pfx,*.p8,*.jks,*.keystore,*.kdbx,*credentials*.json,service-account*.jsonandsecrets.{json,yaml,yml,toml}; templates ending.example,.sample,.templateor.distare not secrets. A secret someone already staged stops the park with nothing committed (git reset -- <file>and run it again), and renames and copies are checked on both sides. -
Existing lanes keep the old hook matcher until
lane resume. The newGlob|Grepmatcher and the larger per-lane deny list are written into a lane’s.claude/settings.local.jsonbywrite_guard; lanes created earlier still have the old ones, so the Grep tool skips the guard there.lane doctornow flags such a lane (“the PreToolUse matcher predates Glob/Grep”) and so does its check of the control plane’s own.claude/settings.json;lane resume <id>rewrites it. The per-lane Claude Code deny rules now cover the same secret names as the guard, not only.env*,*.pemand*.key. -
The pre-approved (no-prompt)
lane-*list got shorter.lane-memoryandlane-workspacewere approved wholesale althoughlane-memory file|rm|movewrite and delete markdown andlane-workspacerewrites the.code-workspacefiles. Now onlylane-memory list|show|auditstay pre-approved (in.claude/settings.jsonand in every lane’s settings); the write verbs andlane-workspaceprompt. A refresh removes the old entries from existing lanes. As before, the list only applies once the workspace has accepted Claude Code’s trust dialog; an untrusted headless run ignores it. (The dialog path itself was not exercised in the trials.) -
Dispatcher text. SessionStart and
CLAUDE.md.templatetold ClaudeEnterWorktree path=<plane>/lanes/<id>/<repo>, which the guard denies (one way in:name); Claude recovered by retrying, now it is toldEnterWorktree name="<lane-id>"(no path, no repo; you land at the lane root and reach each repo at<repo>/…). The same text now says to call lane scripts by absolute path (it prints the scripts directory) because a relativescripts/lane-briefdoes not exist from the lane root and exits 127. -
lane syncandlane importexit codes tell the truth.lane syncexits 1 whenghis missing or not logged in, when there are no repos, when any repo failed, and when nothing could be checked at all (every repo local-only or not on GitHub); 0 only when at least one repo was read from GitHub and none failed.lane import --listexits 1 when there are no conversations to show. Scripts that chained on a 0 from these will now stop. -
lane upgradeafterlane init.lane initedits.claude/settings.json(and.gitignoreandscripts/local/on aprivate: truefork), which madelane upgraderefuse. Those are now allowed to be dirty, and untracked files never block. If upstream also changes.claude/settings.json, it is put back toHEAD, fast-forwarded, and your local top-level keys re-applied (a key changed on both sides keeps upstream’s value and says so). Any other dirty file that upstream also touches is refused up front with agit stash/lane upgrade/git stash poprecipe, instead of a failed merge. -
Board hardening (
lane board,scripts/board/README.md):- Every request,
GET /and static files included, must carry aHost(andOrigin, when sent) of127.0.0.1orlocalhoston the server’s own port, else 403. Before,GET /served the token to any Host, so DNS rebinding could read it and drive the whole API. One token still covers every registered plane. The token has never been printed at start; the docs that said it was are corrected. - Every git call runs with
--literal-pathspecs;/api/stageand the diff endpoint accept only exact changed files (or a directory holding changed files).., globs,..and option-like names are refused 400..used to stage.env, and a file nameds*.jsonused to leaksecrets.json’s diff through/api/show. - Amend and commit refuse while a secret is staged; renames and copies are checked on both
sides (
git mv .env notes.txtused to look harmless), including in a commit’s file list. - Secret names match case-insensitively (
.ENV) and cover the guard’s full list. - Request bodies over 1 MiB get 413, a non-numeric
Content-Length400, a body that is not a JSON object 400; lane ids and memory names outside[A-Za-z0-9._-]+are refused with 400; a stalled client times out after 20 seconds. The refresh loop setsGIT_OPTIONAL_LOCKS=0so it no longer makes your owngit addfail withindex.lock exists. - The board’s “ahead” uses the same origin-based count as
lane status.
- Every request,
-
lane statussays it fetches. It has always rungit fetch -q originin every repo underrepos/; its help line and header comment now say so. -
Smaller:
lane <cmd> --helpprints only the usage comment directly under the shebang (some commands used to print developer notes);lane-env-keysandlane-env-checkas installed stubs now map to the right dispatcher commands (keys,env) instead of failing; the “184 more cases” line inlane doctorno longer states a count.
Fixed
- Path traversal in
lane-memory(and the board’s/api/memory).drop,file,moveandrmbuilt paths from an unvalidated slug, solane-memory drop ../../anythingdeleted, andfile/movecould move, any.mdfile you own, with no prompt because the command was pre-approved. Slugs, repo names, lane ids and memory names must now match[A-Za-z0-9._-]+(not.or.., checked byte-wise so a locale cannot letéthrough), andlane-memoryrefuses to write through a symlinked memory folder.lane brief ../xused to readregistry/x.md; invalid lane ids now just report “no lane”. lane briefon a finished lane printedsederrors and dropped repos, findings and references because it read the live lane file; it reads the archive throughout and says “worktree removed when the lane finished”.- Bare
lanebeforelane initprinted rawsed:/fatal:and exited 128; it now says “this control plane is not set up yet” and exits 2 like the others. lane rules pulloverwrote a same-named file inscripts/local/, which is yours. It now keeps your file and says so, unless an earlier pull put it there (tracked in.cache/scripts-pulled), in which case it is still updated.- macOS and Linux portability:
lane add’s candidate prompt usedmapfile(absent on macOS bash 3.2);lane ref addusedmd5(absent on Linux; now a short SHA-1 via Python);lane-env-keys’sedused\+, which BSD sed lacks. The Claude project-folder name is now computed as every character other than letters, digits and-turned into-, as Claude Code does, so a path with a.or a space (/.boardshot) finds its sessions; it used to map only/and_. - SessionEnd no longer piles up duplicate log lines in the lane file: a line identical to the last one except for its timestamp is skipped, so read-only and denied runs stop filling the log.
lane statuswith no repos yet no longer iterates over a literalrepos/*/.lane initfilled the wrong placeholder inCLAUDE.md, so{{owner}}stayed literal; it now fills it.lane initlinked memory under the wrong Claude project folder for a control plane whose path has a.or a space (it still mapped only/and_); it uses the same slug rule as everything else.lane doctor -vexited 0 when the guard suite failed; it now exits with the suite’s status.- The board linked tickets to
lanes:whentracker.urlwas blank; a blank or missing URL now means plain-text keys, no link. lane done --discard-ignoredwith an untracked secret file removed the folder by hand aftergit worktree removerefused, leaving a stale worktree record, and archived the lane. It now passes--forceonly when that flag was given and nothing but those files is in the way; if the worktree still cannot be removed (locked, in use) the lane is not archived and the command exits 1.- The per-prompt STATE block’s
uncommittedcount included untracked secret-named files, whichlane statusleaves out; both use the same definition now. - README and CONTRIBUTING.md said
lane initasks two questions (it asks up to five), and CONTRIBUTING.md still described a 184-case suite withEXPECTED_CASESand nine probes; both and the other stale counts indocs/now match the code.
Security
- Closes the leak and destruction paths above:
lane-memorypath traversal; the board’s staging, amend and show-by-glob secret leaks; the board’s missing Host/Origin check;lane parkcommitting untracked secrets; the guard bypasses listed under Changed (command substitution and reader flags,.examplesymlink exemption, lane references skipping the secret check, push-refspec spellings, unexpanded$HOME,cdvariants, malformed input crashing the guard open, Glob/Grep outside the matcher, control-plane self-protection gaps). Most of these were found by independent audits and two live trials against Claude Code 2.1.299 in headless (claude -p) mode, the second with thehaikumodel; each fix has a regression case. - Limits that remain (so this section is not read as a guarantee): the guard reads commands, it
does not run a shell, and the list of what it cannot see is in
scripts/hooks/README.md. Stronger models, interactive permission prompts, the trust dialog, live Claude Code sessions on Linux, Windows, the hook timeout and a subagent calling Grep/Glob/WebFetch/MCP were not tested. A hookallowskips Claude Code’s own permission prompt, as observed in the trials. WebFetch, the Agent tool call itself and MCP tools remain outside the matcher (a subagent’s own Bash/Read/Edit/Write calls do reach the guard).
0.8.1 — 2026-09-22
Fixed
- Two findings from the multi-plane registry’s own adversarial audit, before it ever reached
main. Registering a second checkout under a name already used by a different one (usually an auto-derived directory basename collision) silently overwrote the first plane’s registered path — the exact “wrong project’s data” failure the registry exists to close, reintroduced via the name axis instead of the old symlink axis.lane installnow refuses a name collision with a different path, confirming interactively or refusing outright with no terminal to ask. Separately,lane installwriting~/.local/share/lane/planes.pyused a plaincpwith norm -ffirst, unlike every other destination write in the same install step — the identical symlink-clobber class of bug the rest of the install logic was already hardened against, just missed in this one spot. Both caught by adversarial testing against the real change before it merged, not found in the wild.
0.8.0 — 2026-09-22
Added
- A machine can now register more than one control plane at once.
~/.local/share/lane/planes.jsonholds every checkoutlane installhas registered and which one isactive; a newlane use [<name>]lists them (with which isactiveand which the current directory resolves to) or switchesactive.~/.local/bin/laneis now a small, stable, generic dispatcher that is never repointed at one specific checkout again — on every call it resolves which registered plane applies (being physically inside a project’s directory tree always wins overactive) andexecs that checkout’s own reallane.lane installwrites this dispatcher and registers the current checkout, and only changesactivewhen nothing is active yet or--as/--make-activesay so — installing from a second or third project no longer silently stealsactiveaway from whatever you were already using. An old, pre-registry install is detected and migrated automatically. The 27 long-form commands (lane-start,lane-brief, …) are now tiny stubs that route through the same stable dispatcher rather than each resolving independently. lane boardgained a plane switcher: with more than one registered plane, a dropdown in the command bar loads another registered plane’s data in place — no second server process, no second tab — while defaulting to whichever plane the server was started from/inside. The page<title>now names the plane currently shown, instead of the static"lane-board".lane doctorchecks the new mechanism: whether the installedlaneis the stable dispatcher (not a stale pre-registry symlink), whether this checkout is registered at all, and whether resolving from inside it actually picks it.
Fixed
- The general form of the cross-contamination bug described in
v0.7.2above. That fix covered one specific symptom (slash commands resolvinglane-*through a plainPATHlookup instead of$CLAUDE_PROJECT_DIR). The underlying cause was broader and is now closed at the root: before this change,~/.local/bin/lane(and every long-form command) was a symlink directly at ONE specific checkout, machine-wide — only one project could ever be “installed” at a time, and runninglane installfrom a second project silently broke the first. On the machine this was found on, that caused three separate incidents in one session:lane-briefrun inside one project reported a completely unrelated project’s lanes becausePATHpointed elsewhere;lane upgradewas reported “no such command” becausePATHpointed at a checkout that predated the command; andlane boardopened empty becausePATHhad since been repointed to a third, currently lane-less checkout. The multi-plane registry above is the fix: a checkout is chosen by being physically inside its directory tree, never by whatever a single machine-wide symlink happened to be pointed at last.
0.7.2 — 2026-09-22
Fixed
- Slash commands could silently report a completely different repolane installation’s state.
Every
.claude/commands/*.mdfile (lane-brief,lane-done,lane-help,lane-park,lane-resume,lane-sessions,lane-start,status) invoked its script as/bin/<name>(a leading slash) or, forlane-start, as$AD/bin/lane-start—$ADwas never an actual exported environment variable, only ever computed locally inside each script. On a machine with more than one repolane install, both forms resolve through a plainPATHlookup instead of the current project, landing on whichever installation happens to be onPATH— reported live: a freshlane-start’d lane, then/lane-briefon the very same id, said “no lane ‘pr-review’. Active lanes: SHOP-412 SHOP-418” — a real, unrelated repolane checkout’s lanes, reported as if they were the current project’s. Both forms now use$CLAUDE_PROJECT_DIR(Claude Code’s own, actually-exported signal for “which project is this”), the same variable every hook in.claude/settings.jsonalready uses correctly.permissions.allow’s patterns for the affected read-only commands (lane-status,lane-brief,lane-menu,lane-sessions) updated to match the new invocation text (Bash permission rules match literal command text, not the resolved program).
0.7.1 — 2026-09-21
Fixed
knowledge/shared/(written bylane rules pull’sknowledge.d/) was never read by anything. It landed on disk and stayed invisible to a session unless someone wired a pointer to it by hand. Every.mdfile there is now injected into every session’s context automatically, the same wayknowledge/preferences.mdalready is — same “applies to every session, forever” shape as your own preferences, so shared team knowledge actually reaches a session without extra setup.
0.7.0 — 2026-09-21
Added
lane rules pullalso carriesknowledge.d/: the shared repo’s optional layout gains aknowledge.d/directory alongsiderules.yamlandscripts.d/— plain markdown, copied (never symlinked) under the same SHA pin. Unlike rules/scripts, these files aren’t validated or executed, just read by a session — but they still can’t silently overwrite something you typed by hand, so they land in a newknowledge/shared/namespace, never directly inknowledge/.lane rules statusreports what’s loaded there too.
Fixed
lane initnever disabled the template’s own push remote: after cloning this public template,originstill pointed atgithub.com/aj-oss-tools/repolane— a plaingit pushfrom inside the control plane would have publishedregistry/config.yml(can hold a private tracker URL) andregistry/old-checkouts.json(local filesystem paths) to that public repo.lane initnow disables the push side oforigin(git remote set-url --push origin DISABLED-set-a-private-remote-first) whenever it still demonstrably points at the known public template — never a remote you’ve already pointed somewhere of your own. Idempotent, andlane doctornow flags it too, as a safety net for anyone who set up before this fix existed.lane secrets <repo> addreads as bookkeeping but is destructive: every user-facing touchpoint (scripts/repo-secrets’s own help text,lane doctor’s undeclared-secrets advisory,lane add’s interactive/non-interactive prompts,docs/rules.md,docs/commands.md) used “declare”/“protect” language that read as passive bookkeeping.addactually writes deny rules that make the named file(s) unreadable and unwritable to every future Claude Code session — that is now said plainly everywhere the command is offered.
Added
lane statusnow reports the control plane’s own git state:memory/,knowledge/,CLAUDE.mdwere invisible togit status-checking users forever — afterlane initthey’re real uncommitted files with nowhere shown to check. A new== control planesection (reusingstatus-all’s existing dirty/unpushed-counting logic) reports uncommitted file count, unpushed commit count, and whether a push remote is configured (or disabled, per the fix above).private: trueinregistry/config.yml: upgrades the documentedgit add -fworkaround (docs/extending.md, “Tracking your control plane’s own files in a private fork”) into a real mode.lane initasks about it interactively (like the tracker/stale-days questions), or takes--private/--public, and rewrites.gitignoresoregistry/repos.yaml,registry/rules.yamlandscripts/local/are tracked instead of ignored — every other ignore rule (repos/,lanes/,memory/,knowledge/,registry/rules.shared.yaml,registry/.shared-cache/,registry/secrets-ignored.yaml) is untouched. Idempotent, and safe to flip retroactively by editingconfig.ymland re-runninglane init.lane secrets <repo> ignore <path-or-glob>...: marks ascancandidate as reviewed-and-not-actually-a-secret, without writing any deny rule. Most candidates fromscanturn out to be ordinary source, not secrets, and previously the only way to makelane doctorstop mentioning one was to deny it. Ignored paths live in a new gitignoredregistry/secrets-ignored.yaml, are excluded byscan(and therefore bylane doctor’s advisory), and are listed with the newlane secrets <repo> ignoredcommand so a dismissal is visible, not silently forgotten.
0.6.0 — 2026-09-20
Added
scripts/local/<name>: any executable file there becomes alane <name>subcommand, without ever editing the trackedlanedispatcher again. Gitignored, a full peer of built-in commands (cansource scripts/lib.shitself), listed inlane helpunder “your commands:”, and flagged bylane doctorif a name collides with — and is therefore shadowed by — a real built-in. Seedocs/extending.md.lane rules pull: a shared, git-backed source forregistry/rules.yaml-style rules andscripts/local/scripts, for a team that wants the same guard rules everywhere. SHA-pinned — a plain re-pull re-verifies the pin and refuses to advance past it,lane rules pull --latestis required to intentionally move; files are always copied, never symlinked, intoregistry/rules.shared.yaml(a separate file fromregistry/rules.yaml, never merged into it) andscripts/local/; rule ids are namespaced to the shared repo.guard.py’sload_rules()now unions everyregistry/rules.*.yamlfile through the identical parse/validate/deny-only path, so a shared rule is checked exactly like a local one.lane doctorgets a new, more prominent advisory when the pinned SHA has drifted from the shared repo’s real HEAD.lane rules statusinspects the configured source without changing anything. Seedocs/extending.md.
0.5.0 — 2026-09-20
Added
lane installrefuses or confirms before repointing an existing symlink, instead of silently repointing it. Running it again — a second checkout, a moved clone — used to relinkdest/lanewith zero visibility into what it used to point at. A terminal now shows both paths and asks; a non-interactive run (an agent, a script) refuses unless--forceis passed.lane doctorchecks three more things, advisory-only like its existing reports: whether thelaneresolved onPATHactually points at this checkout (the exact bug thelane installfix above surfaces, instead of a confusing downstream symptom), whetherghis installed and authenticated (lane sync/lane ghneed it), and whether each active lane’s spec file (repo:/branch:) still matches what’s actually checked out underlanes/<id>/on disk.lane upgradeautomates the manual procedure documented in Troubleshooting’s “Upgrade friction” section: refuses if tracked files outsideregistry/,memory/,knowledge/,CLAUDE.mdand.claude/settings.local.jsonare dirty, fetches, and fast-forwards onto upstream only when that’s a clean fast-forward — any real divergence or conflict stops with the same manual recipe (including the.claude/settings.jsongit checkout --theirsstep) rather than attempting a merge, which this deliberately never does. On success it prints theVERSIONdelta and theCHANGELOG.mdsections that landed, then runslane doctor.
0.4.0 — 2026-09-20
Added
lane doctorflags repos with undeclared candidate secret/credential files.lane secrets scan|add|listalready let a repo declare filenames the guard’s built-in pattern misses (.npmrc,id_rsa,service-account.json, …), but nothing ever prompted you to actually run it — a repo added before the feature existed, or where the prompt was declined, sat with zero per-repo protection, silently.lane doctornow re-runs the same local, no-network scan every time and lists any repo with candidates nobody has declared, with the exact command to fix it — same advisory-only philosophy as the stale-lane and branch-protection-sync reports, and simpler than both since a local filesystem scan needs no staleness window at all.
0.3.0 — 2026-09-20
Added
lane sync [repo]checks a repo’s real GitHub branch protection (gh api repos/<owner>/<repo>/branches) and records it inregistry/repos.yamlasprotected_branches:+protection_synced: <date>.guard.py’s protected-branch check now unions that list in too, alongsidemain/master/developand a repo’s owndefault_branch/compare_branch— never fewer branches protected, only ever more accurate. Manual and always available; never run automatically by any other command, so nothing that “starts work” blocks on a network call. Fails soft (a clear message, never a crash) for a repo that isn’t on GitHub, or whenghis missing or unauthenticated.lane doctornow also lists, informationally only, any managed GitHub repo whose protection hasn’t been checked inprotection: sync_days(registry/config.yml, 7 by default;0turns it off).
0.2.1 — 2026-09-20
Fixed
- The guard’s default-branch protection (push, force-push, delete/move) only ever recognised
main/master/develop, hardcoded. A repo whose integration branch is named something else —release,staging, anything — got none of that protection, including this repo’s ownreleasebranch, added by the release-automation work without updating the guard.registry/repos.yamlalready records each repo’sdefault_branch/compare_branch(set bylane add, used elsewhere to resolve a lane’s base) butguard.pynever read it. It now unions that into the protected-branch check per repo — never fewer branches protected than before, just more accurate when a repo’s own integration branch has a different name.
0.2.0 — 2026-09-19
Added
lane addaccepts a local checkout with nooriginremote. It used to die outright; it now registers the repo as local-only (origin: (local-only)inregistry/repos.yaml) and says so, including how to add one later:git -C repos/<name> remote add origin <url>.lane secrets <repo> scan|add|list. Every repo can have its own credential filenames beyond the built-in.env/.pem/credentials.jsonset the guard already denies globally.lane addnow scans a newly added repo by filename only (never contents) and either prompts to protect the candidates it finds (interactive) or reports them for review (agent-driven, so nothing is added without a human confirming). Protecting a file writes deny rules intoregistry/rules.yaml, the same mechanism described indocs/rules.md, solane doctorverifies them the same way it verifies any other custom rule.lane donenow asks what to do with a finished lane’s Claude sessions. Removing a worktree left~/.claude/projects/<slug>behind forever, unreported.lane initnow asks (registry/config.yml’slanes: delete_sessions_on_done:, default keep) which way the prompt defaults, but the prompt itself is never skipped in an interactive run;lane done <id> --delete-sessions/--keep-sessionsanswer it up front, and a non-interactive run (no terminal — an agent drivinglane done) never deletes, it only reports the count.
Fixed
-
lane sessions <repo>was checking a folder that can never have sessions in it. It looked under the slug forrepos/<repo>— the mirror — but Claude Code is never launched there (the guard refusescd-ing into it). It now unions every live lane worktree for the repo, every finished lane’s worktree (reconstructed from the archived lane file even though the folder is gone), and notes separately how many conversations still sit at the repo’s pre-Repolane checkout (old_paths:), not yet brought in withlane import. -
pref-scoped memory was written but never read back into a session.lane-memory file <slug> prefcorrectly appended toknowledge/preferences.md, butmemory_brief()inscripts/hooks/guard.pynever read that file, so the most durable scope — meant to apply to every lane, forever — was the one scope that silently never reached a session. It now has its own## preferencessection, read the same way the cross-repo and per-repo sections are. -
lane env <repo-name>silently checked nothing.lane-env-checkfiltered worktrees by lane id only, solane env <repo-name>matched no worktree and printed nothing — a result indistinguishable from “all good.” It now matches against either the lane id or the repo name.
0.1.0 — 2026-09-18
First release. Everything below was built between 2026-09-17 and 2026-09-18; there are no earlier versions, and this is the first tag. It is early, solo-authored software with no external users yet: used daily by its author, stable for that use, and unproven anywhere else. The layout on disk and the command names are settled, and the guard has a real test suite — expect rough edges outside the paths that get walked every day.
Added
- Lanes.
lane start <id> <repo>…gives each named repo a worktree on one shared branch underlanes/<id>/, alongside a spec file inregistry/lanes/. Repos live once inrepos/as clean mirrors, so a second lane costs a checkout rather than a clone.lane resume,lane park,lane with,lane mergeandlane donemove work through its life. - A permission guard.
scripts/hooks/guard.pyis a hook dispatcher that fences a Claude Code session into the lane it belongs to: it refuses reads of other lanes and of the mirrors, refuses.envand credential files outright, requires an explicitALLOW_DEFAULT_BRANCH_PUSH=1marker to push to a default branch, and routes every memory write through a scope the owner chooses. Each refusal is documented indocs/rules.mdtogether with the failure behind it. lane doctor. Compiles the guard, fires nine named probes at it and reports every verdict, then checks that every lane, worktree and the control-plane root is still wired to it.lane board. A local, no-build git client scoped to the work in flight.- Memory and knowledge.
lane notewrites what a session learned intomemory/<repo>/orknowledge/, under a scope the owner picks rather than one the model picks. - Documentation. A README that explains the problem before the mechanism, plus
docs/concepts.md,docs/rules.md,docs/commands.md,docs/board.md, and a README in every folder with something non-obvious in it. CHANGELOG.mdandVERSION, solane --versionreports something other thandev. It readsVERSION, which works from a downloaded tarball with no git metadata, and adds the distance from the release tag when there is a git checkout to ask.docs/troubleshooting.md: what each failure looks like on disk and how to recover from it.
Fixed
- The guard test suite tested nothing on almost every machine. It was written against a fixture
only the author had — a lane named
docscontaining a repo nameddocs— and when that was missing it printed “Skipped” and exited 0. It was also the evidence behind a public claim of 179 cases. It now builds its own throwaway control plane under$HOME, runs every case against a copy of the guard inside it, removes it on exit, and asserts the case count so a run that quietly stops testing fails instead of looking like a pass. It touches no real lane, repo or memory file. lane doctorreported green on a disarmed control plane. It checked that the guard compiled and refused one probe — not that anything still called it. It now verifies the wiring too, and names the lanes and worktrees that have lost it. One unfalsifiable line became nine probes, including a control that must come back undecided, because a guard that refuses everything passes all eight refusal probes and is still useless.- A lane could disarm its own guard with a shell redirect. The control-plane rule was wired into
Edit/Writebut not into Bash write targets, soecho '{}' > lanes/<id>/.claude/settings.local.jsonremoved a lane’s hooks silently while the file tools refused the same thing. Absolute paths under the control plane are now tokenised,mvcounts both ends, the judgement is shared between the two paths so they cannot drift apart again, and a session’s own.claude/settings*is refused outright. - The board staged, committed and pushed secrets.
is_secret()gated listing and diffing but not/api/stage, so a.envcould be committed through the UI — and because the diff is withheld, without anyone seeing what they were committing. Staging a secret is now refused by name, and/api/commitrefuses a staged secret as a second line of defence. Unstaging stays allowed. lane withcould never succeed. It exec’dlane-start, whose “already has a folder” check fires unconditionally, and a started lane always has a folder. Growing a lane now inverts that check rather than sharing it.lane installputbin/README.mdon your PATH. It linked every entry inbin/, and the printed count was one too high. It now links only executables, and removes such a link left by an earlier install — but only when it points into a Repolanebin/at something that is not a command, so an unrelated file in the same directory is never touched.- Drift detection had three readers and no writer.
registry/old-checkouts.jsonwas read bystatus-all,session-startand the guard, and nothing ever created it, so drift was permanently empty and the guard never refused the abandoned checkouts it claimed to.lane addnow records the checkout it adopted. - The resume note swallowed the findings. It was read as everything between
## Resume noteand## Log, but## Findingssits between them, so every finding was printed twice. All three sections now stop at the next heading of any kind. lane mergecould mark a repo you were committing to as read-only, by leaving a straymode: readin the repos block. The frontmatter is now rebuilt deterministically instead of being rewritten by two overlapping regexes.lane donefrom inside the lane printed threegetcwderrors after “removed worktree”, which reads like data loss. It steps out to the control-plane root first.lane brieffailed on a finished lane, though the archive is the point of keeping it. It falls back toregistry/lanes/done/, and an unknown id now lists the lanes that do exist.lane <cmd> --helpwas read as a lane id and answeredno lane '--help'. The dispatcher answers it now, from the same linelane helpprints.- Commands run before
lane initcrashed with raw git and Python errors. They now say to runlane init. lane env <id>was documented as taking a repo name. It filters on lane id, so passing a repo name printed nothing at all and looked like a clean result.- Guard leftovers from the
ws→lanerename: the memory-gate exemption no longer recognised the tool’s own scripts, andSessionEndrewrote the lane file with BSD-onlysed -i "". - A folder
README.mdwas indexed as a cross-repo memory wherever memory files are listed.
Changed
- Renamed from Lane to Repolane. Branding only: the command is still
lane, as are thelanes/folder,registry/lanes/and everylane-*script — the same way Homebrew shipsbrew. - Two documented claims were corrected rather than fixed, because the code was right and the docs
were not. A
lane refis read-only to a session via the hook, not on disk — it is a symlink. And the board fetches its typefaces from Google Fonts, so “no network calls beyond its own server” was false;docs/board.mdnow says so, and how to remove them.