@kontextmind/kxm 0.7.97 → 0.7.99

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 (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/workflows/default.yaml +2 -0
  3. package/CHANGELOG.md +46 -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/guides/peer-messaging.md +2 -2
  9. package/docs/operations/backup-and-restore.md +43 -24
  10. package/docs/operations/deploy.md +1 -1
  11. package/docs/reference/cli-reference.md +65 -28
  12. package/docs/reference/config-reference.md +24 -13
  13. package/docs/reference/harness-routing.md +3 -3
  14. package/docs/reference/http-api.md +2 -1
  15. package/docs/reference/tools.md +3 -3
  16. package/docs/start/first-workflow.md +4 -4
  17. package/docs/start/quickstart-claude-code.md +2 -2
  18. package/package.json +1 -1
  19. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  20. package/plugins/kxm/dist/claude-hook.js +4 -1
  21. package/plugins/kxm/dist/cli.js +407 -156
  22. package/plugins/kxm/dist/client.js +9 -0
  23. package/plugins/kxm/dist/core.js +9 -3
  24. package/plugins/kxm/dist/extension.js +13 -1
  25. package/plugins/kxm/dist/mcp-server.js +14 -2
  26. package/plugins/kxm/dist/runtime-supervisor.js +231 -48
  27. package/plugins/kxm/dist/runtime.js +415 -92
  28. package/plugins/kxm/dist/server.js +19 -1
  29. package/plugins/kxm/package.json +1 -1
  30. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
  31. package/plugins/kxm/skills/kxm-peer/SKILL.md +5 -3
  32. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
  33. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
  34. package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
  35. package/plugins/kxm/src/cli/project.ts +22 -13
  36. package/plugins/kxm/src/cli/system.ts +22 -1
  37. package/plugins/kxm/src/cli.ts +14 -4
  38. package/plugins/kxm/src/client.ts +10 -0
  39. package/plugins/kxm/src/commands.ts +10 -1
  40. package/plugins/kxm/src/database.ts +210 -36
  41. package/plugins/kxm/src/engine.ts +117 -2
  42. package/plugins/kxm/src/extension.ts +1 -0
  43. package/plugins/kxm/src/harness.ts +29 -0
  44. package/plugins/kxm/src/hub.ts +12 -0
  45. package/plugins/kxm/src/init-guide-setup.ts +43 -28
  46. package/plugins/kxm/src/mcp-server.ts +1 -1
  47. package/plugins/kxm/src/oneshot-producer.ts +16 -8
  48. package/plugins/kxm/src/prices.ts +33 -2
  49. package/plugins/kxm/src/routing.ts +13 -7
  50. package/plugins/kxm/src/studio-layout.ts +5 -4
  51. package/plugins/kxm/src/template.ts +31 -0
  52. package/plugins/kxm/src/worktree-witness.ts +71 -0
  53. package/schemas/backup-manifest.schema.json +33 -0
@@ -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.99";
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
+ }
@@ -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
  }