@kontextmind/kxm 0.7.103 → 0.7.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +39 -8
  3. package/docs/concepts/data-and-storage.md +1 -1
  4. package/docs/contributing/test-matrix.md +11 -6
  5. package/docs/operations/backup-and-restore.md +59 -27
  6. package/docs/operations/deploy.md +1 -1
  7. package/docs/reference/cli-reference.md +73 -55
  8. package/docs/reference/config-reference.md +1 -1
  9. package/docs/reference/workflow-definitions.md +4 -4
  10. package/docs/start/first-workflow.md +6 -2
  11. package/docs/start/quickstart-claude-code.md +11 -1
  12. package/docs/templates/runbook.md +2 -1
  13. package/package.json +1 -1
  14. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  15. package/plugins/kxm/dist/cli.js +1802 -1011
  16. package/plugins/kxm/dist/mcp-server.js +1 -1
  17. package/plugins/kxm/dist/runtime-supervisor.js +322 -292
  18. package/plugins/kxm/dist/runtime.js +427 -351
  19. package/plugins/kxm/dist/server.js +78 -78
  20. package/plugins/kxm/package.json +1 -1
  21. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +14 -7
  22. package/plugins/kxm/src/cli/project.ts +132 -30
  23. package/plugins/kxm/src/cli/system.ts +19 -1
  24. package/plugins/kxm/src/cli/tasks.ts +55 -31
  25. package/plugins/kxm/src/cli/workflows.ts +52 -54
  26. package/plugins/kxm/src/cli.ts +8 -6
  27. package/plugins/kxm/src/database.ts +193 -65
  28. package/plugins/kxm/src/engine.ts +79 -3
  29. package/plugins/kxm/src/harness.ts +6 -5
  30. package/plugins/kxm/src/mcp-server.ts +1 -1
  31. package/plugins/kxm/src/runtime-paths.ts +50 -0
  32. package/plugins/kxm/src/runtime-store.ts +22 -53
  33. package/plugins/kxm/src/runtime-supervisor.ts +38 -17
  34. package/plugins/kxm/src/suggest.ts +132 -79
  35. package/plugins/kxm/src/workflow-manager.ts +42 -19
  36. package/schemas/backup-manifest.schema.json +15 -0
@@ -10,10 +10,10 @@ import {
10
10
  unlinkSync,
11
11
  writeFileSync,
12
12
  } from "node:fs";
13
- import { basename, dirname, join, resolve } from "node:path";
13
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
14
  import { DatabaseSync } from "./sqlite.ts";
15
- import { kxmUserStateRoot } from "./bindings.ts";
16
15
  import { KxmConfigError, type KxmConfigIssue } from "./project-config.ts";
16
+ import { kxmProjectRunEventsPath, kxmRuntimePaths, projectRuntimeKey, type KxmRuntimePaths } from "./runtime-paths.ts";
17
17
 
18
18
  export interface DatabaseSchemaSpec {
19
19
  schema: string;
@@ -40,11 +40,25 @@ export interface BackupFileRecord {
40
40
  bytes: number;
41
41
  }
42
42
 
43
+ /**
44
+ * What a backup holds from the user state root. `project` (the default): the
45
+ * checkout's own Runtime event store and its prompt sidecar. `all-projects`: the
46
+ * shared Runtime registry and every project's event store, so restoring it rolls
47
+ * back every project on the machine.
48
+ */
49
+ export type KxmBackupScope = "project" | "all-projects";
50
+
43
51
  export interface BackupManifest {
44
52
  schema: "kxm.backup-manifest.v1";
45
53
  backupId: string;
46
54
  createdAt: string;
47
55
  projectRoot?: string;
56
+ /** Absent on manifests written before backups were scoped. */
57
+ scope?: KxmBackupScope;
58
+ /** The user state root the Runtime stores were copied from; restore rebases them onto the current one. */
59
+ stateRoot?: string;
60
+ /** The Runtime store key of the checkout the backup ran from, derived as the Runtime derives it. */
61
+ runtimeProjectKey?: string;
48
62
  stores: BackupStoreRecord[];
49
63
  files?: BackupFileRecord[];
50
64
  /** False when a discovered store or prompt sidecar was left out. Absent on legacy manifests. */
@@ -642,49 +656,75 @@ function pushStore(stores: DiscoveredStore[], storeId: string, sourcePath: strin
642
656
  stores.push({ storeId, sourcePath, maxSupportedVersion: kxmBackupCeiling(storeId) });
643
657
  }
644
658
 
645
- function discoverUserRuntime(env: NodeJS.ProcessEnv | undefined, stores: DiscoveredStore[], files: DiscoveredFile[]): void {
646
- let stateRoot: string;
659
+ interface DiscoveredSources {
660
+ stores: DiscoveredStore[];
661
+ files: DiscoveredFile[];
662
+ stateRoot?: string;
663
+ runtimeProjectKey: string;
664
+ }
665
+
666
+ interface BackupDiscoverOptions {
667
+ hubDataPath?: string;
668
+ env?: NodeJS.ProcessEnv;
669
+ allProjects?: boolean;
670
+ }
671
+
672
+ function pushEventStore(stores: DiscoveredStore[], files: DiscoveredFile[], paths: KxmRuntimePaths, key: string): void {
673
+ const dbPath = join(paths.projectsDir, key, "run-events.db");
674
+ if (!existsSync(dbPath)) return;
675
+ const safe = key.replace(/[^a-zA-Z0-9_.-]/g, "_");
676
+ let storeId = `events:${safe}`;
677
+ if (stores.some((store) => store.storeId === storeId)) storeId = `events:runtime:${safe}`;
678
+ pushStore(stores, storeId, dbPath);
679
+ const sidecar = `${dbPath}.run-prompts.json`;
680
+ if (existsSync(sidecar)) {
681
+ const id = `${storeId}:run-prompts`;
682
+ if (!files.some((file) => file.id === id || file.sourcePath === sidecar)) {
683
+ files.push({ id, sourcePath: sidecar });
684
+ }
685
+ }
686
+ }
687
+
688
+ /**
689
+ * The Runtime stores under the user state root. By default only this checkout's
690
+ * event store: the registry and the other projects' event stores are shared by every
691
+ * project on the machine, and a restore of them rolls all of those projects back, so
692
+ * only an explicit `allProjects` backup holds them.
693
+ */
694
+ function discoverUserRuntime(
695
+ runtimeProjectKey: string,
696
+ options: BackupDiscoverOptions,
697
+ stores: DiscoveredStore[],
698
+ files: DiscoveredFile[],
699
+ ): string | undefined {
700
+ let paths: KxmRuntimePaths;
647
701
  try {
648
- stateRoot = kxmUserStateRoot(env ? { env } : {});
702
+ paths = kxmRuntimePaths(options.env ? { env: options.env } : {});
649
703
  } catch {
650
- return;
704
+ return undefined;
651
705
  }
652
- const runtimeDir = join(stateRoot, "runtime");
653
- const registryPath = join(runtimeDir, "registry.db");
654
- if (existsSync(registryPath)) {
706
+ if (!options.allProjects) {
707
+ pushEventStore(stores, files, paths, runtimeProjectKey);
708
+ return paths.stateRoot;
709
+ }
710
+ if (existsSync(paths.registryDb)) {
655
711
  const storeId = stores.some((store) => store.storeId === "registry") ? "runtime-registry" : "registry";
656
- pushStore(stores, storeId, registryPath);
712
+ pushStore(stores, storeId, paths.registryDb);
657
713
  }
658
- const projectsDir = join(runtimeDir, "projects");
659
- if (!existsSync(projectsDir)) return;
714
+ if (!existsSync(paths.projectsDir)) return paths.stateRoot;
660
715
  let entries;
661
716
  try {
662
- entries = readdirSync(projectsDir, { withFileTypes: true });
717
+ entries = readdirSync(paths.projectsDir, { withFileTypes: true });
663
718
  } catch {
664
- return;
719
+ return paths.stateRoot;
665
720
  }
666
721
  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
- }
722
+ if (entry.isDirectory()) pushEventStore(stores, files, paths, entry.name);
681
723
  }
724
+ return paths.stateRoot;
682
725
  }
683
726
 
684
- function discoverBackupSources(
685
- projectRoot: string,
686
- options: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {},
687
- ): { stores: DiscoveredStore[]; files: DiscoveredFile[] } {
727
+ function discoverBackupSources(projectRoot: string, options: BackupDiscoverOptions = {}): DiscoveredSources {
688
728
  const root = resolve(projectRoot);
689
729
  const stores: DiscoveredStore[] = [];
690
730
  const files: DiscoveredFile[] = [];
@@ -709,8 +749,9 @@ function discoverBackupSources(
709
749
  }
710
750
  }
711
751
 
712
- discoverUserRuntime(options.env, stores, files);
713
- return { stores, files };
752
+ const runtimeProjectKey = projectRuntimeKey(root);
753
+ const stateRoot = discoverUserRuntime(runtimeProjectKey, options, stores, files);
754
+ return { stores, files, runtimeProjectKey, ...(stateRoot !== undefined ? { stateRoot } : {}) };
714
755
  }
715
756
 
716
757
  function backupPlainFile(sourcePath: string, targetPath: string, id: string): BackupFileRecord {
@@ -748,7 +789,7 @@ function restorePlainFile(backupFilePath: string, targetPath: string): void {
748
789
  try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
749
790
  }
750
791
 
751
- export function discoverProjectStores(projectRoot: string, options: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
792
+ export function discoverProjectStores(projectRoot: string, options: BackupDiscoverOptions = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
752
793
  return discoverBackupSources(projectRoot, options).stores;
753
794
  }
754
795
 
@@ -756,24 +797,26 @@ export interface BackupPlan {
756
797
  projectRoot: string;
757
798
  outDir: string;
758
799
  createdAt: string;
800
+ scope: KxmBackupScope;
801
+ stateRoot?: string;
802
+ runtimeProjectKey: string;
759
803
  stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
760
804
  files: Array<{ id: string; sourcePath: string; backupFile: string }>;
761
805
  }
762
806
 
763
- function backupDiscoverOptions(options: { hubDataPath?: string; env?: NodeJS.ProcessEnv }): { hubDataPath?: string; env?: NodeJS.ProcessEnv } {
807
+ function backupDiscoverOptions(options: BackupDiscoverOptions): BackupDiscoverOptions {
764
808
  return {
765
809
  ...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
766
810
  ...(options.env !== undefined ? { env: options.env } : {}),
811
+ ...(options.allProjects === true ? { allProjects: true } : {}),
767
812
  };
768
813
  }
769
814
 
770
815
  /** Which stores and files a backup would copy and where, without opening any of them.
771
816
  * Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
772
- export function planBackup(options: {
817
+ export function planBackup(options: BackupDiscoverOptions & {
773
818
  projectRoot?: string;
774
819
  outDir?: string;
775
- hubDataPath?: string;
776
- env?: NodeJS.ProcessEnv;
777
820
  } = {}): BackupPlan {
778
821
  const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
779
822
  const discovered = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
@@ -796,16 +839,23 @@ export function planBackup(options: {
796
839
  sourcePath: file.sourcePath,
797
840
  backupFile: backupFilename(file.sourcePath, file.id, usedFilenames),
798
841
  }));
799
- return { projectRoot, outDir, createdAt, stores, files };
842
+ return {
843
+ projectRoot,
844
+ outDir,
845
+ createdAt,
846
+ scope: options.allProjects === true ? "all-projects" : "project",
847
+ ...(discovered.stateRoot !== undefined ? { stateRoot: discovered.stateRoot } : {}),
848
+ runtimeProjectKey: discovered.runtimeProjectKey,
849
+ stores,
850
+ files,
851
+ };
800
852
  }
801
853
 
802
- export function createBackup(options: {
854
+ export function createBackup(options: BackupDiscoverOptions & {
803
855
  projectRoot?: string;
804
856
  outDir?: string;
805
- hubDataPath?: string;
806
- env?: NodeJS.ProcessEnv;
807
857
  } = {}): { manifest: BackupManifest; outDir: string } {
808
- const { projectRoot, outDir, createdAt, stores, files } = planBackup(options);
858
+ const { projectRoot, outDir, createdAt, scope, stateRoot, runtimeProjectKey, stores, files } = planBackup(options);
809
859
  const backupId = `bk_${randomBytes(8).toString("hex")}`;
810
860
 
811
861
  if (!existsSync(outDir)) {
@@ -852,6 +902,9 @@ export function createBackup(options: {
852
902
  backupId,
853
903
  createdAt,
854
904
  projectRoot,
905
+ scope,
906
+ ...(stateRoot !== undefined ? { stateRoot } : {}),
907
+ runtimeProjectKey,
855
908
  stores: backedUpStores,
856
909
  ...(backedUpFiles.length > 0 ? { files: backedUpFiles } : {}),
857
910
  complete: omitted.length === 0,
@@ -872,16 +925,74 @@ export function createBackup(options: {
872
925
  export interface RestorePlan {
873
926
  manifestPath: string;
874
927
  backupId: string;
928
+ /** The manifest's scope; absent on manifests written before backups were scoped. */
929
+ scope?: KxmBackupScope;
875
930
  stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
876
931
  files: Array<{ id: string; backupFilePath: string; targetPath: string }>;
877
932
  }
878
933
 
934
+ export interface RestoreOptions {
935
+ /** The checkout to restore into. Its stores are rebased onto it, and its own Runtime
936
+ * event store is the one the Runtime derives for it. */
937
+ projectRoot?: string;
938
+ env?: NodeJS.ProcessEnv;
939
+ /** Also restore the shared Runtime registry and other projects' event stores. */
940
+ allProjects?: boolean;
941
+ }
942
+
943
+ function pathUnder(root: string | undefined, path: string): string | undefined {
944
+ if (!root) return undefined;
945
+ const rel = relative(root, path);
946
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
947
+ return rel;
948
+ }
949
+
950
+ /**
951
+ * Where one backed-up store or file goes, and whether it belongs to this checkout.
952
+ *
953
+ * A path under the manifest's project root is rebased onto the checkout being restored.
954
+ * The checkout's own Runtime event store (and its prompt sidecar) goes to the store the
955
+ * Runtime derives for that checkout, under the current user state root. Anything else
956
+ * under the user state root is machine-wide — the registry, another project's event
957
+ * store — and so is anything outside both recorded roots except a relocated hub store,
958
+ * which is how a manifest written before scopes records Runtime stores. Those restore
959
+ * only with `allProjects`, rebased onto the current user state root when the manifest
960
+ * recorded one.
961
+ */
962
+ function restoreTarget(
963
+ manifest: BackupManifest,
964
+ sourcePath: string,
965
+ id: string,
966
+ options: RestoreOptions,
967
+ currentStateRoot: () => string,
968
+ ): { targetPath: string; machineWide: boolean } {
969
+ const stateRel = pathUnder(manifest.stateRoot, sourcePath);
970
+ const projectRel = pathUnder(manifest.projectRoot, sourcePath);
971
+ // KXM_STATE_HOME may sit inside the checkout; the deeper root owns the path.
972
+ if (stateRel !== undefined && (projectRel === undefined || stateRel.length <= projectRel.length)) {
973
+ const ownEvents = manifest.runtimeProjectKey !== undefined
974
+ ? join("runtime", "projects", manifest.runtimeProjectKey, "run-events.db")
975
+ : undefined;
976
+ const own = ownEvents !== undefined && (stateRel === ownEvents || stateRel === `${ownEvents}.run-prompts.json`);
977
+ if (own && !options.allProjects && options.projectRoot !== undefined) {
978
+ const eventsPath = kxmProjectRunEventsPath(resolve(options.projectRoot), options.env ?? process.env);
979
+ return { targetPath: stateRel === ownEvents ? eventsPath : `${eventsPath}.run-prompts.json`, machineWide: false };
980
+ }
981
+ return { targetPath: join(currentStateRoot(), stateRel), machineWide: !own };
982
+ }
983
+ if (projectRel !== undefined) {
984
+ return { targetPath: options.projectRoot ? join(resolve(options.projectRoot), projectRel) : sourcePath, machineWide: false };
985
+ }
986
+ return { targetPath: sourcePath, machineWide: id !== "hub-store" };
987
+ }
988
+
879
989
  /** Everything restore checks before it overwrites anything: the manifest, each
880
- * backup file's digest, and each store's schema against this build's ceiling
881
- * (from the manifest). Reads files; opens no database. */
990
+ * backup file's digest, each store's schema against this build's ceiling (from
991
+ * the manifest), and that nothing outside this checkout would be overwritten
992
+ * without `allProjects`. Reads files; opens no database. */
882
993
  export function planRestore(
883
994
  manifestPathOrDir: string,
884
- options: { projectRoot?: string } = {},
995
+ options: RestoreOptions = {},
885
996
  ): RestorePlan {
886
997
  let manifestPath = resolve(manifestPathOrDir);
887
998
  const stat = lstatSync(manifestPath, { throwIfNoEntry: false });
@@ -912,6 +1023,10 @@ export function planRestore(
912
1023
  throw databaseError("restore_incomplete", manifestPath, "backup manifest is incomplete; refusing to restore a partial copy");
913
1024
  }
914
1025
 
1026
+ let stateRoot: string | undefined;
1027
+ const currentStateRoot = (): string => (stateRoot ??= kxmRuntimePaths({ env: options.env ?? process.env }).stateRoot);
1028
+ const machineWide: string[] = [];
1029
+
915
1030
  const stores: RestorePlan["stores"] = [];
916
1031
  for (const store of manifest.stores) {
917
1032
  const backupFilePath = join(manifestDir, store.backupFile);
@@ -937,12 +1052,9 @@ export function planRestore(
937
1052
  );
938
1053
  }
939
1054
 
940
- let targetPath = store.sourcePath;
941
- if (options.projectRoot && manifest.projectRoot && targetPath.startsWith(manifest.projectRoot)) {
942
- const rel = targetPath.slice(manifest.projectRoot.length).replace(/^[\\/]+/, "");
943
- targetPath = join(resolve(options.projectRoot), rel);
944
- }
945
- stores.push({ storeId: store.storeId, backupFilePath, targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
1055
+ const target = restoreTarget(manifest, store.sourcePath, store.storeId, options, currentStateRoot);
1056
+ if (target.machineWide) machineWide.push(store.storeId);
1057
+ stores.push({ storeId: store.storeId, backupFilePath, targetPath: target.targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
946
1058
  }
947
1059
 
948
1060
  const files: RestorePlan["files"] = [];
@@ -959,22 +1071,31 @@ export function planRestore(
959
1071
  `backup file ${file.backupFile} sha256 ${actualSha256} does not match manifest hash ${file.sha256}`,
960
1072
  );
961
1073
  }
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 });
1074
+ const target = restoreTarget(manifest, file.sourcePath, file.id, options, currentStateRoot);
1075
+ if (target.machineWide) machineWide.push(file.id);
1076
+ files.push({ id: file.id, backupFilePath, targetPath: target.targetPath });
968
1077
  }
969
1078
 
970
- return { manifestPath, backupId: manifest.backupId, stores, files };
1079
+ if (machineWide.length > 0 && !options.allProjects) {
1080
+ throw databaseError(
1081
+ "restore_requires_all_projects",
1082
+ manifestPath,
1083
+ `backup ${manifest.backupId} holds Runtime state shared by every project on this machine (${machineWide.join(", ")}); `
1084
+ + "restoring it rolls back the registry or other projects' runs. Pass --all-projects to restore all of it, or restore a backup taken without --all-projects",
1085
+ );
1086
+ }
1087
+
1088
+ return {
1089
+ manifestPath,
1090
+ backupId: manifest.backupId,
1091
+ ...(manifest.scope !== undefined ? { scope: manifest.scope } : {}),
1092
+ stores,
1093
+ files,
1094
+ };
971
1095
  }
972
1096
 
973
- export function restoreBackup(
974
- manifestPathOrDir: string,
975
- options: { projectRoot?: string } = {},
976
- ): RestoreResult {
977
- const plan = planRestore(manifestPathOrDir, options);
1097
+ /** Overwrite each target in a checked plan: the stores, then their prompt sidecars. */
1098
+ export function applyRestorePlan(plan: RestorePlan): RestoreResult {
978
1099
  const restoredStores = plan.stores.map((store) => restoreDatabaseFile(
979
1100
  store.backupFilePath,
980
1101
  store.targetPath,
@@ -991,3 +1112,10 @@ export function restoreBackup(
991
1112
  restoredStores,
992
1113
  };
993
1114
  }
1115
+
1116
+ export function restoreBackup(
1117
+ manifestPathOrDir: string,
1118
+ options: RestoreOptions = {},
1119
+ ): RestoreResult {
1120
+ return applyRestorePlan(planRestore(manifestPathOrDir, options));
1121
+ }
@@ -3,7 +3,6 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import { isRouteAdmitted, listRoleBindings } from "./routes.ts";
6
- import { oneShotWriterArgs } from "./harness.ts";
7
6
  import { applyAuthoringWitness, captureWorktreeWitness } from "./worktree-witness.ts";
8
7
  import {
9
8
  buildFormalContextPacket,
@@ -14,6 +13,7 @@ import {
14
13
  type HandoffManifestV1,
15
14
  } from "./context-packet.ts";
16
15
  import { compileKxmWorkflow, type KxmCompiledPlan, type KxmCompiledStep } from "./engine-compile.ts";
16
+ import { BUILTIN_HARNESSES, oneShotPermissionArgs, oneShotWriterArgs, validateHarnessModelPair } from "./harness.ts";
17
17
  import {
18
18
  effectiveRunDurationBudget,
19
19
  isTerminalRunStatus,
@@ -2693,7 +2693,83 @@ function armRunDurationBudgetTimer(context: KxmRuntimeContext, runId: string): (
2693
2693
  };
2694
2694
  }
2695
2695
 
2696
- function unsupportedLimit(envelope: KxmRunPlanEnvelope): KxmRunHandoff | undefined {
2696
+ /** Read-only admission guidance for a live run, using the same policies as dispatch. */
2697
+ export function kxmLiveRunPrerequisites(
2698
+ bundle: KxmProjectBundle,
2699
+ workflowId: string,
2700
+ projectRoot: string,
2701
+ ): KxmRunHandoff[] {
2702
+ const workflow = bundle.workflows.get(workflowId);
2703
+ if (!workflow) throw runtimeError("run_workflow_unknown", workflowId, `workflow ${workflowId} does not exist in this project`);
2704
+ const plan = compileKxmWorkflow({ id: workflowId, value: workflow.value, logicalPath: workflow.logicalPath });
2705
+ const envelope = { plan, projectLimits: kxmProjectAdmissionLimits(bundle), gates: pinnedGatesForCompiledPlan(plan, bundle, projectRoot) };
2706
+ const prerequisites: KxmRunHandoff[] = [];
2707
+ const limit = unsupportedLimit(envelope);
2708
+ if (limit) prerequisites.push(limit);
2709
+ const visited = new Set<string>();
2710
+ const pending = [plan.entryStepId];
2711
+ while (pending.length > 0) {
2712
+ const stepId = pending.pop()!;
2713
+ if (visited.has(stepId)) continue;
2714
+ visited.add(stepId);
2715
+ const step = plan.steps[stepId]!;
2716
+ for (const transition of Object.values(step.transitions)) {
2717
+ if (transition.to === "step") pending.push(transition.target);
2718
+ }
2719
+ const unsupported = step.kind === "gate"
2720
+ ? unsupportedGateStep(plan, step, envelope, { projectRoot }) ?? starterGatePrerequisite(step, envelope.gates, projectRoot)
2721
+ : unsupportedStep(plan, step, "oneshot");
2722
+ if (unsupported) {
2723
+ prerequisites.push({ ...unsupported, stepId });
2724
+ continue;
2725
+ }
2726
+ if (step.kind !== "agent" && step.kind !== "moa") continue;
2727
+ const agentIds = step.assignments.allowedAgents.length > 0 ? step.assignments.allowedAgents : [step.agent];
2728
+ for (const agentId of agentIds) {
2729
+ const agent = bundle.agents.get(agentId);
2730
+ const harness = String(agent?.value.harness ?? bundle.project.value.defaultHarness ?? "pi");
2731
+ const permission = Object.values(step.repositories).includes("write") ? "edit" : "read-only";
2732
+ if (!BUILTIN_HARNESSES.some((entry) => entry.id === harness && entry.oneShot) || !oneShotPermissionArgs(harness, permission)) {
2733
+ prerequisites.push({ reason: "step_unsupported", stepId, field: "harness", detail: `${agentId}: ${harness} has no audited ${permission} one-shot profile; use a supported workflow or execute this work directly in ${harness}, without substituting another harness` });
2734
+ continue;
2735
+ }
2736
+ const route = resolveProducerRoute(projectRoot, step, agentId);
2737
+ if ("error" in route) {
2738
+ prerequisites.push({ ...route.error, stepId, detail: `${agentId}: ${route.error.detail}; configure its model in .kxm/agents/${agentId}.yaml and admit the installed model with kxm routes admit --model <provider/model>` });
2739
+ continue;
2740
+ }
2741
+ const writeRefusal = unsupportedLiveWrite(projectRoot, step, agentId, route.selector, envelope.projectLimits.maxConcurrentRuns);
2742
+ if (writeRefusal) {
2743
+ prerequisites.push({ ...writeRefusal, stepId });
2744
+ continue;
2745
+ }
2746
+ const compatible = validateHarnessModelPair(harness, route);
2747
+ if (!compatible.valid) {
2748
+ prerequisites.push({ reason: "step_unsupported", stepId, field: "model", detail: `${agentId}: ${compatible.message ?? `${harness} cannot run ${route.selector}`}; configure a model supported by ${harness} in .kxm/agents/${agentId}.yaml` });
2749
+ }
2750
+ }
2751
+ }
2752
+ return prerequisites;
2753
+ }
2754
+
2755
+ function starterGatePrerequisite(step: KxmCompiledStep & { kind: "gate" }, gates: KxmPinnedGates, projectRoot: string): Omit<KxmRunHandoff, "stepId"> | undefined {
2756
+ const definition = gates.definitions[step.gate];
2757
+ if (!definition || definition.kind !== "command" || definition.argv.length !== 2 || definition.argv[0] !== "npm" || definition.argv[1] !== "test") return undefined;
2758
+ let testScript: unknown;
2759
+ try {
2760
+ const manifest: unknown = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8"));
2761
+ if (manifest && typeof manifest === "object" && "scripts" in manifest) {
2762
+ const scripts = manifest.scripts;
2763
+ if (scripts && typeof scripts === "object" && "test" in scripts) testScript = scripts.test;
2764
+ }
2765
+ } catch { /* A missing or unreadable manifest cannot satisfy the starter gate. */ }
2766
+ if (typeof testScript !== "string" || testScript.trim().length === 0) {
2767
+ return { reason: "gate_unsupported", field: `gates.${step.gate}.argv`, detail: `gate ${step.gate} runs npm test but this repository has no readable package.json with scripts.test; configure .kxm/gates.yaml gates.${step.gate}.argv for the repository's actual test runner before driving this workflow` };
2768
+ }
2769
+ return undefined;
2770
+ }
2771
+
2772
+ function unsupportedLimit(envelope: Pick<KxmRunPlanEnvelope, "plan" | "projectLimits">): KxmRunHandoff | undefined {
2697
2773
  if (envelope.plan.limits.maxAgentTimeMs !== undefined) {
2698
2774
  return { reason: "limit_unsupported", field: "limits.maxAgentTimeMs", detail: "agent-time budget enforcement is not available in this slice" };
2699
2775
  }
@@ -2847,7 +2923,7 @@ function unsupportedLiveWrite(
2847
2923
  return undefined;
2848
2924
  }
2849
2925
 
2850
- function unsupportedGateStep(plan: KxmCompiledPlan, step: KxmCompiledStep & { kind: "gate" }, envelope: KxmRunPlanEnvelope, context?: { projectRoot: string }): Omit<KxmRunHandoff, "stepId"> | undefined {
2926
+ function unsupportedGateStep(plan: KxmCompiledPlan, step: KxmCompiledStep & { kind: "gate" }, envelope: Pick<KxmRunPlanEnvelope, "gates">, context?: { projectRoot: string }): Omit<KxmRunHandoff, "stepId"> | undefined {
2851
2927
  if (!step.gate) return { reason: "step_unsupported", field: "gate", detail: "gate step is missing gate id" };
2852
2928
  if (envelope.gates.registry === null) {
2853
2929
  return { reason: "step_unsupported", field: "gate", detail: `step ${step.id} refers to a gate but registry is null` };
@@ -62,6 +62,7 @@ export type HarnessRunCommand = (command: string, args: readonly string[], timeo
62
62
  export type HarnessRunCommandAsync = (command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult | Promise<HarnessCommandResult>;
63
63
 
64
64
  export interface HarnessProbeOptions {
65
+ defaultHarness?: string | undefined;
65
66
  env?: NodeJS.ProcessEnv | undefined;
66
67
  runCommand?: HarnessRunCommand | undefined;
67
68
  timeoutMs?: number | undefined;
@@ -96,7 +97,7 @@ export interface HarnessStatus {
96
97
  }
97
98
 
98
99
  export interface HarnessInventory {
99
- defaultHarness: typeof DEFAULT_HARNESS;
100
+ defaultHarness: string;
100
101
  harnesses: readonly HarnessStatus[];
101
102
  }
102
103
 
@@ -958,7 +959,7 @@ function probeEntry(
958
959
  runCommand: (command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult,
959
960
  timeoutMs: number,
960
961
  platform: NodeJS.Platform,
961
- probe: Pick<HarnessProbeOptions, "env" | "existsSync"> = {},
962
+ probe: Pick<HarnessProbeOptions, "env" | "existsSync" | "defaultHarness"> = {},
962
963
  ): HarnessStatus {
963
964
  const issues: string[] = [];
964
965
  const found = detectHarnessCommand(entry, runCommand, timeoutMs, platform, probe);
@@ -987,7 +988,7 @@ function probeEntry(
987
988
  return {
988
989
  id: entry.id,
989
990
  label: entry.label,
990
- default: entry.default,
991
+ default: entry.id === (probe.defaultHarness ?? DEFAULT_HARNESS),
991
992
  mode: entry.mode,
992
993
  detected,
993
994
  authenticated,
@@ -1009,7 +1010,7 @@ export function probeHarnesses(options: HarnessProbeOptions = {}): HarnessInvent
1009
1010
  const runCommand = options.runCommand ?? defaultRunner(options.env ?? process.env);
1010
1011
  const platform = options.platform ?? process.platform;
1011
1012
  return {
1012
- defaultHarness: DEFAULT_HARNESS,
1013
+ defaultHarness: options.defaultHarness ?? DEFAULT_HARNESS,
1013
1014
  harnesses: BUILTIN_HARNESSES.map((entry) => probeEntry(entry, runCommand, timeoutMs, platform, options)),
1014
1015
  };
1015
1016
  }
@@ -1508,7 +1509,7 @@ export function formatHarnessInventory(inventory: HarnessInventory): string {
1508
1509
  ].join(" ");
1509
1510
  });
1510
1511
  return [
1511
- `default harness: ${inventory.defaultHarness} (omit agent harness: to use headless Pi)`,
1512
+ `default harness: ${inventory.defaultHarness} (used when an agent omits harness:)`,
1512
1513
  "enable/disable = Git YAML (.kxm/agents, .kxm/models) or the harness's own plugin CLI",
1513
1514
  "governed kxm skills are not auto-updated",
1514
1515
  header,
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.103";
14
+ const VERSION = "0.7.105";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -0,0 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { kxmUserStateRoot } from "./bindings.ts";
5
+
6
+ /* ------------------------------------------------------------------ *
7
+ * Where the Runtime keeps its stores under the user state root.
8
+ *
9
+ * A leaf module on purpose: backup and restore (`database.ts`) must find a
10
+ * project's event store exactly as the Runtime does, and `runtime-store.ts`
11
+ * imports `database.ts`, so the derivation cannot live there without a cycle.
12
+ * ------------------------------------------------------------------ */
13
+
14
+ export interface KxmRuntimePaths {
15
+ stateRoot: string;
16
+ runtimeDir: string;
17
+ registryDb: string;
18
+ projectsDir: string;
19
+ }
20
+
21
+ export function kxmRuntimePaths(options: { stateRoot?: string; env?: NodeJS.ProcessEnv; homeDir?: string } = {}): KxmRuntimePaths {
22
+ const stateRoot = options.stateRoot
23
+ ? resolve(options.stateRoot)
24
+ : kxmUserStateRoot({ ...(options.env ? { env: options.env } : {}), ...(options.homeDir ? { homeDir: options.homeDir } : {}) });
25
+ const runtimeDir = join(stateRoot, "runtime");
26
+ return {
27
+ stateRoot,
28
+ runtimeDir,
29
+ registryDb: join(runtimeDir, "registry.db"),
30
+ projectsDir: join(runtimeDir, "projects"),
31
+ };
32
+ }
33
+
34
+ export function projectRuntimeKey(projectRoot: string): string {
35
+ // Canonicalize through the filesystem like the repository binding store so
36
+ // reaching a project through a link cannot mint a second key for it.
37
+ let canonical: string;
38
+ try {
39
+ canonical = realpathSync.native(resolve(projectRoot));
40
+ } catch {
41
+ canonical = resolve(projectRoot);
42
+ }
43
+ const folded = process.platform === "win32" ? canonical.toLocaleLowerCase("en-US") : canonical;
44
+ return createHash("sha256").update(folded, "utf8").digest("hex").slice(0, 24);
45
+ }
46
+
47
+ /** The project's Runtime event store, derived exactly as the Runtime derives it. */
48
+ export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.ProcessEnv): string {
49
+ return join(kxmRuntimePaths({ env }).projectsDir, projectRuntimeKey(projectRoot), "run-events.db");
50
+ }