@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.
Files changed (117) 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 +5 -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 +2 -0
  14. package/docs/agent-skills.md +118 -0
  15. package/docs/architecture.md +1 -1
  16. package/docs/assignment-runner.md +21 -8
  17. package/docs/configuration.md +2 -2
  18. package/docs/kxm-handbook.md +3 -3
  19. package/docs/operator-pi-packages.md +63 -0
  20. package/docs/skills/repo-work-delivery.md +102 -0
  21. package/docs/test-matrix.md +4 -3
  22. package/docs/troubleshooting.md +19 -0
  23. package/docs/vnext/validation.md +9 -0
  24. package/docs/webhook-workflows.md +2 -2
  25. package/examples/README.md +1 -1
  26. package/package.json +16 -17
  27. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  28. package/plugins/kxm/README.md +1 -1
  29. package/plugins/kxm/dist/cli.js +6875 -3672
  30. package/plugins/kxm/dist/core.js +214 -34
  31. package/plugins/kxm/dist/extension.js +7721 -85
  32. package/plugins/kxm/dist/mcp-server.js +75 -21
  33. package/plugins/kxm/dist/runtime.js +5403 -1014
  34. package/plugins/kxm/dist/server.js +3008 -2268
  35. package/plugins/kxm/dist/vnext-runtime-supervisor.js +5751 -631
  36. package/plugins/kxm/package.json +1 -1
  37. package/plugins/kxm/skills/SUITE.md +5 -0
  38. package/plugins/kxm/skills/hints.json +73 -0
  39. package/plugins/kxm/skills/kxm/SKILL.md +30 -83
  40. package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
  41. package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
  42. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
  43. package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
  44. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +34 -0
  45. package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
  46. package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
  47. package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
  48. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +35 -0
  49. package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
  50. package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
  51. package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
  52. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
  53. package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
  54. package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
  55. package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
  56. package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
  57. package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
  58. package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
  59. package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
  60. package/plugins/kxm/src/autocomplete.ts +9 -3
  61. package/plugins/kxm/src/cli.ts +1077 -11
  62. package/plugins/kxm/src/commands.ts +150 -8
  63. package/plugins/kxm/src/config.ts +7 -4
  64. package/plugins/kxm/src/context-packet.ts +172 -0
  65. package/plugins/kxm/src/extension.ts +36 -1
  66. package/plugins/kxm/src/external-effects.ts +356 -7
  67. package/plugins/kxm/src/hub.ts +2 -4
  68. package/plugins/kxm/src/improve.ts +72 -0
  69. package/plugins/kxm/src/mcp-server.ts +1 -1
  70. package/plugins/kxm/src/model-inventory.ts +127 -0
  71. package/plugins/kxm/src/policy-draft.d.mts +55 -0
  72. package/plugins/kxm/src/policy-draft.mjs +565 -0
  73. package/plugins/kxm/src/price-calc.ts +17 -18
  74. package/plugins/kxm/src/prices.ts +32 -16
  75. package/plugins/kxm/src/producers.ts +71 -0
  76. package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
  77. package/plugins/kxm/src/restricted-yaml.mjs +145 -0
  78. package/plugins/kxm/src/role.ts +375 -0
  79. package/plugins/kxm/src/routing.ts +99 -1
  80. package/plugins/kxm/src/session-work.ts +9 -2
  81. package/plugins/kxm/src/studio-layout.ts +660 -17
  82. package/plugins/kxm/src/suggest.ts +7 -13
  83. package/plugins/kxm/src/telemetry.ts +82 -0
  84. package/plugins/kxm/src/tui.ts +140 -0
  85. package/plugins/kxm/src/vnext-config.ts +15 -110
  86. package/plugins/kxm/src/vnext-engine.ts +198 -62
  87. package/plugins/kxm/src/vnext-harness.ts +263 -81
  88. package/plugins/kxm/src/vnext-oneshot-evidence.ts +85 -0
  89. package/plugins/kxm/src/vnext-oneshot-process.ts +149 -0
  90. package/plugins/kxm/src/vnext-oneshot-producer.ts +170 -233
  91. package/plugins/kxm/src/vnext-runtime-store.ts +35 -1
  92. package/plugins/kxm/src/vnext-runtime-supervisor.ts +120 -5
  93. package/plugins/kxm/src/vnext-runtime.ts +14 -0
  94. package/plugins/kxm/src/workflow-manager.ts +392 -0
  95. package/plugins/kxm/src/workflow-tui.ts +255 -0
  96. package/schemas/policy-draft/README.md +17 -0
  97. package/schemas/policy-draft/model.v2.schema.json +140 -0
  98. package/schemas/policy-draft/role.v2.schema.json +91 -0
  99. package/schemas/vnext/role.schema.json +76 -0
  100. package/schemas/vnext/run-event.schema.json +1 -0
  101. package/scripts/assignment-run.d.mts +1 -1
  102. package/scripts/assignment-run.mjs +44 -35
  103. package/scripts/check-generated.mjs +33 -9
  104. package/scripts/emit-codex-artifacts.mjs +255 -11
  105. package/scripts/harness-run.d.mts +12 -4
  106. package/scripts/harness-run.mjs +65 -17
  107. package/scripts/kxm-hub.mjs +6 -0
  108. package/scripts/native-critic.d.mts +5 -0
  109. package/scripts/native-critic.mjs +60 -0
  110. package/.kxm/config/README.md +0 -5
  111. package/.kxm/config/agents.json +0 -43
  112. package/.kxm/config/env.example +0 -56
  113. package/.kxm/config/update.example.yaml +0 -9
  114. package/.kxm/config/workflows/fix.json +0 -160
  115. package/.kxm/config/workflows/jira-development.json +0 -116
  116. package/.kxm/config/workflows/provenance-quorum.json +0 -150
  117. 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 { 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
+ }
@@ -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
- if (!actual) return false;
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.6.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
+ }