@kontextmind/kxm 0.7.97 → 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 (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/workflows/default.yaml +2 -0
  3. package/CHANGELOG.md +39 -2
  4. package/docs/concepts/architecture.md +1 -1
  5. package/docs/concepts/data-and-storage.md +1 -1
  6. package/docs/contracts/routing.md +1 -1
  7. package/docs/contributing/test-matrix.md +6 -5
  8. package/docs/operations/backup-and-restore.md +43 -24
  9. package/docs/operations/deploy.md +1 -1
  10. package/docs/reference/cli-reference.md +59 -24
  11. package/docs/reference/config-reference.md +24 -13
  12. package/docs/reference/harness-routing.md +3 -3
  13. package/docs/reference/http-api.md +1 -1
  14. package/docs/start/first-workflow.md +4 -4
  15. package/docs/start/quickstart-claude-code.md +2 -2
  16. package/package.json +1 -1
  17. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  18. package/plugins/kxm/dist/cli.js +393 -154
  19. package/plugins/kxm/dist/core.js +5 -2
  20. package/plugins/kxm/dist/mcp-server.js +1 -1
  21. package/plugins/kxm/dist/runtime-supervisor.js +231 -48
  22. package/plugins/kxm/dist/runtime.js +415 -92
  23. package/plugins/kxm/dist/server.js +7 -0
  24. package/plugins/kxm/package.json +1 -1
  25. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
  26. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
  27. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
  28. package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
  29. package/plugins/kxm/src/cli/project.ts +22 -13
  30. package/plugins/kxm/src/cli/system.ts +22 -1
  31. package/plugins/kxm/src/cli.ts +11 -3
  32. package/plugins/kxm/src/database.ts +210 -36
  33. package/plugins/kxm/src/engine.ts +117 -2
  34. package/plugins/kxm/src/harness.ts +29 -0
  35. package/plugins/kxm/src/init-guide-setup.ts +43 -28
  36. package/plugins/kxm/src/mcp-server.ts +1 -1
  37. package/plugins/kxm/src/oneshot-producer.ts +16 -8
  38. package/plugins/kxm/src/prices.ts +33 -2
  39. package/plugins/kxm/src/routing.ts +13 -7
  40. package/plugins/kxm/src/studio-layout.ts +5 -4
  41. package/plugins/kxm/src/template.ts +31 -0
  42. package/plugins/kxm/src/worktree-witness.ts +71 -0
  43. package/schemas/backup-manifest.schema.json +33 -0
@@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import { isRouteAdmitted, listRoleBindings } from "./routes.ts";
6
+ import { oneShotWriterArgs } from "./harness.ts";
7
+ import { applyAuthoringWitness, captureWorktreeWitness } from "./worktree-witness.ts";
6
8
  import {
7
9
  buildFormalContextPacket,
8
10
  buildHandoffManifest,
@@ -153,6 +155,8 @@ export interface KxmProducerRequest {
153
155
  readonly thinking?: string | undefined;
154
156
  readonly agentRole?: string | undefined;
155
157
  readonly harness?: string | undefined;
158
+ /** Live producers select an audited argv profile from this ceiling. */
159
+ readonly permission?: "read-only" | "edit" | undefined;
156
160
  readonly contextPacket?: FormalContextPacketV2 | undefined;
157
161
  readonly handoffManifest?: HandoffManifestV1 | undefined;
158
162
  }
@@ -1593,6 +1597,14 @@ function prepareDispatch(
1593
1597
  return { kind: "return", state, handoff: { ...routeResult.error, stepId } };
1594
1598
  }
1595
1599
  resolvedRoute = routeResult;
1600
+ const writeRefusal = unsupportedLiveWrite(
1601
+ context.projectRoot,
1602
+ step,
1603
+ agentId,
1604
+ resolvedRoute.selector,
1605
+ loadKxmRunPlanEnvelope(context.eventStore, run).projectLimits.maxConcurrentRuns,
1606
+ );
1607
+ if (writeRefusal) return { kind: "return", state, handoff: { ...writeRefusal, stepId } };
1596
1608
  }
1597
1609
 
1598
1610
  const used = state.stepAttempts[stepId] ?? 0;
@@ -1825,6 +1837,7 @@ function birthMember(
1825
1837
  signal: controller.signal,
1826
1838
  prompt: input.step.instructions ? `${input.step.instructions}\n\n${generatedPrompt}` : generatedPrompt,
1827
1839
  thinking: input.stepAttempt <= 1 ? "low" : "medium",
1840
+ permission: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
1828
1841
  contextPacket,
1829
1842
  ...(resolvedRoute ? { provider: resolvedRoute.provider, model: resolvedRoute.model } : {}),
1830
1843
  },
@@ -1998,7 +2011,18 @@ async function drivePanel(
1998
2011
  if (!executingBound()) return { attemptId: member.attemptId, invoked: false, skipped: true };
1999
2012
  kxmPanelDispatchSeams.beforeInvoke?.(member);
2000
2013
  if (!executingBound()) return { attemptId: member.attemptId, invoked: false, skipped: true };
2014
+ const live = member.producerId !== "driver-simulated";
2015
+ const writes = Object.values(member.step.repositories).some((access) => access === "write");
2016
+ const before = live ? captureWorktreeWitness(context.projectRoot) : undefined;
2001
2017
  const produced = await invokeProducer(producer, member.request);
2018
+ if (live && produced.result && before) {
2019
+ const after = captureWorktreeWitness(context.projectRoot);
2020
+ return {
2021
+ attemptId: member.attemptId,
2022
+ invoked: true,
2023
+ produced: { ...produced, result: applyAuthoringWitness(produced.result, { writes, before, after }) },
2024
+ };
2025
+ }
2002
2026
  return { attemptId: member.attemptId, invoked: true, produced };
2003
2027
  } catch (error) {
2004
2028
  member.controller.abort();
@@ -2722,11 +2746,102 @@ function unsupportedStep(
2722
2746
  return { reason: "step_unsupported", field: "repositories", detail: `invalid repository access '${access}' on ${repoId}` };
2723
2747
  }
2724
2748
  }
2725
- if (producerId !== "driver-simulated" && Object.values(step.repositories).some((access) => access === "write")) {
2749
+ return undefined;
2750
+ }
2751
+
2752
+ function readYamlRecord(path: string): Record<string, unknown> | undefined {
2753
+ if (!existsSync(path)) return undefined;
2754
+ try {
2755
+ const parsed = parse(readFileSync(path, "utf8")) as unknown;
2756
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
2757
+ } catch {
2758
+ return undefined;
2759
+ }
2760
+ return undefined;
2761
+ }
2762
+
2763
+ function agentHarness(projectRoot: string, agentId: string): string | undefined {
2764
+ const harness = readYamlRecord(join(projectRoot, ".kxm", "agents", `${agentId}.yaml`))?.harness;
2765
+ return typeof harness === "string" && harness.length > 0 ? harness : undefined;
2766
+ }
2767
+
2768
+ function projectDefaultHarness(projectRoot: string): string {
2769
+ const harness = readYamlRecord(join(projectRoot, ".kxm", "project.yaml"))?.defaultHarness;
2770
+ return typeof harness === "string" && harness.length > 0 ? harness : "pi";
2771
+ }
2772
+
2773
+ /**
2774
+ * Live write steps run only on an audited writer profile, and only when the
2775
+ * developer roster (when present) lists that harness and model as an edit writer.
2776
+ * A missing roster is a fresh project: route admission is the other gate.
2777
+ *
2778
+ * The authoring witness fingerprints the one project checkout around each
2779
+ * spawn, so it can only attribute a change to a lone writer. A write step with
2780
+ * more than one assignment would run several writers there (and member n runs
2781
+ * as allowedAgents[n], which the writer check below never sees), and a
2782
+ * project that admits concurrent runs lets another run's writer edit the tree
2783
+ * mid-attempt. Both hand off.
2784
+ */
2785
+ function unsupportedLiveWrite(
2786
+ projectRoot: string,
2787
+ step: KxmCompiledStep,
2788
+ agentId: string,
2789
+ selector: string,
2790
+ maxConcurrentRuns: number,
2791
+ ): Omit<KxmRunHandoff, "stepId"> | undefined {
2792
+ if (!Object.values(step.repositories).some((access) => access === "write")) return undefined;
2793
+ if (step.assignments.maximum !== 1) {
2794
+ return {
2795
+ reason: "step_unsupported",
2796
+ field: "assignments.maximum",
2797
+ detail: "live write steps run a single assignment; the checkout witness cannot attribute edits between writers",
2798
+ };
2799
+ }
2800
+ if (maxConcurrentRuns !== 1) {
2801
+ return {
2802
+ reason: "step_unsupported",
2803
+ field: "limits.maxConcurrentRuns",
2804
+ detail: "live write steps require limits.maxConcurrentRuns of 1; concurrent runs share one checkout",
2805
+ };
2806
+ }
2807
+ const harness = agentHarness(projectRoot, agentId) ?? projectDefaultHarness(projectRoot);
2808
+ if (!oneShotWriterArgs(harness)) {
2726
2809
  return {
2727
2810
  reason: "step_unsupported",
2728
2811
  field: "repositories",
2729
- detail: "live write steps are unsupported until writer sandboxing witness passes",
2812
+ detail: `live write steps require an audited writer profile; ${harness} has none`,
2813
+ };
2814
+ }
2815
+ const rosterPath = join(projectRoot, ".kxm", "roster.yaml");
2816
+ if (!existsSync(rosterPath)) return undefined;
2817
+ const roster = readYamlRecord(rosterPath);
2818
+ if (!roster || roster.schema !== "kxm.developer-roster.v1") {
2819
+ return { reason: "step_unsupported", field: "model", detail: "live write steps require a readable kxm.developer-roster.v1" };
2820
+ }
2821
+ const routes = roster.routes;
2822
+ const lineup = roster.lineup;
2823
+ const writerIds = lineup && typeof lineup === "object" && !Array.isArray(lineup)
2824
+ ? (lineup as Record<string, unknown>).writer
2825
+ : undefined;
2826
+ if (!routes || typeof routes !== "object" || Array.isArray(routes) || !Array.isArray(writerIds)) {
2827
+ return { reason: "step_unsupported", field: "model", detail: "developer roster has no writer lineup" };
2828
+ }
2829
+ const allowed = writerIds.some((id) => {
2830
+ if (typeof id !== "string") return false;
2831
+ const route = (routes as Record<string, unknown>)[id];
2832
+ if (!route || typeof route !== "object" || Array.isArray(route)) return false;
2833
+ const record = route as Record<string, unknown>;
2834
+ if (record.harness !== harness || record.status !== "admitted") return false;
2835
+ if (!Array.isArray(record.permissions) || !record.permissions.includes("edit")) return false;
2836
+ const model = typeof record.model === "string" ? record.model : "";
2837
+ const vendor = typeof record.vendor === "string" ? record.vendor : "";
2838
+ return model === selector || (vendor.length > 0 && `${vendor}/${model}` === selector);
2839
+ });
2840
+ if (!allowed) {
2841
+ return {
2842
+ reason: "step_unsupported",
2843
+ field: "model",
2844
+ detail: `live write route ${selector} on ${harness} is not on the developer roster writer lineup`,
2730
2845
  };
2731
2846
  }
2732
2847
  return undefined;
@@ -518,6 +518,35 @@ export function oneShotReadOnlyArgs(harness: string): readonly string[] | undefi
518
518
  return Object.hasOwn(READ_ONLY_ONESHOT_ARGS, harness) ? READ_ONLY_ONESHOT_ARGS[harness as keyof typeof READ_ONLY_ONESHOT_ARGS] : undefined;
519
519
  }
520
520
 
521
+ /**
522
+ * Audited edit profiles. These are a writer containment, not read-only flags
523
+ * with the sandbox removed. They match the assignment helper
524
+ * (`scripts/harness-run.mjs` `buildArgv` for `permission: "edit"` with hooks
525
+ * and skills refused):
526
+ *
527
+ * - Pi: `-a` auto-approves tools so the process can edit the checkout, while
528
+ * extensions, skills, prompt templates, and session persistence stay off.
529
+ * `--no-tools` is the read-only profile and is not used here.
530
+ * - Grok: `--always-approve` so edits are not an interactive prompt, with
531
+ * subagents and web search disabled. Grok has no audited read-only edit
532
+ * mix; the read-only one-shot flags stay on the read-only profile.
533
+ *
534
+ * Claude, Codex, agy, and Kimi stay read-only. A live write step on a harness
535
+ * without an entry here hands off instead of spawning unconstrained.
536
+ */
537
+ const WRITER_ONESHOT_ARGS = Object.freeze({
538
+ pi: Object.freeze(["-a", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-session"]),
539
+ grok: Object.freeze(["--always-approve", "--no-subagents", "--disable-web-search"]),
540
+ });
541
+
542
+ export function oneShotWriterArgs(harness: string): readonly string[] | undefined {
543
+ return Object.hasOwn(WRITER_ONESHOT_ARGS, harness) ? WRITER_ONESHOT_ARGS[harness as keyof typeof WRITER_ONESHOT_ARGS] : undefined;
544
+ }
545
+
546
+ export function oneShotPermissionArgs(harness: string, permission: "read-only" | "edit"): readonly string[] | undefined {
547
+ return permission === "edit" ? oneShotWriterArgs(harness) : oneShotReadOnlyArgs(harness);
548
+ }
549
+
521
550
  export const BUILTIN_HARNESSES: readonly HarnessCatalogEntry[] = Object.freeze([
522
551
  {
523
552
  id: "pi",
@@ -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.97";
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>();
@@ -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;
@@ -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 } },
@@ -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
+ }