@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.kxm/README.md +20 -2
- package/.kxm/agents/{coordinator.yaml → planner.yaml} +2 -0
- package/.kxm/agents/{critic-arch.yaml → reviewer-arch.yaml} +2 -0
- package/.kxm/agents/{critic-cli.yaml → reviewer-cli.yaml} +2 -0
- package/.kxm/agents/{implementer.yaml → writer.yaml} +2 -0
- package/.kxm/roles/planner.yaml +4 -2
- package/.kxm/roles/reviewer-arch.yaml +4 -2
- package/.kxm/roles/reviewer-cli.yaml +4 -2
- package/.kxm/roles/writer.yaml +6 -3
- package/.kxm/workflows/default.yaml +20 -12
- package/.kxm/workflows/land.yaml +1 -1
- package/.kxm/workflows/{review-arch-only.yaml → reviewer-arch-only.yaml} +7 -3
- package/.kxm/workflows/{review-cli-only.yaml → reviewer-cli-only.yaml} +7 -3
- package/.kxm/workflows/{implement-only.yaml → writer-only.yaml} +8 -4
- package/CHANGELOG.md +22 -5
- package/docs/contributing/harness-routing-internals.md +17 -11
- package/docs/reference/cli-reference.md +23 -24
- package/docs/reference/config-reference.md +69 -62
- package/docs/reference/harness-routing.md +6 -5
- package/docs/reference/workflow-catalog.md +3 -3
- package/package.json +3 -3
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +1144 -804
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +636 -308
- package/plugins/kxm/dist/runtime.js +707 -379
- package/plugins/kxm/dist/server.js +49 -5
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -3
- package/plugins/kxm/src/cli/project.ts +2 -1
- package/plugins/kxm/src/cli/roles.ts +3 -4
- package/plugins/kxm/src/engine.ts +9 -6
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/policy-draft.mjs +11 -5
- package/plugins/kxm/src/project-config.ts +49 -11
- package/plugins/kxm/src/runtime-service.ts +2 -1
- package/plugins/kxm/src/template.ts +39 -10
- package/plugins/kxm/src/workflow-manager.ts +34 -28
- package/plugins/kxm/src/workforce-names.d.mts +38 -0
- package/plugins/kxm/src/workforce-names.mjs +317 -0
- package/schemas/agent.schema.json +7 -0
- package/schemas/model.schema.json +12 -0
- package/schemas/workflow.schema.json +14 -0
- package/scripts/harness-run.mjs +1 -1
- package/scripts/native-critic.mjs +5 -5
- package/scripts/roster-policy.mjs +9 -3
- 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
|
|
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
|
|
74
|
-
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: "
|
|
81
|
+
id: "writer",
|
|
82
|
+
aliases: ["implement"],
|
|
81
83
|
kind: "agent",
|
|
82
|
-
agent: "
|
|
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("
|
|
92
|
+
verifyStep("writer"),
|
|
91
93
|
],
|
|
92
94
|
},
|
|
93
95
|
"dual-critic-review": {
|
|
94
96
|
schema: "kxm.workflow.v1",
|
|
95
|
-
description: "
|
|
96
|
-
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: "
|
|
104
|
+
id: "writer",
|
|
105
|
+
aliases: ["implement"],
|
|
103
106
|
kind: "agent",
|
|
104
|
-
agent: "
|
|
107
|
+
agent: "writer",
|
|
105
108
|
maxAttempts: 3,
|
|
106
109
|
repositories: { control: "write" },
|
|
107
110
|
on: {
|
|
108
|
-
passed: "
|
|
111
|
+
passed: "reviewer-arch",
|
|
109
112
|
failed: failed(),
|
|
110
113
|
},
|
|
111
114
|
},
|
|
112
115
|
{
|
|
113
|
-
id: "
|
|
116
|
+
id: "reviewer-arch",
|
|
117
|
+
aliases: ["review-arch", "critic-arch"],
|
|
114
118
|
kind: "agent",
|
|
115
|
-
agent: "
|
|
119
|
+
agent: "planner",
|
|
116
120
|
maxAttempts: 3,
|
|
117
121
|
repositories: { control: "read" },
|
|
118
122
|
on: {
|
|
119
|
-
passed: "
|
|
120
|
-
failed: { target: "
|
|
123
|
+
passed: "reviewer-cli",
|
|
124
|
+
failed: { target: "writer", maxTransitions: 2 },
|
|
121
125
|
},
|
|
122
126
|
},
|
|
123
127
|
{
|
|
124
|
-
id: "
|
|
128
|
+
id: "reviewer-cli",
|
|
129
|
+
aliases: ["review-cli", "critic-cli"],
|
|
125
130
|
kind: "agent",
|
|
126
|
-
agent: "
|
|
131
|
+
agent: "planner",
|
|
127
132
|
maxAttempts: 3,
|
|
128
133
|
repositories: { control: "read" },
|
|
129
134
|
on: {
|
|
130
135
|
passed: "verify",
|
|
131
|
-
failed: { target: "
|
|
136
|
+
failed: { target: "writer", maxTransitions: 2 },
|
|
132
137
|
},
|
|
133
138
|
},
|
|
134
|
-
verifyStep("
|
|
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
|
|
140
|
-
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: "
|
|
153
|
+
agent: "planner",
|
|
149
154
|
maxAttempts: 3,
|
|
150
155
|
repositories: { control: "read" },
|
|
151
156
|
on: {
|
|
152
|
-
passed: "
|
|
157
|
+
passed: "reviewer-arch",
|
|
153
158
|
failed: failed(),
|
|
154
159
|
},
|
|
155
160
|
},
|
|
156
161
|
{
|
|
157
|
-
id: "
|
|
162
|
+
id: "reviewer-arch",
|
|
163
|
+
aliases: ["review-arch", "critic-arch"],
|
|
158
164
|
kind: "agent",
|
|
159
|
-
agent: "
|
|
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: "
|
|
182
|
+
coordinator: "planner",
|
|
177
183
|
limits: { maxTransitions: 8 },
|
|
178
184
|
steps: [
|
|
179
185
|
{
|
|
180
186
|
id: "step-1",
|
|
181
187
|
kind: "agent",
|
|
182
|
-
agent: "
|
|
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 },
|
package/scripts/harness-run.mjs
CHANGED
|
@@ -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"
|
|
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"
|
|
12
|
-
throw new Error("usage: native-critic.mjs <fable|astra
|
|
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" :
|
|
15
|
-
const command = harness === "fable"
|
|
16
|
-
const args = harness === "fable"
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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`);
|