@kontextmind/kxm 0.7.118 → 0.7.120

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.
@@ -36307,6 +36307,12 @@ function claudeBridgeConflictWarning(registered) {
36307
36307
  );
36308
36308
  }
36309
36309
  function claudeBridgeRegistrationNotice(report, pi) {
36310
+ if (report?.warning) {
36311
+ return {
36312
+ message: boundedRegistrationWarning(report.warning),
36313
+ type: "warning"
36314
+ };
36315
+ }
36310
36316
  const conflict = Boolean(report?.conflict) || (pi ? claudeBridgeStandaloneToolsPresent(pi) : false);
36311
36317
  if (!conflict) return void 0;
36312
36318
  return {
@@ -36325,14 +36331,24 @@ function registerClaudeBridgeProvider(pi) {
36325
36331
  if (typeof pi.registerProvider !== "function") {
36326
36332
  return { registered: false, conflict: false };
36327
36333
  }
36328
- pi.registerProvider(PROVIDER_ID2, {
36329
- name: PROVIDER_NAME2,
36330
- baseUrl: PROVIDER_ID2,
36331
- api: CLAUDE_BRIDGE_API,
36332
- models: registeredClaudeBridgeModels(),
36333
- streamSimple: streamClaudeBridge
36334
- });
36335
- return { registered: true, conflict: false };
36334
+ try {
36335
+ pi.registerProvider(PROVIDER_ID2, {
36336
+ name: PROVIDER_NAME2,
36337
+ baseUrl: PROVIDER_ID2,
36338
+ apiKey: PROVIDER_ID2,
36339
+ api: CLAUDE_BRIDGE_API,
36340
+ models: registeredClaudeBridgeModels(),
36341
+ streamSimple: streamClaudeBridge
36342
+ });
36343
+ return { registered: true, conflict: false };
36344
+ } catch (error2) {
36345
+ const reason = error2 instanceof Error ? error2.message : String(error2);
36346
+ return {
36347
+ registered: false,
36348
+ conflict: false,
36349
+ warning: `kxm: claude-bridge provider registration failed (${reason}). Configure apiKey or ensure provider auth is valid.`
36350
+ };
36351
+ }
36336
36352
  }
36337
36353
 
36338
36354
  // plugins/kxm/src/diagnostics.ts
@@ -17313,7 +17313,7 @@ function sessionTokenFixHint(policy) {
17313
17313
  }
17314
17314
 
17315
17315
  // plugins/kxm/src/mcp-server.ts
17316
- var VERSION = "0.7.118";
17316
+ var VERSION = "0.7.120";
17317
17317
  var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
17318
17318
  var inbox = /* @__PURE__ */ new Map();
17319
17319
  var notifiedInbox = /* @__PURE__ */ new Set();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.118",
3
+ "version": "0.7.120",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -20,7 +20,7 @@ writers, or replace `.kxm/roster.yaml` trusted policy.
20
20
  | Delegate to or answer another agent | `kxm-peer` | `peer` (`peer await` is capped at 60 seconds) |
21
21
  | Work inside a durable workflow run, gates, signed callbacks | `kxm-workflow` | `workflow`, `gate` |
22
22
  | Role definitions, role hosts, model rosters | `kxm-definitions` | `role` |
23
- | Create, drive, inspect, or cancel a run | `kxm-runs` | `run`, `runs` |
23
+ | Create, drive, inspect, or cancel a run, or use a worktree lane | `kxm-runs` | `run`, `runs`, `lane` |
24
24
  | What we know or decided, recall, context footprint, Git memory | `kxm-context-memory` | `context`, `memory`, `explain` |
25
25
  | Turn a repeated practice into a governed skill | `kxm-skill-lifecycle` | `skills` |
26
26
  | What KXM learned, repeated asks, recorded route spend | `kxm-routing-improve` | `routing`, `improve` |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kxm-runs
3
- description: Create, drive, and inspect local KXM runs. kxm runs drive with --simulated executes a run model-free and settles it with a verified receipt. Use when asked to start a workflow run, check its status, read its receipt, cancel it, or smoke-test a workflow.
3
+ description: Create, drive, and inspect local KXM runs, and manage worktree lanes. kxm runs drive with --simulated executes a run model-free and settles it with a verified receipt. Use when asked to start a workflow run, check its status, read its receipt, cancel it, smoke-test a workflow, or create a lane.
4
4
  ---
5
5
 
6
6
  # KXM runs
@@ -15,12 +15,17 @@ create, or logs verbs under `runs`.
15
15
 
16
16
  | Command | Purpose | Options / arguments |
17
17
  |---|---|---|
18
- | `kxm run [workflow] [prompt...]` | Create a run; the full prompt is kept on disk (see below) | `--dry-run` (plan only), `--json` |
19
- | `kxm runs drive <runId>` | Drive a run; with `--wait`, exits 0 only for a verified completed settlement | `--simulated`, `--wait`, `--timeout-ms <n>` (default 60000, max 600000), `--json` |
20
- | `kxm runs status <runId>` | Projected run status plus drive receipt state (open, receipt verified, unsettled, orphaned) | `--json` |
21
- | `kxm runs receipt <runId>` | Newest drive receipt for a run | `--all`, `--json` |
22
- | `kxm runs cancel <runId>` | Durably request cancellation | `--json` |
18
+ | `kxm run [workflow] [prompt...]` | Create a run; the full prompt is kept on disk (see below) | `--brief <file>`, `--lane <unit>`, `--dry-run` (plan only), `--json` |
19
+ | `kxm runs drive <runId>` | Drive a run; with `--wait`, exits 0 only for a verified completed settlement | `--simulated`, `--wait`, `--timeout-ms <n>` (default 60000, max 600000), `--lane <unit>`, `--json` |
20
+ | `kxm runs status <runId>` | Projected run status plus drive receipt state (open, receipt verified, unsettled, orphaned) | `--lane <unit>`, `--json` |
21
+ | `kxm runs receipt <runId>` | Newest drive receipt for a run | `--all`, `--lane <unit>`, `--json` |
22
+ | `kxm runs cancel <runId>` | Durably request cancellation | `--lane <unit>`, `--json` |
23
23
  | `kxm runs list` | Recent runs for the current project | `--json` |
24
+ | `kxm lane create <unit>` | Add a worktree lane at a resolved base sha. Default base is origin/main | `--base <ref>`, `--json` |
25
+ | `kxm lane list` | List lanes with dirty, ahead, behind, and exists | `--json` |
26
+ | `kxm lane status <unit>` | One lane plus its last run status, or unknown when the supervisor is not answering | `--json` |
27
+ | `kxm lane drop <unit>` | Remove the worktree and the record. The branch is not deleted | `--force`, `--json` |
28
+ | `kxm lane run <unit>` | Create the lane if needed, start a run from a brief, and drive it | `--brief <file>` (required), `--workflow <id>`, `--base <ref>`, `--wait`, `--timeout-ms <n>`, `--json` |
24
29
 
25
30
  The run record and its events keep only the prompt's hash, but the full
26
31
  prompt text is kept in a local `run-events.db.run-prompts.json` file (mode
@@ -54,6 +59,12 @@ The dry run prints `run plan: workflow first at sha256:… (no run created)`.
54
59
  After the drive, `kxm runs status` prints `completed … (receipt verified)` and
55
60
  the receipt's settlement is terminal `completed`.
56
61
 
62
+ `kxm lane` keeps one git worktree per unit in the control checkout's
63
+ `.kxm/state/lanes.json`. `kxm run --brief <file>` reads the prompt from that
64
+ file. `kxm run --lane <unit>` and `kxm runs status|drive|receipt|cancel --lane`
65
+ discover the project from the lane worktree. `kxm lane run <unit> --brief <file>`
66
+ creates the lane when it is missing, starts the run, and drives it.
67
+
57
68
  ## Refusals
58
69
 
59
70
  - `run_workflow_unknown`: the workflow ID is not a project workflow. Run only
@@ -0,0 +1,540 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { discoverKxmProjectRoot, kxmResourceIdentifier } from "../project-config.ts";
5
+ import { attachKxmSupervisor, ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts";
6
+ import { print, printPlan, workspaceDirs, type Runtime } from "./types.ts";
7
+
8
+ /** One worktree lane recorded in the control checkout. The sha is the base,
9
+ * not the ref: worktrees share refs, and a later fetch moves origin/main. */
10
+ export interface LaneRecord {
11
+ path: string;
12
+ branch: string;
13
+ baseRef: string;
14
+ baseSha: string;
15
+ createdAt: string;
16
+ lastRunId?: string;
17
+ }
18
+
19
+ interface LaneFile {
20
+ schema: "kxm.lanes.v1";
21
+ lanes: Record<string, LaneRecord>;
22
+ }
23
+
24
+ export interface LaneView extends LaneRecord {
25
+ unit: string;
26
+ dirty: number | null;
27
+ ahead: number | null;
28
+ behind: number | null;
29
+ exists: boolean;
30
+ }
31
+
32
+ const LANE_SCHEMA = "kxm.lanes.v1";
33
+ const SHA_RE = /^[a-f0-9]{40,64}$/;
34
+ const TIMESTAMP_RE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z$/;
35
+ const SETTLED_RUN = new Set(["completed", "failed", "cancelled"]);
36
+
37
+ function lanesFile(runtime: Runtime): string {
38
+ return join(runtime.dirs.state, "lanes.json");
39
+ }
40
+
41
+ function gitEnv(): NodeJS.ProcessEnv {
42
+ return Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_")));
43
+ }
44
+
45
+ function git(cwd: string, args: readonly string[]): { status: number; stdout: string; stderr: string } {
46
+ const result = spawnSync("git", ["-C", cwd, ...args], {
47
+ encoding: "utf8",
48
+ env: gitEnv(),
49
+ timeout: 30_000,
50
+ windowsHide: true,
51
+ });
52
+ return {
53
+ status: result.status ?? 1,
54
+ stdout: result.stdout ?? "",
55
+ stderr: `${result.stderr ?? ""}${result.error ? result.error.message : ""}`,
56
+ };
57
+ }
58
+
59
+ function refuse(runtime: Runtime, command: string, error: string, text: string, extra: Record<string, unknown> = {}): number {
60
+ print(runtime.io, runtime.json, { ok: false, command, error, ...extra }, text);
61
+ return 1;
62
+ }
63
+
64
+ function isLaneRecord(value: unknown): value is LaneRecord {
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
66
+ const record = value as Record<string, unknown>;
67
+ const keys = Object.keys(record);
68
+ const allowed = new Set(["path", "branch", "baseRef", "baseSha", "createdAt", "lastRunId"]);
69
+ if (keys.some((key) => !allowed.has(key))) return false;
70
+ if (typeof record.path !== "string" || record.path.length === 0) return false;
71
+ if (typeof record.branch !== "string" || record.branch.length === 0) return false;
72
+ if (typeof record.baseRef !== "string" || record.baseRef.length === 0) return false;
73
+ if (typeof record.baseSha !== "string" || !SHA_RE.test(record.baseSha)) return false;
74
+ if (typeof record.createdAt !== "string" || !TIMESTAMP_RE.test(record.createdAt)) return false;
75
+ if (record.lastRunId !== undefined && (typeof record.lastRunId !== "string" || record.lastRunId.length === 0)) return false;
76
+ return true;
77
+ }
78
+
79
+ function isLaneFile(value: unknown): value is LaneFile {
80
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
81
+ const file = value as Record<string, unknown>;
82
+ if (Object.keys(file).some((key) => key !== "schema" && key !== "lanes")) return false;
83
+ if (file.schema !== LANE_SCHEMA || !file.lanes || typeof file.lanes !== "object" || Array.isArray(file.lanes)) return false;
84
+ for (const [unit, record] of Object.entries(file.lanes as Record<string, unknown>)) {
85
+ if (!kxmResourceIdentifier(unit) || !isLaneRecord(record)) return false;
86
+ }
87
+ return true;
88
+ }
89
+
90
+ /** Read `<state>/lanes.json`. A missing file is an empty record. A malformed
91
+ * file is refused; it is never replaced with an empty record. */
92
+ function loadLanes(runtime: Runtime, command: string): LaneFile | number {
93
+ const file = lanesFile(runtime);
94
+ if (!existsSync(file)) return { schema: LANE_SCHEMA, lanes: {} };
95
+ try {
96
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
97
+ if (!isLaneFile(parsed)) return refuse(runtime, command, "lanes_unreadable", `lane record ${file} is malformed`);
98
+ return parsed;
99
+ } catch {
100
+ return refuse(runtime, command, "lanes_unreadable", `lane record ${file} is unreadable`);
101
+ }
102
+ }
103
+
104
+ function writeLanes(runtime: Runtime, file: LaneFile): void {
105
+ const target = lanesFile(runtime);
106
+ mkdirSync(runtime.dirs.state, { recursive: true });
107
+ const lanes: Record<string, LaneRecord> = {};
108
+ for (const unit of Object.keys(file.lanes).sort()) {
109
+ const record = file.lanes[unit];
110
+ if (!record) continue;
111
+ lanes[unit] = {
112
+ path: record.path,
113
+ branch: record.branch,
114
+ baseRef: record.baseRef,
115
+ baseSha: record.baseSha,
116
+ createdAt: record.createdAt,
117
+ ...(record.lastRunId ? { lastRunId: record.lastRunId } : {}),
118
+ };
119
+ }
120
+ writeFileSync(target, `${JSON.stringify({ schema: LANE_SCHEMA, lanes }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
121
+ try { chmodSync(target, 0o600); } catch { /* Windows may not expose POSIX modes. */ }
122
+ }
123
+
124
+ function controlRoot(runtime: Runtime, command: string): string | number {
125
+ const root = discoverKxmProjectRoot(runtime.cwd);
126
+ if (!root) return refuse(runtime, command, "project_required", `${command} requires a KXM project (run kxm init first)`);
127
+ return root;
128
+ }
129
+
130
+ function refuseUnit(runtime: Runtime, command: string, unit: string): number | undefined {
131
+ if (kxmResourceIdentifier(unit)) return undefined;
132
+ return refuse(runtime, command, "lane_unit_invalid", `lane unit ${unit} must match the KXM identifier pattern`);
133
+ }
134
+
135
+ function laneWorktreePath(projectRoot: string, unit: string): string {
136
+ return resolve(dirname(projectRoot), `${basename(projectRoot)}-${unit}`);
137
+ }
138
+
139
+ function branchExists(projectRoot: string, unit: string): boolean {
140
+ return git(projectRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${unit}`]).status === 0;
141
+ }
142
+
143
+ function resolveBase(projectRoot: string, base: string): string | undefined {
144
+ const result = git(projectRoot, ["rev-parse", "--verify", "--end-of-options", `${base}^{commit}`]);
145
+ if (result.status !== 0) return undefined;
146
+ const sha = result.stdout.trim();
147
+ return SHA_RE.test(sha) ? sha : undefined;
148
+ }
149
+
150
+ /** The only `--lane` mechanism: discovery cwd and workspace dirs come from the lane path. */
151
+ export function withLaneCwd(runtime: Runtime, unit: string, command = "lane"): Runtime | number {
152
+ const invalid = refuseUnit(runtime, command, unit);
153
+ if (invalid !== undefined) return invalid;
154
+ const loaded = loadLanes(runtime, command);
155
+ if (typeof loaded === "number") return loaded;
156
+ const lane = loaded.lanes[unit];
157
+ if (!lane || !existsSync(lane.path)) {
158
+ return refuse(runtime, command, "lane_missing", `lane ${unit} is not recorded or its worktree is absent`, { unit });
159
+ }
160
+ return {
161
+ ...runtime,
162
+ cwd: lane.path,
163
+ dirs: workspaceDirs(lane.path, undefined, runtime.env),
164
+ };
165
+ }
166
+
167
+ export function applyLane(runtime: Runtime, unit: string | undefined, command: string): Runtime | number {
168
+ if (unit === undefined) return runtime;
169
+ return withLaneCwd(runtime, unit, command);
170
+ }
171
+
172
+ /** `--lane` for `kxm runs`: same cwd swap as `withLaneCwd`, or the original runtime when unset. */
173
+ export function runtimeForLane(runtime: Runtime, unit: string | undefined): Runtime | number {
174
+ return applyLane(runtime, unit, "lane");
175
+ }
176
+
177
+ interface StartedRun {
178
+ code: number;
179
+ runId?: string;
180
+ }
181
+
182
+ /** Shared path for `kxm run --brief --lane` and `kxm lane run`. */
183
+ async function startRecordedRun(
184
+ runtime: Runtime,
185
+ workflow: string | undefined,
186
+ promptParts: string[],
187
+ options: { brief?: string; lane?: string; command: string; allowDefaultWorkflow?: boolean; skipOpenCheck?: boolean },
188
+ ): Promise<StartedRun> {
189
+ let prompt: string | undefined;
190
+ if (options.brief !== undefined) {
191
+ if (promptParts.length > 0) {
192
+ return { code: refuse(runtime, options.command, "brief_and_prompt", "pass either --brief or a prompt, not both", { brief: options.brief }) };
193
+ }
194
+ const brief = readRunBrief(runtime, options.brief, options.command);
195
+ if (typeof brief === "number") return { code: brief };
196
+ prompt = brief.text;
197
+ }
198
+ let active = runtime;
199
+ if (options.lane !== undefined) {
200
+ const scoped = withLaneCwd(runtime, options.lane, options.command);
201
+ if (typeof scoped === "number") return { code: scoped };
202
+ active = scoped;
203
+ if (options.skipOpenCheck !== true) {
204
+ const open = await refuseIfLaneRunOpen(runtime, options.lane, options.command);
205
+ if (open !== undefined) return { code: open };
206
+ }
207
+ }
208
+ const project = await import("./project.ts");
209
+ const runIdOut: { runId?: string } = {};
210
+ const code = await project.cmdKxmRun(active, workflow, prompt === undefined ? promptParts : [], false, {
211
+ ...(options.brief !== undefined ? { brief: options.brief } : {}),
212
+ ...(prompt !== undefined ? { prompt } : {}),
213
+ ...(options.allowDefaultWorkflow ? { allowDefaultWorkflow: true } : {}),
214
+ runIdOut,
215
+ });
216
+ if (code !== 0 || !runIdOut.runId) return { code, ...(runIdOut.runId ? { runId: runIdOut.runId } : {}) };
217
+ if (options.lane !== undefined) {
218
+ const recorded = recordLaneRun(runtime, options.lane, runIdOut.runId, options.command);
219
+ if (recorded !== undefined) return { code: recorded };
220
+ }
221
+ return { code: 0, runId: runIdOut.runId };
222
+ }
223
+
224
+ export async function cmdKxmRunCli(
225
+ runtime: Runtime,
226
+ workflow: string | undefined,
227
+ promptParts: string[],
228
+ options: { brief?: string; lane?: string } = {},
229
+ ): Promise<number> {
230
+ const started = await startRecordedRun(runtime, workflow, promptParts, { ...options, command: "run" });
231
+ return started.code;
232
+ }
233
+
234
+ export function readRunBrief(runtime: Runtime, file: string, command: string): { text: string } | number {
235
+ try {
236
+ return { text: readFileSync(resolve(runtime.cwd, file), "utf8").trim() };
237
+ } catch {
238
+ return refuse(runtime, command, "brief_unreadable", `cannot read brief ${file}`, { brief: file });
239
+ }
240
+ }
241
+
242
+ function porcelainCount(path: string): number | null {
243
+ const result = git(path, ["status", "--porcelain"]);
244
+ if (result.status !== 0) return null;
245
+ return result.stdout.split("\n").filter((line) => line.length > 0).length;
246
+ }
247
+
248
+ /** Left count is commits on the base that HEAD lacks (behind). Right is the reverse (ahead). */
249
+ function aheadBehind(path: string, baseSha: string): { ahead: number | null; behind: number | null } {
250
+ const result = git(path, ["rev-list", "--left-right", "--count", `${baseSha}...HEAD`]);
251
+ const match = result.status === 0 ? result.stdout.trim().match(/^(\d+)\s+(\d+)$/) : null;
252
+ if (!match) return { ahead: null, behind: null };
253
+ return { behind: Number(match[1]), ahead: Number(match[2]) };
254
+ }
255
+
256
+ function laneView(unit: string, record: LaneRecord): LaneView {
257
+ const exists = existsSync(record.path);
258
+ const counts = exists ? aheadBehind(record.path, record.baseSha) : { ahead: null, behind: null };
259
+ return {
260
+ unit,
261
+ path: record.path,
262
+ branch: record.branch,
263
+ baseRef: record.baseRef,
264
+ baseSha: record.baseSha,
265
+ createdAt: record.createdAt,
266
+ ...(record.lastRunId ? { lastRunId: record.lastRunId } : {}),
267
+ dirty: exists ? porcelainCount(record.path) : null,
268
+ ahead: counts.ahead,
269
+ behind: counts.behind,
270
+ exists,
271
+ };
272
+ }
273
+
274
+ function formatCount(value: number | null): string {
275
+ return value === null ? "?" : String(value);
276
+ }
277
+
278
+ function formatLaneLine(view: LaneView): string {
279
+ return [
280
+ view.unit,
281
+ view.path,
282
+ `branch=${view.branch}`,
283
+ `base=${view.baseSha}`,
284
+ `dirty=${formatCount(view.dirty)}`,
285
+ `ahead=${formatCount(view.ahead)}`,
286
+ `behind=${formatCount(view.behind)}`,
287
+ `exists=${view.exists}`,
288
+ ].join(" ");
289
+ }
290
+
291
+ /** A running supervisor can answer. This never starts one. A test seam stands in for that process. */
292
+ async function projectedRunStatus(runtime: Runtime, projectRoot: string, runId: string, allowStart: boolean): Promise<string> {
293
+ try {
294
+ const project = await import("./project.ts");
295
+ const request = project.kxmDriveCliSeams.runtimeRequest ?? kxmRuntimeRequest;
296
+ // `lane status` never starts the supervisor. Run and drop may, except under --dry-run.
297
+ const handle = !allowStart || runtime.dryRun
298
+ ? await attachKxmSupervisor({ env: runtime.env })
299
+ : await (project.kxmDriveCliSeams.ensureSupervisor ?? ensureKxmSupervisor)({ env: runtime.env });
300
+ if (!handle) return "unknown";
301
+ const result = await request(
302
+ handle,
303
+ "GET",
304
+ `/v1/runs/${encodeURIComponent(runId)}?projectRoot=${encodeURIComponent(projectRoot)}`,
305
+ );
306
+ const run = result.run;
307
+ if (!run || typeof run !== "object" || Array.isArray(run)) return "unknown";
308
+ const status = (run as { status?: unknown }).status;
309
+ return typeof status === "string" && status.length > 0 ? status : "unknown";
310
+ } catch {
311
+ return "unknown";
312
+ }
313
+ }
314
+
315
+ function isSettled(status: string): boolean {
316
+ return SETTLED_RUN.has(status);
317
+ }
318
+
319
+ export async function refuseIfLaneRunOpen(runtime: Runtime, unit: string, command: string): Promise<number | undefined> {
320
+ const loaded = loadLanes(runtime, command);
321
+ if (typeof loaded === "number") return loaded;
322
+ const lane = loaded.lanes[unit];
323
+ if (!lane?.lastRunId) return undefined;
324
+ const runStatus = await projectedRunStatus(runtime, lane.path, lane.lastRunId, true);
325
+ if (isSettled(runStatus)) return undefined;
326
+ return refuse(
327
+ runtime,
328
+ command,
329
+ "lane_run_open",
330
+ `lane ${unit} run ${lane.lastRunId} is ${runStatus}`,
331
+ { unit, runId: lane.lastRunId, runStatus },
332
+ );
333
+ }
334
+
335
+ export function recordLaneRun(runtime: Runtime, unit: string, runId: string, command: string): number | undefined {
336
+ const loaded = loadLanes(runtime, command);
337
+ if (typeof loaded === "number") return loaded;
338
+ const lane = loaded.lanes[unit];
339
+ if (!lane) return refuse(runtime, command, "lane_missing", `lane ${unit} is not recorded or its worktree is absent`, { unit });
340
+ lane.lastRunId = runId;
341
+ try {
342
+ writeLanes(runtime, loaded);
343
+ } catch {
344
+ return refuse(runtime, command, "lanes_unreadable", `lane record ${lanesFile(runtime)} could not be written`);
345
+ }
346
+ return undefined;
347
+ }
348
+
349
+ export async function cmdLaneCreate(runtime: Runtime, unit: string, options: { base?: string } = {}): Promise<number> {
350
+ const command = "lane create";
351
+ const invalid = refuseUnit(runtime, command, unit);
352
+ if (invalid !== undefined) return invalid;
353
+ const root = controlRoot(runtime, command);
354
+ if (typeof root === "number") return root;
355
+ const base = options.base?.trim() || "origin/main";
356
+ const loaded = loadLanes(runtime, command);
357
+ if (typeof loaded === "number") return loaded;
358
+ const path = laneWorktreePath(root, unit);
359
+ if (loaded.lanes[unit] || existsSync(path) || branchExists(root, unit)) {
360
+ return refuse(runtime, command, "lane_exists", `lane ${unit} already exists`, { unit });
361
+ }
362
+ const baseSha = resolveBase(root, base);
363
+ if (!baseSha) {
364
+ return refuse(
365
+ runtime,
366
+ command,
367
+ "lane_base_unresolved",
368
+ `base ${base} does not resolve; git fetch origin`,
369
+ { unit, base, hint: "git fetch origin" },
370
+ );
371
+ }
372
+ if (runtime.dryRun) {
373
+ printPlan(runtime, { command, unit, base, baseSha, path }, [
374
+ { action: "write", target: path },
375
+ { action: "write", target: lanesFile(runtime) },
376
+ ], `create lane ${unit} at ${path} from ${baseSha}`);
377
+ return 0;
378
+ }
379
+ const added = git(root, ["worktree", "add", "-b", unit, path, baseSha]);
380
+ if (added.status !== 0) {
381
+ return refuse(runtime, command, "lane_git_failed", `git worktree add failed: ${added.stderr.trim().slice(0, 300)}`, { unit });
382
+ }
383
+ // Same string `discoverKxmProjectRoot` will return when `--lane` swaps cwd.
384
+ const recordedPath = discoverKxmProjectRoot(path) ?? path;
385
+ if (!existsSync(join(recordedPath, ".kxm", "project.yaml"))) {
386
+ git(root, ["worktree", "remove", "--force", path]);
387
+ return refuse(runtime, command, "lane_not_project", `lane ${recordedPath} has no .kxm/project.yaml; the worktree was removed`, { unit, path: recordedPath });
388
+ }
389
+ const lane: LaneRecord = {
390
+ path: recordedPath,
391
+ branch: unit,
392
+ baseRef: base,
393
+ baseSha,
394
+ createdAt: new Date().toISOString(),
395
+ };
396
+ loaded.lanes[unit] = lane;
397
+ try {
398
+ writeLanes(runtime, loaded);
399
+ } catch {
400
+ git(root, ["worktree", "remove", "--force", path]);
401
+ return refuse(runtime, command, "lanes_unreadable", `lane record ${lanesFile(runtime)} could not be written`, { unit });
402
+ }
403
+ print(runtime.io, runtime.json, { ok: true, command, unit, lane }, formatLaneLine(laneView(unit, lane)));
404
+ return 0;
405
+ }
406
+
407
+ export async function cmdLaneList(runtime: Runtime): Promise<number> {
408
+ const command = "lane list";
409
+ const loaded = loadLanes(runtime, command);
410
+ if (typeof loaded === "number") return loaded;
411
+ const lanes = Object.keys(loaded.lanes).sort().map((unit) => laneView(unit, loaded.lanes[unit]!));
412
+ print(
413
+ runtime.io,
414
+ runtime.json,
415
+ { ok: true, command, lanes },
416
+ lanes.length === 0 ? "no lanes" : lanes.map((lane) => formatLaneLine(lane)).join("\n"),
417
+ );
418
+ return 0;
419
+ }
420
+
421
+ export async function cmdLaneStatus(runtime: Runtime, unit: string): Promise<number> {
422
+ const command = "lane status";
423
+ const invalid = refuseUnit(runtime, command, unit);
424
+ if (invalid !== undefined) return invalid;
425
+ const loaded = loadLanes(runtime, command);
426
+ if (typeof loaded === "number") return loaded;
427
+ const record = loaded.lanes[unit];
428
+ if (!record) return refuse(runtime, command, "lane_missing", `lane ${unit} is not recorded`, { unit });
429
+ const view = laneView(unit, record);
430
+ const runStatus = record.lastRunId ? await projectedRunStatus(runtime, record.path, record.lastRunId, false) : "unknown";
431
+ print(
432
+ runtime.io,
433
+ runtime.json,
434
+ { ok: true, command, lane: { ...view, status: runStatus } },
435
+ `${formatLaneLine(view)} status=${runStatus}`,
436
+ );
437
+ return 0;
438
+ }
439
+
440
+ export async function cmdLaneDrop(runtime: Runtime, unit: string, options: { force?: boolean } = {}): Promise<number> {
441
+ const command = "lane drop";
442
+ const invalid = refuseUnit(runtime, command, unit);
443
+ if (invalid !== undefined) return invalid;
444
+ const root = controlRoot(runtime, command);
445
+ if (typeof root === "number") return root;
446
+ const loaded = loadLanes(runtime, command);
447
+ if (typeof loaded === "number") return loaded;
448
+ const record = loaded.lanes[unit];
449
+ if (!record) return refuse(runtime, command, "lane_missing", `lane ${unit} is not recorded`, { unit });
450
+ const force = options.force === true;
451
+ if (!force) {
452
+ if (existsSync(record.path)) {
453
+ const dirty = porcelainCount(record.path);
454
+ if (dirty === null) {
455
+ return refuse(runtime, command, "lane_git_failed", `lane ${unit} worktree status could not be read`, { unit });
456
+ }
457
+ if (dirty !== 0) {
458
+ return refuse(runtime, command, "lane_dirty", `lane ${unit} has ${dirty} uncommitted change(s)`, { unit, dirty });
459
+ }
460
+ }
461
+ const open = await refuseIfLaneRunOpen(runtime, unit, command);
462
+ if (open !== undefined) return open;
463
+ }
464
+ if (runtime.dryRun) {
465
+ printPlan(runtime, { command, unit, branchKept: record.branch }, [
466
+ { action: "delete", target: record.path },
467
+ { action: "write", target: lanesFile(runtime) },
468
+ ], `drop lane ${unit}; branch ${record.branch} is not deleted`);
469
+ return 0;
470
+ }
471
+ if (existsSync(record.path)) {
472
+ const removed = git(root, ["worktree", "remove", ...(force ? ["--force"] : []), record.path]);
473
+ if (removed.status !== 0) {
474
+ return refuse(runtime, command, "lane_git_failed", `git worktree remove failed: ${removed.stderr.trim().slice(0, 300)}`, { unit });
475
+ }
476
+ }
477
+ delete loaded.lanes[unit];
478
+ try {
479
+ writeLanes(runtime, loaded);
480
+ } catch {
481
+ return refuse(runtime, command, "lanes_unreadable", `lane record ${lanesFile(runtime)} could not be written`, { unit });
482
+ }
483
+ print(
484
+ runtime.io,
485
+ runtime.json,
486
+ { ok: true, command, unit, branch: record.branch, branchDeleted: false },
487
+ `dropped lane ${unit}; branch ${record.branch} was not deleted`,
488
+ );
489
+ return 0;
490
+ }
491
+
492
+ export async function cmdLaneRun(
493
+ runtime: Runtime,
494
+ unit: string,
495
+ options: { brief?: string; workflow?: string; base?: string; wait?: boolean; timeoutMs?: string },
496
+ ): Promise<number> {
497
+ const command = "lane run";
498
+ const invalid = refuseUnit(runtime, command, unit);
499
+ if (invalid !== undefined) return invalid;
500
+ if (!options.brief) return refuse(runtime, command, "brief_unreadable", "lane run requires --brief <file>");
501
+ const brief = readRunBrief(runtime, options.brief, command);
502
+ if (typeof brief === "number") return brief;
503
+ const loaded = loadLanes(runtime, command);
504
+ if (typeof loaded === "number") return loaded;
505
+ const existed = Boolean(loaded.lanes[unit]);
506
+ if (!existed) {
507
+ const created = await cmdLaneCreate(runtime, unit, options.base !== undefined ? { base: options.base } : {});
508
+ if (created !== 0) return created;
509
+ }
510
+ if (runtime.dryRun) {
511
+ if (existed) {
512
+ const open = await refuseIfLaneRunOpen(runtime, unit, command);
513
+ if (open !== undefined) return open;
514
+ }
515
+ const root = controlRoot(runtime, command);
516
+ const path = existed ? loaded.lanes[unit]!.path : (typeof root === "number" ? unit : laneWorktreePath(root, unit));
517
+ printPlan(runtime, { command, unit, brief: options.brief }, [
518
+ { action: "request", target: `POST /v1/runs projectRoot=${path}` },
519
+ { action: "request", target: `POST /v1/runs/<new>/drive projectRoot=${path}` },
520
+ ], `run lane ${unit} from ${options.brief} and drive it`);
521
+ return 0;
522
+ }
523
+ const open = await refuseIfLaneRunOpen(runtime, unit, command);
524
+ if (open !== undefined) return open;
525
+ const outcome = await startRecordedRun(runtime, options.workflow, [], {
526
+ brief: options.brief,
527
+ lane: unit,
528
+ command,
529
+ allowDefaultWorkflow: true,
530
+ skipOpenCheck: true,
531
+ });
532
+ if (outcome.code !== 0 || !outcome.runId) return outcome.code === 0 ? 1 : outcome.code;
533
+ const scoped = withLaneCwd(runtime, unit, command);
534
+ if (typeof scoped === "number") return scoped;
535
+ const project = await import("./project.ts");
536
+ return project.cmdKxmRunDrive(scoped, outcome.runId, false, {
537
+ wait: options.wait === true,
538
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
539
+ });
540
+ }