@kontextmind/kxm 0.7.96 → 0.7.98

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 (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/workflows/default.yaml +2 -0
  3. package/CHANGELOG.md +61 -2
  4. package/docs/concepts/architecture.md +1 -1
  5. package/docs/concepts/data-and-storage.md +1 -1
  6. package/docs/concepts/trust-model.md +3 -2
  7. package/docs/contracts/routing.md +1 -1
  8. package/docs/contributing/test-matrix.md +6 -5
  9. package/docs/glossary.md +1 -1
  10. package/docs/guides/peer-messaging.md +2 -2
  11. package/docs/guides/pi-workers.md +1 -1
  12. package/docs/guides/webhook-workflows.md +53 -18
  13. package/docs/operations/backup-and-restore.md +43 -24
  14. package/docs/operations/deploy.md +2 -2
  15. package/docs/operations/troubleshooting.md +3 -2
  16. package/docs/reference/cli-reference.md +65 -30
  17. package/docs/reference/config-reference.md +24 -13
  18. package/docs/reference/configuration.md +2 -2
  19. package/docs/reference/harness-routing.md +3 -3
  20. package/docs/reference/http-api.md +7 -7
  21. package/docs/reference/tools.md +1 -1
  22. package/docs/reference/workflow-definitions.md +2 -2
  23. package/docs/start/first-workflow.md +4 -4
  24. package/docs/start/quickstart-claude-code.md +2 -2
  25. package/docs/start/quickstart-pi.md +1 -1
  26. package/examples/workflow-signal.ts +4 -5
  27. package/package.json +1 -1
  28. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  29. package/plugins/kxm/dist/claude-hook.js +11 -1
  30. package/plugins/kxm/dist/cli.js +552 -228
  31. package/plugins/kxm/dist/client.js +3 -1
  32. package/plugins/kxm/dist/core.js +16 -3
  33. package/plugins/kxm/dist/extension.js +45 -13
  34. package/plugins/kxm/dist/mcp-server.js +20 -4
  35. package/plugins/kxm/dist/runtime-supervisor.js +232 -51
  36. package/plugins/kxm/dist/runtime.js +432 -95
  37. package/plugins/kxm/dist/server.js +122 -20
  38. package/plugins/kxm/package.json +1 -1
  39. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
  40. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
  41. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
  42. package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
  43. package/plugins/kxm/src/cli/project.ts +22 -13
  44. package/plugins/kxm/src/cli/system.ts +22 -1
  45. package/plugins/kxm/src/cli/workflows.ts +12 -7
  46. package/plugins/kxm/src/cli.ts +30 -8
  47. package/plugins/kxm/src/client.ts +4 -0
  48. package/plugins/kxm/src/commands.ts +23 -1
  49. package/plugins/kxm/src/database.ts +210 -36
  50. package/plugins/kxm/src/engine.ts +117 -2
  51. package/plugins/kxm/src/extension.ts +20 -14
  52. package/plugins/kxm/src/github-watch.ts +8 -5
  53. package/plugins/kxm/src/harness.ts +29 -0
  54. package/plugins/kxm/src/hub-env.ts +19 -1
  55. package/plugins/kxm/src/hub.ts +105 -21
  56. package/plugins/kxm/src/improve-sources.ts +2 -7
  57. package/plugins/kxm/src/init-guide-setup.ts +43 -28
  58. package/plugins/kxm/src/mcp-server.ts +9 -2
  59. package/plugins/kxm/src/oneshot-producer.ts +16 -8
  60. package/plugins/kxm/src/prices.ts +33 -2
  61. package/plugins/kxm/src/routing.ts +13 -7
  62. package/plugins/kxm/src/runtime-store.ts +23 -0
  63. package/plugins/kxm/src/studio-layout.ts +5 -4
  64. package/plugins/kxm/src/template.ts +31 -0
  65. package/plugins/kxm/src/workflow.ts +70 -1
  66. package/plugins/kxm/src/worktree-witness.ts +71 -0
  67. package/schemas/backup-manifest.schema.json +33 -0
  68. package/scripts/smoke-multi-pi.mjs +5 -1
@@ -18,7 +18,7 @@ import { Command, CommanderError } from "commander";
18
18
  import { readInstalledKxmVersion } from "./kxm-update.ts";
19
19
  import { findKxmRepoRoot } from "./repo-root.ts";
20
20
  import { HubClient } from "./client.ts";
21
- import { resolveClientHubAuthToken } from "./hub-env.ts";
21
+ import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
22
22
  import { defaultProjectName } from "./project-name.ts";
23
23
  import {
24
24
  AGENT_COMMANDS_MAP,
@@ -27,6 +27,7 @@ import {
27
27
  import { discoverKxmProjectRoot } from "./project-config.ts";
28
28
  import { JOURNAL_CATEGORIES } from "./workflow.ts";
29
29
  import { ensureKxmSupervisor, kxmRuntimeRequest } from "./runtime-supervisor.ts";
30
+ import { projectRuntimeOwnsRun } from "./runtime-store.ts";
30
31
 
31
32
  // Submodule imports
32
33
  import {
@@ -148,6 +149,7 @@ import {
148
149
  cmdConfigList,
149
150
  cmdCompletion,
150
151
  cmdCompletionInstall,
152
+ cmdPricesAcknowledge,
151
153
  cmdRoutingReport,
152
154
  cmdRoutingBenchmark,
153
155
  maybeOfferCompletionInstall,
@@ -234,14 +236,17 @@ async function ensureCliClient(runtime: Runtime): Promise<HubClient> {
234
236
  const project = defaultProjectName(runtime.cwd, runtime.env);
235
237
  const name = runtime.env.KXM_AGENT_NAME?.trim() || `cli-${process.pid}`;
236
238
  const purpose = runtime.env.KXM_AGENT_PURPOSE?.trim() || "CLI agent client";
237
- const authToken = resolveClientHubAuthToken(runtime.env, project);
239
+ // These commands act as a peer agent, so they take the agent credential: never the
240
+ // persisted admin token, which the hub accepts for any project without its own token.
241
+ const authToken = resolveAgentHubAuthToken(runtime.env, project);
242
+ if (!authToken) throw new AgentProjectTokenMissingError(project);
238
243
  const client = new HubClient({
239
244
  serverUrl,
240
245
  name,
241
246
  project,
242
247
  purpose,
243
248
  fetchImpl: runtime.fetchImpl,
244
- ...(authToken ? { authToken } : {}),
249
+ authToken,
245
250
  });
246
251
  await client.start(() => {});
247
252
  return client;
@@ -308,11 +313,12 @@ async function dispatchAgentCliCommand(
308
313
  }
309
314
  }
310
315
 
311
- // Handle KXM run binding for workflow wait
316
+ // A workflow wait on a run this project's Runtime owns goes to the Runtime; any other
317
+ // run id, including a hub workflow run of the same shape, goes to the hub.
312
318
  if (toolName === "kxm_workflow_wait") {
313
319
  const runId = typeof args.runId === "string" ? args.runId : undefined;
314
320
  const projectRoot = discoverKxmProjectRoot(runtime.cwd);
315
- if (runId && projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
321
+ if (runId && projectRoot && projectRuntimeOwnsRun(projectRoot, runId, runtime.env)) {
316
322
  if (runtime.dryRun) {
317
323
  print(runtime.io, runtime.json, { ok: true, command: "workflow wait", runId, dryRun: true }, `would wait for signal on KXM run ${runId}`);
318
324
  return 0;
@@ -349,6 +355,15 @@ async function dispatchAgentCliCommand(
349
355
  return 0;
350
356
  } catch (error) {
351
357
  const message = error instanceof Error ? error.message : String(error);
358
+ if (error instanceof AgentProjectTokenMissingError) {
359
+ print(
360
+ runtime.io,
361
+ runtime.json,
362
+ { ok: false, error: error.code, project: error.project, nextAction: "export_kxm_auth_token", detail: message },
363
+ message,
364
+ );
365
+ return 2;
366
+ }
352
367
  print(runtime.io, runtime.json, { ok: false, error: "command_failed", detail: message }, `command failed: ${message}`);
353
368
  return 1;
354
369
  } finally {
@@ -402,7 +417,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
402
417
  });
403
418
  });
404
419
 
405
- addGlobalOptions(program.command("backup").description("Create a verified SQLite backup of the project hub store with a hashed manifest (Runtime stores under the user state root are not included)"))
420
+ addGlobalOptions(program.command("backup").description("Create a verified SQLite backup of the project hub store and the Runtime stores under the user state root, with a hashed manifest"))
406
421
  .option("--out <dir>", "Directory to write backup and manifest")
407
422
  .action(async function backupAction(this: Command, options: { out?: string }) {
408
423
  result.code = await cmdBackup(runtimeFrom(ctx, this), options);
@@ -428,7 +443,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
428
443
  });
429
444
  addGlobalOptions(runCmd.command("drive").description("Drive a run with live harness calls, or with the model-free simulation when --simulated is passed"))
430
445
  .argument("<runId>", "Run id")
431
- .option("--simulated", "Use the model-free simulation producer")
446
+ .option("--simulated", "Use the model-free simulation producer instead of a live model")
432
447
  .option("--wait", "Wait until a drive receipt is recorded; exits 0 only for a VERIFIED COMPLETED settlement")
433
448
  .option("--timeout-ms <n>", "Wait timeout in milliseconds (default 60000, max 600000)")
434
449
  .action(async function runDriveAction(this: Command, runId: string, options: { simulated?: boolean; wait?: boolean; timeoutMs?: string }) {
@@ -460,6 +475,13 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
460
475
  result.code = await cmdTenantStatus(runtimeFrom(ctx, this));
461
476
  });
462
477
 
478
+ const pricesCmd = addGlobalOptions(program.command("prices").description("Stamp the local list-price catalog. Estimates stay unknown until today's stamp"));
479
+ pricesCmd.helpCommand("help", "Show prices help");
480
+ addGlobalOptions(pricesCmd.command("acknowledge").description("Stamp the existing .kxm/prices.yaml list as today's estimate without fetching vendor rates"))
481
+ .action(async function pricesAcknowledgeAction(this: Command) {
482
+ result.code = await cmdPricesAcknowledge(runtimeFrom(ctx, this));
483
+ });
484
+
463
485
  const modelsCmd = addGlobalOptions(program.command("models").description("Manage model catalogs, roles, and route state"));
464
486
  modelsCmd.action(async function modelsScreenAction(this: Command) { result.code = await cmdModelsScreen(runtimeFrom(ctx, this)); });
465
487
  modelsCmd.helpCommand("help", "Show models help");
@@ -914,7 +936,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
914
936
  result.code = await cmdGithubWatch(runtimeFrom(ctx, this), options);
915
937
  });
916
938
 
917
- const improve = addGlobalOptions(program.command("improve").description("Propose coded-repeat candidates from this project's Runtime routing records and telemetry"));
939
+ const improve = addGlobalOptions(program.command("improve").description("Propose coded-repeat candidates from this project's Runtime routing records and telemetry. Proposals are not applied to the next run"));
918
940
  improve.helpCommand("help", "Show improve help");
919
941
  addGlobalOptions(improve.command("report", { isDefault: true }).description("Generate improvement report and candidates from routing records"))
920
942
  .option("--file <path>", "Read only this routing-record JSONL instead of the project's Runtime store and telemetry")
@@ -259,6 +259,8 @@ export class HubClient {
259
259
  ttlMs?: number;
260
260
  timeoutMs?: number;
261
261
  signal?: AbortSignal;
262
+ hops?: number;
263
+ maxHops?: number;
262
264
  }): Promise<FanoutResult[]> {
263
265
  const targets = [...new Set(options.targets.map((target) => target.trim().toLowerCase()).filter(Boolean))];
264
266
  if (targets.length < 1 || targets.length > 3) throw new Error("fanout requires between one and three unique targets");
@@ -280,6 +282,8 @@ export class HubClient {
280
282
  ),
281
283
  } : {}),
282
284
  ...(options.ttlMs ? { ttlMs: options.ttlMs } : {}),
285
+ ...(options.hops !== undefined ? { hops: options.hops } : {}),
286
+ ...(options.maxHops !== undefined ? { maxHops: options.maxHops } : {}),
283
287
  });
284
288
  const completed = await this.awaitResponse(
285
289
  message.id,
@@ -74,6 +74,26 @@ export interface CommandExecutionContext {
74
74
  signal?: AbortSignal | undefined;
75
75
  inbox?: Map<string, MessageRecord> | undefined;
76
76
  notifiedInbox?: Set<string> | undefined;
77
+ /** Inbound requests this session is handling: the Pi extension's active request, the MCP
78
+ * server's open inbox. A request sent meanwhile is one more hop along their chain. */
79
+ handling?: readonly Pick<MessageRecord, "hops" | "maxHops">[] | undefined;
80
+ }
81
+
82
+ /**
83
+ * Hop fields for a request sent while handling inbound work: one hop past the furthest
84
+ * handled request, under the tightest limit among them, so the hub's `hop_limit_reached`
85
+ * refusal bounds a chain of agents forwarding to each other. Handling nothing starts a new
86
+ * chain with the hub defaults. When several requests are open the furthest one counts, so a
87
+ * forwarding loop cannot reset its count because an unrelated request arrived beside it.
88
+ */
89
+ export function forwardedHops(
90
+ handling: CommandExecutionContext["handling"],
91
+ ): { hops: number; maxHops: number } | undefined {
92
+ if (!handling?.length) return undefined;
93
+ return {
94
+ hops: Math.max(...handling.map((message) => message.hops)) + 1,
95
+ maxHops: Math.min(...handling.map((message) => message.maxHops)),
96
+ };
77
97
  }
78
98
 
79
99
  export interface AgentCommand {
@@ -260,12 +280,13 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
260
280
  required: ["target", "content"],
261
281
  additionalProperties: false,
262
282
  },
263
- async execute(client, args) {
283
+ async execute(client, args, context) {
264
284
  const delivery = optionalString(args.delivery) as DeliveryMode | undefined;
265
285
  const correlationId = optionalString(args.correlationId);
266
286
  const idempotencyKey = optionalString(args.idempotencyKey);
267
287
  const workflowContext = optionalWorkflowContext(args.workflowContext);
268
288
  const message = await client.send({
289
+ ...forwardedHops(context?.handling),
269
290
  target: requiredString(args.target, "target"),
270
291
  content: requiredString(args.content, "content"),
271
292
  ...(delivery ? { delivery } : {}),
@@ -346,6 +367,7 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
346
367
  : [];
347
368
  return {
348
369
  responses: await client.fanout({
370
+ ...forwardedHops(context?.handling),
349
371
  targets,
350
372
  content: requiredString(args.content, "content"),
351
373
  ...(optionalString(args.correlationId) ? { correlationId: optionalString(args.correlationId)! } : {}),
@@ -12,6 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { basename, dirname, join, resolve } from "node:path";
14
14
  import { DatabaseSync } from "./sqlite.ts";
15
+ import { kxmUserStateRoot } from "./bindings.ts";
15
16
  import { KxmConfigError, type KxmConfigIssue } from "./project-config.ts";
16
17
 
17
18
  export interface DatabaseSchemaSpec {
@@ -31,12 +32,24 @@ export interface BackupStoreRecord {
31
32
  integrity: "ok";
32
33
  }
33
34
 
35
+ export interface BackupFileRecord {
36
+ id: string;
37
+ sourcePath: string;
38
+ backupFile: string;
39
+ sha256: string;
40
+ bytes: number;
41
+ }
42
+
34
43
  export interface BackupManifest {
35
44
  schema: "kxm.backup-manifest.v1";
36
45
  backupId: string;
37
46
  createdAt: string;
38
47
  projectRoot?: string;
39
48
  stores: BackupStoreRecord[];
49
+ files?: BackupFileRecord[];
50
+ /** False when a discovered store or prompt sidecar was left out. Absent on legacy manifests. */
51
+ complete?: boolean;
52
+ omitted?: string[];
40
53
  manifestSha256?: string;
41
54
  }
42
55
 
@@ -607,46 +620,136 @@ export const KXM_BACKUP_CEILINGS = {
607
620
  } as const;
608
621
 
609
622
  export function kxmBackupCeiling(storeId: string): number {
623
+ if (storeId === "runtime-registry") return KXM_BACKUP_CEILINGS.registry;
610
624
  if (storeId.startsWith("events:")) return KXM_BACKUP_CEILINGS.events;
611
625
  // An id this build does not know keeps the ceiling restore has always defaulted to.
612
626
  return KXM_BACKUP_CEILINGS[storeId as keyof typeof KXM_BACKUP_CEILINGS] ?? KXM_BACKUP_CEILINGS["hub-store"];
613
627
  }
614
628
 
615
- export function discoverProjectStores(projectRoot: string, options: { hubDataPath?: string } = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
629
+ interface DiscoveredStore {
630
+ storeId: string;
631
+ sourcePath: string;
632
+ maxSupportedVersion: number;
633
+ }
634
+
635
+ interface DiscoveredFile {
636
+ id: string;
637
+ sourcePath: string;
638
+ }
639
+
640
+ function pushStore(stores: DiscoveredStore[], storeId: string, sourcePath: string): void {
641
+ if (stores.some((store) => store.storeId === storeId || store.sourcePath === sourcePath)) return;
642
+ stores.push({ storeId, sourcePath, maxSupportedVersion: kxmBackupCeiling(storeId) });
643
+ }
644
+
645
+ function discoverUserRuntime(env: NodeJS.ProcessEnv | undefined, stores: DiscoveredStore[], files: DiscoveredFile[]): void {
646
+ let stateRoot: string;
647
+ try {
648
+ stateRoot = kxmUserStateRoot(env ? { env } : {});
649
+ } catch {
650
+ return;
651
+ }
652
+ const runtimeDir = join(stateRoot, "runtime");
653
+ const registryPath = join(runtimeDir, "registry.db");
654
+ if (existsSync(registryPath)) {
655
+ const storeId = stores.some((store) => store.storeId === "registry") ? "runtime-registry" : "registry";
656
+ pushStore(stores, storeId, registryPath);
657
+ }
658
+ const projectsDir = join(runtimeDir, "projects");
659
+ if (!existsSync(projectsDir)) return;
660
+ let entries;
661
+ try {
662
+ entries = readdirSync(projectsDir, { withFileTypes: true });
663
+ } catch {
664
+ return;
665
+ }
666
+ for (const entry of entries) {
667
+ if (!entry.isDirectory()) continue;
668
+ const dbPath = join(projectsDir, entry.name, "run-events.db");
669
+ if (!existsSync(dbPath)) continue;
670
+ const safe = entry.name.replace(/[^a-zA-Z0-9_.-]/g, "_");
671
+ let storeId = `events:${safe}`;
672
+ if (stores.some((store) => store.storeId === storeId)) storeId = `events:runtime:${safe}`;
673
+ pushStore(stores, storeId, dbPath);
674
+ const sidecar = `${dbPath}.run-prompts.json`;
675
+ if (existsSync(sidecar)) {
676
+ const id = `${storeId}:run-prompts`;
677
+ if (!files.some((file) => file.id === id || file.sourcePath === sidecar)) {
678
+ files.push({ id, sourcePath: sidecar });
679
+ }
680
+ }
681
+ }
682
+ }
683
+
684
+ function discoverBackupSources(
685
+ projectRoot: string,
686
+ options: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {},
687
+ ): { stores: DiscoveredStore[]; files: DiscoveredFile[] } {
616
688
  const root = resolve(projectRoot);
617
- const stores: Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> = [];
689
+ const stores: DiscoveredStore[] = [];
690
+ const files: DiscoveredFile[] = [];
618
691
 
619
692
  const hubPath = options.hubDataPath ? resolve(options.hubDataPath) : join(root, ".kxm", "state", "kxm.db");
620
- if (existsSync(hubPath)) {
621
- stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: kxmBackupCeiling("hub-store") });
622
- }
693
+ if (existsSync(hubPath)) pushStore(stores, "hub-store", hubPath);
623
694
 
624
695
  const registryPath = join(root, ".kxm", "runtime", "registry.db");
625
- if (existsSync(registryPath)) {
626
- stores.push({ storeId: "registry", sourcePath: registryPath, maxSupportedVersion: kxmBackupCeiling("registry") });
627
- }
696
+ if (existsSync(registryPath)) pushStore(stores, "registry", registryPath);
628
697
 
629
698
  const bindingsPath = join(root, ".kxm", "runtime", "bindings.db");
630
- if (existsSync(bindingsPath)) {
631
- stores.push({ storeId: "binding-store", sourcePath: bindingsPath, maxSupportedVersion: kxmBackupCeiling("binding-store") });
632
- }
699
+ if (existsSync(bindingsPath)) pushStore(stores, "binding-store", bindingsPath);
633
700
 
634
701
  const eventsDir = join(root, ".kxm", "runtime", "events");
635
702
  if (existsSync(eventsDir)) {
636
703
  const entries = readdirSync(eventsDir, { withFileTypes: true });
637
704
  for (const entry of entries) {
638
705
  if (entry.isFile() && entry.name.endsWith(".db")) {
639
- const key = entry.name.replace(/\.db$/, "");
640
- stores.push({
641
- storeId: `events:${key}`,
642
- sourcePath: join(eventsDir, entry.name),
643
- maxSupportedVersion: kxmBackupCeiling(`events:${key}`),
644
- });
706
+ const key = entry.name.replace(/\.db$/, "").replace(/[^a-zA-Z0-9_.-]/g, "_");
707
+ pushStore(stores, `events:${key}`, join(eventsDir, entry.name));
645
708
  }
646
709
  }
647
710
  }
648
711
 
649
- return stores;
712
+ discoverUserRuntime(options.env, stores, files);
713
+ return { stores, files };
714
+ }
715
+
716
+ function backupPlainFile(sourcePath: string, targetPath: string, id: string): BackupFileRecord {
717
+ const resolvedSource = resolve(sourcePath);
718
+ const sourceStat = lstatSync(resolvedSource, { throwIfNoEntry: false });
719
+ if (!sourceStat || !sourceStat.isFile() || sourceStat.isSymbolicLink()) {
720
+ throw databaseError("runtime_path_invalid", resolvedSource, `backup file ${resolvedSource} must be a regular file, not a link or directory`);
721
+ }
722
+ checkedParent(targetPath, "backup target");
723
+ if (existsSync(targetPath)) unlinkSync(targetPath);
724
+ copyFileSync(resolvedSource, targetPath);
725
+ try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
726
+ return {
727
+ id,
728
+ sourcePath: resolvedSource,
729
+ backupFile: basename(targetPath),
730
+ sha256: fileSha256(targetPath),
731
+ bytes: sourceStat.size,
732
+ };
733
+ }
734
+
735
+ function backupFilename(sourcePath: string, id: string, used: Set<string>): string {
736
+ let filename = basename(sourcePath);
737
+ if (used.has(filename)) {
738
+ filename = `${id.replace(/[^a-zA-Z0-9_.-]/g, "_")}-${filename}`;
739
+ }
740
+ used.add(filename);
741
+ return filename;
742
+ }
743
+
744
+ function restorePlainFile(backupFilePath: string, targetPath: string): void {
745
+ checkedParent(targetPath, "restore target");
746
+ if (existsSync(targetPath)) unlinkSync(targetPath);
747
+ copyFileSync(backupFilePath, targetPath);
748
+ try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
749
+ }
750
+
751
+ export function discoverProjectStores(projectRoot: string, options: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
752
+ return discoverBackupSources(projectRoot, options).stores;
650
753
  }
651
754
 
652
755
  export interface BackupPlan {
@@ -654,21 +757,28 @@ export interface BackupPlan {
654
757
  outDir: string;
655
758
  createdAt: string;
656
759
  stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
760
+ files: Array<{ id: string; sourcePath: string; backupFile: string }>;
761
+ }
762
+
763
+ function backupDiscoverOptions(options: { hubDataPath?: string; env?: NodeJS.ProcessEnv }): { hubDataPath?: string; env?: NodeJS.ProcessEnv } {
764
+ return {
765
+ ...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
766
+ ...(options.env !== undefined ? { env: options.env } : {}),
767
+ };
657
768
  }
658
769
 
659
- /** Which stores a backup would copy and where, without opening any of them.
770
+ /** Which stores and files a backup would copy and where, without opening any of them.
660
771
  * Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
661
772
  export function planBackup(options: {
662
773
  projectRoot?: string;
663
774
  outDir?: string;
664
775
  hubDataPath?: string;
776
+ env?: NodeJS.ProcessEnv;
665
777
  } = {}): BackupPlan {
666
778
  const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
667
- const stores = discoverProjectStores(projectRoot, {
668
- ...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
669
- });
779
+ const discovered = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
670
780
 
671
- if (stores.length === 0) {
781
+ if (discovered.stores.length === 0) {
672
782
  throw databaseError("backup_no_stores", projectRoot, "no existing SQLite stores found to backup");
673
783
  }
674
784
 
@@ -676,24 +786,26 @@ export function planBackup(options: {
676
786
  const timestamp = createdAt.replace(/[:.]/g, "-");
677
787
  const outDir = options.outDir ? resolve(options.outDir) : join(projectRoot, ".kxm", "backups", `backup-${timestamp}`);
678
788
  const usedFilenames = new Set<string>();
679
- const planned = stores.map((store) => {
680
- let filename = basename(store.sourcePath);
681
- if (usedFilenames.has(filename)) {
682
- const sanitizedId = store.storeId.replace(/[^a-zA-Z0-9_.-]/g, "_");
683
- filename = `${sanitizedId}-${filename}`;
684
- }
685
- usedFilenames.add(filename);
686
- return { storeId: store.storeId, sourcePath: store.sourcePath, backupFile: filename };
687
- });
688
- return { projectRoot, outDir, createdAt, stores: planned };
789
+ const stores = discovered.stores.map((store) => ({
790
+ storeId: store.storeId,
791
+ sourcePath: store.sourcePath,
792
+ backupFile: backupFilename(store.sourcePath, store.storeId, usedFilenames),
793
+ }));
794
+ const files = discovered.files.map((file) => ({
795
+ id: file.id,
796
+ sourcePath: file.sourcePath,
797
+ backupFile: backupFilename(file.sourcePath, file.id, usedFilenames),
798
+ }));
799
+ return { projectRoot, outDir, createdAt, stores, files };
689
800
  }
690
801
 
691
802
  export function createBackup(options: {
692
803
  projectRoot?: string;
693
804
  outDir?: string;
694
805
  hubDataPath?: string;
806
+ env?: NodeJS.ProcessEnv;
695
807
  } = {}): { manifest: BackupManifest; outDir: string } {
696
- const { projectRoot, outDir, createdAt, stores } = planBackup(options);
808
+ const { projectRoot, outDir, createdAt, stores, files } = planBackup(options);
697
809
  const backupId = `bk_${randomBytes(8).toString("hex")}`;
698
810
 
699
811
  if (!existsSync(outDir)) {
@@ -701,8 +813,38 @@ export function createBackup(options: {
701
813
  }
702
814
 
703
815
  const backedUpStores: BackupStoreRecord[] = [];
816
+ const backedUpFiles: BackupFileRecord[] = [];
817
+ const omitted: string[] = [];
818
+
704
819
  for (const store of stores) {
705
- backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
820
+ try {
821
+ backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
822
+ } catch {
823
+ omitted.push(store.storeId);
824
+ }
825
+ }
826
+ for (const file of files) {
827
+ try {
828
+ backedUpFiles.push(backupPlainFile(file.sourcePath, join(outDir, file.backupFile), file.id));
829
+ } catch {
830
+ omitted.push(file.id);
831
+ }
832
+ }
833
+
834
+ if (backedUpStores.length === 0) {
835
+ throw databaseError("backup_no_stores", projectRoot, "no SQLite store could be copied");
836
+ }
837
+
838
+ const again = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
839
+ for (const store of again.stores) {
840
+ if (!backedUpStores.some((copied) => copied.storeId === store.storeId) && !omitted.includes(store.storeId)) {
841
+ omitted.push(store.storeId);
842
+ }
843
+ }
844
+ for (const file of again.files) {
845
+ if (!backedUpFiles.some((copied) => copied.id === file.id) && !omitted.includes(file.id)) {
846
+ omitted.push(file.id);
847
+ }
706
848
  }
707
849
 
708
850
  const manifest: BackupManifest = {
@@ -711,6 +853,9 @@ export function createBackup(options: {
711
853
  createdAt,
712
854
  projectRoot,
713
855
  stores: backedUpStores,
856
+ ...(backedUpFiles.length > 0 ? { files: backedUpFiles } : {}),
857
+ complete: omitted.length === 0,
858
+ ...(omitted.length > 0 ? { omitted } : {}),
714
859
  };
715
860
 
716
861
  const manifestJson = JSON.stringify(manifest, null, 2) + "\n";
@@ -728,6 +873,7 @@ export interface RestorePlan {
728
873
  manifestPath: string;
729
874
  backupId: string;
730
875
  stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
876
+ files: Array<{ id: string; backupFilePath: string; targetPath: string }>;
731
877
  }
732
878
 
733
879
  /** Everything restore checks before it overwrites anything: the manifest, each
@@ -762,6 +908,9 @@ export function planRestore(
762
908
  if (manifest.schema !== "kxm.backup-manifest.v1" || !Array.isArray(manifest.stores) || manifest.stores.length === 0) {
763
909
  throw databaseError("restore_manifest_invalid", manifestPath, "manifest is not a valid kxm.backup-manifest.v1 document");
764
910
  }
911
+ if (manifest.complete === false) {
912
+ throw databaseError("restore_incomplete", manifestPath, "backup manifest is incomplete; refusing to restore a partial copy");
913
+ }
765
914
 
766
915
  const stores: RestorePlan["stores"] = [];
767
916
  for (const store of manifest.stores) {
@@ -796,7 +945,29 @@ export function planRestore(
796
945
  stores.push({ storeId: store.storeId, backupFilePath, targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
797
946
  }
798
947
 
799
- return { manifestPath, backupId: manifest.backupId, stores };
948
+ const files: RestorePlan["files"] = [];
949
+ for (const file of manifest.files ?? []) {
950
+ const backupFilePath = join(manifestDir, file.backupFile);
951
+ if (!existsSync(backupFilePath)) {
952
+ throw databaseError("restore_file_missing", backupFilePath, `backup file ${file.backupFile} missing from ${manifestDir}`);
953
+ }
954
+ const actualSha256 = fileSha256(backupFilePath);
955
+ if (actualSha256 !== file.sha256) {
956
+ throw databaseError(
957
+ "restore_manifest_digest_mismatch",
958
+ backupFilePath,
959
+ `backup file ${file.backupFile} sha256 ${actualSha256} does not match manifest hash ${file.sha256}`,
960
+ );
961
+ }
962
+ let targetPath = file.sourcePath;
963
+ if (options.projectRoot && manifest.projectRoot && targetPath.startsWith(manifest.projectRoot)) {
964
+ const rel = targetPath.slice(manifest.projectRoot.length).replace(/^[\\/]+/, "");
965
+ targetPath = join(resolve(options.projectRoot), rel);
966
+ }
967
+ files.push({ id: file.id, backupFilePath, targetPath });
968
+ }
969
+
970
+ return { manifestPath, backupId: manifest.backupId, stores, files };
800
971
  }
801
972
 
802
973
  export function restoreBackup(
@@ -811,6 +982,9 @@ export function restoreBackup(
811
982
  store.schemaVersion,
812
983
  store.maxSupportedVersion,
813
984
  ));
985
+ for (const file of plan.files) {
986
+ restorePlainFile(file.backupFilePath, file.targetPath);
987
+ }
814
988
  return {
815
989
  manifestPath: plan.manifestPath,
816
990
  backupId: plan.backupId,