@kontextmind/kxm 0.7.128 → 0.7.131

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 (65) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/README.md +4 -1
  3. package/.kxm/agents/coordinator.yaml +1 -0
  4. package/.kxm/agents/critic-arch.yaml +1 -4
  5. package/.kxm/agents/critic-cli.yaml +1 -4
  6. package/.kxm/agents/implementer.yaml +1 -4
  7. package/.kxm/workflows/implement-only.yaml +1 -1
  8. package/.kxm/workflows/review-arch-only.yaml +1 -1
  9. package/.kxm/workflows/review-cli-only.yaml +1 -1
  10. package/CHANGELOG.md +42 -1
  11. package/docs/architecture/inventory.md +1 -1
  12. package/docs/concepts/architecture.md +1 -1
  13. package/docs/concepts/data-and-storage.md +1 -1
  14. package/docs/contracts/README.md +1 -1
  15. package/docs/contracts/migration.md +12 -8
  16. package/docs/contracts/routing.md +3 -3
  17. package/docs/contributing/assignment-runner.md +14 -15
  18. package/docs/contributing/development.md +22 -6
  19. package/docs/contributing/harness-routing-internals.md +8 -8
  20. package/docs/glossary.md +1 -1
  21. package/docs/guides/agent-skills.md +1 -1
  22. package/docs/operations/backup-and-restore.md +7 -7
  23. package/docs/operations/upgrade.md +2 -2
  24. package/docs/reference/cli-reference.md +37 -14
  25. package/docs/reference/config-reference.md +81 -155
  26. package/docs/reference/harness-routing.md +29 -21
  27. package/docs/reference/workflow-catalog.md +4 -4
  28. package/docs/start/first-workflow.md +1 -1
  29. package/examples/project/.kxm/agents/coordinator.yaml +1 -2
  30. package/examples/project/.kxm/agents/critic-1.yaml +1 -3
  31. package/examples/project/.kxm/agents/critic-2.yaml +1 -2
  32. package/examples/project/.kxm/agents/critic-3.yaml +2 -3
  33. package/examples/project/.kxm/agents/implementer.yaml +1 -2
  34. package/examples/project/.kxm/agents/planner.yaml +1 -2
  35. package/examples/project/.kxm/agents/reproducer.yaml +1 -2
  36. package/examples/project/.kxm/agents/reviewer.yaml +1 -2
  37. package/examples/project/.kxm/workflows/fix.yaml +0 -12
  38. package/package.json +8 -8
  39. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  40. package/plugins/kxm/dist/cli.js +2565 -465
  41. package/plugins/kxm/dist/mcp-server.js +1 -1
  42. package/plugins/kxm/dist/runtime-supervisor.js +683 -348
  43. package/plugins/kxm/dist/runtime.js +694 -358
  44. package/plugins/kxm/dist/server.js +17 -14
  45. package/plugins/kxm/package.json +1 -1
  46. package/plugins/kxm/skills/kxm/SKILL.md +1 -1
  47. package/plugins/kxm/skills/kxm-definitions/SKILL.md +8 -0
  48. package/plugins/kxm/src/cli/lanes.ts +109 -4
  49. package/plugins/kxm/src/cli/project.ts +6 -6
  50. package/plugins/kxm/src/database.ts +25 -19
  51. package/plugins/kxm/src/engine.ts +168 -163
  52. package/plugins/kxm/src/init-guide-setup.ts +75 -15
  53. package/plugins/kxm/src/mcp-server.ts +1 -1
  54. package/plugins/kxm/src/oneshot-producer.ts +3 -15
  55. package/plugins/kxm/src/project-config.ts +83 -70
  56. package/plugins/kxm/src/routes.ts +26 -18
  57. package/plugins/kxm/src/runtime-service.ts +1 -0
  58. package/plugins/kxm/src/runtime-store.ts +390 -77
  59. package/plugins/kxm/src/runtime-supervisor.ts +87 -21
  60. package/plugins/kxm/src/suggest.ts +2 -2
  61. package/plugins/kxm/src/template.ts +2 -14
  62. package/schemas/agent.schema.json +8 -0
  63. package/scripts/roster-policy.d.mts +2 -1
  64. package/scripts/roster-policy.mjs +119 -22
  65. package/scripts/run-bounded.mjs +53 -0
@@ -29,7 +29,6 @@ import {
29
29
  export interface KxmOneShotProducerOptions {
30
30
  projectRoot?: string | undefined;
31
31
  evidenceRoot?: string | undefined;
32
- defaultHarness?: string | undefined;
33
32
  defaultModel?: string | undefined;
34
33
  defaultProvider?: string | undefined;
35
34
  inventory?: HarnessInventory | undefined;
@@ -39,9 +38,7 @@ export interface KxmOneShotProducerOptions {
39
38
  timeoutMs?: number | undefined;
40
39
  spawnProcess?: KxmOneShotSpawn | undefined;
41
40
  probeHarness?: ((options: Omit<HarnessAssignmentProbeOptions, "runCommand"> & { signal?: AbortSignal | undefined }) => HarnessStatus | Promise<HarnessStatus>) | undefined;
42
- resolveHarness?: ((agentId: string, runId: string) => string | undefined) | undefined;
43
41
  resolveModel?: ((agentId: string, runId: string) => {
44
- harness?: string | undefined;
45
42
  provider?: string | undefined;
46
43
  model?: string | undefined;
47
44
  thinking?: string | undefined;
@@ -66,21 +63,12 @@ function determineOutcome(text: string, allowedOutcomes: readonly string[]): str
66
63
  }
67
64
 
68
65
  export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}): KxmOneShotProducer {
69
- const defaultHarness = options.defaultHarness ?? "claude";
70
66
  const running = new Map<AbortController, Promise<KxmProducerResult>>();
71
67
  let closed = false;
72
68
 
73
- function resolveHarnessForRequest(request: KxmProducerRequest): string {
69
+ function requireHarness(request: KxmProducerRequest): string {
74
70
  if (request.harness) return request.harness;
75
- if (options.resolveHarness) {
76
- const resolved = options.resolveHarness(request.agentId, request.runId);
77
- if (resolved) return resolved;
78
- }
79
- if (options.resolveModel) {
80
- const resolved = options.resolveModel(request.agentId, request.runId);
81
- if (resolved?.harness) return resolved.harness;
82
- }
83
- return defaultHarness;
71
+ throw new Error("producer_harness_required");
84
72
  }
85
73
 
86
74
  function parseModelString(spec: string, harness: string): { provider: string; model: string } {
@@ -155,7 +143,7 @@ export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}
155
143
 
156
144
  async function executeRequest(request: KxmProducerRequest): Promise<KxmProducerResult> {
157
145
  const startTime = Date.now();
158
- const harness = resolveHarnessForRequest(request);
146
+ const harness = requireHarness(request);
159
147
  const resolved = resolveModelForRequest(request, harness);
160
148
 
161
149
  if (closed) throw new Error("oneshot_producer_closed");
@@ -13,7 +13,7 @@ import { pathToFileURL } from "node:url";
13
13
  import { validatePolicyDraft } from "./policy-draft.mjs";
14
14
  import { resolveKxmTemplateBaseline } from "./template.ts";
15
15
  import { findKxmRepoRoot } from "./repo-root.ts";
16
- import { BUILTIN_HARNESS_IDS, DEFAULT_HARNESS, validateHarnessModelPair } from "./harness.ts";
16
+ import { BUILTIN_HARNESS_IDS, DEFAULT_HARNESS } from "./harness.ts";
17
17
 
18
18
  export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
19
19
  export type JsonObject = { [key: string]: JsonValue };
@@ -522,6 +522,7 @@ function listNamedResources(
522
522
  logicalDirectory: string,
523
523
  kind: "agent" | "model" | "role" | "workflow",
524
524
  replacedId?: string,
525
+ collected?: KxmConfigIssue[],
525
526
  ): Map<string, KxmResource> {
526
527
  const resources = new Map<string, KxmResource>();
527
528
  if (!existsSync(directory)) return resources;
@@ -561,7 +562,10 @@ function listNamedResources(
561
562
  else throw error;
562
563
  }
563
564
  }
564
- if (issues.length > 0) throw new KxmConfigError(issues);
565
+ if (issues.length > 0) {
566
+ if (collected) collected.push(...issues);
567
+ else throw new KxmConfigError(issues);
568
+ }
565
569
  return resources;
566
570
  }
567
571
 
@@ -925,6 +929,9 @@ function validateWorkflow(
925
929
  for (const repositoryId of Object.keys(objectValue(step.repositories) ?? {})) {
926
930
  if (!repositories.has(repositoryId)) issues.push(issue("reference", "repository_unknown", file, `${stepId} references unknown repository ${repositoryId}`));
927
931
  }
932
+ if ((kind === "agent" || kind === "moa") && step.model !== undefined) {
933
+ issues.push(issue("semantic", "producer_route_unsupported", file, `${stepId} model is not honored; remove model from the step`));
934
+ }
928
935
  selectorCandidates(step.model, models, file, `${stepId}.model`, issues);
929
936
 
930
937
  const assignment = objectValue(step.assignments);
@@ -1128,7 +1135,7 @@ function validateBundle(
1128
1135
  options: KxmConfigOptions,
1129
1136
  gateRegistry?: KxmResource,
1130
1137
  projectRoot?: string,
1131
- writerRole?: string,
1138
+ roles: ReadonlyMap<string, KxmResource> = new Map(),
1132
1139
  ): KxmConfigIssue[] {
1133
1140
  const issues: KxmConfigIssue[] = [];
1134
1141
  const entries = valuesOf(project.value, "repositories").map((candidate) => objectValue(candidate)).filter((candidate): candidate is JsonObject => Boolean(candidate));
@@ -1173,28 +1180,17 @@ function validateBundle(
1173
1180
  if (!harnesses.has(defaultHarness)) issues.push(issue("reference", "harness_unknown", project.logicalPath, `defaultHarness ${defaultHarness} is not registered`));
1174
1181
 
1175
1182
  for (const agent of agents.values()) {
1183
+ if (agent.value.model !== undefined || agent.value.harness !== undefined) {
1184
+ issues.push(issue("semantic", "retired_agent_routing_fields", agent.logicalPath, "routing resolves from role; remove model and harness"));
1185
+ }
1176
1186
  validateToolPolicy(agent, objectValue(agent.value.tools), "tools", issues);
1177
1187
  const executor = stringValue(agent.value.executor);
1178
1188
  if (executor && !executors.has(executor)) issues.push(issue("reference", "executor_unknown", agent.logicalPath, `executor ${executor} is not registered`));
1179
- const harness = stringValue(agent.value.harness) ?? defaultHarness;
1180
- if (!harnesses.has(harness)) issues.push(issue("reference", "harness_unknown", agent.logicalPath, `harness ${harness} is not registered`));
1181
1189
  const preset = stringValue(objectValue(agent.value.tools)?.preset);
1182
1190
  if (preset && !presets.has(preset)) issues.push(issue("reference", "tool_preset_unknown", agent.logicalPath, `tool preset ${preset} is not registered`));
1183
1191
  for (const repositoryId of Object.keys(objectValue(agent.value.repositories) ?? {})) {
1184
1192
  if (!repositoryIds.has(repositoryId)) issues.push(issue("reference", "repository_unknown", agent.logicalPath, `references unknown repository ${repositoryId}`));
1185
1193
  }
1186
- const candidates = selectorCandidates(agent.value.model, models, agent.logicalPath, "model", issues);
1187
- const declaredHarness = stringValue(agent.value.harness);
1188
- if (declaredHarness && harnesses.has(declaredHarness)) {
1189
- for (const candidate of candidates) {
1190
- const candidateProvider = stringValue(candidate.value.provider);
1191
- const candidateModel = stringValue(candidate.value.model);
1192
- const validation = validateHarnessModelPair(declaredHarness, { provider: candidateProvider, model: candidateModel });
1193
- if (!validation.valid) {
1194
- issues.push(issue("semantic", validation.issue ?? "harness_unhosted_model", agent.logicalPath, validation.message ?? `harness ${declaredHarness} cannot host model ${candidateModel ?? candidate.logicalPath}`));
1195
- }
1196
- }
1197
- }
1198
1194
  }
1199
1195
  validateModelReferences(models, issues);
1200
1196
 
@@ -1202,62 +1198,73 @@ function validateBundle(
1202
1198
  if (!workflows.has(defaultWorkflow)) issues.push(issue("reference", "default_workflow_unknown", project.logicalPath, `default workflow ${defaultWorkflow} does not exist`));
1203
1199
  for (const workflow of workflows.values()) validateWorkflow(workflow, agents, models, repositoryIds, gates, issues);
1204
1200
 
1201
+ // Developer policy is the role files under .kxm/roles/ (with the model
1202
+ // files they name). scripts/harness-run.mjs only supplies ceilings.
1203
+ if (projectRoot && existsSync(join(projectRoot, ".kxm", "roster.yaml"))) {
1204
+ issues.push(issue("semantic", "retired_roster_file", ".kxm/roster.yaml", "create the role and model files and delete roster.yaml"));
1205
+ }
1206
+ if (projectRoot && existsSync(join(projectRoot, ".kxm", "roles"))) {
1207
+ issues.push(...developerRolePolicyIssues(projectRoot));
1208
+ }
1205
1209
  if (projectRoot) {
1206
- const writerRolePath = join(projectRoot, ".kxm", "roles", "writer.yaml");
1207
- const implementerAgent = agents.get("implementer") ?? agents.get("writer");
1208
- if ((writerRole !== undefined || existsSync(writerRolePath)) && implementerAgent) {
1210
+ issues.push(...rosterModelIssues(projectRoot, roles));
1211
+ issues.push(...originHashIssues(projectRoot, models));
1212
+ }
1213
+
1214
+ return sortIssues(issues);
1215
+ }
1216
+
1217
+ function rosterModelIssues(projectRoot: string, roles: ReadonlyMap<string, KxmResource>): KxmConfigIssue[] {
1218
+ const issues: KxmConfigIssue[] = [];
1219
+ for (const role of roles.values()) {
1220
+ for (const entry of valuesOf(role.value, "roster")) {
1221
+ const route = stringValue(objectValue(entry)?.route);
1222
+ if (!route) continue;
1223
+ const logical = `.kxm/models/${route}.yaml`;
1224
+ const file = join(projectRoot, ".kxm", "models", `${route}.yaml`);
1225
+ let harness: string | undefined;
1209
1226
  try {
1210
- const rawRole = parseRestrictedYaml(writerRole ?? readFileSync(writerRolePath, "utf8"));
1211
- const roleObj = objectValue(rawRole);
1212
- const rosterEntries = valuesOf(roleObj ?? {}, "roster")
1213
- .map((candidate) => objectValue(candidate))
1214
- .filter((entry): entry is JsonObject => Boolean(entry));
1215
- const enabledRosterModels = rosterEntries.flatMap((entry) => {
1216
- const direct = stringValue(entry.model);
1217
- const route = stringValue(entry.route);
1218
- const named: string[] = direct ? [direct] : [];
1219
- if (route) {
1220
- const modelFile = join(projectRoot, ".kxm", "models", `${route}.yaml`);
1221
- if (existsSync(modelFile)) {
1222
- try {
1223
- const modelDoc = objectValue(parseRestrictedYaml(readFileSync(modelFile, "utf8"), modelFile));
1224
- const model = modelDoc ? stringValue(modelDoc.model) : undefined;
1225
- const vendor = modelDoc ? stringValue(modelDoc.vendor) : undefined;
1226
- const harness = modelDoc ? stringValue(modelDoc.harness) : undefined;
1227
- if (model) named.push(model);
1228
- if (vendor && model) named.push(`${vendor}/${model}`);
1229
- if (harness && model) named.push(`${harness}/${model}`);
1230
- } catch { /* schema issues are reported by resource validation */ }
1231
- }
1232
- }
1233
- return named;
1234
- });
1235
-
1236
- const agentModelObj = objectValue(implementerAgent.value.model);
1237
- const agentModelStr = stringValue(implementerAgent.value.model);
1238
- const agentProvider = agentModelObj ? stringValue(agentModelObj.provider) : undefined;
1239
- const agentModel = agentModelObj ? stringValue(agentModelObj.model) : agentModelStr;
1240
- const canonicalAgentModel = agentProvider && agentModel ? `${agentProvider}/${agentModel}` : agentModel;
1241
-
1242
- if (enabledRosterModels.length > 0 && canonicalAgentModel) {
1243
- const matches = enabledRosterModels.some((rm) => rm === canonicalAgentModel || rm === agentModel || rm.endsWith(`/${agentModel}`));
1244
- if (!matches) {
1245
- issues.push(issue("semantic", "role_roster_conflicts_with_agent", ".kxm/roles/writer.yaml", `role roster in .kxm/roles/writer.yaml does not include agent model ${canonicalAgentModel} from ${implementerAgent.logicalPath}`));
1246
- }
1247
- }
1248
- } catch (error) {
1249
- if (error instanceof KxmConfigError) {
1250
- issues.push(...error.issues);
1251
- }
1227
+ if (existsSync(file)) harness = stringValue(parseRestrictedYaml(readFileSync(file, "utf8"), logical).harness);
1228
+ } catch {
1229
+ harness = undefined;
1230
+ }
1231
+ if (!harness) {
1232
+ issues.push(issue("semantic", "roster_model_unreadable", role.logicalPath, `roster route ${route} does not resolve to a readable ${logical} carrying harness`));
1252
1233
  }
1253
1234
  }
1254
1235
  }
1236
+ return issues;
1237
+ }
1255
1238
 
1256
- if (projectRoot && existsSync(join(projectRoot, ".kxm", "roster.yaml"))) {
1257
- issues.push(...developerRolePolicyIssues(projectRoot));
1239
+ function originHashIssues(projectRoot: string, models: ReadonlyMap<string, KxmResource>): KxmConfigIssue[] {
1240
+ const issues: KxmConfigIssue[] = [];
1241
+ for (const model of models.values()) {
1242
+ const origin = objectValue(model.value.origin);
1243
+ const source = origin ? stringValue(origin.source) : undefined;
1244
+ const digest = origin ? stringValue(origin.sha256) : undefined;
1245
+ if (!source || !digest) continue;
1246
+ const file = resolve(projectRoot, source);
1247
+ const escaped = relative(projectRoot, file);
1248
+ if (isAbsolute(source) || escaped.startsWith("..") || isAbsolute(escaped)) {
1249
+ issues.push(issue("semantic", "origin_evidence_missing", model.logicalPath, `missing evidence bytes for ${source}`));
1250
+ continue;
1251
+ }
1252
+ let actual: string;
1253
+ try {
1254
+ actual = createHash("sha256").update(readFileSync(file, "utf8"), "utf8").digest("hex");
1255
+ } catch (error) {
1256
+ const code = (error as NodeJS.ErrnoException).code;
1257
+ if (code === "ENOENT" || code === "EACCES" || code === "EPERM" || code === "EISDIR") {
1258
+ issues.push(issue("semantic", "origin_evidence_missing", model.logicalPath, `missing evidence bytes for ${source}`));
1259
+ continue;
1260
+ }
1261
+ throw error;
1262
+ }
1263
+ if (actual !== digest) {
1264
+ issues.push(issue("semantic", "origin_hash_mismatch", model.logicalPath, "origin evidence hash does not match supplied bytes"));
1265
+ }
1258
1266
  }
1259
-
1260
- return sortIssues(issues);
1267
+ return issues;
1261
1268
  }
1262
1269
 
1263
1270
  function developerCeilings(): {
@@ -1294,6 +1301,10 @@ function developerCeilings(): {
1294
1301
  }
1295
1302
 
1296
1303
  function developerRolePolicyIssues(projectRoot: string): KxmConfigIssue[] {
1304
+ // Product checkouts also have .kxm/roles/ for dispatch. The ceiling check
1305
+ // is this package's developer policy, so it runs when the loaded project
1306
+ // is that package.
1307
+ if (resolve(projectRoot) !== findKxmRepoRoot(import.meta.url)) return [];
1297
1308
  const rolesDir = join(projectRoot, ".kxm", "roles");
1298
1309
  const modelsDir = join(projectRoot, ".kxm", "models");
1299
1310
  const roles: Record<string, JsonObject> = {};
@@ -1314,6 +1325,7 @@ function developerRolePolicyIssues(projectRoot: string): KxmConfigIssue[] {
1314
1325
  parseIssues.push(...error.issues);
1315
1326
  continue;
1316
1327
  }
1328
+ throw error;
1317
1329
  }
1318
1330
  }
1319
1331
  };
@@ -1328,7 +1340,7 @@ function developerRolePolicyIssues(projectRoot: string): KxmConfigIssue[] {
1328
1340
  }
1329
1341
  const ceilings = developerCeilings();
1330
1342
  if (!ceilings.ok) {
1331
- return [...parseIssues, issue("semantic", "developer_ceilings_unavailable", ".kxm/roster.yaml", `developer ceilings could not be loaded: ${ceilings.detail}`)];
1343
+ return [...parseIssues, issue("semantic", "developer_ceilings_unavailable", ".kxm/models", `developer ceilings could not be loaded: ${ceilings.detail}`)];
1332
1344
  }
1333
1345
  const result = validatePolicyDraft({ models, roles, evidence }, {
1334
1346
  ceilings: ceilings.ROUTES,
@@ -1489,7 +1501,8 @@ function loadProjectBundle(
1489
1501
  if (earlyIssues.length > 0) throw new KxmConfigError(earlyIssues);
1490
1502
 
1491
1503
  const agents = listNamedResources(registry, root, join(root, ".kxm", "agents"), ".kxm/agents", "agent");
1492
- const models = listNamedResources(registry, root, join(root, ".kxm", "models"), ".kxm/models", "model");
1504
+ const modelIssues: KxmConfigIssue[] = [];
1505
+ const models = listNamedResources(registry, root, join(root, ".kxm", "models"), ".kxm/models", "model", undefined, modelIssues);
1493
1506
  const roles = listNamedResources(registry, root, join(root, ".kxm", "roles"), ".kxm/roles", "role", writerRole === undefined ? undefined : "writer");
1494
1507
  if (writerRole !== undefined) {
1495
1508
  const logicalPath = ".kxm/roles/writer.yaml";
@@ -1620,9 +1633,9 @@ function loadProjectBundle(
1620
1633
  }
1621
1634
  }
1622
1635
  }
1623
- if (loadIssues.length > 0) throw new KxmConfigError(loadIssues);
1636
+ if (loadIssues.length > 0) throw new KxmConfigError([...loadIssues, ...modelIssues]);
1624
1637
 
1625
- const issues = validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry, root, writerRole);
1638
+ const issues = [...modelIssues, ...validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry, root, roles)];
1626
1639
  if (issues.length > 0) throw new KxmConfigError(issues);
1627
1640
  const resources = [project, ...repositories.values(), ...agents.values(), ...models.values(), ...roles.values(), ...workflows.values(), ...environments, ...(gateRegistry ? [gateRegistry] : [])]
1628
1641
  .sort((left, right) => compareCodeUnits(left.logicalPath, right.logicalPath));
@@ -5,15 +5,15 @@ import { rolePurposeForId } from "./role.ts";
5
5
 
6
6
  const RETIRED_POLICY = ".kxm/producers.yaml";
7
7
 
8
- export interface RoutePolicy { schema: "kxm.routes.v2"; updatedAt: string; admitted: string[]; disabled: string[]; roles: Record<string, string[]>; }
9
- const empty = (): RoutePolicy => ({ schema: "kxm.routes.v2", updatedAt: new Date().toISOString(), admitted: [], disabled: [], roles: {} });
8
+ export interface RoutePolicy { schema: "kxm.routes.v2"; updatedAt: string; admitted: string[]; disabled: string[]; }
9
+ const empty = (): RoutePolicy => ({ schema: "kxm.routes.v2", updatedAt: new Date().toISOString(), admitted: [], disabled: [] });
10
10
  export function loadRoutePolicy(root: string): RoutePolicy {
11
11
  if (existsSync(join(root, RETIRED_POLICY))) throw new Error(`retired ${RETIRED_POLICY} present; use .kxm/routes.yaml (kxm.routes.v2)`);
12
12
  const path = join(root, ".kxm", "routes.yaml");
13
13
  if (!existsSync(path)) return empty();
14
14
  const value = parse(readFileSync(path, "utf8")) as Partial<RoutePolicy>;
15
15
  if (value?.schema !== "kxm.routes.v2" || !Array.isArray(value.admitted)) throw new Error("invalid .kxm/routes.yaml");
16
- return { schema: "kxm.routes.v2", updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : new Date().toISOString(), admitted: value.admitted.filter((x): x is string => typeof x === "string"), disabled: Array.isArray(value.disabled) ? value.disabled.filter((x): x is string => typeof x === "string") : [], roles: value.roles && typeof value.roles === "object" ? Object.fromEntries(Object.entries(value.roles).filter(([, v]) => Array.isArray(v)).map(([k, v]) => [k, (v as unknown[]).filter((x): x is string => typeof x === "string")])) : {} };
16
+ return { schema: "kxm.routes.v2", updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : new Date().toISOString(), admitted: value.admitted.filter((x): x is string => typeof x === "string"), disabled: Array.isArray(value.disabled) ? value.disabled.filter((x): x is string => typeof x === "string") : [] };
17
17
  }
18
18
  export function updateRouteState(root: string, model: string, state: "admitted" | "disabled"): RoutePolicy {
19
19
  return setRouteState(root, model, state);
@@ -33,21 +33,7 @@ export function setRouteState(root: string, model: string, state: "admitted" | "
33
33
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) roleFile = parsed as Record<string, unknown>;
34
34
  }
35
35
  const roster = Array.isArray(roleFile.roster) ? roleFile.roster.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
36
- const modelsDir = join(root, ".kxm", "models");
37
- let routeId: string | undefined;
38
- if (existsSync(modelsDir)) {
39
- for (const name of readdirSync(modelsDir)) {
40
- if (!name.endsWith(".yaml") || name === "inventory.yaml") continue;
41
- const parsedModel = parse(readFileSync(join(modelsDir, name), "utf8")) as { vendor?: unknown; model?: unknown; schema?: unknown };
42
- if (parsedModel?.schema !== "kxm.model.v2") continue;
43
- const vendor = typeof parsedModel.vendor === "string" ? parsedModel.vendor : "";
44
- const named = typeof parsedModel.model === "string" ? parsedModel.model : "";
45
- if (named === model || (vendor && `${vendor}/${named}` === model)) {
46
- routeId = name.slice(0, -5);
47
- break;
48
- }
49
- }
50
- }
36
+ const routeId = routeIdForInventoryModel(root, model);
51
37
  if (!routeId) throw new Error(`unknown route: no v2 model file matches '${model}'`);
52
38
  const existing = roster.findIndex((entry) => entry.route === routeId || entry.model === model);
53
39
  if (removeRole) { if (existing >= 0) roster.splice(existing, 1); }
@@ -68,6 +54,28 @@ export function setRouteState(root: string, model: string, state: "admitted" | "
68
54
  return policy;
69
55
  }
70
56
 
57
+ /** Route-file id whose vendor/model matches an inventory selector, if one exists. */
58
+ export function routeIdForInventoryModel(root: string, model: string): string | undefined {
59
+ const modelsDir = join(root, ".kxm", "models");
60
+ if (!existsSync(modelsDir)) return undefined;
61
+ for (const name of readdirSync(modelsDir)) {
62
+ if (!name.endsWith(".yaml") || name === "inventory.yaml") continue;
63
+ const parsedModel = parse(readFileSync(join(modelsDir, name), "utf8")) as { vendor?: unknown; model?: unknown; schema?: unknown };
64
+ if (parsedModel?.schema !== "kxm.model.v2") continue;
65
+ const vendor = typeof parsedModel.vendor === "string" ? parsedModel.vendor : "";
66
+ const named = typeof parsedModel.model === "string" ? parsedModel.model : "";
67
+ if (named === model || (vendor && `${vendor}/${named}` === model)) return name.slice(0, -5);
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ /** Role ids whose roster names the model file that matches this inventory selector. */
73
+ export function rolesForInventoryModel(root: string, inventoryId: string): string[] {
74
+ const routeId = routeIdForInventoryModel(root, inventoryId);
75
+ if (!routeId) return [];
76
+ return Object.entries(listRoleBindings(root)).filter(([, ids]) => ids.includes(routeId)).map(([role]) => role);
77
+ }
78
+
71
79
  export function listRoleBindings(root: string): Record<string, string[]> {
72
80
  const dir = join(root, ".kxm", "roles"); const result: Record<string, string[]> = {};
73
81
  if (!existsSync(dir)) return result;
@@ -315,6 +315,7 @@ export function openKxmRuntimeContext(
315
315
  homeRuntimeId: options.homeRuntimeId,
316
316
  configRevision: bundle.configRevision,
317
317
  now: options.now ?? new Date().toISOString(),
318
+ ...(options.logger ? { logger: options.logger } : {}),
318
319
  });
319
320
  const eventStore = new KxmRunEventStore(join(paths.projectsDir, registration.projectKey, "run-events.db"));
320
321
  registerKxmRuntimeHandle(eventStore.path);