@kontextmind/kxm 0.7.126 → 0.7.128
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.kxm/roles/planner.yaml +6 -7
- package/.kxm/roles/reviewer-arch.yaml +6 -7
- package/.kxm/roles/reviewer-cli.yaml +6 -7
- package/.kxm/roles/writer.yaml +8 -17
- package/CHANGELOG.md +17 -0
- package/docs/architecture/access.md +2 -4
- package/docs/contracts/validation.md +1 -1
- package/docs/contributing/harness-routing-internals.md +1 -1
- package/docs/contributing/learnings.md +21 -0
- package/docs/contributing/test-matrix.md +1 -1
- package/docs/glossary.md +1 -1
- package/docs/guides/agent-skills.md +1 -1
- package/docs/operations/backup-and-restore.md +2 -2
- package/docs/reference/cli-reference.md +31 -74
- package/docs/reference/config-reference.md +83 -118
- package/docs/reference/harness-routing.md +15 -8
- package/examples/project/.kxm/models/critic-claude.yaml +6 -2
- package/examples/project/.kxm/models/critic-gemini.yaml +6 -2
- package/examples/project/.kxm/models/critic-grok.yaml +6 -2
- package/examples/project/.kxm/models/implementation.yaml +6 -2
- package/examples/project/.kxm/models/primary.yaml +6 -2
- package/examples/project/.kxm/roles/planner.yaml +8 -0
- package/examples/project/.kxm/roles/reviewer-arch.yaml +8 -0
- package/examples/project/.kxm/roles/reviewer-cli.yaml +8 -0
- package/examples/project/.kxm/roles/writer.yaml +8 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +1759 -1306
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +994 -245
- package/plugins/kxm/dist/runtime.js +1018 -269
- package/plugins/kxm/dist/server.js +55 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-definitions/SKILL.md +3 -7
- package/plugins/kxm/src/autocomplete.ts +1 -1
- package/plugins/kxm/src/cli/project.ts +7 -1
- package/plugins/kxm/src/cli/roles.ts +46 -150
- package/plugins/kxm/src/cli.ts +8 -22
- package/plugins/kxm/src/engine.ts +24 -1
- package/plugins/kxm/src/init-guide-setup.ts +38 -4
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/permission.ts +11 -0
- package/plugins/kxm/src/policy-draft.mjs +56 -16
- package/plugins/kxm/src/project-config.ts +121 -10
- package/plugins/kxm/src/repair.ts +1 -0
- package/plugins/kxm/src/role.ts +40 -440
- package/plugins/kxm/src/routes.ts +28 -5
- package/plugins/kxm/src/template.ts +34 -1
- package/schemas/README.md +2 -1
- package/schemas/model.schema.json +111 -13
- package/schemas/role.schema.json +61 -31
- package/schemas/policy-draft/README.md +0 -17
- package/schemas/policy-draft/model.v2.schema.json +0 -140
- package/schemas/policy-draft/role.v2.schema.json +0 -91
package/plugins/kxm/src/role.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* KXM
|
|
3
|
-
*
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
60
|
-
primaryHarness?: string | undefined;
|
|
72
|
+
primaryRoute?: string | undefined;
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(/\.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
38
|
-
roleFile.
|
|
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<{
|
|
53
|
-
result[role] = (value.roster ?? []).map((entry) => entry.
|
|
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
|
-
|
|
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.
|
|
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` |
|