@kontextmind/kxm 0.6.0 → 0.7.4

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 (136) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/agents/coordinator.yaml +9 -0
  3. package/.kxm/agents/critic-arch.yaml +13 -0
  4. package/.kxm/agents/critic-cli.yaml +13 -0
  5. package/.kxm/agents/implementer.yaml +13 -0
  6. package/.kxm/gates.yaml +8 -0
  7. package/.kxm/producers.yaml +22 -0
  8. package/.kxm/project.yaml +15 -0
  9. package/.kxm/roles/writer.yaml +7 -0
  10. package/.kxm/workflows/default.yaml +47 -0
  11. package/CHANGELOG.md +39 -7
  12. package/README.md +1 -0
  13. package/docs/README.md +4 -0
  14. package/docs/agent-skills.md +121 -0
  15. package/docs/architecture.md +1 -1
  16. package/docs/assignment-runner.md +21 -8
  17. package/docs/configuration.md +11 -2
  18. package/docs/getting-started.md +21 -0
  19. package/docs/kxm-handbook.md +3 -3
  20. package/docs/operations.md +24 -0
  21. package/docs/operator-pi-packages.md +63 -0
  22. package/docs/skills/repo-work-delivery.md +107 -0
  23. package/docs/skills.md +2 -0
  24. package/docs/test-matrix.md +4 -3
  25. package/docs/troubleshooting.md +41 -1
  26. package/docs/vnext/validation.md +9 -0
  27. package/docs/webhook-workflows.md +2 -2
  28. package/examples/README.md +1 -1
  29. package/package.json +16 -17
  30. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  31. package/plugins/kxm/README.md +1 -1
  32. package/plugins/kxm/dist/cli.js +8955 -3934
  33. package/plugins/kxm/dist/core.js +271 -34
  34. package/plugins/kxm/dist/extension.js +7759 -86
  35. package/plugins/kxm/dist/mcp-server.js +75 -21
  36. package/plugins/kxm/dist/runtime.js +5637 -1125
  37. package/plugins/kxm/dist/server.js +3125 -2260
  38. package/plugins/kxm/dist/vnext-runtime-supervisor.js +5808 -565
  39. package/plugins/kxm/package.json +1 -1
  40. package/plugins/kxm/skills/SUITE.md +5 -0
  41. package/plugins/kxm/skills/hints.json +73 -0
  42. package/plugins/kxm/skills/kxm/SKILL.md +30 -83
  43. package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
  44. package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
  45. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
  46. package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
  47. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +43 -0
  48. package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
  49. package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
  50. package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
  51. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +42 -0
  52. package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
  53. package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
  54. package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
  55. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
  56. package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
  57. package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
  58. package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
  59. package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
  60. package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
  61. package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
  62. package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
  63. package/plugins/kxm/src/autocomplete.ts +9 -3
  64. package/plugins/kxm/src/cli.ts +1637 -73
  65. package/plugins/kxm/src/commands.ts +150 -8
  66. package/plugins/kxm/src/completion-install.ts +223 -0
  67. package/plugins/kxm/src/config.ts +7 -4
  68. package/plugins/kxm/src/context-packet.ts +172 -0
  69. package/plugins/kxm/src/database.ts +1 -1
  70. package/plugins/kxm/src/extension.ts +36 -1
  71. package/plugins/kxm/src/external-effects.ts +357 -8
  72. package/plugins/kxm/src/hub-env.ts +193 -0
  73. package/plugins/kxm/src/hub.ts +2 -4
  74. package/plugins/kxm/src/improve.ts +72 -0
  75. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  76. package/plugins/kxm/src/local-snapshot.ts +1 -1
  77. package/plugins/kxm/src/mcp-server.ts +1 -1
  78. package/plugins/kxm/src/model-inventory.ts +127 -0
  79. package/plugins/kxm/src/policy-draft.d.mts +55 -0
  80. package/plugins/kxm/src/policy-draft.mjs +565 -0
  81. package/plugins/kxm/src/price-calc.ts +17 -18
  82. package/plugins/kxm/src/prices.ts +32 -16
  83. package/plugins/kxm/src/producers.ts +71 -0
  84. package/plugins/kxm/src/protocol.ts +111 -0
  85. package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
  86. package/plugins/kxm/src/restricted-yaml.mjs +145 -0
  87. package/plugins/kxm/src/role.ts +710 -0
  88. package/plugins/kxm/src/routing.ts +99 -1
  89. package/plugins/kxm/src/safety-integrity.ts +76 -0
  90. package/plugins/kxm/src/session-work.ts +9 -2
  91. package/plugins/kxm/src/sqlite.ts +76 -0
  92. package/plugins/kxm/src/store.ts +1 -1
  93. package/plugins/kxm/src/studio-layout.ts +660 -17
  94. package/plugins/kxm/src/suggest.ts +7 -13
  95. package/plugins/kxm/src/telemetry.ts +82 -0
  96. package/plugins/kxm/src/tui.ts +140 -0
  97. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  98. package/plugins/kxm/src/vnext-config.ts +53 -111
  99. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  100. package/plugins/kxm/src/vnext-engine.ts +214 -62
  101. package/plugins/kxm/src/vnext-harness.ts +267 -83
  102. package/plugins/kxm/src/vnext-oneshot-evidence.ts +85 -0
  103. package/plugins/kxm/src/vnext-oneshot-process.ts +166 -0
  104. package/plugins/kxm/src/vnext-oneshot-producer.ts +179 -224
  105. package/plugins/kxm/src/vnext-runtime-store.ts +36 -2
  106. package/plugins/kxm/src/vnext-runtime-supervisor.ts +120 -5
  107. package/plugins/kxm/src/vnext-runtime.ts +14 -0
  108. package/plugins/kxm/src/workflow-manager.ts +392 -0
  109. package/plugins/kxm/src/workflow-tui.ts +255 -0
  110. package/plugins/kxm/src/workflow.ts +144 -0
  111. package/schemas/policy-draft/README.md +17 -0
  112. package/schemas/policy-draft/model.v2.schema.json +140 -0
  113. package/schemas/policy-draft/role.v2.schema.json +91 -0
  114. package/schemas/vnext/role.schema.json +76 -0
  115. package/schemas/vnext/run-event.schema.json +1 -0
  116. package/scripts/assignment-run.d.mts +1 -1
  117. package/scripts/assignment-run.mjs +44 -35
  118. package/scripts/check-generated.mjs +33 -9
  119. package/scripts/emit-codex-artifacts.mjs +255 -11
  120. package/scripts/harness-run.d.mts +12 -4
  121. package/scripts/harness-run.mjs +65 -17
  122. package/scripts/kxm-bump-version.mjs +146 -0
  123. package/scripts/kxm-hub.mjs +150 -2
  124. package/scripts/kxm-publish-npm.mjs +3 -1
  125. package/scripts/kxm-release-github.mjs +3 -1
  126. package/scripts/kxm.mjs +0 -0
  127. package/scripts/native-critic.d.mts +5 -0
  128. package/scripts/native-critic.mjs +60 -0
  129. package/.kxm/config/README.md +0 -5
  130. package/.kxm/config/agents.json +0 -43
  131. package/.kxm/config/env.example +0 -56
  132. package/.kxm/config/update.example.yaml +0 -9
  133. package/.kxm/config/workflows/fix.json +0 -160
  134. package/.kxm/config/workflows/jira-development.json +0 -116
  135. package/.kxm/config/workflows/provenance-quorum.json +0 -150
  136. package/.kxm/config/workflows/v04-dogfood.json +0 -72
@@ -0,0 +1,710 @@
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.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { parse, stringify } from "yaml";
10
+ import { repoConfigDirectory, userConfigDirectory } from "./config.ts";
11
+
12
+ export const KXM_ROLE_SCHEMA = "kxm.role.v1" as const;
13
+
14
+ export interface KxmRosterEntry {
15
+ harness: string;
16
+ model: string;
17
+ provider?: string | undefined;
18
+ effort?: "low" | "medium" | "high" | "xhigh" | undefined;
19
+ mode?: "headless" | "interactive" | "either" | undefined;
20
+ }
21
+
22
+ export interface KxmRoleToolPolicy {
23
+ preset?: string | undefined;
24
+ allow?: string[] | undefined;
25
+ deny?: string[] | undefined;
26
+ }
27
+
28
+ export interface KxmTemplateContract {
29
+ template: string;
30
+ schema?: string | undefined;
31
+ }
32
+
33
+ export interface KxmRolePolicy {
34
+ vendorIndependenceRequired?: boolean | undefined;
35
+ maxTransitions?: number | undefined;
36
+ requiresGateVerification?: boolean | undefined;
37
+ }
38
+
39
+ export interface KxmRoleDefinition {
40
+ schema: typeof KXM_ROLE_SCHEMA;
41
+ id: string;
42
+ description: string;
43
+ skills?: string[] | undefined;
44
+ tools?: KxmRoleToolPolicy | undefined;
45
+ produces?: KxmTemplateContract[] | undefined;
46
+ consumes?: KxmTemplateContract[] | undefined;
47
+ roster: KxmRosterEntry[];
48
+ policy?: KxmRolePolicy | undefined;
49
+ }
50
+
51
+ export interface KxmRoleSummary {
52
+ id: string;
53
+ description: string;
54
+ scope: "global" | "local" | "overridden";
55
+ filePath: string;
56
+ skills: string[];
57
+ toolsCount: number;
58
+ roster: KxmRosterEntry[];
59
+ primaryModel?: string | undefined;
60
+ primaryHarness?: string | undefined;
61
+ }
62
+
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
+ };
138
+
139
+ export function rolesDirectory(
140
+ scope: "global" | "local",
141
+ repoRoot = process.cwd(),
142
+ userConfigDir?: string,
143
+ ): string {
144
+ if (scope === "global") {
145
+ return join(userConfigDirectory(userConfigDir), "roles");
146
+ }
147
+ return join(repoConfigDirectory(repoRoot), "roles");
148
+ }
149
+
150
+ export function ensureRolesDirectory(
151
+ scope: "global" | "local",
152
+ repoRoot = process.cwd(),
153
+ userConfigDir?: string,
154
+ ): string {
155
+ const dir = rolesDirectory(scope, repoRoot, userConfigDir);
156
+ if (!existsSync(dir)) {
157
+ mkdirSync(dir, { recursive: true });
158
+ }
159
+ return dir;
160
+ }
161
+
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
+ export function parseRoleFile(filePath: string): KxmRoleDefinition | undefined {
173
+ if (!existsSync(filePath)) return undefined;
174
+ try {
175
+ const raw = readFileSync(filePath, "utf8");
176
+ const parsed = parse(raw) as Partial<KxmRoleDefinition>;
177
+ if (!parsed || typeof parsed !== "object" || parsed.schema !== KXM_ROLE_SCHEMA) {
178
+ return undefined;
179
+ }
180
+ const id = parsed.id || filePath.replace(/^.*[\\/]/, "").replace(/\.yaml$/i, "");
181
+ return {
182
+ schema: KXM_ROLE_SCHEMA,
183
+ id,
184
+ description: parsed.description || "",
185
+ skills: Array.isArray(parsed.skills) ? parsed.skills : [],
186
+ tools: parsed.tools,
187
+ produces: parsed.produces,
188
+ consumes: parsed.consumes,
189
+ roster: Array.isArray(parsed.roster) ? parsed.roster : [],
190
+ policy: parsed.policy,
191
+ };
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ }
196
+
197
+ export function listRoles(options: {
198
+ scope?: "all" | "global" | "local" | undefined;
199
+ repoRoot?: string | undefined;
200
+ userConfigDir?: string | undefined;
201
+ } = {}): KxmRoleSummary[] {
202
+ const scopeFilter = options.scope ?? "all";
203
+ const repoRoot = options.repoRoot ?? process.cwd();
204
+ const globalDir = rolesDirectory("global", repoRoot, options.userConfigDir);
205
+ const localDir = rolesDirectory("local", repoRoot, options.userConfigDir);
206
+
207
+ const localRoles = new Map<string, { role: KxmRoleDefinition; filePath: string }>();
208
+ if (scopeFilter !== "global" && existsSync(localDir)) {
209
+ for (const entry of readdirSync(localDir)) {
210
+ if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
211
+ const filePath = join(localDir, entry);
212
+ const parsed = parseRoleFile(filePath);
213
+ if (parsed) {
214
+ localRoles.set(parsed.id, { role: parsed, filePath });
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ const globalRoles = new Map<string, { role: KxmRoleDefinition; filePath: string }>();
221
+ if (scopeFilter !== "local" && existsSync(globalDir)) {
222
+ for (const entry of readdirSync(globalDir)) {
223
+ if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
224
+ const filePath = join(globalDir, entry);
225
+ const parsed = parseRoleFile(filePath);
226
+ if (parsed) {
227
+ globalRoles.set(parsed.id, { role: parsed, filePath });
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ const result: KxmRoleSummary[] = [];
234
+
235
+ // 1. Process local roles
236
+ for (const [id, { role, filePath }] of localRoles) {
237
+ const isOverridden = globalRoles.has(id);
238
+ const primary = role.roster[0];
239
+ result.push({
240
+ id,
241
+ description: role.description,
242
+ scope: isOverridden ? "overridden" : "local",
243
+ filePath,
244
+ skills: role.skills ?? [],
245
+ toolsCount: (role.tools?.allow?.length ?? 0),
246
+ roster: role.roster,
247
+ primaryModel: primary?.model,
248
+ primaryHarness: primary?.harness,
249
+ });
250
+ }
251
+
252
+ // 2. Process global roles not in local (or if scope is global only)
253
+ for (const [id, { role, filePath }] of globalRoles) {
254
+ if (scopeFilter === "global" || !localRoles.has(id)) {
255
+ const primary = role.roster[0];
256
+ result.push({
257
+ id,
258
+ description: role.description,
259
+ scope: "global",
260
+ filePath,
261
+ skills: role.skills ?? [],
262
+ toolsCount: (role.tools?.allow?.length ?? 0),
263
+ roster: role.roster,
264
+ primaryModel: primary?.model,
265
+ primaryHarness: primary?.harness,
266
+ });
267
+ }
268
+ }
269
+
270
+ result.sort((a, b) => a.id.localeCompare(b.id));
271
+ return result;
272
+ }
273
+
274
+ export function getRole(
275
+ roleId: string,
276
+ options: {
277
+ scope?: "all" | "global" | "local" | undefined;
278
+ repoRoot?: string | undefined;
279
+ userConfigDir?: string | undefined;
280
+ } = {},
281
+ ): { role: KxmRoleDefinition; scope: "global" | "local"; filePath: string } | undefined {
282
+ const scope = options.scope ?? "all";
283
+ const repoRoot = options.repoRoot ?? process.cwd();
284
+
285
+ // Check local first if allowed
286
+ if (scope !== "global") {
287
+ const localDir = rolesDirectory("local", repoRoot, options.userConfigDir);
288
+ const localFile = join(localDir, `${roleId}.yaml`);
289
+ const parsed = parseRoleFile(localFile);
290
+ if (parsed) return { role: parsed, scope: "local", filePath: localFile };
291
+ }
292
+
293
+ // Check global
294
+ if (scope !== "local") {
295
+ const globalDir = rolesDirectory("global", repoRoot, options.userConfigDir);
296
+ const globalFile = join(globalDir, `${roleId}.yaml`);
297
+ const parsed = parseRoleFile(globalFile);
298
+ if (parsed) return { role: parsed, scope: "global", filePath: globalFile };
299
+ }
300
+
301
+ return undefined;
302
+ }
303
+
304
+ export function addRole(
305
+ role: KxmRoleDefinition,
306
+ options: {
307
+ scope?: "global" | "local" | undefined;
308
+ repoRoot?: string | undefined;
309
+ userConfigDir?: string | undefined;
310
+ overwrite?: boolean | undefined;
311
+ } = {},
312
+ ): { id: string; filePath: string; scope: "global" | "local" } {
313
+ const scope = options.scope ?? "local";
314
+ const repoRoot = options.repoRoot ?? process.cwd();
315
+ const dir = ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
316
+ const filePath = join(dir, `${role.id}.yaml`);
317
+
318
+ if (existsSync(filePath) && !options.overwrite) {
319
+ throw new Error(`role_already_exists: role '${role.id}' already exists at ${filePath}`);
320
+ }
321
+
322
+ writeFileSync(filePath, stringify(role), "utf8");
323
+ return { id: role.id, filePath, scope };
324
+ }
325
+
326
+ export function removeRole(
327
+ roleId: string,
328
+ options: {
329
+ scope?: "global" | "local" | undefined;
330
+ repoRoot?: string | undefined;
331
+ userConfigDir?: string | undefined;
332
+ } = {},
333
+ ): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
334
+ const scope = options.scope ?? "local";
335
+ const repoRoot = options.repoRoot ?? process.cwd();
336
+ const dir = rolesDirectory(scope, repoRoot, options.userConfigDir);
337
+ const filePath = join(dir, `${roleId}.yaml`);
338
+
339
+ if (!existsSync(filePath)) {
340
+ throw new Error(`role_not_found: role '${roleId}' not found in ${scope} directory (${filePath})`);
341
+ }
342
+
343
+ rmSync(filePath);
344
+ return { id: roleId, removed: true, filePath, scope };
345
+ }
346
+
347
+ export function modifyRole(
348
+ roleId: string,
349
+ updates: Partial<KxmRoleDefinition>,
350
+ options: {
351
+ scope?: "global" | "local" | undefined;
352
+ repoRoot?: string | undefined;
353
+ userConfigDir?: string | undefined;
354
+ } = {},
355
+ ): { id: string; role: KxmRoleDefinition; filePath: string; scope: "global" | "local" } {
356
+ const target = getRole(roleId, { scope: options.scope, repoRoot: options.repoRoot, userConfigDir: options.userConfigDir });
357
+ if (!target) {
358
+ throw new Error(`role_not_found: role '${roleId}' does not exist`);
359
+ }
360
+
361
+ const updated: KxmRoleDefinition = {
362
+ ...target.role,
363
+ ...updates,
364
+ schema: KXM_ROLE_SCHEMA,
365
+ id: roleId, // preserve identity
366
+ };
367
+
368
+ const scope = options.scope ?? target.scope;
369
+ const repoRoot = options.repoRoot ?? process.cwd();
370
+ const dir = ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
371
+ const filePath = join(dir, `${roleId}.yaml`);
372
+
373
+ writeFileSync(filePath, stringify(updated), "utf8");
374
+ return { id: roleId, role: updated, filePath, scope };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Role Seat & Host Mapping Governance (kxm.role-hosts.v1)
379
+ // ---------------------------------------------------------------------------
380
+
381
+ export const KXM_ROLE_HOSTS_SCHEMA = "kxm.role-hosts.v1" as const;
382
+
383
+ export interface RoleSeatDefinition {
384
+ seatId: string;
385
+ description?: string | undefined;
386
+ defaultModel?: string | undefined;
387
+ defaultHost?: string | undefined;
388
+ allowedTools?: readonly string[] | undefined;
389
+ requiredEvidenceKind?: string | undefined;
390
+ }
391
+
392
+ export interface RoleSeatBinding {
393
+ model?: string | undefined;
394
+ host?: string | undefined;
395
+ effort?: "low" | "medium" | "high" | "xhigh" | undefined;
396
+ }
397
+
398
+ export interface RoleHostsConfig {
399
+ schema: typeof KXM_ROLE_HOSTS_SCHEMA;
400
+ seats?: Record<string, RoleSeatBinding> | undefined;
401
+ hostProviders?: Record<string, string> | undefined;
402
+ }
403
+
404
+ export const DEFAULT_ROLE_SEATS: Record<string, RoleSeatDefinition> = {
405
+ planner: {
406
+ seatId: "planner",
407
+ description: "Architecture breakdown, requirement decomposition, and safety boundary definition.",
408
+ defaultModel: "anthropic/claude-fable-5.1",
409
+ defaultHost: "pi",
410
+ allowedTools: ["view_file", "grep_search", "find_by_name", "list_dir"],
411
+ requiredEvidenceKind: "architecture_review",
412
+ },
413
+ writer: {
414
+ seatId: "writer",
415
+ description: "Primary implementation agent. Writes code, refactors components, and authors unit tests.",
416
+ defaultModel: "x-ai/grok-4.6",
417
+ defaultHost: "grok",
418
+ allowedTools: ["view_file", "replace_file_content", "write_to_file", "run_command"],
419
+ requiredEvidenceKind: "git_diff",
420
+ },
421
+ "critic-arch": {
422
+ seatId: "critic-arch",
423
+ description: "Independent critic reviewing implementation diffs for architectural integrity.",
424
+ defaultModel: "anthropic/claude-fable-5.1",
425
+ defaultHost: "pi",
426
+ allowedTools: ["view_file", "grep_search"],
427
+ requiredEvidenceKind: "architecture_review",
428
+ },
429
+ "critic-cli": {
430
+ seatId: "critic-cli",
431
+ description: "CLI, documentation, and developer ergonomics critic.",
432
+ defaultModel: "openai/gpt-5.6-sol",
433
+ defaultHost: "pi",
434
+ allowedTools: ["view_file", "grep_search"],
435
+ requiredEvidenceKind: "cli_review",
436
+ },
437
+ verifier: {
438
+ seatId: "verifier",
439
+ description: "Deterministic gate evaluation runner.",
440
+ defaultModel: "evaluator",
441
+ defaultHost: "pi",
442
+ allowedTools: ["run_command"],
443
+ requiredEvidenceKind: "test_run",
444
+ },
445
+ };
446
+
447
+ export function roleHostsFilePath(
448
+ scope: "global" | "local",
449
+ repoRoot = process.cwd(),
450
+ userConfigDir?: string,
451
+ preferJson = false,
452
+ ): string {
453
+ const dir = scope === "global" ? userConfigDirectory(userConfigDir) : repoConfigDirectory(repoRoot);
454
+ return join(dir, preferJson ? "role-hosts.json" : "role-hosts.yaml");
455
+ }
456
+
457
+ export function findExistingRoleHostsFile(
458
+ scope: "global" | "local",
459
+ repoRoot = process.cwd(),
460
+ userConfigDir?: string,
461
+ ): { filePath: string; exists: boolean; format: "yaml" | "json" } {
462
+ const dir = scope === "global" ? userConfigDirectory(userConfigDir) : repoConfigDirectory(repoRoot);
463
+ const yamlPath = join(dir, "role-hosts.yaml");
464
+ const ymlPath = join(dir, "role-hosts.yml");
465
+ const jsonPath = join(dir, "role-hosts.json");
466
+
467
+ if (existsSync(yamlPath)) return { filePath: yamlPath, exists: true, format: "yaml" };
468
+ if (existsSync(ymlPath)) return { filePath: ymlPath, exists: true, format: "yaml" };
469
+ if (existsSync(jsonPath)) return { filePath: jsonPath, exists: true, format: "json" };
470
+ return { filePath: yamlPath, exists: false, format: "yaml" };
471
+ }
472
+
473
+ export function parseRoleHostsFile(filePath: string): RoleHostsConfig | undefined {
474
+ if (!existsSync(filePath)) return undefined;
475
+ try {
476
+ const raw = readFileSync(filePath, "utf8");
477
+ const parsed = parse(raw) as Partial<RoleHostsConfig>;
478
+ if (!parsed || typeof parsed !== "object") return undefined;
479
+ if (parsed.schema && parsed.schema !== KXM_ROLE_HOSTS_SCHEMA) return undefined;
480
+ return {
481
+ schema: KXM_ROLE_HOSTS_SCHEMA,
482
+ seats: parsed.seats && typeof parsed.seats === "object" ? parsed.seats : {},
483
+ hostProviders: parsed.hostProviders && typeof parsed.hostProviders === "object" ? parsed.hostProviders : {},
484
+ };
485
+ } catch {
486
+ return undefined;
487
+ }
488
+ }
489
+
490
+ export function loadRoleHostsConfig(options: {
491
+ scope?: "all" | "global" | "local" | undefined;
492
+ repoRoot?: string | undefined;
493
+ userConfigDir?: string | undefined;
494
+ } = {}): { config: RoleHostsConfig; filePath?: string | undefined; scope: "local" | "global" | "default" } {
495
+ const scopeFilter = options.scope ?? "all";
496
+ const repoRoot = options.repoRoot ?? process.cwd();
497
+
498
+ let localConfig: RoleHostsConfig | undefined;
499
+ let localPath: string | undefined;
500
+ if (scopeFilter !== "global") {
501
+ const localFound = findExistingRoleHostsFile("local", repoRoot, options.userConfigDir);
502
+ if (localFound.exists) {
503
+ localConfig = parseRoleHostsFile(localFound.filePath);
504
+ localPath = localFound.filePath;
505
+ }
506
+ }
507
+
508
+ let globalConfig: RoleHostsConfig | undefined;
509
+ let globalPath: string | undefined;
510
+ if (scopeFilter !== "local") {
511
+ const globalFound = findExistingRoleHostsFile("global", repoRoot, options.userConfigDir);
512
+ if (globalFound.exists) {
513
+ globalConfig = parseRoleHostsFile(globalFound.filePath);
514
+ globalPath = globalFound.filePath;
515
+ }
516
+ }
517
+
518
+ if (scopeFilter === "local") {
519
+ return {
520
+ config: localConfig ?? { schema: KXM_ROLE_HOSTS_SCHEMA, seats: {}, hostProviders: {} },
521
+ filePath: localPath,
522
+ scope: localPath ? "local" : "default",
523
+ };
524
+ }
525
+
526
+ if (scopeFilter === "global") {
527
+ return {
528
+ config: globalConfig ?? { schema: KXM_ROLE_HOSTS_SCHEMA, seats: {}, hostProviders: {} },
529
+ filePath: globalPath,
530
+ scope: globalPath ? "global" : "default",
531
+ };
532
+ }
533
+
534
+ // Merge: local seats override global seats; local hostProviders merge over global hostProviders
535
+ const mergedSeats: Record<string, RoleSeatBinding> = {
536
+ ...(globalConfig?.seats ?? {}),
537
+ ...(localConfig?.seats ?? {}),
538
+ };
539
+ const mergedHostProviders: Record<string, string> = {
540
+ ...(globalConfig?.hostProviders ?? {}),
541
+ ...(localConfig?.hostProviders ?? {}),
542
+ };
543
+
544
+ const primaryPath = localPath ?? globalPath;
545
+ const primaryScope = localPath ? "local" : (globalPath ? "global" : "default");
546
+
547
+ return {
548
+ config: {
549
+ schema: KXM_ROLE_HOSTS_SCHEMA,
550
+ seats: mergedSeats,
551
+ hostProviders: mergedHostProviders,
552
+ },
553
+ filePath: primaryPath,
554
+ scope: primaryScope,
555
+ };
556
+ }
557
+
558
+ export function saveRoleHostsConfig(
559
+ config: RoleHostsConfig,
560
+ options: {
561
+ scope?: "global" | "local" | undefined;
562
+ repoRoot?: string | undefined;
563
+ userConfigDir?: string | undefined;
564
+ format?: "yaml" | "json" | undefined;
565
+ } = {},
566
+ ): { filePath: string; scope: "global" | "local" } {
567
+ const scope = options.scope ?? "local";
568
+ const repoRoot = options.repoRoot ?? process.cwd();
569
+ const dir = scope === "global" ? userConfigDirectory(options.userConfigDir) : repoConfigDirectory(repoRoot);
570
+ if (!existsSync(dir)) {
571
+ mkdirSync(dir, { recursive: true });
572
+ }
573
+
574
+ const format = options.format ?? "yaml";
575
+ const filePath = join(dir, format === "json" ? "role-hosts.json" : "role-hosts.yaml");
576
+ const payload: RoleHostsConfig = {
577
+ schema: KXM_ROLE_HOSTS_SCHEMA,
578
+ seats: config.seats ?? {},
579
+ ...(config.hostProviders && Object.keys(config.hostProviders).length > 0 ? { hostProviders: config.hostProviders } : {}),
580
+ };
581
+
582
+ const content = format === "json" ? JSON.stringify(payload, null, 2) + "\n" : stringify(payload);
583
+ writeFileSync(filePath, content, "utf8");
584
+ return { filePath, scope };
585
+ }
586
+
587
+ export function setRoleSeatHost(
588
+ seatId: string,
589
+ host: string,
590
+ options: {
591
+ model?: string | undefined;
592
+ effort?: "low" | "medium" | "high" | "xhigh" | undefined;
593
+ scope?: "global" | "local" | undefined;
594
+ repoRoot?: string | undefined;
595
+ userConfigDir?: string | undefined;
596
+ format?: "yaml" | "json" | undefined;
597
+ } = {},
598
+ ): { filePath: string; seatId: string; binding: RoleSeatBinding; scope: "global" | "local" } {
599
+ const scope = options.scope ?? "local";
600
+ const current = loadRoleHostsConfig({
601
+ scope,
602
+ repoRoot: options.repoRoot,
603
+ userConfigDir: options.userConfigDir,
604
+ });
605
+
606
+ const seats = { ...(current.config.seats ?? {}) };
607
+ const existing = seats[seatId] ?? {};
608
+ const binding: RoleSeatBinding = {
609
+ ...existing,
610
+ host,
611
+ ...(options.model !== undefined ? { model: options.model } : {}),
612
+ ...(options.effort !== undefined ? { effort: options.effort } : {}),
613
+ };
614
+ seats[seatId] = binding;
615
+
616
+ const updatedConfig: RoleHostsConfig = {
617
+ ...current.config,
618
+ schema: KXM_ROLE_HOSTS_SCHEMA,
619
+ seats,
620
+ };
621
+
622
+ const saved = saveRoleHostsConfig(updatedConfig, {
623
+ scope,
624
+ repoRoot: options.repoRoot,
625
+ userConfigDir: options.userConfigDir,
626
+ format: options.format,
627
+ });
628
+
629
+ return { filePath: saved.filePath, seatId, binding, scope };
630
+ }
631
+
632
+ export function resolveRoleSeat(
633
+ seatId: string,
634
+ options: {
635
+ hostOverride?: string | undefined;
636
+ modelOverride?: string | undefined;
637
+ repoRoot?: string | undefined;
638
+ userConfigDir?: string | undefined;
639
+ } = {},
640
+ ): {
641
+ seatId: string;
642
+ host: string;
643
+ model?: string | undefined;
644
+ provider?: string | undefined;
645
+ effort?: "low" | "medium" | "high" | "xhigh" | undefined;
646
+ source: "override" | "role-hosts" | "seat-default" | "role-roster" | "fallback";
647
+ } {
648
+ const hostsConfig = loadRoleHostsConfig({
649
+ scope: "all",
650
+ repoRoot: options.repoRoot,
651
+ userConfigDir: options.userConfigDir,
652
+ }).config;
653
+
654
+ const configuredSeat = hostsConfig.seats?.[seatId];
655
+ const defaultSeat = DEFAULT_ROLE_SEATS[seatId];
656
+ const roleDef = getRole(seatId, {
657
+ scope: "all",
658
+ repoRoot: options.repoRoot,
659
+ userConfigDir: options.userConfigDir,
660
+ })?.role ?? DEFAULT_ROLES[seatId];
661
+ const primaryRoster = roleDef?.roster?.[0];
662
+
663
+ // Resolve host
664
+ let host = "pi";
665
+ let source: "override" | "role-hosts" | "seat-default" | "role-roster" | "fallback" = "fallback";
666
+
667
+ if (options.hostOverride) {
668
+ host = options.hostOverride;
669
+ source = "override";
670
+ } else if (configuredSeat?.host) {
671
+ host = configuredSeat.host;
672
+ source = "role-hosts";
673
+ } else if (defaultSeat?.defaultHost) {
674
+ host = defaultSeat.defaultHost;
675
+ source = "seat-default";
676
+ } else if (primaryRoster?.harness) {
677
+ host = primaryRoster.harness;
678
+ source = "role-roster";
679
+ }
680
+
681
+ // Resolve model
682
+ let model: string | undefined;
683
+ if (options.modelOverride) {
684
+ model = options.modelOverride;
685
+ } else if (configuredSeat?.model) {
686
+ model = configuredSeat.model;
687
+ } else if (defaultSeat?.defaultModel) {
688
+ model = defaultSeat.defaultModel;
689
+ } else if (primaryRoster?.model) {
690
+ model = primaryRoster.model;
691
+ }
692
+
693
+ // Resolve provider
694
+ const provider = hostsConfig.hostProviders?.[host]
695
+ ?? roleDef?.roster?.find((r) => r.harness === host)?.provider;
696
+
697
+ // Resolve effort
698
+ const effort = configuredSeat?.effort
699
+ ?? roleDef?.roster?.find((r) => r.harness === host)?.effort;
700
+
701
+ return {
702
+ seatId,
703
+ host,
704
+ model,
705
+ provider,
706
+ effort,
707
+ source,
708
+ };
709
+ }
710
+