@kontextmind/kxm 0.6.0 → 0.7.10

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 (175) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/agents/coordinator.yaml +9 -0
  3. package/.kxm/agents/critic-arch.yaml +13 -0
  4. package/.kxm/agents/critic-cli.yaml +13 -0
  5. package/.kxm/agents/implementer.yaml +13 -0
  6. package/.kxm/gates.yaml +8 -0
  7. package/.kxm/producers.yaml +22 -0
  8. package/.kxm/project.yaml +15 -0
  9. package/.kxm/roles/writer.yaml +7 -0
  10. package/.kxm/workflows/default.yaml +47 -0
  11. package/CHANGELOG.md +39 -7
  12. package/README.md +1 -0
  13. package/docs/README.md +5 -0
  14. package/docs/adr/ADR-0002-browser-automation-steel-doks.md +103 -0
  15. package/docs/agent-skills.md +135 -0
  16. package/docs/architecture.md +1 -1
  17. package/docs/assignment-runner.md +21 -8
  18. package/docs/browser-automation.md +116 -0
  19. package/docs/configuration.md +11 -2
  20. package/docs/getting-started.md +21 -0
  21. package/docs/kb/how-credentials-retrieved-safely.md +31 -0
  22. package/docs/kb/how-to-capture-and-annotate-section.md +60 -0
  23. package/docs/kb/how-to-connect-playwright-to-steel.md +54 -0
  24. package/docs/kb/how-to-recover-expired-session-or-orphan.md +54 -0
  25. package/docs/kb/how-to-resume-after-mfa.md +28 -0
  26. package/docs/kb/how-to-take-over-session.md +32 -0
  27. package/docs/kb/why-authentication-disappeared.md +32 -0
  28. package/docs/kb/why-automation-opened-different-browser.md +32 -0
  29. package/docs/kb/why-session-viewer-cannot-control.md +31 -0
  30. package/docs/kxm-handbook.md +3 -3
  31. package/docs/operations.md +24 -0
  32. package/docs/operator-pi-packages.md +63 -0
  33. package/docs/prompts/browser-annotate-feedback.md +41 -0
  34. package/docs/prompts/browser-diagnose-recover.md +38 -0
  35. package/docs/prompts/browser-explore.md +42 -0
  36. package/docs/prompts/browser-repro-fix.md +48 -0
  37. package/docs/prompts/browser-start.md +41 -0
  38. package/docs/prompts/browser-takeover.md +50 -0
  39. package/docs/skills/repo-work-delivery.md +107 -0
  40. package/docs/skills.md +2 -0
  41. package/docs/test-matrix.md +4 -3
  42. package/docs/troubleshooting.md +41 -1
  43. package/docs/vnext/validation.md +9 -0
  44. package/docs/webhook-workflows.md +2 -2
  45. package/examples/README.md +1 -1
  46. package/package.json +16 -17
  47. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  48. package/plugins/kxm/README.md +1 -1
  49. package/plugins/kxm/dist/cli.js +41620 -35578
  50. package/plugins/kxm/dist/core.js +271 -34
  51. package/plugins/kxm/dist/extension.js +7759 -86
  52. package/plugins/kxm/dist/mcp-server.js +75 -21
  53. package/plugins/kxm/dist/runtime.js +8218 -2328
  54. package/plugins/kxm/dist/server.js +3125 -2260
  55. package/plugins/kxm/dist/vnext-runtime-supervisor.js +5961 -661
  56. package/plugins/kxm/package.json +1 -1
  57. package/plugins/kxm/skills/SUITE.md +5 -0
  58. package/plugins/kxm/skills/hints.json +103 -0
  59. package/plugins/kxm/skills/kxm/SKILL.md +30 -83
  60. package/plugins/kxm/skills/kxm-browser-annotate/SKILL.md +90 -0
  61. package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +47 -0
  62. package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +48 -0
  63. package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +48 -0
  64. package/plugins/kxm/skills/kxm-browser-session/SKILL.md +94 -0
  65. package/plugins/kxm/skills/kxm-browser-takeover/SKILL.md +87 -0
  66. package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +71 -0
  67. package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
  68. package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
  69. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
  70. package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
  71. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +43 -0
  72. package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
  73. package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
  74. package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
  75. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +42 -0
  76. package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
  77. package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
  78. package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
  79. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
  80. package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
  81. package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
  82. package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
  83. package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
  84. package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
  85. package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
  86. package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
  87. package/plugins/kxm/src/autocomplete.ts +9 -3
  88. package/plugins/kxm/src/browser.ts +603 -0
  89. package/plugins/kxm/src/cli/context-skills.ts +373 -0
  90. package/plugins/kxm/src/cli/hub.ts +614 -0
  91. package/plugins/kxm/src/cli/roles.ts +615 -0
  92. package/plugins/kxm/src/cli/system.ts +906 -0
  93. package/plugins/kxm/src/cli/tasks.ts +364 -0
  94. package/plugins/kxm/src/cli/types.ts +270 -0
  95. package/plugins/kxm/src/cli/vnext.ts +698 -0
  96. package/plugins/kxm/src/cli/workflows.ts +699 -0
  97. package/plugins/kxm/src/cli.ts +362 -2849
  98. package/plugins/kxm/src/commands.ts +150 -8
  99. package/plugins/kxm/src/completion-install.ts +223 -0
  100. package/plugins/kxm/src/config.ts +7 -4
  101. package/plugins/kxm/src/context-packet.ts +172 -0
  102. package/plugins/kxm/src/database.ts +1 -1
  103. package/plugins/kxm/src/extension.ts +36 -1
  104. package/plugins/kxm/src/external-effects.ts +357 -8
  105. package/plugins/kxm/src/hub-env.ts +193 -0
  106. package/plugins/kxm/src/hub.ts +2 -4
  107. package/plugins/kxm/src/improve.ts +72 -0
  108. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  109. package/plugins/kxm/src/local-snapshot.ts +1 -1
  110. package/plugins/kxm/src/mcp-server.ts +1 -1
  111. package/plugins/kxm/src/model-inventory.ts +127 -0
  112. package/plugins/kxm/src/modes.ts +348 -0
  113. package/plugins/kxm/src/policy-draft.d.mts +55 -0
  114. package/plugins/kxm/src/policy-draft.mjs +565 -0
  115. package/plugins/kxm/src/price-calc.ts +17 -18
  116. package/plugins/kxm/src/prices.ts +32 -16
  117. package/plugins/kxm/src/producers.ts +71 -0
  118. package/plugins/kxm/src/protocol.ts +111 -0
  119. package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
  120. package/plugins/kxm/src/restricted-yaml.mjs +145 -0
  121. package/plugins/kxm/src/role.ts +710 -0
  122. package/plugins/kxm/src/routing.ts +99 -1
  123. package/plugins/kxm/src/runtime.ts +4 -0
  124. package/plugins/kxm/src/safety-integrity.ts +76 -0
  125. package/plugins/kxm/src/session-work.ts +9 -2
  126. package/plugins/kxm/src/sqlite.ts +76 -0
  127. package/plugins/kxm/src/ssh-remote.ts +560 -0
  128. package/plugins/kxm/src/store.ts +1 -1
  129. package/plugins/kxm/src/studio-layout.ts +660 -17
  130. package/plugins/kxm/src/subagent-control.ts +312 -0
  131. package/plugins/kxm/src/suggest.ts +7 -13
  132. package/plugins/kxm/src/telemetry.ts +82 -0
  133. package/plugins/kxm/src/tui.ts +140 -0
  134. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  135. package/plugins/kxm/src/vnext-config.ts +53 -111
  136. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  137. package/plugins/kxm/src/vnext-engine.ts +214 -62
  138. package/plugins/kxm/src/vnext-harness.ts +336 -84
  139. package/plugins/kxm/src/vnext-oneshot-evidence.ts +117 -0
  140. package/plugins/kxm/src/vnext-oneshot-process.ts +187 -0
  141. package/plugins/kxm/src/vnext-oneshot-producer.ts +182 -224
  142. package/plugins/kxm/src/vnext-pi-producer.ts +11 -7
  143. package/plugins/kxm/src/vnext-runtime-store.ts +36 -2
  144. package/plugins/kxm/src/vnext-runtime-supervisor.ts +122 -5
  145. package/plugins/kxm/src/vnext-runtime.ts +14 -0
  146. package/plugins/kxm/src/workflow-manager.ts +392 -0
  147. package/plugins/kxm/src/workflow-tui.ts +255 -0
  148. package/plugins/kxm/src/workflow.ts +144 -0
  149. package/schemas/policy-draft/README.md +17 -0
  150. package/schemas/policy-draft/model.v2.schema.json +140 -0
  151. package/schemas/policy-draft/role.v2.schema.json +91 -0
  152. package/schemas/vnext/modes.schema.json +56 -0
  153. package/schemas/vnext/role.schema.json +76 -0
  154. package/schemas/vnext/run-event.schema.json +1 -0
  155. package/scripts/assignment-run.d.mts +1 -1
  156. package/scripts/assignment-run.mjs +44 -35
  157. package/scripts/check-generated.mjs +33 -9
  158. package/scripts/emit-codex-artifacts.mjs +255 -11
  159. package/scripts/harness-run.d.mts +12 -4
  160. package/scripts/harness-run.mjs +65 -17
  161. package/scripts/kxm-bump-version.mjs +146 -0
  162. package/scripts/kxm-hub.mjs +150 -2
  163. package/scripts/kxm-publish-npm.mjs +3 -1
  164. package/scripts/kxm-release-github.mjs +3 -1
  165. package/scripts/kxm.mjs +0 -0
  166. package/scripts/native-critic.d.mts +5 -0
  167. package/scripts/native-critic.mjs +60 -0
  168. package/.kxm/config/README.md +0 -5
  169. package/.kxm/config/agents.json +0 -43
  170. package/.kxm/config/env.example +0 -56
  171. package/.kxm/config/update.example.yaml +0 -9
  172. package/.kxm/config/workflows/fix.json +0 -160
  173. package/.kxm/config/workflows/jira-development.json +0 -116
  174. package/.kxm/config/workflows/provenance-quorum.json +0 -150
  175. package/.kxm/config/workflows/v04-dogfood.json +0 -72
@@ -24,6 +24,11 @@ import {
24
24
  type SessionHubStatus,
25
25
  type SessionWorkItem,
26
26
  } from "./session-work.ts";
27
+ import {
28
+ loadActiveWorkflowProgress,
29
+ renderWorkflowTuiText,
30
+ renderWorkflowWidgetLines,
31
+ } from "./workflow-tui.ts";
27
32
 
28
33
  const SETTLEMENT_RETRY_BASE_MS = 250;
29
34
  const SETTLEMENT_RETRY_MAX_MS = 30_000;
@@ -303,6 +308,10 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
303
308
  const brief = await loadSessionBriefAsync(cwd, process.env, currentWork, hub);
304
309
  ctx.ui.setStatus?.("kxm", brief.statusLine);
305
310
  ctx.ui.setWidget?.("kxm-work", brief.widgetLines);
311
+ const progress = loadActiveWorkflowProgress(cwd);
312
+ if (progress) {
313
+ ctx.ui.setWidget?.("kxm-progress", renderWorkflowWidgetLines(progress));
314
+ }
306
315
  if (!offerPicker || !sessionBriefPickerEnabled({
307
316
  env: process.env,
308
317
  ...(ctx.mode ? { mode: ctx.mode } : {}),
@@ -866,8 +875,20 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
866
875
  ctx.ui.notify(brief.statusLine, "info");
867
876
  return;
868
877
  }
878
+ if (command === "progress" || command === "workflow") {
879
+ const cwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
880
+ const progress = loadActiveWorkflowProgress(cwd);
881
+ if (progress) {
882
+ const lines = renderWorkflowWidgetLines(progress);
883
+ ctx.ui.setWidget?.("kxm-progress", lines);
884
+ ctx.ui.notify(renderWorkflowTuiText(progress), "info");
885
+ } else {
886
+ ctx.ui.notify("kxm: no active workflow run found in state.", "info");
887
+ }
888
+ return;
889
+ }
869
890
  if (command === "help") {
870
- ctx.ui.notify("kxm: /kxm | /kxm status | /kxm hub | /kxm memory | /kxm help. CLI: kxm session brief, kxm hub view, kxm memory brief", "info");
891
+ ctx.ui.notify("kxm: /kxm | /kxm status | /kxm progress | /kxm workflow | /kxm hub | /kxm memory | /kxm help. CLI: kxm session brief, kxm hub view, kxm memory brief", "info");
871
892
  return;
872
893
  }
873
894
  if (command === "memory") {
@@ -889,6 +910,20 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
889
910
  },
890
911
  });
891
912
 
913
+ pi.registerCommand("workflow", {
914
+ description: "Display active KXM workflow stage progress, assigned roles, and model metrics",
915
+ handler: async (_args, ctx) => {
916
+ const cwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
917
+ const progress = loadActiveWorkflowProgress(cwd);
918
+ if (progress) {
919
+ ctx.ui.setWidget?.("kxm-progress", renderWorkflowWidgetLines(progress));
920
+ ctx.ui.notify(renderWorkflowTuiText(progress), "info");
921
+ } else {
922
+ ctx.ui.notify("kxm: no active workflow run found in state.", "info");
923
+ }
924
+ },
925
+ });
926
+
892
927
  return nousFactoryWork(pi, (report) => {
893
928
  nousReport = report;
894
929
  });
@@ -3,13 +3,26 @@
3
3
  * Ensures deterministic branching, preflight CAS checks, and immutable receipts for external mutations.
4
4
  */
5
5
 
6
- import { DatabaseSync } from "node:sqlite";
6
+ import { DatabaseSync } from "./sqlite.ts";
7
7
  import { createHash } from "node:crypto";
8
- import { mkdirSync } from "node:fs";
9
- import { dirname } from "node:path";
8
+ import {
9
+ mkdirSync,
10
+ openSync,
11
+ closeSync,
12
+ writeFileSync,
13
+ readFileSync,
14
+ unlinkSync,
15
+ existsSync,
16
+ lstatSync,
17
+ } from "node:fs";
18
+ import { dirname, join, resolve } from "node:path";
19
+ import { spawnSync } from "node:child_process";
10
20
 
11
21
  export const EXTERNAL_EFFECT_SCHEMA = "kxm.external-effect-receipt.v1" as const;
12
22
 
23
+ export const DEFAULT_LEASE_TIMEOUT_MS = 300_000; // 300s (5 minutes) per Decision Q6
24
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000; // 30s per Decision Q6
25
+
13
26
  export type ExternalActionKind =
14
27
  | "git-branch"
15
28
  | "git-commit"
@@ -32,6 +45,7 @@ export interface ExternalEffectReceipt {
32
45
  payloadHash: string;
33
46
  receiptPayload: Record<string, unknown>;
34
47
  executedAt: string;
48
+ lastHeartbeatAt?: string | undefined;
35
49
  completedAt?: string | undefined;
36
50
  }
37
51
 
@@ -124,15 +138,22 @@ export class ExternalEffectsLedger {
124
138
  payload_hash TEXT NOT NULL,
125
139
  receipt_payload TEXT NOT NULL,
126
140
  executed_at TEXT NOT NULL,
141
+ last_heartbeat_at TEXT,
127
142
  completed_at TEXT
128
143
  );
129
144
  CREATE INDEX IF NOT EXISTS idx_ext_effects_run ON external_effects(run_id);
130
145
  `);
146
+ try {
147
+ this.db.exec(`ALTER TABLE external_effects ADD COLUMN last_heartbeat_at TEXT;`);
148
+ } catch {
149
+ // Column already present in schema
150
+ }
131
151
  }
132
152
 
133
153
  /**
134
154
  * Preflight Check-And-Set (CAS):
135
155
  * Guarantees that an external mutation is only initiated if not already committed or currently in-flight.
156
+ * Auto-reclaims stale leases after timeoutMs (default 300s per Decision Q6).
136
157
  */
137
158
  claimEffect(input: {
138
159
  runId: string;
@@ -147,6 +168,7 @@ export class ExternalEffectsLedger {
147
168
  const now = new Date().toISOString();
148
169
  const payloadStr = JSON.stringify(input.payload ?? {});
149
170
  const payloadHash = createHash("sha256").update(payloadStr).digest("hex");
171
+ const timeout = input.timeoutMs ?? DEFAULT_LEASE_TIMEOUT_MS;
150
172
 
151
173
  const existing = this.getReceipt(effectKey);
152
174
  if (existing) {
@@ -158,8 +180,8 @@ export class ExternalEffectsLedger {
158
180
  };
159
181
  }
160
182
  if (existing.status === "in-flight") {
161
- const timeout = input.timeoutMs ?? 60000;
162
- const elapsed = Date.now() - Date.parse(existing.executedAt);
183
+ const lastActivity = existing.lastHeartbeatAt ?? existing.executedAt;
184
+ const elapsed = Date.now() - Date.parse(lastActivity);
163
185
  if (elapsed < timeout) {
164
186
  return {
165
187
  ok: false,
@@ -167,19 +189,20 @@ export class ExternalEffectsLedger {
167
189
  existing,
168
190
  };
169
191
  }
170
- // If timed out, allow reclaim by updating status
192
+ // If timed out, allow reclaim by updating status and timestamps
171
193
  }
172
194
  }
173
195
 
174
196
  const stmt = this.db.prepare(`
175
197
  INSERT INTO external_effects (
176
198
  effect_key, run_id, step_id, attempt_id, action_kind, target_ref,
177
- status, payload_hash, receipt_payload, executed_at
178
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
199
+ status, payload_hash, receipt_payload, executed_at, last_heartbeat_at
200
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
179
201
  ON CONFLICT(effect_key) DO UPDATE SET
180
202
  attempt_id = excluded.attempt_id,
181
203
  status = 'in-flight',
182
204
  executed_at = excluded.executed_at,
205
+ last_heartbeat_at = excluded.last_heartbeat_at,
183
206
  payload_hash = excluded.payload_hash,
184
207
  receipt_payload = excluded.receipt_payload
185
208
  `);
@@ -195,11 +218,37 @@ export class ExternalEffectsLedger {
195
218
  payloadHash,
196
219
  payloadStr,
197
220
  now,
221
+ now,
198
222
  );
199
223
 
200
224
  return { ok: true, effectKey };
201
225
  }
202
226
 
227
+ /**
228
+ * Refreshes the lease heartbeat for an in-flight effect (Decision Q6).
229
+ * Workers invoke this periodically (default 30s) while performing side-effects.
230
+ */
231
+ heartbeatEffect(effectKey: string): { ok: true; lastHeartbeatAt: string } | { ok: false; error: string } {
232
+ const existing = this.getReceipt(effectKey);
233
+ if (!existing) {
234
+ return { ok: false, error: `effect_not_found: effect ${effectKey} does not exist` };
235
+ }
236
+ if (existing.status !== "in-flight") {
237
+ return {
238
+ ok: false,
239
+ error: `effect_not_in_flight: cannot heartbeat effect in status '${existing.status}'`,
240
+ };
241
+ }
242
+ const now = new Date().toISOString();
243
+ const stmt = this.db.prepare(`
244
+ UPDATE external_effects
245
+ SET last_heartbeat_at = ?
246
+ WHERE effect_key = ? AND status = 'in-flight'
247
+ `);
248
+ stmt.run(now, effectKey);
249
+ return { ok: true, lastHeartbeatAt: now };
250
+ }
251
+
203
252
  commitEffect(
204
253
  effectKey: string,
205
254
  receiptPayload: Record<string, unknown>,
@@ -238,6 +287,7 @@ export class ExternalEffectsLedger {
238
287
  payload_hash: string;
239
288
  receipt_payload: string;
240
289
  executed_at: string;
290
+ last_heartbeat_at: string | null;
241
291
  completed_at: string | null;
242
292
  } | undefined;
243
293
 
@@ -255,6 +305,7 @@ export class ExternalEffectsLedger {
255
305
  payloadHash: row.payload_hash,
256
306
  receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>,
257
307
  executedAt: row.executed_at,
308
+ lastHeartbeatAt: row.last_heartbeat_at ?? undefined,
258
309
  completedAt: row.completed_at ?? undefined,
259
310
  };
260
311
  }
@@ -274,6 +325,7 @@ export class ExternalEffectsLedger {
274
325
  payload_hash: string;
275
326
  receipt_payload: string;
276
327
  executed_at: string;
328
+ last_heartbeat_at: string | null;
277
329
  completed_at: string | null;
278
330
  }>;
279
331
 
@@ -289,6 +341,7 @@ export class ExternalEffectsLedger {
289
341
  payloadHash: row.payload_hash,
290
342
  receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>,
291
343
  executedAt: row.executed_at,
344
+ lastHeartbeatAt: row.last_heartbeat_at ?? undefined,
292
345
  completedAt: row.completed_at ?? undefined,
293
346
  }));
294
347
  }
@@ -297,3 +350,299 @@ export class ExternalEffectsLedger {
297
350
  this.db.close();
298
351
  }
299
352
  }
353
+
354
+ export interface WorktreeLock {
355
+ lockPath: string;
356
+ release: () => void;
357
+ }
358
+
359
+ /**
360
+ * Resolves the lockfile location for concurrent git worktree modifications.
361
+ * By default targets .git/kxm-worktree.lock, or if in a linked worktree, targets the main git directory.
362
+ */
363
+ export function resolveWorktreeLockPath(repoRoot: string): string {
364
+ const gitPath = join(repoRoot, ".git");
365
+ if (existsSync(gitPath)) {
366
+ try {
367
+ const stat = lstatSync(gitPath);
368
+ if (stat.isDirectory()) {
369
+ return join(gitPath, "kxm-worktree.lock");
370
+ }
371
+ if (stat.isFile()) {
372
+ const content = readFileSync(gitPath, "utf8").trim();
373
+ const match = content.match(/^gitdir:\s*(.+)$/i);
374
+ if (match && match[1]) {
375
+ const resolvedGitDir = resolve(repoRoot, match[1]);
376
+ return join(resolvedGitDir, "kxm-worktree.lock");
377
+ }
378
+ }
379
+ } catch {
380
+ // Fall back
381
+ }
382
+ }
383
+ return join(repoRoot, ".kxm-worktree.lock");
384
+ }
385
+
386
+ /**
387
+ * Acquires an exclusive file-based lock for worktree mutations.
388
+ * Supports auto-breaking stale locks if the holding process has died or timed out.
389
+ */
390
+ export function acquireWorktreeLock(
391
+ repoRoot: string,
392
+ options?: {
393
+ timeoutMs?: number | undefined;
394
+ staleTimeoutMs?: number | undefined;
395
+ pollIntervalMs?: number | undefined;
396
+ },
397
+ ): { ok: true; lock: WorktreeLock } | { ok: false; error: string } {
398
+ const lockPath = resolveWorktreeLockPath(repoRoot);
399
+ const timeoutMs = options?.timeoutMs ?? 5_000;
400
+ const staleTimeoutMs = options?.staleTimeoutMs ?? 30_000;
401
+ const pollIntervalMs = options?.pollIntervalMs ?? 50;
402
+ const startTime = Date.now();
403
+
404
+ mkdirSync(dirname(lockPath), { recursive: true });
405
+
406
+ while (true) {
407
+ try {
408
+ const fd = openSync(lockPath, "wx");
409
+ const metadata = JSON.stringify({
410
+ pid: process.pid,
411
+ acquiredAt: new Date().toISOString(),
412
+ repoRoot,
413
+ });
414
+ writeFileSync(fd, metadata, "utf8");
415
+ closeSync(fd);
416
+
417
+ return {
418
+ ok: true,
419
+ lock: {
420
+ lockPath,
421
+ release: () => {
422
+ try {
423
+ unlinkSync(lockPath);
424
+ } catch {
425
+ // Ignore if already unlinked
426
+ }
427
+ },
428
+ },
429
+ };
430
+ } catch (err: unknown) {
431
+ const code = (err as { code?: string }).code;
432
+ if (code !== "EEXIST") {
433
+ return { ok: false, error: `lock_io_error: ${(err as Error).message}` };
434
+ }
435
+
436
+ // Existing lock found - check for staleness
437
+ try {
438
+ const content = readFileSync(lockPath, "utf8");
439
+ const parsed = JSON.parse(content) as { pid?: number; acquiredAt?: string };
440
+ let isStale = false;
441
+
442
+ if (parsed.acquiredAt) {
443
+ const age = Date.now() - Date.parse(parsed.acquiredAt);
444
+ if (age > staleTimeoutMs) {
445
+ isStale = true;
446
+ }
447
+ }
448
+
449
+ if (parsed.pid && typeof parsed.pid === "number") {
450
+ try {
451
+ process.kill(parsed.pid, 0);
452
+ } catch (e: unknown) {
453
+ if ((e as { code?: string }).code === "ESRCH") {
454
+ isStale = true; // Process dead
455
+ }
456
+ }
457
+ }
458
+
459
+ if (isStale) {
460
+ try {
461
+ unlinkSync(lockPath);
462
+ continue; // Immediately retry lock
463
+ } catch {
464
+ // Already unlinked or contention
465
+ }
466
+ }
467
+ } catch {
468
+ // Corrupt lock file
469
+ try {
470
+ unlinkSync(lockPath);
471
+ continue;
472
+ } catch {
473
+ // Ignore
474
+ }
475
+ }
476
+
477
+ if (Date.now() - startTime >= timeoutMs) {
478
+ return {
479
+ ok: false,
480
+ error: `lock_timeout: timed out after ${timeoutMs}ms waiting for worktree lock at ${lockPath}`,
481
+ };
482
+ }
483
+
484
+ if (pollIntervalMs > 0) {
485
+ const waitBuf = new Int32Array(new SharedArrayBuffer(4));
486
+ Atomics.wait(waitBuf, 0, 0, pollIntervalMs);
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Runs a function inside an exclusive worktree lock.
494
+ */
495
+ export async function withWorktreeLock<T>(
496
+ repoRoot: string,
497
+ fn: () => T | Promise<T>,
498
+ options?: {
499
+ timeoutMs?: number | undefined;
500
+ staleTimeoutMs?: number | undefined;
501
+ pollIntervalMs?: number | undefined;
502
+ },
503
+ ): Promise<T> {
504
+ const res = acquireWorktreeLock(repoRoot, options);
505
+ if (!res.ok) {
506
+ throw new Error(`worktree_lock_failed: ${res.error}`);
507
+ }
508
+ try {
509
+ return await fn();
510
+ } finally {
511
+ res.lock.release();
512
+ }
513
+ }
514
+
515
+ export interface BranchCleanupOptions {
516
+ removeWorktree?: boolean | undefined;
517
+ deleteRemote?: boolean | undefined;
518
+ remoteName?: string | undefined;
519
+ execFn?: ((cmd: string, args: string[]) => { status: number; stdout: string; stderr: string }) | undefined;
520
+ }
521
+
522
+ export interface BranchCleanupResult {
523
+ ok: boolean;
524
+ branchName: string;
525
+ deletedLocalBranch: boolean;
526
+ deletedRemoteBranch: boolean;
527
+ removedWorktreePath?: string | undefined;
528
+ error?: string | undefined;
529
+ }
530
+
531
+ /**
532
+ * Deterministic Branch Cleanup (Decision Q7).
533
+ * Removes associated git worktrees and deletes the local (and optionally remote) run branch.
534
+ */
535
+ export function cleanupMergedRunBranch(
536
+ repoRoot: string,
537
+ branchName: string,
538
+ options?: BranchCleanupOptions,
539
+ ): BranchCleanupResult {
540
+ const runner = options?.execFn ?? ((cmd: string, args: string[]) => {
541
+ const res = spawnSync(cmd, args, {
542
+ cwd: repoRoot,
543
+ encoding: "utf8",
544
+ windowsHide: true,
545
+ env: Object.fromEntries(
546
+ Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_")),
547
+ ),
548
+ });
549
+ return {
550
+ status: res.status ?? 1,
551
+ stdout: res.stdout || "",
552
+ stderr: res.stderr || "",
553
+ };
554
+ });
555
+
556
+ const shouldRemoveWorktree = options?.removeWorktree ?? true;
557
+ let removedWorktreePath: string | undefined;
558
+ let deletedLocalBranch = false;
559
+ let deletedRemoteBranch = false;
560
+
561
+ // 1. Remove worktree if one is attached to this branch
562
+ if (shouldRemoveWorktree) {
563
+ try {
564
+ const wtList = runner("git", ["worktree", "list", "--porcelain"]);
565
+ if (wtList.status === 0) {
566
+ const blocks = wtList.stdout.split(/\n\n+/);
567
+ for (const block of blocks) {
568
+ const lines = block.trim().split("\n");
569
+ let wtPath: string | undefined;
570
+ let wtBranch: string | undefined;
571
+ for (const line of lines) {
572
+ if (line.startsWith("worktree ")) {
573
+ wtPath = line.substring("worktree ".length).trim();
574
+ } else if (line.startsWith("branch ")) {
575
+ wtBranch = line.substring("branch ".length).trim();
576
+ }
577
+ }
578
+ if (wtPath && wtBranch && (wtBranch === `refs/heads/${branchName}` || wtBranch === branchName)) {
579
+ const rmRes = runner("git", ["worktree", "remove", "--force", wtPath]);
580
+ if (rmRes.status === 0) {
581
+ removedWorktreePath = wtPath;
582
+ } else {
583
+ return {
584
+ ok: false,
585
+ branchName,
586
+ deletedLocalBranch: false,
587
+ deletedRemoteBranch: false,
588
+ error: `failed_to_remove_worktree: ${rmRes.stderr.trim() || rmRes.stdout.trim()}`,
589
+ };
590
+ }
591
+ }
592
+ }
593
+ }
594
+ } catch (err: unknown) {
595
+ return {
596
+ ok: false,
597
+ branchName,
598
+ deletedLocalBranch: false,
599
+ deletedRemoteBranch: false,
600
+ error: `worktree_inspection_failed: ${(err as Error).message}`,
601
+ };
602
+ }
603
+ }
604
+
605
+ // 2. Delete local branch if it exists
606
+ const checkBranch = runner("git", ["rev-parse", "--verify", `refs/heads/${branchName}`]);
607
+ if (checkBranch.status === 0) {
608
+ const delRes = runner("git", ["branch", "-D", branchName]);
609
+ if (delRes.status === 0) {
610
+ deletedLocalBranch = true;
611
+ } else {
612
+ return {
613
+ ok: false,
614
+ branchName,
615
+ deletedLocalBranch: false,
616
+ deletedRemoteBranch: false,
617
+ ...(removedWorktreePath ? { removedWorktreePath } : {}),
618
+ error: `failed_to_delete_local_branch: ${delRes.stderr.trim() || delRes.stdout.trim()}`,
619
+ };
620
+ }
621
+ }
622
+
623
+ // 3. Delete remote branch if requested
624
+ if (options?.deleteRemote) {
625
+ const remote = options.remoteName || "origin";
626
+ const remoteDelRes = runner("git", ["push", remote, "--delete", branchName]);
627
+ if (remoteDelRes.status === 0) {
628
+ deletedRemoteBranch = true;
629
+ } else {
630
+ return {
631
+ ok: false,
632
+ branchName,
633
+ deletedLocalBranch,
634
+ deletedRemoteBranch: false,
635
+ ...(removedWorktreePath ? { removedWorktreePath } : {}),
636
+ error: `failed_to_delete_remote_branch: ${remoteDelRes.stderr.trim() || remoteDelRes.stdout.trim()}`,
637
+ };
638
+ }
639
+ }
640
+
641
+ return {
642
+ ok: true,
643
+ branchName,
644
+ deletedLocalBranch,
645
+ deletedRemoteBranch,
646
+ ...(removedWorktreePath ? { removedWorktreePath } : {}),
647
+ };
648
+ }