@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,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
- import { createBackup, planBackup, planRestore, restoreBackup } from "../database.ts";
6
+ import { applyRestorePlan, createBackup, databaseError, planBackup, planRestore, type RestorePlan } from "../database.ts";
7
+ import { readLiveHubClaim } from "../hub-autostart.ts";
7
8
  import { refreshModelInventory } from "../model-inventory.ts";
8
9
  import { listInventoryModels, listRoleBindings, loadRoutePolicy, setRouteState, updateRouteState } from "../routes.ts";
9
10
  import {
@@ -15,6 +16,7 @@ import { initializeKxmProject } from "../init.ts";
15
16
  import { diffKxmProjectAgainstRevision, formatKxmPermissionDiff } from "../permission.ts";
16
17
  import { readKxmLocalBindings, kxmUserStateRoot } from "../bindings.ts";
17
18
  import { loadKxmProject } from "../project-config.ts";
19
+ import { kxmLiveRunPrerequisites, type KxmRunHandoff } from "../engine.ts";
18
20
  import {
19
21
  attachKxmSupervisor,
20
22
  ensureKxmSupervisor,
@@ -114,6 +116,13 @@ export async function cmdKxmInit(
114
116
  localStateRoot: kxmUserStateRoot({ env: runtime.env }),
115
117
  dryRun: runtime.dryRun,
116
118
  });
119
+ const starterGuidance = initialized.action === "created" || (initialized.action === "planned" && initialized.plan.mode === "create")
120
+ ? [
121
+ "defaultHarness: pi and the npm test gate are generic starter settings, not repository detection.",
122
+ "For Claude, set defaultHarness: claude in .kxm/project.yaml, update any explicit harness overrides in .kxm/agents/*.yaml, and configure compatible agent models.",
123
+ "For .NET or other non-npm repositories, set gates.test.argv in .kxm/gates.yaml to the repository's actual test runner before driving a workflow.",
124
+ ]
125
+ : [];
117
126
  const payload = {
118
127
  ok: initialized.action !== "planned" || runtime.dryRun,
119
128
  command: "init",
@@ -127,9 +136,10 @@ export async function cmdKxmInit(
127
136
  ...(initialized.resumePending === undefined ? {} : { resumePending: initialized.resumePending }),
128
137
  ...(initialized.transactionKind === undefined ? {} : { transactionKind: initialized.transactionKind }),
129
138
  plannedOnly: initialized.action === "planned",
139
+ ...(starterGuidance.length > 0 ? { guidance: starterGuidance } : {}),
130
140
  };
131
141
  const finishInit = (code: number, text: string): number => {
132
- print(runtime.io, runtime.json, payload, text);
142
+ print(runtime.io, runtime.json, payload, [text, ...starterGuidance].join("\n"));
133
143
  return code;
134
144
  };
135
145
  if (initialized.action === "created") {
@@ -181,23 +191,30 @@ export async function cmdKxmInit(
181
191
  }
182
192
  }
183
193
 
184
- export async function cmdBackup(runtime: Runtime, options: { out?: string | undefined }): Promise<number> {
194
+ /** The checkout backup and restore act for, found as the Runtime finds it, so the
195
+ * project's event store key is the one the Runtime derives. */
196
+ function backupProjectRoot(runtime: Runtime): string {
197
+ return discoverKxmProjectRoot(runtime.cwd) ?? runtime.cwd;
198
+ }
199
+
200
+ export async function cmdBackup(runtime: Runtime, options: { out?: string | undefined; allProjects?: boolean | undefined }): Promise<number> {
185
201
  try {
186
202
  const backupOptions = {
187
- projectRoot: runtime.cwd,
203
+ projectRoot: backupProjectRoot(runtime),
188
204
  env: runtime.env,
205
+ ...(options.allProjects === true ? { allProjects: true } : {}),
189
206
  ...(options.out ? { outDir: resolve(runtime.cwd, options.out) } : {}),
190
207
  };
191
208
  if (runtime.dryRun) {
192
209
  const plan = planBackup(backupOptions);
193
210
  printPlan(
194
211
  runtime,
195
- { command: "backup", outDir: plan.outDir, stores: plan.stores, files: plan.files },
212
+ { command: "backup", scope: plan.scope, outDir: plan.outDir, stores: plan.stores, files: plan.files },
196
213
  [
197
214
  ...[...plan.stores, ...plan.files].map((entry) => ({ action: "write" as const, target: join(plan.outDir, entry.backupFile) })),
198
215
  { action: "write", target: join(plan.outDir, "manifest.json") },
199
216
  ],
200
- `back up ${plan.stores.length} store(s) and ${plan.files.length} file(s) to ${plan.outDir} (sources are not opened, so their WAL is not checkpointed)`,
217
+ `back up ${plan.stores.length} store(s) and ${plan.files.length} file(s) (${plan.scope} scope) to ${plan.outDir} (sources are not opened, so their WAL is not checkpointed)`,
201
218
  );
202
219
  return 0;
203
220
  }
@@ -212,7 +229,7 @@ export async function cmdBackup(runtime: Runtime, options: { out?: string | unde
212
229
  };
213
230
  const summary = [
214
231
  complete
215
- ? `Created SQLite backup with ${manifest.stores.length} store(s):`
232
+ ? `Created SQLite backup with ${manifest.stores.length} store(s) (${manifest.scope ?? "project"} scope):`
216
233
  : `Backup is incomplete (${manifest.omitted?.length ?? 0} omitted); not ok:`,
217
234
  ...manifest.stores.map((s) => ` - ${s.storeId}: ${s.sourcePath} -> ${s.backupFile} (schema v${s.schemaVersion}, ${s.bytes} bytes, sha256 ${s.sha256.slice(0, 12)}...)`),
218
235
  ...(manifest.omitted ?? []).map((id) => ` - omitted ${id}`),
@@ -230,16 +247,63 @@ export async function cmdBackup(runtime: Runtime, options: { out?: string | unde
230
247
  }
231
248
  }
232
249
 
233
- export async function cmdRestore(runtime: Runtime, manifestArg: string): Promise<number> {
250
+ /**
251
+ * Refuse while anything holds the stores a restore would overwrite. Replacing a
252
+ * SQLite file under an open connection loses the writer's next commit or corrupts
253
+ * the store, and the supervisor holds the registry and every project's event store
254
+ * open. Reads only: the registry is opened read-only and the hub claim is a file,
255
+ * so the check is the same under --dry-run.
256
+ */
257
+ function assertRestoreTargetsStopped(runtime: Runtime, plan: RestorePlan): void {
258
+ const paths = kxmRuntimePaths({ env: runtime.env });
259
+ let supervisor: ReturnType<typeof kxmSupervisorStatus>;
234
260
  try {
261
+ supervisor = kxmSupervisorStatus(paths, { readOnly: true });
262
+ } catch (error) {
263
+ // Fail closed, but name the way out: a registry too broken to read is also one a
264
+ // restore may be meant to replace.
265
+ throw databaseError(
266
+ "restore_runtime_unverified",
267
+ paths.registryDb,
268
+ `cannot read the Runtime registry to check whether the supervisor is running (${error instanceof Error ? error.message : String(error)}); stop the Runtime, move the registry aside, and restore again`,
269
+ );
270
+ }
271
+ if (supervisor.running) {
272
+ throw databaseError(
273
+ "restore_runtime_running",
274
+ paths.registryDb,
275
+ `the Runtime supervisor is running (pid ${String(supervisor.pid)}); stop it with \`kxm runtime stop\` and keep it stopped until the restore finishes`,
276
+ );
277
+ }
278
+ for (const store of plan.stores) {
279
+ if (store.storeId !== "hub-store") continue;
280
+ const claim = readLiveHubClaim(dirname(store.targetPath));
281
+ if (claim) {
282
+ throw databaseError(
283
+ "restore_hub_running",
284
+ store.targetPath,
285
+ `a hub (pid ${String(claim.pid)}) is running on ${store.targetPath}; stop it with \`kxm hub stop\` before restoring`,
286
+ );
287
+ }
288
+ }
289
+ }
290
+
291
+ export async function cmdRestore(runtime: Runtime, manifestArg: string, options: { allProjects?: boolean | undefined } = {}): Promise<number> {
292
+ try {
293
+ const plan = planRestore(resolve(runtime.cwd, manifestArg), {
294
+ projectRoot: backupProjectRoot(runtime),
295
+ env: runtime.env,
296
+ ...(options.allProjects === true ? { allProjects: true } : {}),
297
+ });
298
+ assertRestoreTargetsStopped(runtime, plan);
235
299
  if (runtime.dryRun) {
236
- const plan = planRestore(resolve(runtime.cwd, manifestArg), { projectRoot: runtime.cwd });
237
300
  printPlan(
238
301
  runtime,
239
302
  {
240
303
  command: "restore",
241
304
  backupId: plan.backupId,
242
305
  manifestPath: plan.manifestPath,
306
+ ...(plan.scope !== undefined ? { scope: plan.scope } : {}),
243
307
  stores: plan.stores.map(({ storeId, targetPath, schemaVersion }) => ({ storeId, targetPath, schemaVersion })),
244
308
  files: plan.files.map(({ id, targetPath }) => ({ id, targetPath })),
245
309
  },
@@ -256,14 +320,13 @@ export async function cmdRestore(runtime: Runtime, manifestArg: string): Promise
256
320
  );
257
321
  return 0;
258
322
  }
259
- const result = restoreBackup(resolve(runtime.cwd, manifestArg), {
260
- projectRoot: runtime.cwd,
261
- });
323
+ const result = applyRestorePlan(plan);
262
324
  const payload = {
263
325
  ok: true,
264
326
  command: "restore",
265
327
  backupId: result.backupId,
266
328
  manifestPath: result.manifestPath,
329
+ ...(plan.scope !== undefined ? { scope: plan.scope } : {}),
267
330
  restoredStores: result.restoredStores,
268
331
  };
269
332
  const summary = [
@@ -334,11 +397,16 @@ async function readSupervisor(runtime: Runtime, command: string): Promise<Awaite
334
397
  return attached ?? refuseDryRun(runtime.io, runtime.json, command, "the Runtime supervisor is not running and --dry-run will not start it");
335
398
  }
336
399
 
337
- export const RUN_ENGINE_PHASE = "pre-3a";
338
-
339
- /** The next step `kxm run` prints for the run it just created. */
340
- export function runEngineNotice(runId: string): string {
341
- return `drive it model-free: kxm runs drive ${runId} --simulated --wait (or cancel: kxm runs cancel ${runId})`;
400
+ /** Creation is not execution; local runs are driven and inspected in the runs namespace. */
401
+ export function runEngineNotice(runId: string, defaultHarness: string, prerequisites: readonly KxmRunHandoff[]): string {
402
+ return [
403
+ `No steps executed. Project default harness: ${defaultHarness}; per-agent harness settings take precedence.`,
404
+ ...prerequisites.map((item) => `Live prerequisite${item.stepId ? ` (${item.stepId})` : ""}: ${item.detail}`),
405
+ "Live execution uses one-shot harness calls; no hub or Pi worker is required. Check installation/authentication with kxm harness list.",
406
+ `${prerequisites.length > 0 ? "Resolve the prerequisites above, then execute" : "Execute"}: kxm runs drive ${runId} --wait`,
407
+ `Inspect: kxm runs status ${runId} --json; receipt: kxm runs receipt ${runId} --json; cancel: kxm runs cancel ${runId}`,
408
+ "These are local Runtime runs, not webhook workflows; use kxm runs, not kxm workflow get.",
409
+ ].join("\n");
342
410
  }
343
411
 
344
412
  /** Where `kxm run <workflow>` would create its run, or the exit code of the
@@ -346,7 +414,8 @@ export function runEngineNotice(runId: string): string {
346
414
  export function resolveKxmRunTarget(
347
415
  runtime: Runtime,
348
416
  workflow: string | undefined,
349
- ): { projectRoot: string; workflowId: string; configRevision: string } | number {
417
+ forTask = false,
418
+ ): { projectRoot: string; workflowId: string; configRevision: string; defaultHarness: string; prerequisites: KxmRunHandoff[] } | number {
350
419
  if (runtime.workspaceFlag !== undefined) {
351
420
  print(runtime.io, runtime.json, {
352
421
  ok: false,
@@ -355,7 +424,7 @@ export function resolveKxmRunTarget(
355
424
  }, "kxm run discovers the authoritative project from the current directory; --workspace is not supported");
356
425
  return 2;
357
426
  }
358
- if (!workflow) {
427
+ if (!workflow && !forTask) {
359
428
  print(runtime.io, runtime.json, { ok: false, command: "run", error: "workflow_required" }, "usage: kxm run <workflow> [prompt]");
360
429
  return 2;
361
430
  }
@@ -365,16 +434,26 @@ export function resolveKxmRunTarget(
365
434
  return 1;
366
435
  }
367
436
  const bundle = loadKxmProject(projectRoot, {});
368
- if (!bundle.workflows.has(workflow)) {
369
- print(runtime.io, runtime.json, { ok: false, command: "run", error: "run_workflow_unknown", workflow }, `workflow ${workflow} does not exist in this project`);
437
+ const workflowId = workflow ?? String(bundle.project.value.defaultWorkflow ?? "default");
438
+ if (!bundle.workflows.has(workflowId)) {
439
+ print(runtime.io, runtime.json, { ok: false, command: "run", error: "run_workflow_unknown", workflow: workflowId }, `workflow ${workflowId} does not exist in this project`);
440
+ return 1;
441
+ }
442
+ const defaultHarness = String(bundle.project.value.defaultHarness ?? "pi");
443
+ const prerequisites = kxmLiveRunPrerequisites(bundle, workflowId, projectRoot);
444
+ if (forTask && prerequisites.length > 0) {
445
+ print(runtime.io, runtime.json, {
446
+ ok: false, command: "task run", error: "run_execution_unavailable", workflowId, defaultHarness,
447
+ execution: { status: "not_started", mode: "live", prerequisites },
448
+ }, `task run refused; no run created or task changed (default harness: ${defaultHarness}).\n${prerequisites.map((item) => `${item.stepId ?? workflowId}: ${item.detail}`).join("\n")}`);
370
449
  return 1;
371
450
  }
372
- return { projectRoot, workflowId: workflow, configRevision: bundle.configRevision };
451
+ return { projectRoot, workflowId, configRevision: bundle.configRevision, defaultHarness, prerequisites };
373
452
  }
374
453
 
375
- export async function cmdKxmRun(runtime: Runtime, workflow: string | undefined, promptParts: string[]): Promise<number> {
454
+ export async function cmdKxmRun(runtime: Runtime, workflow: string | undefined, promptParts: string[], forTask = false): Promise<number> {
376
455
  try {
377
- const target = resolveKxmRunTarget(runtime, workflow);
456
+ const target = resolveKxmRunTarget(runtime, workflow, forTask);
378
457
  if (typeof target === "number") return target;
379
458
  const { projectRoot } = target;
380
459
  if (runtime.dryRun) {
@@ -397,11 +476,23 @@ export async function cmdKxmRun(runtime: Runtime, workflow: string | undefined,
397
476
  print(runtime.io, runtime.json, {
398
477
  ok: true,
399
478
  command: "run",
400
- phase: RUN_ENGINE_PHASE,
479
+ execution: {
480
+ status: "not_started",
481
+ mode: "live",
482
+ defaultHarness: target.defaultHarness,
483
+ authentication: "not_checked",
484
+ prerequisites: target.prerequisites,
485
+ nextSteps: {
486
+ harnesses: "kxm harness list",
487
+ drive: `kxm runs drive ${run.runId} --wait`,
488
+ status: `kxm runs status ${run.runId} --json`,
489
+ receipt: `kxm runs receipt ${run.runId} --json`,
490
+ },
491
+ },
401
492
  idempotent: acceptance.idempotent === true,
402
493
  run,
403
494
  supervisor: { runtimeId: supervisor.runtimeId, port: supervisor.port, started: supervisor.started },
404
- }, `run ${run.status}: ${run.runId} (home ${run.homeRuntimeId.slice(0, 12)}…, config ${run.configRevision.slice(0, 19)}…)\n${runEngineNotice(run.runId)}`);
495
+ }, `run ${run.status}: ${run.runId} (home ${run.homeRuntimeId.slice(0, 12)}…, config ${run.configRevision.slice(0, 19)}…)\n${runEngineNotice(run.runId, target.defaultHarness, target.prerequisites)}`);
405
496
  return 0;
406
497
  } catch (error) {
407
498
  if (error instanceof KxmConfigError) {
@@ -728,9 +819,20 @@ export async function cmdTenantStatus(runtime: Runtime): Promise<number> {
728
819
  }
729
820
 
730
821
  export async function cmdHarnessList(runtime: Runtime): Promise<number> {
731
- const inventory = await probeHarnessesAsync({ env: runtime.env });
732
- print(runtime.io, runtime.json, { ok: true, command: "harness list", ...inventory }, formatHarnessInventory(inventory));
733
- return 0;
822
+ try {
823
+ const projectRoot = discoverKxmProjectRoot(runtime.cwd);
824
+ const defaultHarness = projectRoot ? String(loadKxmProject(projectRoot).project.value.defaultHarness ?? "pi") : undefined;
825
+ const inventory = await probeHarnessesAsync({ env: runtime.env, defaultHarness });
826
+ print(runtime.io, runtime.json, { ok: true, command: "harness list", ...inventory }, formatHarnessInventory(inventory));
827
+ return 0;
828
+ } catch (error) {
829
+ if (error instanceof KxmConfigError) {
830
+ print(runtime.io, runtime.json, { ok: false, command: "harness list", error: "harness_list_failed", issues: error.issues }, `harness list failed: ${error.message}`);
831
+ return 1;
832
+ }
833
+ print(runtime.io, runtime.json, { ok: false, command: "harness list", error: "harness_list_io_failed" }, "harness list failed because a local operation did not complete");
834
+ return 1;
835
+ }
734
836
  }
735
837
 
736
838
  export async function selectInventoryModel(runtime: Runtime, requested?: string | undefined): Promise<string | undefined> {
@@ -2,10 +2,12 @@ import { spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { homedir, tmpdir } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { basename, extname, join, resolve } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
7
  import { verifyArtifactExists } from "../artifacts-exist.ts";
8
8
  import { parseWorkflowDefinitions } from "../workflow.ts";
9
+ import { KxmConfigError, KxmSchemaRegistry, parseRestrictedYaml } from "../project-config.ts";
10
+ import { compileKxmWorkflow } from "../engine-compile.ts";
9
11
  import { redactSecrets } from "../redact.ts";
10
12
  import {
11
13
  telemetryPath,
@@ -478,6 +480,22 @@ export async function cmdValidate(runtime: Runtime, fileFlag?: string | undefine
478
480
  }
479
481
  try {
480
482
  const raw = file ? readFileSync(file, "utf8") : inline!;
483
+ // Local workflow mappings use the runner's restricted YAML/schema/compiler.
484
+ // Webhook sources remain JSON arrays with their existing secret checks.
485
+ if (explicitFile && file && !raw.trimStart().startsWith("[")) {
486
+ const value = parseRestrictedYaml(raw, file);
487
+ const issues = new KxmSchemaRegistry().validate("workflow", value, file);
488
+ if (issues.length > 0) throw new KxmConfigError(issues);
489
+ const id = basename(file, extname(file));
490
+ compileKxmWorkflow({ id, value, logicalPath: file });
491
+ printWorker(
492
+ runtime,
493
+ worker,
494
+ { ok: true, command: "validate", source: "file", file, workflows: [{ id, schema: value.schema }], warnings: [] },
495
+ `validated local workflow ${id} (schema and transitions); kxm init validates project references`,
496
+ );
497
+ return 0;
498
+ }
481
499
  const warnings: string[] = [];
482
500
  const definitions = parseWorkflowDefinitions(raw, runtime.env, (message) => warnings.push(message));
483
501
  const secretEnvs = definitions.map((definition) => ({
@@ -9,7 +9,6 @@ import {
9
9
  listGoals,
10
10
  listTasks,
11
11
  getTask,
12
- updateTaskStatus,
13
12
  syncTaskWithTracker,
14
13
  goalFilePath,
15
14
  taskFilePath,
@@ -27,40 +26,71 @@ import { readSessionTokenFromDisk } from "../commands.ts";
27
26
  import { print, printPlan, type Runtime } from "./types.ts";
28
27
  import { cmdKxmRun, resolveKxmRunTarget } from "./project.ts";
29
28
 
30
- export async function cmdSuggest(runtime: Runtime, promptParts: string[]): Promise<number> {
29
+ export async function cmdSuggest(
30
+ runtime: Runtime,
31
+ promptParts: string[],
32
+ probeHarnesses = probeHarnessesAsync,
33
+ ): Promise<number> {
31
34
  try {
32
35
  const prompt = promptParts.join(" ").trim();
33
36
  if (!prompt) {
34
- runtime.io.stderr("prompt must be non-empty\n");
37
+ print(runtime.io, runtime.json, { ok: false, command: "suggest", error: "prompt_required" }, "prompt must be non-empty");
35
38
  return 2;
36
39
  }
37
- const inventory = await probeHarnessesAsync({ env: runtime.env });
38
- const availableHarnesses = inventory.harnesses.map((h) => ({
39
- harness: h.id,
40
- auth: h.authenticated === true ? "authenticated" : "unauthenticated",
41
- }));
42
- const suggestion = suggestWorkflowAndRoles(prompt, { availableHarnesses });
43
-
40
+ // Native authentication probes may initialize state. A dry run must not invoke them.
41
+ const inventory = runtime.dryRun ? undefined : await probeHarnesses({ env: runtime.env });
42
+ const suggestion = suggestWorkflowAndRoles(prompt, { availableHarnesses: inventory?.harnesses });
43
+ if (suggestion.execution.supported && [".yaml", ".yml"].some((extension) =>
44
+ existsSync(join(runtime.cwd, ".kxm", "workflows", `${suggestion.workflowId}${extension}`)))) {
45
+ suggestion.roles = [];
46
+ suggestion.execution = {
47
+ supported: false,
48
+ error: "workflow_already_exists",
49
+ reason: `Workflow ${suggestion.workflowId} already exists; its agents and permissions may differ from the recommended template.`,
50
+ nextSteps: ["Review the existing definition and every agent's harness/model before running it, or install the recommended template under a fresh flat ID. No existing workflow is overwritten or recommended for execution."],
51
+ };
52
+ }
53
+ const execution = suggestion.execution;
44
54
  const text = [
45
55
  `Suggested Workflow: ${suggestion.workflowId} (${suggestion.area})`,
56
+ `Template: ${suggestion.template}`,
46
57
  `Confidence: ${(suggestion.confidence * 100).toFixed(0)}%`,
47
58
  `Reasons: ${suggestion.reasons.join("; ")}`,
48
59
  `Suggested Skills: ${suggestion.suggestedSkills.join(", ") || "none"}`,
49
- `Roles:`,
50
- ` Planner: ${suggestion.roles.planner.harness} (${suggestion.roles.planner.model})`,
51
- ` Writer: ${suggestion.roles.writer.harness} (${suggestion.roles.writer.model})`,
52
- ` Critics: ${suggestion.roles.critics.map((c) => `${c.harness}:${c.model}`).join(", ")}`,
53
- ` Verifier: ${suggestion.roles.verifier.command}`,
54
- ``,
55
- `Execute with:`,
60
+ "",
61
+ "Install definition only (requires kxm init; does not execute):",
56
62
  ` ${suggestion.suggestedCommand}`,
63
+ "",
64
+ ...(execution.supported ? [
65
+ "Suggested agent routing (not applied):",
66
+ ...suggestion.roles.map((role) => ` ${role.agent}: ${role.harness} (${role.role}; use a configured compatible model)`),
67
+ "Prerequisites:",
68
+ ...execution.prerequisites.map((step) => ` ${step}`),
69
+ `Create a run only (${execution.shell}; does not execute steps):`,
70
+ ` ${execution.createCommand}`,
71
+ "Then drive the returned run ID with live calls and inspect its result:",
72
+ ` ${execution.driveCommand}`,
73
+ ` ${execution.statusCommand}`,
74
+ ` ${execution.receiptCommand}`,
75
+ ] : [
76
+ `Execution unavailable: ${execution.reason}`,
77
+ ...execution.nextSteps.map((step) => ` ${step}`),
78
+ ...(runtime.dryRun ? ["Harness authentication was not probed during --dry-run."] : []),
79
+ ]),
57
80
  ].join("\n");
58
81
 
59
- print(runtime.io, runtime.json, { ok: true, command: "suggest", prompt, ...suggestion }, text);
60
- return 0;
82
+ print(runtime.io, runtime.json, {
83
+ ok: execution.supported,
84
+ command: "suggest",
85
+ prompt,
86
+ ...suggestion,
87
+ ...(runtime.dryRun ? { dryRun: true } : {}),
88
+ ...(!execution.supported ? { error: execution.error } : {}),
89
+ }, text);
90
+ return execution.supported ? 0 : 1;
61
91
  } catch (error) {
62
92
  const message = error instanceof Error ? error.message : String(error);
63
- runtime.io.stderr(`suggest failed: ${message}\n`);
93
+ print(runtime.io, runtime.json, { ok: false, command: "suggest", error: "suggest_failed", detail: message }, `suggest failed: ${message}`);
64
94
  return 1;
65
95
  }
66
96
  }
@@ -196,27 +226,21 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
196
226
  runtime.io.stderr(`Task ${taskId} not found\n`);
197
227
  return 1;
198
228
  }
199
- const workflow = task.assignedWorkflow ?? "default";
229
+ const workflow = task.assignedWorkflow;
200
230
  if (runtime.dryRun) {
201
- const target = resolveKxmRunTarget(runtime, workflow);
231
+ const target = resolveKxmRunTarget(runtime, workflow, true);
202
232
  if (typeof target === "number") return target;
203
- const started = updateTaskStatus(runtime.cwd, taskId, "in_progress", { dryRun: true });
204
233
  printPlan(
205
234
  runtime,
206
- { command: "task run", taskId, ...target, status: started.status },
235
+ { command: "task run", taskId, ...target, status: task.status, execution: { status: "not_started", mode: "live" } },
207
236
  [
208
237
  { action: "request", target: "POST kxm-runtime /v1/runs (starts the Runtime supervisor if it is not running)" },
209
- { action: "write", target: taskFilePath(runtime.cwd, taskId) },
210
238
  ],
211
- `run workflow ${workflow} for task ${taskId}, then mark it ${started.status}`,
239
+ `create workflow ${target.workflowId} for task ${taskId}; task status stays ${task.status} until work actually starts`,
212
240
  );
213
241
  return 0;
214
242
  }
215
- const exitCode = await cmdKxmRun(runtime, workflow, [task.objective]);
216
- if (exitCode === 0) {
217
- updateTaskStatus(runtime.cwd, taskId, "in_progress");
218
- }
219
- return exitCode;
243
+ return await cmdKxmRun(runtime, workflow, [task.objective], true);
220
244
  } catch (error) {
221
245
  const message = error instanceof Error ? error.message : String(error);
222
246
  runtime.io.stderr(`task run failed: ${message}\n`);
@@ -11,7 +11,6 @@ import {
11
11
  addWorkflowDefinition,
12
12
  removeWorkflowDefinition,
13
13
  modifyWorkflowDefinition,
14
- parseWorkflowFile,
15
14
  scaffoldWorkflowDefinition,
16
15
  WORKFLOW_TEMPLATES,
17
16
  } from "../workflow-manager.ts";
@@ -23,7 +22,7 @@ import {
23
22
  type WorkflowJournalEntry,
24
23
  type WorkflowRun,
25
24
  } from "../workflow.ts";
26
- import { discoverKxmProjectRoot, kxmWorkflowWriteIssues } from "../project-config.ts";
25
+ import { discoverKxmProjectRoot, kxmWorkflowWriteIssues, parseRestrictedYaml } from "../project-config.ts";
27
26
  import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts";
28
27
  import { projectRuntimeOwnsRun } from "../runtime-store.ts";
29
28
  import type { WorkerOutcome } from "../envelope.ts";
@@ -215,63 +214,63 @@ export async function cmdWorkflowAdd(
215
214
  },
216
215
  ): Promise<number> {
217
216
  const scope = options.scope ?? "local";
218
- const refuse = (error: string, text: string): number => {
219
- print(runtime.io, runtime.json, { ok: false, command: "workflow add", error }, `workflow add failed: ${text}`);
220
- return 2;
217
+ const refuse = (error: string, message: string, code = 2): number => {
218
+ print(runtime.io, runtime.json, { ok: false, command: "workflow add", error, message }, `workflow add failed: ${message}`);
219
+ return code;
221
220
  };
222
- let content: Record<string, unknown> | string | undefined;
223
- if (options.template !== undefined) {
224
- if (options.file !== undefined || options.pick !== undefined) {
225
- return refuse("workflow_add_conflict", "--template cannot be combined with --file or --pick");
226
- }
227
- if (!workflowId) return refuse("workflow_id_required", "usage: kxm workflow add <workflowId> --template <name>");
228
- const template = Object.hasOwn(WORKFLOW_TEMPLATES, options.template) ? WORKFLOW_TEMPLATES[options.template] : undefined;
229
- if (!template) {
230
- return refuse("workflow_template_unknown", `unknown template ${options.template}; choose ${Object.keys(WORKFLOW_TEMPLATES).join(", ")}`);
231
- }
232
- content = { ...template, ...(options.description ? { description: options.description } : {}) };
233
- } else if (!workflowId || options.pick) {
234
- const candidates: PickCandidate[] = Object.entries(WORKFLOW_TEMPLATES).map(([id, tmpl]) => ({
235
- id,
236
- description: String(tmpl.description ?? id),
237
- label: "template",
238
- payload: tmpl,
239
- }));
240
- if (scope === "local") {
241
- const globalDefs = listWorkflowDefinitions({ scope: "global", userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
242
- for (const gd of globalDefs) {
243
- // Read the listed file itself: it may be `.yml`, which a lookup by `<id>.yaml` misses.
244
- const definition = parseWorkflowFile(gd.filePath);
245
- if (definition && !candidates.some((c) => c.id === gd.id)) {
246
- candidates.push({ id: gd.id, description: gd.description, label: "global", payload: definition });
221
+ try {
222
+ let content: Record<string, unknown> | string | undefined;
223
+ if (options.template !== undefined) {
224
+ if (options.file !== undefined || options.pick !== undefined) {
225
+ return refuse("workflow_add_conflict", "--template cannot be combined with --file or --pick");
226
+ }
227
+ if (!workflowId) return refuse("workflow_id_required", "usage: kxm workflow add <workflowId> --template <name>");
228
+ const template = Object.hasOwn(WORKFLOW_TEMPLATES, options.template) ? WORKFLOW_TEMPLATES[options.template] : undefined;
229
+ if (!template) {
230
+ return refuse("workflow_template_unknown", `unknown template ${options.template}; choose ${Object.keys(WORKFLOW_TEMPLATES).join(", ")}`);
231
+ }
232
+ content = { ...template, ...(options.description !== undefined ? { description: options.description } : {}) };
233
+ } else if (!workflowId || options.pick) {
234
+ const candidates: PickCandidate[] = Object.entries(WORKFLOW_TEMPLATES).map(([id, tmpl]) => ({
235
+ id,
236
+ description: String(tmpl.description ?? id),
237
+ label: "template",
238
+ payload: tmpl,
239
+ }));
240
+ if (scope === "local") {
241
+ const globalDefs = listWorkflowDefinitions({ scope: "global", userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
242
+ for (const gd of globalDefs) {
243
+ // Read the listed path when selected: globals may use `.yml`, not only `.yaml`.
244
+ if (!candidates.some((c) => c.id === gd.id)) {
245
+ candidates.push({ id: gd.id, description: gd.description, label: "global", payload: gd.filePath });
246
+ }
247
247
  }
248
248
  }
249
- }
250
- const picked = await resolvePickItem(runtime.io, `Select a workflow template to add (${scope})`, candidates, options.pick, runtime.env);
251
- if (!picked) {
252
- if (!workflowId) {
253
- runtime.io.stderr("workflow add failed: missing workflowId or pick selection\n");
254
- return 1;
249
+ const picked = await resolvePickItem(runtime.io, `Select a workflow template to add (${scope})`, candidates, options.pick, runtime.env);
250
+ if (!picked) {
251
+ return refuse("workflow_id_required", "provide a workflowId or select an available workflow with --pick <id>", 1);
255
252
  }
256
- } else {
257
- workflowId = picked.id;
258
- if (!options.file && picked.payload) {
259
- content = {
260
- ...picked.payload,
261
- ...(options.description ? { description: options.description } : {}),
262
- };
253
+ workflowId ??= picked.id;
254
+ if (options.file === undefined) {
255
+ const pickedContent = typeof picked.payload === "string"
256
+ ? readFileSync(picked.payload, "utf8")
257
+ : picked.payload;
258
+ content = options.description !== undefined
259
+ ? {
260
+ ...(typeof pickedContent === "string" ? parseRestrictedYaml(pickedContent, String(picked.payload)) : pickedContent),
261
+ description: options.description,
262
+ }
263
+ : pickedContent;
263
264
  }
264
265
  }
265
- }
266
266
 
267
- if (options.file) {
268
- const filePath = resolve(runtime.cwd, options.file);
269
- content = readFileSync(filePath, "utf8");
270
- } else if (!content) {
271
- content = scaffoldWorkflowDefinition(options.description || `Workflow ${workflowId}`);
272
- }
267
+ if (options.file !== undefined) {
268
+ const filePath = resolve(runtime.cwd, options.file);
269
+ content = readFileSync(filePath, "utf8");
270
+ } else if (!content) {
271
+ content = scaffoldWorkflowDefinition(options.description || `Workflow ${workflowId}`);
272
+ }
273
273
 
274
- try {
275
274
  const document = typeof content === "string" ? content : stringify(content);
276
275
  // A local workflow is read by the project loader, which refuses the whole
277
276
  // project over one bad file, so it is checked by that loader before it lands.
@@ -286,7 +285,7 @@ export async function cmdWorkflowAdd(
286
285
  print(
287
286
  runtime.io,
288
287
  runtime.json,
289
- { ok: false, command: "workflow add", error: "workflow_invalid", issues },
288
+ { ok: false, command: "workflow add", error: "workflow_invalid", message: "with this workflow the project would not load, so nothing was written", issues },
290
289
  `workflow add failed: with this workflow the project would not load, so nothing was written\n${issues.map((entry) => ` ${entry.file}: ${entry.code}: ${entry.message}`).join("\n")}`,
291
290
  );
292
291
  return 2;
@@ -312,8 +311,7 @@ export async function cmdWorkflowAdd(
312
311
  );
313
312
  return 0;
314
313
  } catch (err: unknown) {
315
- runtime.io.stderr(`workflow add failed: ${(err as Error).message}\n`);
316
- return 1;
314
+ return refuse("workflow_add_failed", err instanceof Error ? err.message : String(err), 1);
317
315
  }
318
316
  }
319
317
 
@@ -419,18 +419,20 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
419
419
  });
420
420
  });
421
421
 
422
- addGlobalOptions(program.command("backup").description("Create a verified SQLite backup of the project hub store and the Runtime stores under the user state root, with a hashed manifest"))
422
+ addGlobalOptions(program.command("backup").description("Create a verified SQLite backup of this project's hub store and Runtime event store, with a hashed manifest"))
423
423
  .option("--out <dir>", "Directory to write backup and manifest")
424
- .action(async function backupAction(this: Command, options: { out?: string }) {
424
+ .option("--all-projects", "Also back up the shared Runtime registry and every project's event store on this machine")
425
+ .action(async function backupAction(this: Command, options: { out?: string; allProjects?: boolean }) {
425
426
  result.code = await cmdBackup(runtimeFrom(ctx, this), options);
426
427
  });
427
428
 
428
- addGlobalOptions(program.command("restore <manifest>").description("Restore SQLite stores from a verified backup manifest"))
429
- .action(async function restoreAction(this: Command, manifest: string) {
430
- result.code = await cmdRestore(runtimeFrom(ctx, this), manifest);
429
+ addGlobalOptions(program.command("restore <manifest>").description("Restore this project's SQLite stores from a verified backup manifest; refuses while the Runtime or hub is running"))
430
+ .option("--all-projects", "Also restore the shared Runtime registry and other projects' event stores the backup holds")
431
+ .action(async function restoreAction(this: Command, manifest: string, options: { allProjects?: boolean }) {
432
+ result.code = await cmdRestore(runtimeFrom(ctx, this), manifest, options);
431
433
  });
432
434
 
433
- addGlobalOptions(program.command("run").description("Create a KXM run (offline-first; kxm runs drive <runId> --simulated executes it model-free)")
435
+ addGlobalOptions(program.command("run").description("Create a KXM run without executing steps; follow its prerequisites, then kxm runs drive <runId> --wait")
434
436
  .argument("[workflow]", "Workflow id to run")
435
437
  .argument("[prompt...]", "Run prompt (events keep its hash; the full text is kept in a local 0600 sidecar file)")
436
438
  .action(async function runAction(this: Command, workflow: string | undefined, promptParts: string[]) {