@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
@@ -303,8 +303,8 @@ export function setKxmConfigValue(
303
303
  repoRoot: string,
304
304
  keyPath: string,
305
305
  value: unknown,
306
- options: { scope?: "user" | "project"; userConfigDir?: string } = {},
307
- ): void {
306
+ options: { scope?: "user" | "project"; userConfigDir?: string; dryRun?: boolean } = {},
307
+ ): { file: string } {
308
308
  const targetFile = kxmConfigFileForScope(repoRoot, options.scope ?? "project", options.userConfigDir);
309
309
  const existing = readConfigFile(targetFile);
310
310
  const parts = configKeyParts(keyPath);
@@ -317,7 +317,8 @@ export function setKxmConfigValue(
317
317
  cursor = cursor[p] as Record<string, unknown>;
318
318
  }
319
319
  cursor[parts[parts.length - 1]!] = value;
320
- writeConfigFile(targetFile, existing);
320
+ if (!options.dryRun) writeConfigFile(targetFile, existing);
321
+ return { file: targetFile };
321
322
  }
322
323
 
323
324
  /**
@@ -44,6 +44,10 @@ export interface StateChangeProposal {
44
44
  confidence: ContextItem["confidence"];
45
45
  evidenceRefs: string[];
46
46
  proposedBy: string;
47
+ /** Origin of the credential the hub verified for the proposer: an agent
48
+ * key is `peer` (evidence at most), the administrative token `human`.
49
+ * Never inferred from `proposedBy`, which is a label, not a credential. */
50
+ origin: "peer" | "human";
47
51
  supersedes?: string[];
48
52
  }
49
53
 
@@ -649,11 +649,20 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
649
649
  return stores;
650
650
  }
651
651
 
652
- export function createBackup(options: {
652
+ export interface BackupPlan {
653
+ projectRoot: string;
654
+ outDir: string;
655
+ createdAt: string;
656
+ stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
657
+ }
658
+
659
+ /** Which stores a backup would copy and where, without opening any of them.
660
+ * Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
661
+ export function planBackup(options: {
653
662
  projectRoot?: string;
654
663
  outDir?: string;
655
664
  hubDataPath?: string;
656
- } = {}): { manifest: BackupManifest; outDir: string } {
665
+ } = {}): BackupPlan {
657
666
  const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
658
667
  const stores = discoverProjectStores(projectRoot, {
659
668
  ...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
@@ -663,35 +672,43 @@ export function createBackup(options: {
663
672
  throw databaseError("backup_no_stores", projectRoot, "no existing SQLite stores found to backup");
664
673
  }
665
674
 
666
- const now = new Date();
667
- const timestamp = now.toISOString().replace(/[:.]/g, "-");
668
- const backupId = `bk_${randomBytes(8).toString("hex")}`;
675
+ const createdAt = new Date().toISOString();
676
+ const timestamp = createdAt.replace(/[:.]/g, "-");
669
677
  const outDir = options.outDir ? resolve(options.outDir) : join(projectRoot, ".kxm", "backups", `backup-${timestamp}`);
670
-
671
- if (!existsSync(outDir)) {
672
- mkdirSync(outDir, { recursive: true, mode: 0o700 });
673
- }
674
-
675
- const backedUpStores: BackupStoreRecord[] = [];
676
678
  const usedFilenames = new Set<string>();
677
-
678
- for (const store of stores) {
679
+ const planned = stores.map((store) => {
679
680
  let filename = basename(store.sourcePath);
680
681
  if (usedFilenames.has(filename)) {
681
682
  const sanitizedId = store.storeId.replace(/[^a-zA-Z0-9_.-]/g, "_");
682
683
  filename = `${sanitizedId}-${filename}`;
683
684
  }
684
685
  usedFilenames.add(filename);
686
+ return { storeId: store.storeId, sourcePath: store.sourcePath, backupFile: filename };
687
+ });
688
+ return { projectRoot, outDir, createdAt, stores: planned };
689
+ }
690
+
691
+ export function createBackup(options: {
692
+ projectRoot?: string;
693
+ outDir?: string;
694
+ hubDataPath?: string;
695
+ } = {}): { manifest: BackupManifest; outDir: string } {
696
+ const { projectRoot, outDir, createdAt, stores } = planBackup(options);
697
+ const backupId = `bk_${randomBytes(8).toString("hex")}`;
685
698
 
686
- const targetFile = join(outDir, filename);
687
- const record = backupDatabaseFile(store.sourcePath, targetFile, store.storeId);
688
- backedUpStores.push(record);
699
+ if (!existsSync(outDir)) {
700
+ mkdirSync(outDir, { recursive: true, mode: 0o700 });
701
+ }
702
+
703
+ const backedUpStores: BackupStoreRecord[] = [];
704
+ for (const store of stores) {
705
+ backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
689
706
  }
690
707
 
691
708
  const manifest: BackupManifest = {
692
709
  schema: "kxm.backup-manifest.v1",
693
710
  backupId,
694
- createdAt: now.toISOString(),
711
+ createdAt,
695
712
  projectRoot,
696
713
  stores: backedUpStores,
697
714
  };
@@ -707,10 +724,19 @@ export function createBackup(options: {
707
724
  return { manifest, outDir };
708
725
  }
709
726
 
710
- export function restoreBackup(
727
+ export interface RestorePlan {
728
+ manifestPath: string;
729
+ backupId: string;
730
+ stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
731
+ }
732
+
733
+ /** Everything restore checks before it overwrites anything: the manifest, each
734
+ * backup file's digest, and each store's schema against this build's ceiling
735
+ * (from the manifest). Reads files; opens no database. */
736
+ export function planRestore(
711
737
  manifestPathOrDir: string,
712
738
  options: { projectRoot?: string } = {},
713
- ): RestoreResult {
739
+ ): RestorePlan {
714
740
  let manifestPath = resolve(manifestPathOrDir);
715
741
  const stat = lstatSync(manifestPath, { throwIfNoEntry: false });
716
742
  if (!stat) {
@@ -737,8 +763,7 @@ export function restoreBackup(
737
763
  throw databaseError("restore_manifest_invalid", manifestPath, "manifest is not a valid kxm.backup-manifest.v1 document");
738
764
  }
739
765
 
740
- const restoredStores: RestoreStoreRecord[] = [];
741
-
766
+ const stores: RestorePlan["stores"] = [];
742
767
  for (const store of manifest.stores) {
743
768
  const backupFilePath = join(manifestDir, store.backupFile);
744
769
  if (!existsSync(backupFilePath)) {
@@ -754,27 +779,41 @@ export function restoreBackup(
754
779
  );
755
780
  }
756
781
 
757
- const maxSupported = kxmBackupCeiling(store.storeId);
782
+ const maxSupportedVersion = kxmBackupCeiling(store.storeId);
783
+ if (store.schemaVersion > maxSupportedVersion) {
784
+ throw databaseError(
785
+ "runtime_schema_newer",
786
+ backupFilePath,
787
+ `backup store ${store.storeId} schema version ${store.schemaVersion} is newer than supported maximum ${maxSupportedVersion}`,
788
+ );
789
+ }
758
790
 
759
791
  let targetPath = store.sourcePath;
760
792
  if (options.projectRoot && manifest.projectRoot && targetPath.startsWith(manifest.projectRoot)) {
761
793
  const rel = targetPath.slice(manifest.projectRoot.length).replace(/^[\\/]+/, "");
762
794
  targetPath = join(resolve(options.projectRoot), rel);
763
795
  }
764
-
765
- const result = restoreDatabaseFile(
766
- backupFilePath,
767
- targetPath,
768
- store.storeId,
769
- store.schemaVersion,
770
- maxSupported,
771
- );
772
- restoredStores.push(result);
796
+ stores.push({ storeId: store.storeId, backupFilePath, targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
773
797
  }
774
798
 
799
+ return { manifestPath, backupId: manifest.backupId, stores };
800
+ }
801
+
802
+ export function restoreBackup(
803
+ manifestPathOrDir: string,
804
+ options: { projectRoot?: string } = {},
805
+ ): RestoreResult {
806
+ const plan = planRestore(manifestPathOrDir, options);
807
+ const restoredStores = plan.stores.map((store) => restoreDatabaseFile(
808
+ store.backupFilePath,
809
+ store.targetPath,
810
+ store.storeId,
811
+ store.schemaVersion,
812
+ store.maxSupportedVersion,
813
+ ));
775
814
  return {
776
- manifestPath,
777
- backupId: manifest.backupId,
815
+ manifestPath: plan.manifestPath,
816
+ backupId: plan.backupId,
778
817
  restoredStores,
779
818
  };
780
819
  }
@@ -601,9 +601,12 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
601
601
 
602
602
  /** Context callers authenticate either as a registered agent (project-
603
603
  * scoped to their own project) or with the administrative token (single
604
- * explicit project scope per request). Returns the validated project and
605
- * a stable caller identity for provenance. */
606
- function contextCallerProject(request: IncomingMessage, requested: unknown): { project: string; caller: string } {
604
+ * explicit project scope per request). Returns the validated project, a
605
+ * stable caller identity for provenance, and which credential proved it. */
606
+ function contextCallerProject(
607
+ request: IncomingMessage,
608
+ requested: unknown,
609
+ ): { project: string; caller: string; credential: "agent" | "admin" } {
607
610
  const project = requireString(requested, "project", { max: 200 });
608
611
  const agentHeader = request.headers["x-kxm-agent-id"];
609
612
  if (typeof agentHeader === "string" && agentHeader.trim()) {
@@ -612,14 +615,14 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
612
615
  if (agent.project !== project) {
613
616
  throw new ProtocolError(403, "context requests are limited to the agent's project", "context_isolation_violation");
614
617
  }
615
- return { project, caller: agent.id };
618
+ return { project, caller: agent.id, credential: "agent" };
616
619
  }
617
620
  requireAdminAuth(request);
618
621
  const callerHeader = request.headers["x-kxm-caller-id"];
619
622
  const caller = typeof callerHeader === "string" && callerHeader.trim()
620
623
  ? callerHeader.trim()
621
624
  : "kxm-admin";
622
- return { project, caller };
625
+ return { project, caller, credential: "admin" };
623
626
  }
624
627
 
625
628
  function requireAdminAuth(request: IncomingMessage): void {
@@ -1829,7 +1832,21 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1829
1832
  const contextStateProposeMatch = url.pathname.match(/^\/v1\/context\/state\/propose$/);
1830
1833
  if (method === "POST" && contextStateProposeMatch) {
1831
1834
  const body = await readJson(request);
1832
- const { project: callerProject, caller: callerId } = contextCallerProject(request, body.project);
1835
+ const { project: callerProject, caller: callerId, credential } = contextCallerProject(request, body.project);
1836
+ // Origin follows the credential, never a name: an agent key proposes as
1837
+ // a peer, capped at evidence by the grant floor. Human origin can claim
1838
+ // policy, so it takes the configured administrative token with no
1839
+ // loopback bypass, as promotion does.
1840
+ if (credential === "admin") requireConfiguredAdminAuth(request, "human-origin state proposals");
1841
+ if (body.proposedBy !== undefined && body.proposedBy !== callerId) {
1842
+ logger({ event: "security_alert", alert: "state_proposer_mismatch", project: callerProject, callerId, credential });
1843
+ throw new ProtocolError(
1844
+ 403,
1845
+ "proposedBy must name the authenticated caller",
1846
+ "state_proposer_mismatch",
1847
+ );
1848
+ }
1849
+ const origin = credential === "agent" ? "peer" : "human";
1833
1850
  const proposalId = await stateProvider.propose({
1834
1851
  schema: "kxm.state-change-proposal.v1",
1835
1852
  project: callerProject,
@@ -1838,11 +1855,12 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1838
1855
  authority: parseContextAuthority(body.authority),
1839
1856
  confidence: parseContextConfidence(body.confidence),
1840
1857
  evidenceRefs: boundedStringList(body.evidenceRefs, "evidenceRefs", 32),
1841
- proposedBy: (typeof body.proposedBy === "string" && body.proposedBy.trim()) ? body.proposedBy.trim() : callerId,
1858
+ proposedBy: callerId,
1859
+ origin,
1842
1860
  });
1843
1861
  counters.contextRequests += 1;
1844
1862
  publishOps(callerProject, "workflows");
1845
- logger({ event: "context_state_proposed", project: callerProject, proposalId, proposedBy: callerId });
1863
+ logger({ event: "context_state_proposed", project: callerProject, proposalId, proposedBy: callerId, origin });
1846
1864
  json(response, 201, { proposalId });
1847
1865
  return;
1848
1866
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
- import { DatabaseSync } from "./sqlite.ts";
4
+ import { DatabaseSync, openReadOnlyDatabase } from "./sqlite.ts";
5
5
  import type { AgentIdentity, AgentRecord, MessageRecord } from "./protocol.ts";
6
6
  import type { WorkflowRun } from "./workflow.ts";
7
7
  import { readRoutingRecords } from "./telemetry.ts";
@@ -250,7 +250,7 @@ export function loadLocalMeshSnapshot(
250
250
  let plans: MeshTuiPlan[] = [];
251
251
  if (existsSync(dataPath)) {
252
252
  hasLegacy = true;
253
- const database = new DatabaseSync(dataPath, { readOnly: true });
253
+ const database = openReadOnlyDatabase(dataPath);
254
254
  try {
255
255
  database.exec("PRAGMA busy_timeout = 5000");
256
256
  // Stored rows carry identity only. Presence is derived here against the
@@ -287,7 +287,7 @@ export function loadLocalMeshSnapshot(
287
287
  if (existsSync(registryDbPath)) {
288
288
  hasKxm = true;
289
289
  try {
290
- const regDb = new DatabaseSync(registryDbPath, { readOnly: true });
290
+ const regDb = openReadOnlyDatabase(registryDbPath);
291
291
  try {
292
292
  regDb.exec("PRAGMA busy_timeout = 5000");
293
293
  const pRows = regDb.prepare("SELECT project_key FROM projects").all() as Array<{ project_key: string }>;
@@ -315,7 +315,7 @@ export function loadLocalMeshSnapshot(
315
315
  if (existsSync(eventDbPath)) {
316
316
  hasKxm = true;
317
317
  try {
318
- const eventDb = new DatabaseSync(eventDbPath, { readOnly: true });
318
+ const eventDb = openReadOnlyDatabase(eventDbPath);
319
319
  try {
320
320
  eventDb.exec("PRAGMA busy_timeout = 5000");
321
321
  const runRows = eventDb.prepare(`
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
8
8
  import { deliverInboxNotification } from "./inbox.ts";
9
9
  import type { HubEvent, MessageRecord } from "./protocol.ts";
10
10
 
11
- const VERSION = "0.7.86";
11
+ const VERSION = "0.7.88";
12
12
  const inbox = new Map<string, MessageRecord>();
13
13
  const notifiedInbox = new Set<string>();
14
14
  let meshClient: HubClient | undefined;
@@ -245,10 +245,10 @@ export function createMemoryNote(
245
245
  body?: string | undefined;
246
246
  sourceRef?: string | undefined;
247
247
  runId?: string | undefined;
248
+ dryRun?: boolean | undefined;
248
249
  } = {},
249
250
  ): { record: MemoryRecord; path: string } {
250
251
  const { candidatesDir } = memoryDirectories(repoRoot);
251
- mkdirSync(candidatesDir, { recursive: true });
252
252
 
253
253
  const scope: MemoryScope = options.scope ?? "project";
254
254
  if (!VALID_SCOPES.has(scope)) {
@@ -277,6 +277,8 @@ export function createMemoryNote(
277
277
  };
278
278
 
279
279
  const targetPath = join(candidatesDir, `${id}.md`);
280
+ if (options.dryRun) return { record, path: targetPath };
281
+ mkdirSync(candidatesDir, { recursive: true });
280
282
  writeFileSync(targetPath, formatMemoryRecord(record), "utf8");
281
283
  return { record, path: targetPath };
282
284
  }
@@ -316,7 +318,8 @@ export function formatHarnessMemoryBlock(records: MemoryRecord[]): string {
316
318
  return contentLines.join("\n");
317
319
  }
318
320
 
319
- export function updateHarnessDocument(filePath: string, block: string, defaultHeader: string): boolean {
321
+ /** Returns whether the projection block changed the file; `dryRun` answers without writing. */
322
+ export function updateHarnessDocument(filePath: string, block: string, defaultHeader: string, dryRun = false): boolean {
320
323
  let original = "";
321
324
  if (existsSync(filePath)) {
322
325
  original = readFileSync(filePath, "utf8");
@@ -340,13 +343,13 @@ export function updateHarnessDocument(filePath: string, block: string, defaultHe
340
343
  }
341
344
 
342
345
  if (updated !== original) {
343
- writeFileSync(filePath, updated, "utf8");
346
+ if (!dryRun) writeFileSync(filePath, updated, "utf8");
344
347
  return true;
345
348
  }
346
349
  return false;
347
350
  }
348
351
 
349
- export function syncHarnessMemory(repoRoot: string): { updated: string[]; created: string[] } {
352
+ export function syncHarnessMemory(repoRoot: string, options: { dryRun?: boolean } = {}): { updated: string[]; created: string[] } {
350
353
  const root = resolve(repoRoot);
351
354
  const records = loadAuthoredMemory(root);
352
355
  const block = formatHarnessMemoryBlock(records);
@@ -358,7 +361,7 @@ export function syncHarnessMemory(repoRoot: string): { updated: string[]; create
358
361
  const agentsPath = join(root, "AGENTS.md");
359
362
  const agentsHeader = "# AGENTS\n\nFollow project instructions.\n";
360
363
  const agentsExisted = existsSync(agentsPath);
361
- if (updateHarnessDocument(agentsPath, block, agentsHeader)) {
364
+ if (updateHarnessDocument(agentsPath, block, agentsHeader, options.dryRun)) {
362
365
  if (agentsExisted) updated.push("AGENTS.md");
363
366
  else created.push("AGENTS.md");
364
367
  }
@@ -367,7 +370,7 @@ export function syncHarnessMemory(repoRoot: string): { updated: string[]; create
367
370
  const claudePath = join(root, "CLAUDE.md");
368
371
  const claudeHeader = `# KXM (Claude)\n\nFollow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:\n[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).\n\nYou are the **planner / architecture critic** unless the human explicitly asks\nyou to implement. Default writer is native Grok CLI (\`grok --model grok-4.6\`) — a starting\nrotation, not a sole writer. If \`grok\` is logged out, never bill Grok through another harness;\nuse a relief route Tracking **admits**, or stop and name the limits hit. Your reviews are\nartifacts, not hub \`peer-reply\` evidence.\n`;
369
372
  const claudeExisted = existsSync(claudePath);
370
- if (updateHarnessDocument(claudePath, block, claudeHeader)) {
373
+ if (updateHarnessDocument(claudePath, block, claudeHeader, options.dryRun)) {
371
374
  if (claudeExisted) updated.push("CLAUDE.md");
372
375
  else created.push("CLAUDE.md");
373
376
  }
@@ -376,7 +379,7 @@ export function syncHarnessMemory(repoRoot: string): { updated: string[]; create
376
379
  const geminiPath = join(root, "GEMINI.md");
377
380
  const geminiHeader = `# KXM (Gemini / Antigravity)\n\nFollow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:\n[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).\n\nGoogle goes through the \`antigravity\` **Pi provider** (Tracking \u2192 Decided,\n2026-09-15); \`agy\` stays a harness catalog/helper entry, not the admission path.\nCurrent admissions come from Tracking and \`kxm harness list\`. Starting rotation\nremains Grok.\n`;
378
381
  const geminiExisted = existsSync(geminiPath);
379
- if (updateHarnessDocument(geminiPath, block, geminiHeader)) {
382
+ if (updateHarnessDocument(geminiPath, block, geminiHeader, options.dryRun)) {
380
383
  if (geminiExisted) updated.push("GEMINI.md");
381
384
  else created.push("GEMINI.md");
382
385
  }
@@ -308,18 +308,21 @@ export function addRole(
308
308
  repoRoot?: string | undefined;
309
309
  userConfigDir?: string | undefined;
310
310
  overwrite?: boolean | undefined;
311
+ dryRun?: boolean | undefined;
311
312
  } = {},
312
313
  ): { id: string; filePath: string; scope: "global" | "local" } {
313
314
  const scope = options.scope ?? "local";
314
315
  const repoRoot = options.repoRoot ?? process.cwd();
315
- const dir = ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
316
+ const dir = options.dryRun
317
+ ? rolesDirectory(scope, repoRoot, options.userConfigDir)
318
+ : ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
316
319
  const filePath = join(dir, `${role.id}.yaml`);
317
320
 
318
321
  if (existsSync(filePath) && !options.overwrite) {
319
322
  throw new Error(`role_already_exists: role '${role.id}' already exists at ${filePath}`);
320
323
  }
321
324
 
322
- writeFileSync(filePath, stringify(role), "utf8");
325
+ if (!options.dryRun) writeFileSync(filePath, stringify(role), "utf8");
323
326
  return { id: role.id, filePath, scope };
324
327
  }
325
328
 
@@ -329,6 +332,7 @@ export function removeRole(
329
332
  scope?: "global" | "local" | undefined;
330
333
  repoRoot?: string | undefined;
331
334
  userConfigDir?: string | undefined;
335
+ dryRun?: boolean | undefined;
332
336
  } = {},
333
337
  ): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
334
338
  const scope = options.scope ?? "local";
@@ -340,6 +344,7 @@ export function removeRole(
340
344
  throw new Error(`role_not_found: role '${roleId}' not found in ${scope} directory (${filePath})`);
341
345
  }
342
346
 
347
+ if (options.dryRun) return { id: roleId, removed: false, filePath, scope };
343
348
  rmSync(filePath);
344
349
  return { id: roleId, removed: true, filePath, scope };
345
350
  }
@@ -351,6 +356,7 @@ export function modifyRole(
351
356
  scope?: "global" | "local" | undefined;
352
357
  repoRoot?: string | undefined;
353
358
  userConfigDir?: string | undefined;
359
+ dryRun?: boolean | undefined;
354
360
  } = {},
355
361
  ): { id: string; role: KxmRoleDefinition; filePath: string; scope: "global" | "local" } {
356
362
  const target = getRole(roleId, { scope: options.scope, repoRoot: options.repoRoot, userConfigDir: options.userConfigDir });
@@ -367,10 +373,12 @@ export function modifyRole(
367
373
 
368
374
  const scope = options.scope ?? target.scope;
369
375
  const repoRoot = options.repoRoot ?? process.cwd();
370
- const dir = ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
376
+ const dir = options.dryRun
377
+ ? rolesDirectory(scope, repoRoot, options.userConfigDir)
378
+ : ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
371
379
  const filePath = join(dir, `${roleId}.yaml`);
372
380
 
373
- writeFileSync(filePath, stringify(updated), "utf8");
381
+ if (!options.dryRun) writeFileSync(filePath, stringify(updated), "utf8");
374
382
  return { id: roleId, role: updated, filePath, scope };
375
383
  }
376
384
 
@@ -562,17 +570,19 @@ export function saveRoleHostsConfig(
562
570
  repoRoot?: string | undefined;
563
571
  userConfigDir?: string | undefined;
564
572
  format?: "yaml" | "json" | undefined;
573
+ dryRun?: boolean | undefined;
565
574
  } = {},
566
575
  ): { filePath: string; scope: "global" | "local" } {
567
576
  const scope = options.scope ?? "local";
568
577
  const repoRoot = options.repoRoot ?? process.cwd();
569
578
  const dir = scope === "global" ? userConfigDirectory(options.userConfigDir) : repoConfigDirectory(repoRoot);
579
+ const format = options.format ?? "yaml";
580
+ const filePath = join(dir, format === "json" ? "role-hosts.json" : "role-hosts.yaml");
581
+ if (options.dryRun) return { filePath, scope };
570
582
  if (!existsSync(dir)) {
571
583
  mkdirSync(dir, { recursive: true });
572
584
  }
573
585
 
574
- const format = options.format ?? "yaml";
575
- const filePath = join(dir, format === "json" ? "role-hosts.json" : "role-hosts.yaml");
576
586
  const payload: RoleHostsConfig = {
577
587
  schema: KXM_ROLE_HOSTS_SCHEMA,
578
588
  seats: config.seats ?? {},
@@ -594,6 +604,7 @@ export function setRoleSeatHost(
594
604
  repoRoot?: string | undefined;
595
605
  userConfigDir?: string | undefined;
596
606
  format?: "yaml" | "json" | undefined;
607
+ dryRun?: boolean | undefined;
597
608
  } = {},
598
609
  ): { filePath: string; seatId: string; binding: RoleSeatBinding; scope: "global" | "local" } {
599
610
  const scope = options.scope ?? "local";
@@ -624,6 +635,7 @@ export function setRoleSeatHost(
624
635
  repoRoot: options.repoRoot,
625
636
  userConfigDir: options.userConfigDir,
626
637
  format: options.format,
638
+ dryRun: options.dryRun,
627
639
  });
628
640
 
629
641
  return { filePath: saved.filePath, seatId, binding, scope };
@@ -398,6 +398,8 @@ export function loadSessionBrief(
398
398
  cost?: string;
399
399
  sessionToken?: string;
400
400
  ship?: SessionShipStatus;
401
+ /** false answers from disk and the hub without refreshing session-brief.json. */
402
+ writeCache?: boolean;
401
403
  } = {},
402
404
  ): SessionBrief {
403
405
  const paths = resolveKxmSnapshotPaths(cwd, env);
@@ -464,7 +466,7 @@ export function loadSessionBrief(
464
466
  );
465
467
  }
466
468
 
467
- writeCachedSessionBrief(paths.stateDir, brief);
469
+ if (options.writeCache !== false) writeCachedSessionBrief(paths.stateDir, brief);
468
470
  return brief;
469
471
  }
470
472
 
@@ -479,6 +481,7 @@ export async function loadSessionBriefAsync(
479
481
  cost?: string;
480
482
  sessionToken?: string;
481
483
  ship?: SessionShipStatus;
484
+ writeCache?: boolean;
482
485
  } = {},
483
486
  ): Promise<SessionBrief> {
484
487
  const paths = resolveKxmSnapshotPaths(cwd, env);
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import { redactSecrets } from "./redact.ts";
6
6
 
@@ -190,17 +190,32 @@ export interface SkillLifecycleOptions {
190
190
  * evals. When unset, `optimization` evaluations are rejected. */
191
191
  allowOptimizationEvals?: boolean;
192
192
  now?: () => string;
193
+ /** Validate and record every change in `planned` without touching disk. */
194
+ dryRun?: boolean;
193
195
  }
194
196
 
195
197
  export class SkillLifecycle {
196
198
  private readonly root: string;
197
199
  private readonly now: () => string;
198
200
  private readonly allowOptimizationEvals: boolean;
201
+ private readonly dryRun: boolean;
202
+ /** What a `dryRun` lifecycle would have written or moved, in order. */
203
+ readonly planned: Array<{ action: "write" | "move"; target: string }> = [];
199
204
 
200
205
  constructor(root: string, options: SkillLifecycleOptions = {}) {
201
206
  this.root = root;
202
207
  this.now = options.now ?? (() => new Date().toISOString());
203
208
  this.allowOptimizationEvals = options.allowOptimizationEvals === true;
209
+ this.dryRun = options.dryRun === true;
210
+ }
211
+
212
+ private write(file: string, content: string): void {
213
+ if (this.dryRun) {
214
+ this.planned.push({ action: "write", target: file });
215
+ return;
216
+ }
217
+ mkdirSync(dirname(file), { recursive: true });
218
+ writeFileSync(file, content);
204
219
  }
205
220
 
206
221
  private dir(state: SkillState): string {
@@ -217,15 +232,14 @@ export class SkillLifecycle {
217
232
  }
218
233
 
219
234
  private appendHistory(id: string, record: unknown): void {
220
- mkdirSync(join(this.root, "history"), { recursive: true });
221
235
  const line = `${JSON.stringify(record)}\n`;
222
236
  if (existsSync(this.historyFile(id))) {
223
237
  // Bound the history file: append within the audit limit.
224
238
  const existing = readFileSync(this.historyFile(id), "utf8");
225
239
  const lines = existing.split("\n").filter((entry) => entry.trim());
226
- writeFileSync(this.historyFile(id), [...lines.slice(-499), line.trim()].join("\n") + "\n");
240
+ this.write(this.historyFile(id), [...lines.slice(-499), line.trim()].join("\n") + "\n");
227
241
  } else {
228
- writeFileSync(this.historyFile(id), line);
242
+ this.write(this.historyFile(id), line);
229
243
  }
230
244
  }
231
245
 
@@ -252,6 +266,10 @@ export class SkillLifecycle {
252
266
  if (!existsSync(fromDir)) {
253
267
  throw new SkillLifecycleError("skill_not_found", `skill ${id} not found in ${from}`);
254
268
  }
269
+ if (this.dryRun) {
270
+ this.planned.push({ action: "move", target: `${fromDir} -> ${toDir}` });
271
+ return;
272
+ }
255
273
  mkdirSync(this.dir(to), { recursive: true });
256
274
  if (existsSync(toDir)) rmSync(toDir, { recursive: true, force: true });
257
275
  renameSync(fromDir, toDir);
@@ -299,7 +317,7 @@ export class SkillLifecycle {
299
317
 
300
318
  const contentSha256 = skillContentSha256(content);
301
319
  const id = skillIdFor(name, contentSha256);
302
- const { dir, metadata, skill } = this.paths("candidate", id);
320
+ const { metadata, skill } = this.paths("candidate", id);
303
321
  if (existsSync(metadata)) {
304
322
  throw new SkillLifecycleError(
305
323
  "skill_candidate_exists",
@@ -319,9 +337,8 @@ export class SkillLifecycle {
319
337
  createdAt: this.now(),
320
338
  ...(input.supersedes ? { supersedes: input.supersedes } : {}),
321
339
  };
322
- mkdirSync(dir, { recursive: true });
323
- writeFileSync(skill, content);
324
- writeFileSync(metadata, `${JSON.stringify(record, null, 2)}\n`);
340
+ this.write(skill, content);
341
+ this.write(metadata, `${JSON.stringify(record, null, 2)}\n`);
325
342
  this.appendHistory(id, { schema: "kxm.skill-history-event.v1", event: "candidate_created", by: createdBy, supersedes: input.supersedes, at: record.createdAt });
326
343
  return record;
327
344
  }
@@ -429,20 +446,17 @@ export class SkillLifecycle {
429
446
  // Instead of moving directory, write promoted directory and generate patch
430
447
  const candidatePaths = this.paths("candidate", candidateId);
431
448
  const promotedPaths = this.paths("promoted", candidateId);
432
- mkdirSync(promotedPaths.dir, { recursive: true });
433
449
 
434
450
  const skillContent = readFileSync(candidatePaths.skill, "utf8");
435
451
  const metadataContent = readFileSync(candidatePaths.metadata, "utf8");
436
- writeFileSync(promotedPaths.skill, skillContent);
437
- writeFileSync(promotedPaths.metadata, metadataContent);
452
+ this.write(promotedPaths.skill, skillContent);
453
+ this.write(promotedPaths.metadata, metadataContent);
438
454
 
439
- const patchesDir = join(this.root, "patches");
440
- mkdirSync(patchesDir, { recursive: true });
441
- const patchPath = join(patchesDir, `${candidateId}.patch`);
455
+ const patchPath = join(this.root, "patches", `${candidateId}.patch`);
442
456
  const relSkillPath = `.kxm/skills/promoted/${candidateId}/SKILL.md`;
443
457
  const relMetaPath = `.kxm/skills/promoted/${candidateId}/metadata.json`;
444
458
  const patch = `${createUnifiedPatch(relSkillPath, skillContent)}${createUnifiedPatch(relMetaPath, metadataContent)}`;
445
- writeFileSync(patchPath, patch, "utf8");
459
+ this.write(patchPath, patch);
446
460
 
447
461
  this.appendHistory(candidateId, record);
448
462
  return { ...metadata, patch, patchPath };
@@ -8,7 +8,10 @@
8
8
  * flag `readOnly`, Bun spells it `readonly`. Options are normalized here so
9
9
  * callers keep using the Node spelling everywhere.
10
10
  */
11
+ import { existsSync } from "node:fs";
11
12
  import { createRequire } from "node:module";
13
+ import { resolve } from "node:path";
14
+ import { pathToFileURL } from "node:url";
12
15
 
13
16
  export interface DatabaseSyncOptions {
14
17
  readOnly?: boolean;
@@ -74,3 +77,18 @@ export class DatabaseSync {
74
77
  this.inner.close();
75
78
  }
76
79
  }
80
+
81
+ /**
82
+ * Open a database for reading without touching its directory. A plain
83
+ * read-only open of a WAL database creates the `-wal` and `-shm` sidecars when
84
+ * they are missing and leaves them behind. With no `-wal` present, the main
85
+ * file already holds every committed page, so it is opened `immutable` (no
86
+ * locks, no sidecars); a present `-wal` means a writer or un-checkpointed
87
+ * frames, and the ordinary read-only open reads through them.
88
+ */
89
+ export function openReadOnlyDatabase(path: string): DatabaseSync {
90
+ if (existsSync(`${path}-wal`)) return new DatabaseSync(path, { readOnly: true });
91
+ const uri = pathToFileURL(resolve(path));
92
+ uri.searchParams.set("immutable", "1");
93
+ return new DatabaseSync(uri.href, { readOnly: true });
94
+ }