@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.kxm/agents/coordinator.yaml +9 -0
- package/.kxm/agents/critic-arch.yaml +13 -0
- package/.kxm/agents/critic-cli.yaml +13 -0
- package/.kxm/agents/implementer.yaml +13 -0
- package/.kxm/gates.yaml +8 -0
- package/.kxm/producers.yaml +22 -0
- package/.kxm/project.yaml +15 -0
- package/.kxm/roles/writer.yaml +7 -0
- package/.kxm/workflows/default.yaml +47 -0
- package/CHANGELOG.md +39 -7
- package/README.md +1 -0
- package/docs/README.md +5 -0
- package/docs/adr/ADR-0002-browser-automation-steel-doks.md +103 -0
- package/docs/agent-skills.md +135 -0
- package/docs/architecture.md +1 -1
- package/docs/assignment-runner.md +21 -8
- package/docs/browser-automation.md +116 -0
- package/docs/configuration.md +11 -2
- package/docs/getting-started.md +21 -0
- package/docs/kb/how-credentials-retrieved-safely.md +31 -0
- package/docs/kb/how-to-capture-and-annotate-section.md +60 -0
- package/docs/kb/how-to-connect-playwright-to-steel.md +54 -0
- package/docs/kb/how-to-recover-expired-session-or-orphan.md +54 -0
- package/docs/kb/how-to-resume-after-mfa.md +28 -0
- package/docs/kb/how-to-take-over-session.md +32 -0
- package/docs/kb/why-authentication-disappeared.md +32 -0
- package/docs/kb/why-automation-opened-different-browser.md +32 -0
- package/docs/kb/why-session-viewer-cannot-control.md +31 -0
- package/docs/kxm-handbook.md +3 -3
- package/docs/operations.md +24 -0
- package/docs/operator-pi-packages.md +63 -0
- package/docs/prompts/browser-annotate-feedback.md +41 -0
- package/docs/prompts/browser-diagnose-recover.md +38 -0
- package/docs/prompts/browser-explore.md +42 -0
- package/docs/prompts/browser-repro-fix.md +48 -0
- package/docs/prompts/browser-start.md +41 -0
- package/docs/prompts/browser-takeover.md +50 -0
- package/docs/skills/repo-work-delivery.md +107 -0
- package/docs/skills.md +2 -0
- package/docs/test-matrix.md +4 -3
- package/docs/troubleshooting.md +41 -1
- package/docs/vnext/validation.md +9 -0
- package/docs/webhook-workflows.md +2 -2
- package/examples/README.md +1 -1
- package/package.json +16 -17
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/README.md +1 -1
- package/plugins/kxm/dist/cli.js +41620 -35578
- package/plugins/kxm/dist/core.js +271 -34
- package/plugins/kxm/dist/extension.js +7759 -86
- package/plugins/kxm/dist/mcp-server.js +75 -21
- package/plugins/kxm/dist/runtime.js +8218 -2328
- package/plugins/kxm/dist/server.js +3125 -2260
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +5961 -661
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/SUITE.md +5 -0
- package/plugins/kxm/skills/hints.json +103 -0
- package/plugins/kxm/skills/kxm/SKILL.md +30 -83
- package/plugins/kxm/skills/kxm-browser-annotate/SKILL.md +90 -0
- package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +47 -0
- package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-browser-session/SKILL.md +94 -0
- package/plugins/kxm/skills/kxm-browser-takeover/SKILL.md +87 -0
- package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +71 -0
- package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
- package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
- package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
- package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +43 -0
- package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
- package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +42 -0
- package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
- package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
- package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
- package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
- package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
- package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
- package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
- package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
- package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
- package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
- package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
- package/plugins/kxm/src/autocomplete.ts +9 -3
- package/plugins/kxm/src/browser.ts +603 -0
- package/plugins/kxm/src/cli/context-skills.ts +373 -0
- package/plugins/kxm/src/cli/hub.ts +614 -0
- package/plugins/kxm/src/cli/roles.ts +615 -0
- package/plugins/kxm/src/cli/system.ts +906 -0
- package/plugins/kxm/src/cli/tasks.ts +364 -0
- package/plugins/kxm/src/cli/types.ts +270 -0
- package/plugins/kxm/src/cli/vnext.ts +698 -0
- package/plugins/kxm/src/cli/workflows.ts +699 -0
- package/plugins/kxm/src/cli.ts +362 -2849
- package/plugins/kxm/src/commands.ts +150 -8
- package/plugins/kxm/src/completion-install.ts +223 -0
- package/plugins/kxm/src/config.ts +7 -4
- package/plugins/kxm/src/context-packet.ts +172 -0
- package/plugins/kxm/src/database.ts +1 -1
- package/plugins/kxm/src/extension.ts +36 -1
- package/plugins/kxm/src/external-effects.ts +357 -8
- package/plugins/kxm/src/hub-env.ts +193 -0
- package/plugins/kxm/src/hub.ts +2 -4
- package/plugins/kxm/src/improve.ts +72 -0
- package/plugins/kxm/src/init-guide-setup.ts +547 -0
- package/plugins/kxm/src/local-snapshot.ts +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/model-inventory.ts +127 -0
- package/plugins/kxm/src/modes.ts +348 -0
- package/plugins/kxm/src/policy-draft.d.mts +55 -0
- package/plugins/kxm/src/policy-draft.mjs +565 -0
- package/plugins/kxm/src/price-calc.ts +17 -18
- package/plugins/kxm/src/prices.ts +32 -16
- package/plugins/kxm/src/producers.ts +71 -0
- package/plugins/kxm/src/protocol.ts +111 -0
- package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
- package/plugins/kxm/src/restricted-yaml.mjs +145 -0
- package/plugins/kxm/src/role.ts +710 -0
- package/plugins/kxm/src/routing.ts +99 -1
- package/plugins/kxm/src/runtime.ts +4 -0
- package/plugins/kxm/src/safety-integrity.ts +76 -0
- package/plugins/kxm/src/session-work.ts +9 -2
- package/plugins/kxm/src/sqlite.ts +76 -0
- package/plugins/kxm/src/ssh-remote.ts +560 -0
- package/plugins/kxm/src/store.ts +1 -1
- package/plugins/kxm/src/studio-layout.ts +660 -17
- package/plugins/kxm/src/subagent-control.ts +312 -0
- package/plugins/kxm/src/suggest.ts +7 -13
- package/plugins/kxm/src/telemetry.ts +82 -0
- package/plugins/kxm/src/tui.ts +140 -0
- package/plugins/kxm/src/vnext-bindings.ts +1 -1
- package/plugins/kxm/src/vnext-config.ts +53 -111
- package/plugins/kxm/src/vnext-engine-command.ts +2 -0
- package/plugins/kxm/src/vnext-engine.ts +214 -62
- package/plugins/kxm/src/vnext-harness.ts +336 -84
- package/plugins/kxm/src/vnext-oneshot-evidence.ts +117 -0
- package/plugins/kxm/src/vnext-oneshot-process.ts +187 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +182 -224
- package/plugins/kxm/src/vnext-pi-producer.ts +11 -7
- package/plugins/kxm/src/vnext-runtime-store.ts +36 -2
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +122 -5
- package/plugins/kxm/src/vnext-runtime.ts +14 -0
- package/plugins/kxm/src/workflow-manager.ts +392 -0
- package/plugins/kxm/src/workflow-tui.ts +255 -0
- package/plugins/kxm/src/workflow.ts +144 -0
- package/schemas/policy-draft/README.md +17 -0
- package/schemas/policy-draft/model.v2.schema.json +140 -0
- package/schemas/policy-draft/role.v2.schema.json +91 -0
- package/schemas/vnext/modes.schema.json +56 -0
- package/schemas/vnext/role.schema.json +76 -0
- package/schemas/vnext/run-event.schema.json +1 -0
- package/scripts/assignment-run.d.mts +1 -1
- package/scripts/assignment-run.mjs +44 -35
- package/scripts/check-generated.mjs +33 -9
- package/scripts/emit-codex-artifacts.mjs +255 -11
- package/scripts/harness-run.d.mts +12 -4
- package/scripts/harness-run.mjs +65 -17
- package/scripts/kxm-bump-version.mjs +146 -0
- package/scripts/kxm-hub.mjs +150 -2
- package/scripts/kxm-publish-npm.mjs +3 -1
- package/scripts/kxm-release-github.mjs +3 -1
- package/scripts/kxm.mjs +0 -0
- package/scripts/native-critic.d.mts +5 -0
- package/scripts/native-critic.mjs +60 -0
- package/.kxm/config/README.md +0 -5
- package/.kxm/config/agents.json +0 -43
- package/.kxm/config/env.example +0 -56
- package/.kxm/config/update.example.yaml +0 -9
- package/.kxm/config/workflows/fix.json +0 -160
- package/.kxm/config/workflows/jira-development.json +0 -116
- package/.kxm/config/workflows/provenance-quorum.json +0 -150
- 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 "
|
|
6
|
+
import { DatabaseSync } from "./sqlite.ts";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
-
import {
|
|
9
|
-
|
|
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
|
|
162
|
-
const elapsed = Date.now() - Date.parse(
|
|
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
|
+
}
|