@kontextmind/kxm 0.7.86 → 0.7.87

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.
@@ -14783,7 +14783,7 @@ import { createHash as createHash5, createHmac } from "node:crypto";
14783
14783
  import { existsSync as existsSync6 } from "node:fs";
14784
14784
  import { createServer } from "node:http";
14785
14785
  import { isIP } from "node:net";
14786
- import { dirname as dirname5, join as join6, resolve as resolve6 } from "node:path";
14786
+ import { dirname as dirname6, join as join6, resolve as resolve6 } from "node:path";
14787
14787
 
14788
14788
  // plugins/kxm/src/protocol.ts
14789
14789
  import { randomUUID } from "node:crypto";
@@ -18213,7 +18213,7 @@ function boundedRefs(value, field) {
18213
18213
  var import_yaml4 = __toESM(require_dist(), 1);
18214
18214
  import { createHash as createHash4 } from "node:crypto";
18215
18215
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, renameSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
18216
- import { join as join4 } from "node:path";
18216
+ import { dirname as dirname4, join as join4 } from "node:path";
18217
18217
  var SKILL_CANDIDATE_SCHEMA = "kxm.skill-candidate.v1";
18218
18218
  var SKILL_EVALUATION_SCHEMA = "kxm.skill-evaluation.v1";
18219
18219
  var SKILL_DECISION_SCHEMA = "kxm.skill-decision.v1";
@@ -18301,10 +18301,22 @@ var SkillLifecycle = class {
18301
18301
  root;
18302
18302
  now;
18303
18303
  allowOptimizationEvals;
18304
+ dryRun;
18305
+ /** What a `dryRun` lifecycle would have written or moved, in order. */
18306
+ planned = [];
18304
18307
  constructor(root, options = {}) {
18305
18308
  this.root = root;
18306
18309
  this.now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
18307
18310
  this.allowOptimizationEvals = options.allowOptimizationEvals === true;
18311
+ this.dryRun = options.dryRun === true;
18312
+ }
18313
+ write(file, content) {
18314
+ if (this.dryRun) {
18315
+ this.planned.push({ action: "write", target: file });
18316
+ return;
18317
+ }
18318
+ mkdirSync2(dirname4(file), { recursive: true });
18319
+ writeFileSync2(file, content);
18308
18320
  }
18309
18321
  dir(state) {
18310
18322
  return join4(this.root, state === "candidate" ? "candidates" : `${state}s`.replace("rejecteds", "rejected").replace("promoteds", "promoted"));
@@ -18317,15 +18329,14 @@ var SkillLifecycle = class {
18317
18329
  return { dir, metadata: join4(dir, "metadata.json"), skill: join4(dir, "SKILL.md") };
18318
18330
  }
18319
18331
  appendHistory(id, record) {
18320
- mkdirSync2(join4(this.root, "history"), { recursive: true });
18321
18332
  const line = `${JSON.stringify(record)}
18322
18333
  `;
18323
18334
  if (existsSync4(this.historyFile(id))) {
18324
18335
  const existing = readFileSync3(this.historyFile(id), "utf8");
18325
18336
  const lines = existing.split("\n").filter((entry) => entry.trim());
18326
- writeFileSync2(this.historyFile(id), [...lines.slice(-499), line.trim()].join("\n") + "\n");
18337
+ this.write(this.historyFile(id), [...lines.slice(-499), line.trim()].join("\n") + "\n");
18327
18338
  } else {
18328
- writeFileSync2(this.historyFile(id), line);
18339
+ this.write(this.historyFile(id), line);
18329
18340
  }
18330
18341
  }
18331
18342
  history(id) {
@@ -18346,6 +18357,10 @@ var SkillLifecycle = class {
18346
18357
  if (!existsSync4(fromDir)) {
18347
18358
  throw new SkillLifecycleError("skill_not_found", `skill ${id} not found in ${from}`);
18348
18359
  }
18360
+ if (this.dryRun) {
18361
+ this.planned.push({ action: "move", target: `${fromDir} -> ${toDir}` });
18362
+ return;
18363
+ }
18349
18364
  mkdirSync2(this.dir(to), { recursive: true });
18350
18365
  if (existsSync4(toDir)) rmSync(toDir, { recursive: true, force: true });
18351
18366
  renameSync(fromDir, toDir);
@@ -18389,7 +18404,7 @@ var SkillLifecycle = class {
18389
18404
  if (!harness) throw new SkillLifecycleError("invalid_skill_compatibility", "compatibility.harness is required");
18390
18405
  const contentSha256 = skillContentSha256(content);
18391
18406
  const id = skillIdFor(name, contentSha256);
18392
- const { dir, metadata, skill } = this.paths("candidate", id);
18407
+ const { metadata, skill } = this.paths("candidate", id);
18393
18408
  if (existsSync4(metadata)) {
18394
18409
  throw new SkillLifecycleError(
18395
18410
  "skill_candidate_exists",
@@ -18409,9 +18424,8 @@ var SkillLifecycle = class {
18409
18424
  createdAt: this.now(),
18410
18425
  ...input.supersedes ? { supersedes: input.supersedes } : {}
18411
18426
  };
18412
- mkdirSync2(dir, { recursive: true });
18413
- writeFileSync2(skill, content);
18414
- writeFileSync2(metadata, `${JSON.stringify(record, null, 2)}
18427
+ this.write(skill, content);
18428
+ this.write(metadata, `${JSON.stringify(record, null, 2)}
18415
18429
  `);
18416
18430
  this.appendHistory(id, { schema: "kxm.skill-history-event.v1", event: "candidate_created", by: createdBy, supersedes: input.supersedes, at: record.createdAt });
18417
18431
  return record;
@@ -18500,18 +18514,15 @@ var SkillLifecycle = class {
18500
18514
  };
18501
18515
  const candidatePaths = this.paths("candidate", candidateId);
18502
18516
  const promotedPaths = this.paths("promoted", candidateId);
18503
- mkdirSync2(promotedPaths.dir, { recursive: true });
18504
18517
  const skillContent = readFileSync3(candidatePaths.skill, "utf8");
18505
18518
  const metadataContent = readFileSync3(candidatePaths.metadata, "utf8");
18506
- writeFileSync2(promotedPaths.skill, skillContent);
18507
- writeFileSync2(promotedPaths.metadata, metadataContent);
18508
- const patchesDir = join4(this.root, "patches");
18509
- mkdirSync2(patchesDir, { recursive: true });
18510
- const patchPath = join4(patchesDir, `${candidateId}.patch`);
18519
+ this.write(promotedPaths.skill, skillContent);
18520
+ this.write(promotedPaths.metadata, metadataContent);
18521
+ const patchPath = join4(this.root, "patches", `${candidateId}.patch`);
18511
18522
  const relSkillPath = `.kxm/skills/promoted/${candidateId}/SKILL.md`;
18512
18523
  const relMetaPath = `.kxm/skills/promoted/${candidateId}/metadata.json`;
18513
18524
  const patch = `${createUnifiedPatch(relSkillPath, skillContent)}${createUnifiedPatch(relMetaPath, metadataContent)}`;
18514
- writeFileSync2(patchPath, patch, "utf8");
18525
+ this.write(patchPath, patch);
18515
18526
  this.appendHistory(candidateId, record);
18516
18527
  return { ...metadata, patch, patchPath };
18517
18528
  }
@@ -19031,7 +19042,7 @@ import {
19031
19042
  unlinkSync,
19032
19043
  writeFileSync as writeFileSync4
19033
19044
  } from "node:fs";
19034
- import { basename as basename3, dirname as dirname4, join as join5, resolve as resolve4 } from "node:path";
19045
+ import { basename as basename3, dirname as dirname5, join as join5, resolve as resolve4 } from "node:path";
19035
19046
 
19036
19047
  // plugins/kxm/src/sqlite.ts
19037
19048
  import { createRequire } from "node:module";
@@ -19078,7 +19089,7 @@ function databaseError(code, file, message) {
19078
19089
  return new KxmConfigError([issue2]);
19079
19090
  }
19080
19091
  function checkedParent(path, description) {
19081
- const parent = dirname4(path);
19092
+ const parent = dirname5(path);
19082
19093
  if (!existsSync5(parent)) mkdirSync4(parent, { recursive: true, mode: 448 });
19083
19094
  const stat = lstatSync2(parent, { throwIfNoEntry: false });
19084
19095
  if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
@@ -20359,7 +20370,7 @@ function createMeshHub(options = {}) {
20359
20370
  const logger = options.logger ?? (() => void 0);
20360
20371
  const hubNow = options.now ?? (() => Date.now());
20361
20372
  const assetsDir2 = options.assetsDir;
20362
- const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve6(dirname5(dirname5(options.dataPath))) : process.cwd());
20373
+ const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve6(dirname6(dirname6(options.dataPath))) : process.cwd());
20363
20374
  const store = new MeshStore(options.dataPath);
20364
20375
  const agents = store.agents;
20365
20376
  const messages = store.messages;
@@ -22556,11 +22567,11 @@ data: ${JSON.stringify({ agent: publicAgent(current, staleAfterMs) })}
22556
22567
 
22557
22568
  // plugins/kxm/src/server.ts
22558
22569
  import { mkdirSync as mkdirSync6, readFileSync as readFileSync5 } from "node:fs";
22559
- import { dirname as dirname7, join as join7, resolve as resolve7 } from "node:path";
22570
+ import { dirname as dirname8, join as join7, resolve as resolve7 } from "node:path";
22560
22571
 
22561
22572
  // plugins/kxm/src/logger.ts
22562
22573
  import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync5, renameSync as renameSync3, statSync as statSync2, unlinkSync as unlinkSync2 } from "node:fs";
22563
- import { dirname as dirname6 } from "node:path";
22574
+ import { dirname as dirname7 } from "node:path";
22564
22575
  var LOG_LEVEL_PRIORITY = {
22565
22576
  debug: 10,
22566
22577
  info: 20,
@@ -22666,7 +22677,7 @@ function createLogger(options) {
22666
22677
  currentSize = 0;
22667
22678
  }
22668
22679
  try {
22669
- mkdirSync5(dirname6(filePath), { recursive: true });
22680
+ mkdirSync5(dirname7(filePath), { recursive: true });
22670
22681
  appendFileSync(filePath, line, { encoding: "utf8", mode: 384 });
22671
22682
  currentSize += lineBytes;
22672
22683
  } catch {
@@ -22729,7 +22740,7 @@ var rateLimitWindowMs = Number.parseInt(
22729
22740
  process.env.KXM_RATE_LIMIT_WINDOW_MS ?? String(DEFAULT_RATE_LIMIT_WINDOW_MS),
22730
22741
  10
22731
22742
  );
22732
- for (const directory of [configDir, logsDir, assetsDir, stateDir, dirname7(logPath)]) {
22743
+ for (const directory of [configDir, logsDir, assetsDir, stateDir, dirname8(logPath)]) {
22733
22744
  mkdirSync6(directory, { recursive: true });
22734
22745
  }
22735
22746
  var structuredLog = createLogger({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.86",
3
+ "version": "0.7.87",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { join, relative } from "node:path";
2
+ import { join, relative, resolve } from "node:path";
3
3
  import { redactSecrets } from "../redact.ts";
4
4
  import {
5
5
  createMemoryNote,
@@ -10,7 +10,7 @@ import {
10
10
  } from "../memory.ts";
11
11
  import { SkillLifecycle, type SkillEvaluationKind, type SkillState } from "../skills.ts";
12
12
  import { writeCompiledWiki } from "../wiki.ts";
13
- import { print, type Runtime } from "./types.ts";
13
+ import { print, printPlan, type Runtime } from "./types.ts";
14
14
 
15
15
  /** Authenticated hub POST for context operations. The CLI operates as the
16
16
  * control plane: the administrative token scopes one project per request. */
@@ -122,6 +122,15 @@ export async function cmdContextPromote(runtime: Runtime, project: string, propo
122
122
  runtime.io.stderr("context promote --evidence must contain at least one durable evidence reference\n");
123
123
  return 2;
124
124
  }
125
+ if (runtime.dryRun) {
126
+ printPlan(
127
+ runtime,
128
+ { command: "context promote", project, proposalId, evidence },
129
+ [{ action: "request", target: `POST ${runtime.serverUrl.replace(/\/$/, "")}/v1/context/state/promote` }],
130
+ `promote proposal ${proposalId} in ${project}`,
131
+ );
132
+ return 0;
133
+ }
125
134
  const response = await hubContextPost({
126
135
  serverUrl: runtime.serverUrl,
127
136
  path: "/v1/context/state/promote",
@@ -158,6 +167,16 @@ export async function cmdContextWikiCompile(runtime: Runtime, project: string, o
158
167
  return 1;
159
168
  }
160
169
  const compiled = response.body as { audit: { pages: string[]; contradictions: number }; pages: { path: string; content: string }[] };
170
+ if (options.out && runtime.dryRun) {
171
+ const outDir = options.out;
172
+ printPlan(
173
+ runtime,
174
+ { command: "context wiki-compile", project, pages: compiled.audit.pages, openContradictions: compiled.audit.contradictions, outDir },
175
+ compiled.pages.map((page) => ({ action: "write", target: resolve(outDir, page.path) })),
176
+ `write ${compiled.pages.length} compiled wiki page(s) to ${outDir}`,
177
+ );
178
+ return 0;
179
+ }
161
180
  let written: string[] = [];
162
181
  if (options.out) {
163
182
  const pages = new Map(compiled.pages.map((page) => [page.path, page.content]));
@@ -233,7 +252,7 @@ export async function cmdSkillsCreate(runtime: Runtime, options: { file: string;
233
252
  }
234
253
 
235
254
  export async function cmdSkillsEvaluate(runtime: Runtime, skillId: string, options: { kind: string; evaluator: string; fail?: boolean | undefined; score?: string | undefined; details?: string | undefined }): Promise<number> {
236
- const lifecycle = new SkillLifecycle(skillsRoot(runtime));
255
+ const lifecycle = new SkillLifecycle(skillsRoot(runtime), { dryRun: runtime.dryRun });
237
256
  try {
238
257
  const outcome = lifecycle.evaluate(skillId, {
239
258
  kind: options.kind as SkillEvaluationKind,
@@ -242,6 +261,10 @@ export async function cmdSkillsEvaluate(runtime: Runtime, skillId: string, optio
242
261
  ...(options.score !== undefined ? { score: Number(options.score) } : {}),
243
262
  ...(options.details ? { details: options.details } : {}),
244
263
  });
264
+ if (runtime.dryRun) {
265
+ printPlan(runtime, { command: "skills evaluate", skillId, quarantined: outcome.quarantined, evaluation: outcome.evaluation }, lifecycle.planned, `record ${options.kind} evaluation${outcome.quarantined ? " (candidate quarantined)" : ""}`);
266
+ return 0;
267
+ }
245
268
  print(runtime.io, runtime.json, { ok: true, command: "skills evaluate", skillId, quarantined: outcome.quarantined, evaluation: outcome.evaluation }, `recorded ${options.kind} evaluation${outcome.quarantined ? " (candidate quarantined)" : ""}`);
246
269
  return 0;
247
270
  } catch (error) {
@@ -252,13 +275,17 @@ export async function cmdSkillsEvaluate(runtime: Runtime, skillId: string, optio
252
275
  }
253
276
 
254
277
  export async function cmdSkillsPromote(runtime: Runtime, skillId: string, options: { decidedBy: string; evidence: string; reason?: string | undefined }): Promise<number> {
255
- const lifecycle = new SkillLifecycle(skillsRoot(runtime));
278
+ const lifecycle = new SkillLifecycle(skillsRoot(runtime), { dryRun: runtime.dryRun });
256
279
  try {
257
280
  const promoted = lifecycle.promote(skillId, {
258
281
  decidedBy: options.decidedBy,
259
282
  reason: options.reason ?? "passed protected evaluation",
260
283
  evidenceRefs: csv(options.evidence) ?? [],
261
284
  });
285
+ if (runtime.dryRun) {
286
+ printPlan(runtime, { command: "skills promote", skillId, metadata: promoted, patchPath: promoted.patchPath }, lifecycle.planned, `promote skill ${skillId}`);
287
+ return 0;
288
+ }
262
289
  print(runtime.io, runtime.json, { ok: true, command: "skills promote", skillId, metadata: promoted, patchPath: promoted.patchPath }, `promoted skill ${skillId} (patch: ${promoted.patchPath})`);
263
290
  return 0;
264
291
  } catch (error) {
@@ -269,9 +296,13 @@ export async function cmdSkillsPromote(runtime: Runtime, skillId: string, option
269
296
  }
270
297
 
271
298
  export async function cmdSkillsReject(runtime: Runtime, skillId: string, options: { decidedBy: string; reason?: string | undefined }): Promise<number> {
272
- const lifecycle = new SkillLifecycle(skillsRoot(runtime));
299
+ const lifecycle = new SkillLifecycle(skillsRoot(runtime), { dryRun: runtime.dryRun });
273
300
  try {
274
301
  const metadata = lifecycle.reject(skillId, { decidedBy: options.decidedBy, reason: options.reason ?? "rejected" });
302
+ if (runtime.dryRun) {
303
+ printPlan(runtime, { command: "skills reject", skillId, metadata }, lifecycle.planned, `reject skill ${skillId} (history retained)`);
304
+ return 0;
305
+ }
275
306
  print(runtime.io, runtime.json, { ok: true, command: "skills reject", skillId, metadata }, `rejected skill ${skillId} (history retained)`);
276
307
  return 0;
277
308
  } catch (error) {
@@ -339,8 +370,13 @@ export async function cmdMemoryNote(
339
370
  scope: (options.scope ?? "project") as MemoryScope,
340
371
  ...(options.kind !== undefined ? { kind: options.kind } : {}),
341
372
  ...(options.body !== undefined ? { body: options.body } : {}),
373
+ dryRun: runtime.dryRun,
342
374
  });
343
375
  const relPath = relative(runtime.cwd, path);
376
+ if (runtime.dryRun) {
377
+ printPlan(runtime, { command: "memory note", candidate: record, path: relPath }, [{ action: "write", target: path }], `record memory candidate in ${relPath} (the id is assigned when it is recorded)`);
378
+ return 0;
379
+ }
344
380
  print(
345
381
  runtime.io,
346
382
  runtime.json,
@@ -357,7 +393,16 @@ export async function cmdMemoryNote(
357
393
 
358
394
  export async function cmdMemorySync(runtime: Runtime): Promise<number> {
359
395
  try {
360
- const result = syncHarnessMemory(runtime.cwd);
396
+ const result = syncHarnessMemory(runtime.cwd, { dryRun: runtime.dryRun });
397
+ if (runtime.dryRun) {
398
+ printPlan(
399
+ runtime,
400
+ { command: "memory sync", ...result },
401
+ [...result.created, ...result.updated].map((file) => ({ action: "write", target: join(runtime.cwd, file) })),
402
+ "regenerate project memory blocks in AGENTS.md, CLAUDE.md, and GEMINI.md",
403
+ );
404
+ return 0;
405
+ }
361
406
  print(
362
407
  runtime.io,
363
408
  runtime.json,
@@ -6,7 +6,7 @@ import { defaultProjectName } from "../project-name.ts";
6
6
  import { hasClientHubCredential, resolveClientHubAuthToken } from "../hub-env.ts";
7
7
  import { agentWorker, type Worker } from "../envelope.ts";
8
8
  import { MESH_TUI_PANELS, runMeshTui, type MeshTuiPanel } from "../tui.ts";
9
- import { formatSessionBriefText, loadSessionBriefAsync, type SessionHubStatus } from "../session-work.ts";
9
+ import { formatSessionBriefText, loadSessionBriefAsync, readCachedSessionBrief, type SessionHubStatus } from "../session-work.ts";
10
10
  import {
11
11
  HUB_BINDING_SCHEMA,
12
12
  HubBindingError,
@@ -47,11 +47,13 @@ import {
47
47
  import { createSession, loadNamedWorkers, rosterNames, sessionAssetDirs, workflowAssetDirs, writeSession } from "../session.ts";
48
48
  import {
49
49
  print,
50
+ printPlan,
50
51
  printWorker,
51
52
  hostMode,
52
53
  workspaceEnv,
53
54
  spawnScript,
54
55
  processExists,
56
+ type PlannedChange,
55
57
  type Runtime,
56
58
  } from "./types.ts";
57
59
 
@@ -140,7 +142,7 @@ export async function refreshKxmUpdateNotice(
140
142
  }
141
143
  const fetched = await fetchLatestKxmVersion(resolved.source, runtime.env, runtime.fetchImpl);
142
144
  const notice = noticeFromVersions(installed, fetched.latest, resolved, fetched.error, fetched.asset);
143
- writeUpdateCache(runtime.dirs.state, notice);
145
+ if (!runtime.dryRun) writeUpdateCache(runtime.dirs.state, notice);
144
146
  return notice;
145
147
  }
146
148
 
@@ -556,6 +558,12 @@ export async function cmdSessionStatus(runtime: Runtime): Promise<number> {
556
558
  }
557
559
 
558
560
  export async function cmdAuthToken(runtime: Runtime, options: { status?: boolean | undefined; clear?: boolean | undefined; issue?: boolean | undefined } = {}): Promise<number> {
561
+ const tokenFile = sessionTokenPath(runtime.env.KXM_USER_CONFIG_DIR);
562
+ if (options.clear && runtime.dryRun) {
563
+ const present = existsSync(tokenFile);
564
+ printPlan(runtime, { command: "auth token", cleared: present }, present ? [{ action: "delete", target: tokenFile }] : [], present ? "clear the session token from disk" : "no session token file to clear");
565
+ return 0;
566
+ }
559
567
  if (options.clear) {
560
568
  const cleared = clearSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
561
569
  print(
@@ -615,18 +623,19 @@ export async function cmdAuthToken(runtime: Runtime, options: { status?: boolean
615
623
  return 1;
616
624
  }
617
625
 
626
+ const existingToken = options.issue ? undefined : readSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
627
+ if (runtime.dryRun && !existingToken) {
628
+ // The token is only worth printing once it is on disk; a dry run never is.
629
+ printPlan(runtime, { command: "auth token" }, [{ action: "write", target: tokenFile }], "issue an operator session token and persist it");
630
+ return 0;
631
+ }
632
+
618
633
  let token: string;
619
- if (options.issue) {
634
+ if (existingToken) {
635
+ token = existingToken.token;
636
+ } else {
620
637
  token = mintSessionToken({ preset: "operator" });
621
638
  persistSessionTokenToDisk(token, { userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
622
- } else {
623
- const existing = readSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
624
- if (existing) {
625
- token = existing.token;
626
- } else {
627
- token = mintSessionToken({ preset: "operator" });
628
- persistSessionTokenToDisk(token, { userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
629
- }
630
639
  }
631
640
 
632
641
  print(runtime.io, runtime.json, { ok: true, command: "auth token", token }, token);
@@ -635,11 +644,17 @@ export async function cmdAuthToken(runtime: Runtime, options: { status?: boolean
635
644
 
636
645
  export async function cmdSessionBrief(runtime: Runtime, options: { status?: boolean | undefined; token?: boolean | undefined } = {}): Promise<number> {
637
646
  const existing = readSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
638
- const sessionToken = existing ? existing.token : mintSessionToken({ preset: "operator" });
639
- if (!existing) {
647
+ // A dry run neither mints nor persists a token; it names the file it would write.
648
+ const planned: PlannedChange[] = existing ? [] : [{ action: "write", target: sessionTokenPath(runtime.env.KXM_USER_CONFIG_DIR) }];
649
+ const sessionToken = existing?.token ?? (runtime.dryRun ? undefined : mintSessionToken({ preset: "operator" }));
650
+ if (!existing && sessionToken) {
640
651
  persistSessionTokenToDisk(sessionToken, { userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
641
652
  }
642
653
  if (options.token) {
654
+ if (!sessionToken) {
655
+ printPlan(runtime, { command: "session brief" }, planned, "issue an operator session token and persist it");
656
+ return 0;
657
+ }
643
658
  print(runtime.io, runtime.json, { ok: true, command: "session brief", sessionToken }, sessionToken);
644
659
  return 0;
645
660
  }
@@ -668,9 +683,19 @@ export async function cmdSessionBrief(runtime: Runtime, options: { status?: bool
668
683
 
669
684
  const brief = await loadSessionBriefAsync(runtime.dirs.workdir, env, undefined, hub, {
670
685
  fetchImpl: runtime.fetchImpl,
671
- sessionToken,
686
+ ...(sessionToken ? { sessionToken } : {}),
687
+ writeCache: !runtime.dryRun,
672
688
  });
673
689
 
690
+ if (runtime.dryRun) {
691
+ if (!readCachedSessionBrief(runtime.dirs.state)) planned.push({ action: "write", target: join(runtime.dirs.state, "session-brief.json") });
692
+ print(runtime.io, runtime.json, { ...brief, dryRun: true, planned }, [
693
+ options.status ? brief.statusLine : formatSessionBriefText(brief),
694
+ ...planned.map((change) => `dry run: would ${change.action} ${change.target}`),
695
+ ].join("\n"));
696
+ return 0;
697
+ }
698
+
674
699
  if (options.status) {
675
700
  print(runtime.io, runtime.json, brief, brief.statusLine);
676
701
  return 0;
@@ -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",