@kontextmind/kxm 0.7.125 → 0.7.127

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 (59) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/roles/planner.yaml +6 -7
  3. package/.kxm/roles/reviewer-arch.yaml +6 -7
  4. package/.kxm/roles/reviewer-cli.yaml +6 -7
  5. package/.kxm/roles/writer.yaml +8 -17
  6. package/CHANGELOG.md +17 -0
  7. package/docs/README.md +2 -0
  8. package/docs/architecture/access.md +2 -4
  9. package/docs/architecture/inventory.md +4 -0
  10. package/docs/contracts/validation.md +1 -1
  11. package/docs/contributing/harness-routing-internals.md +1 -1
  12. package/docs/contributing/learnings.md +105 -0
  13. package/docs/contributing/operating-rules.md +107 -0
  14. package/docs/contributing/test-matrix.md +1 -1
  15. package/docs/glossary.md +1 -1
  16. package/docs/guides/agent-skills.md +1 -1
  17. package/docs/operations/backup-and-restore.md +2 -2
  18. package/docs/reference/cli-reference.md +31 -74
  19. package/docs/reference/config-reference.md +83 -118
  20. package/docs/reference/harness-routing.md +15 -8
  21. package/examples/project/.kxm/models/critic-claude.yaml +6 -2
  22. package/examples/project/.kxm/models/critic-gemini.yaml +6 -2
  23. package/examples/project/.kxm/models/critic-grok.yaml +6 -2
  24. package/examples/project/.kxm/models/implementation.yaml +6 -2
  25. package/examples/project/.kxm/models/primary.yaml +6 -2
  26. package/examples/project/.kxm/roles/planner.yaml +8 -0
  27. package/examples/project/.kxm/roles/reviewer-arch.yaml +8 -0
  28. package/examples/project/.kxm/roles/reviewer-cli.yaml +8 -0
  29. package/examples/project/.kxm/roles/writer.yaml +8 -0
  30. package/package.json +1 -1
  31. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  32. package/plugins/kxm/dist/cli.js +1759 -1306
  33. package/plugins/kxm/dist/mcp-server.js +1 -1
  34. package/plugins/kxm/dist/runtime-supervisor.js +994 -245
  35. package/plugins/kxm/dist/runtime.js +1018 -269
  36. package/plugins/kxm/dist/server.js +55 -1
  37. package/plugins/kxm/package.json +1 -1
  38. package/plugins/kxm/skills/kxm/SKILL.md +1 -1
  39. package/plugins/kxm/skills/kxm-definitions/SKILL.md +3 -7
  40. package/plugins/kxm/src/autocomplete.ts +1 -1
  41. package/plugins/kxm/src/cli/project.ts +7 -1
  42. package/plugins/kxm/src/cli/roles.ts +46 -150
  43. package/plugins/kxm/src/cli.ts +8 -22
  44. package/plugins/kxm/src/engine.ts +24 -1
  45. package/plugins/kxm/src/init-guide-setup.ts +38 -4
  46. package/plugins/kxm/src/mcp-server.ts +1 -1
  47. package/plugins/kxm/src/permission.ts +11 -0
  48. package/plugins/kxm/src/policy-draft.mjs +56 -16
  49. package/plugins/kxm/src/project-config.ts +121 -10
  50. package/plugins/kxm/src/repair.ts +1 -0
  51. package/plugins/kxm/src/role.ts +40 -440
  52. package/plugins/kxm/src/routes.ts +28 -5
  53. package/plugins/kxm/src/template.ts +34 -1
  54. package/schemas/README.md +2 -1
  55. package/schemas/model.schema.json +111 -13
  56. package/schemas/role.schema.json +61 -31
  57. package/schemas/policy-draft/README.md +0 -17
  58. package/schemas/policy-draft/model.v2.schema.json +0 -140
  59. package/schemas/policy-draft/role.v2.schema.json +0 -91
@@ -14943,6 +14943,59 @@ var KXM_YAML_LIMITS = Object.freeze({
14943
14943
  maxKeys: 8192
14944
14944
  });
14945
14945
 
14946
+ // plugins/kxm/src/policy-draft.mjs
14947
+ var POLICY_DRAFT_PURPOSES = Object.freeze([
14948
+ "writer",
14949
+ "planner",
14950
+ "reviewer-arch",
14951
+ "reviewer-cli",
14952
+ "experiment"
14953
+ ]);
14954
+ var POLICY_DRAFT_PERMISSIONS = Object.freeze(["edit", "read-only"]);
14955
+ var POLICY_DRAFT_STATUSES = Object.freeze(["admitted", "candidate", "retired"]);
14956
+ var EFFORTS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
14957
+ var MODES = Object.freeze(["headless", "interactive", "either"]);
14958
+ var MODEL_KEYS = Object.freeze([
14959
+ "schema",
14960
+ "id",
14961
+ "harness",
14962
+ "model",
14963
+ "vendor",
14964
+ "status",
14965
+ "permissions",
14966
+ "origin",
14967
+ "thinking",
14968
+ "tags",
14969
+ "capabilities",
14970
+ "priority",
14971
+ "fallbacks",
14972
+ "limits"
14973
+ ]);
14974
+ var ROLE_KEYS = Object.freeze([
14975
+ "schema",
14976
+ "id",
14977
+ "purpose",
14978
+ "permission",
14979
+ "description",
14980
+ "extends",
14981
+ "roster",
14982
+ "skills",
14983
+ "tools",
14984
+ "produces",
14985
+ "consumes",
14986
+ "policy"
14987
+ ]);
14988
+ var ORIGIN_KEYS = Object.freeze(["source", "sha256"]);
14989
+ var LIMIT_KEYS = Object.freeze(["contextTokens", "outputTokens", "timeoutMs"]);
14990
+ var TOOL_KEYS = Object.freeze(["preset", "allow", "deny"]);
14991
+ var TEMPLATE_KEYS = Object.freeze(["template", "schema"]);
14992
+ var POLICY_KEYS = Object.freeze(["vendorIndependenceRequired", "maxTransitions", "requiresGateVerification", "fallback"]);
14993
+ var FALLBACK_KEYS = Object.freeze(["onError", "maxSwitches", "revert"]);
14994
+ var FALLBACK_ERRORS = Object.freeze(["rate_limit", "transport", "provider_unavailable"]);
14995
+ var FALLBACK_REVERT = Object.freeze(["next_run", "never"]);
14996
+ var ROSTER_ENTRY_KEYS = Object.freeze(["route", "effort", "mode"]);
14997
+ var CRITIC_PURPOSES = Object.freeze(["reviewer-arch", "reviewer-cli"]);
14998
+
14946
14999
  // plugins/kxm/src/template.ts
14947
15000
  var import_yaml2 = __toESM(require_dist(), 1);
14948
15001
 
@@ -15439,7 +15492,8 @@ var RESOURCE_SCHEMA = Object.freeze({
15439
15492
  project: { identity: "kxm.project.v1", file: "project.schema.json" },
15440
15493
  repository: { identity: "kxm.repository.v1", file: "repository.schema.json" },
15441
15494
  agent: { identity: "kxm.agent.v1", file: "agent.schema.json" },
15442
- model: { identity: "kxm.model.v1", file: "model.schema.json" },
15495
+ model: { identity: "kxm.model.v2", file: "model.schema.json" },
15496
+ role: { identity: "kxm.role.v2", file: "role.schema.json" },
15443
15497
  environment: { identity: "kxm.environment.v1", file: "environment.schema.json" },
15444
15498
  workflow: { identity: "kxm.workflow.v1", file: "workflow.schema.json" },
15445
15499
  "gate-registry": { identity: "kxm.gate-registry.v1", file: "gate-registry.schema.json" }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.125",
3
+ "version": "0.7.127",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -19,7 +19,7 @@ writers, or replace `.kxm/roster.yaml` trusted policy.
19
19
  | Session status, dashboard screens, studio layout | `kxm-session` | `session`, `dash`, `studio` |
20
20
  | Delegate to or answer another agent | `kxm-peer` | `peer` (`peer await` is capped at 60 seconds) |
21
21
  | Work inside a durable workflow run, gates, signed callbacks | `kxm-workflow` | `workflow`, `gate` |
22
- | Role definitions, role hosts, model rosters | `kxm-definitions` | `role` |
22
+ | Role definitions and model rosters | `kxm-definitions` | `role` |
23
23
  | Create, drive, inspect, or cancel a run, use a worktree lane, land a branch, run the developer assignment loop, or build and serve the docs site | `kxm-runs` | `run`, `runs`, `lane`, `land`, `assign`, `docs` |
24
24
  | What we know or decided, recall, context footprint, Git memory | `kxm-context-memory` | `context`, `memory`, `explain` |
25
25
  | Turn a repeated practice into a governed skill | `kxm-skill-lifecycle` | `skills` |
@@ -15,7 +15,6 @@ or permission to execute an assignment.
15
15
  ```bash
16
16
  kxm role list --scope all --json
17
17
  kxm role get writer --scope local --json
18
- kxm role hosts --json
19
18
  kxm role --help
20
19
  ```
21
20
 
@@ -29,19 +28,16 @@ changes every project or the trusted developer runner.
29
28
  |---|---|---|
30
29
  | `kxm role list` | List configured roles | `--scope all\|global\|local` |
31
30
  | `kxm role get <roleId>` | Read role YAML and details | `--scope all\|global\|local` |
32
- | `kxm role add [roleId]` | Add a YAML definition or construct a role | `--file`, `--description`, `--skills`, `--harness`, `--model`, `--scope`, `--overwrite`, `--pick` |
33
- | `kxm role modify [roleId]` | Change description, skill references, or model roster | `--description`, `--add-skill`, `--remove-skill`, `--add-model <harness:model>`, `--remove-model`, `--scope`, `--pick` |
31
+ | `kxm role add [roleId]` | Add a YAML definition or construct a role | `--file`, `--description`, `--skills`, `--route` (repeatable; the first id is primary and must name `.kxm/models/<route-id>.yaml`), `--scope`, `--overwrite`, `--pick` |
32
+ | `kxm role modify [roleId]` | Change description, skill references, or the route roster | `--description`, `--add-skill`, `--remove-skill`, `--add-route <route-id>`, `--remove-route <route-id>`, `--scope`, `--pick` |
34
33
  | `kxm role remove [roleId]` | Remove a definition | `--scope`, `--pick` |
35
- | `kxm role hosts` | List role seats and the host, model, and effort each resolves to, with the source of that decision | `--scope all\|global\|local` |
36
- | `kxm role set-host <seatId> <host>` | Bind a role seat to a host in `.kxm/role-hosts.yaml` | `--model`, `--effort low\|medium\|high\|xhigh`, `--scope global\|local` |
37
34
  | `kxm role resume <runId> [ruling]` | Resume an audit-escalated run with an operator directive | `[ruling]` free text |
38
35
 
39
36
  Use `--json` for structured output. Inspect command-specific `--help` before
40
37
  constructing a mutation; do not invent `create`, `update`, `delete`, `validate`,
41
38
  or `apply` subcommands under `kxm role`.
42
39
 
43
- `kxm role hosts` reads only. `kxm role set-host` writes `.kxm/role-hosts.yaml`
44
- (or the global file); show the user the resulting diff. `kxm role resume`
40
+ `kxm role resume`
45
41
  records the operator's ruling on an audit escalation: run it only with the
46
42
  ruling the user gave you, and preview a KXM run with `--dry-run` first.
47
43
 
@@ -57,7 +57,7 @@ const SUBCOMMANDS: Record<string, string[]> = {
57
57
  goal: ["create", "list", "get"],
58
58
  task: ["create", "list", "get", "run", "sync"],
59
59
  studio: ["layout", "serve"],
60
- role: ["list", "get", "add", "remove", "modify", "hosts", "set-host", "resume"],
60
+ role: ["list", "get", "add", "remove", "modify", "resume"],
61
61
  };
62
62
 
63
63
  export function generateShellCompletion(shell: SupportedShell): string {
@@ -897,7 +897,13 @@ export async function cmdModelsScreen(runtime: Runtime): Promise<number> {
897
897
  if (!Number.isInteger(index) || !models[index]) continue;
898
898
  const model = models[index];
899
899
  if (command === "a" || command === "d") setRouteState(runtime.dirs.workdir, model, command === "a" ? "admitted" : "disabled");
900
- else if (command === "r" || command === "x") setRouteState(runtime.dirs.workdir, model, "admitted", (await ask("role: ")).trim(), command === "x");
900
+ else if (command === "r" || command === "x") {
901
+ try {
902
+ setRouteState(runtime.dirs.workdir, model, "admitted", (await ask("role: ")).trim(), command === "x");
903
+ } catch (error) {
904
+ runtime.io.stderr(`${error instanceof Error ? error.message : "unknown route"}\n`);
905
+ }
906
+ }
901
907
  }
902
908
  } finally { rl.close(); }
903
909
  }
@@ -5,17 +5,13 @@ import { createInterface } from "node:readline";
5
5
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
6
6
  import { DatabaseSync, openReadOnlyDatabase } from "../sqlite.ts";
7
7
  import {
8
- DEFAULT_ROLES,
9
- DEFAULT_ROLE_SEATS,
10
8
  listRoles,
11
9
  getRole,
12
10
  addRole,
13
11
  removeRole,
14
12
  modifyRole,
15
- loadRoleHostsConfig,
16
- setRoleSeatHost,
17
- resolveRoleSeat,
18
13
  parseRoleFile,
14
+ rolePurposeForId,
19
15
  type KxmRoleDefinition,
20
16
  } from "../role.ts";
21
17
  import { discoverKxmProjectRoot, kxmRoleWriteIssues } from "../project-config.ts";
@@ -112,7 +108,7 @@ export async function cmdRoleList(
112
108
  const lines: string[] = ["ROLES:"];
113
109
  for (const r of roles) {
114
110
  const scopeTag = r.scope === "overridden" ? "[local override]" : `[${r.scope}]`;
115
- const modelTag = r.primaryModel ? `(${r.primaryHarness ?? "harness"}:${r.primaryModel})` : "";
111
+ const modelTag = r.primaryRoute ? `(${r.primaryRoute})` : "";
116
112
  lines.push(` ${r.id.padEnd(16)} ${scopeTag.padEnd(16)} ${modelTag.padEnd(28)} ${r.description}`);
117
113
  }
118
114
  print(runtime.io, false, {}, `${lines.join("\n")}\n`);
@@ -149,8 +145,7 @@ export async function cmdRoleAdd(
149
145
  file?: string | undefined;
150
146
  description?: string | undefined;
151
147
  skills?: string | undefined;
152
- harness?: string | undefined;
153
- model?: string | undefined;
148
+ route?: string[] | undefined;
154
149
  scope?: "global" | "local" | undefined;
155
150
  overwrite?: boolean | undefined;
156
151
  pick?: string | boolean | undefined;
@@ -159,12 +154,7 @@ export async function cmdRoleAdd(
159
154
  const scope = options.scope ?? "local";
160
155
  let base: KxmRoleDefinition | undefined;
161
156
  if (!roleId || options.pick) {
162
- const candidates: PickCandidate[] = Object.values(DEFAULT_ROLES).map((r) => ({
163
- id: r.id,
164
- description: r.description,
165
- label: "template",
166
- payload: r,
167
- }));
157
+ const candidates: PickCandidate[] = [];
168
158
  if (scope === "local") {
169
159
  const globalRoles = listRoles({ scope: "global", userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
170
160
  for (const gr of globalRoles) {
@@ -175,7 +165,7 @@ export async function cmdRoleAdd(
175
165
  }
176
166
  }
177
167
  }
178
- const picked = await resolvePickItem(runtime.io, `Select a role template to add (${scope})`, candidates, options.pick, runtime.env);
168
+ const picked = await resolvePickItem(runtime.io, `Select a global role to add (${scope})`, candidates, options.pick, runtime.env);
179
169
  if (!picked) {
180
170
  if (!roleId) {
181
171
  runtime.io.stderr("role add failed: missing roleId or pick selection\n");
@@ -188,24 +178,50 @@ export async function cmdRoleAdd(
188
178
  }
189
179
 
190
180
  const skills = options.skills ? options.skills.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
191
- const roster = options.model ? [{ harness: options.harness ?? "pi", model: options.model }] : undefined;
181
+ const routes = (options.route ?? []).filter((route) => route.length > 0);
182
+ const roster = routes.length > 0 ? routes.map((route) => ({ route })) : undefined;
183
+ // A constructed or copied role takes --route, and the first id is the primary.
184
+ // A missing model file is the same refusal as modify --add-route. --file is checked
185
+ // after its roster is parsed, for both local and global scope.
186
+ const projectRoot = discoverKxmProjectRoot(runtime.cwd) ?? runtime.cwd;
187
+ const refuseMissingRoute = (routeId: string): boolean => {
188
+ const modelFile = join(projectRoot, ".kxm", "models", `${routeId}.yaml`);
189
+ if (existsSync(modelFile)) return false;
190
+ runtime.io.stderr(`kxm: route '${routeId}' is not a file under .kxm/models/\n`);
191
+ return true;
192
+ };
193
+ if (roster && !options.file) {
194
+ for (const entry of roster) {
195
+ if (refuseMissingRoute(entry.route)) return 1;
196
+ }
197
+ }
198
+ const purpose = rolePurposeForId(roleId ?? "experiment");
192
199
  let roleDef: KxmRoleDefinition;
193
200
  if (options.file) {
194
201
  const filePath = resolve(runtime.cwd, options.file);
195
202
  const content = readFileSync(filePath, "utf8");
196
203
  roleDef = parseYaml(content) as KxmRoleDefinition;
197
204
  roleDef.id = roleId!;
205
+ roleDef.schema = "kxm.role.v2";
206
+ roleDef.purpose ??= rolePurposeForId(roleDef.id);
207
+ roleDef.permission ??= roleDef.purpose === "writer" ? "edit" : "read-only";
208
+ for (const entry of roleDef.roster ?? []) {
209
+ if (typeof entry?.route === "string" && entry.route.length > 0 && refuseMissingRoute(entry.route)) return 1;
210
+ }
198
211
  } else if (base) {
199
212
  roleDef = {
200
213
  ...base,
214
+ schema: "kxm.role.v2",
201
215
  description: options.description || base.description,
202
216
  skills: skills ?? base.skills,
203
217
  roster: roster ?? base.roster,
204
218
  };
205
219
  } else {
206
220
  roleDef = {
207
- schema: "kxm.role.v1",
221
+ schema: "kxm.role.v2",
208
222
  id: roleId!,
223
+ purpose,
224
+ permission: purpose === "writer" ? "edit" : "read-only",
209
225
  description: options.description || `Role ${roleId}`,
210
226
  skills: skills ?? [],
211
227
  roster: roster ?? [],
@@ -327,8 +343,8 @@ export async function cmdRoleModify(
327
343
  description?: string | undefined;
328
344
  addSkill?: string | undefined;
329
345
  removeSkill?: string | undefined;
330
- addModel?: string | undefined;
331
- removeModel?: string | undefined;
346
+ addRoute?: string | undefined;
347
+ removeRoute?: string | undefined;
332
348
  scope?: "global" | "local" | undefined;
333
349
  pick?: string | boolean | undefined;
334
350
  },
@@ -379,14 +395,18 @@ export async function cmdRoleModify(
379
395
  }
380
396
 
381
397
  let roster = [...(role.roster ?? [])];
382
- if (options.addModel) {
383
- const [harnessOrModel, maybeModel] = options.addModel.split(":");
384
- const harness = maybeModel ? harnessOrModel! : "pi";
385
- const model = maybeModel || harnessOrModel!;
386
- roster.push({ harness, model });
398
+ if (options.addRoute) {
399
+ const routeId = options.addRoute;
400
+ const projectRoot = discoverKxmProjectRoot(runtime.cwd) ?? runtime.cwd;
401
+ const modelFile = join(projectRoot, ".kxm", "models", `${routeId}.yaml`);
402
+ if (!existsSync(modelFile)) {
403
+ runtime.io.stderr(`kxm: route '${routeId}' is not a file under .kxm/models/\n`);
404
+ return 1;
405
+ }
406
+ if (!roster.some((entry) => entry.route === routeId)) roster.push({ route: routeId });
387
407
  }
388
- if (options.removeModel) {
389
- roster = roster.filter((entry) => entry.model !== options.removeModel);
408
+ if (options.removeRoute) {
409
+ roster = roster.filter((entry) => entry.route !== options.removeRoute);
390
410
  }
391
411
 
392
412
  const updates: Partial<KxmRoleDefinition> = {
@@ -419,130 +439,6 @@ export async function cmdRoleModify(
419
439
  }
420
440
  }
421
441
 
422
- export async function cmdRoleHosts(
423
- runtime: Runtime,
424
- options: { scope?: "all" | "global" | "local" | undefined } = {},
425
- ): Promise<number> {
426
- const hostsConfig = loadRoleHostsConfig({
427
- scope: options.scope,
428
- repoRoot: runtime.cwd,
429
- userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
430
- });
431
-
432
- const seatIds = Array.from(
433
- new Set([
434
- ...Object.keys(DEFAULT_ROLE_SEATS),
435
- ...Object.keys(hostsConfig.config.seats ?? {}),
436
- ]),
437
- ).sort();
438
-
439
- const seats = seatIds.map((seatId) => {
440
- const resolved = resolveRoleSeat(seatId, {
441
- repoRoot: runtime.cwd,
442
- userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
443
- });
444
- return {
445
- seatId,
446
- host: resolved.host,
447
- model: resolved.model,
448
- provider: resolved.provider,
449
- effort: resolved.effort,
450
- source: resolved.source,
451
- configuredHost: hostsConfig.config.seats?.[seatId]?.host,
452
- configuredModel: hostsConfig.config.seats?.[seatId]?.model,
453
- };
454
- });
455
-
456
- if (runtime.json) {
457
- print(
458
- runtime.io,
459
- true,
460
- {
461
- ok: true,
462
- command: "role hosts",
463
- scope: hostsConfig.scope,
464
- filePath: hostsConfig.filePath,
465
- seats,
466
- hostProviders: hostsConfig.config.hostProviders ?? {},
467
- },
468
- "",
469
- );
470
- return 0;
471
- }
472
-
473
- const lines: string[] = [
474
- `ROLE SEATS (${hostsConfig.scope}${hostsConfig.filePath ? ` at ${hostsConfig.filePath}` : ""}):`,
475
- ];
476
- for (const s of seats) {
477
- const modelStr = s.model ? ` [${s.model}]` : "";
478
- const sourceTag = `(via ${s.source})`;
479
- lines.push(` ${s.seatId.padEnd(16)} -> host: ${s.host.padEnd(12)} ${modelStr.padEnd(30)} ${sourceTag}`);
480
- }
481
- if (hostsConfig.config.hostProviders && Object.keys(hostsConfig.config.hostProviders).length > 0) {
482
- lines.push("\nHOST PROVIDERS:");
483
- for (const [h, p] of Object.entries(hostsConfig.config.hostProviders)) {
484
- lines.push(` ${h.padEnd(16)} -> provider: ${p}`);
485
- }
486
- }
487
- print(runtime.io, false, {}, `${lines.join("\n")}\n`);
488
- return 0;
489
- }
490
-
491
- export async function cmdRoleSetHost(
492
- runtime: Runtime,
493
- seatId: string,
494
- host: string,
495
- options: {
496
- model?: string | undefined;
497
- effort?: "low" | "medium" | "high" | "xhigh" | undefined;
498
- scope?: "global" | "local" | undefined;
499
- } = {},
500
- ): Promise<number> {
501
- if (!seatId || !host) {
502
- runtime.io.stderr("kxm role set-host requires <seatId> and <host>\n");
503
- return 1;
504
- }
505
-
506
- try {
507
- const result = setRoleSeatHost(seatId, host, {
508
- model: options.model,
509
- effort: options.effort,
510
- scope: options.scope ?? "local",
511
- repoRoot: runtime.cwd,
512
- userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
513
- dryRun: runtime.dryRun,
514
- });
515
- if (runtime.dryRun) {
516
- printPlan(
517
- runtime,
518
- { command: "role set-host", seatId, host, binding: result.binding, filePath: result.filePath, scope: result.scope },
519
- [{ action: "write", target: result.filePath }],
520
- `bind seat '${seatId}' to host '${host}'`,
521
- );
522
- return 0;
523
- }
524
-
525
- print(
526
- runtime.io,
527
- runtime.json,
528
- {
529
- ok: true,
530
- command: "role set-host",
531
- seatId,
532
- host,
533
- binding: result.binding,
534
- filePath: result.filePath,
535
- scope: result.scope,
536
- },
537
- `Bound seat '${seatId}' to host '${host}' in ${result.filePath}\n`,
538
- );
539
- return 0;
540
- } catch (err: unknown) {
541
- runtime.io.stderr(`role set-host failed: ${(err as Error).message}\n`);
542
- return 1;
543
- }
544
- }
545
-
546
442
  export async function cmdRoleResume(
547
443
  runtime: Runtime,
548
444
  runId: string,
@@ -48,8 +48,7 @@ import {
48
48
  cmdRoleAdd,
49
49
  cmdRoleRemove,
50
50
  cmdRoleModify,
51
- cmdRoleHosts,
52
- cmdRoleSetHost,
51
+
53
52
  cmdRoleResume,
54
53
  } from "./cli/roles.ts";
55
54
 
@@ -215,7 +214,7 @@ const DRY_RUN_COMMANDS: ReadonlySet<string> = new Set([
215
214
  "peer list", "peer send", "peer get", "peer await", "peer cancel", "peer fanout", "peer inbox", "peer reply",
216
215
  "workflow list", "workflow get", "workflow checkpoint", "workflow record", "workflow wait", "workflow signal",
217
216
  "workflow start", "workflow export", "workflow definitions", "workflow add", "workflow remove", "workflow modify",
218
- "role list", "role get", "role add", "role remove", "role modify", "role hosts", "role set-host", "role resume",
217
+ "role list", "role get", "role add", "role remove", "role modify", "role resume",
219
218
  "gate validate", "gate artifacts-exist", "gate degrade", "gate signal", "gate github watch",
220
219
  "improve report",
221
220
  "context get", "context recall", "context state", "context episode", "context promote", "context explain",
@@ -1026,12 +1025,11 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
1026
1025
  .option("--file <path>", "Path to YAML role definition file")
1027
1026
  .option("--description <text>", "Role description")
1028
1027
  .option("--skills <skills>", "Comma-separated skills list")
1029
- .option("--harness <harness>", "Primary harness name (e.g. grok, claude, agy, pi)")
1030
- .option("--model <model>", "Primary model identifier (e.g. grok-4.6, fable, gemini-3.8-flash-high)")
1028
+ .option("--route <route-id>", "Route id under .kxm/models/. Repeat to set the roster; the first id is primary", (value: string, previous: string[] | undefined) => (previous ?? []).concat(value))
1031
1029
  .option("--scope <scope>", "Configuration scope: global or local (default: local)", "local")
1032
1030
  .option("--overwrite", "Overwrite existing role definition if present")
1033
- .option("--pick [selection]", "Pick from available role templates (index or id)")
1034
- .action(async function roleAddAction(this: Command, roleId?: string, options?: { file?: string; description?: string; skills?: string; harness?: string; model?: string; scope?: "global" | "local"; overwrite?: boolean; pick?: string | boolean }) {
1031
+ .option("--pick [selection]", "Pick a global role to copy (index or id)")
1032
+ .action(async function roleAddAction(this: Command, roleId?: string, options?: { file?: string; description?: string; skills?: string; route?: string[]; scope?: "global" | "local"; overwrite?: boolean; pick?: string | boolean }) {
1035
1033
  result.code = await cmdRoleAdd(runtimeFrom(ctx, this), roleId, options ?? {});
1036
1034
  });
1037
1035
  addGlobalOptions(role.command("remove [roleId]").description("Remove a role definition"))
@@ -1044,25 +1042,13 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
1044
1042
  .option("--description <text>", "Updated description")
1045
1043
  .option("--add-skill <skill>", "Skill to add")
1046
1044
  .option("--remove-skill <skill>", "Skill to remove")
1047
- .option("--add-model <harness:model>", "Model to add to roster")
1048
- .option("--remove-model <model>", "Model to remove from roster")
1045
+ .option("--add-route <route-id>", "Route id to add to the roster. The id must name a file under .kxm/models/")
1046
+ .option("--remove-route <route-id>", "Route id to remove from the roster")
1049
1047
  .option("--scope <scope>", "Configuration scope: global or local")
1050
1048
  .option("--pick [selection]", "Pick a role to modify (index or id)")
1051
- .action(async function roleModifyAction(this: Command, roleId?: string, options?: { description?: string; addSkill?: string; removeSkill?: string; addModel?: string; removeModel?: string; scope?: "global" | "local"; pick?: string | boolean }) {
1049
+ .action(async function roleModifyAction(this: Command, roleId?: string, options?: { description?: string; addSkill?: string; removeSkill?: string; addRoute?: string; removeRoute?: string; scope?: "global" | "local"; pick?: string | boolean }) {
1052
1050
  result.code = await cmdRoleModify(runtimeFrom(ctx, this), roleId, options ?? {});
1053
1051
  });
1054
- addGlobalOptions(role.command("hosts").description("List role seats and resolved execution hosts from .kxm/role-hosts.yaml"))
1055
- .option("--scope <scope>", "Filter by scope: all, global, or local", "all")
1056
- .action(async function roleHostsAction(this: Command, options: { scope?: "all" | "global" | "local" }) {
1057
- result.code = await cmdRoleHosts(runtimeFrom(ctx, this), options);
1058
- });
1059
- addGlobalOptions(role.command("set-host <seatId> <host>").description("Bind a role seat to a host in .kxm/role-hosts.yaml"))
1060
- .option("--model <model>", "Model identifier for this seat")
1061
- .option("--effort <effort>", "Effort level: low, medium, high, xhigh")
1062
- .option("--scope <scope>", "Configuration scope: global or local (default: local)", "local")
1063
- .action(async function roleSetHostAction(this: Command, seatId: string, host: string, options: { model?: string; effort?: "low" | "medium" | "high" | "xhigh"; scope?: "global" | "local" }) {
1064
- result.code = await cmdRoleSetHost(runtimeFrom(ctx, this), seatId, host, options);
1065
- });
1066
1052
  addGlobalOptions(role.command("resume <runId> [ruling]").description("Resume an audit-escalated role run with an operator directive"))
1067
1053
  .action(async function roleResumeAction(this: Command, runId: string, ruling?: string) {
1068
1054
  result.code = await cmdRoleResume(runtimeFrom(ctx, this), runId, ruling);
@@ -1342,6 +1342,28 @@ export interface KxmPanelDispatchSeams {
1342
1342
 
1343
1343
  export const kxmPanelDispatchSeams: KxmPanelDispatchSeams = {};
1344
1344
 
1345
+ /** Selectors named by v2 route files: the model id, `vendor/model`, and `harness/model`. */
1346
+ function selectorsNamedByRoutes(projectRoot: string, routeIds: readonly string[]): string[] {
1347
+ const selectors = new Set<string>();
1348
+ for (const routeId of routeIds) {
1349
+ const file = join(projectRoot, ".kxm", "models", `${routeId}.yaml`);
1350
+ if (!existsSync(file)) continue;
1351
+ let parsed: { harness?: unknown; model?: unknown; vendor?: unknown };
1352
+ try {
1353
+ parsed = parse(readFileSync(file, "utf8")) as { harness?: unknown; model?: unknown; vendor?: unknown };
1354
+ } catch {
1355
+ continue;
1356
+ }
1357
+ const model = typeof parsed.model === "string" ? parsed.model : "";
1358
+ const vendor = typeof parsed.vendor === "string" ? parsed.vendor : "";
1359
+ const harness = typeof parsed.harness === "string" ? parsed.harness : "";
1360
+ if (model) selectors.add(model);
1361
+ if (vendor && model) selectors.add(`${vendor}/${model}`);
1362
+ if (harness && model) selectors.add(`${harness}/${model}`);
1363
+ }
1364
+ return [...selectors];
1365
+ }
1366
+
1345
1367
  function unreconciledPanelAttemptId(state: KxmRunState): string | undefined {
1346
1368
  const current = state.currentStep;
1347
1369
  if (!current) return undefined;
@@ -1438,7 +1460,8 @@ function resolveProducerRoute(
1438
1460
  const role = agentId === "implementer" ? "writer" : agentId;
1439
1461
  const roleBindings = listRoleBindings(projectRoot);
1440
1462
  const roster = roleBindings[role];
1441
- if (roster && !roster.includes(selector)) {
1463
+ const rosterSelectors = roster ? selectorsNamedByRoutes(projectRoot, roster) : undefined;
1464
+ if (rosterSelectors && !rosterSelectors.includes(selector)) {
1442
1465
  return {
1443
1466
  error: {
1444
1467
  reason: "step_unsupported",
@@ -10,10 +10,11 @@
10
10
  * This module writes only current KXM project resources:
11
11
  * - `.kxm/agents/<role-slug>.yaml` (kxm.agent.v1)
12
12
  * - `.kxm/workflows/<slug>.yaml` (kxm.workflow.v1)
13
+ * - `.kxm/roles/<role-slug>.yaml` (kxm.role.v2)
14
+ * - `.kxm/models/<route-id>.yaml` (kxm.model.v2)
13
15
  * - admitted selectors appended to `.kxm/routes.yaml`
14
16
  * It never writes retired legacy authority (`.kxm/config`, retired
15
- * `.kxm/roster.json`) or the trusted `.kxm/roster.yaml` policy
16
- * and does not use the kxm.role.v1 subsystem.
17
+ * `.kxm/roster.json`) or the trusted `.kxm/roster.yaml` policy.
17
18
  *
18
19
  * Guide research ids are not dispatch ids. Only the admitted map below is
19
20
  * written, and only when that harness is authenticated. Unmapped ids are skipped.
@@ -27,7 +28,8 @@
27
28
  * admission decision says so.
28
29
  */
29
30
 
30
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
31
+ import { createHash } from "node:crypto";
32
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
33
  import { dirname, join } from "node:path";
32
34
  import { stringify } from "yaml";
33
35
  import { type HarnessInventory } from "./harness.ts";
@@ -485,9 +487,14 @@ function workflowDocument(workflow: GuideWorkflow): Record<string, unknown> {
485
487
 
486
488
  /**
487
489
  * Render the planned KXM resource files (`.kxm/agents/*.yaml`,
488
- * `.kxm/workflows/*.yaml`). Pure: no disk access.
490
+ * `.kxm/workflows/*.yaml`). A Pi model origin hashes `.kxm/project.yaml`
491
+ * when that file exists, and is omitted when it does not.
489
492
  */
490
493
  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;
491
498
  const files: GuideSetupFile[] = [];
492
499
  const roleStages = new Map<string, GuideStage>();
493
500
  for (const workflow of plan.workflows) {
@@ -496,10 +503,37 @@ export function renderGuideSetupFiles(projectRoot: string, plan: GuideSetupPlan)
496
503
  for (const [role, binding] of [...plan.agents.entries()].sort(([a], [b]) => a.localeCompare(b))) {
497
504
  const stage = roleStages.get(role);
498
505
  if (!stage) continue;
506
+ const routeId = role.replaceAll("_", "-");
507
+ const writer = isWriterRole(role);
508
+ const modelDocument: Record<string, unknown> = {
509
+ schema: "kxm.model.v2",
510
+ id: routeId,
511
+ harness: binding.harness,
512
+ model: binding.model,
513
+ vendor: binding.provider,
514
+ status: "admitted",
515
+ permissions: [writer ? "edit" : "read-only"],
516
+ };
517
+ if (binding.harness === "pi" && projectOrigin) modelDocument.origin = projectOrigin;
499
518
  files.push({
500
519
  path: join(projectRoot, ".kxm", "agents", `${role}.yaml`),
501
520
  content: stringify(agentDocument(role, stage, binding)),
502
521
  });
522
+ files.push({
523
+ path: join(projectRoot, ".kxm", "models", `${routeId}.yaml`),
524
+ content: stringify(modelDocument),
525
+ });
526
+ files.push({
527
+ path: join(projectRoot, ".kxm", "roles", `${role}.yaml`),
528
+ content: stringify({
529
+ schema: "kxm.role.v2",
530
+ id: role,
531
+ purpose: writer ? "writer" : "experiment",
532
+ permission: writer ? "edit" : "read-only",
533
+ description: stage.domain.slice(0, 2000),
534
+ roster: [{ route: routeId }],
535
+ }),
536
+ });
503
537
  }
504
538
  for (const workflow of plan.workflows) {
505
539
  files.push({
@@ -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.125";
14
+ const VERSION = "0.7.127";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -56,6 +56,7 @@ const PROSE_FIELDS: Readonly<Record<KxmResourceKind, ReadonlySet<string>>> = {
56
56
  repository: new Set(["description"]),
57
57
  agent: new Set(["purpose", "instructions"]),
58
58
  model: new Set([]),
59
+ role: new Set(["description"]),
59
60
  environment: new Set([]),
60
61
  workflow: new Set(["description"]),
61
62
  "gate-registry": new Set([]),
@@ -193,6 +194,16 @@ export function kxmAuthorityEntries(resource: { kind: KxmResourceKind; id?: stri
193
194
  }
194
195
  break;
195
196
  }
197
+ case "role": {
198
+ push("/schema", "resource-shape", value.schema);
199
+ push("/purpose", "resource-shape", value.purpose);
200
+ push("/permission", "resource-shape", value.permission);
201
+ push("/extends", "resource-shape", value.extends);
202
+ push("/roster", "resource-shape", value.roster);
203
+ push("/policy", "resource-shape", value.policy);
204
+ push("/tools", "tools", value.tools);
205
+ break;
206
+ }
196
207
  case "gate-registry": {
197
208
  push("/schema", "resource-shape", value.schema);
198
209
  const gates = asObject(value.gates) ?? {};