@kontextmind/kxm 0.7.96 → 0.7.98

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 (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/workflows/default.yaml +2 -0
  3. package/CHANGELOG.md +61 -2
  4. package/docs/concepts/architecture.md +1 -1
  5. package/docs/concepts/data-and-storage.md +1 -1
  6. package/docs/concepts/trust-model.md +3 -2
  7. package/docs/contracts/routing.md +1 -1
  8. package/docs/contributing/test-matrix.md +6 -5
  9. package/docs/glossary.md +1 -1
  10. package/docs/guides/peer-messaging.md +2 -2
  11. package/docs/guides/pi-workers.md +1 -1
  12. package/docs/guides/webhook-workflows.md +53 -18
  13. package/docs/operations/backup-and-restore.md +43 -24
  14. package/docs/operations/deploy.md +2 -2
  15. package/docs/operations/troubleshooting.md +3 -2
  16. package/docs/reference/cli-reference.md +65 -30
  17. package/docs/reference/config-reference.md +24 -13
  18. package/docs/reference/configuration.md +2 -2
  19. package/docs/reference/harness-routing.md +3 -3
  20. package/docs/reference/http-api.md +7 -7
  21. package/docs/reference/tools.md +1 -1
  22. package/docs/reference/workflow-definitions.md +2 -2
  23. package/docs/start/first-workflow.md +4 -4
  24. package/docs/start/quickstart-claude-code.md +2 -2
  25. package/docs/start/quickstart-pi.md +1 -1
  26. package/examples/workflow-signal.ts +4 -5
  27. package/package.json +1 -1
  28. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  29. package/plugins/kxm/dist/claude-hook.js +11 -1
  30. package/plugins/kxm/dist/cli.js +552 -228
  31. package/plugins/kxm/dist/client.js +3 -1
  32. package/plugins/kxm/dist/core.js +16 -3
  33. package/plugins/kxm/dist/extension.js +45 -13
  34. package/plugins/kxm/dist/mcp-server.js +20 -4
  35. package/plugins/kxm/dist/runtime-supervisor.js +232 -51
  36. package/plugins/kxm/dist/runtime.js +432 -95
  37. package/plugins/kxm/dist/server.js +122 -20
  38. package/plugins/kxm/package.json +1 -1
  39. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
  40. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
  41. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
  42. package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
  43. package/plugins/kxm/src/cli/project.ts +22 -13
  44. package/plugins/kxm/src/cli/system.ts +22 -1
  45. package/plugins/kxm/src/cli/workflows.ts +12 -7
  46. package/plugins/kxm/src/cli.ts +30 -8
  47. package/plugins/kxm/src/client.ts +4 -0
  48. package/plugins/kxm/src/commands.ts +23 -1
  49. package/plugins/kxm/src/database.ts +210 -36
  50. package/plugins/kxm/src/engine.ts +117 -2
  51. package/plugins/kxm/src/extension.ts +20 -14
  52. package/plugins/kxm/src/github-watch.ts +8 -5
  53. package/plugins/kxm/src/harness.ts +29 -0
  54. package/plugins/kxm/src/hub-env.ts +19 -1
  55. package/plugins/kxm/src/hub.ts +105 -21
  56. package/plugins/kxm/src/improve-sources.ts +2 -7
  57. package/plugins/kxm/src/init-guide-setup.ts +43 -28
  58. package/plugins/kxm/src/mcp-server.ts +9 -2
  59. package/plugins/kxm/src/oneshot-producer.ts +16 -8
  60. package/plugins/kxm/src/prices.ts +33 -2
  61. package/plugins/kxm/src/routing.ts +13 -7
  62. package/plugins/kxm/src/runtime-store.ts +23 -0
  63. package/plugins/kxm/src/studio-layout.ts +5 -4
  64. package/plugins/kxm/src/template.ts +31 -0
  65. package/plugins/kxm/src/workflow.ts +70 -1
  66. package/plugins/kxm/src/worktree-witness.ts +71 -0
  67. package/schemas/backup-manifest.schema.json +33 -0
  68. package/scripts/smoke-multi-pi.mjs +5 -1
@@ -10,15 +10,21 @@
10
10
  * This module writes only current KXM project resources:
11
11
  * - `.kxm/agents/<role-slug>.yaml` (kxm.agent.v1)
12
12
  * - `.kxm/workflows/<slug>.yaml` (kxm.workflow.v1)
13
+ * - admitted selectors appended to `.kxm/routes.yaml`
13
14
  * It never writes retired legacy authority (`.kxm/config`, retired
14
15
  * `.kxm/roster.json`) or the trusted `.kxm/roster.yaml` policy
15
16
  * and does not use the kxm.role.v1 subsystem.
17
+ *
18
+ * Guide research ids are not dispatch ids. Only the admitted map below is
19
+ * written, and only when that harness is authenticated. Google goes through
20
+ * the Pi `antigravity` provider. Unmapped ids are skipped.
16
21
  */
17
22
 
18
23
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
19
24
  import { dirname, join } from "node:path";
20
25
  import { stringify } from "yaml";
21
- import { NATIVE_HARNESS_PROVIDERS, type HarnessInventory } from "./harness.ts";
26
+ import { type HarnessInventory } from "./harness.ts";
27
+ import { loadRoutePolicy } from "./routes.ts";
22
28
 
23
29
  export interface GuideCandidate {
24
30
  readonly vendor: string;
@@ -41,19 +47,17 @@ export interface GuideWorkflow {
41
47
  readonly stages: readonly GuideStage[];
42
48
  }
43
49
 
44
- /** Vendor prefix → native harness, using the guide's vendor spellings.
45
- * Everything else routes via Pi/OpenRouter. Native-vendor candidates never
46
- * fall back to OpenRouter when their native harness is unavailable: fail
47
- * closed instead of billing the same vendor through a second provider. */
48
- const NATIVE_VENDOR_HARNESS: Readonly<Record<string, string>> = Object.freeze({
49
- anthropic: "claude",
50
- openai: "codex",
51
- "x-ai": "grok",
52
- xai: "grok",
53
- google: "agy",
54
- moonshotai: "kimi",
55
- moonshot: "kimi",
56
- deepseek: "deepseek",
50
+ /** Research id → the admitted harness/provider/model drive will actually accept.
51
+ * Anything absent is skipped, even when its harness is logged in. */
52
+ const ADMITTED_GUIDE_BINDINGS: Readonly<Record<string, AgentBinding>> = Object.freeze({
53
+ "anthropic/claude-fable-5.1": { harness: "claude", provider: "anthropic", model: "fable" },
54
+ "anthropic/fable": { harness: "claude", provider: "anthropic", model: "fable" },
55
+ "openai/gpt-5.6-sol": { harness: "codex", provider: "openai", model: "gpt-5.6-sol" },
56
+ "x-ai/grok-4.6": { harness: "grok", provider: "xai", model: "grok-4.6" },
57
+ "xai/grok-4.6": { harness: "grok", provider: "xai", model: "grok-4.6" },
58
+ "google/gemini-3.8-flash": { harness: "pi", provider: "antigravity", model: "gemini-3.8-flash-high" },
59
+ "google/gemini-3.8-flash-high": { harness: "pi", provider: "antigravity", model: "gemini-3.8-flash-high" },
60
+ "qwen/qwen3-coder-plus": { harness: "pi", provider: "openrouter", model: "qwen/qwen3-coder-plus" },
57
61
  });
58
62
 
59
63
  /** Guide candidates are ordered by preference; the first eligible wins. */
@@ -380,25 +384,17 @@ function authenticatedHarnesses(inventory: HarnessInventory): ReadonlySet<string
380
384
  }
381
385
 
382
386
  /**
383
- * Resolve a guide candidate to a dispatch binding, or undefined when no
384
- * candidate's harness is authenticated. Non-native vendors route through the
385
- * Pi OpenRouter provider (guide ids are already OpenRouter-style slugs).
387
+ * Resolve a guide candidate to an admitted dispatch binding, or undefined when
388
+ * no candidate is both on the admitted map and authenticated on its harness.
386
389
  */
387
390
  export function resolveCandidate(
388
391
  candidates: readonly GuideCandidate[],
389
392
  eligible: ReadonlySet<string>,
390
393
  ): AgentBinding | undefined {
391
394
  for (const candidate of candidates) {
392
- const nativeHarness = NATIVE_VENDOR_HARNESS[candidate.vendor];
393
- if (nativeHarness) {
394
- if (eligible.has(nativeHarness)) {
395
- return { harness: nativeHarness, provider: NATIVE_HARNESS_PROVIDERS[nativeHarness]!, model: candidate.model };
396
- }
397
- continue;
398
- }
399
- if (eligible.has("pi")) {
400
- return { harness: "pi", provider: "openrouter", model: `${candidate.vendor}/${candidate.model}` };
401
- }
395
+ const mapped = ADMITTED_GUIDE_BINDINGS[`${candidate.vendor}/${candidate.model}`];
396
+ if (!mapped || !eligible.has(mapped.harness)) continue;
397
+ return mapped;
402
398
  }
403
399
  return undefined;
404
400
  }
@@ -424,7 +420,7 @@ export function planGuideSetup(options: {
424
420
  workflow: workflow.slug,
425
421
  stage: stage.slug,
426
422
  role: stage.role,
427
- reason: "no guide candidate has an authenticated harness (see `kxm harness list`)",
423
+ reason: "no admitted guide candidate has an authenticated harness (see `kxm harness list`)",
428
424
  });
429
425
  covered = false;
430
426
  continue;
@@ -465,6 +461,7 @@ function workflowDocument(workflow: GuideWorkflow): Record<string, unknown> {
465
461
  id: stage.slug,
466
462
  kind: "agent",
467
463
  agent: stage.role,
464
+ repositories: { control: isWriterRole(stage.role) ? "write" : "read" },
468
465
  maxAttempts: 2,
469
466
  on: {
470
467
  passed: last ? { target: "$terminal", terminalStatus: "completed" } : workflow.stages[index + 1]!.slug,
@@ -508,6 +505,24 @@ export function renderGuideSetupFiles(projectRoot: string, plan: GuideSetupPlan)
508
505
  return files;
509
506
  }
510
507
 
508
+ /** Append the plan's admitted selectors to `.kxm/routes.yaml`. Does not write role files. */
509
+ export function mergeGuideRouteAdmission(projectRoot: string, plan: GuideSetupPlan): string[] {
510
+ const policy = loadRoutePolicy(projectRoot);
511
+ const added: string[] = [];
512
+ for (const binding of plan.agents.values()) {
513
+ const selector = `${binding.provider}/${binding.model}`;
514
+ if (policy.disabled.includes(selector) || policy.admitted.includes(selector)) continue;
515
+ policy.admitted.push(selector);
516
+ added.push(selector);
517
+ }
518
+ if (added.length === 0) return added;
519
+ policy.updatedAt = new Date().toISOString();
520
+ const path = join(projectRoot, ".kxm", "routes.yaml");
521
+ mkdirSync(dirname(path), { recursive: true });
522
+ writeFileSync(path, stringify(policy), "utf8");
523
+ return added;
524
+ }
525
+
511
526
  export interface WriteReport {
512
527
  readonly written: string[];
513
528
  readonly existed: string[];
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.96";
14
+ const VERSION = "0.7.98";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -190,7 +190,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
190
190
  const cmd = AGENT_COMMANDS_MAP.get(request.params.name);
191
191
  if (!cmd) throw new Error(`unknown tool: ${request.params.name}`);
192
192
  const args = asRecord(request.params.arguments);
193
- const result = await cmd.execute(client, args, { signal: extra.signal, inbox, notifiedInbox });
193
+ // Every open inbound request is work this session is handling; a request it sends
194
+ // meanwhile continues their hop chain.
195
+ const result = await cmd.execute(client, args, {
196
+ signal: extra.signal,
197
+ inbox,
198
+ notifiedInbox,
199
+ handling: [...inbox.values()],
200
+ });
194
201
  return textResult(result);
195
202
  } catch (error) {
196
203
  return {
@@ -7,7 +7,7 @@ import {
7
7
  BUILTIN_HARNESSES,
8
8
  NATIVE_HARNESS_PROVIDERS,
9
9
  probeHarnessAssignmentAsync,
10
- oneShotReadOnlyArgs,
10
+ oneShotPermissionArgs,
11
11
  type HarnessAssignmentProbeOptions,
12
12
  type HarnessStatus,
13
13
  type HarnessCatalogEntry,
@@ -128,11 +128,11 @@ export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}
128
128
  };
129
129
  }
130
130
  }
131
- const defaultModel = options.defaultModel ?? (
132
- harness === "codex" ? "gpt-5.6-sol" : harness === "kimi" ? "kimi-for-coding" : harness === "agy" ? "gemini-3.8-flash-high" : "claude-3-7-sonnet"
133
- );
134
- const parsed = parseModelString(defaultModel, harness);
135
- return { provider: parsed.provider, model: parsed.model, thinking: request.thinking };
131
+ if (options.defaultModel) {
132
+ const parsed = parseModelString(options.defaultModel, harness);
133
+ return { provider: parsed.provider, model: parsed.model, thinking: request.thinking };
134
+ }
135
+ throw new Error("producer_route_not_admitted");
136
136
  }
137
137
 
138
138
  async function checkAuth(harness: string, provider: string, model: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<HarnessStatus> {
@@ -167,8 +167,13 @@ export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}
167
167
  if (request.signal.aborted) return cancelled();
168
168
  const catalogEntry = (options.catalog ?? BUILTIN_HARNESSES).find((h) => h.id === harness);
169
169
  if (!catalogEntry?.oneShot) throw new Error(`oneshot_harness_unsupported: ${harness}`);
170
- const permissionArgs = oneShotReadOnlyArgs(harness);
171
- if (!permissionArgs) throw new Error(`oneshot_harness_unsupported: ${harness} permission_profile_unaudited`);
170
+ const permission = request.permission === "edit" || request.contextPacket?.task.permissionCeiling === "edit"
171
+ ? "edit"
172
+ : "read-only";
173
+ const permissionArgs = oneShotPermissionArgs(harness, permission);
174
+ if (!permissionArgs) {
175
+ throw new Error(`oneshot_harness_unsupported: ${harness} ${permission === "edit" ? "writer_profile_unaudited" : "permission_profile_unaudited"}`);
176
+ }
172
177
  // Pin the environment for auth and execution; don't observe subscription
173
178
  // auth under one environment and then spawn under changed API-key settings.
174
179
  const env = { ...(options.env ?? process.env) };
@@ -287,6 +292,9 @@ export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}
287
292
  const providerMetadata: Record<string, string | number | boolean> = {
288
293
  processStatus: aborted ? "aborted" : transportFailed ? "failed" : "completed",
289
294
  executionEvidenceId: evidence.id,
295
+ permission,
296
+ permissionProfile: permission === "edit" ? "writer" : "read-only",
297
+ authored: false,
290
298
  };
291
299
  if (procResult.code !== null && Number.isFinite(procResult.code)) providerMetadata.processExitCode = procResult.code;
292
300
  if (procResult.signal) providerMetadata.processSignal = procResult.signal;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { parse } from "yaml";
4
+ import { parse, stringify } from "yaml";
5
5
 
6
6
  export * from "./price-calc.ts";
7
7
  import type { PriceCatalog, ModelPriceRow, PriceTier } from "./price-calc.ts";
@@ -167,3 +167,34 @@ export function loadPriceCatalogForEstimate(options?: {
167
167
  return { catalog, unavailable: false, stale: false };
168
168
  }
169
169
 
170
+ /**
171
+ * Stamp the existing project list-price file as today's estimate.
172
+ * This does not fetch vendor rates. Until a catalog's date is today,
173
+ * `loadPriceCatalogForEstimate` keeps the estimate unknown.
174
+ */
175
+ export function acknowledgePriceCatalog(projectRoot: string, now = new Date()): PriceCatalog {
176
+ const path = join(projectRoot, ".kxm", "prices.yaml");
177
+ const loaded = loadPriceCatalog(projectRoot);
178
+ if (!loaded) throw new Error("price catalog missing");
179
+ const date = now.toISOString().slice(0, 10);
180
+ const body = {
181
+ schema: loaded.schema,
182
+ date,
183
+ currency: loaded.currency ?? "USD",
184
+ models: loaded.models,
185
+ };
186
+ const stamped: PriceCatalog = { ...body, sha256: hashPriceCatalog(body) };
187
+ writeFileSync(path, stringify({
188
+ schema: stamped.schema,
189
+ date: stamped.date,
190
+ sha256: stamped.sha256,
191
+ currency: stamped.currency ?? "USD",
192
+ models: stamped.models,
193
+ }), "utf8");
194
+ const again = loadPriceCatalog(projectRoot);
195
+ if (!again || again.date !== date || again.sha256 !== stamped.sha256) {
196
+ throw new Error("price catalog acknowledge failed verification");
197
+ }
198
+ return again;
199
+ }
200
+
@@ -422,7 +422,8 @@ export interface RoutingComparison {
422
422
  blocked: number;
423
423
  failed: number;
424
424
  reworkRate: number;
425
- totalCostUsd: number;
425
+ totalCostUsd: number | null;
426
+ missingCostRuns: number;
426
427
  totalTokensIn: number;
427
428
  totalTokensOut: number;
428
429
  totalHumanInterventions: number;
@@ -445,6 +446,8 @@ export function compareRoutingRecords(records: RoutingRecord[]): RoutingComparis
445
446
  const blocked = settled.filter((record) => record.finalOutcome === "blocked").length;
446
447
  const failed = settled.filter((record) => record.finalOutcome === "failed").length;
447
448
  const reworked = records.filter((record) => record.retries > 0 || record.transitions > 0).length;
449
+ const missingCostRuns = records.filter((record) => typeof record.costUsd !== "number" || !Number.isFinite(record.costUsd)).length;
450
+ const summedCost = records.reduce((sum, record) => sum + (typeof record.costUsd === "number" && Number.isFinite(record.costUsd) ? record.costUsd : 0), 0);
448
451
  return {
449
452
  behavioralSha256,
450
453
  runs: records.length,
@@ -452,7 +455,8 @@ export function compareRoutingRecords(records: RoutingRecord[]): RoutingComparis
452
455
  blocked,
453
456
  failed,
454
457
  reworkRate: records.length === 0 ? 0 : Math.round((reworked / records.length) * 100) / 100,
455
- totalCostUsd: Math.round(records.reduce((sum, record) => sum + (record.costUsd ?? 0), 0) * 10_000) / 10_000,
458
+ totalCostUsd: missingCostRuns > 0 ? null : Math.round(summedCost * 10_000) / 10_000,
459
+ missingCostRuns,
456
460
  totalTokensIn: records.reduce((sum, record) => sum + (record.tokensIn ?? 0), 0),
457
461
  totalTokensOut: records.reduce((sum, record) => sum + (record.tokensOut ?? 0), 0),
458
462
  totalHumanInterventions: records.reduce((sum, record) => sum + record.humanInterventions, 0),
@@ -748,9 +752,11 @@ export function generateRoutingReport(
748
752
 
749
753
  const meteredCostUsd = Math.round(meteredCostTotal * 10_000) / 10_000;
750
754
  const costPerAcceptedUsd = acceptedCount > 0
751
- ? (meteredCostUsd > 0 || unmeteredAttempts > 0
752
- ? Math.round((meteredCostUsd / acceptedCount) * 10_000) / 10_000
753
- : (unknownCostAttempts === attempts ? null : 0))
755
+ ? (unknownCostAttempts > 0
756
+ ? null
757
+ : (meteredCostUsd > 0 || unmeteredAttempts > 0
758
+ ? Math.round((meteredCostUsd / acceptedCount) * 10_000) / 10_000
759
+ : 0))
754
760
  : null;
755
761
 
756
762
  const flagged = unknownCostAttempts > 0;
@@ -817,8 +823,8 @@ export function generateRoutingReport(
817
823
  return a.reworkRate - b.reworkRate;
818
824
  }
819
825
 
820
- // 2. Cost: unknown is never ranked cheapest. Any unknown-cost attempt makes the
821
- // route's cost a lower bound (unmetered or metered attempts beside it do not price it).
826
+ // 2. Cost: unknown is never ranked cheapest. Any unknown-cost attempt leaves the
827
+ // route's cost unknown (unmetered or metered attempts beside it do not price it).
822
828
  const aCostUnknown = a.unknownCostAttempts > 0;
823
829
  const bCostUnknown = b.unknownCostAttempts > 0;
824
830
  if (aCostUnknown && !bCostUnknown) return 1;
@@ -51,6 +51,29 @@ export function projectRuntimeKey(projectRoot: string): string {
51
51
  return createHash("sha256").update(folded, "utf8").digest("hex").slice(0, 24);
52
52
  }
53
53
 
54
+ /** The project's Runtime event store, derived exactly as the Runtime derives it. */
55
+ export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.ProcessEnv): string {
56
+ return join(kxmRuntimePaths({ env }).projectsDir, projectRuntimeKey(projectRoot), "run-events.db");
57
+ }
58
+
59
+ /**
60
+ * Whether this project's Runtime event store holds `runId`. Hub workflow runs and Runtime
61
+ * runs share the `run_<32 hex>` shape, so a command addressed by run id asks the owning
62
+ * store instead of guessing from the id. Read-only; a project whose Runtime never ran
63
+ * owns no runs.
64
+ */
65
+ export function projectRuntimeOwnsRun(projectRoot: string, runId: string, env: NodeJS.ProcessEnv): boolean {
66
+ const path = kxmProjectRunEventsPath(projectRoot, env);
67
+ if (!existsSync(path)) return false;
68
+ const database = new DatabaseSync(path, { readOnly: true });
69
+ try {
70
+ database.exec("PRAGMA busy_timeout = 5000");
71
+ return database.prepare("SELECT 1 FROM runs WHERE run_id = ?").get(runId) !== undefined;
72
+ } finally {
73
+ database.close();
74
+ }
75
+ }
76
+
54
77
  function checkedParent(path: string, description: string): void {
55
78
  const parent = dirname(path);
56
79
  if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 });
@@ -420,13 +420,14 @@ export function createStudioServer(options: StudioServerOptions = {}): StudioSer
420
420
  return;
421
421
  }
422
422
 
423
- res.writeHead(200, { "Content-Type": "application/json" });
423
+ res.writeHead(501, { "Content-Type": "application/json" });
424
424
  res.end(JSON.stringify({
425
- ok: true,
425
+ ok: false,
426
+ executed: false,
426
427
  mutationId,
427
428
  command,
428
- mappedToCli: true,
429
- executedAt: new Date().toISOString(),
429
+ mappedToCli: false,
430
+ error: "mutation_handler_missing",
430
431
  }));
431
432
  return;
432
433
  }
@@ -174,6 +174,37 @@ function coreTemplate(projectId: string, projectName: string, variant: KxmTempla
174
174
  }],
175
175
  ]);
176
176
  if (variant === "v4-registry") {
177
+ const coordinator = files.get(".kxm/agents/coordinator.yaml");
178
+ if (coordinator) {
179
+ files.set(".kxm/agents/coordinator.yaml", {
180
+ ...coordinator,
181
+ harness: "claude",
182
+ model: { provider: "anthropic", model: "fable" },
183
+ });
184
+ }
185
+ const implementer = files.get(".kxm/agents/implementer.yaml");
186
+ if (implementer) {
187
+ files.set(".kxm/agents/implementer.yaml", {
188
+ ...implementer,
189
+ harness: "grok",
190
+ model: { provider: "xai", model: "grok-4.6" },
191
+ });
192
+ }
193
+ const workflow = files.get(".kxm/workflows/default.yaml");
194
+ if (workflow) {
195
+ const limits = { ...(workflow.limits as JsonObject) };
196
+ delete limits.maxAgentTimeMs;
197
+ files.set(".kxm/workflows/default.yaml", { ...workflow, limits });
198
+ }
199
+ // The two models the template names, and nothing else. A fresh project can
200
+ // be driven without falling through to an unadmitted default model.
201
+ files.set(".kxm/routes.yaml", {
202
+ schema: "kxm.routes.v2",
203
+ updatedAt: "2026-09-23T00:00:00.000Z",
204
+ admitted: ["anthropic/fable", "xai/grok-4.6"],
205
+ disabled: [],
206
+ roles: { implementer: ["xai/grok-4.6"] },
207
+ });
177
208
  files.set(".kxm/gates.yaml", {
178
209
  schema: "kxm.gate-registry.v1",
179
210
  gates: { test: { kind: "command", argv: ["npm", "test"], timeoutMs: 3_600_000 } },
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac } from "node:crypto";
2
2
  import {
3
3
  IMPROVEMENT_AREAS,
4
4
  MAX_MESSAGE_TTL_MS,
@@ -1386,6 +1386,75 @@ export function workflowDefinitionHash(definition: WebhookWorkflowDefinition): s
1386
1386
  return createHash("sha256").update(canonicalWorkflowDefinitionJson(definition), "utf8").digest("hex");
1387
1387
  }
1388
1388
 
1389
+ /** The KXM-owned webhook sender contract: every signal callback, and any
1390
+ * workflow start that is not a Jira or GitHub provider delivery. The HMAC
1391
+ * covers a fixed-arity header block and the exact body bytes, so a captured
1392
+ * request cannot be replayed under another delivery ID, against another run
1393
+ * or signal key, or outside the timestamp window. */
1394
+ export const WORKFLOW_WEBHOOK_SIGNATURE_VERSION = "kxm-webhook-v1";
1395
+ export const WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS = 300;
1396
+
1397
+ /** What a signature is bound to. A start names only the definition; a signal
1398
+ * callback also names the run and signal key from its route. */
1399
+ export type WorkflowWebhookScope =
1400
+ | { definitionId: string; runId?: undefined; signalKey?: undefined }
1401
+ | { definitionId: string; runId: string; signalKey: string };
1402
+
1403
+ /** The exact bytes a KXM webhook signature covers: seven newline-terminated
1404
+ * fields (version, kind, timestamp, delivery ID, definition ID, run ID, signal
1405
+ * key; the last two empty for a start) followed by the raw body. No field may
1406
+ * contain a line break, so the encoding is unambiguous. */
1407
+ export function workflowWebhookSignedMaterial(
1408
+ scope: WorkflowWebhookScope,
1409
+ timestamp: string,
1410
+ deliveryId: string,
1411
+ body: string | Uint8Array,
1412
+ ): Buffer {
1413
+ const fields = [
1414
+ WORKFLOW_WEBHOOK_SIGNATURE_VERSION,
1415
+ scope.runId === undefined ? "start" : "signal",
1416
+ timestamp,
1417
+ deliveryId,
1418
+ scope.definitionId,
1419
+ scope.runId ?? "",
1420
+ scope.signalKey ?? "",
1421
+ ];
1422
+ if (fields.some((field) => /[\r\n]/.test(field))) {
1423
+ throw new ProtocolError(400, "webhook signature fields must not contain line breaks", "webhook_signature_field_invalid");
1424
+ }
1425
+ return Buffer.concat([
1426
+ Buffer.from(`${fields.join("\n")}\n`, "utf8"),
1427
+ typeof body === "string" ? Buffer.from(body, "utf8") : Buffer.from(body),
1428
+ ]);
1429
+ }
1430
+
1431
+ export function workflowWebhookSignature(
1432
+ secret: string,
1433
+ scope: WorkflowWebhookScope,
1434
+ timestamp: string,
1435
+ deliveryId: string,
1436
+ body: string | Uint8Array,
1437
+ ): string {
1438
+ return `sha256=${createHmac("sha256", secret).update(workflowWebhookSignedMaterial(scope, timestamp, deliveryId, body)).digest("hex")}`;
1439
+ }
1440
+
1441
+ /** Headers for one KXM webhook send. Sign at send time: a transport retry
1442
+ * re-signs with a fresh timestamp and keeps the same delivery ID and body. */
1443
+ export function workflowWebhookHeaders(input: {
1444
+ secret: string;
1445
+ scope: WorkflowWebhookScope;
1446
+ deliveryId: string;
1447
+ body: string;
1448
+ nowMs?: number;
1449
+ }): Record<string, string> {
1450
+ const timestamp = String(Math.floor((input.nowMs ?? Date.now()) / 1_000));
1451
+ return {
1452
+ "x-kxm-delivery-id": input.deliveryId,
1453
+ "x-kxm-timestamp": timestamp,
1454
+ "x-kxm-signature": workflowWebhookSignature(input.secret, input.scope, timestamp, input.deliveryId, input.body),
1455
+ };
1456
+ }
1457
+
1389
1458
  /** Resolve a declared transition rule for a stage outcome, or undefined when
1390
1459
  * the outcome keeps the v0.4 default edges. */
1391
1460
  function resolveOutcomeRule(stage: WorkflowStageState, outcomeKey: string): WorkflowTransitionRule | undefined {
@@ -0,0 +1,71 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import type { KxmProducerResult } from "./engine.ts";
3
+
4
+ /**
5
+ * Fingerprint of a checkout taken around a live producer spawn.
6
+ * Porcelain alone misses a content edit that keeps the same status line, so the
7
+ * witness also includes unstaged and staged diffs.
8
+ */
9
+ export interface WorktreeWitness {
10
+ readonly unwitnessed: boolean;
11
+ readonly fingerprint: string;
12
+ }
13
+
14
+ function gitText(cwd: string, args: readonly string[]): string | undefined {
15
+ const result = spawnSync("git", ["-C", cwd, ...args], {
16
+ encoding: "utf8",
17
+ windowsHide: true,
18
+ timeout: 15_000,
19
+ });
20
+ if (result.error || result.status !== 0) return undefined;
21
+ return result.stdout ?? "";
22
+ }
23
+
24
+ export function captureWorktreeWitness(cwd: string): WorktreeWitness {
25
+ const porcelain = gitText(cwd, ["status", "--porcelain=v1", "-uall"]);
26
+ const diff = gitText(cwd, ["diff", "--no-ext-diff"]);
27
+ const staged = gitText(cwd, ["diff", "--cached", "--no-ext-diff"]);
28
+ if (porcelain === undefined || diff === undefined || staged === undefined) {
29
+ return { unwitnessed: true, fingerprint: "" };
30
+ }
31
+ return { unwitnessed: false, fingerprint: `${porcelain}\0${diff}\0${staged}` };
32
+ }
33
+
34
+ export function worktreeChanged(before: WorktreeWitness, after: WorktreeWitness): boolean {
35
+ if (before.unwitnessed || after.unwitnessed) return false;
36
+ return before.fingerprint !== after.fingerprint;
37
+ }
38
+
39
+ /**
40
+ * A live `passed` is an authoring success only when the step declared write
41
+ * and the checkout fingerprint changed. A read-only step that mutates the tree
42
+ * cannot stay `passed`. Metadata `authored` is set here, not trusted from the
43
+ * producer.
44
+ */
45
+ export function applyAuthoringWitness(
46
+ result: KxmProducerResult,
47
+ input: { writes: boolean; before: WorktreeWitness; after: WorktreeWitness },
48
+ ): KxmProducerResult {
49
+ const changed = worktreeChanged(input.before, input.after);
50
+ const unwitnessed = input.before.unwitnessed || input.after.unwitnessed;
51
+ const providerMetadata: Record<string, string | number | boolean> = { ...(result.providerMetadata ?? {}) };
52
+ if (input.writes) {
53
+ if (unwitnessed || !changed) {
54
+ providerMetadata.authored = false;
55
+ providerMetadata.authoringWitness = unwitnessed ? "unwitnessed" : "unchanged";
56
+ if (result.outcome === "passed") return { ...result, outcome: "failed", providerMetadata };
57
+ } else {
58
+ providerMetadata.authored = true;
59
+ providerMetadata.authoringWitness = "changed";
60
+ }
61
+ } else {
62
+ providerMetadata.authored = false;
63
+ if (!unwitnessed && changed) {
64
+ providerMetadata.authoringWitness = "readonly_mutated";
65
+ if (result.outcome === "passed") return { ...result, outcome: "failed", providerMetadata };
66
+ } else {
67
+ providerMetadata.authoringWitness = unwitnessed ? "unwitnessed" : "read-only";
68
+ }
69
+ }
70
+ return { ...result, providerMetadata };
71
+ }
@@ -31,6 +31,17 @@
31
31
  "minItems": 1,
32
32
  "maxItems": 64
33
33
  },
34
+ "files": {
35
+ "type": "array",
36
+ "items": { "$ref": "#/$defs/backupFileRecord" },
37
+ "maxItems": 64
38
+ },
39
+ "complete": { "type": "boolean" },
40
+ "omitted": {
41
+ "type": "array",
42
+ "items": { "type": "string", "minLength": 1, "maxLength": 256 },
43
+ "maxItems": 64
44
+ },
34
45
  "manifestSha256": {
35
46
  "$ref": "common.schema.json#/$defs/sha256"
36
47
  }
@@ -84,6 +95,28 @@
84
95
  }
85
96
  },
86
97
  "additionalProperties": false
98
+ },
99
+ "backupFileRecord": {
100
+ "type": "object",
101
+ "required": ["id", "sourcePath", "backupFile", "sha256", "bytes"],
102
+ "properties": {
103
+ "id": {
104
+ "type": "string",
105
+ "minLength": 1,
106
+ "maxLength": 128,
107
+ "pattern": "^[a-zA-Z0-9_.:-]+$"
108
+ },
109
+ "sourcePath": { "type": "string", "minLength": 1, "maxLength": 1024 },
110
+ "backupFile": {
111
+ "type": "string",
112
+ "minLength": 1,
113
+ "maxLength": 256,
114
+ "pattern": "^[a-zA-Z0-9_.-]+$"
115
+ },
116
+ "sha256": { "$ref": "common.schema.json#/$defs/sha256" },
117
+ "bytes": { "type": "integer", "minimum": 0, "maximum": 107374182400 }
118
+ },
119
+ "additionalProperties": false
87
120
  }
88
121
  }
89
122
  }
@@ -423,10 +423,14 @@ export async function runRealSmoke(options = {}) {
423
423
  stage = "workflow-journal-checkpoint";
424
424
  const resumedOperator = values["durable-restart-resume"];
425
425
  const payload = JSON.stringify({ event: "smoke.requested" });
426
+ // KXM webhook contract (workflowWebhookSignedMaterial in plugins/kxm/src/workflow.ts).
427
+ const deliveryId = `smoke-${randomUUID()}`;
428
+ const timestamp = String(Math.floor(Date.now() / 1_000));
429
+ const signedMaterial = ["kxm-webhook-v1", "start", timestamp, deliveryId, "real-pi-smoke", "", ""].join("\n") + "\n" + payload;
426
430
  const started = await api(baseUrl, "/v1/webhooks/real-pi-smoke", {
427
431
  method: "POST",
428
432
  authToken,
429
- headers: { "x-mesh-event": "smoke.requested", "x-kxm-delivery-id": `smoke-${randomUUID()}`, "x-hub-signature": `sha256=${createHmac("sha256", webhookSecret).update(payload).digest("hex")}` },
433
+ headers: { "x-kxm-delivery-id": deliveryId, "x-kxm-timestamp": timestamp, "x-kxm-signature": `sha256=${createHmac("sha256", webhookSecret).update(signedMaterial).digest("hex")}` },
430
434
  body: payload,
431
435
  });
432
436
  await api(baseUrl, `/v1/workflows/${encodeURIComponent(started.run.id)}/journal`, {