@kontextmind/kxm 0.7.86 → 0.7.88

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 (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/configuration.md +2 -0
  3. package/package.json +1 -1
  4. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  5. package/plugins/kxm/dist/cli.js +897 -500
  6. package/plugins/kxm/dist/core.js +2 -2
  7. package/plugins/kxm/dist/extension.js +72 -63
  8. package/plugins/kxm/dist/mcp-server.js +3 -3
  9. package/plugins/kxm/dist/runtime.js +44 -26
  10. package/plugins/kxm/dist/server.js +56 -31
  11. package/plugins/kxm/package.json +1 -1
  12. package/plugins/kxm/src/cli/context-skills.ts +51 -6
  13. package/plugins/kxm/src/cli/hub.ts +39 -14
  14. package/plugins/kxm/src/cli/project.ts +78 -23
  15. package/plugins/kxm/src/cli/roles.ts +48 -4
  16. package/plugins/kxm/src/cli/system.ts +18 -1
  17. package/plugins/kxm/src/cli/tasks.ts +34 -5
  18. package/plugins/kxm/src/cli/types.ts +30 -0
  19. package/plugins/kxm/src/cli/workflows.ts +20 -4
  20. package/plugins/kxm/src/cli.ts +57 -0
  21. package/plugins/kxm/src/commands.ts +2 -2
  22. package/plugins/kxm/src/config.ts +4 -3
  23. package/plugins/kxm/src/context/providers.ts +4 -0
  24. package/plugins/kxm/src/database.ts +72 -33
  25. package/plugins/kxm/src/hub.ts +26 -8
  26. package/plugins/kxm/src/local-snapshot.ts +4 -4
  27. package/plugins/kxm/src/mcp-server.ts +1 -1
  28. package/plugins/kxm/src/memory.ts +10 -7
  29. package/plugins/kxm/src/role.ts +18 -6
  30. package/plugins/kxm/src/session-work.ts +4 -1
  31. package/plugins/kxm/src/skills.ts +29 -15
  32. package/plugins/kxm/src/sqlite.ts +18 -0
  33. package/plugins/kxm/src/state.ts +4 -1
  34. package/plugins/kxm/src/task-manager.ts +20 -13
  35. package/plugins/kxm/src/workflow-manager.ts +12 -4
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:f
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
- import { createBackup, restoreBackup } from "../database.ts";
6
+ import { createBackup, planBackup, planRestore, restoreBackup } from "../database.ts";
7
7
  import { refreshModelInventory } from "../model-inventory.ts";
8
8
  import { listInventoryModels, listRoleBindings, loadRoutePolicy, setRouteState, updateRouteState } from "../routes.ts";
9
9
  import {
@@ -43,7 +43,7 @@ import {
43
43
  } from "../kxm-update.ts";
44
44
  import { loadKxmUpdateConfig } from "../kxm-update-config.ts";
45
45
  import type { InstallKindReport, InstallProbe } from "../kxm-install-kind.ts";
46
- import { print, type CliIo, type CliSpawnResult, type Runtime } from "./types.ts";
46
+ import { print, printPlan, refuseDryRun, type CliIo, type CliSpawnResult, type Runtime } from "./types.ts";
47
47
 
48
48
  export const kxmDriveCliSeams: {
49
49
  ensureSupervisor?: typeof ensureKxmSupervisor;
@@ -181,10 +181,24 @@ export async function cmdKxmInit(
181
181
 
182
182
  export async function cmdBackup(runtime: Runtime, options: { out?: string | undefined }): Promise<number> {
183
183
  try {
184
- const { manifest, outDir } = createBackup({
184
+ const backupOptions = {
185
185
  projectRoot: runtime.cwd,
186
186
  ...(options.out ? { outDir: resolve(runtime.cwd, options.out) } : {}),
187
- });
187
+ };
188
+ if (runtime.dryRun) {
189
+ const plan = planBackup(backupOptions);
190
+ printPlan(
191
+ runtime,
192
+ { command: "backup", outDir: plan.outDir, stores: plan.stores },
193
+ [
194
+ ...plan.stores.map((store) => ({ action: "write" as const, target: join(plan.outDir, store.backupFile) })),
195
+ { action: "write", target: join(plan.outDir, "manifest.json") },
196
+ ],
197
+ `back up ${plan.stores.length} store(s) to ${plan.outDir} (sources are not opened, so their WAL is not checkpointed)`,
198
+ );
199
+ return 0;
200
+ }
201
+ const { manifest, outDir } = createBackup(backupOptions);
188
202
  const payload = {
189
203
  ok: true,
190
204
  command: "backup",
@@ -211,6 +225,26 @@ export async function cmdBackup(runtime: Runtime, options: { out?: string | unde
211
225
 
212
226
  export async function cmdRestore(runtime: Runtime, manifestArg: string): Promise<number> {
213
227
  try {
228
+ if (runtime.dryRun) {
229
+ const plan = planRestore(resolve(runtime.cwd, manifestArg), { projectRoot: runtime.cwd });
230
+ printPlan(
231
+ runtime,
232
+ {
233
+ command: "restore",
234
+ backupId: plan.backupId,
235
+ manifestPath: plan.manifestPath,
236
+ stores: plan.stores.map(({ storeId, targetPath, schemaVersion }) => ({ storeId, targetPath, schemaVersion })),
237
+ },
238
+ plan.stores.flatMap((store) => [
239
+ { action: "write" as const, target: store.targetPath },
240
+ ...[`${store.targetPath}-wal`, `${store.targetPath}-shm`]
241
+ .filter((file) => existsSync(file))
242
+ .map((file) => ({ action: "delete" as const, target: file })),
243
+ ]),
244
+ `restore ${plan.stores.length} store(s) from ${plan.manifestPath}; digests verified against the manifest`,
245
+ );
246
+ return 0;
247
+ }
214
248
  const result = restoreBackup(resolve(runtime.cwd, manifestArg), {
215
249
  projectRoot: runtime.cwd,
216
250
  });
@@ -281,10 +315,23 @@ export async function cmdKxmTrust(runtime: Runtime, check: boolean, options: { b
281
315
  }
282
316
  }
283
317
 
318
+ /** The supervisor a run read talks to. Normally it is started on demand; under
319
+ * `--dry-run` a read may only attach, because starting the Runtime is a change. */
320
+ async function readSupervisor(runtime: Runtime, command: string): Promise<Awaited<ReturnType<typeof ensureKxmSupervisor>> | number> {
321
+ if (!runtime.dryRun) return await (kxmDriveCliSeams.ensureSupervisor ?? ensureKxmSupervisor)({ env: runtime.env });
322
+ const attached = await attachKxmSupervisor({ env: runtime.env });
323
+ return attached ?? refuseDryRun(runtime.io, runtime.json, command, "the Runtime supervisor is not running and --dry-run will not start it");
324
+ }
325
+
284
326
  export const RUN_ENGINE_PHASE = "pre-3a";
285
327
  export const RUN_ENGINE_NOTICE = "runs remain created until the run engine lands; no steps execute yet";
286
328
 
287
- export async function cmdKxmRun(runtime: Runtime, workflow: string | undefined, promptParts: string[]): Promise<number> {
329
+ /** Where `kxm run <workflow>` would create its run, or the exit code of the
330
+ * refusal it already printed. Reads only; shared with `task run --dry-run`. */
331
+ export function resolveKxmRunTarget(
332
+ runtime: Runtime,
333
+ workflow: string | undefined,
334
+ ): { projectRoot: string; workflowId: string; configRevision: string } | number {
288
335
  if (runtime.workspaceFlag !== undefined) {
289
336
  print(runtime.io, runtime.json, {
290
337
  ok: false,
@@ -297,33 +344,38 @@ export async function cmdKxmRun(runtime: Runtime, workflow: string | undefined,
297
344
  print(runtime.io, runtime.json, { ok: false, command: "run", error: "workflow_required" }, "usage: kxm run <workflow> [prompt]");
298
345
  return 2;
299
346
  }
347
+ const projectRoot = discoverKxmProjectRoot(runtime.cwd);
348
+ if (!projectRoot) {
349
+ print(runtime.io, runtime.json, { ok: false, command: "run", error: "project_required" }, "kxm run requires a KXM project (run kxm init first)");
350
+ return 1;
351
+ }
352
+ const bundle = loadKxmProject(projectRoot, {});
353
+ if (!bundle.workflows.has(workflow)) {
354
+ print(runtime.io, runtime.json, { ok: false, command: "run", error: "run_workflow_unknown", workflow }, `workflow ${workflow} does not exist in this project`);
355
+ return 1;
356
+ }
357
+ return { projectRoot, workflowId: workflow, configRevision: bundle.configRevision };
358
+ }
359
+
360
+ export async function cmdKxmRun(runtime: Runtime, workflow: string | undefined, promptParts: string[]): Promise<number> {
300
361
  try {
301
- const projectRoot = discoverKxmProjectRoot(runtime.cwd);
302
- if (!projectRoot) {
303
- print(runtime.io, runtime.json, { ok: false, command: "run", error: "project_required" }, "kxm run requires a KXM project (run kxm init first)");
304
- return 1;
305
- }
306
- const bundle = loadKxmProject(projectRoot, {});
307
- if (!bundle.workflows.has(workflow)) {
308
- print(runtime.io, runtime.json, { ok: false, command: "run", error: "run_workflow_unknown", workflow }, `workflow ${workflow} does not exist in this project`);
309
- return 1;
310
- }
362
+ const target = resolveKxmRunTarget(runtime, workflow);
363
+ if (typeof target === "number") return target;
364
+ const { projectRoot } = target;
311
365
  if (runtime.dryRun) {
312
366
  print(runtime.io, runtime.json, {
313
367
  ok: true,
314
368
  command: "run",
315
369
  dryRun: true,
316
- projectRoot,
317
- workflowId: workflow,
318
- configRevision: bundle.configRevision,
319
- }, `run plan: workflow ${workflow} at ${bundle.configRevision.slice(0, 19)}… (no run created)`);
370
+ ...target,
371
+ }, `run plan: workflow ${target.workflowId} at ${target.configRevision.slice(0, 19)}… (no run created)`);
320
372
  return 0;
321
373
  }
322
374
  const supervisor = await ensureKxmSupervisor({ env: runtime.env });
323
375
  const prompt = promptParts.join(" ").trim();
324
376
  const acceptance = await kxmRuntimeRequest(supervisor, "POST", "/v1/runs", {
325
377
  projectRoot,
326
- workflowId: workflow,
378
+ workflowId: target.workflowId,
327
379
  prompt,
328
380
  });
329
381
  const run = acceptance.run as { runId: string; homeRuntimeId: string; status: string; configRevision: string };
@@ -408,7 +460,8 @@ export async function cmdKxmRunStatus(runtime: Runtime, runId: string): Promise<
408
460
  print(runtime.io, runtime.json, { ok: false, command: "runs status", error: "project_required" }, "kxm runs status requires a KXM project (run kxm init first)");
409
461
  return 1;
410
462
  }
411
- const supervisor = await (kxmDriveCliSeams.ensureSupervisor ?? ensureKxmSupervisor)({ env: runtime.env });
463
+ const supervisor = await readSupervisor(runtime, "runs status");
464
+ if (typeof supervisor === "number") return supervisor;
412
465
  const result = await (kxmDriveCliSeams.runtimeRequest ?? kxmRuntimeRequest)(supervisor, "GET", `/v1/runs/${encodeURIComponent(runId)}?projectRoot=${encodeURIComponent(projectRoot)}`);
413
466
  const run = result.run as { runId: string; status: string; workflowId: string; configRevision: string; updatedAt: string };
414
467
  const drive = result.drive as KxmStatusDrive | undefined;
@@ -570,7 +623,8 @@ export async function cmdKxmRunList(runtime: Runtime): Promise<number> {
570
623
  }
571
624
  const bundle = loadKxmProject(projectRoot, {});
572
625
  const projectId = String(bundle.project.value.id);
573
- const supervisor = await ensureKxmSupervisor({ env: runtime.env });
626
+ const supervisor = await readSupervisor(runtime, "runs list");
627
+ if (typeof supervisor === "number") return supervisor;
574
628
  const result = await kxmRuntimeRequest(supervisor, "GET", `/v1/projects/${encodeURIComponent(projectId)}/runs?projectRoot=${encodeURIComponent(projectRoot)}`);
575
629
  const runs = (result.runs ?? []) as Array<{ runId: string; status: string; workflowId: string; createdAt: string; projectionError?: string }>;
576
630
  print(
@@ -851,7 +905,8 @@ export async function cmdKxmRunReceipt(runtime: Runtime, runId: string, options:
851
905
  print(runtime.io, runtime.json, { ok: false, command: "runs receipt", error: "project_required" }, "kxm runs receipt requires a KXM project (run kxm init first)");
852
906
  return 1;
853
907
  }
854
- const supervisor = await (kxmDriveCliSeams.ensureSupervisor ?? ensureKxmSupervisor)({ env: runtime.env });
908
+ const supervisor = await readSupervisor(runtime, "runs receipt");
909
+ if (typeof supervisor === "number") return supervisor;
855
910
  const result = await (kxmDriveCliSeams.runtimeRequest ?? kxmRuntimeRequest)(
856
911
  supervisor,
857
912
  "GET",
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { createInterface } from "node:readline";
5
5
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
6
- import { DatabaseSync } from "../sqlite.ts";
6
+ import { DatabaseSync, openReadOnlyDatabase } from "../sqlite.ts";
7
7
  import {
8
8
  DEFAULT_ROLES,
9
9
  DEFAULT_ROLE_SEATS,
@@ -20,7 +20,7 @@ import {
20
20
  import { discoverKxmProjectRoot } from "../project-config.ts";
21
21
  import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts";
22
22
  import { resumeWorkflowFromRuling, type WorkflowRun } from "../workflow.ts";
23
- import { print, type CliIo, type Runtime } from "./types.ts";
23
+ import { print, printPlan, type CliIo, type Runtime } from "./types.ts";
24
24
 
25
25
  export interface PickCandidate {
26
26
  id: string;
@@ -192,7 +192,12 @@ export async function cmdRoleAdd(
192
192
  repoRoot: runtime.cwd,
193
193
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
194
194
  overwrite: options.overwrite,
195
+ dryRun: runtime.dryRun,
195
196
  });
197
+ if (runtime.dryRun) {
198
+ printPlan(runtime, { command: "role add", roleId, ...res }, [{ action: "write", target: res.filePath }], `add role '${roleId}' to ${res.scope}`);
199
+ return 0;
200
+ }
196
201
  print(
197
202
  runtime.io,
198
203
  runtime.json,
@@ -232,7 +237,12 @@ export async function cmdRoleAdd(
232
237
  repoRoot: runtime.cwd,
233
238
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
234
239
  overwrite: options.overwrite,
240
+ dryRun: runtime.dryRun,
235
241
  });
242
+ if (runtime.dryRun) {
243
+ printPlan(runtime, { command: "role add", roleId, ...res }, [{ action: "write", target: res.filePath }], `add role '${roleId}' to ${res.scope}`);
244
+ return 0;
245
+ }
236
246
  print(
237
247
  runtime.io,
238
248
  runtime.json,
@@ -283,7 +293,12 @@ export async function cmdRoleRemove(
283
293
  scope,
284
294
  repoRoot: runtime.cwd,
285
295
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
296
+ dryRun: runtime.dryRun,
286
297
  });
298
+ if (runtime.dryRun) {
299
+ printPlan(runtime, { command: "role remove", roleId, ...res }, [{ action: "delete", target: res.filePath }], `remove role '${roleId}' from ${res.scope}`);
300
+ return 0;
301
+ }
287
302
  print(
288
303
  runtime.io,
289
304
  runtime.json,
@@ -377,7 +392,12 @@ export async function cmdRoleModify(
377
392
  scope: options.scope,
378
393
  repoRoot: runtime.cwd,
379
394
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
395
+ dryRun: runtime.dryRun,
380
396
  });
397
+ if (runtime.dryRun) {
398
+ printPlan(runtime, { command: "role modify", roleId, ...res }, [{ action: "write", target: res.filePath }], `modify role '${roleId}' in ${res.scope}`);
399
+ return 0;
400
+ }
381
401
  print(
382
402
  runtime.io,
383
403
  runtime.json,
@@ -482,7 +502,17 @@ export async function cmdRoleSetHost(
482
502
  scope: options.scope ?? "local",
483
503
  repoRoot: runtime.cwd,
484
504
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
505
+ dryRun: runtime.dryRun,
485
506
  });
507
+ if (runtime.dryRun) {
508
+ printPlan(
509
+ runtime,
510
+ { command: "role set-host", seatId, host, binding: result.binding, filePath: result.filePath, scope: result.scope },
511
+ [{ action: "write", target: result.filePath }],
512
+ `bind seat '${seatId}' to host '${host}'`,
513
+ );
514
+ return 0;
515
+ }
486
516
 
487
517
  print(
488
518
  runtime.io,
@@ -521,7 +551,12 @@ export async function cmdRoleResume(
521
551
  const projectRoot = discoverKxmProjectRoot(runtime.cwd);
522
552
  if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
523
553
  if (runtime.dryRun) {
524
- print(runtime.io, runtime.json, { ok: true, command: "role resume", runId, ruling: effectiveRuling }, `would resume KXM run ${runId}`);
554
+ printPlan(
555
+ runtime,
556
+ { command: "role resume", runId, ruling: effectiveRuling },
557
+ [{ action: "request", target: `POST kxm-runtime /v1/runs/${runId}/signal (audit_escalation unblock)` }],
558
+ `resume KXM run ${runId}`,
559
+ );
525
560
  return 0;
526
561
  }
527
562
  try {
@@ -555,7 +590,7 @@ export async function cmdRoleResume(
555
590
  const dbPath = join(runtime.cwd, ".kxm", "state", "kxm.db");
556
591
  if (existsSync(dbPath)) {
557
592
  try {
558
- const database = new DatabaseSync(dbPath);
593
+ const database = runtime.dryRun ? openReadOnlyDatabase(dbPath) : new DatabaseSync(dbPath);
559
594
  try {
560
595
  const row = database.prepare("SELECT record FROM workflow_runs WHERE id = ?").get(runId) as { record: string } | undefined;
561
596
  if (!row) {
@@ -565,6 +600,15 @@ export async function cmdRoleResume(
565
600
  const run = JSON.parse(row.record) as WorkflowRun;
566
601
  const now = new Date().toISOString();
567
602
  const resumeResult = resumeWorkflowFromRuling(run, effectiveRuling, now);
603
+ if (runtime.dryRun) {
604
+ printPlan(
605
+ runtime,
606
+ { command: "role resume", runId, stageId: resumeResult.stageId, ruling: effectiveRuling, status: resumeResult.run.status },
607
+ [{ action: "write", target: `${dbPath} (workflow_runs ${runId}, one workflow_journal decision)` }],
608
+ `resume workflow run ${runId} (stage: ${resumeResult.stageId})`,
609
+ );
610
+ return 0;
611
+ }
568
612
 
569
613
  database.prepare("UPDATE workflow_runs SET record = ? WHERE id = ?").run(
570
614
  JSON.stringify(resumeResult.run),
@@ -88,6 +88,7 @@ import {
88
88
  import { kxmUserStateRoot } from "../bindings.ts";
89
89
  import {
90
90
  print,
91
+ printPlan,
91
92
  printWorker,
92
93
  gateOf,
93
94
  type CliIo,
@@ -224,6 +225,10 @@ export async function cmdSshRun(
224
225
  options: { sudo?: boolean | undefined },
225
226
  ): Promise<number> {
226
227
  const command = commandParts.join(" ");
228
+ if (runtime.dryRun) {
229
+ printPlan(runtime, { command: "ssh run", host, remoteCommand: command, sudo: options.sudo === true }, [{ action: "ssh", target: `${host}: ${options.sudo ? "sudo " : ""}${command}` }], `run a command on ${host}`);
230
+ return 0;
231
+ }
227
232
  const receipt = executeSshRun({
228
233
  action: "command",
229
234
  host,
@@ -247,6 +252,10 @@ export async function cmdSshFile(
247
252
  options: { content?: string | undefined; read?: boolean | undefined; append?: boolean | undefined; sudo?: boolean | undefined },
248
253
  ): Promise<number> {
249
254
  const op = options.read ? "read" : (options.append ? "append" : "write");
255
+ if (runtime.dryRun) {
256
+ printPlan(runtime, { command: "ssh file", host, path: filePath, op, sudo: options.sudo === true }, [{ action: "ssh", target: `${host}: ${op} ${filePath}` }], `${op} ${filePath} on ${host}`);
257
+ return 0;
258
+ }
250
259
  const receipt = executeSshRun({
251
260
  action: "file",
252
261
  host,
@@ -269,6 +278,10 @@ export async function cmdSshFile(
269
278
  }
270
279
 
271
280
  export async function cmdSshClose(runtime: Runtime, host: string): Promise<number> {
281
+ if (runtime.dryRun) {
282
+ printPlan(runtime, { command: "ssh close", host }, [{ action: "ssh", target: `${host}: close the ControlMaster socket` }], `close the ControlMaster socket for ${host}`);
283
+ return 0;
284
+ }
272
285
  const closed = closeControlSocket(host);
273
286
  if (runtime.json) {
274
287
  print(runtime.io, runtime.json, { ok: true, closed, command: "ssh close", host }, "");
@@ -568,7 +581,11 @@ export async function cmdConfigSet(
568
581
  } catch {
569
582
  // keep string
570
583
  }
571
- setKxmConfigValue(runtime.cwd, key, parsedVal, { scope });
584
+ const { file } = setKxmConfigValue(runtime.cwd, key, parsedVal, { scope, dryRun: runtime.dryRun });
585
+ if (runtime.dryRun) {
586
+ printPlan(runtime, { command: "config set", key, value: parsedVal, scope }, [{ action: "write", target: file }], `set ${key} = ${value} in ${scope} config`);
587
+ return 0;
588
+ }
572
589
  print(
573
590
  runtime.io,
574
591
  runtime.json,
@@ -11,6 +11,8 @@ import {
11
11
  getTask,
12
12
  updateTaskStatus,
13
13
  syncTaskWithTracker,
14
+ goalFilePath,
15
+ taskFilePath,
14
16
  type TaskStatus,
15
17
  type TrackerType,
16
18
  } from "../task-manager.ts";
@@ -22,8 +24,8 @@ import {
22
24
  generateStudioLayout,
23
25
  } from "../studio-layout.ts";
24
26
  import { readSessionTokenFromDisk } from "../commands.ts";
25
- import { print, type Runtime } from "./types.ts";
26
- import { cmdKxmRun } from "./project.ts";
27
+ import { print, printPlan, type Runtime } from "./types.ts";
28
+ import { cmdKxmRun, resolveKxmRunTarget } from "./project.ts";
27
29
 
28
30
  export async function cmdSuggest(runtime: Runtime, promptParts: string[]): Promise<number> {
29
31
  try {
@@ -74,7 +76,11 @@ export async function cmdGoalCreate(
74
76
  area: options.area,
75
77
  successMetrics: options.metric,
76
78
  targetDate: options.targetDate,
77
- });
79
+ }, { dryRun: runtime.dryRun });
80
+ if (runtime.dryRun) {
81
+ printPlan(runtime, { command: "goal create", goal }, [{ action: "write", target: goalFilePath(runtime.cwd, goal.id) }], `create goal ${goal.title} (the id is assigned when it is created)`);
82
+ return 0;
83
+ }
78
84
  print(
79
85
  runtime.io,
80
86
  runtime.json,
@@ -118,7 +124,11 @@ export async function cmdTaskCreate(
118
124
  trackerSync: options.tracker && options.issue
119
125
  ? { tracker: options.tracker as TrackerType, issueKey: options.issue }
120
126
  : undefined,
121
- });
127
+ }, { dryRun: runtime.dryRun });
128
+ if (runtime.dryRun) {
129
+ printPlan(runtime, { command: "task create", task }, [{ action: "write", target: taskFilePath(runtime.cwd, task.id) }], `create task ${task.title} [${task.status}] (the id is assigned when it is created)`);
130
+ return 0;
131
+ }
122
132
  print(
123
133
  runtime.io,
124
134
  runtime.json,
@@ -187,6 +197,21 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
187
197
  return 1;
188
198
  }
189
199
  const workflow = task.assignedWorkflow ?? "default";
200
+ if (runtime.dryRun) {
201
+ const target = resolveKxmRunTarget(runtime, workflow);
202
+ if (typeof target === "number") return target;
203
+ const started = updateTaskStatus(runtime.cwd, taskId, "in_progress", { dryRun: true });
204
+ printPlan(
205
+ runtime,
206
+ { command: "task run", taskId, ...target, status: started.status },
207
+ [
208
+ { 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
+ ],
211
+ `run workflow ${workflow} for task ${taskId}, then mark it ${started.status}`,
212
+ );
213
+ return 0;
214
+ }
190
215
  const exitCode = await cmdKxmRun(runtime, workflow, [task.objective]);
191
216
  if (exitCode === 0) {
192
217
  updateTaskStatus(runtime.cwd, taskId, "in_progress");
@@ -201,7 +226,11 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
201
226
 
202
227
  export async function cmdTaskSync(runtime: Runtime, taskId: string): Promise<number> {
203
228
  try {
204
- const synced = syncTaskWithTracker(runtime.cwd, taskId);
229
+ const synced = syncTaskWithTracker(runtime.cwd, taskId, { dryRun: runtime.dryRun });
230
+ if (runtime.dryRun) {
231
+ printPlan(runtime, { command: "task sync", task: synced }, [{ action: "write", target: taskFilePath(runtime.cwd, taskId) }], `mark task ${taskId} synced with ${synced.trackerSync?.tracker} #${synced.trackerSync?.issueKey}`);
232
+ return 0;
233
+ }
205
234
  print(
206
235
  runtime.io,
207
236
  runtime.json,
@@ -101,6 +101,36 @@ export function print(io: CliIo, jsonMode: boolean, payload: object, text: strin
101
101
  else io.stdout(line);
102
102
  }
103
103
 
104
+ /** One change a command would have made had `--dry-run` not been given. */
105
+ export interface PlannedChange {
106
+ action: "write" | "delete" | "move" | "request" | "ssh";
107
+ target: string;
108
+ }
109
+
110
+ /** The `--dry-run` answer of a mutating command: the envelope the real run
111
+ * prints, marked `dryRun: true`, plus every write, request, or remote step it
112
+ * would have taken. Nothing is written by the caller before or after this. */
113
+ export function printPlan(
114
+ runtime: Runtime,
115
+ payload: Record<string, unknown> & { command: string },
116
+ planned: readonly PlannedChange[],
117
+ text: string,
118
+ ): void {
119
+ print(runtime.io, runtime.json, { ok: true, ...payload, dryRun: true, planned }, [
120
+ `dry run: ${text}`,
121
+ ...(planned.length === 0 ? [" no changes"] : planned.map((change) => ` would ${change.action} ${change.target}`)),
122
+ ].join("\n"));
123
+ }
124
+
125
+ export const DRY_RUN_UNSUPPORTED = "dry_run_unsupported";
126
+
127
+ /** For a command that cannot say what it would do without doing some of it:
128
+ * refuse under `--dry-run` instead of executing. */
129
+ export function refuseDryRun(io: CliIo, jsonMode: boolean, command: string, reason: string): number {
130
+ print(io, jsonMode, { ok: false, command, dryRun: true, error: DRY_RUN_UNSUPPORTED, detail: reason }, `kxm ${command} --dry-run refused: ${reason}`);
131
+ return 2;
132
+ }
133
+
104
134
  export function printWorker(
105
135
  runtime: Runtime,
106
136
  worker: Worker,
@@ -1,7 +1,7 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { basename, join, resolve } from "node:path";
4
- import { DatabaseSync } from "../sqlite.ts";
4
+ import { openReadOnlyDatabase } from "../sqlite.ts";
5
5
  import { buildRetrospective, writeRetrospective } from "../retrospective.ts";
6
6
  import { redactSecrets } from "../redact.ts";
7
7
  import { postWorkflowSignal, watchGithubChecks } from "../github-watch.ts";
@@ -24,6 +24,7 @@ import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts
24
24
  import type { WorkerOutcome } from "../envelope.ts";
25
25
  import {
26
26
  print,
27
+ printPlan,
27
28
  printWorker,
28
29
  gateOf,
29
30
  parseEvidencePairs,
@@ -37,7 +38,7 @@ const CLI_NAME = "kxm";
37
38
 
38
39
  export function localWorkflowSnapshot(dataPath: string, runId?: string): { runs: WorkflowRun[]; journal: WorkflowJournalEntry[] } {
39
40
  if (!existsSync(dataPath)) throw new Error("state_database_not_found");
40
- const database = new DatabaseSync(dataPath, { readOnly: true });
41
+ const database = openReadOnlyDatabase(dataPath);
41
42
  try {
42
43
  const rows = runId
43
44
  ? database.prepare("SELECT record FROM workflow_runs WHERE id = ?").all(runId)
@@ -265,7 +266,12 @@ export async function cmdWorkflowAdd(
265
266
  repoRoot: runtime.cwd,
266
267
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
267
268
  overwrite: options.overwrite,
269
+ dryRun: runtime.dryRun,
268
270
  });
271
+ if (runtime.dryRun) {
272
+ printPlan(runtime, { command: "workflow add", workflowId, ...res }, [{ action: "write", target: res.filePath }], `add workflow '${workflowId}' to ${res.scope}`);
273
+ return 0;
274
+ }
269
275
  print(
270
276
  runtime.io,
271
277
  runtime.json,
@@ -316,7 +322,12 @@ export async function cmdWorkflowRemove(
316
322
  scope,
317
323
  repoRoot: runtime.cwd,
318
324
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
325
+ dryRun: runtime.dryRun,
319
326
  });
327
+ if (runtime.dryRun) {
328
+ printPlan(runtime, { command: "workflow remove", workflowId, ...res }, [{ action: "delete", target: res.filePath }], `remove workflow '${workflowId}' from ${res.scope}`);
329
+ return 0;
330
+ }
320
331
  print(
321
332
  runtime.io,
322
333
  runtime.json,
@@ -373,8 +384,13 @@ export async function cmdWorkflowModify(
373
384
  scope: options.scope,
374
385
  repoRoot: runtime.cwd,
375
386
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
387
+ dryRun: runtime.dryRun,
376
388
  },
377
389
  );
390
+ if (runtime.dryRun) {
391
+ printPlan(runtime, { command: "workflow modify", workflowId, ...res }, [{ action: "write", target: res.filePath }], `modify workflow '${workflowId}' in ${res.scope}`);
392
+ return 0;
393
+ }
378
394
  print(
379
395
  runtime.io,
380
396
  runtime.json,
@@ -520,7 +536,7 @@ export async function cmdSignal(runtime: Runtime, runId: string, signalKey: stri
520
536
  const projectRoot = discoverKxmProjectRoot(runtime.cwd);
521
537
  if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
522
538
  if (runtime.dryRun) {
523
- printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signal to KXM run");
539
+ printWorker(runtime, worker, { ok: true, command: "signal", dryRun: true, runId, signalKey, status, summary, evidence }, "would post signal to KXM run");
524
540
  return 0;
525
541
  }
526
542
  const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
@@ -557,7 +573,7 @@ export async function cmdSignal(runtime: Runtime, runId: string, signalKey: stri
557
573
  return 2;
558
574
  }
559
575
  if (runtime.dryRun) {
560
- printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signed signal");
576
+ printWorker(runtime, worker, { ok: true, command: "signal", dryRun: true, runId, signalKey, status, summary, evidence }, "would post signed signal");
561
577
  return 0;
562
578
  }
563
579
  const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
@@ -30,6 +30,7 @@ import { ensureKxmSupervisor, kxmRuntimeRequest } from "./runtime-supervisor.ts"
30
30
  // Submodule imports
31
31
  import {
32
32
  print,
33
+ refuseDryRun,
33
34
  runtimeFrom,
34
35
  redactConfiguredValues,
35
36
  type CliContext,
@@ -172,6 +173,54 @@ const USAGE_ERROR_CODES = new Set([
172
173
  "commander.optionMissingArgument",
173
174
  ]);
174
175
 
176
+ /**
177
+ * Every command that answers `--dry-run` without changing anything: it only
178
+ * reads, or it prints the plan (`dryRun: true` plus `planned`) and stops.
179
+ * A command missing from this set is refused under `--dry-run` with
180
+ * `dry_run_unsupported` before its action runs, so a new command that forgets
181
+ * to check the flag fails closed instead of mutating. Keyed by command path.
182
+ */
183
+ const DRY_RUN_COMMANDS: ReadonlySet<string> = new Set([
184
+ "init", "backup", "restore", "run", "explain", "suggest", "update", "dash", "completion", "completion install",
185
+ "runs status", "runs drive", "runs receipt", "runs cancel", "runs list",
186
+ "tenant status", "models inventory-refresh", "harness list", "auth token",
187
+ "routes list", "routes count", "routes admit", "routes disable",
188
+ "runtime start", "runtime status", "runtime sync-retry", "runtime stop",
189
+ "trust diff", "trust check", "agent worker",
190
+ "session status", "session brief", "session token", "session start", "session stop",
191
+ "peer list", "peer send", "peer get", "peer await", "peer cancel", "peer fanout", "peer inbox", "peer reply",
192
+ "workflow list", "workflow get", "workflow checkpoint", "workflow record", "workflow wait", "workflow signal",
193
+ "workflow start", "workflow export", "workflow definitions", "workflow add", "workflow remove", "workflow modify",
194
+ "role list", "role get", "role add", "role remove", "role modify", "role hosts", "role set-host", "role resume",
195
+ "gate validate", "gate artifacts-exist", "gate degrade", "gate signal", "gate github watch",
196
+ "improve report",
197
+ "context get", "context recall", "context state", "context episode", "context promote", "context explain",
198
+ "context wiki-compile", "context wiki-lint",
199
+ "skills create", "skills evaluate", "skills promote", "skills reject", "skills list", "skills verify",
200
+ "memory brief", "memory note", "memory sync",
201
+ "routing report", "routing benchmark",
202
+ "ssh info", "ssh run", "ssh file", "ssh close",
203
+ "hub view", "hub start", "hub stop", "hub bind", "hub unbind",
204
+ "config get", "config set", "config list",
205
+ "goal create", "goal list",
206
+ "task create", "task list", "task get", "task run", "task sync",
207
+ "studio layout", "studio serve",
208
+ ]);
209
+
210
+ class DryRunRefused extends Error {
211
+ readonly code: number;
212
+ constructor(code: number) {
213
+ super("dry_run_unsupported");
214
+ this.code = code;
215
+ }
216
+ }
217
+
218
+ function commandPath(command: Command): string {
219
+ const names: string[] = [];
220
+ for (let current: Command | null = command; current?.parent; current = current.parent) names.unshift(current.name());
221
+ return names.join(" ");
222
+ }
223
+
175
224
  function addGlobalOptions(command: Command): Command {
176
225
  return command
177
226
  .option("--json", "Print machine-readable JSON")
@@ -331,6 +380,13 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
331
380
  })
332
381
  .helpCommand("help", "Show help");
333
382
  addGlobalOptions(program);
383
+ program.hook("preAction", (_program, actionCommand) => {
384
+ const opts = actionCommand.optsWithGlobals() as { dryRun?: boolean; json?: boolean };
385
+ if (!opts.dryRun) return;
386
+ const path = commandPath(actionCommand);
387
+ if (DRY_RUN_COMMANDS.has(path)) return;
388
+ throw new DryRunRefused(refuseDryRun(ctx.io, Boolean(opts.json), path, "this command cannot plan without making changes; rerun without --dry-run"));
389
+ });
334
390
 
335
391
  program.command("init").description("Create, validate, repair, or join a KXM project")
336
392
  .option("--json", "Print machine-readable JSON")
@@ -1222,6 +1278,7 @@ export async function runCli(
1222
1278
  return result.code;
1223
1279
  } catch (error) {
1224
1280
  if (error instanceof CommanderError) return mapCommanderError(error);
1281
+ if (error instanceof DryRunRefused) return error.code;
1225
1282
  throw error;
1226
1283
  }
1227
1284
  }
@@ -823,8 +823,8 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
823
823
  summary: { type: "string", description: "Promotion summary" },
824
824
  authority: {
825
825
  type: "string",
826
- enum: ["policy", "instruction", "evidence", "hypothesis"],
827
- description: "Authority class",
826
+ enum: ["evidence", "hypothesis"],
827
+ description: "Authority class; an agent's proposal is peer origin, so evidence at most",
828
828
  },
829
829
  confidence: {
830
830
  type: "string",