@kontextmind/kxm 0.7.145 → 0.7.147

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 (73) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/README.md +20 -2
  3. package/.kxm/agents/{coordinator.yaml → planner.yaml} +2 -0
  4. package/.kxm/agents/{critic-arch.yaml → reviewer-arch.yaml} +2 -0
  5. package/.kxm/agents/{critic-cli.yaml → reviewer-cli.yaml} +2 -0
  6. package/.kxm/agents/{implementer.yaml → writer.yaml} +2 -0
  7. package/.kxm/roles/planner.yaml +4 -2
  8. package/.kxm/roles/reviewer-arch.yaml +4 -2
  9. package/.kxm/roles/reviewer-cli.yaml +4 -2
  10. package/.kxm/roles/writer.yaml +6 -3
  11. package/.kxm/workflows/default.yaml +20 -12
  12. package/.kxm/workflows/land.yaml +1 -1
  13. package/.kxm/workflows/{review-arch-only.yaml → reviewer-arch-only.yaml} +7 -3
  14. package/.kxm/workflows/{review-cli-only.yaml → reviewer-cli-only.yaml} +7 -3
  15. package/.kxm/workflows/{implement-only.yaml → writer-only.yaml} +8 -4
  16. package/CHANGELOG.md +48 -8
  17. package/docs/README.md +1 -1
  18. package/docs/adr/ADR-0002-browser-automation-steel-doks.md +8 -5
  19. package/docs/adr/ADR-0005-obscura-default-playwright.md +4 -3
  20. package/docs/adr/ADR-0006-machine-account-names.md +90 -0
  21. package/docs/adr/ADR-0007-steel-caddy-authentik.md +97 -0
  22. package/docs/adr/README.md +3 -1
  23. package/docs/contributing/harness-routing-internals.md +17 -11
  24. package/docs/contributing/operating-rules.md +13 -1
  25. package/docs/guides/agent-skills.md +1 -1
  26. package/docs/guides/browser-automation.md +44 -28
  27. package/docs/kb/how-credentials-retrieved-safely.md +22 -23
  28. package/docs/kb/how-to-connect-playwright-to-steel.md +8 -5
  29. package/docs/kb/how-to-recover-expired-session-or-orphan.md +11 -10
  30. package/docs/kb/why-automation-opened-different-browser.md +4 -3
  31. package/docs/operations/deploy.md +31 -0
  32. package/docs/operations/troubleshooting.md +23 -1
  33. package/docs/prompts/browser-diagnose-recover.md +2 -2
  34. package/docs/prompts/browser-start.md +1 -1
  35. package/docs/reference/cli-reference.md +23 -24
  36. package/docs/reference/config-reference.md +69 -62
  37. package/docs/reference/configuration.md +9 -7
  38. package/docs/reference/harness-routing.md +6 -5
  39. package/docs/reference/workflow-catalog.md +3 -3
  40. package/package.json +3 -3
  41. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  42. package/plugins/kxm/dist/cli.js +1145 -805
  43. package/plugins/kxm/dist/mcp-server.js +1 -1
  44. package/plugins/kxm/dist/runtime-supervisor.js +636 -308
  45. package/plugins/kxm/dist/runtime.js +710 -382
  46. package/plugins/kxm/dist/server.js +49 -5
  47. package/plugins/kxm/package.json +1 -1
  48. package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +11 -12
  49. package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +5 -4
  50. package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +4 -3
  51. package/plugins/kxm/skills/kxm-browser-session/SKILL.md +5 -5
  52. package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +1 -1
  53. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -3
  54. package/plugins/kxm/src/browser.ts +12 -9
  55. package/plugins/kxm/src/cli/project.ts +2 -1
  56. package/plugins/kxm/src/cli/roles.ts +3 -4
  57. package/plugins/kxm/src/engine.ts +9 -6
  58. package/plugins/kxm/src/mcp-server.ts +1 -1
  59. package/plugins/kxm/src/modes.ts +1 -1
  60. package/plugins/kxm/src/policy-draft.mjs +11 -5
  61. package/plugins/kxm/src/project-config.ts +49 -11
  62. package/plugins/kxm/src/runtime-service.ts +2 -1
  63. package/plugins/kxm/src/template.ts +39 -10
  64. package/plugins/kxm/src/workflow-manager.ts +34 -28
  65. package/plugins/kxm/src/workforce-names.d.mts +38 -0
  66. package/plugins/kxm/src/workforce-names.mjs +317 -0
  67. package/schemas/agent.schema.json +7 -0
  68. package/schemas/model.schema.json +12 -0
  69. package/schemas/workflow.schema.json +14 -0
  70. package/scripts/harness-run.mjs +1 -1
  71. package/scripts/native-critic.mjs +5 -5
  72. package/scripts/roster-policy.mjs +9 -3
  73. package/scripts/workforce-lint.mjs +18 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Workforce id conventions and rename resolution.
3
+ *
4
+ * Role ids are the vocabulary (`planner`, `writer`, `reviewer-arch`,
5
+ * `reviewer-cli`, `experiment`). Agent ids and agent-step ids use that same
6
+ * vocabulary. Route ids are `<harness>-<model-slug>[-<provider>]`, with `.`
7
+ * in the model slug written as `-`. A provider suffix is required when the
8
+ * model id contains `/`.
9
+ *
10
+ * Resolution is bidirectional and only fills a missing side. A commit whose
11
+ * roles and models still use the old ids keeps matching itself, which is what
12
+ * the assignment runner reads at one commit. A tree that has already renamed
13
+ * the files still accepts the old id. A mixed tree accepts either side.
14
+ * `opus-claude` is not renamed: that route pointed at an unadmitted model and
15
+ * was removed.
16
+ */
17
+
18
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import YAML from "yaml";
21
+
22
+ export const ROSTER_EFFORTS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
23
+
24
+ export const ROLE_IDS = Object.freeze([
25
+ "writer",
26
+ "planner",
27
+ "reviewer-arch",
28
+ "reviewer-cli",
29
+ "experiment",
30
+ ]);
31
+
32
+ /** Old id, canonical id. Listed once; lookup tries both directions. */
33
+ export const ROUTE_RENAMES = Object.freeze([
34
+ ["grok-native", "grok-grok-4-7"],
35
+ ["qwen-openrouter-pi", "pi-qwen3-coder-plus-openrouter"],
36
+ ["gemini-agy", "agy-gemini-3-8-flash-high"],
37
+ ["fable-claude", "claude-fable"],
38
+ ["sol-codex", "codex-gpt-5-6-sol"],
39
+ ["grok-default", "grok-grok-4-6"],
40
+ ["fable-default", "claude-fable"],
41
+ ]);
42
+
43
+ export const AGENT_RENAMES = Object.freeze([
44
+ ["coordinator", "planner"],
45
+ ["implementer", "writer"],
46
+ ["critic-arch", "reviewer-arch"],
47
+ ["critic-cli", "reviewer-cli"],
48
+ ]);
49
+
50
+ export const WORKFLOW_RENAMES = Object.freeze([
51
+ ["implement-only", "writer-only"],
52
+ ["review-arch-only", "reviewer-arch-only"],
53
+ ["review-cli-only", "reviewer-cli-only"],
54
+ ]);
55
+
56
+ export const STEP_RENAMES = Object.freeze([
57
+ ["implement", "writer"],
58
+ ["critic-arch", "reviewer-arch"],
59
+ ["review-arch", "reviewer-arch"],
60
+ ["critic-cli", "reviewer-cli"],
61
+ ["review-cli", "reviewer-cli"],
62
+ ]);
63
+
64
+ const RENAMES = Object.freeze({
65
+ route: ROUTE_RENAMES,
66
+ agent: AGENT_RENAMES,
67
+ workflow: WORKFLOW_RENAMES,
68
+ step: STEP_RENAMES,
69
+ });
70
+
71
+ const RESERVED_WORKFLOW_IDS = new Set(["default", "land"]);
72
+ const warned = new Set();
73
+
74
+ export function noteDeprecatedId(kind, from, to) {
75
+ const key = `${kind}\0${from}\0${to}`;
76
+ if (warned.has(key)) return;
77
+ warned.add(key);
78
+ if (process.env.KXM_QUIET_ALIASES === "1") return;
79
+ console.warn(`kxm: deprecated ${kind} id '${from}' resolves to '${to}'`);
80
+ }
81
+
82
+ export function resetDeprecatedIdWarnings() {
83
+ warned.clear();
84
+ }
85
+
86
+ export function resolveRenamedId(requested, knownIds, pairs) {
87
+ if (typeof requested !== "string" || knownIds.has(requested)) return requested;
88
+ for (const [oldId, newId] of pairs) {
89
+ if (requested === oldId && knownIds.has(newId)) return newId;
90
+ if (requested === newId && knownIds.has(oldId)) return oldId;
91
+ }
92
+ return requested;
93
+ }
94
+
95
+ export function canonicalRouteId(harness, model) {
96
+ if (typeof harness !== "string" || typeof model !== "string" || !harness || !model) return undefined;
97
+ const parts = model.split("/").filter((part) => part.length > 0);
98
+ if (parts.length === 0) return undefined;
99
+ const slug = parts[parts.length - 1].replaceAll(".", "-").toLowerCase();
100
+ const provider = parts.length > 1 ? parts[0].replaceAll(".", "-").toLowerCase() : undefined;
101
+ const id = provider ? `${harness}-${slug}-${provider}` : `${harness}-${slug}`;
102
+ return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(id) && id.length <= 64 ? id : undefined;
103
+ }
104
+
105
+ function aliasList(value) {
106
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
107
+ }
108
+
109
+ /**
110
+ * @param {Iterable<{ id?: string, aliases?: readonly string[] }>} records
111
+ * @param {string} requested
112
+ * @param {"route"|"agent"|"workflow"|"step"} kind
113
+ */
114
+ export function lookupById(records, requested, kind) {
115
+ const list = [...records].filter((record) => typeof record?.id === "string" && record.id.length > 0);
116
+ const known = new Set(list.map((record) => record.id));
117
+ const direct = list.find((record) => record.id === requested);
118
+ if (direct) return { record: direct, viaAlias: false };
119
+ for (const record of list) {
120
+ if (aliasList(record.aliases).includes(requested)) {
121
+ noteDeprecatedId(kind, requested, record.id);
122
+ return { record, viaAlias: true };
123
+ }
124
+ }
125
+ const renamed = resolveRenamedId(requested, known, RENAMES[kind] ?? []);
126
+ if (renamed !== requested) {
127
+ const record = list.find((item) => item.id === renamed);
128
+ if (record) {
129
+ noteDeprecatedId(kind, requested, renamed);
130
+ return { record, viaAlias: true };
131
+ }
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ export function findYamlBasename(directory, requested, kind) {
137
+ if (typeof requested !== "string" || !requested) return undefined;
138
+ if (existsSync(join(directory, `${requested}.yaml`))) return requested;
139
+ if (!existsSync(directory)) return undefined;
140
+ const found = [];
141
+ for (const name of readdirSync(directory)) {
142
+ if (!name.endsWith(".yaml") || name === "inventory.yaml") continue;
143
+ const id = name.slice(0, -5);
144
+ let doc;
145
+ try {
146
+ doc = YAML.parse(readFileSync(join(directory, name), "utf8"));
147
+ } catch {
148
+ continue;
149
+ }
150
+ const aliases = doc && typeof doc === "object" ? aliasList(doc.aliases) : [];
151
+ found.push({ id, aliases });
152
+ }
153
+ const hit = lookupById(found, requested, kind);
154
+ return hit?.record.id;
155
+ }
156
+
157
+ function readYamlDir(directory, skipInventory) {
158
+ if (!existsSync(directory)) return [];
159
+ const docs = [];
160
+ for (const name of readdirSync(directory)) {
161
+ if (!name.endsWith(".yaml")) continue;
162
+ const id = name.slice(0, -5);
163
+ if (skipInventory && id === "inventory") continue;
164
+ let doc;
165
+ try {
166
+ doc = YAML.parse(readFileSync(join(directory, name), "utf8"));
167
+ } catch (error) {
168
+ docs.push({ id, file: `${directory}/${name}`, error: error instanceof Error ? error.message : "invalid YAML" });
169
+ continue;
170
+ }
171
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
172
+ docs.push({ id, file: `${directory}/${name}`, error: "root must be a mapping" });
173
+ continue;
174
+ }
175
+ docs.push({ id, file: name, doc });
176
+ }
177
+ return docs;
178
+ }
179
+
180
+ function push(bucket, severity, code, file, message) {
181
+ bucket.push({ severity, code, file, message });
182
+ }
183
+
184
+ function selectorsFor(doc) {
185
+ const model = typeof doc.model === "string" ? doc.model : "";
186
+ const vendor = typeof doc.vendor === "string" ? doc.vendor : "";
187
+ const harness = typeof doc.harness === "string" ? doc.harness : "";
188
+ return [model, vendor && model ? `${vendor}/${model}` : "", harness && model ? `${harness}/${model}` : ""].filter(Boolean);
189
+ }
190
+
191
+ /**
192
+ * Lint one project's `.kxm` workforce. Errors fail CI. An admitted model with
193
+ * no route is a warning. Alias declarations are not uses; a roster or step
194
+ * that still cites an old id is a `deprecated_id` warning.
195
+ */
196
+ export function lintWorkforce(root) {
197
+ const errors = [];
198
+ const warnings = [];
199
+ const models = readYamlDir(join(root, ".kxm", "models"), true).filter((entry) => !entry.error);
200
+ for (const entry of readYamlDir(join(root, ".kxm", "models"), true)) {
201
+ if (entry.error) push(errors, "error", "invalid_yaml", `.kxm/models/${entry.id}.yaml`, entry.error);
202
+ }
203
+ const roles = readYamlDir(join(root, ".kxm", "roles"), false).filter((entry) => !entry.error);
204
+ const agents = readYamlDir(join(root, ".kxm", "agents"), false).filter((entry) => !entry.error);
205
+ const workflows = readYamlDir(join(root, ".kxm", "workflows"), false).filter((entry) => !entry.error);
206
+ let admitted = [];
207
+ const routesFile = join(root, ".kxm", "routes.yaml");
208
+ if (existsSync(routesFile)) {
209
+ try {
210
+ const policy = YAML.parse(readFileSync(routesFile, "utf8"));
211
+ admitted = Array.isArray(policy?.admitted) ? policy.admitted.filter((item) => typeof item === "string") : [];
212
+ } catch (error) {
213
+ push(errors, "error", "invalid_yaml", ".kxm/routes.yaml", error instanceof Error ? error.message : "invalid YAML");
214
+ }
215
+ }
216
+
217
+ const modelRecords = models.map((entry) => ({ id: entry.id, aliases: aliasList(entry.doc.aliases), entry }));
218
+ const used = new Set();
219
+ for (const role of roles) {
220
+ const purpose = role.doc.purpose;
221
+ if (typeof purpose === "string" && role.id !== purpose) {
222
+ push(errors, "error", "role_id_convention", `.kxm/roles/${role.id}.yaml`, `role id '${role.id}' must equal purpose '${purpose}'`);
223
+ }
224
+ const roster = Array.isArray(role.doc.roster) ? role.doc.roster : [];
225
+ for (const item of roster) {
226
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
227
+ const route = item.route;
228
+ const effort = item.effort;
229
+ const routeLabel = typeof route === "string" && route ? route : "(missing route)";
230
+ if (typeof effort !== "string" || !ROSTER_EFFORTS.has(effort)) {
231
+ const detail = typeof effort === "string"
232
+ ? `effort '${effort}' is not one of off, minimal, low, medium, high, xhigh, max`
233
+ : "has no effort";
234
+ push(errors, "error", "roster_effort_required", `.kxm/roles/${role.id}.yaml`, `roster route '${routeLabel}' ${detail}`);
235
+ }
236
+ if (typeof route !== "string" || !route) continue;
237
+ const found = lookupById(modelRecords, route, "route");
238
+ if (!found) {
239
+ push(errors, "error", "route_unknown", `.kxm/roles/${role.id}.yaml`, `roster route '${route}' does not match a model file`);
240
+ continue;
241
+ }
242
+ used.add(found.record.id);
243
+ if (found.viaAlias) {
244
+ push(warnings, "warning", "deprecated_id", `.kxm/roles/${role.id}.yaml`, `roster route '${route}' is a deprecated alias of '${found.record.id}'`);
245
+ }
246
+ }
247
+ }
248
+
249
+ const covered = new Set();
250
+ for (const model of models) {
251
+ const doc = model.doc;
252
+ if (typeof doc.id === "string" && doc.id !== model.id) {
253
+ push(errors, "error", "route_id_convention", `.kxm/models/${model.id}.yaml`, `declared id '${doc.id}' does not match the filename`);
254
+ }
255
+ const expected = canonicalRouteId(doc.harness, doc.model);
256
+ if (!expected || model.id !== expected) {
257
+ push(errors, "error", "route_id_convention", `.kxm/models/${model.id}.yaml`, `route id '${model.id}' must be '${expected ?? "<harness>-<model-slug>[-<provider>]"}'`);
258
+ }
259
+ if (!used.has(model.id)) {
260
+ push(errors, "error", "route_unused", `.kxm/models/${model.id}.yaml`, `route '${model.id}' is not on any role roster`);
261
+ }
262
+ const selectorHit = selectorsFor(doc).find((selector) => admitted.includes(selector));
263
+ if (!selectorHit) {
264
+ push(errors, "error", "route_not_admitted", `.kxm/models/${model.id}.yaml`, `model '${doc.model ?? ""}' is not an admitted selector`);
265
+ } else {
266
+ covered.add(selectorHit);
267
+ }
268
+ }
269
+ for (const selector of admitted) {
270
+ if (!covered.has(selector)) {
271
+ push(warnings, "warning", "admitted_unrouted", ".kxm/routes.yaml", `admitted model '${selector}' is not used by any route`);
272
+ }
273
+ }
274
+
275
+ const agentRecords = agents.map((entry) => ({ id: entry.id, aliases: aliasList(entry.doc.aliases), entry }));
276
+ for (const agent of agents) {
277
+ const role = agent.doc.role;
278
+ if (typeof role !== "string" || agent.id !== role) {
279
+ push(errors, "error", "agent_id_convention", `.kxm/agents/${agent.id}.yaml`, `agent id '${agent.id}' must equal its role '${role ?? ""}'`);
280
+ }
281
+ }
282
+ for (const workflow of workflows) {
283
+ if (!RESERVED_WORKFLOW_IDS.has(workflow.id) && !ROLE_IDS.some((role) => workflow.id === `${role}-only`)) {
284
+ push(errors, "error", "workflow_id_convention", `.kxm/workflows/${workflow.id}.yaml`, `workflow id '${workflow.id}' must be default, land, or <role>-only`);
285
+ }
286
+ const steps = Array.isArray(workflow.doc.steps) ? workflow.doc.steps : [];
287
+ const realIds = new Set(steps.map((step) => step && typeof step.id === "string" ? step.id : "").filter(Boolean));
288
+ for (const step of steps) {
289
+ if (!step || typeof step !== "object" || typeof step.id !== "string") continue;
290
+ if (step.kind === "agent" || step.kind === "moa") {
291
+ const agentName = typeof step.agent === "string" ? step.agent : "";
292
+ const found = agentName ? lookupById(agentRecords, agentName, "agent") : undefined;
293
+ if (!found) {
294
+ push(errors, "error", "agent_unknown", `.kxm/workflows/${workflow.id}.yaml`, `step '${step.id}' references unknown agent '${agentName}'`);
295
+ } else if (found.viaAlias) {
296
+ push(warnings, "warning", "deprecated_id", `.kxm/workflows/${workflow.id}.yaml`, `step '${step.id}' agent '${agentName}' is a deprecated alias of '${found.record.id}'`);
297
+ }
298
+ const role = found?.record.entry.doc.role;
299
+ if (typeof role === "string" && step.id !== role) {
300
+ push(errors, "error", "step_id_convention", `.kxm/workflows/${workflow.id}.yaml`, `agent step '${step.id}' must use the role id '${role}'`);
301
+ }
302
+ } else if (step.kind === "gate") {
303
+ const gate = typeof step.gate === "string" ? step.gate : "";
304
+ const allowed = step.id === "verify" || step.id === gate || step.id.startsWith(`${workflow.id}-`);
305
+ if (!allowed) {
306
+ push(errors, "error", "step_id_convention", `.kxm/workflows/${workflow.id}.yaml`, `gate step '${step.id}' must be verify, the gate id, or ${workflow.id}-<name>`);
307
+ }
308
+ }
309
+ for (const alias of aliasList(step.aliases)) {
310
+ if (realIds.has(alias) && alias !== step.id) {
311
+ push(errors, "error", "step_id_convention", `.kxm/workflows/${workflow.id}.yaml`, `step alias '${alias}' collides with another step id`);
312
+ }
313
+ }
314
+ }
315
+ }
316
+ return { errors, warnings };
317
+ }
@@ -13,6 +13,13 @@
13
13
  "executor": { "$ref": "common.schema.json#/$defs/identifier" },
14
14
  "harness": { "$ref": "common.schema.json#/$defs/identifier" },
15
15
  "role": { "$ref": "common.schema.json#/$defs/identifier" },
16
+ "aliases": {
17
+ "type": "array",
18
+ "description": "Deprecated agent ids that still resolve to this file.",
19
+ "items": { "$ref": "common.schema.json#/$defs/identifier" },
20
+ "uniqueItems": true,
21
+ "maxItems": 16
22
+ },
16
23
  "effort": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max"] },
17
24
  "skills": {
18
25
  "type": "array",
@@ -14,6 +14,18 @@
14
14
  "minLength": 1,
15
15
  "maxLength": 64
16
16
  },
17
+ "aliases": {
18
+ "type": "array",
19
+ "description": "Deprecated route ids that still resolve to this file. A deprecation warning is printed when one is used.",
20
+ "items": {
21
+ "type": "string",
22
+ "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
23
+ "minLength": 1,
24
+ "maxLength": 64
25
+ },
26
+ "uniqueItems": true,
27
+ "maxItems": 16
28
+ },
17
29
  "harness": {
18
30
  "type": "string",
19
31
  "pattern": "^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$",
@@ -9,6 +9,13 @@
9
9
  "schema": { "const": "kxm.workflow.v1" },
10
10
  "description": { "type": "string", "maxLength": 4000 },
11
11
  "coordinator": { "$ref": "common.schema.json#/$defs/identifier" },
12
+ "aliases": {
13
+ "type": "array",
14
+ "description": "Deprecated workflow ids that still resolve to this file.",
15
+ "items": { "$ref": "common.schema.json#/$defs/identifier" },
16
+ "uniqueItems": true,
17
+ "maxItems": 16
18
+ },
12
19
  "limits": { "$ref": "#/$defs/workflowLimits" },
13
20
  "reproOracle": { "$ref": "#/$defs/oracle" },
14
21
  "planHash": { "$ref": "#/$defs/oracle" },
@@ -170,6 +177,13 @@
170
177
  "required": ["id", "kind"],
171
178
  "properties": {
172
179
  "id": { "$ref": "common.schema.json#/$defs/identifier" },
180
+ "aliases": {
181
+ "type": "array",
182
+ "description": "Deprecated step ids that still resolve to this step.",
183
+ "items": { "$ref": "common.schema.json#/$defs/identifier" },
184
+ "uniqueItems": true,
185
+ "maxItems": 16
186
+ },
173
187
  "kind": { "enum": ["agent", "moa", "gate", "approval", "wait"] },
174
188
  "description": { "type": "string", "maxLength": 2000 },
175
189
  "maxAttempts": { "type": "integer", "minimum": 1, "maximum": 20, "default": 1 },
@@ -153,7 +153,7 @@ export const ROUTES = Object.freeze({
153
153
  provider: "anthropic",
154
154
  roles: Object.freeze(["planner", "reviewer-arch"]),
155
155
  permissions: Object.freeze(["read-only"]),
156
- models: Object.freeze(["fable", "opus"]),
156
+ models: Object.freeze(["fable"]),
157
157
  efforts: Object.freeze(["low", "medium", "high", "xhigh", "max"]),
158
158
  auth: Object.freeze({ args: ["auth", "status"], loginHint: "claude auth login" }),
159
159
  },
@@ -8,12 +8,12 @@ import { buildArgv } from "./harness-run.mjs";
8
8
 
9
9
  // Launch intent only: these labels are not observed model identity or admission.
10
10
  export function nativeCriticLaunch(harness) {
11
- if (!["fable", "astra", "opus"].includes(harness)) {
12
- throw new Error("usage: native-critic.mjs <fable|astra|opus> <prompt>");
11
+ if (!["fable", "astra"].includes(harness)) {
12
+ throw new Error("usage: native-critic.mjs <fable|astra> <prompt>");
13
13
  }
14
- const model = harness === "fable" ? "fable" : harness === "opus" ? "opus" : "gpt-6-astra";
15
- const command = harness === "fable" || harness === "opus" ? "claude" : "codex";
16
- const args = harness === "fable" || harness === "opus"
14
+ const model = harness === "fable" ? "fable" : "gpt-6-astra";
15
+ const command = harness === "fable" ? "claude" : "codex";
16
+ const args = harness === "fable"
17
17
  ? ["-p", "--model", model, "--effort", "medium", "--output-format", "json", "--no-session-persistence", "--tools", "Read,Glob,Grep", "--setting-sources", "user"]
18
18
  : buildArgv({ harness: "codex", model, effort: "low", permission: "read-only", prompt_file: "-" });
19
19
  return { model, command, args };
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import YAML from 'yaml';
8
8
  import { NATIVE_PI_BRAKE_PROVIDERS, PI_ALLOWED_PROVIDERS, PI_ANTIGRAVITY_MODEL_ID, PI_NATIVE_VENDOR_PROVIDERS, ROUTES } from './harness-run.mjs';
9
+ import { lookupById } from '../plugins/kxm/src/workforce-names.mjs';
9
10
 
10
11
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
12
  const MODEL_DIR = '.kxm/models';
@@ -201,12 +202,17 @@ export function assembleRosterPolicy(models, roles) {
201
202
  text(role.id, 'role id');
202
203
  if (!Array.isArray(role.roster)) refuse('lineup contains unsupported or duplicate values');
203
204
  const ids = [];
205
+ const modelRecords = models
206
+ .filter((doc) => doc && typeof doc.id === 'string')
207
+ .map((doc) => ({ id: doc.id, aliases: Array.isArray(doc.aliases) ? doc.aliases : [] }));
204
208
  for (const entry of role.roster) {
205
209
  record(entry, 'roster entry');
206
210
  text(entry.route, 'roster route');
207
- ids.push(entry.route);
208
- if (!rolesByRoute[entry.route]) rolesByRoute[entry.route] = [];
209
- if (!rolesByRoute[entry.route].includes(role.id)) rolesByRoute[entry.route].push(role.id);
211
+ const resolved = lookupById(modelRecords, entry.route, 'route');
212
+ const routeId = resolved?.record.id ?? entry.route;
213
+ ids.push(routeId);
214
+ if (!rolesByRoute[routeId]) rolesByRoute[routeId] = [];
215
+ if (!rolesByRoute[routeId].includes(role.id)) rolesByRoute[routeId].push(role.id);
210
216
  }
211
217
  lineup[role.id] = ids;
212
218
  }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ // Fail when this project's workforce ids drift: a route at a non-admitted
3
+ // model, a roster entry with no route file, a route no role uses, or an id
4
+ // that breaks the workforce convention. An admitted model with no route is a
5
+ // warning and does not fail the process.
6
+
7
+ import { lintWorkforce } from "../plugins/kxm/src/workforce-names.mjs";
8
+
9
+ const root = process.argv[2] ?? process.cwd();
10
+ const report = lintWorkforce(root);
11
+ for (const item of [...report.errors, ...report.warnings]) {
12
+ process.stderr.write(`${item.severity} ${item.file}: ${item.code}: ${item.message}\n`);
13
+ }
14
+ if (report.errors.length > 0) {
15
+ process.stderr.write(`workforce lint failed: ${report.errors.length} error(s), ${report.warnings.length} warning(s)\n`);
16
+ process.exit(1);
17
+ }
18
+ process.stdout.write(`workforce lint passed (${report.warnings.length} warning(s))\n`);