@kontextmind/kxm 0.7.126 → 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 (55) 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/architecture/access.md +2 -4
  8. package/docs/contracts/validation.md +1 -1
  9. package/docs/contributing/harness-routing-internals.md +1 -1
  10. package/docs/contributing/test-matrix.md +1 -1
  11. package/docs/glossary.md +1 -1
  12. package/docs/guides/agent-skills.md +1 -1
  13. package/docs/operations/backup-and-restore.md +2 -2
  14. package/docs/reference/cli-reference.md +31 -74
  15. package/docs/reference/config-reference.md +83 -118
  16. package/docs/reference/harness-routing.md +15 -8
  17. package/examples/project/.kxm/models/critic-claude.yaml +6 -2
  18. package/examples/project/.kxm/models/critic-gemini.yaml +6 -2
  19. package/examples/project/.kxm/models/critic-grok.yaml +6 -2
  20. package/examples/project/.kxm/models/implementation.yaml +6 -2
  21. package/examples/project/.kxm/models/primary.yaml +6 -2
  22. package/examples/project/.kxm/roles/planner.yaml +8 -0
  23. package/examples/project/.kxm/roles/reviewer-arch.yaml +8 -0
  24. package/examples/project/.kxm/roles/reviewer-cli.yaml +8 -0
  25. package/examples/project/.kxm/roles/writer.yaml +8 -0
  26. package/package.json +1 -1
  27. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  28. package/plugins/kxm/dist/cli.js +1759 -1306
  29. package/plugins/kxm/dist/mcp-server.js +1 -1
  30. package/plugins/kxm/dist/runtime-supervisor.js +994 -245
  31. package/plugins/kxm/dist/runtime.js +1018 -269
  32. package/plugins/kxm/dist/server.js +55 -1
  33. package/plugins/kxm/package.json +1 -1
  34. package/plugins/kxm/skills/kxm/SKILL.md +1 -1
  35. package/plugins/kxm/skills/kxm-definitions/SKILL.md +3 -7
  36. package/plugins/kxm/src/autocomplete.ts +1 -1
  37. package/plugins/kxm/src/cli/project.ts +7 -1
  38. package/plugins/kxm/src/cli/roles.ts +46 -150
  39. package/plugins/kxm/src/cli.ts +8 -22
  40. package/plugins/kxm/src/engine.ts +24 -1
  41. package/plugins/kxm/src/init-guide-setup.ts +38 -4
  42. package/plugins/kxm/src/mcp-server.ts +1 -1
  43. package/plugins/kxm/src/permission.ts +11 -0
  44. package/plugins/kxm/src/policy-draft.mjs +56 -16
  45. package/plugins/kxm/src/project-config.ts +121 -10
  46. package/plugins/kxm/src/repair.ts +1 -0
  47. package/plugins/kxm/src/role.ts +40 -440
  48. package/plugins/kxm/src/routes.ts +28 -5
  49. package/plugins/kxm/src/template.ts +34 -1
  50. package/schemas/README.md +2 -1
  51. package/schemas/model.schema.json +111 -13
  52. package/schemas/role.schema.json +61 -31
  53. package/schemas/policy-draft/README.md +0 -17
  54. package/schemas/policy-draft/model.v2.schema.json +0 -140
  55. package/schemas/policy-draft/role.v2.schema.json +0 -91
@@ -1,21 +1,22 @@
1
1
  /**
2
- * KXM Role Management Subsystem (kxm.role.v1).
3
- * Supports modular per-role YAML files with descriptions, skills, tool permissions, and model rosters.
4
- * Implements global (~/.config/kxm/roles/) and local (.kxm/roles/) inheritance.
2
+ * KXM role files (kxm.role.v2).
3
+ * Global (~/.config/kxm/roles/) and local (.kxm/roles/) files. A local id overrides a global one.
5
4
  */
6
5
 
7
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
- import { dirname, join, resolve } from "node:path";
7
+ import { join } from "node:path";
9
8
  import { parse, stringify } from "yaml";
10
9
  import { repoConfigDirectory, userConfigDirectory } from "./config.ts";
11
10
 
12
- export const KXM_ROLE_SCHEMA = "kxm.role.v1" as const;
11
+ export const KXM_ROLE_SCHEMA = "kxm.role.v2" as const;
12
+
13
+ export type KxmRolePurpose = "writer" | "planner" | "reviewer-arch" | "reviewer-cli" | "experiment";
14
+ export type KxmRolePermission = "edit" | "read-only";
15
+ export type KxmRoleEffort = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
13
16
 
14
17
  export interface KxmRosterEntry {
15
- harness: string;
16
- model: string;
17
- provider?: string | undefined;
18
- effort?: "low" | "medium" | "high" | "xhigh" | undefined;
18
+ route: string;
19
+ effort?: KxmRoleEffort | undefined;
19
20
  mode?: "headless" | "interactive" | "either" | undefined;
20
21
  }
21
22
 
@@ -30,16 +31,26 @@ export interface KxmTemplateContract {
30
31
  schema?: string | undefined;
31
32
  }
32
33
 
34
+ export interface KxmRoleFallback {
35
+ onError?: Array<"rate_limit" | "transport" | "provider_unavailable"> | undefined;
36
+ maxSwitches?: number | undefined;
37
+ revert?: "next_run" | "never" | undefined;
38
+ }
39
+
33
40
  export interface KxmRolePolicy {
34
41
  vendorIndependenceRequired?: boolean | undefined;
35
42
  maxTransitions?: number | undefined;
36
43
  requiresGateVerification?: boolean | undefined;
44
+ fallback?: KxmRoleFallback | undefined;
37
45
  }
38
46
 
39
47
  export interface KxmRoleDefinition {
40
48
  schema: typeof KXM_ROLE_SCHEMA;
41
49
  id: string;
50
+ purpose: KxmRolePurpose;
51
+ permission: KxmRolePermission;
42
52
  description: string;
53
+ extends?: string | undefined;
43
54
  skills?: string[] | undefined;
44
55
  tools?: KxmRoleToolPolicy | undefined;
45
56
  produces?: KxmTemplateContract[] | undefined;
@@ -50,91 +61,22 @@ export interface KxmRoleDefinition {
50
61
 
51
62
  export interface KxmRoleSummary {
52
63
  id: string;
64
+ purpose?: KxmRolePurpose | undefined;
65
+ permission?: KxmRolePermission | undefined;
53
66
  description: string;
54
67
  scope: "global" | "local" | "overridden";
55
68
  filePath: string;
56
69
  skills: string[];
57
70
  toolsCount: number;
58
71
  roster: KxmRosterEntry[];
59
- primaryModel?: string | undefined;
60
- primaryHarness?: string | undefined;
72
+ primaryRoute?: string | undefined;
61
73
  }
62
74
 
63
- export const DEFAULT_ROLES: Record<string, KxmRoleDefinition> = {
64
- writer: {
65
- schema: KXM_ROLE_SCHEMA,
66
- id: "writer",
67
- description: "Primary implementation agent. Writes code, refactors components, and authors unit tests.",
68
- skills: ["kxm", "unit-testing"],
69
- tools: {
70
- preset: "author",
71
- allow: ["view_file", "replace_file_content", "write_to_file", "run_command"],
72
- deny: ["git push"],
73
- },
74
- produces: [{ template: "code-patch" }],
75
- roster: [
76
- { harness: "grok", model: "grok-4.6", effort: "low", mode: "headless" },
77
- { harness: "pi", provider: "openrouter", model: "openrouter/qwen/qwen3-coder-plus", effort: "medium" },
78
- { harness: "agy", model: "gemini-2.5-pro", effort: "low" },
79
- ],
80
- },
81
- planner: {
82
- schema: KXM_ROLE_SCHEMA,
83
- id: "planner",
84
- description: "Architecture breakdown, requirement decomposition, and safety boundary definition.",
85
- skills: ["kxm-session", "architecture-planning"],
86
- tools: {
87
- preset: "read_only",
88
- allow: ["view_file", "grep_search", "find_by_name", "list_dir"],
89
- deny: ["write_to_file", "replace_file_content", "run_command"],
90
- },
91
- produces: [{ template: "implementation-plan" }],
92
- roster: [
93
- { harness: "claude", model: "fable", effort: "medium" },
94
- ],
95
- },
96
- "critic-arch": {
97
- schema: KXM_ROLE_SCHEMA,
98
- id: "critic-arch",
99
- description: "Independent critic reviewing implementation diffs for architectural integrity and permission boundaries.",
100
- policy: { vendorIndependenceRequired: true },
101
- tools: {
102
- preset: "critic",
103
- allow: ["view_file", "grep_search"],
104
- },
105
- produces: [{ template: "critic-signoff" }],
106
- roster: [
107
- { harness: "claude", model: "fable", effort: "medium" },
108
- ],
109
- },
110
- "critic-cli": {
111
- schema: KXM_ROLE_SCHEMA,
112
- id: "critic-cli",
113
- description: "CLI, documentation, and developer ergonomics critic.",
114
- policy: { vendorIndependenceRequired: true },
115
- tools: {
116
- preset: "critic",
117
- allow: ["view_file", "grep_search"],
118
- },
119
- produces: [{ template: "critic-signoff" }],
120
- roster: [
121
- { harness: "codex", model: "gpt-5.6-sol", effort: "low" },
122
- ],
123
- },
124
- verifier: {
125
- schema: KXM_ROLE_SCHEMA,
126
- id: "verifier",
127
- description: "Deterministic gate evaluation runner.",
128
- tools: {
129
- preset: "verifier",
130
- allow: ["run_command"],
131
- },
132
- produces: [{ template: "verification-receipt" }],
133
- roster: [
134
- { harness: "pi", model: "evaluator", effort: "low" },
135
- ],
136
- },
137
- };
75
+ const ROLE_PURPOSES: readonly KxmRolePurpose[] = ["writer", "planner", "reviewer-arch", "reviewer-cli", "experiment"];
76
+
77
+ export function rolePurposeForId(id: string): KxmRolePurpose {
78
+ return (ROLE_PURPOSES as readonly string[]).includes(id) ? id as KxmRolePurpose : "experiment";
79
+ }
138
80
 
139
81
  export function rolesDirectory(
140
82
  scope: "global" | "local",
@@ -159,16 +101,6 @@ export function ensureRolesDirectory(
159
101
  return dir;
160
102
  }
161
103
 
162
- export function ensureDefaultRoles(userConfigDir?: string): void {
163
- const dir = ensureRolesDirectory("global", undefined, userConfigDir);
164
- for (const [id, def] of Object.entries(DEFAULT_ROLES)) {
165
- const filePath = join(dir, `${id}.yaml`);
166
- if (!existsSync(filePath)) {
167
- writeFileSync(filePath, stringify(def), "utf8");
168
- }
169
- }
170
- }
171
-
172
104
  export function parseRoleFile(filePath: string): KxmRoleDefinition | undefined {
173
105
  if (!existsSync(filePath)) return undefined;
174
106
  try {
@@ -177,11 +109,16 @@ export function parseRoleFile(filePath: string): KxmRoleDefinition | undefined {
177
109
  if (!parsed || typeof parsed !== "object" || parsed.schema !== KXM_ROLE_SCHEMA) {
178
110
  return undefined;
179
111
  }
180
- const id = parsed.id || filePath.replace(/^.*[\\/]/, "").replace(/\.yaml$/i, "");
112
+ const id = parsed.id || filePath.replace(/^.*[\\/]/, "").replace(/\.ya?ml$/i, "");
113
+ const purpose = parsed.purpose ?? rolePurposeForId(id);
114
+ const permission = parsed.permission ?? (purpose === "writer" ? "edit" : "read-only");
181
115
  return {
182
116
  schema: KXM_ROLE_SCHEMA,
183
117
  id,
118
+ purpose,
119
+ permission,
184
120
  description: parsed.description || "",
121
+ ...(parsed.extends ? { extends: parsed.extends } : {}),
185
122
  skills: Array.isArray(parsed.skills) ? parsed.skills : [],
186
123
  tools: parsed.tools,
187
124
  produces: parsed.produces,
@@ -238,14 +175,15 @@ export function listRoles(options: {
238
175
  const primary = role.roster[0];
239
176
  result.push({
240
177
  id,
178
+ purpose: role.purpose,
179
+ permission: role.permission,
241
180
  description: role.description,
242
181
  scope: isOverridden ? "overridden" : "local",
243
182
  filePath,
244
183
  skills: role.skills ?? [],
245
184
  toolsCount: (role.tools?.allow?.length ?? 0),
246
185
  roster: role.roster,
247
- primaryModel: primary?.model,
248
- primaryHarness: primary?.harness,
186
+ primaryRoute: primary?.route,
249
187
  });
250
188
  }
251
189
 
@@ -255,14 +193,15 @@ export function listRoles(options: {
255
193
  const primary = role.roster[0];
256
194
  result.push({
257
195
  id,
196
+ purpose: role.purpose,
197
+ permission: role.permission,
258
198
  description: role.description,
259
199
  scope: "global",
260
200
  filePath,
261
201
  skills: role.skills ?? [],
262
202
  toolsCount: (role.tools?.allow?.length ?? 0),
263
203
  roster: role.roster,
264
- primaryModel: primary?.model,
265
- primaryHarness: primary?.harness,
204
+ primaryRoute: primary?.route,
266
205
  });
267
206
  }
268
207
  }
@@ -381,342 +320,3 @@ export function modifyRole(
381
320
  if (!options.dryRun) writeFileSync(filePath, stringify(updated), "utf8");
382
321
  return { id: roleId, role: updated, filePath, scope };
383
322
  }
384
-
385
- // ---------------------------------------------------------------------------
386
- // Role Seat & Host Mapping Governance (kxm.role-hosts.v1)
387
- // ---------------------------------------------------------------------------
388
-
389
- export const KXM_ROLE_HOSTS_SCHEMA = "kxm.role-hosts.v1" as const;
390
-
391
- export interface RoleSeatDefinition {
392
- seatId: string;
393
- description?: string | undefined;
394
- defaultModel?: string | undefined;
395
- defaultHost?: string | undefined;
396
- allowedTools?: readonly string[] | undefined;
397
- requiredEvidenceKind?: string | undefined;
398
- }
399
-
400
- export interface RoleSeatBinding {
401
- model?: string | undefined;
402
- host?: string | undefined;
403
- effort?: "low" | "medium" | "high" | "xhigh" | undefined;
404
- }
405
-
406
- export interface RoleHostsConfig {
407
- schema: typeof KXM_ROLE_HOSTS_SCHEMA;
408
- seats?: Record<string, RoleSeatBinding> | undefined;
409
- hostProviders?: Record<string, string> | undefined;
410
- }
411
-
412
- export const DEFAULT_ROLE_SEATS: Record<string, RoleSeatDefinition> = {
413
- planner: {
414
- seatId: "planner",
415
- description: "Architecture breakdown, requirement decomposition, and safety boundary definition.",
416
- defaultModel: "anthropic/claude-fable-5.1",
417
- defaultHost: "pi",
418
- allowedTools: ["view_file", "grep_search", "find_by_name", "list_dir"],
419
- requiredEvidenceKind: "architecture_review",
420
- },
421
- writer: {
422
- seatId: "writer",
423
- description: "Primary implementation agent. Writes code, refactors components, and authors unit tests.",
424
- defaultModel: "x-ai/grok-4.6",
425
- defaultHost: "grok",
426
- allowedTools: ["view_file", "replace_file_content", "write_to_file", "run_command"],
427
- requiredEvidenceKind: "git_diff",
428
- },
429
- "critic-arch": {
430
- seatId: "critic-arch",
431
- description: "Independent critic reviewing implementation diffs for architectural integrity.",
432
- defaultModel: "anthropic/claude-fable-5.1",
433
- defaultHost: "pi",
434
- allowedTools: ["view_file", "grep_search"],
435
- requiredEvidenceKind: "architecture_review",
436
- },
437
- "critic-cli": {
438
- seatId: "critic-cli",
439
- description: "CLI, documentation, and developer ergonomics critic.",
440
- defaultModel: "openai/gpt-5.6-sol",
441
- defaultHost: "pi",
442
- allowedTools: ["view_file", "grep_search"],
443
- requiredEvidenceKind: "cli_review",
444
- },
445
- verifier: {
446
- seatId: "verifier",
447
- description: "Deterministic gate evaluation runner.",
448
- defaultModel: "evaluator",
449
- defaultHost: "pi",
450
- allowedTools: ["run_command"],
451
- requiredEvidenceKind: "test_run",
452
- },
453
- };
454
-
455
- export function roleHostsFilePath(
456
- scope: "global" | "local",
457
- repoRoot = process.cwd(),
458
- userConfigDir?: string,
459
- preferJson = false,
460
- ): string {
461
- const dir = scope === "global" ? userConfigDirectory(userConfigDir) : repoConfigDirectory(repoRoot);
462
- return join(dir, preferJson ? "role-hosts.json" : "role-hosts.yaml");
463
- }
464
-
465
- export function findExistingRoleHostsFile(
466
- scope: "global" | "local",
467
- repoRoot = process.cwd(),
468
- userConfigDir?: string,
469
- ): { filePath: string; exists: boolean; format: "yaml" | "json" } {
470
- const dir = scope === "global" ? userConfigDirectory(userConfigDir) : repoConfigDirectory(repoRoot);
471
- const yamlPath = join(dir, "role-hosts.yaml");
472
- const ymlPath = join(dir, "role-hosts.yml");
473
- const jsonPath = join(dir, "role-hosts.json");
474
-
475
- if (existsSync(yamlPath)) return { filePath: yamlPath, exists: true, format: "yaml" };
476
- if (existsSync(ymlPath)) return { filePath: ymlPath, exists: true, format: "yaml" };
477
- if (existsSync(jsonPath)) return { filePath: jsonPath, exists: true, format: "json" };
478
- return { filePath: yamlPath, exists: false, format: "yaml" };
479
- }
480
-
481
- export function parseRoleHostsFile(filePath: string): RoleHostsConfig | undefined {
482
- if (!existsSync(filePath)) return undefined;
483
- try {
484
- const raw = readFileSync(filePath, "utf8");
485
- const parsed = parse(raw) as Partial<RoleHostsConfig>;
486
- if (!parsed || typeof parsed !== "object") return undefined;
487
- if (parsed.schema && parsed.schema !== KXM_ROLE_HOSTS_SCHEMA) return undefined;
488
- return {
489
- schema: KXM_ROLE_HOSTS_SCHEMA,
490
- seats: parsed.seats && typeof parsed.seats === "object" ? parsed.seats : {},
491
- hostProviders: parsed.hostProviders && typeof parsed.hostProviders === "object" ? parsed.hostProviders : {},
492
- };
493
- } catch {
494
- return undefined;
495
- }
496
- }
497
-
498
- export function loadRoleHostsConfig(options: {
499
- scope?: "all" | "global" | "local" | undefined;
500
- repoRoot?: string | undefined;
501
- userConfigDir?: string | undefined;
502
- } = {}): { config: RoleHostsConfig; filePath?: string | undefined; scope: "local" | "global" | "default" } {
503
- const scopeFilter = options.scope ?? "all";
504
- const repoRoot = options.repoRoot ?? process.cwd();
505
-
506
- let localConfig: RoleHostsConfig | undefined;
507
- let localPath: string | undefined;
508
- if (scopeFilter !== "global") {
509
- const localFound = findExistingRoleHostsFile("local", repoRoot, options.userConfigDir);
510
- if (localFound.exists) {
511
- localConfig = parseRoleHostsFile(localFound.filePath);
512
- localPath = localFound.filePath;
513
- }
514
- }
515
-
516
- let globalConfig: RoleHostsConfig | undefined;
517
- let globalPath: string | undefined;
518
- if (scopeFilter !== "local") {
519
- const globalFound = findExistingRoleHostsFile("global", repoRoot, options.userConfigDir);
520
- if (globalFound.exists) {
521
- globalConfig = parseRoleHostsFile(globalFound.filePath);
522
- globalPath = globalFound.filePath;
523
- }
524
- }
525
-
526
- if (scopeFilter === "local") {
527
- return {
528
- config: localConfig ?? { schema: KXM_ROLE_HOSTS_SCHEMA, seats: {}, hostProviders: {} },
529
- filePath: localPath,
530
- scope: localPath ? "local" : "default",
531
- };
532
- }
533
-
534
- if (scopeFilter === "global") {
535
- return {
536
- config: globalConfig ?? { schema: KXM_ROLE_HOSTS_SCHEMA, seats: {}, hostProviders: {} },
537
- filePath: globalPath,
538
- scope: globalPath ? "global" : "default",
539
- };
540
- }
541
-
542
- // Merge: local seats override global seats; local hostProviders merge over global hostProviders
543
- const mergedSeats: Record<string, RoleSeatBinding> = {
544
- ...(globalConfig?.seats ?? {}),
545
- ...(localConfig?.seats ?? {}),
546
- };
547
- const mergedHostProviders: Record<string, string> = {
548
- ...(globalConfig?.hostProviders ?? {}),
549
- ...(localConfig?.hostProviders ?? {}),
550
- };
551
-
552
- const primaryPath = localPath ?? globalPath;
553
- const primaryScope = localPath ? "local" : (globalPath ? "global" : "default");
554
-
555
- return {
556
- config: {
557
- schema: KXM_ROLE_HOSTS_SCHEMA,
558
- seats: mergedSeats,
559
- hostProviders: mergedHostProviders,
560
- },
561
- filePath: primaryPath,
562
- scope: primaryScope,
563
- };
564
- }
565
-
566
- export function saveRoleHostsConfig(
567
- config: RoleHostsConfig,
568
- options: {
569
- scope?: "global" | "local" | undefined;
570
- repoRoot?: string | undefined;
571
- userConfigDir?: string | undefined;
572
- format?: "yaml" | "json" | undefined;
573
- dryRun?: boolean | undefined;
574
- } = {},
575
- ): { filePath: string; scope: "global" | "local" } {
576
- const scope = options.scope ?? "local";
577
- const repoRoot = options.repoRoot ?? process.cwd();
578
- const dir = scope === "global" ? userConfigDirectory(options.userConfigDir) : repoConfigDirectory(repoRoot);
579
- const format = options.format ?? "yaml";
580
- const filePath = join(dir, format === "json" ? "role-hosts.json" : "role-hosts.yaml");
581
- if (options.dryRun) return { filePath, scope };
582
- if (!existsSync(dir)) {
583
- mkdirSync(dir, { recursive: true });
584
- }
585
-
586
- const payload: RoleHostsConfig = {
587
- schema: KXM_ROLE_HOSTS_SCHEMA,
588
- seats: config.seats ?? {},
589
- ...(config.hostProviders && Object.keys(config.hostProviders).length > 0 ? { hostProviders: config.hostProviders } : {}),
590
- };
591
-
592
- const content = format === "json" ? JSON.stringify(payload, null, 2) + "\n" : stringify(payload);
593
- writeFileSync(filePath, content, "utf8");
594
- return { filePath, scope };
595
- }
596
-
597
- export function setRoleSeatHost(
598
- seatId: string,
599
- host: string,
600
- options: {
601
- model?: string | undefined;
602
- effort?: "low" | "medium" | "high" | "xhigh" | undefined;
603
- scope?: "global" | "local" | undefined;
604
- repoRoot?: string | undefined;
605
- userConfigDir?: string | undefined;
606
- format?: "yaml" | "json" | undefined;
607
- dryRun?: boolean | undefined;
608
- } = {},
609
- ): { filePath: string; seatId: string; binding: RoleSeatBinding; scope: "global" | "local" } {
610
- const scope = options.scope ?? "local";
611
- const current = loadRoleHostsConfig({
612
- scope,
613
- repoRoot: options.repoRoot,
614
- userConfigDir: options.userConfigDir,
615
- });
616
-
617
- const seats = { ...(current.config.seats ?? {}) };
618
- const existing = seats[seatId] ?? {};
619
- const binding: RoleSeatBinding = {
620
- ...existing,
621
- host,
622
- ...(options.model !== undefined ? { model: options.model } : {}),
623
- ...(options.effort !== undefined ? { effort: options.effort } : {}),
624
- };
625
- seats[seatId] = binding;
626
-
627
- const updatedConfig: RoleHostsConfig = {
628
- ...current.config,
629
- schema: KXM_ROLE_HOSTS_SCHEMA,
630
- seats,
631
- };
632
-
633
- const saved = saveRoleHostsConfig(updatedConfig, {
634
- scope,
635
- repoRoot: options.repoRoot,
636
- userConfigDir: options.userConfigDir,
637
- format: options.format,
638
- dryRun: options.dryRun,
639
- });
640
-
641
- return { filePath: saved.filePath, seatId, binding, scope };
642
- }
643
-
644
- export function resolveRoleSeat(
645
- seatId: string,
646
- options: {
647
- hostOverride?: string | undefined;
648
- modelOverride?: string | undefined;
649
- repoRoot?: string | undefined;
650
- userConfigDir?: string | undefined;
651
- } = {},
652
- ): {
653
- seatId: string;
654
- host: string;
655
- model?: string | undefined;
656
- provider?: string | undefined;
657
- effort?: "low" | "medium" | "high" | "xhigh" | undefined;
658
- source: "override" | "role-hosts" | "seat-default" | "role-roster" | "fallback";
659
- } {
660
- const hostsConfig = loadRoleHostsConfig({
661
- scope: "all",
662
- repoRoot: options.repoRoot,
663
- userConfigDir: options.userConfigDir,
664
- }).config;
665
-
666
- const configuredSeat = hostsConfig.seats?.[seatId];
667
- const defaultSeat = DEFAULT_ROLE_SEATS[seatId];
668
- const roleDef = getRole(seatId, {
669
- scope: "all",
670
- repoRoot: options.repoRoot,
671
- userConfigDir: options.userConfigDir,
672
- })?.role ?? DEFAULT_ROLES[seatId];
673
- const primaryRoster = roleDef?.roster?.[0];
674
-
675
- // Resolve host
676
- let host = "pi";
677
- let source: "override" | "role-hosts" | "seat-default" | "role-roster" | "fallback" = "fallback";
678
-
679
- if (options.hostOverride) {
680
- host = options.hostOverride;
681
- source = "override";
682
- } else if (configuredSeat?.host) {
683
- host = configuredSeat.host;
684
- source = "role-hosts";
685
- } else if (defaultSeat?.defaultHost) {
686
- host = defaultSeat.defaultHost;
687
- source = "seat-default";
688
- } else if (primaryRoster?.harness) {
689
- host = primaryRoster.harness;
690
- source = "role-roster";
691
- }
692
-
693
- // Resolve model
694
- let model: string | undefined;
695
- if (options.modelOverride) {
696
- model = options.modelOverride;
697
- } else if (configuredSeat?.model) {
698
- model = configuredSeat.model;
699
- } else if (defaultSeat?.defaultModel) {
700
- model = defaultSeat.defaultModel;
701
- } else if (primaryRoster?.model) {
702
- model = primaryRoster.model;
703
- }
704
-
705
- // Resolve provider
706
- const provider = hostsConfig.hostProviders?.[host]
707
- ?? roleDef?.roster?.find((r) => r.harness === host)?.provider;
708
-
709
- // Resolve effort
710
- const effort = configuredSeat?.effort
711
- ?? roleDef?.roster?.find((r) => r.harness === host)?.effort;
712
-
713
- return {
714
- seatId,
715
- host,
716
- model,
717
- provider,
718
- effort,
719
- source,
720
- };
721
- }
722
-
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parse, stringify } from "yaml";
4
+ import { rolePurposeForId } from "./role.ts";
4
5
 
5
6
  const RETIRED_POLICY = ".kxm/producers.yaml";
6
7
 
@@ -32,10 +33,32 @@ export function setRouteState(root: string, model: string, state: "admitted" | "
32
33
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) roleFile = parsed as Record<string, unknown>;
33
34
  }
34
35
  const roster = Array.isArray(roleFile.roster) ? roleFile.roster.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
35
- const existing = roster.findIndex((entry) => entry.model === model);
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
+ }
51
+ if (!routeId) throw new Error(`unknown route: no v2 model file matches '${model}'`);
52
+ const existing = roster.findIndex((entry) => entry.route === routeId || entry.model === model);
36
53
  if (removeRole) { if (existing >= 0) roster.splice(existing, 1); }
37
- else if (existing < 0) roster.push({ model, enabled: true });
38
- roleFile.schema ??= "kxm.role.v1"; roleFile.id ??= role; roleFile.roster = roster;
54
+ else if (existing < 0) roster.push({ route: routeId });
55
+ const purpose = typeof roleFile.purpose === "string" ? roleFile.purpose : rolePurposeForId(role);
56
+ roleFile.schema = "kxm.role.v2";
57
+ roleFile.id ??= role;
58
+ roleFile.purpose ??= purpose;
59
+ roleFile.permission ??= purpose === "writer" ? "edit" : "read-only";
60
+ roleFile.description ??= role;
61
+ roleFile.roster = roster;
39
62
  mkdirSync(join(root, ".kxm", "roles"), { recursive: true });
40
63
  writeFileSync(rolePath, stringify(roleFile), "utf8");
41
64
  }
@@ -49,8 +72,8 @@ export function listRoleBindings(root: string): Record<string, string[]> {
49
72
  const dir = join(root, ".kxm", "roles"); const result: Record<string, string[]> = {};
50
73
  if (!existsSync(dir)) return result;
51
74
  for (const file of readdirSync(dir).filter((name) => name.endsWith(".yaml"))) {
52
- const role = file.slice(0, -5); const value = parse(readFileSync(join(dir, file), "utf8")) as { roster?: Array<{ model?: unknown }> };
53
- result[role] = (value.roster ?? []).map((entry) => entry.model).filter((model): model is string => typeof model === "string");
75
+ const role = file.slice(0, -5); const value = parse(readFileSync(join(dir, file), "utf8")) as { roster?: Array<{ route?: unknown }> };
76
+ result[role] = (value.roster ?? []).map((entry) => entry.route).filter((route): route is string => typeof route === "string");
54
77
  }
55
78
  return result;
56
79
  }
@@ -203,7 +203,40 @@ function coreTemplate(projectId: string, projectName: string, variant: KxmTempla
203
203
  updatedAt: "2026-09-23T00:00:00.000Z",
204
204
  admitted: ["anthropic/fable", "xai/grok-4.6"],
205
205
  disabled: [],
206
- roles: { implementer: ["xai/grok-4.6"] },
206
+ });
207
+ files.set(".kxm/models/grok-default.yaml", {
208
+ schema: "kxm.model.v2",
209
+ id: "grok-default",
210
+ harness: "grok",
211
+ model: "grok-4.6",
212
+ vendor: "xai",
213
+ status: "admitted",
214
+ permissions: ["edit"],
215
+ });
216
+ files.set(".kxm/models/fable-default.yaml", {
217
+ schema: "kxm.model.v2",
218
+ id: "fable-default",
219
+ harness: "claude",
220
+ model: "fable",
221
+ vendor: "anthropic",
222
+ status: "admitted",
223
+ permissions: ["read-only"],
224
+ });
225
+ files.set(".kxm/roles/writer.yaml", {
226
+ schema: "kxm.role.v2",
227
+ id: "writer",
228
+ purpose: "writer",
229
+ permission: "edit",
230
+ description: "Primary implementation agent.",
231
+ roster: [{ route: "grok-default" }],
232
+ });
233
+ files.set(".kxm/roles/planner.yaml", {
234
+ schema: "kxm.role.v2",
235
+ id: "planner",
236
+ purpose: "planner",
237
+ permission: "read-only",
238
+ description: "Plans the change before implementation.",
239
+ roster: [{ route: "fable-default" }],
207
240
  });
208
241
  files.set(".kxm/gates.yaml", {
209
242
  schema: "kxm.gate-registry.v1",
package/schemas/README.md CHANGED
@@ -8,7 +8,8 @@ Runtime-local records, and JSON events/results for the planned KXM contract.
8
8
  | `project.schema.json` | `kxm.project.v1` |
9
9
  | `repository.schema.json` | `kxm.repository.v1` |
10
10
  | `agent.schema.json` | `kxm.agent.v1` |
11
- | `model.schema.json` | `kxm.model.v1` |
11
+ | `model.schema.json` | `kxm.model.v2` |
12
+ | `role.schema.json` | `kxm.role.v2` |
12
13
  | `environment.schema.json` | `kxm.environment.v1` |
13
14
  | `workflow.schema.json` | `kxm.workflow.v1` |
14
15
  | `template-provenance.schema.json` | `kxm.template-provenance.v1` |