@kontextmind/kxm 0.7.144 → 0.7.146

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 (48) 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 +22 -5
  17. package/docs/contributing/harness-routing-internals.md +17 -11
  18. package/docs/reference/cli-reference.md +23 -24
  19. package/docs/reference/config-reference.md +69 -62
  20. package/docs/reference/harness-routing.md +6 -5
  21. package/docs/reference/workflow-catalog.md +3 -3
  22. package/package.json +3 -3
  23. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  24. package/plugins/kxm/dist/cli.js +1144 -804
  25. package/plugins/kxm/dist/mcp-server.js +1 -1
  26. package/plugins/kxm/dist/runtime-supervisor.js +636 -308
  27. package/plugins/kxm/dist/runtime.js +707 -379
  28. package/plugins/kxm/dist/server.js +49 -5
  29. package/plugins/kxm/package.json +1 -1
  30. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -3
  31. package/plugins/kxm/src/cli/project.ts +2 -1
  32. package/plugins/kxm/src/cli/roles.ts +3 -4
  33. package/plugins/kxm/src/engine.ts +9 -6
  34. package/plugins/kxm/src/mcp-server.ts +1 -1
  35. package/plugins/kxm/src/policy-draft.mjs +11 -5
  36. package/plugins/kxm/src/project-config.ts +49 -11
  37. package/plugins/kxm/src/runtime-service.ts +2 -1
  38. package/plugins/kxm/src/template.ts +39 -10
  39. package/plugins/kxm/src/workflow-manager.ts +34 -28
  40. package/plugins/kxm/src/workforce-names.d.mts +38 -0
  41. package/plugins/kxm/src/workforce-names.mjs +317 -0
  42. package/schemas/agent.schema.json +7 -0
  43. package/schemas/model.schema.json +12 -0
  44. package/schemas/workflow.schema.json +14 -0
  45. package/scripts/harness-run.mjs +1 -1
  46. package/scripts/native-critic.mjs +5 -5
  47. package/scripts/roster-policy.mjs +9 -3
  48. package/scripts/workforce-lint.mjs +18 -0
@@ -63,23 +63,25 @@ function verifyStep(retryStep: string): Record<string, unknown> {
63
63
  /**
64
64
  * Built-in `kxm workflow add --template` definitions. Each is a complete
65
65
  * kxm.workflow.v1 document (the file name is the workflow id) that uses only
66
- * what `kxm init` creates: the coordinator and implementer agents, the control
66
+ * what `kxm init` creates: the planner and writer agents, the control
67
67
  * repository, and the `test` gate. Agent steps declare `failed` because the
68
- * producer falls back to it.
68
+ * producer falls back to it. `coordinator` and `implementer` still resolve
69
+ * as aliases of `planner` and `writer`.
69
70
  */
70
71
  export const WORKFLOW_TEMPLATES: Record<string, Record<string, unknown>> = {
71
72
  "implement-and-verify": {
72
73
  schema: "kxm.workflow.v1",
73
- description: "Implement a change, then run the project's test gate; a failing gate sends the work back to implement.",
74
- coordinator: "coordinator",
74
+ description: "Implement a change, then run the project's test gate; a failing gate sends the work back to writer.",
75
+ coordinator: "planner",
75
76
  limits: {
76
77
  maxTransitions: 8,
77
78
  },
78
79
  steps: [
79
80
  {
80
- id: "implement",
81
+ id: "writer",
82
+ aliases: ["implement"],
81
83
  kind: "agent",
82
- agent: "implementer",
84
+ agent: "writer",
83
85
  maxAttempts: 3,
84
86
  repositories: { control: "write" },
85
87
  on: {
@@ -87,57 +89,60 @@ export const WORKFLOW_TEMPLATES: Record<string, Record<string, unknown>> = {
87
89
  failed: failed(),
88
90
  },
89
91
  },
90
- verifyStep("implement"),
92
+ verifyStep("writer"),
91
93
  ],
92
94
  },
93
95
  "dual-critic-review": {
94
96
  schema: "kxm.workflow.v1",
95
- description: "Implement, review twice, then run the project's test gate. Both reviews run as the coordinator agent; for independent critics, add agents under .kxm/agents and point review-arch and review-cli at them.",
96
- coordinator: "coordinator",
97
+ description: "Write, review twice, then run the project's test gate. Both reviews run as the planner agent; for independent reviewers, add reviewer-arch and reviewer-cli agents and point the review steps at them.",
98
+ coordinator: "planner",
97
99
  limits: {
98
100
  maxTransitions: 12,
99
101
  },
100
102
  steps: [
101
103
  {
102
- id: "implement",
104
+ id: "writer",
105
+ aliases: ["implement"],
103
106
  kind: "agent",
104
- agent: "implementer",
107
+ agent: "writer",
105
108
  maxAttempts: 3,
106
109
  repositories: { control: "write" },
107
110
  on: {
108
- passed: "review-arch",
111
+ passed: "reviewer-arch",
109
112
  failed: failed(),
110
113
  },
111
114
  },
112
115
  {
113
- id: "review-arch",
116
+ id: "reviewer-arch",
117
+ aliases: ["review-arch", "critic-arch"],
114
118
  kind: "agent",
115
- agent: "coordinator",
119
+ agent: "planner",
116
120
  maxAttempts: 3,
117
121
  repositories: { control: "read" },
118
122
  on: {
119
- passed: "review-cli",
120
- failed: { target: "implement", maxTransitions: 2 },
123
+ passed: "reviewer-cli",
124
+ failed: { target: "writer", maxTransitions: 2 },
121
125
  },
122
126
  },
123
127
  {
124
- id: "review-cli",
128
+ id: "reviewer-cli",
129
+ aliases: ["review-cli", "critic-cli"],
125
130
  kind: "agent",
126
- agent: "coordinator",
131
+ agent: "planner",
127
132
  maxAttempts: 3,
128
133
  repositories: { control: "read" },
129
134
  on: {
130
135
  passed: "verify",
131
- failed: { target: "implement", maxTransitions: 2 },
136
+ failed: { target: "writer", maxTransitions: 2 },
132
137
  },
133
138
  },
134
- verifyStep("implement"),
139
+ verifyStep("writer"),
135
140
  ],
136
141
  },
137
142
  "spec-and-plan": {
138
143
  schema: "kxm.workflow.v1",
139
- description: "Plan a change, then review the plan. Both steps run as the coordinator agent and only read the repository.",
140
- coordinator: "coordinator",
144
+ description: "Plan a change, then review the plan. Both steps run as the planner agent and only read the repository.",
145
+ coordinator: "planner",
141
146
  limits: {
142
147
  maxTransitions: 8,
143
148
  },
@@ -145,18 +150,19 @@ export const WORKFLOW_TEMPLATES: Record<string, Record<string, unknown>> = {
145
150
  {
146
151
  id: "plan",
147
152
  kind: "agent",
148
- agent: "coordinator",
153
+ agent: "planner",
149
154
  maxAttempts: 3,
150
155
  repositories: { control: "read" },
151
156
  on: {
152
- passed: "review-arch",
157
+ passed: "reviewer-arch",
153
158
  failed: failed(),
154
159
  },
155
160
  },
156
161
  {
157
- id: "review-arch",
162
+ id: "reviewer-arch",
163
+ aliases: ["review-arch", "critic-arch"],
158
164
  kind: "agent",
159
- agent: "coordinator",
165
+ agent: "planner",
160
166
  maxAttempts: 3,
161
167
  repositories: { control: "read" },
162
168
  on: {
@@ -173,13 +179,13 @@ export function scaffoldWorkflowDefinition(description: string): Record<string,
173
179
  return {
174
180
  schema: "kxm.workflow.v1",
175
181
  description,
176
- coordinator: "coordinator",
182
+ coordinator: "planner",
177
183
  limits: { maxTransitions: 8 },
178
184
  steps: [
179
185
  {
180
186
  id: "step-1",
181
187
  kind: "agent",
182
- agent: "implementer",
188
+ agent: "writer",
183
189
  repositories: { control: "write" },
184
190
  on: { passed: completed(), failed: failed() },
185
191
  },
@@ -0,0 +1,38 @@
1
+ export const ROLE_IDS: readonly string[];
2
+ export const ROUTE_RENAMES: readonly (readonly [string, string])[];
3
+ export const AGENT_RENAMES: readonly (readonly [string, string])[];
4
+ export const WORKFLOW_RENAMES: readonly (readonly [string, string])[];
5
+ export const STEP_RENAMES: readonly (readonly [string, string])[];
6
+
7
+ export function noteDeprecatedId(kind: string, from: string, to: string): void;
8
+ export function resetDeprecatedIdWarnings(): void;
9
+ export function resolveRenamedId(
10
+ requested: string,
11
+ knownIds: ReadonlySet<string>,
12
+ pairs: readonly (readonly [string, string])[],
13
+ ): string;
14
+ export function canonicalRouteId(harness: string, model: string): string | undefined;
15
+
16
+ export function lookupById<T extends { id?: string | undefined; aliases?: readonly string[] | undefined }>(
17
+ records: Iterable<T>,
18
+ requested: string,
19
+ kind: "route" | "agent" | "workflow" | "step",
20
+ ): { record: T; viaAlias: boolean } | undefined;
21
+
22
+ export function findYamlBasename(
23
+ directory: string,
24
+ requested: string,
25
+ kind: "route" | "agent" | "workflow" | "step",
26
+ ): string | undefined;
27
+
28
+ export interface WorkforceLintIssue {
29
+ severity: "error" | "warning";
30
+ code: string;
31
+ file: string;
32
+ message: string;
33
+ }
34
+
35
+ export function lintWorkforce(root: string): {
36
+ errors: WorkforceLintIssue[];
37
+ warnings: WorkforceLintIssue[];
38
+ };
@@ -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`);