@kontextmind/kxm 0.7.103 → 0.7.105

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 (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +39 -8
  3. package/docs/concepts/data-and-storage.md +1 -1
  4. package/docs/contributing/test-matrix.md +11 -6
  5. package/docs/operations/backup-and-restore.md +59 -27
  6. package/docs/operations/deploy.md +1 -1
  7. package/docs/reference/cli-reference.md +73 -55
  8. package/docs/reference/config-reference.md +1 -1
  9. package/docs/reference/workflow-definitions.md +4 -4
  10. package/docs/start/first-workflow.md +6 -2
  11. package/docs/start/quickstart-claude-code.md +11 -1
  12. package/docs/templates/runbook.md +2 -1
  13. package/package.json +1 -1
  14. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  15. package/plugins/kxm/dist/cli.js +1802 -1011
  16. package/plugins/kxm/dist/mcp-server.js +1 -1
  17. package/plugins/kxm/dist/runtime-supervisor.js +322 -292
  18. package/plugins/kxm/dist/runtime.js +427 -351
  19. package/plugins/kxm/dist/server.js +78 -78
  20. package/plugins/kxm/package.json +1 -1
  21. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +14 -7
  22. package/plugins/kxm/src/cli/project.ts +132 -30
  23. package/plugins/kxm/src/cli/system.ts +19 -1
  24. package/plugins/kxm/src/cli/tasks.ts +55 -31
  25. package/plugins/kxm/src/cli/workflows.ts +52 -54
  26. package/plugins/kxm/src/cli.ts +8 -6
  27. package/plugins/kxm/src/database.ts +193 -65
  28. package/plugins/kxm/src/engine.ts +79 -3
  29. package/plugins/kxm/src/harness.ts +6 -5
  30. package/plugins/kxm/src/mcp-server.ts +1 -1
  31. package/plugins/kxm/src/runtime-paths.ts +50 -0
  32. package/plugins/kxm/src/runtime-store.ts +22 -53
  33. package/plugins/kxm/src/runtime-supervisor.ts +38 -17
  34. package/plugins/kxm/src/suggest.ts +132 -79
  35. package/plugins/kxm/src/workflow-manager.ts +42 -19
  36. package/schemas/backup-manifest.schema.json +15 -0
@@ -1,34 +1,16 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { DatabaseSync, openReadOnlyDatabase } from "./sqlite.ts";
5
5
  import { KxmConfigError, validateCoordinator, validateDriveReceipt, validateIntakeMessage, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
6
- import { kxmUserStateRoot } from "./bindings.ts";
6
+ import { kxmProjectRunEventsPath, projectRuntimeKey } from "./runtime-paths.ts";
7
7
  import { deriveKxmSyncEvent, kxmSyncEventBytes, KxmSyncRedactor } from "./sync-transform.ts";
8
8
 
9
9
  /* ------------------------------------------------------------------ *
10
10
  * Runtime registry (per-user, platform state root)
11
11
  * ------------------------------------------------------------------ */
12
12
 
13
- export interface KxmRuntimePaths {
14
- stateRoot: string;
15
- runtimeDir: string;
16
- registryDb: string;
17
- projectsDir: string;
18
- }
19
-
20
- export function kxmRuntimePaths(options: { stateRoot?: string; env?: NodeJS.ProcessEnv; homeDir?: string } = {}): KxmRuntimePaths {
21
- const stateRoot = options.stateRoot
22
- ? resolve(options.stateRoot)
23
- : kxmUserStateRoot({ ...(options.env ? { env: options.env } : {}), ...(options.homeDir ? { homeDir: options.homeDir } : {}) });
24
- const runtimeDir = join(stateRoot, "runtime");
25
- return {
26
- stateRoot,
27
- runtimeDir,
28
- registryDb: join(runtimeDir, "registry.db"),
29
- projectsDir: join(runtimeDir, "projects"),
30
- };
31
- }
13
+ export { kxmRuntimePaths, projectRuntimeKey, kxmProjectRunEventsPath, type KxmRuntimePaths } from "./runtime-paths.ts";
32
14
 
33
15
  function runtimeIssue(phase: KxmConfigIssue["phase"], code: string, file: string, message: string): KxmConfigIssue {
34
16
  return { phase, code, file, message };
@@ -38,24 +20,6 @@ export function runtimeError(code: string, file: string, message: string): KxmCo
38
20
  return new KxmConfigError([runtimeIssue("semantic", code, file, message)]);
39
21
  }
40
22
 
41
- export function projectRuntimeKey(projectRoot: string): string {
42
- // Canonicalize through the filesystem like the repository binding store so
43
- // reaching a project through a link cannot mint a second key for it.
44
- let canonical: string;
45
- try {
46
- canonical = realpathSync.native(resolve(projectRoot));
47
- } catch {
48
- canonical = resolve(projectRoot);
49
- }
50
- const folded = process.platform === "win32" ? canonical.toLocaleLowerCase("en-US") : canonical;
51
- return createHash("sha256").update(folded, "utf8").digest("hex").slice(0, 24);
52
- }
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
23
  /**
60
24
  * Whether this project's Runtime event store holds `runId`. Hub workflow runs and Runtime
61
25
  * runs share the `run_<32 hex>` shape, so a command addressed by run id asks the owning
@@ -141,6 +105,24 @@ CREATE TABLE projects (
141
105
  ) STRICT;
142
106
  `;
143
107
 
108
+ /** The supervisor singleton row, from any connection to a registry, including a read-only one. */
109
+ export function readKxmSupervisorRecord(database: DatabaseSync): KxmSupervisorRecord | undefined {
110
+ const row = database.prepare("SELECT runtime_id, pid, port, token_hash, started_at, heartbeat_at, state FROM supervisor WHERE singleton_id = 1").get() as
111
+ | { runtime_id: string; pid: number; port: number; token_hash: string; started_at: string; heartbeat_at: string; state: KxmSupervisorRecord["state"] }
112
+ | undefined;
113
+ return row
114
+ ? {
115
+ runtimeId: row.runtime_id,
116
+ pid: row.pid,
117
+ port: row.port,
118
+ tokenHash: row.token_hash,
119
+ startedAt: row.started_at,
120
+ heartbeatAt: row.heartbeat_at,
121
+ state: row.state,
122
+ }
123
+ : undefined;
124
+ }
125
+
144
126
  export class KxmRuntimeRegistry {
145
127
  readonly path: string;
146
128
  private readonly database: DatabaseSync;
@@ -228,20 +210,7 @@ export class KxmRuntimeRegistry {
228
210
  }
229
211
 
230
212
  private readSupervisorRow(): KxmSupervisorRecord | undefined {
231
- const row = this.database.prepare("SELECT runtime_id, pid, port, token_hash, started_at, heartbeat_at, state FROM supervisor WHERE singleton_id = 1").get() as
232
- | { runtime_id: string; pid: number; port: number; token_hash: string; started_at: string; heartbeat_at: string; state: KxmSupervisorRecord["state"] }
233
- | undefined;
234
- return row
235
- ? {
236
- runtimeId: row.runtime_id,
237
- pid: row.pid,
238
- port: row.port,
239
- tokenHash: row.token_hash,
240
- startedAt: row.started_at,
241
- heartbeatAt: row.heartbeat_at,
242
- state: row.state,
243
- }
244
- : undefined;
213
+ return readKxmSupervisorRecord(this.database);
245
214
  }
246
215
 
247
216
  supervisor(): KxmSupervisorRecord | undefined {
@@ -8,12 +8,15 @@ import { loadKxmProject, KxmConfigError, type KxmConfigOptions } from "./project
8
8
  import {
9
9
  KxmRuntimeRegistry,
10
10
  projectRuntimeKey,
11
+ readKxmSupervisorRecord,
11
12
  runtimeError,
12
13
  verifyKxmDriveReceipt,
13
14
  kxmRuntimePaths,
14
15
  type KxmRunEventStore,
15
16
  type KxmRuntimePaths,
17
+ type KxmSupervisorRecord,
16
18
  } from "./runtime-store.ts";
19
+ import { openReadOnlyDatabase } from "./sqlite.ts";
17
20
  import {
18
21
  acceptKxmRun,
19
22
  cancelKxmRun,
@@ -122,26 +125,44 @@ function readRecentSupervisorError(paths: KxmRuntimePaths): string | undefined {
122
125
  }
123
126
  }
124
127
 
125
- export function kxmSupervisorStatus(paths: KxmRuntimePaths): KxmSupervisorStatus {
128
+ function supervisorStatusOf(record: KxmSupervisorRecord | undefined): KxmSupervisorStatus {
129
+ if (!record) return { running: false };
130
+ // Pid-only liveness is not enough: after a crash the pid may be reused by
131
+ // an unrelated process. A stale heartbeat is authoritative.
132
+ const heartbeatAgeMs = Date.now() - Date.parse(record.heartbeatAt);
133
+ const fresh = Number.isFinite(heartbeatAgeMs) && heartbeatAgeMs < HEARTBEAT_STALE_MS;
134
+ const alive = record.state === "running" && fresh && processAlive(record.pid);
135
+ return {
136
+ running: alive,
137
+ runtimeId: record.runtimeId,
138
+ pid: record.pid,
139
+ port: record.port,
140
+ state: alive ? record.state : "dead",
141
+ heartbeatAt: record.heartbeatAt,
142
+ startedAt: record.startedAt,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Is the supervisor up? `readOnly` gives the same answer without opening the
148
+ * registry for writing: the ordinary open takes the write lock and, as the last
149
+ * connection, checkpoints the WAL on close. `kxm restore` asks this way, dry run
150
+ * or not, because a project-scoped restore never otherwise touches the registry.
151
+ */
152
+ export function kxmSupervisorStatus(paths: KxmRuntimePaths, options: { readOnly?: boolean } = {}): KxmSupervisorStatus {
126
153
  if (!existsSync(paths.registryDb)) return { running: false };
154
+ if (options.readOnly) {
155
+ const database = openReadOnlyDatabase(paths.registryDb);
156
+ try {
157
+ database.exec("PRAGMA busy_timeout = 5000");
158
+ return supervisorStatusOf(readKxmSupervisorRecord(database));
159
+ } finally {
160
+ database.close();
161
+ }
162
+ }
127
163
  const registry = new KxmRuntimeRegistry(paths.registryDb);
128
164
  try {
129
- const record = registry.supervisor();
130
- if (!record) return { running: false };
131
- // Pid-only liveness is not enough: after a crash the pid may be reused by
132
- // an unrelated process. A stale heartbeat is authoritative.
133
- const heartbeatAgeMs = Date.now() - Date.parse(record.heartbeatAt);
134
- const fresh = Number.isFinite(heartbeatAgeMs) && heartbeatAgeMs < HEARTBEAT_STALE_MS;
135
- const alive = record.state === "running" && fresh && processAlive(record.pid);
136
- return {
137
- running: alive,
138
- runtimeId: record.runtimeId,
139
- pid: record.pid,
140
- port: record.port,
141
- state: alive ? record.state : "dead",
142
- heartbeatAt: record.heartbeatAt,
143
- startedAt: record.startedAt,
144
- };
165
+ return supervisorStatusOf(registry.supervisor());
145
166
  } finally {
146
167
  registry.close();
147
168
  }
@@ -1,156 +1,209 @@
1
+ import { BUILTIN_HARNESS_IDS, oneShotWriterArgs, type HarnessStatus } from "./harness.ts";
2
+ import { WORKFLOW_TEMPLATES } from "./workflow-manager.ts";
3
+
1
4
  export interface RoleSpec {
5
+ agent: string;
2
6
  harness: string;
3
- model: string;
4
7
  role: string;
5
8
  }
6
9
 
10
+ export type SuggestionExecution = {
11
+ supported: true;
12
+ prerequisites: string[];
13
+ shell: "powershell" | "posix";
14
+ createCommand: string;
15
+ driveCommand: string;
16
+ statusCommand: string;
17
+ receiptCommand: string;
18
+ } | {
19
+ supported: false;
20
+ error: "harness_unavailable" | "live_write_unsupported" | "workflow_already_exists";
21
+ reason: string;
22
+ nextSteps: string[];
23
+ };
24
+
7
25
  export interface SuggestionResult {
8
26
  workflowId: string;
27
+ template: string;
9
28
  area: string;
10
29
  confidence: number;
11
30
  reasons: string[];
12
31
  suggestedSkills: string[];
13
- roles: {
14
- planner: RoleSpec;
15
- writer: RoleSpec;
16
- critics: RoleSpec[];
17
- verifier: { kind: "witness"; command: string };
18
- };
32
+ roles: RoleSpec[];
33
+ /** Installs a definition; it does not create or execute a run. */
19
34
  suggestedCommand: string;
35
+ execution: SuggestionExecution;
20
36
  }
21
37
 
22
38
  interface WorkflowPattern {
23
39
  id: string;
40
+ template: string;
24
41
  area: string;
25
42
  keywords: string[];
26
43
  skills: string[];
27
- defaultCommand: (prompt: string) => string;
28
44
  }
29
45
 
30
46
  const WORKFLOW_PATTERNS: WorkflowPattern[] = [
31
47
  {
32
- id: "software-engineering/bug-fix",
48
+ id: "bug-fix",
49
+ template: "implement-and-verify",
33
50
  area: "software-engineering",
34
51
  keywords: ["fix", "bug", "flaky", "failure", "timeout", "error", "repro", "crash", "broken", "hang"],
35
52
  skills: ["kxm-workflow", "kxm-runs", "kxm-context-memory"],
36
- defaultCommand: (p) => `kxm run software-engineering/bug-fix "${p}"`,
37
53
  },
38
54
  {
39
- id: "software-engineering/feature-implementation",
55
+ id: "feature-implementation",
56
+ template: "implement-and-verify",
40
57
  area: "software-engineering",
41
58
  keywords: ["feature", "implement", "add", "build", "create", "develop", "support", "endpoint", "ui", "tui"],
42
59
  skills: ["kxm-workflow", "kxm-peer", "kxm-context-memory"],
43
- defaultCommand: (p) => `kxm run software-engineering/feature-implementation "${p}"`,
44
60
  },
45
61
  {
46
- id: "software-engineering/refactoring",
62
+ id: "refactoring",
63
+ template: "dual-critic-review",
47
64
  area: "software-engineering",
48
65
  keywords: ["refactor", "cleanup", "reorganize", "modularize", "deduplicate", "split", "simplify", "deprecate"],
49
66
  skills: ["kxm-workflow", "kxm-runs"],
50
- defaultCommand: (p) => `kxm run software-engineering/refactoring "${p}"`,
51
67
  },
52
68
  {
53
- id: "security-reliability/vulnerability-remediation",
69
+ id: "vulnerability-remediation",
70
+ template: "dual-critic-review",
54
71
  area: "security-reliability",
55
72
  keywords: ["cve", "vulnerability", "security", "exploit", "sanitize", "leak", "secret", "injection", "redact", "auth"],
56
73
  skills: ["kxm-workflow", "kxm-definitions"],
57
- defaultCommand: (p) => `kxm run security-reliability/vulnerability-remediation "${p}"`,
58
74
  },
59
75
  {
60
- id: "security-reliability/reliability-hardening",
76
+ id: "reliability-hardening",
77
+ template: "dual-critic-review",
61
78
  area: "security-reliability",
62
79
  keywords: ["idempotency", "retry", "circuit-breaker", "cas", "lock", "concurrency", "deadlock", "race", "crash-recovery"],
63
80
  skills: ["kxm-workflow", "kxm-peer"],
64
- defaultCommand: (p) => `kxm run security-reliability/reliability-hardening "${p}"`,
65
81
  },
66
82
  {
67
- id: "data-analytics/pipeline-migration",
83
+ id: "pipeline-migration",
84
+ template: "implement-and-verify",
68
85
  area: "data-analytics",
69
86
  keywords: ["database", "sqlite", "migration", "pipeline", "schema", "transform", "table", "wal", "foreign", "cascading"],
70
87
  skills: ["kxm-runs", "kxm-context-memory"],
71
- defaultCommand: (p) => `kxm run data-analytics/pipeline-migration "${p}"`,
72
88
  },
73
89
  {
74
- id: "research-strategy/architecture-spike",
90
+ id: "architecture-spike",
91
+ template: "spec-and-plan",
75
92
  area: "research-strategy",
76
93
  keywords: ["spike", "investigate", "prototype", "research", "feasibility", "benchmark", "explore", "evaluate"],
77
94
  skills: ["kxm-session", "kxm-context-memory", "kxm-routing-improve"],
78
- defaultCommand: (p) => `kxm run research-strategy/architecture-spike "${p}"`,
79
95
  },
80
96
  ];
81
97
 
98
+ type TemplateStep = { id: string; kind: string; agent?: string; repositories?: Record<string, string> };
99
+ type SuggestionHarness = Pick<HarnessStatus, "id" | "detected" | "authenticated" | "dispatch">;
100
+
82
101
  export function suggestWorkflowAndRoles(
83
102
  prompt: string,
84
- options: {
85
- availableHarnesses?: Array<{ harness: string; auth?: string | undefined }> | undefined;
86
- } = {},
103
+ options: { availableHarnesses?: readonly SuggestionHarness[] | undefined } = {},
87
104
  ): SuggestionResult {
88
105
  const normalized = prompt.toLowerCase();
89
106
  const words = normalized.split(/\W+/).filter(Boolean);
90
-
91
- let bestPattern: WorkflowPattern = WORKFLOW_PATTERNS[0]!;
92
- let bestScore = -1;
93
- const matchedReasons: string[] = [];
107
+ const claudeOnly = /\bclaude(?:\s+code)?(?:[\s-]+only|\s+exclusively)\b|\b(?:only|exclusively)(?:\s+(?:use|using|with|via))?\s+claude(?:\s+code)?\b/i.test(prompt);
108
+ let bestPattern: WorkflowPattern = WORKFLOW_PATTERNS[1]!;
109
+ let bestScore = 0;
110
+ const reasons: string[] = [];
94
111
 
95
112
  for (const pattern of WORKFLOW_PATTERNS) {
96
- let score = 0;
97
- const matchedWords: string[] = [];
98
- for (const keyword of pattern.keywords) {
99
- if (words.includes(keyword) || normalized.includes(keyword)) {
100
- score += 1;
101
- matchedWords.push(keyword);
102
- }
103
- }
104
- if (score > bestScore) {
105
- bestScore = score;
113
+ const matchedWords = pattern.keywords.filter((keyword) => words.includes(keyword) || normalized.includes(keyword));
114
+ if (matchedWords.length > bestScore) {
115
+ bestScore = matchedWords.length;
106
116
  bestPattern = pattern;
107
- matchedReasons.length = 0;
108
- matchedReasons.push(`Matched keywords: ${matchedWords.join(", ")}`);
117
+ reasons.length = 0;
118
+ reasons.push(`Matched keywords: ${matchedWords.join(", ")}`);
109
119
  }
110
120
  }
121
+ if (bestScore === 0) reasons.push("Default fallback: general feature implementation workflow");
122
+ if (claudeOnly) reasons.push("Explicit Claude-only constraint; other harnesses are excluded");
111
123
 
112
- if (bestScore <= 0) {
113
- bestPattern = WORKFLOW_PATTERNS[1]!;
114
- matchedReasons.push("Default fallback: general feature implementation workflow");
115
- }
116
-
117
- const activeHarnesses = new Set(
118
- (options.availableHarnesses ?? [])
119
- .filter((h) => h.auth === "active" || h.auth === "ready" || h.auth === "configured")
120
- .map((h) => h.harness.toLowerCase()),
121
- );
122
-
123
- const hasClaude = activeHarnesses.size === 0 || activeHarnesses.has("claude");
124
- const hasGrok = activeHarnesses.size === 0 || activeHarnesses.has("grok");
125
- const hasCodex = activeHarnesses.size === 0 || activeHarnesses.has("codex");
126
-
127
- const planner: RoleSpec = hasClaude
128
- ? { harness: "claude", model: "fable", role: "planner" }
129
- : { harness: "codex", model: "gpt-5.6-sol", role: "planner" };
130
-
131
- const writer: RoleSpec = hasGrok
132
- ? { harness: "grok", model: "grok-4.6", role: "writer" }
133
- : (activeHarnesses.has("agy")
134
- ? { harness: "agy", model: "gemini-2.5-pro", role: "writer" }
135
- : { harness: "pi", model: "qwen/qwen3-coder-plus", role: "writer" });
136
-
137
- const critics: RoleSpec[] = [
138
- { harness: "claude", model: "fable", role: "reviewer-arch" },
139
- { harness: "codex", model: "gpt-5.6-sol", role: "reviewer-cli" },
140
- ];
141
-
142
- return {
124
+ const template = WORKFLOW_TEMPLATES[bestPattern.template]!;
125
+ const steps = template.steps as TemplateStep[];
126
+ const base = {
143
127
  workflowId: bestPattern.id,
128
+ template: bestPattern.template,
144
129
  area: bestPattern.area,
145
130
  confidence: bestScore > 0 ? Math.min(1, 0.5 + bestScore * 0.15) : 0.5,
146
- reasons: matchedReasons,
131
+ reasons,
147
132
  suggestedSkills: bestPattern.skills,
148
- roles: {
149
- planner,
150
- writer,
151
- critics,
152
- verifier: { kind: "witness", command: "npm run verify" },
133
+ suggestedCommand: `kxm workflow add ${bestPattern.id} --template ${bestPattern.template}`,
134
+ };
135
+ const writeStep = steps.find((step) => step.kind === "agent" && Object.values(step.repositories ?? {}).includes("write"));
136
+
137
+ const available = (options.availableHarnesses ?? []).filter((entry) =>
138
+ BUILTIN_HARNESS_IDS.includes(entry.id) && entry.detected && entry.authenticated === true
139
+ && entry.dispatch?.supported === true && entry.dispatch.status === "yes"
140
+ && (!claudeOnly || entry.id === "claude"));
141
+ const selected = available.find((entry) => entry.id === "claude") ?? available[0];
142
+ if (!selected) {
143
+ const relevant = (options.availableHarnesses ?? []).filter((entry) => !claudeOnly || entry.id === "claude");
144
+ const detail = relevant.map((entry) => `${entry.id}: ${entry.dispatch?.reason ?? (!entry.detected ? "not_detected" : entry.authenticated !== true ? "authentication_unverified" : "dispatch_unavailable")}`).join("; ");
145
+ return {
146
+ ...base,
147
+ roles: [],
148
+ execution: {
149
+ supported: false,
150
+ error: "harness_unavailable",
151
+ reason: `${claudeOnly ? "Claude-only execution requires Claude Code" : "Execution requires a supported harness"} detected, authenticated, and ready for dispatch. ${detail || "No verified harness inventory is available."}`,
152
+ nextSteps: claudeOnly
153
+ ? ["Install Claude Code if missing, authenticate with claude auth login, then confirm claude auth status and kxm harness list. Other harnesses will not be substituted."]
154
+ : ["Install and authenticate a supported harness, then run kxm harness list to check detection, authentication, and dispatch readiness. Pi requires a configured model/provider auth context."],
155
+ },
156
+ };
157
+ }
158
+
159
+ if (writeStep && !oneShotWriterArgs(selected.id)) {
160
+ return {
161
+ ...base,
162
+ roles: [],
163
+ execution: {
164
+ supported: false,
165
+ error: "live_write_unsupported",
166
+ reason: `${bestPattern.template} requires repository writes at step ${writeStep.id}, but the selected ${selected.id} harness has no audited live writer profile. No other harness will be substituted.`,
167
+ nextSteps: [
168
+ `Run the implementation directly in ${claudeOnly ? "Claude Code" : selected.id} in the target worktree, and run the repository's actual verification command.`,
169
+ `For read-only KXM planning, try kxm suggest "Investigate an architecture spike${claudeOnly ? " using Claude only" : ""}"; the shipped spec-and-plan template does not implement changes.`,
170
+ "Installing a definition or creating a run does not execute it. Simulation is not evidence of a bug fix or workflow completion.",
171
+ ],
172
+ },
173
+ };
174
+ }
175
+
176
+ const roles: RoleSpec[] = [];
177
+ for (const step of steps) {
178
+ if (!step.agent) continue;
179
+ const role = roles.find((entry) => entry.agent === step.agent);
180
+ if (role) role.role += `, ${step.id}`;
181
+ else roles.push({ agent: step.agent, harness: selected.id, role: step.id });
182
+ }
183
+ const shell = process.platform === "win32" ? "powershell" : "posix";
184
+ const quotedPrompt = shell === "powershell"
185
+ ? `'${prompt.replace(/['\u2018-\u201b]/g, "$&$&")}'`
186
+ : `'${prompt.replace(/'/g, "'\\''")}'`;
187
+ return {
188
+ ...base,
189
+ roles,
190
+ execution: {
191
+ supported: true,
192
+ prerequisites: [
193
+ "Run kxm init in the target repository if needed, then install the exact template with the suggested command. If that workflow ID already exists, stop and review it; the commands below apply only to a newly installed template, not an existing definition with potentially different agents or permissions.",
194
+ ...roles.map((role) => `Set harness: ${role.harness} and model: <your authenticated compatible model selector> in .kxm/agents/${role.agent}.yaml. Admit that exact selector in .kxm/routes.yaml and ensure it is not disabled; this recommendation does not choose a model or change routing.`),
195
+ ...(writeStep ? [
196
+ `Keep each writer step at assignments.maximum: 1 and set limits.maxConcurrentRuns: 1 in .kxm/project.yaml; the live ${selected.id} writer profile requires a lone writer in the checkout.`,
197
+ `If .kxm/roster.yaml exists, it must be readable kxm.developer-roster.v1 with a lineup.writer route matching harness: ${selected.id} and the selected writer model (model or vendor/model), status: admitted, and permissions containing edit.`,
198
+ "Configure the test gate in .kxm/gates.yaml with kind: command and argv for this repository's actual verification command. A scaffold/example command or simulation is not evidence that the implementation works.",
199
+ ] : []),
200
+ `Validate the installed workflow with kxm gate validate --file .kxm/workflows/${bestPattern.id}.yaml and the project configuration with kxm init --dry-run before creating a run.`,
201
+ ],
202
+ shell,
203
+ createCommand: `kxm run ${bestPattern.id} -- ${quotedPrompt}`,
204
+ driveCommand: "kxm runs drive <runId> --wait",
205
+ statusCommand: "kxm runs status <runId>",
206
+ receiptCommand: "kxm runs receipt <runId>",
153
207
  },
154
- suggestedCommand: bestPattern.defaultCommand(prompt.replace(/"/g, '\\"')),
155
208
  };
156
209
  }
@@ -5,9 +5,28 @@
5
5
  */
6
6
 
7
7
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
- import { dirname, join, resolve } from "node:path";
9
- import { parse, stringify } from "yaml";
8
+ import { join } from "node:path";
9
+ import { stringify } from "yaml";
10
10
  import { repoConfigDirectory, userConfigDirectory } from "./config.ts";
11
+ import { compileKxmWorkflow } from "./engine-compile.ts";
12
+ import { KxmConfigError, KxmSchemaRegistry, kxmResourceIdentifier, parseRestrictedYaml } from "./project-config.ts";
13
+
14
+ function assertWorkflowId(workflowId: string): void {
15
+ if (!kxmResourceIdentifier(workflowId)) {
16
+ throw new Error(`workflow_id_invalid: '${workflowId}' must be a flat lowercase slug of at most 64 characters, starting with a letter and using letters, digits, single hyphens or underscores; paths and platform-reserved names are not allowed (use 'bug-fix', not 'software-engineering/bug-fix')`);
17
+ }
18
+ }
19
+
20
+ let workflowSchemaRegistry: KxmSchemaRegistry | undefined;
21
+
22
+ function workflowPayload(workflowId: string, content: Record<string, unknown> | string, filePath: string): string {
23
+ const payload = typeof content === "string" ? content : stringify(content);
24
+ const value = parseRestrictedYaml(payload, filePath);
25
+ const issues = (workflowSchemaRegistry ??= new KxmSchemaRegistry()).validate("workflow", value, filePath);
26
+ if (issues.length > 0) throw new KxmConfigError(issues);
27
+ compileKxmWorkflow({ id: workflowId, value, logicalPath: filePath });
28
+ return payload;
29
+ }
11
30
 
12
31
  export interface WorkflowDefSummary {
13
32
  id: string;
@@ -197,9 +216,7 @@ export function parseWorkflowFile(filePath: string): Record<string, unknown> | u
197
216
  if (!existsSync(filePath)) return undefined;
198
217
  try {
199
218
  const raw = readFileSync(filePath, "utf8");
200
- const parsed = parse(raw) as Record<string, unknown>;
201
- if (!parsed || typeof parsed !== "object") return undefined;
202
- return parsed;
219
+ return parseRestrictedYaml(raw, filePath);
203
220
  } catch {
204
221
  return undefined;
205
222
  }
@@ -218,11 +235,11 @@ export function listWorkflowDefinitions(options: {
218
235
  const localDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
219
236
  if (scopeFilter !== "global" && existsSync(localDir)) {
220
237
  for (const entry of readdirSync(localDir)) {
221
- if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
238
+ const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined;
239
+ if (id && kxmResourceIdentifier(id)) {
222
240
  const filePath = join(localDir, entry);
223
241
  const def = parseWorkflowFile(filePath);
224
242
  if (def) {
225
- const id = (def.id as string) || entry.replace(/\.ya?ml$/i, "");
226
243
  localDefs.set(id, { def, filePath });
227
244
  }
228
245
  }
@@ -232,11 +249,11 @@ export function listWorkflowDefinitions(options: {
232
249
  const globalDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
233
250
  if (scopeFilter !== "local" && existsSync(globalDir)) {
234
251
  for (const entry of readdirSync(globalDir)) {
235
- if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
252
+ const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined;
253
+ if (id && kxmResourceIdentifier(id)) {
236
254
  const filePath = join(globalDir, entry);
237
255
  const def = parseWorkflowFile(filePath);
238
256
  if (def) {
239
- const id = (def.id as string) || entry.replace(/\.ya?ml$/i, "");
240
257
  globalDefs.set(id, { def, filePath });
241
258
  }
242
259
  }
@@ -248,7 +265,7 @@ export function listWorkflowDefinitions(options: {
248
265
  const steps = Array.isArray(def.steps) ? def.steps : [];
249
266
  for (const step of steps) {
250
267
  if (step && typeof step === "object") {
251
- const role = (step as Record<string, unknown>).role || (step as Record<string, unknown>).agent;
268
+ const role = (step as Record<string, unknown>).agent;
252
269
  if (typeof role === "string" && !roles.includes(role)) {
253
270
  roles.push(role);
254
271
  }
@@ -298,6 +315,7 @@ export function getWorkflowDefinition(
298
315
  userConfigDir?: string | undefined;
299
316
  } = {},
300
317
  ): { workflow: Record<string, unknown>; scope: "global" | "local"; filePath: string } | undefined {
318
+ assertWorkflowId(workflowId);
301
319
  const scope = options.scope ?? "all";
302
320
  const repoRoot = options.repoRoot ?? process.cwd();
303
321
 
@@ -331,19 +349,21 @@ export function addWorkflowDefinition(
331
349
  dryRun?: boolean | undefined;
332
350
  } = {},
333
351
  ): { id: string; filePath: string; scope: "global" | "local" } {
352
+ assertWorkflowId(workflowId);
334
353
  const scope = options.scope ?? "local";
335
354
  const repoRoot = options.repoRoot ?? process.cwd();
336
- const dir = options.dryRun
337
- ? workflowsDirectory(scope, repoRoot, options.userConfigDir)
338
- : ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
355
+ const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
339
356
  const filePath = join(dir, `${workflowId}.yaml`);
340
357
 
341
358
  if (existsSync(filePath) && !options.overwrite) {
342
359
  throw new Error(`workflow_already_exists: workflow '${workflowId}' already exists at ${filePath}`);
343
360
  }
344
361
 
345
- const payload = typeof content === "string" ? content : stringify(content);
346
- if (!options.dryRun) writeFileSync(filePath, payload, "utf8");
362
+ const payload = workflowPayload(workflowId, content, filePath);
363
+ if (!options.dryRun) {
364
+ ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
365
+ writeFileSync(filePath, payload, "utf8");
366
+ }
347
367
  return { id: workflowId, filePath, scope };
348
368
  }
349
369
 
@@ -356,6 +376,7 @@ export function removeWorkflowDefinition(
356
376
  dryRun?: boolean | undefined;
357
377
  } = {},
358
378
  ): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
379
+ assertWorkflowId(workflowId);
359
380
  const scope = options.scope ?? "local";
360
381
  const repoRoot = options.repoRoot ?? process.cwd();
361
382
  const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
@@ -392,11 +413,13 @@ export function modifyWorkflowDefinition(
392
413
 
393
414
  const scope = options.scope ?? target.scope;
394
415
  const repoRoot = options.repoRoot ?? process.cwd();
395
- const dir = options.dryRun
396
- ? workflowsDirectory(scope, repoRoot, options.userConfigDir)
397
- : ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
416
+ const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
398
417
  const filePath = join(dir, `${workflowId}.yaml`);
399
418
 
400
- if (!options.dryRun) writeFileSync(filePath, stringify(updated), "utf8");
419
+ const payload = workflowPayload(workflowId, updated, filePath);
420
+ if (!options.dryRun) {
421
+ ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
422
+ writeFileSync(filePath, payload, "utf8");
423
+ }
401
424
  return { id: workflowId, workflow: updated, filePath, scope };
402
425
  }
@@ -23,6 +23,21 @@
23
23
  "type": "string",
24
24
  "maxLength": 1024
25
25
  },
26
+ "scope": {
27
+ "description": "project: the checkout's stores and its own Runtime event store. all-projects: also the shared Runtime registry and every project's event store. Absent on manifests written before backups were scoped.",
28
+ "enum": ["project", "all-projects"]
29
+ },
30
+ "stateRoot": {
31
+ "description": "The user state root the Runtime stores were copied from.",
32
+ "type": "string",
33
+ "minLength": 1,
34
+ "maxLength": 1024
35
+ },
36
+ "runtimeProjectKey": {
37
+ "description": "The Runtime store key of the checkout the backup ran from.",
38
+ "type": "string",
39
+ "pattern": "^[0-9a-f]{24}$"
40
+ },
26
41
  "stores": {
27
42
  "type": "array",
28
43
  "items": {