@kontextmind/kxm 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +5 -0
- package/.kxm/workflows/default.yaml +47 -0
- package/CHANGELOG.md +39 -7
- package/README.md +1 -0
- package/docs/README.md +2 -0
- package/docs/agent-skills.md +118 -0
- package/docs/architecture.md +1 -1
- package/docs/assignment-runner.md +21 -8
- package/docs/configuration.md +2 -2
- package/docs/kxm-handbook.md +3 -3
- package/docs/operator-pi-packages.md +63 -0
- package/docs/skills/repo-work-delivery.md +102 -0
- package/docs/test-matrix.md +4 -3
- package/docs/troubleshooting.md +19 -0
- 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 +6875 -3672
- package/plugins/kxm/dist/core.js +214 -34
- package/plugins/kxm/dist/extension.js +7721 -85
- package/plugins/kxm/dist/mcp-server.js +75 -21
- package/plugins/kxm/dist/runtime.js +5403 -1014
- package/plugins/kxm/dist/server.js +3008 -2268
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +5751 -631
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/SUITE.md +5 -0
- package/plugins/kxm/skills/hints.json +73 -0
- package/plugins/kxm/skills/kxm/SKILL.md +30 -83
- 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 +34 -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 +35 -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/cli.ts +1077 -11
- package/plugins/kxm/src/commands.ts +150 -8
- package/plugins/kxm/src/config.ts +7 -4
- package/plugins/kxm/src/context-packet.ts +172 -0
- package/plugins/kxm/src/extension.ts +36 -1
- package/plugins/kxm/src/external-effects.ts +356 -7
- package/plugins/kxm/src/hub.ts +2 -4
- package/plugins/kxm/src/improve.ts +72 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/model-inventory.ts +127 -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/restricted-yaml.d.mts +31 -0
- package/plugins/kxm/src/restricted-yaml.mjs +145 -0
- package/plugins/kxm/src/role.ts +375 -0
- package/plugins/kxm/src/routing.ts +99 -1
- package/plugins/kxm/src/session-work.ts +9 -2
- package/plugins/kxm/src/studio-layout.ts +660 -17
- 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-config.ts +15 -110
- package/plugins/kxm/src/vnext-engine.ts +198 -62
- package/plugins/kxm/src/vnext-harness.ts +263 -81
- package/plugins/kxm/src/vnext-oneshot-evidence.ts +85 -0
- package/plugins/kxm/src/vnext-oneshot-process.ts +149 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +170 -233
- package/plugins/kxm/src/vnext-runtime-store.ts +35 -1
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +120 -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/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/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-hub.mjs +6 -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
|
@@ -5,11 +5,24 @@
|
|
|
5
5
|
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
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
|
+
}
|
package/plugins/kxm/src/hub.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type WorkflowMessageContext,
|
|
30
30
|
} from "./protocol.ts";
|
|
31
31
|
import { workflowScopeExtras } from "./diagnostics.ts";
|
|
32
|
+
import { timingSafeStringCompare } from "./commands.ts";
|
|
32
33
|
import { arbitrate, explainContextItem, journalEntryToContextItem, memoryRecordToContextItem, rolePolicy } from "./arbiter.ts";
|
|
33
34
|
import { loadAuthoredMemory } from "./memory.ts";
|
|
34
35
|
import { contextItemAuditMetadata, CONTEXT_AUTHORITIES, CONTEXT_CONFIDENCES, type ContextAuthority, type ContextConfidence, type ContextItem } from "./context.ts";
|
|
@@ -123,10 +124,7 @@ function publicAgent(agent: StoredAgent): AgentRecord {
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
function safeTokenEqual(actual: string | undefined, expected: string): boolean {
|
|
126
|
-
|
|
127
|
-
const actualHash = createHash("sha256").update(actual).digest();
|
|
128
|
-
const expectedHash = createHash("sha256").update(expected).digest();
|
|
129
|
-
return timingSafeEqual(actualHash, expectedHash);
|
|
127
|
+
return timingSafeStringCompare(actual, expected);
|
|
130
128
|
}
|
|
131
129
|
|
|
132
130
|
function bearerToken(request: IncomingMessage): string | undefined {
|
|
@@ -381,3 +381,75 @@ export function formatImprovementReport(report: ImprovementReport): string {
|
|
|
381
381
|
|
|
382
382
|
return lines.join("\n");
|
|
383
383
|
}
|
|
384
|
+
|
|
385
|
+
export interface PromotionDecision {
|
|
386
|
+
eligible: boolean;
|
|
387
|
+
policy: "manual_pr" | "critic_quorum" | "auto_threshold";
|
|
388
|
+
authorized: boolean;
|
|
389
|
+
reason: string;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Evaluates candidate promotion policy (Decision Q11).
|
|
394
|
+
* Supports:
|
|
395
|
+
* - manual_pr: strict operator signoff via Git PR / CLI (fail-closed anti-privilege-escalation)
|
|
396
|
+
* - critic_quorum: requires dual critic approval before auto-promotion
|
|
397
|
+
* - auto_threshold: requires candidate to exceed recurrence and verifyPassRate thresholds
|
|
398
|
+
*/
|
|
399
|
+
export function evaluatePromotionPolicy(
|
|
400
|
+
candidate: ImprovementCandidate,
|
|
401
|
+
policy: "manual_pr" | "critic_quorum" | "auto_threshold" = "manual_pr",
|
|
402
|
+
options?: {
|
|
403
|
+
criticApprovals?: string[] | undefined;
|
|
404
|
+
autoThreshold?: {
|
|
405
|
+
minRuns: number;
|
|
406
|
+
minPassRate: number;
|
|
407
|
+
minCostSavings?: number | undefined;
|
|
408
|
+
} | undefined;
|
|
409
|
+
},
|
|
410
|
+
): PromotionDecision {
|
|
411
|
+
if (policy === "manual_pr") {
|
|
412
|
+
return {
|
|
413
|
+
eligible: true,
|
|
414
|
+
policy,
|
|
415
|
+
authorized: false,
|
|
416
|
+
reason: "Manual PR review and signoff required by policy (fail-closed anti-privilege-escalation)",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (policy === "critic_quorum") {
|
|
421
|
+
const approvals = options?.criticApprovals ?? [];
|
|
422
|
+
const hasQuorum = approvals.length >= 2;
|
|
423
|
+
return {
|
|
424
|
+
eligible: true,
|
|
425
|
+
policy,
|
|
426
|
+
authorized: hasQuorum,
|
|
427
|
+
reason: hasQuorum
|
|
428
|
+
? `Authorized by critic quorum (${approvals.join(", ")})`
|
|
429
|
+
: `Requires dual critic quorum; current approvals: ${approvals.length}/2`,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (policy === "auto_threshold") {
|
|
434
|
+
const threshold = options?.autoThreshold ?? { minRuns: 10, minPassRate: 0.95 };
|
|
435
|
+
const recurrence = candidate.baselineMetrics.recurrence;
|
|
436
|
+
const passRate = candidate.baselineMetrics.verifyPassRate;
|
|
437
|
+
const meetsThreshold = recurrence >= threshold.minRuns && passRate >= threshold.minPassRate;
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
eligible: true,
|
|
441
|
+
policy,
|
|
442
|
+
authorized: meetsThreshold,
|
|
443
|
+
reason: meetsThreshold
|
|
444
|
+
? `Authorized by auto-threshold (runs=${recurrence}>=${threshold.minRuns}, passRate=${passRate}>=${threshold.minPassRate})`
|
|
445
|
+
: `Auto-threshold not met: runs=${recurrence}/${threshold.minRuns}, passRate=${passRate}/${threshold.minPassRate}`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
eligible: false,
|
|
451
|
+
policy,
|
|
452
|
+
authorized: false,
|
|
453
|
+
reason: `Unknown promotion policy: ${String(policy)}`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
@@ -7,7 +7,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
|
|
|
7
7
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
8
8
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
9
9
|
|
|
10
|
-
const VERSION = "0.
|
|
10
|
+
const VERSION = "0.7.0";
|
|
11
11
|
const inbox = new Map<string, MessageRecord>();
|
|
12
12
|
const notifiedInbox = new Set<string>();
|
|
13
13
|
let meshClient: HubClient | undefined;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { stringify } from "yaml";
|
|
5
|
+
|
|
6
|
+
export interface InventoryPrice {
|
|
7
|
+
inputPerMillion: number | null;
|
|
8
|
+
outputPerMillion: number | null;
|
|
9
|
+
cacheReadPerMillion: number | null;
|
|
10
|
+
cacheWritePerMillion: number | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface InventoryCapabilities {
|
|
14
|
+
thinking: { supported: boolean | null; levels?: string[]; default?: string; source: string };
|
|
15
|
+
speed: { fast: boolean | null; tiers?: string[]; source: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface InventoryModel {
|
|
19
|
+
id: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
contextLength?: number;
|
|
22
|
+
capabilities: InventoryCapabilities;
|
|
23
|
+
standard?: InventoryPrice;
|
|
24
|
+
discount?: InventoryPrice;
|
|
25
|
+
sources: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ModelInventory {
|
|
29
|
+
schema: "kxm.model-inventory.v1";
|
|
30
|
+
fetchedAt: string;
|
|
31
|
+
currency: "USD";
|
|
32
|
+
sources: Record<string, { url: string; ok: boolean; error?: string }>;
|
|
33
|
+
models: InventoryModel[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const OR_URL = "https://openrouter.ai/api/v1/models";
|
|
37
|
+
const NOUS_URL = "https://inference-api.nousresearch.com/v1/models";
|
|
38
|
+
|
|
39
|
+
function price(value: unknown): number | null {
|
|
40
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value * 1_000_000;
|
|
41
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)) && Number(value) >= 0) return Number(value) * 1_000_000;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function rates(raw: any): InventoryPrice | undefined {
|
|
45
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
46
|
+
const input = price(raw.prompt ?? raw.input);
|
|
47
|
+
const output = price(raw.completion ?? raw.output);
|
|
48
|
+
if (input === null && output === null) return undefined;
|
|
49
|
+
return {
|
|
50
|
+
inputPerMillion: input,
|
|
51
|
+
outputPerMillion: output,
|
|
52
|
+
cacheReadPerMillion: price(raw.input_cache_read ?? raw.cache_read),
|
|
53
|
+
cacheWritePerMillion: price(raw.input_cache_write ?? raw.cache_write),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function modelId(id: string): string {
|
|
57
|
+
return id.trim().toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
async function fetchModels(url: string, authorization?: string): Promise<{ data: any[]; error?: string }> {
|
|
60
|
+
try {
|
|
61
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
62
|
+
if (authorization) headers.Authorization = `Bearer ${authorization}`;
|
|
63
|
+
const response = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
|
|
64
|
+
if (!response.ok) return { data: [], error: `HTTP ${response.status}` };
|
|
65
|
+
const payload = await response.json() as any;
|
|
66
|
+
if (Array.isArray(payload?.data)) return { data: payload.data };
|
|
67
|
+
return { data: [], error: "response has no data array" };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { data: [], error: error instanceof Error ? error.message : "request failed" };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Refreshes a provenance-bearing model inventory. Aggregator prices are never treated as native auth evidence. */
|
|
74
|
+
export async function refreshModelInventory(options: { outputRoot: string; env?: NodeJS.ProcessEnv; now?: Date }): Promise<ModelInventory> {
|
|
75
|
+
const env = options.env ?? process.env;
|
|
76
|
+
const fetchedAt = (options.now ?? new Date()).toISOString();
|
|
77
|
+
const sources: ModelInventory["sources"] = {};
|
|
78
|
+
const byId = new Map<string, InventoryModel>();
|
|
79
|
+
const add = (item: any, source: string, kind: "standard" | "discount") => {
|
|
80
|
+
if (!item || typeof item.id !== "string" || !item.id.trim()) return;
|
|
81
|
+
const key = modelId(item.id);
|
|
82
|
+
const current: InventoryModel = byId.get(key) ?? { id: item.id, ...(typeof item.name === "string" ? { name: item.name } : {}), ...(typeof item.context_length === "number" ? { contextLength: item.context_length } : {}), capabilities: { thinking: { supported: null, source: "unknown" }, speed: { fast: null, source: "unknown" } }, sources: [] };
|
|
83
|
+
if (!current.sources.includes(source)) current.sources.push(source);
|
|
84
|
+
const parsed = rates(item.pricing);
|
|
85
|
+
if (parsed) {
|
|
86
|
+
if (kind === "standard") current.standard = parsed;
|
|
87
|
+
else current.discount = parsed;
|
|
88
|
+
}
|
|
89
|
+
if (!current.name && typeof item.name === "string") current.name = item.name;
|
|
90
|
+
if (!current.contextLength && typeof item.context_length === "number") current.contextLength = item.context_length;
|
|
91
|
+
const parameters = Array.isArray(item.supported_parameters) ? item.supported_parameters.filter((value: unknown): value is string => typeof value === "string") : [];
|
|
92
|
+
if (parameters.some((value: string) => /reasoning|thinking/i.test(value))) current.capabilities.thinking = { supported: true, source };
|
|
93
|
+
if (parameters.some((value: string) => /speed|service.?tier|fast/i.test(value))) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
|
|
94
|
+
if (Array.isArray(item.thinkingLevels) && item.thinkingLevels.length > 0) current.capabilities.thinking = { supported: true, levels: item.thinkingLevels, ...(item.defaultThinking ? { default: item.defaultThinking } : {}), source };
|
|
95
|
+
if (item.fast === true) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
|
|
96
|
+
byId.set(key, current);
|
|
97
|
+
};
|
|
98
|
+
const native = (command: string, args: string[], source: string) => {
|
|
99
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout: 20_000, shell: false });
|
|
100
|
+
const ok = result.status === 0;
|
|
101
|
+
sources[source] = { url: `${command} ${args.join(" ")}`, ok, ...(ok ? {} : { error: (result.stderr || "command failed").trim().slice(0, 240) }) };
|
|
102
|
+
if (!ok) return;
|
|
103
|
+
for (const line of (result.stdout || "").split(/\r?\n/)) {
|
|
104
|
+
const parts = line.trim().split(/\s+/);
|
|
105
|
+
const id = source === "pi" && parts.length >= 2 && parts[0] !== "provider" ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
106
|
+
if (id && /^[a-z0-9][a-z0-9._/-]+$/i.test(id) && id !== "Available") {
|
|
107
|
+
const effortMatch = source === "agy" ? id.match(/-(low|medium|high)$/i) : null;
|
|
108
|
+
add({ id, ...(effortMatch?.[1] ? { thinkingLevels: [effortMatch[1].toLowerCase()] } : {}) }, source, "standard");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
native("pi", ["--list-models"], "pi");
|
|
113
|
+
native("grok", ["models"], "grok");
|
|
114
|
+
native("agy", ["models"], "agy");
|
|
115
|
+
const openrouter = await fetchModels(env.KXM_OPENROUTER_MODELS_URL?.trim() || OR_URL, env.OPENROUTER_API_KEY);
|
|
116
|
+
const openrouterUrl = env.KXM_OPENROUTER_MODELS_URL?.trim() || OR_URL;
|
|
117
|
+
sources.openrouter = { url: openrouterUrl, ok: !openrouter.error, ...(openrouter.error ? { error: openrouter.error } : {}) };
|
|
118
|
+
for (const item of openrouter.data) add(item, "openrouter", "standard");
|
|
119
|
+
const nousUrl = env.KXM_NOUS_MODELS_URL?.trim() || NOUS_URL;
|
|
120
|
+
const nous = await fetchModels(nousUrl, env.NOUS_API_KEY);
|
|
121
|
+
sources.nous = { url: nousUrl, ok: !nous.error, ...(nous.error ? { error: nous.error } : {}) };
|
|
122
|
+
for (const item of nous.data) add(item, "nous", "discount");
|
|
123
|
+
const inventory: ModelInventory = { schema: "kxm.model-inventory.v1", fetchedAt, currency: "USD", sources, models: [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)) };
|
|
124
|
+
mkdirSync(join(options.outputRoot, ".kxm", "models"), { recursive: true });
|
|
125
|
+
writeFileSync(join(options.outputRoot, ".kxm", "models", "inventory.yaml"), stringify(inventory), "utf8");
|
|
126
|
+
return inventory;
|
|
127
|
+
}
|