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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/dist/bin/lanekeeper.d.ts +2 -0
  4. package/dist/bin/lanekeeper.js +269 -0
  5. package/dist/build-lock.d.ts +1 -0
  6. package/dist/build-lock.js +70 -0
  7. package/dist/hooks/worktree-create.d.ts +13 -0
  8. package/dist/hooks/worktree-create.js +150 -0
  9. package/dist/land.d.ts +1 -0
  10. package/dist/land.js +128 -0
  11. package/dist/lib/check-command.d.ts +29 -0
  12. package/dist/lib/check-command.js +83 -0
  13. package/dist/lib/check-push.d.ts +37 -0
  14. package/dist/lib/check-push.js +48 -0
  15. package/dist/lib/claude-md-snippet.d.ts +16 -0
  16. package/dist/lib/claude-md-snippet.js +18 -0
  17. package/dist/lib/config.d.ts +92 -0
  18. package/dist/lib/config.js +137 -0
  19. package/dist/lib/ephemeral.d.ts +40 -0
  20. package/dist/lib/ephemeral.js +100 -0
  21. package/dist/lib/lane-port.d.ts +3 -0
  22. package/dist/lib/lane-port.js +25 -0
  23. package/dist/lib/main-checkout.d.ts +1 -0
  24. package/dist/lib/main-checkout.js +19 -0
  25. package/dist/lib/prune-lanes.d.ts +8 -0
  26. package/dist/lib/prune-lanes.js +120 -0
  27. package/dist/lib/queue-lock.d.ts +26 -0
  28. package/dist/lib/queue-lock.js +212 -0
  29. package/dist/lib/tty-confirm.d.ts +1 -0
  30. package/dist/lib/tty-confirm.js +44 -0
  31. package/dist/lib/wire-hooks.d.ts +26 -0
  32. package/dist/lib/wire-hooks.js +123 -0
  33. package/dist/preview.d.ts +1 -0
  34. package/dist/preview.js +119 -0
  35. package/dist/promote.d.ts +1 -0
  36. package/dist/promote.js +77 -0
  37. package/dist/sync.d.ts +2 -0
  38. package/dist/sync.js +115 -0
  39. package/examples/ephemeral-tmp-dir.example.ts +66 -0
  40. package/examples/lanekeeper.config.mjs +67 -0
  41. package/hooks/claude-settings.example.json +14 -0
  42. package/hooks/pre-push +23 -0
  43. 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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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>;