@kontextmind/kxm 0.7.130 → 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 (54) 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 +22 -1
  11. package/docs/concepts/architecture.md +1 -1
  12. package/docs/contracts/routing.md +3 -3
  13. package/docs/contributing/assignment-runner.md +14 -15
  14. package/docs/contributing/development.md +19 -5
  15. package/docs/contributing/harness-routing-internals.md +8 -8
  16. package/docs/glossary.md +1 -1
  17. package/docs/guides/agent-skills.md +1 -1
  18. package/docs/operations/backup-and-restore.md +4 -4
  19. package/docs/reference/cli-reference.md +26 -8
  20. package/docs/reference/config-reference.md +76 -153
  21. package/docs/reference/harness-routing.md +29 -21
  22. package/docs/reference/workflow-catalog.md +4 -4
  23. package/docs/start/first-workflow.md +1 -1
  24. package/examples/project/.kxm/agents/coordinator.yaml +1 -2
  25. package/examples/project/.kxm/agents/critic-1.yaml +1 -3
  26. package/examples/project/.kxm/agents/critic-2.yaml +1 -2
  27. package/examples/project/.kxm/agents/critic-3.yaml +2 -3
  28. package/examples/project/.kxm/agents/implementer.yaml +1 -2
  29. package/examples/project/.kxm/agents/planner.yaml +1 -2
  30. package/examples/project/.kxm/agents/reproducer.yaml +1 -2
  31. package/examples/project/.kxm/agents/reviewer.yaml +1 -2
  32. package/examples/project/.kxm/workflows/fix.yaml +0 -12
  33. package/package.json +1 -1
  34. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  35. package/plugins/kxm/dist/cli.js +287 -249
  36. package/plugins/kxm/dist/mcp-server.js +1 -1
  37. package/plugins/kxm/dist/runtime-supervisor.js +222 -242
  38. package/plugins/kxm/dist/runtime.js +224 -244
  39. package/plugins/kxm/package.json +1 -1
  40. package/plugins/kxm/skills/kxm/SKILL.md +1 -1
  41. package/plugins/kxm/skills/kxm-definitions/SKILL.md +8 -0
  42. package/plugins/kxm/src/cli/project.ts +4 -4
  43. package/plugins/kxm/src/engine.ts +168 -163
  44. package/plugins/kxm/src/init-guide-setup.ts +75 -15
  45. package/plugins/kxm/src/mcp-server.ts +1 -1
  46. package/plugins/kxm/src/oneshot-producer.ts +3 -15
  47. package/plugins/kxm/src/project-config.ts +83 -70
  48. package/plugins/kxm/src/routes.ts +26 -18
  49. package/plugins/kxm/src/runtime-supervisor.ts +0 -18
  50. package/plugins/kxm/src/suggest.ts +2 -2
  51. package/plugins/kxm/src/template.ts +2 -14
  52. package/schemas/agent.schema.json +8 -0
  53. package/scripts/roster-policy.d.mts +2 -1
  54. package/scripts/roster-policy.mjs +119 -22
@@ -12,9 +12,10 @@
12
12
  * - `.kxm/workflows/<slug>.yaml` (kxm.workflow.v1)
13
13
  * - `.kxm/roles/<role-slug>.yaml` (kxm.role.v2)
14
14
  * - `.kxm/models/<route-id>.yaml` (kxm.model.v2)
15
+ * - `plans/evidence/route-guide-qwen-pi.md` when a Pi binding matches that note
15
16
  * - admitted selectors appended to `.kxm/routes.yaml`
16
- * It never writes retired legacy authority (`.kxm/config`, retired
17
- * `.kxm/roster.json`) or the trusted `.kxm/roster.yaml` policy.
17
+ * It never writes retired legacy authority (`.kxm/config` or
18
+ * `.kxm/roster.json`). Role and model files are the dispatch authority.
18
19
  *
19
20
  * Guide research ids are not dispatch ids. Only the admitted map below is
20
21
  * written, and only when that harness is authenticated. Unmapped ids are skipped.
@@ -28,8 +29,7 @@
28
29
  * admission decision says so.
29
30
  */
30
31
 
31
- import { createHash } from "node:crypto";
32
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
33
33
  import { dirname, join } from "node:path";
34
34
  import { stringify } from "yaml";
35
35
  import { type HarnessInventory } from "./harness.ts";
@@ -446,13 +446,12 @@ function isWriterRole(roleSlug: string): boolean {
446
446
  return WRITER_ROLE_PATTERN.test(roleSlug);
447
447
  }
448
448
 
449
- function agentDocument(roleSlug: string, stage: GuideStage, binding: AgentBinding): Record<string, unknown> {
449
+ function agentDocument(roleSlug: string, stage: GuideStage): Record<string, unknown> {
450
450
  const writer = isWriterRole(roleSlug);
451
451
  return {
452
452
  schema: "kxm.agent.v1",
453
453
  purpose: `${stage.domain} (workflow-guide ${stage.title}; candidate verification is the operator's responsibility)`,
454
- harness: binding.harness,
455
- model: { provider: binding.provider, model: binding.model },
454
+ role: roleSlug,
456
455
  tools: { preset: writer ? "workspace-writer" : "read-only" },
457
456
  defaultRepositoryAccess: writer ? "none" : "read",
458
457
  repositories: { control: writer ? "write" : "read" },
@@ -485,26 +484,78 @@ function workflowDocument(workflow: GuideWorkflow): Record<string, unknown> {
485
484
  };
486
485
  }
487
486
 
487
+ /**
488
+ * Evidence written into the user's project for the one admitted guided Pi model.
489
+ * The hash is of these exact bytes. A Pi binding that is not this vendor, model
490
+ * id, and harness gets no model file and no origin.
491
+ */
492
+ const GUIDE_PI_EVIDENCE_SOURCE = "plans/evidence/route-guide-qwen-pi.md";
493
+ const GUIDE_PI_EVIDENCE_SHA256 = "109f39728b251dd0565e275ae689a6e1d9b889a488d996b157d500c538115759";
494
+ const GUIDE_PI_NOTE = {
495
+ harness: "pi",
496
+ vendor: "openrouter",
497
+ model: "qwen/qwen3-coder-plus",
498
+ reason: "Qwen has no supported native harness on this runner, and the operator admitted that exact model for guided setup.",
499
+ provingCommand: "pi auth check --provider openrouter",
500
+ } as const;
501
+
502
+ const GUIDE_PI_EVIDENCE_NOTE = `---
503
+ schema: "kxm.doc.v1"
504
+ id: "RES-GUIDE-QWEN-PI"
505
+ type: "research"
506
+ title: "Research: guided setup Pi Qwen origin"
507
+ project: "kxm"
508
+ status: "approved"
509
+ owner: "@operator"
510
+ created: "2026-09-26"
511
+ updated: "2026-09-26"
512
+ authority: "evidence"
513
+ confidence: "verified"
514
+ summary: "Guided setup pins every Pi model origin to this note so a later project.yaml edit cannot change the hash."
515
+ tags: ["routing", "qwen", "pi", "init-guide"]
516
+ related: []
517
+ details:
518
+ research_status: "complete"
519
+ target_decision_date: "2026-09-26"
520
+ ---
521
+
522
+ # route-guide-qwen-pi
523
+
524
+ Dated 2026-09-26. Route ids are the guided role slugs. Harness \`${GUIDE_PI_NOTE.harness}\`. Model id \`${GUIDE_PI_NOTE.model}\`. Vendor \`${GUIDE_PI_NOTE.vendor}\`. Admitted because ${GUIDE_PI_NOTE.reason} Auth was proved with \`${GUIDE_PI_NOTE.provingCommand}\`.
525
+ `;
526
+
527
+ function guidePiNoteMatches(binding: AgentBinding): boolean {
528
+ return binding.harness === GUIDE_PI_NOTE.harness
529
+ && binding.provider === GUIDE_PI_NOTE.vendor
530
+ && binding.model === GUIDE_PI_NOTE.model;
531
+ }
532
+
488
533
  /**
489
534
  * Render the planned KXM resource files (`.kxm/agents/*.yaml`,
490
- * `.kxm/workflows/*.yaml`). A Pi model origin hashes `.kxm/project.yaml`
491
- * when that file exists, and is omitted when it does not.
535
+ * `.kxm/workflows/*.yaml`, and the Pi evidence note when a binding matches it).
536
+ * The Pi origin is the hash of `plans/evidence/route-guide-qwen-pi.md` written
537
+ * into the project, whether or not `.kxm/project.yaml` exists.
492
538
  */
493
539
  export function renderGuideSetupFiles(projectRoot: string, plan: GuideSetupPlan): GuideSetupFile[] {
494
- const projectYaml = join(projectRoot, ".kxm", "project.yaml");
495
- const projectOrigin = existsSync(projectYaml)
496
- ? { source: ".kxm/project.yaml", sha256: createHash("sha256").update(readFileSync(projectYaml)).digest("hex") }
497
- : undefined;
498
540
  const files: GuideSetupFile[] = [];
499
541
  const roleStages = new Map<string, GuideStage>();
500
542
  for (const workflow of plan.workflows) {
501
543
  for (const stage of workflow.stages) roleStages.set(stage.role, stage);
502
544
  }
545
+ let evidenceQueued = false;
503
546
  for (const [role, binding] of [...plan.agents.entries()].sort(([a], [b]) => a.localeCompare(b))) {
504
547
  const stage = roleStages.get(role);
505
548
  if (!stage) continue;
506
549
  const routeId = role.replaceAll("_", "-");
507
550
  const writer = isWriterRole(role);
551
+ const piMatch = binding.harness === "pi" && guidePiNoteMatches(binding);
552
+ if (binding.harness === "pi" && !piMatch) {
553
+ files.push({
554
+ path: join(projectRoot, ".kxm", "agents", `${role}.yaml`),
555
+ content: stringify(agentDocument(role, stage)),
556
+ });
557
+ continue;
558
+ }
508
559
  const modelDocument: Record<string, unknown> = {
509
560
  schema: "kxm.model.v2",
510
561
  id: routeId,
@@ -514,10 +565,19 @@ export function renderGuideSetupFiles(projectRoot: string, plan: GuideSetupPlan)
514
565
  status: "admitted",
515
566
  permissions: [writer ? "edit" : "read-only"],
516
567
  };
517
- if (binding.harness === "pi" && projectOrigin) modelDocument.origin = projectOrigin;
568
+ if (piMatch) {
569
+ modelDocument.origin = { source: GUIDE_PI_EVIDENCE_SOURCE, sha256: GUIDE_PI_EVIDENCE_SHA256 };
570
+ if (!evidenceQueued) {
571
+ files.push({
572
+ path: join(projectRoot, GUIDE_PI_EVIDENCE_SOURCE),
573
+ content: GUIDE_PI_EVIDENCE_NOTE,
574
+ });
575
+ evidenceQueued = true;
576
+ }
577
+ }
518
578
  files.push({
519
579
  path: join(projectRoot, ".kxm", "agents", `${role}.yaml`),
520
- content: stringify(agentDocument(role, stage, binding)),
580
+ content: stringify(agentDocument(role, stage)),
521
581
  });
522
582
  files.push({
523
583
  path: join(projectRoot, ".kxm", "models", `${routeId}.yaml`),
@@ -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.130";
14
+ const VERSION = "0.7.131";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -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;
@@ -28,7 +28,6 @@ import {
28
28
  type KxmRuntimeContext,
29
29
  } from "./runtime-service.ts";
30
30
  import { createKxmOneShotProducer } from "./oneshot-producer.ts";
31
- import { isRouteAdmitted } from "./routes.ts";
32
31
  import { KxmRunScheduler, createKxmSimulatedProducer, recordDriveReceipt, recoverKxmRun, kxmDrivePollProjection } from "./engine.ts";
33
32
  import { kxmDriveSession, kxmOpenDriveSessions } from "./runtime-owner.ts";
34
33
  import { RuntimeHubClient, HubHttpError, type SyncPushResponse } from "./client.ts";
@@ -919,23 +918,6 @@ async function startKxmRuntimeSupervisorInner(
919
918
  ? createKxmOneShotProducer({
920
919
  projectRoot,
921
920
  timeoutMs: kxmProjectAdmissionLimits(bundle).agentStepTimeoutMs,
922
- defaultHarness: String(bundle.project.value.defaultHarness ?? "pi"),
923
- resolveHarness: (agentId) => {
924
- const agent = bundle.agents.get(agentId);
925
- return typeof agent?.value.harness === "string" ? agent.value.harness : undefined;
926
- },
927
- resolveModel: (agentId) => {
928
- const agent = bundle.agents.get(agentId);
929
- const model = agent?.value.model;
930
- if (!model || typeof model !== "object" || Array.isArray(model)) return undefined;
931
- const value = model as Record<string, unknown>;
932
- const provider = typeof value.provider === "string" ? value.provider : undefined;
933
- const modelName = typeof value.model === "string" ? value.model : undefined;
934
- if (!provider || !modelName || !isRouteAdmitted(projectRoot, `${provider}/${modelName}`)) {
935
- throw new Error("producer_route_not_admitted");
936
- }
937
- return { provider, model: modelName };
938
- },
939
921
  })
940
922
  : createKxmSimulatedProducer(async () => {
941
923
  if (delayMs > 0) {
@@ -191,10 +191,10 @@ export function suggestWorkflowAndRoles(
191
191
  supported: true,
192
192
  prerequisites: [
193
193
  "Run kxm init in the target repository if needed, then install the exact template with the suggested command. If that workflow ID already exists, stop and review it; the commands below apply only to a newly installed template, not an existing definition with potentially different agents or permissions.",
194
- ...roles.map((role) => `Set harness: ${role.harness} and model: <your authenticated compatible model selector> in .kxm/agents/${role.agent}.yaml. Admit that exact selector in .kxm/routes.yaml and ensure it is not disabled; this recommendation does not choose a model or change routing.`),
194
+ ...roles.map((role) => `Set role on .kxm/agents/${role.agent}.yaml to a role whose roster names an admitted route for harness ${role.harness}. Admit that route's selector in .kxm/routes.yaml and ensure it is not disabled; this recommendation does not choose a model or change routing.`),
195
195
  ...(writeStep ? [
196
196
  `Keep each writer step at assignments.maximum: 1 and set limits.maxConcurrentRuns: 1 in .kxm/project.yaml; the live ${selected.id} writer profile requires a lone writer in the checkout.`,
197
- `If .kxm/roster.yaml exists, it must be readable kxm.developer-roster.v1 with a lineup.writer route matching harness: ${selected.id} and the selected writer model (model or vendor/model), status: admitted, and permissions containing edit.`,
197
+ `The writer role roster must name a route whose harness is ${selected.id}, whose status is admitted, and whose permissions contain edit.`,
198
198
  "Configure the test gate in .kxm/gates.yaml with kind: command and argv for this repository's actual verification command. A scaffold/example command or simulation is not evidence that the implementation works.",
199
199
  ] : []),
200
200
  `Validate the installed workflow with kxm gate validate --file .kxm/workflows/${bestPattern.id}.yaml and the project configuration with kxm init --dry-run before creating a run.`,
@@ -175,21 +175,9 @@ function coreTemplate(projectId: string, projectName: string, variant: KxmTempla
175
175
  ]);
176
176
  if (variant === "v4-registry") {
177
177
  const coordinator = files.get(".kxm/agents/coordinator.yaml");
178
- if (coordinator) {
179
- files.set(".kxm/agents/coordinator.yaml", {
180
- ...coordinator,
181
- harness: "claude",
182
- model: { provider: "anthropic", model: "fable" },
183
- });
184
- }
178
+ if (coordinator) files.set(".kxm/agents/coordinator.yaml", { ...coordinator, role: "planner" });
185
179
  const implementer = files.get(".kxm/agents/implementer.yaml");
186
- if (implementer) {
187
- files.set(".kxm/agents/implementer.yaml", {
188
- ...implementer,
189
- harness: "grok",
190
- model: { provider: "xai", model: "grok-4.6" },
191
- });
192
- }
180
+ if (implementer) files.set(".kxm/agents/implementer.yaml", { ...implementer, role: "writer" });
193
181
  const workflow = files.get(".kxm/workflows/default.yaml");
194
182
  if (workflow) {
195
183
  const limits = { ...(workflow.limits as JsonObject) };
@@ -12,6 +12,14 @@
12
12
  "model": { "$ref": "common.schema.json#/$defs/modelSelector" },
13
13
  "executor": { "$ref": "common.schema.json#/$defs/identifier" },
14
14
  "harness": { "$ref": "common.schema.json#/$defs/identifier" },
15
+ "role": { "$ref": "common.schema.json#/$defs/identifier" },
16
+ "effort": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max"] },
17
+ "skills": {
18
+ "type": "array",
19
+ "items": { "$ref": "common.schema.json#/$defs/identifier" },
20
+ "maxItems": 64,
21
+ "uniqueItems": true
22
+ },
15
23
  "tools": { "$ref": "common.schema.json#/$defs/toolPolicy" },
16
24
  "defaultRepositoryAccess": { "$ref": "common.schema.json#/$defs/repositoryAccess" },
17
25
  "repositories": {
@@ -7,7 +7,6 @@ export interface RosterRoute {
7
7
  readonly status: 'admitted' | 'retired';
8
8
  }
9
9
  export interface RosterPolicy {
10
- readonly schema: 'kxm.developer-roster.v1';
11
10
  readonly routes: Readonly<Record<string, RosterRoute>>;
12
11
  readonly lineup: Readonly<Record<string, readonly string[]>>;
13
12
  readonly required_critics: Readonly<Record<'review-arch' | 'review-cli', string>>;
@@ -15,9 +14,11 @@ export interface RosterPolicy {
15
14
  }
16
15
  export interface PolicyIdentity { readonly commit: string; readonly blob: string; readonly sha256: string }
17
16
  export interface LoadedPolicy { readonly identity: PolicyIdentity; readonly policy: RosterPolicy }
17
+ export function refuseRetiredRosterFile(root: string): void;
18
18
  export function loadTrustedRosterPolicy(): LoadedPolicy;
19
19
  export function resolveBoundPolicy(identity: PolicyIdentity): LoadedPolicy;
20
20
  export function canonicalVendor(value: string): string;
21
+ export function assembleRosterPolicy(models: readonly Record<string, unknown>[], roles: readonly Record<string, unknown>[]): RosterPolicy;
21
22
  export function validateRosterDocument(
22
23
  policy: RosterPolicy,
23
24
  readBlob: (source: string) => string | Uint8Array | null | undefined,