@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
@@ -32,49 +32,49 @@ const WORKFLOW_PATTERNS: WorkflowPattern[] = [
32
32
  id: "software-engineering/bug-fix",
33
33
  area: "software-engineering",
34
34
  keywords: ["fix", "bug", "flaky", "failure", "timeout", "error", "repro", "crash", "broken", "hang"],
35
- skills: ["troubleshooting", "memory-leak-debugging"],
35
+ skills: ["troubleshooting", "kxm", "kxm-query", "kxm-work"],
36
36
  defaultCommand: (p) => `kxm run software-engineering/bug-fix "${p}"`,
37
37
  },
38
38
  {
39
39
  id: "software-engineering/feature-implementation",
40
40
  area: "software-engineering",
41
41
  keywords: ["feature", "implement", "add", "build", "create", "develop", "support", "endpoint", "ui", "tui"],
42
- skills: ["modern-web-guidance", "kxm"],
42
+ skills: ["modern-web-guidance", "kxm", "kxm-mind", "kxm-query"],
43
43
  defaultCommand: (p) => `kxm run software-engineering/feature-implementation "${p}"`,
44
44
  },
45
45
  {
46
46
  id: "software-engineering/refactoring",
47
47
  area: "software-engineering",
48
48
  keywords: ["refactor", "cleanup", "reorganize", "modularize", "deduplicate", "split", "simplify", "deprecate"],
49
- skills: ["kxm"],
49
+ skills: ["kxm", "kxm-mind", "kxm-query"],
50
50
  defaultCommand: (p) => `kxm run software-engineering/refactoring "${p}"`,
51
51
  },
52
52
  {
53
53
  id: "security-reliability/vulnerability-remediation",
54
54
  area: "security-reliability",
55
55
  keywords: ["cve", "vulnerability", "security", "exploit", "sanitize", "leak", "secret", "injection", "redact", "auth"],
56
- skills: ["kxm"],
56
+ skills: ["kxm", "kxm-protocol", "kxm-triage"],
57
57
  defaultCommand: (p) => `kxm run security-reliability/vulnerability-remediation "${p}"`,
58
58
  },
59
59
  {
60
60
  id: "security-reliability/reliability-hardening",
61
61
  area: "security-reliability",
62
62
  keywords: ["idempotency", "retry", "circuit-breaker", "cas", "lock", "concurrency", "deadlock", "race", "crash-recovery"],
63
- skills: ["kxm"],
63
+ skills: ["kxm", "kxm-protocol"],
64
64
  defaultCommand: (p) => `kxm run security-reliability/reliability-hardening "${p}"`,
65
65
  },
66
66
  {
67
67
  id: "data-analytics/pipeline-migration",
68
68
  area: "data-analytics",
69
69
  keywords: ["database", "sqlite", "migration", "pipeline", "schema", "transform", "table", "wal", "foreign", "cascading"],
70
- skills: ["kxm"],
70
+ skills: ["kxm", "kxm-query"],
71
71
  defaultCommand: (p) => `kxm run data-analytics/pipeline-migration "${p}"`,
72
72
  },
73
73
  {
74
74
  id: "research-strategy/architecture-spike",
75
75
  area: "research-strategy",
76
76
  keywords: ["spike", "investigate", "prototype", "research", "feasibility", "benchmark", "explore", "evaluate"],
77
- skills: ["kxm-session"],
77
+ skills: ["kxm-session", "kxm-mind", "kxm-query", "kxm-insights"],
78
78
  defaultCommand: (p) => `kxm run research-strategy/architecture-spike "${p}"`,
79
79
  },
80
80
  ];
@@ -110,36 +110,30 @@ export function suggestWorkflowAndRoles(
110
110
  }
111
111
 
112
112
  if (bestScore <= 0) {
113
- // Default fallback to feature-implementation
114
113
  bestPattern = WORKFLOW_PATTERNS[1]!;
115
114
  matchedReasons.push("Default fallback: general feature implementation workflow");
116
115
  }
117
116
 
118
- // Determine active harnesses
119
117
  const activeHarnesses = new Set(
120
118
  (options.availableHarnesses ?? [])
121
119
  .filter((h) => h.auth === "active" || h.auth === "ready" || h.auth === "configured")
122
120
  .map((h) => h.harness.toLowerCase()),
123
121
  );
124
122
 
125
- // If none specified or active, assume standard default rotation
126
123
  const hasClaude = activeHarnesses.size === 0 || activeHarnesses.has("claude");
127
124
  const hasGrok = activeHarnesses.size === 0 || activeHarnesses.has("grok");
128
125
  const hasCodex = activeHarnesses.size === 0 || activeHarnesses.has("codex");
129
126
 
130
- // Planner
131
127
  const planner: RoleSpec = hasClaude
132
128
  ? { harness: "claude", model: "fable", role: "planner" }
133
129
  : { harness: "codex", model: "gpt-5.6-sol", role: "planner" };
134
130
 
135
- // Writer (Starting rotation: Grok 4.6 headless)
136
131
  const writer: RoleSpec = hasGrok
137
132
  ? { harness: "grok", model: "grok-4.6", role: "writer" }
138
133
  : (activeHarnesses.has("agy")
139
134
  ? { harness: "agy", model: "gemini-2.5-pro", role: "writer" }
140
135
  : { harness: "pi", model: "qwen/qwen3-coder-plus", role: "writer" });
141
136
 
142
- // Critics (Vendor independent: Fable Arch + Codex CLI)
143
137
  const critics: RoleSpec[] = [
144
138
  { harness: "claude", model: "fable", role: "reviewer-arch" },
145
139
  { harness: "codex", model: "gpt-5.6-sol", role: "reviewer-cli" },
@@ -114,3 +114,85 @@ export function readRoutingRecords(path: string): Array<{ recordedAt: string; ro
114
114
  }
115
115
  return records;
116
116
  }
117
+
118
+ export const FEDERATED_METRIC_SCHEMA = "kxm.federated-metric.v1" as const;
119
+
120
+ export interface FederatedModelMetric {
121
+ schema: typeof FEDERATED_METRIC_SCHEMA;
122
+ harness: string;
123
+ model: string;
124
+ timestamp: string;
125
+ latencyMs: number;
126
+ tokensIn?: number | null | undefined;
127
+ tokensOut?: number | null | undefined;
128
+ costUsd?: number | null | undefined;
129
+ verifierOutcome?: "passed" | "warning" | "failed" | undefined;
130
+ }
131
+
132
+ /**
133
+ * Exports anonymized model telemetry metrics to federated user directory (Decision Q15).
134
+ * Strictly strips repository paths, prompts, code diffs, run IDs, and memory items.
135
+ */
136
+ export function exportFederatedTelemetry(
137
+ records: Array<RoutingRecord | RoutingRecordV2>,
138
+ globalDir?: string,
139
+ ): number {
140
+ const targetDir = globalDir
141
+ ? join(globalDir, "telemetry")
142
+ : join(process.env.XDG_CONFIG_HOME || join(process.env.HOME || "~", ".config"), "kxm", "telemetry");
143
+
144
+ mkdirSync(targetDir, { recursive: true });
145
+ const metricsPath = join(targetDir, "model-metrics.jsonl");
146
+
147
+ let exported = 0;
148
+ for (const r of records) {
149
+ const isV2 = r.schema === "kxm.routing-record.v2";
150
+ const v2 = isV2 ? (r as RoutingRecordV2) : undefined;
151
+ const harness = (v2 ? v2.harness : (r.providerMetadata?.harness as string | undefined)) || "unknown";
152
+ const model = (v2 ? (v2.effectiveModel || v2.requestedModel) : (r.effectiveModel || r.requestedModel)) || "unknown";
153
+ const timestamp = (r as any).recordedAt || new Date().toISOString();
154
+ const latencyMs = v2 ? v2.latencyMs : (typeof r.providerMetadata?.latencyMs === "number" ? (r.providerMetadata.latencyMs as number) : 0);
155
+
156
+ const metric: FederatedModelMetric = {
157
+ schema: FEDERATED_METRIC_SCHEMA,
158
+ harness,
159
+ model,
160
+ timestamp,
161
+ latencyMs,
162
+ tokensIn: r.tokensIn ?? null,
163
+ tokensOut: r.tokensOut ?? null,
164
+ costUsd: r.costUsd ?? null,
165
+ verifierOutcome: r.verifierOutcome,
166
+ };
167
+
168
+ appendFileSync(metricsPath, `${JSON.stringify(metric)}\n`, { encoding: "utf8", mode: 0o600 });
169
+ exported++;
170
+ }
171
+
172
+ return exported;
173
+ }
174
+
175
+ export function readFederatedTelemetry(globalDir?: string): FederatedModelMetric[] {
176
+ const targetDir = globalDir
177
+ ? join(globalDir, "telemetry")
178
+ : join(process.env.XDG_CONFIG_HOME || join(process.env.HOME || "~", ".config"), "kxm", "telemetry");
179
+ const metricsPath = join(targetDir, "model-metrics.jsonl");
180
+ try {
181
+ const raw = readFileSync(metricsPath, "utf8");
182
+ const metrics: FederatedModelMetric[] = [];
183
+ for (const line of raw.split(/\r?\n/)) {
184
+ if (!line.trim()) continue;
185
+ try {
186
+ const parsed = JSON.parse(line) as FederatedModelMetric;
187
+ if (parsed.schema === FEDERATED_METRIC_SCHEMA) {
188
+ metrics.push(parsed);
189
+ }
190
+ } catch {
191
+ // Skip malformed
192
+ }
193
+ }
194
+ return metrics;
195
+ } catch {
196
+ return [];
197
+ }
198
+ }
@@ -15,7 +15,11 @@ import {
15
15
  type Terminal,
16
16
  type TUI,
17
17
  } from "@earendil-works/pi-tui";
18
+ import { mkdirSync } from "node:fs";
19
+ import { join, resolve, dirname } from "node:path";
20
+ import { spawnSync } from "node:child_process";
18
21
  import type { AgentRecord } from "./protocol.ts";
22
+ import { slugifyBranchPart } from "./external-effects.ts";
19
23
  import {
20
24
  loadLocalMeshSnapshot,
21
25
  summarizeMeshRun,
@@ -138,6 +142,114 @@ export function applyMeshTuiKey(view: MeshTuiView, key: string, itemCount = 0):
138
142
  return view;
139
143
  }
140
144
 
145
+ export interface DegradeWorktreeResult {
146
+ ok: boolean;
147
+ branchName: string;
148
+ worktreePath: string;
149
+ jumpCommand: string;
150
+ copiedToClipboard: boolean;
151
+ error?: string | undefined;
152
+ }
153
+
154
+ export function copyToClipboard(text: string): boolean {
155
+ try {
156
+ if (process.platform === "darwin") {
157
+ const proc = spawnSync("pbcopy", { input: text, encoding: "utf8", windowsHide: true });
158
+ return proc.status === 0;
159
+ }
160
+ if (process.platform === "win32") {
161
+ const proc = spawnSync("clip", { input: text, encoding: "utf8", windowsHide: true });
162
+ return proc.status === 0;
163
+ }
164
+ const wl = spawnSync("wl-copy", [text], { encoding: "utf8", windowsHide: true });
165
+ if (wl.status === 0) return true;
166
+ const xclip = spawnSync("xclip", ["-selection", "clipboard"], { input: text, encoding: "utf8", windowsHide: true });
167
+ return xclip.status === 0;
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Automated Degrade Worktree Spawn (Decision Q1).
175
+ * Spawns an isolated git worktree for human operator intervention on key 'd'.
176
+ * Detaches the AI worker, copies directory path to clipboard, and prints jump command.
177
+ */
178
+ export function spawnDegradeWorktree(
179
+ repoRoot: string,
180
+ runId: string,
181
+ options?: {
182
+ description?: string | undefined;
183
+ execFn?: ((cmd: string, args: string[]) => { status: number; stdout: string; stderr: string }) | undefined;
184
+ clipboardFn?: ((text: string) => boolean) | undefined;
185
+ },
186
+ ): DegradeWorktreeResult {
187
+ const runner = options?.execFn ?? ((cmd: string, args: string[]) => {
188
+ const res = spawnSync(cmd, args, {
189
+ cwd: repoRoot,
190
+ encoding: "utf8",
191
+ windowsHide: true,
192
+ env: Object.fromEntries(
193
+ Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_")),
194
+ ),
195
+ });
196
+ return {
197
+ status: res.status ?? 1,
198
+ stdout: res.stdout || "",
199
+ stderr: res.stderr || "",
200
+ };
201
+ });
202
+
203
+ const cleanRunId = runId.replace(/^run_/, "");
204
+ const desc = options?.description ? slugifyBranchPart(options.description, 30) : "degrade";
205
+ const branchName = `kxm/run-${cleanRunId}-${desc}`;
206
+ const relativeWtPath = join(".kxm", "worktrees", `run-${cleanRunId}-${desc}`);
207
+ const absoluteWtPath = resolve(repoRoot, relativeWtPath);
208
+ const jumpCommand = `cd "${absoluteWtPath}"`;
209
+
210
+ try {
211
+ mkdirSync(dirname(absoluteWtPath), { recursive: true });
212
+
213
+ const branchCheck = runner("git", ["rev-parse", "--verify", `refs/heads/${branchName}`]);
214
+ let wtRes: { status: number; stdout: string; stderr: string };
215
+ if (branchCheck.status === 0) {
216
+ wtRes = runner("git", ["worktree", "add", absoluteWtPath, branchName]);
217
+ } else {
218
+ wtRes = runner("git", ["worktree", "add", "-b", branchName, absoluteWtPath]);
219
+ }
220
+
221
+ if (wtRes.status !== 0) {
222
+ return {
223
+ ok: false,
224
+ branchName,
225
+ worktreePath: absoluteWtPath,
226
+ jumpCommand,
227
+ copiedToClipboard: false,
228
+ error: `failed_to_add_worktree: ${wtRes.stderr.trim() || wtRes.stdout.trim()}`,
229
+ };
230
+ }
231
+
232
+ const clipFn = options?.clipboardFn ?? copyToClipboard;
233
+ const copied = clipFn(absoluteWtPath);
234
+ return {
235
+ ok: true,
236
+ branchName,
237
+ worktreePath: absoluteWtPath,
238
+ jumpCommand,
239
+ copiedToClipboard: copied,
240
+ };
241
+ } catch (err: unknown) {
242
+ return {
243
+ ok: false,
244
+ branchName,
245
+ worktreePath: absoluteWtPath,
246
+ jumpCommand,
247
+ copiedToClipboard: false,
248
+ error: `degrade_worktree_spawn_error: ${(err as Error).message}`,
249
+ };
250
+ }
251
+ }
252
+
141
253
  export interface MeshTuiSnapshot extends LocalMeshSnapshot {
142
254
  serverUrl: string;
143
255
  healthOk: boolean;
@@ -534,6 +646,12 @@ export class KxmDashboard implements Component {
534
646
  this.requestRender();
535
647
  }
536
648
 
649
+ setStatusMessage(message: string): void {
650
+ this.view = { ...this.view, statusMessage: message };
651
+ this.rebuild();
652
+ this.requestRender();
653
+ }
654
+
537
655
  private activeScroll(): ScrollView {
538
656
  return this.view.pane === "detail" ? this.detailScroll : this.listScroll;
539
657
  }
@@ -886,6 +1004,28 @@ export async function runMeshTui(input: {
886
1004
  body: JSON.stringify({ signalKey: "operator-approval", status: "failed", summary: "Rejected by operator in kxm dash" }),
887
1005
  signal: abort.signal,
888
1006
  });
1007
+ } else if (action.action === "degrade") {
1008
+ await input.fetchImpl(`${base}/v1/runs/${encodeURIComponent(targetId)}/signal?project=${encodeURIComponent(input.project)}`, {
1009
+ method: "POST",
1010
+ headers: headers(),
1011
+ body: JSON.stringify({ signalKey: "operator-degrade", status: "degraded", summary: "Degraded to operator worktree from kxm dash" }),
1012
+ signal: abort.signal,
1013
+ });
1014
+ const repoRoot = process.cwd();
1015
+ const degradeRes = spawnDegradeWorktree(repoRoot, targetId, {
1016
+ description: "operator-degrade",
1017
+ });
1018
+ if (dashboard) {
1019
+ if (degradeRes.ok) {
1020
+ dashboard.setStatusMessage(
1021
+ `[DEGRADE] Worktree: ${degradeRes.worktreePath} (cd command copied to clipboard)`,
1022
+ );
1023
+ } else {
1024
+ dashboard.setStatusMessage(
1025
+ `[DEGRADE] Signal sent, worktree failed: ${degradeRes.error}`,
1026
+ );
1027
+ }
1028
+ }
889
1029
  }
890
1030
  } catch {
891
1031
  // fail-soft on dashboard network errors during key press
@@ -4,30 +4,19 @@ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js";
7
- import { isAlias, isCollection, isMap, isScalar, parseDocument, visit } from "yaml";
7
+ import {
8
+ RestrictedYamlError,
9
+ VNEXT_YAML_LIMITS,
10
+ parseRestrictedYaml as parseRestrictedYamlShared,
11
+ type VnextYamlLimits,
12
+ } from "./restricted-yaml.mjs";
8
13
  import { resolveVnextTemplateBaseline } from "./vnext-template.ts";
9
14
  import { BUILTIN_HARNESS_IDS, DEFAULT_HARNESS, validateHarnessModelPair } from "./vnext-harness.ts";
10
15
 
11
16
  export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
12
17
  export type JsonObject = { [key: string]: JsonValue };
13
-
14
- export interface VnextYamlLimits {
15
- maxDocumentBytes: number;
16
- maxDepth: number;
17
- maxScalarBytes: number;
18
- maxCollectionItems: number;
19
- maxTotalNodes: number;
20
- maxKeys: number;
21
- }
22
-
23
- export const VNEXT_YAML_LIMITS: Readonly<VnextYamlLimits> = Object.freeze({
24
- maxDocumentBytes: 256 * 1024,
25
- maxDepth: 32,
26
- maxScalarBytes: 64 * 1024,
27
- maxCollectionItems: 4096,
28
- maxTotalNodes: 16_384,
29
- maxKeys: 8192,
30
- });
18
+ export type { VnextYamlLimits };
19
+ export { VNEXT_YAML_LIMITS };
31
20
 
32
21
  export type VnextValidationPhase = "discovery" | "parse" | "schema" | "path" | "reference" | "semantic";
33
22
 
@@ -120,16 +109,6 @@ const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
120
109
  /\bAKIA[A-Z0-9]{16}\b/,
121
110
  /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
122
111
  ];
123
- const ALLOWED_YAML_TAGS = new Set([
124
- "tag:yaml.org,2002:map",
125
- "tag:yaml.org,2002:seq",
126
- "tag:yaml.org,2002:str",
127
- "tag:yaml.org,2002:null",
128
- "tag:yaml.org,2002:bool",
129
- "tag:yaml.org,2002:int",
130
- "tag:yaml.org,2002:float",
131
- ]);
132
-
133
112
  function compareCodeUnits(left: string, right: string): number {
134
113
  return left < right ? -1 : left > right ? 1 : 0;
135
114
  }
@@ -149,97 +128,22 @@ function fail(phase: VnextValidationPhase, code: string, file: string, message:
149
128
  throw new VnextConfigError([issue(phase, code, file, message)]);
150
129
  }
151
130
 
152
- function decodeUtf8(input: string | Uint8Array, label: string): string {
153
- if (typeof input === "string") return input;
154
- try {
155
- return new TextDecoder("utf-8", { fatal: true }).decode(input);
156
- } catch {
157
- fail("parse", "invalid_utf8", label, "document is not valid UTF-8");
158
- }
159
- }
160
-
161
131
  function isJsonObject(value: JsonValue): value is JsonObject {
162
132
  return value !== null && typeof value === "object" && !Array.isArray(value);
163
133
  }
164
134
 
165
- function assertJsonValue(value: unknown, label: string, path = "$", seen = new Set<unknown>()): asserts value is JsonValue {
166
- if (value === null || typeof value === "string" || typeof value === "boolean") return;
167
- if (typeof value === "number") {
168
- if (!Number.isFinite(value)) fail("parse", "non_json_number", label, `${path} is not a finite JSON number`);
169
- return;
170
- }
171
- if (!value || typeof value !== "object") fail("parse", "non_json_value", label, `${path} is not JSON-compatible`);
172
- if (seen.has(value)) fail("parse", "cyclic_value", label, `${path} is cyclic`);
173
- seen.add(value);
174
- if (Array.isArray(value)) {
175
- for (const [index, candidate] of value.entries()) assertJsonValue(candidate, label, `${path}[${index}]`, seen);
176
- } else {
177
- const prototype = Object.getPrototypeOf(value) as unknown;
178
- if (prototype !== Object.prototype && prototype !== null) {
179
- fail("parse", "constructed_object", label, `${path} has a forbidden constructed type`);
180
- }
181
- for (const [key, candidate] of Object.entries(value)) assertJsonValue(candidate, label, `${path}.${key}`, seen);
182
- }
183
- seen.delete(value);
184
- }
185
-
186
135
  /** Parse the restricted, JSON-compatible YAML profile used by all vNext configuration. */
187
136
  export function parseRestrictedYaml(
188
137
  input: string | Uint8Array,
189
138
  label = "<yaml>",
190
139
  limits: Readonly<VnextYamlLimits> = VNEXT_YAML_LIMITS,
191
140
  ): JsonObject {
192
- const byteLength = typeof input === "string" ? Buffer.byteLength(input, "utf8") : input.byteLength;
193
- if (byteLength > limits.maxDocumentBytes) {
194
- fail("parse", "document_too_large", label, `document exceeds ${limits.maxDocumentBytes} bytes`);
195
- }
196
- const text = decodeUtf8(input, label);
197
- const document = parseDocument(text, {
198
- customTags: [],
199
- strict: true,
200
- uniqueKeys: true,
201
- });
202
- if (document.errors.length > 0) {
203
- fail("parse", "invalid_yaml", label, document.errors.map((error) => error.message).join("; "));
204
- }
205
- if (document.warnings.length > 0) {
206
- fail("parse", "yaml_warning", label, document.warnings.map((warning) => warning.message).join("; "));
207
- }
208
-
209
- let nodes = 0;
210
- let keys = 0;
211
- visit(document, (_key, node, path) => {
212
- nodes += 1;
213
- if (nodes > limits.maxTotalNodes) fail("parse", "node_limit", label, `document exceeds ${limits.maxTotalNodes} nodes`);
214
- if (path.length > limits.maxDepth) fail("parse", "depth_limit", label, `document exceeds nesting depth ${limits.maxDepth}`);
215
- if (isAlias(node)) fail("parse", "alias_forbidden", label, "aliases are forbidden");
216
- if (node && typeof node === "object" && "anchor" in node && typeof node.anchor === "string") {
217
- fail("parse", "anchor_forbidden", label, "anchors are forbidden");
218
- }
219
- if (isCollection(node) && node.items.length > limits.maxCollectionItems) {
220
- fail("parse", "collection_limit", label, `collection exceeds ${limits.maxCollectionItems} items`);
221
- }
222
- if (isMap(node)) {
223
- keys += node.items.length;
224
- if (keys > limits.maxKeys) fail("parse", "key_limit", label, `document exceeds ${limits.maxKeys} mapping keys`);
225
- for (const pair of node.items) {
226
- if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
227
- fail("parse", "non_string_key", label, "mapping keys must be strings");
228
- }
229
- }
230
- }
231
- if (isScalar(node) && typeof node.value === "string" && Buffer.byteLength(node.value, "utf8") > limits.maxScalarBytes) {
232
- fail("parse", "scalar_limit", label, `scalar exceeds ${limits.maxScalarBytes} bytes`);
233
- }
234
- if (node && typeof node === "object" && "tag" in node && typeof node.tag === "string" && !ALLOWED_YAML_TAGS.has(node.tag)) {
235
- fail("parse", "tag_forbidden", label, `tag ${node.tag} is forbidden`);
236
- }
237
- });
238
-
239
- const value = document.toJS({ maxAliasCount: 0 }) as unknown;
240
- assertJsonValue(value, label);
241
- if (!isJsonObject(value)) fail("parse", "root_not_object", label, "resource root must be a mapping");
242
- return value;
141
+ try {
142
+ return parseRestrictedYamlShared(input, label, limits);
143
+ } catch (error) {
144
+ if (error instanceof RestrictedYamlError) throw new VnextConfigError(error.issues);
145
+ throw error;
146
+ }
243
147
  }
244
148
 
245
149
  function readJsonObject(file: string): Record<string, unknown> {
@@ -570,6 +474,7 @@ function listNamedResources(
570
474
  continue;
571
475
  }
572
476
  const id = basename(entry.name, ".yaml");
477
+ if (kind === "model" && id === "inventory") continue;
573
478
  if (!resourceIdentifier(id)) {
574
479
  issues.push(issue("path", "resource_id_invalid", displayPath(root, join(directory, entry.name)), `filename-derived identity ${id} is invalid or platform-reserved`));
575
480
  continue;