lanekeeper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/bin/lanekeeper.d.ts +2 -0
- package/dist/bin/lanekeeper.js +269 -0
- package/dist/build-lock.d.ts +1 -0
- package/dist/build-lock.js +70 -0
- package/dist/hooks/worktree-create.d.ts +13 -0
- package/dist/hooks/worktree-create.js +150 -0
- package/dist/land.d.ts +1 -0
- package/dist/land.js +128 -0
- package/dist/lib/check-command.d.ts +29 -0
- package/dist/lib/check-command.js +83 -0
- package/dist/lib/check-push.d.ts +37 -0
- package/dist/lib/check-push.js +48 -0
- package/dist/lib/claude-md-snippet.d.ts +16 -0
- package/dist/lib/claude-md-snippet.js +18 -0
- package/dist/lib/config.d.ts +92 -0
- package/dist/lib/config.js +137 -0
- package/dist/lib/ephemeral.d.ts +40 -0
- package/dist/lib/ephemeral.js +100 -0
- package/dist/lib/lane-port.d.ts +3 -0
- package/dist/lib/lane-port.js +25 -0
- package/dist/lib/main-checkout.d.ts +1 -0
- package/dist/lib/main-checkout.js +19 -0
- package/dist/lib/prune-lanes.d.ts +8 -0
- package/dist/lib/prune-lanes.js +120 -0
- package/dist/lib/queue-lock.d.ts +26 -0
- package/dist/lib/queue-lock.js +212 -0
- package/dist/lib/tty-confirm.d.ts +1 -0
- package/dist/lib/tty-confirm.js +44 -0
- package/dist/lib/wire-hooks.d.ts +26 -0
- package/dist/lib/wire-hooks.js +123 -0
- package/dist/preview.d.ts +1 -0
- package/dist/preview.js +119 -0
- package/dist/promote.d.ts +1 -0
- package/dist/promote.js +77 -0
- package/dist/sync.d.ts +2 -0
- package/dist/sync.js +115 -0
- package/examples/ephemeral-tmp-dir.example.ts +66 -0
- package/examples/lanekeeper.config.mjs +67 -0
- package/hooks/claude-settings.example.json +14 -0
- package/hooks/pre-push +23 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jesse Heaslip
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/banner.svg" alt="LaneKeeper β the local, zero-cost merge queue for parallel Claude Code agents" width="100%" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg">
|
|
7
|
+
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg">
|
|
8
|
+
<img alt="Node" src="https://img.shields.io/badge/node-%3E%3D18-339933.svg">
|
|
9
|
+
<img alt="Runtime deps" src="https://img.shields.io/badge/runtime%20deps-0-brightgreen.svg">
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
# LaneKeeper π¦
|
|
13
|
+
|
|
14
|
+
**The local, zero-cost merge queue for parallel Claude Code agents.**
|
|
15
|
+
|
|
16
|
+
Claude Code already isolates your agents β `--worktree` (or `isolation:
|
|
17
|
+
"worktree"` on a subagent) gives every session its own git worktree, natively,
|
|
18
|
+
no setup. That part's solved. LaneKeeper is the part that comes after: what
|
|
19
|
+
happens when four isolated agents all try to land, build, and test *at the
|
|
20
|
+
same time*.
|
|
21
|
+
|
|
22
|
+
- π Everyone pushes to the same branch, someone loses the race, and the
|
|
23
|
+
rejected push turns into a rebase, which sometimes turns into *another*
|
|
24
|
+
rejected push.
|
|
25
|
+
- π₯ A full build is heavy. Four of them running at once turn your laptop
|
|
26
|
+
into a space heater.
|
|
27
|
+
- π² If your tests hit a shared database, concurrent runs race each other's
|
|
28
|
+
resets. The failures look flaky. They are not flaky. They're just honest.
|
|
29
|
+
|
|
30
|
+
None of that is a skill issue. It's what happens when several fast,
|
|
31
|
+
confident processes share one mutable thing with no traffic control.
|
|
32
|
+
|
|
33
|
+
Telling the agents to "please coordinate" doesn't fix it. An agent (or a
|
|
34
|
+
teammate in a hurry) will violate a documented convention exactly once, at
|
|
35
|
+
exactly the wrong moment, and mean nothing by it.
|
|
36
|
+
|
|
37
|
+
**So don't ask nicely. Make the collision impossible.** π¦
|
|
38
|
+
|
|
39
|
+
## π vs. GitHub's Merge Queue
|
|
40
|
+
|
|
41
|
+
GitHub already ships a merge queue. Two things it costs you that this doesn't:
|
|
42
|
+
|
|
43
|
+
| | GitHub Merge Queue | LaneKeeper |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| Private repo | **Enterprise Cloud only** | Any plan, any repo |
|
|
46
|
+
| Cost per landing | GitHub Actions minutes, every queue attempt | $0 β runs on your own machine |
|
|
47
|
+
| Requires | A pull request | Nothing β direct rebase + push |
|
|
48
|
+
|
|
49
|
+
Same idea β serialize landings, test before merge, keep history clean β run
|
|
50
|
+
locally instead of in someone else's billed cloud. πΈ
|
|
51
|
+
|
|
52
|
+
## π§° What's in the box
|
|
53
|
+
|
|
54
|
+
| Command | What it does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `lanekeeper hook worktree-create` | A Claude Code `WorktreeCreate` hook. Plugs LaneKeeper's numbered lanes into Claude's *native* worktree creation β doesn't reinvent it. |
|
|
57
|
+
| `lanekeeper build-lock -- <cmd>` | Runs `<cmd>` β your build β serialized across every lane, machine-wide. |
|
|
58
|
+
| `lanekeeper land` | Rebases and pushes your lane onto the integration branch through a FIFO queue, so two lanes are never mid-push at once. Agents run this themselves β see below. |
|
|
59
|
+
| `lanekeeper sync` | Fast-forwards your main checkout so a dev server actually sees what just landed. |
|
|
60
|
+
| `lanekeeper promote` | Ships the integration branch to production. **Human-only** β never in an agent's instructions, never automated. |
|
|
61
|
+
| `lanekeeper preview` | Instantly mirrors a lane's live working tree β uncommitted changes included β onto the main checkout, so you can look at it without a build. |
|
|
62
|
+
| `lanekeeper port` | Prints a lane's dev-server port, derived from its own directory name. |
|
|
63
|
+
|
|
64
|
+
Plus π a pre-push hook that makes `land` non-optional: a direct `git push`
|
|
65
|
+
straight to the integration branch gets bounced, full stop. Not a lint
|
|
66
|
+
warning. Not a Slack reminder. Rejected β with the actual command to run
|
|
67
|
+
instead. The same hook also runs your actual checks (`checkCommand` β
|
|
68
|
+
lint/typecheck/test/build) before allowing a landing through at all; a
|
|
69
|
+
config with no checkCommand set **fails every push by default** rather than
|
|
70
|
+
landing unverified code silently.
|
|
71
|
+
|
|
72
|
+
Every one of those blocks has a real, deliberate way out β see "The
|
|
73
|
+
emergency hatch" below β but it takes two matching, specific pieces of
|
|
74
|
+
intent, not one flag.
|
|
75
|
+
|
|
76
|
+
And π§ͺ a documented extension point (`src/lib/ephemeral.ts` +
|
|
77
|
+
`examples/ephemeral-tmp-dir.example.ts`) for the thing every setup guide
|
|
78
|
+
skips: if your tests hit a shared resource β a database, a queue, anything
|
|
79
|
+
stateful β concurrent lanes need their own throwaway copy of it, and a
|
|
80
|
+
crashed run's copy needs to clean itself up without anyone noticing it died.
|
|
81
|
+
|
|
82
|
+
## β‘ Quickstart
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm install --save-dev lanekeeper # or: pnpm add -D / yarn add -D / bun add -d
|
|
86
|
+
npx lanekeeper init
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This does the whole setup, not just the config file:
|
|
90
|
+
|
|
91
|
+
- **`lanekeeper.config.mjs`** β `integrationBranch` auto-detected from your
|
|
92
|
+
current branch, `checkCommand` auto-detected from package.json
|
|
93
|
+
(`check:push` / `check` / `ci` / `test`, first match wins).
|
|
94
|
+
- **`CLAUDE.md`** (or appends to yours if you already have one) β the part
|
|
95
|
+
that makes the whole thing hands-off. Claude Code reads it automatically,
|
|
96
|
+
every session, and it tells the agent to land its own work once green,
|
|
97
|
+
without being asked. See "The hands-off part" below.
|
|
98
|
+
- **`.claude/settings.json`** β the `WorktreeCreate` hook wired in (created,
|
|
99
|
+
or merged into your existing settings without touching anything else
|
|
100
|
+
already there).
|
|
101
|
+
- **`.husky/pre-push`** β created or appended to, *if* you already have
|
|
102
|
+
Husky. If you don't, `init` tells you so instead of silently writing to
|
|
103
|
+
the untracked, not-shared-with-your-team `.git/hooks/pre-push`.
|
|
104
|
+
|
|
105
|
+
**Commit everything it wrote.** Then add to `package.json`:
|
|
106
|
+
```json
|
|
107
|
+
"scripts": {
|
|
108
|
+
"land": "lanekeeper land",
|
|
109
|
+
"sync": "lanekeeper sync",
|
|
110
|
+
"promote": "lanekeeper promote",
|
|
111
|
+
"preview": "lanekeeper preview",
|
|
112
|
+
"preview:restore": "lanekeeper preview --restore"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
If `init` couldn't detect a `checkCommand` (no matching script in
|
|
117
|
+
package.json), every push is **blocked** until you set one β see π§° What's
|
|
118
|
+
in the box above. That's on purpose.
|
|
119
|
+
|
|
120
|
+
From here on: `claude --worktree <name>` to spin up an isolated lane β
|
|
121
|
+
LaneKeeper's hook takes it from there, and CLAUDE.md tells the agent the rest.
|
|
122
|
+
You show up to run `lanekeeper promote` when you actually want to ship. π
|
|
123
|
+
|
|
124
|
+
## βοΈ Configuration
|
|
125
|
+
|
|
126
|
+
Everything lives in one file β see
|
|
127
|
+
[`examples/lanekeeper.config.mjs`](examples/lanekeeper.config.mjs) for every
|
|
128
|
+
field with comments. The short version:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
export default {
|
|
132
|
+
branchPrefix: "lane/", // lane/1, lane/2, ...
|
|
133
|
+
worktreeSuffix: "-lane-", // ../your-repo-lane-1
|
|
134
|
+
portBase: 3000, // lane n gets portBase + n
|
|
135
|
+
integrationBranch: "main", // where agents land β see below
|
|
136
|
+
productionBranch: null, // set this for a two-stage model β see below
|
|
137
|
+
protectedBranches: [], // extra branches beyond the two above; most repos need none
|
|
138
|
+
regenerableFiles: [], // files a build tool rewrites β never block a rebase on these
|
|
139
|
+
symlinks: [".env", ".env.local", "node_modules"],
|
|
140
|
+
buildOutputDirs: ["dist", "build", ".next"], // preview never copies these onto your checkout
|
|
141
|
+
checkCommand: "npm run check", // what actually gates a landing β see below
|
|
142
|
+
checksRequired: true, // false = deliberately run with none; see below
|
|
143
|
+
};
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Nothing here is hardcoded to any framework or branch model. π§© A malformed
|
|
147
|
+
config (empty branch names, a negative port, `productionBranch` equal to
|
|
148
|
+
`integrationBranch`, ...) fails loud with every problem listed, the moment
|
|
149
|
+
any command loads it β not a mysterious failure three steps later.
|
|
150
|
+
|
|
151
|
+
## π¨ The emergency hatch
|
|
152
|
+
|
|
153
|
+
Every blocked push β the integration branch, `productionBranch`, anything
|
|
154
|
+
in `protectedBranches` β has a real way through it. It's meant to be used
|
|
155
|
+
rarely and on purpose, so it asks for two separate, specific things instead
|
|
156
|
+
of one flag an agent could set just as easily as a human:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
LANEKEEPER_EMERGENCY_PUSH=1 git push origin HEAD:main
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
That alone isn't enough β you'll be prompted, right there in your terminal,
|
|
163
|
+
to **type the exact branch name** to confirm. No terminal attached (CI, a
|
|
164
|
+
script)? Provide the second factor yourself instead:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
LANEKEEPER_EMERGENCY_PUSH=1 LANEKEEPER_EMERGENCY_PUSH_CONFIRM=main git push origin HEAD:main
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
No confirmation, no tty, no match β it fails **closed**, not open. This is
|
|
171
|
+
the one place in LaneKeeper that's honestly a convention, not a hard
|
|
172
|
+
guarantee: the env vars stop mistakes and stray pushes, not a truly
|
|
173
|
+
adversarial agent that decides to set them itself. Worth knowing, not worth
|
|
174
|
+
pretending isn't true.
|
|
175
|
+
|
|
176
|
+
## π The hands-off part
|
|
177
|
+
|
|
178
|
+
Tests are the reviewer. Not a human, at any point in this pipeline β and
|
|
179
|
+
that's a deliberate, named tradeoff, not an oversight.
|
|
180
|
+
|
|
181
|
+
- **`checkCommand` gates landing.** Nothing reaches `integrationBranch`
|
|
182
|
+
without passing it. This is the first, and for most changes the *only*,
|
|
183
|
+
correctness check anything gets.
|
|
184
|
+
- **`lanekeeper promote` is a release decision, not a code review.**
|
|
185
|
+
Running it means "this batch of already-landed, already-tested work
|
|
186
|
+
should ship now" β not "I read the diff and it looks right." If your own
|
|
187
|
+
CI provider also runs checks against the production branch (most real
|
|
188
|
+
setups have this β a deploy gate, an E2E suite, whatever), that's a
|
|
189
|
+
*second automated* checkpoint, still not a human one.
|
|
190
|
+
- **When something gets through anyway, the fix is a test, not a
|
|
191
|
+
reviewer.** If a bug lands, the answer isn't "a human should have caught
|
|
192
|
+
that" β it's "what check would have caught it, and why didn't it exist
|
|
193
|
+
yet." Every miss becomes a permanent guardrail instead of a one-off
|
|
194
|
+
catch, which is the only version of this that scales past however many
|
|
195
|
+
diffs one person can actually read.
|
|
196
|
+
|
|
197
|
+
This isn't for every team. If what you actually want is a human looking at
|
|
198
|
+
every change before it ships, this tool will feel like it's missing a
|
|
199
|
+
step β because it is, on purpose. It's built for the case where you trust
|
|
200
|
+
the agent's *output conditional on the checks being real*, and the checks
|
|
201
|
+
being non-optional (see π§° above) is what makes that trust earned rather
|
|
202
|
+
than assumed.
|
|
203
|
+
|
|
204
|
+
## π The one idea underneath most of it
|
|
205
|
+
|
|
206
|
+
The build queue, the landing queue, and the ephemeral-resource pattern are
|
|
207
|
+
all crash-safe the **same way**, on purpose:
|
|
208
|
+
|
|
209
|
+
1. Claim a resource.
|
|
210
|
+
2. Tag the claim with your process ID.
|
|
211
|
+
3. Let liveness β not a timeout β decide when a claim is stale.
|
|
212
|
+
|
|
213
|
+
`queue-lock.ts` does it for the build and landing queues. `ephemeral.ts`
|
|
214
|
+
does it for whatever test resource you wire in. `Kill -9` any of them
|
|
215
|
+
mid-claim, and the next process to come along notices the PID is dead and
|
|
216
|
+
reclaims it.
|
|
217
|
+
|
|
218
|
+
The `WorktreeCreate` hook uses a cousin of the same idea, adapted to the fact
|
|
219
|
+
that it's a one-shot script with no long-lived process to check liveness
|
|
220
|
+
against: the claim IS the worktree, and `git worktree add` failing on an
|
|
221
|
+
already-taken path is the atomicity guard, delegated straight to git instead
|
|
222
|
+
of a PID file.
|
|
223
|
+
|
|
224
|
+
Either way: no stale locks, no "just restart your laptop," no magic number
|
|
225
|
+
for how long is too long to wait. β
Structurally impossible beats politely
|
|
226
|
+
requested, every time.
|
|
227
|
+
|
|
228
|
+
## π Know the limits
|
|
229
|
+
|
|
230
|
+
Things a sharp reader should already know before they ask:
|
|
231
|
+
|
|
232
|
+
- **One machine, not a fleet.** The FIFO queue lives in local temp storage β
|
|
233
|
+
it doesn't coordinate across laptops. Two machines landing at the same
|
|
234
|
+
moment just get git's own ordinary non-fast-forward rejection (safe, not
|
|
235
|
+
corrupting β the loser re-fetches and retries, same as any team without a
|
|
236
|
+
queue does today). This solves the one-machine problem completely; it was
|
|
237
|
+
never trying to solve the distributed one.
|
|
238
|
+
- **Not a security boundary.** Every guardrail here stops mistakes and
|
|
239
|
+
convention drift β a fast, confident, forgetful agent β not a truly
|
|
240
|
+
adversarial one. An agent with shell access can always `git push
|
|
241
|
+
--no-verify`, delete the hook, or edit the config on purpose. If your
|
|
242
|
+
threat model includes an agent actively trying to get around this, none
|
|
243
|
+
of this helps, and nothing local-only ever could.
|
|
244
|
+
- **Guarantees a check ran β not that the check is good.** LaneKeeper
|
|
245
|
+
enforces that `checkCommand` exists and passed. It has no way to know if
|
|
246
|
+
that's a real test suite or `echo ok`. "Tests are the reviewer" is only
|
|
247
|
+
as true as what's actually in them.
|
|
248
|
+
- **The `WorktreeCreate` hook is the youngest piece of this stack** β Claude
|
|
249
|
+
Code shipped it Feb 2026. Losing it degrades gracefully: fall back to
|
|
250
|
+
`git worktree add` by hand and you still keep the build queue, landing
|
|
251
|
+
queue, preview, and ephemeral-resource pieces, none of which depend on it.
|
|
252
|
+
- **A slow `checkCommand` is a real throughput ceiling, not a free lunch.**
|
|
253
|
+
The FIFO lock holds for its entire duration β one landing at a time,
|
|
254
|
+
machine-wide. A 3β4 minute suite caps you well under 20 landings/hour
|
|
255
|
+
flat-out, before any queue wait.
|
|
256
|
+
- **Rebase conflicts abort, they never guess.** `git rebase --abort` on any
|
|
257
|
+
conflict, working tree left clean β it never auto-resolves or silently
|
|
258
|
+
picks a side. In the normal flow that "you" is the agent, not a human:
|
|
259
|
+
CLAUDE.md tells it to resolve the conflict itself and re-run `land`, the
|
|
260
|
+
same way it'd fix any other bug β `checkCommand` still gates the result
|
|
261
|
+
either way, so a bad resolution gets caught there.
|
|
262
|
+
|
|
263
|
+
## 𧬠Where this came from
|
|
264
|
+
|
|
265
|
+
This is the extracted, generalized shape of tooling built to run several
|
|
266
|
+
parallel Claude Code agents on one real production codebase (and this repo)
|
|
267
|
+
without them tripping over each other β a build queue, a landing queue
|
|
268
|
+
enforced by a git hook, instant previews, and the ephemeral-resource pattern
|
|
269
|
+
for tests, all sitting on top of Claude Code's own native worktree isolation.
|
|
270
|
+
The names have been filed off; the mechanics haven't.
|
|
271
|
+
|
|
272
|
+
## π License
|
|
273
|
+
|
|
274
|
+
MIT. Fork it, rename it, argue with the config shape β that's the point.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The lanekeeper CLI. Every subcommand reads lanekeeper.config from the
|
|
4
|
+
* current repo β see src/lib/config.ts β so none of this is hardcoded to
|
|
5
|
+
* any one project's branch names.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { land } from "../land.js";
|
|
9
|
+
import { sync } from "../sync.js";
|
|
10
|
+
import { promote } from "../promote.js";
|
|
11
|
+
import { runPreview } from "../preview.js";
|
|
12
|
+
import { buildLock } from "../build-lock.js";
|
|
13
|
+
import { findRepoRoot, hasConfig, loadConfig, detectCurrentBranch, DEFAULTS } from "../lib/config.js";
|
|
14
|
+
import { checkPush, parseRefUpdates } from "../lib/check-push.js";
|
|
15
|
+
import { runWorktreeCreateHook } from "../hooks/worktree-create.js";
|
|
16
|
+
import { lanePort } from "../lib/lane-port.js";
|
|
17
|
+
import { claudeMdSnippet, MARKER } from "../lib/claude-md-snippet.js";
|
|
18
|
+
import { detectCheckCommand, runCheckCommand } from "../lib/check-command.js";
|
|
19
|
+
import { wireClaudeSettings, wireHuskyPrePush, ensureHooksPath } from "../lib/wire-hooks.js";
|
|
20
|
+
const [, , command, ...rest] = process.argv;
|
|
21
|
+
async function readStdin() {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const chunk of process.stdin)
|
|
24
|
+
chunks.push(chunk);
|
|
25
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
26
|
+
}
|
|
27
|
+
async function init() {
|
|
28
|
+
const root = findRepoRoot();
|
|
29
|
+
if (!root) {
|
|
30
|
+
console.error("lanekeeper init: not inside a git repo.");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const { writeFileSync, readFileSync: read, existsSync, appendFileSync } = await import("node:fs");
|
|
34
|
+
const { join } = await import("node:path");
|
|
35
|
+
if (hasConfig(root)) {
|
|
36
|
+
console.log("lanekeeper init: lanekeeper.config.mjs already exists β leaving it alone.");
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const detectedBranch = detectCurrentBranch(root);
|
|
40
|
+
const detectedCheck = detectCheckCommand(root);
|
|
41
|
+
const generated = {
|
|
42
|
+
...DEFAULTS,
|
|
43
|
+
...(detectedBranch ? { integrationBranch: detectedBranch } : {}),
|
|
44
|
+
...(detectedCheck ? { checkCommand: detectedCheck } : {}),
|
|
45
|
+
};
|
|
46
|
+
const template = `// lanekeeper.config.mjs β generated by \`lanekeeper init\`. Edit freely.
|
|
47
|
+
// Its presence here is what turns LaneKeeper ON for this repo.
|
|
48
|
+
|
|
49
|
+
/** @type {import("lanekeeper").LaneKeeperConfig} */
|
|
50
|
+
export default ${JSON.stringify(generated, null, 2)};
|
|
51
|
+
`;
|
|
52
|
+
writeFileSync(join(root, "lanekeeper.config.mjs"), template);
|
|
53
|
+
console.log(`lanekeeper init: wrote ${join(root, "lanekeeper.config.mjs")}`);
|
|
54
|
+
if (detectedBranch && detectedBranch !== DEFAULTS.integrationBranch) {
|
|
55
|
+
console.log(` (detected current branch "${detectedBranch}" β set as integrationBranch instead of the "${DEFAULTS.integrationBranch}" default)`);
|
|
56
|
+
}
|
|
57
|
+
else if (!detectedBranch) {
|
|
58
|
+
console.log("");
|
|
59
|
+
console.log(` β οΈ Couldn't detect the current branch (detached HEAD?) β integrationBranch`);
|
|
60
|
+
console.log(` defaulted to "${DEFAULTS.integrationBranch}". Verify that's actually right`);
|
|
61
|
+
console.log(` in lanekeeper.config.mjs before committing it.`);
|
|
62
|
+
}
|
|
63
|
+
if (detectedCheck) {
|
|
64
|
+
console.log(` (detected "${detectedCheck}" from package.json β set as checkCommand)`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log(" β οΈ No checkCommand detected (no check:push/check/ci/test script found in");
|
|
69
|
+
console.log(" package.json). Every push is BLOCKED until you set one β or set");
|
|
70
|
+
console.log(" checksRequired: false to deliberately run with no checks.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// This is the part that actually makes agents use the workflow without a
|
|
74
|
+
// human reminding them every session β see claude-md-snippet.ts.
|
|
75
|
+
const claudeMdPath = join(root, "CLAUDE.md");
|
|
76
|
+
const cfg = hasConfig(root) ? await loadConfig(root) : DEFAULTS;
|
|
77
|
+
const snippet = claudeMdSnippet(cfg);
|
|
78
|
+
if (!existsSync(claudeMdPath)) {
|
|
79
|
+
writeFileSync(claudeMdPath, `# Project instructions for Claude Code\n\n${snippet}`);
|
|
80
|
+
console.log(`lanekeeper init: wrote ${claudeMdPath}`);
|
|
81
|
+
}
|
|
82
|
+
else if (!read(claudeMdPath, "utf8").includes(MARKER)) {
|
|
83
|
+
appendFileSync(claudeMdPath, `\n${snippet}`);
|
|
84
|
+
console.log(`lanekeeper init: appended the LaneKeeper workflow section to ${claudeMdPath}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
console.log(`lanekeeper init: ${claudeMdPath} already has the LaneKeeper workflow section β leaving it alone.`);
|
|
88
|
+
}
|
|
89
|
+
const claudeSettingsResult = wireClaudeSettings(root);
|
|
90
|
+
switch (claudeSettingsResult) {
|
|
91
|
+
case "created":
|
|
92
|
+
console.log(`lanekeeper init: wrote ${join(root, ".claude", "settings.json")} with the WorktreeCreate hook.`);
|
|
93
|
+
break;
|
|
94
|
+
case "merged":
|
|
95
|
+
console.log(`lanekeeper init: added the WorktreeCreate hook to your existing ${join(root, ".claude", "settings.json")}.`);
|
|
96
|
+
break;
|
|
97
|
+
case "already-wired":
|
|
98
|
+
console.log("lanekeeper init: .claude/settings.json already has the WorktreeCreate hook β leaving it alone.");
|
|
99
|
+
break;
|
|
100
|
+
case "unparseable":
|
|
101
|
+
console.log("lanekeeper init: .claude/settings.json exists but isn't valid JSON β left untouched. Wire it manually,");
|
|
102
|
+
console.log(" see node_modules/lanekeeper/hooks/claude-settings.example.json.");
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
const prePushResult = wireHuskyPrePush(root);
|
|
106
|
+
switch (prePushResult) {
|
|
107
|
+
case "created":
|
|
108
|
+
console.log("lanekeeper init: wrote .husky/pre-push.");
|
|
109
|
+
break;
|
|
110
|
+
case "merged":
|
|
111
|
+
console.log("lanekeeper init: appended LaneKeeper's checks to your existing .husky/pre-push.");
|
|
112
|
+
break;
|
|
113
|
+
case "already-wired":
|
|
114
|
+
console.log("lanekeeper init: .husky/pre-push already wired β leaving it alone.");
|
|
115
|
+
break;
|
|
116
|
+
case "no-husky":
|
|
117
|
+
console.log("lanekeeper init: no .husky/ directory found β pre-push hook NOT wired automatically.");
|
|
118
|
+
console.log(" Install Husky, or copy node_modules/lanekeeper/hooks/pre-push to .git/hooks/pre-push");
|
|
119
|
+
console.log(" yourself (note: .git/hooks isn't version-controlled β only Husky's is shared with your team).");
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
if (prePushResult === "created" || prePushResult === "merged" || prePushResult === "already-wired") {
|
|
123
|
+
// A .husky/pre-push file enforces nothing until core.hooksPath actually
|
|
124
|
+
// points at .husky β normally a side effect of the package manager's
|
|
125
|
+
// install step, which may not have run yet (e.g. a fresh clone, right
|
|
126
|
+
// where Quickstart leaves you). Without this, a direct push sails
|
|
127
|
+
// through uncontested with no indication anything's wrong.
|
|
128
|
+
switch (ensureHooksPath(root)) {
|
|
129
|
+
case "set":
|
|
130
|
+
console.log("lanekeeper init: set core.hooksPath=.husky so the pre-push hook actually runs (normally set by your package manager's install step, which may not have run yet).");
|
|
131
|
+
break;
|
|
132
|
+
case "already-set":
|
|
133
|
+
break; // the common case once installed β nothing to say
|
|
134
|
+
case "custom-path":
|
|
135
|
+
console.log("lanekeeper init: core.hooksPath is set to something other than .husky β leaving it alone.");
|
|
136
|
+
console.log(" Wrote .husky/pre-push, but it won't run until your hooks path points there. Reconcile this yourself.");
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
console.log("");
|
|
141
|
+
console.log("Next steps:");
|
|
142
|
+
console.log(" 1. Check lanekeeper.config.mjs β integrationBranch/checkCommand were auto-detected;");
|
|
143
|
+
console.log(" set productionBranch too if you run a two-stage model.");
|
|
144
|
+
console.log(' 2. Add to package.json "scripts": land, sync, promote, preview -> "lanekeeper <name>".');
|
|
145
|
+
console.log(" 3. Commit lanekeeper.config.mjs, CLAUDE.md, .claude/settings.json, and .husky/pre-push.");
|
|
146
|
+
console.log(" 4. claude --worktree <name> β the agent takes it from there.");
|
|
147
|
+
}
|
|
148
|
+
async function main() {
|
|
149
|
+
switch (command) {
|
|
150
|
+
case "init":
|
|
151
|
+
return init();
|
|
152
|
+
case "hook": {
|
|
153
|
+
const sub = rest[0];
|
|
154
|
+
if (sub === "worktree-create")
|
|
155
|
+
return runWorktreeCreateHook();
|
|
156
|
+
console.error(`lanekeeper hook: unknown hook "${sub ?? ""}". Only "worktree-create" is supported.`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case "port": {
|
|
161
|
+
const root = findRepoRoot();
|
|
162
|
+
if (!root || !hasConfig(root)) {
|
|
163
|
+
console.error("lanekeeper port: no lanekeeper.config found β not a lane.");
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
const cfg = await loadConfig(root);
|
|
167
|
+
const port = lanePort(process.cwd(), cfg);
|
|
168
|
+
if (port === null) {
|
|
169
|
+
console.error("lanekeeper port: current directory doesn't look like a lane worktree.");
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
console.log(port);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
case "land":
|
|
176
|
+
return land();
|
|
177
|
+
case "sync":
|
|
178
|
+
process.exit(await sync());
|
|
179
|
+
return;
|
|
180
|
+
case "promote":
|
|
181
|
+
process.exit(await promote());
|
|
182
|
+
return;
|
|
183
|
+
case "preview":
|
|
184
|
+
return runPreview(rest);
|
|
185
|
+
case "build-lock": {
|
|
186
|
+
const parts = rest[0] === "--" ? rest.slice(1) : rest;
|
|
187
|
+
return buildLock(parts);
|
|
188
|
+
}
|
|
189
|
+
case "check-push": {
|
|
190
|
+
const root = findRepoRoot();
|
|
191
|
+
if (!root || !hasConfig(root)) {
|
|
192
|
+
// No config means LaneKeeper isn't enabled for this repo β nothing to enforce.
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
const cfg = await loadConfig(root);
|
|
196
|
+
const stdin = await readStdin();
|
|
197
|
+
const refUpdates = parseRefUpdates(stdin);
|
|
198
|
+
// git still invokes pre-push (with empty stdin) for a push that
|
|
199
|
+
// updates zero refs β e.g. re-pushing a branch that's already exactly
|
|
200
|
+
// up to date on the remote. There's nothing to block (nothing is
|
|
201
|
+
// actually changing on the remote either way) and nothing meaningful
|
|
202
|
+
// for checkCommand to verify β running it anyway just produces a
|
|
203
|
+
// confusing, unrelated failure (a missing devDependency, say) that
|
|
204
|
+
// looks exactly like a real block but isn't one. Recognize "nothing
|
|
205
|
+
// to push" explicitly instead of silently falling through both checks.
|
|
206
|
+
if (refUpdates.length === 0) {
|
|
207
|
+
console.log("lanekeeper check-push: nothing being pushed (already up to date) β skipping checks.");
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
// LANEKEEPER_EMERGENCY_PUSH=1 alone isn't enough β that's one flag an
|
|
211
|
+
// agent (or a fat-fingered alias) could set as easily as a human. The
|
|
212
|
+
// second factor is naming the exact branch, either non-interactively
|
|
213
|
+
// (LANEKEEPER_EMERGENCY_PUSH_CONFIRM=<branch>, for a scripted case
|
|
214
|
+
// that already knows what it's doing) or by typing it at a real
|
|
215
|
+
// terminal right now. No tty available means no confirmation β fails
|
|
216
|
+
// closed, not open.
|
|
217
|
+
if (process.env.LANEKEEPER_EMERGENCY_PUSH === "1" && !process.env.LANEKEEPER_EMERGENCY_PUSH_CONFIRM) {
|
|
218
|
+
const target = refUpdates[0]?.remoteRef.replace("refs/heads/", "");
|
|
219
|
+
if (target) {
|
|
220
|
+
const { promptTtyConfirm } = await import("../lib/tty-confirm.js");
|
|
221
|
+
const typed = promptTtyConfirm(`\nπ¨ LANEKEEPER_EMERGENCY_PUSH is set β this bypasses the landing queue.\nType the branch name ("${target}") to confirm, anything else to abort: `);
|
|
222
|
+
if (typed === target) {
|
|
223
|
+
process.env.LANEKEEPER_EMERGENCY_PUSH_CONFIRM = target;
|
|
224
|
+
console.log(`\nConfirmed β pushing directly to '${target}', bypassing the queue.\n`);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
console.error(`\nβ Confirmation didn't match "${target}" exactly β push aborted.\n`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const result = checkPush(refUpdates, cfg, process.env);
|
|
233
|
+
if (!result.ok) {
|
|
234
|
+
console.error(result.message);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
process.exit(runCheckCommand(cfg, root));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
case "--version":
|
|
241
|
+
case "-v": {
|
|
242
|
+
const { join, dirname } = await import("node:path");
|
|
243
|
+
const { fileURLToPath } = await import("node:url");
|
|
244
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
245
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
246
|
+
console.log(pkg.version);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
default:
|
|
250
|
+
console.log(`lanekeeper β keep parallel coding agents in their lane.
|
|
251
|
+
|
|
252
|
+
Usage:
|
|
253
|
+
lanekeeper init write a starter lanekeeper.config.mjs
|
|
254
|
+
lanekeeper land rebase + push this lane onto the integration branch (queued)
|
|
255
|
+
lanekeeper sync fast-forward the MAIN checkout to its upstream
|
|
256
|
+
lanekeeper promote ship the integration branch to production (human-only β never script this)
|
|
257
|
+
lanekeeper preview [--restore] swap the MAIN checkout to this lane's working tree, or restore it
|
|
258
|
+
lanekeeper build-lock -- <cmd> run <cmd>, serialized across every lane
|
|
259
|
+
lanekeeper port print this lane's dev-server port
|
|
260
|
+
lanekeeper hook worktree-create (Claude Code WorktreeCreate hook β not for direct use)
|
|
261
|
+
lanekeeper check-push (used by the pre-push hook β not for direct use)
|
|
262
|
+
`);
|
|
263
|
+
process.exit(command ? 1 : 0);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
main().catch((err) => {
|
|
267
|
+
console.error(err instanceof Error ? err.message : err);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function buildLock(commandParts: string[]): Promise<void>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* build-lock.ts β wrap any command so only ONE runs at a time, machine-wide,
|
|
3
|
+
* across every lane of this repo.
|
|
4
|
+
*
|
|
5
|
+
* A full production build is CPU/RAM-heavy. Run one per lane and four
|
|
6
|
+
* parallel agents will thrash a laptop into a death spiral. This doesn't
|
|
7
|
+
* make builds faster β it makes them take turns, via the same cross-worktree
|
|
8
|
+
* FIFO lock everything else in this repo shares (queue-lock.ts).
|
|
9
|
+
*
|
|
10
|
+
* Usage: lanekeeper build-lock -- <shell command>
|
|
11
|
+
*
|
|
12
|
+
* Crash-safe with no timeouts: a lock whose holder PID has died is reclaimed
|
|
13
|
+
* deterministically, so a killed build can't wedge the queue for anyone
|
|
14
|
+
* else. That's also why the child is spawned detached β SIGKILLing this
|
|
15
|
+
* process still leaves an orphaned build running unless something can reach
|
|
16
|
+
* its whole process group, so a caller that wants to hard-kill a build (a CI
|
|
17
|
+
* runner enforcing a deadline, say) needs a group to signal, not just a PID.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { createQueueLock } from "./lib/queue-lock.js";
|
|
21
|
+
export async function buildLock(commandParts) {
|
|
22
|
+
const command = commandParts.join(" ").trim();
|
|
23
|
+
if (!command) {
|
|
24
|
+
console.error("lanekeeper build-lock: no command given. Usage: lanekeeper build-lock -- <command>");
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
const lock = createQueueLock("build");
|
|
28
|
+
await lock.acquire({
|
|
29
|
+
label: command,
|
|
30
|
+
onWait: ({ ahead, holder }) => {
|
|
31
|
+
if (ahead > 0) {
|
|
32
|
+
console.log(`\x1b[2m[build-queue] ${lock.lane}: waiting β ${ahead} build${ahead === 1 ? "" : "s"} aheadβ¦\x1b[0m`);
|
|
33
|
+
}
|
|
34
|
+
else if (holder) {
|
|
35
|
+
console.log(`\x1b[2m[build-queue] ${lock.lane}: next up β waiting for the running build to finishβ¦\x1b[0m`);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
console.log(`\x1b[2m[build-queue] ${lock.lane}: lock acquired β buildingβ¦\x1b[0m`);
|
|
40
|
+
const child = spawn(command, { shell: true, stdio: "inherit", detached: true });
|
|
41
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
42
|
+
process.on(sig, () => {
|
|
43
|
+
try {
|
|
44
|
+
// detached:true made the child its own process-group leader, so a
|
|
45
|
+
// negative pid signals the whole tree, not just the shell wrapper.
|
|
46
|
+
if (child.pid)
|
|
47
|
+
process.kill(-child.pid, sig);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* noop */
|
|
51
|
+
}
|
|
52
|
+
lock.release();
|
|
53
|
+
process.exit(130);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
child.on("exit", (code, signal) => {
|
|
57
|
+
lock.release();
|
|
58
|
+
if (signal) {
|
|
59
|
+
process.kill(process.pid, signal);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
process.exit(code ?? 1);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
child.on("error", (err) => {
|
|
66
|
+
console.error(`lanekeeper build-lock: failed to start command: ${err.message}`);
|
|
67
|
+
lock.release();
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type LaneKeeperConfig } from "../lib/config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Claim the lowest free lane, create its worktree, and symlink the
|
|
4
|
+
* configured git-ignored paths into it. Throws with a human-readable
|
|
5
|
+
* message on failure β the caller turns that into the hook's stderr +
|
|
6
|
+
* non-zero exit.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createLane(mainTop: string, cfg: LaneKeeperConfig): {
|
|
9
|
+
wt: string;
|
|
10
|
+
branch: string;
|
|
11
|
+
lane: number;
|
|
12
|
+
};
|
|
13
|
+
export declare function runWorktreeCreateHook(): Promise<void>;
|