@plainconceptsplatform/agent-harness 3.8.3 → 3.9.0
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/cli/steps/copy/fullstack-engineer.js +0 -8
- package/harness/.opencode/_gitignore +2 -0
- package/harness/.opencode/package.json +6 -5
- package/harness/.opencode/plugins/pc-subagent-tiers-core.js +337 -0
- package/harness/.opencode/plugins/pc-subagent-tiers.js +56 -367
- package/harness/.opencode/scripts/harness-doctor.mjs +52 -0
- package/harness/.opencode/scripts/harness-doctor.test.mjs +58 -0
- package/harness/.opencode/scripts/sync-agents.mjs +29 -0
- package/package.json +1 -1
- package/skills/pc-make-user-model/SKILL.md +1 -1
- package/skills/pc-plan-apply/SKILL.md +3 -3
- package/skills/pc-plan-propose/task-annotation.md +1 -1
|
@@ -64,14 +64,6 @@ export async function generateFullstackEngineer({ cwd = process.cwd(), updateMod
|
|
|
64
64
|
// like every other subagent.
|
|
65
65
|
// Quoted: a bare #hex is a YAML comment, which opencode rejects.
|
|
66
66
|
`color: ${yamlColor(agentColor('fullstack-engineer'))}`,
|
|
67
|
-
'permission:',
|
|
68
|
-
' edit: allow',
|
|
69
|
-
' bash: allow',
|
|
70
|
-
' read: allow',
|
|
71
|
-
' glob: allow',
|
|
72
|
-
' grep: allow',
|
|
73
|
-
' question: allow',
|
|
74
|
-
' todowrite: allow',
|
|
75
67
|
'---',
|
|
76
68
|
]
|
|
77
69
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
{
|
|
2
|
-
"dependencies": {
|
|
3
|
-
"@opencode/plugin": "2.0.22"
|
|
4
|
-
}
|
|
5
|
-
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@opencode/plugin": "2.0.22"
|
|
4
|
+
},
|
|
5
|
+
"type": "module"
|
|
6
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// pc-subagent-tiers-core: pure Node agent generation. The plugin and the
|
|
2
|
+
// standalone sync script both call this, so a CI pre-start step can write the
|
|
3
|
+
// agents before OpenCode builds its task enum. No @opencode/plugin import here.
|
|
4
|
+
//
|
|
5
|
+
// Agent topology:
|
|
6
|
+
// build.md / plan.md mode: primary the only agents a human selects.
|
|
7
|
+
// planner.md mode: all fullstack body on the plan tier;
|
|
8
|
+
// commands run on it, it cannot spawn.
|
|
9
|
+
// planner-sub.md mode: subagent spawnable plan-tier alias for the
|
|
10
|
+
// task tool, same body, cannot spawn.
|
|
11
|
+
// fullstack-engineer.md mode: subagent shared body and fallback worker.
|
|
12
|
+
// *-engineer.md mode: subagent specialists.
|
|
13
|
+
// *-engineer.<tier>.md mode: subagent the same specialist pinned to a tier.
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
export const TIERS = ["build", "fast", "plan"];
|
|
19
|
+
export const MANIFEST_FILE = "harness-models.json";
|
|
20
|
+
export const FULLSTACK_NAME = "fullstack-engineer";
|
|
21
|
+
export const FULLSTACK_TEMPLATE = `${FULLSTACK_NAME}.md`;
|
|
22
|
+
|
|
23
|
+
export const PRIMARIES = {
|
|
24
|
+
build: {
|
|
25
|
+
tier: "build",
|
|
26
|
+
color: "primary",
|
|
27
|
+
description:
|
|
28
|
+
"Implement changes in this repository. Full write access, spawns specialist engineers for parallel work.",
|
|
29
|
+
permissions: null,
|
|
30
|
+
},
|
|
31
|
+
plan: {
|
|
32
|
+
tier: "plan",
|
|
33
|
+
color: "warning",
|
|
34
|
+
description:
|
|
35
|
+
"Explore and plan without touching the tree. Read-only: proposes work for build to carry out.",
|
|
36
|
+
permissions: [
|
|
37
|
+
{ action: "edit", resource: "*", effect: "deny" },
|
|
38
|
+
{ action: "subagent", resource: "*", effect: "deny" },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
planner: {
|
|
42
|
+
tier: "plan",
|
|
43
|
+
mode: "all",
|
|
44
|
+
color: "info",
|
|
45
|
+
description:
|
|
46
|
+
"Write plans, proposals, stories, reviews and archives on the plan model. Edits the tree, never spawns workers.",
|
|
47
|
+
permissions: [{ action: "subagent", resource: "*", effect: "deny" }],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const THEME_COLORS = new Set(["primary", "secondary", "accent", "success", "warning", "error", "info"]);
|
|
52
|
+
|
|
53
|
+
const LEGACY_FIELDS = [
|
|
54
|
+
"model",
|
|
55
|
+
"permission",
|
|
56
|
+
"prompt",
|
|
57
|
+
"disable",
|
|
58
|
+
"disabled",
|
|
59
|
+
"temperature",
|
|
60
|
+
"top_p",
|
|
61
|
+
"maxSteps",
|
|
62
|
+
"tools",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function agentColor(name) {
|
|
66
|
+
let h = 0x811c9dc5;
|
|
67
|
+
for (let i = 0; i < name.length; i++) {
|
|
68
|
+
h ^= name.charCodeAt(i);
|
|
69
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
70
|
+
}
|
|
71
|
+
const hue = (h >>> 0) % 360;
|
|
72
|
+
const sat = 0.68;
|
|
73
|
+
const light = 0.52;
|
|
74
|
+
const c = (1 - Math.abs(2 * light - 1)) * sat;
|
|
75
|
+
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
|
76
|
+
const m = light - c / 2;
|
|
77
|
+
const [r, g, b] =
|
|
78
|
+
hue < 60
|
|
79
|
+
? [c, x, 0]
|
|
80
|
+
: hue < 120
|
|
81
|
+
? [x, c, 0]
|
|
82
|
+
: hue < 180
|
|
83
|
+
? [0, c, x]
|
|
84
|
+
: hue < 240
|
|
85
|
+
? [0, x, c]
|
|
86
|
+
: hue < 300
|
|
87
|
+
? [x, 0, c]
|
|
88
|
+
: [c, 0, x];
|
|
89
|
+
const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, "0");
|
|
90
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function shouldDeriveColor(current) {
|
|
94
|
+
if (!current) return true;
|
|
95
|
+
return THEME_COLORS.has(current.trim().toLowerCase());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function yamlColor(value) {
|
|
99
|
+
return value.startsWith("#") ? `"${value}"` : value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function unquoteColor(raw) {
|
|
103
|
+
return (raw ?? "").trim().replace(/^["']/, "").replace(/["']$/, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function splitFrontmatter(content) {
|
|
107
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
108
|
+
if (!match) return { frontmatter: null, body: content };
|
|
109
|
+
return { frontmatter: match[1], body: content.slice(match[0].length) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stripLegacyFields(frontmatter) {
|
|
113
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
114
|
+
const kept = [];
|
|
115
|
+
for (let i = 0; i < lines.length; i++) {
|
|
116
|
+
const key = lines[i].match(/^([A-Za-z0-9_-]+):/)?.[1];
|
|
117
|
+
if (key && LEGACY_FIELDS.includes(key)) {
|
|
118
|
+
i++;
|
|
119
|
+
while (i < lines.length && /^\s+\S/.test(lines[i])) i++;
|
|
120
|
+
i--;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
kept.push(lines[i]);
|
|
124
|
+
}
|
|
125
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function setScalar(frontmatter, key, value) {
|
|
129
|
+
const line = `${key}: ${value}`;
|
|
130
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
131
|
+
const index = lines.findIndex((current) => current.startsWith(`${key}:`));
|
|
132
|
+
if (index === -1) return frontmatter ? `${line}\n${frontmatter}` : line;
|
|
133
|
+
lines[index] = line;
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function permissionsYaml(permissions) {
|
|
138
|
+
return [
|
|
139
|
+
"permissions:",
|
|
140
|
+
...permissions.flatMap((p) => [
|
|
141
|
+
` - action: ${p.action}`,
|
|
142
|
+
` resource: ${p.resource}`,
|
|
143
|
+
` effect: ${p.effect}`,
|
|
144
|
+
]),
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function colorLineFor(name, rawCurrent) {
|
|
149
|
+
const current = unquoteColor(rawCurrent);
|
|
150
|
+
const target = shouldDeriveColor(current) ? agentColor(name) : current;
|
|
151
|
+
const rendered = yamlColor(target);
|
|
152
|
+
return (rawCurrent ?? "").trim() === rendered ? null : `color: ${rendered}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeTemplate(content, name) {
|
|
156
|
+
const { frontmatter, body } = splitFrontmatter(content);
|
|
157
|
+
if (frontmatter === null) {
|
|
158
|
+
return `---\nmode: subagent\ncolor: ${yamlColor(agentColor(name))}\n---\n\n${content}`;
|
|
159
|
+
}
|
|
160
|
+
let fm = stripLegacyFields(frontmatter);
|
|
161
|
+
fm = setScalar(fm, "mode", "subagent");
|
|
162
|
+
const colorLine = colorLineFor(name, fm.match(/^color:\s*(.+)$/m)?.[1]);
|
|
163
|
+
if (colorLine) fm = setScalar(fm, "color", colorLine.slice("color: ".length));
|
|
164
|
+
return `---\n${fm}\n---\n${body}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildVariant(templateContent, model) {
|
|
168
|
+
const { frontmatter, body } = splitFrontmatter(templateContent);
|
|
169
|
+
let fm = stripLegacyFields(frontmatter ?? "");
|
|
170
|
+
fm = setScalar(fm, "mode", "subagent");
|
|
171
|
+
fm = setScalar(fm, "model", model);
|
|
172
|
+
fm = setScalar(fm, "hidden", "true");
|
|
173
|
+
if (!/^color:/m.test(fm)) {
|
|
174
|
+
fm = setScalar(fm, "color", yamlColor(agentColor("engineer")));
|
|
175
|
+
}
|
|
176
|
+
return `---\n${fm}\n---\n${body}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildPrimary(spec, fullstackContent, model) {
|
|
180
|
+
const { body } = splitFrontmatter(fullstackContent);
|
|
181
|
+
const lines = ["---", `description: ${spec.description}`, `mode: ${spec.mode ?? "primary"}`];
|
|
182
|
+
if (model) lines.push(`model: ${model}`);
|
|
183
|
+
lines.push(`color: ${yamlColor(spec.color)}`);
|
|
184
|
+
if (spec.permissions) lines.push(permissionsYaml(spec.permissions));
|
|
185
|
+
lines.push("---");
|
|
186
|
+
return `${lines.join("\n")}\n\n${body.trim()}\n`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildPlannerSub(fullstackContent, model) {
|
|
190
|
+
const { body } = splitFrontmatter(fullstackContent);
|
|
191
|
+
const lines = [
|
|
192
|
+
"---",
|
|
193
|
+
"description: Spawnable planner alias for the task tool. Uses the plan model and cannot spawn workers.",
|
|
194
|
+
"mode: subagent",
|
|
195
|
+
];
|
|
196
|
+
if (model) lines.push(`model: ${model}`);
|
|
197
|
+
lines.push(`color: ${yamlColor(agentColor("planner-sub"))}`);
|
|
198
|
+
lines.push("hidden: true");
|
|
199
|
+
lines.push(permissionsYaml([{ action: "subagent", resource: "*", effect: "deny" }]));
|
|
200
|
+
lines.push("---");
|
|
201
|
+
return `${lines.join("\n")}\n\n${body.trim()}\n`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function readJson(filePath) {
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(await fs.readFile(filePath, "utf-8"));
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function scanEngineers(agentsDir) {
|
|
213
|
+
try {
|
|
214
|
+
const entries = await fs.readdir(agentsDir);
|
|
215
|
+
return {
|
|
216
|
+
templates: entries
|
|
217
|
+
.filter((f) => /^[\w-]+-engineer\.md$/.test(f) && f !== FULLSTACK_TEMPLATE)
|
|
218
|
+
.map((f) => f.replace(/\.md$/, "")),
|
|
219
|
+
variantFiles: entries.filter((f) => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
|
|
220
|
+
hasFullstack: entries.includes(FULLSTACK_TEMPLATE),
|
|
221
|
+
};
|
|
222
|
+
} catch {
|
|
223
|
+
return { templates: [], variantFiles: [], hasFullstack: false };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function writeIfChanged(filePath, content) {
|
|
228
|
+
try {
|
|
229
|
+
if ((await fs.readFile(filePath, "utf-8")) === content) return false;
|
|
230
|
+
} catch {
|
|
231
|
+
// file missing, write it
|
|
232
|
+
}
|
|
233
|
+
const tmpPath = `${filePath}.tmp`;
|
|
234
|
+
await fs.writeFile(tmpPath, content, "utf-8");
|
|
235
|
+
await fs.rename(tmpPath, filePath);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isModelId(value) {
|
|
240
|
+
return typeof value === "string" && /^[^/\s]+\/[^/\s]+$/.test(value);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function generateAgents({ root, writeManifest = true } = {}) {
|
|
244
|
+
if (!root) throw new Error("generateAgents requires a root directory");
|
|
245
|
+
const agentsDir = path.join(root, ".opencode", "agents");
|
|
246
|
+
const [user, team] = await Promise.all([
|
|
247
|
+
readJson(path.join(root, ".opencode", "harness.user.json")),
|
|
248
|
+
readJson(path.join(root, ".opencode", "harness.json")),
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
const models = {};
|
|
252
|
+
const sources = {};
|
|
253
|
+
const warnings = [];
|
|
254
|
+
for (const tier of TIERS) {
|
|
255
|
+
const fromUser = user?.models?.[tier];
|
|
256
|
+
const fromTeam = team?.models?.[tier];
|
|
257
|
+
models[tier] = fromUser ?? fromTeam ?? null;
|
|
258
|
+
sources[tier] = fromUser ? "user" : fromTeam ? "team" : "missing";
|
|
259
|
+
if (!models[tier]) {
|
|
260
|
+
warnings.push(`tier ${tier} has no model; its agents inherit the session model`);
|
|
261
|
+
} else if (!isModelId(models[tier])) {
|
|
262
|
+
warnings.push(`tier ${tier} model "${models[tier]}" is not provider/model`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const available = TIERS.filter((tier) => models[tier]);
|
|
267
|
+
const { templates, variantFiles, hasFullstack } = await scanEngineers(agentsDir);
|
|
268
|
+
const manifest = {
|
|
269
|
+
generatedAt: new Date().toISOString(),
|
|
270
|
+
models,
|
|
271
|
+
source: sources,
|
|
272
|
+
agents: {},
|
|
273
|
+
warnings,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
if (hasFullstack) {
|
|
277
|
+
const fullstackPath = path.join(agentsDir, FULLSTACK_TEMPLATE);
|
|
278
|
+
const rawFullstack = await fs.readFile(fullstackPath, "utf-8");
|
|
279
|
+
const fullstack = normalizeTemplate(rawFullstack, FULLSTACK_NAME);
|
|
280
|
+
if (fullstack !== rawFullstack) await writeIfChanged(fullstackPath, fullstack);
|
|
281
|
+
|
|
282
|
+
for (const [name, spec] of Object.entries(PRIMARIES)) {
|
|
283
|
+
await writeIfChanged(path.join(agentsDir, `${name}.md`), buildPrimary(spec, fullstack, models[spec.tier]));
|
|
284
|
+
manifest.agents[name] = {
|
|
285
|
+
file: `${name}.md`,
|
|
286
|
+
mode: spec.mode ?? "primary",
|
|
287
|
+
model: models[spec.tier] ?? null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
await writeIfChanged(path.join(agentsDir, "planner-sub.md"), buildPlannerSub(fullstack, models.plan));
|
|
291
|
+
manifest.agents["planner-sub"] = {
|
|
292
|
+
file: "planner-sub.md",
|
|
293
|
+
mode: "subagent",
|
|
294
|
+
model: models.plan ?? null,
|
|
295
|
+
hidden: true,
|
|
296
|
+
};
|
|
297
|
+
} else {
|
|
298
|
+
warnings.push(`${FULLSTACK_TEMPLATE} missing: build, plan, planner and planner-sub not generated`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const keepSet = new Set();
|
|
302
|
+
const variants = [];
|
|
303
|
+
for (const name of templates) {
|
|
304
|
+
const rawContent = await fs.readFile(path.join(agentsDir, `${name}.md`), "utf-8");
|
|
305
|
+
const content = normalizeTemplate(rawContent, name);
|
|
306
|
+
if (content !== rawContent) await writeIfChanged(path.join(agentsDir, `${name}.md`), content);
|
|
307
|
+
for (const tier of available) {
|
|
308
|
+
const file = `${name}.${tier}.md`;
|
|
309
|
+
variants.push({ file, path: path.join(agentsDir, file), content: buildVariant(content, models[tier]) });
|
|
310
|
+
keepSet.add(file);
|
|
311
|
+
manifest.agents[name] ??= {};
|
|
312
|
+
manifest.agents[name][tier] = { file, model: models[tier], hidden: true };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await Promise.all(variants.map((variant) => writeIfChanged(variant.path, variant.content)));
|
|
317
|
+
await Promise.all(
|
|
318
|
+
variantFiles.filter((file) => !keepSet.has(file)).map((file) => fs.unlink(path.join(agentsDir, file)).catch(() => {})),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
if (templates.length === 0) warnings.push("no *-engineer.md templates found; no tier variants generated");
|
|
322
|
+
|
|
323
|
+
if (writeManifest) {
|
|
324
|
+
await fs.mkdir(path.join(root, ".opencode"), { recursive: true });
|
|
325
|
+
await writeIfChanged(path.join(root, ".opencode", MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
models,
|
|
330
|
+
sources,
|
|
331
|
+
warnings,
|
|
332
|
+
manifest,
|
|
333
|
+
templates,
|
|
334
|
+
variants: variants.map((variant) => variant.file),
|
|
335
|
+
hasFullstack,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
@@ -1,367 +1,56 @@
|
|
|
1
|
-
// pc-subagent-tiers: on startup,
|
|
2
|
-
//
|
|
3
|
-
// the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
mode: "all",
|
|
58
|
-
color: "info",
|
|
59
|
-
description: "Write plans, proposals, stories, reviews and archives on the plan model. Edits the tree, never spawns workers.",
|
|
60
|
-
permissions: [{ action: "subagent", resource: "*", effect: "deny" }],
|
|
61
|
-
},
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// opencode's theme keywords. Treated as unset so a name-derived hex wins: the
|
|
65
|
-
// old /make-engineer template asked whoever ran it to pick one of these and
|
|
66
|
-
// avoid collisions by hand, which does not survive more than a few agents.
|
|
67
|
-
const THEME_COLORS = new Set(["primary", "secondary", "accent", "success", "warning", "error", "info"])
|
|
68
|
-
|
|
69
|
-
// Fields V1 agent frontmatter used that V2 forbids. Stripped on sight so a repo
|
|
70
|
-
// upgraded in place does not carry a definition V2 refuses to load.
|
|
71
|
-
const LEGACY_FIELDS = [
|
|
72
|
-
"model",
|
|
73
|
-
"permission",
|
|
74
|
-
"prompt",
|
|
75
|
-
"disable",
|
|
76
|
-
"disabled",
|
|
77
|
-
"temperature",
|
|
78
|
-
"top_p",
|
|
79
|
-
"maxSteps",
|
|
80
|
-
"tools",
|
|
81
|
-
]
|
|
82
|
-
|
|
83
|
-
// Mirrors cli/utils/agent-color.js in the CLI, duplicated because a plugin has
|
|
84
|
-
// to stand alone in the consumer repo. agent-color.test.js asserts the two stay
|
|
85
|
-
// in step; change one and change the other.
|
|
86
|
-
//
|
|
87
|
-
// Only the hue is hashed. Fixed saturation and lightness are what keep every
|
|
88
|
-
// result usable: hashing those too would eventually produce a near-grey,
|
|
89
|
-
// near-black or near-white agent, invisible against one theme or the other.
|
|
90
|
-
function agentColor(name) {
|
|
91
|
-
let h = 0x811c9dc5
|
|
92
|
-
for (let i = 0; i < name.length; i++) {
|
|
93
|
-
h ^= name.charCodeAt(i)
|
|
94
|
-
h = Math.imul(h, 0x01000193) >>> 0
|
|
95
|
-
}
|
|
96
|
-
const hue = (h >>> 0) % 360
|
|
97
|
-
const sat = 0.68
|
|
98
|
-
const light = 0.52
|
|
99
|
-
const c = (1 - Math.abs(2 * light - 1)) * sat
|
|
100
|
-
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
|
|
101
|
-
const m = light - c / 2
|
|
102
|
-
const [r, g, b] =
|
|
103
|
-
hue < 60 ? [c, x, 0] :
|
|
104
|
-
hue < 120 ? [x, c, 0] :
|
|
105
|
-
hue < 180 ? [0, c, x] :
|
|
106
|
-
hue < 240 ? [0, x, c] :
|
|
107
|
-
hue < 300 ? [x, 0, c] :
|
|
108
|
-
[c, 0, x]
|
|
109
|
-
const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, "0")
|
|
110
|
-
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** A theme keyword or a missing value yields to the derived hex; a hex stays. */
|
|
114
|
-
function shouldDeriveColor(current) {
|
|
115
|
-
if (!current) return true
|
|
116
|
-
return THEME_COLORS.has(current.trim().toLowerCase())
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Render a colour for YAML frontmatter.
|
|
121
|
-
*
|
|
122
|
-
* A hex has to be quoted. `#` opens a comment in YAML, so `color: #D83155`
|
|
123
|
-
* parses as null and opencode refuses the agent with "Invalid input color".
|
|
124
|
-
* Theme keywords need no quoting and stay bare.
|
|
125
|
-
*/
|
|
126
|
-
function yamlColor(value) {
|
|
127
|
-
return value.startsWith("#") ? `"${value}"` : value
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function unquoteColor(raw) {
|
|
131
|
-
return (raw ?? "").trim().replace(/^["']/, "").replace(/["']$/, "")
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function splitFrontmatter(content) {
|
|
135
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
|
|
136
|
-
if (!match) return { frontmatter: null, body: content }
|
|
137
|
-
return { frontmatter: match[1], body: content.slice(match[0].length) }
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function stripLegacyFields(frontmatter) {
|
|
141
|
-
const lines = frontmatter.split(/\r?\n/)
|
|
142
|
-
const kept = []
|
|
143
|
-
for (let i = 0; i < lines.length; i++) {
|
|
144
|
-
const key = lines[i].match(/^([A-Za-z0-9_-]+):/)?.[1]
|
|
145
|
-
if (key && LEGACY_FIELDS.includes(key)) {
|
|
146
|
-
// Drop the key and any indented continuation lines that belong to it.
|
|
147
|
-
i++
|
|
148
|
-
while (i < lines.length && /^\s+\S/.test(lines[i])) i++
|
|
149
|
-
i--
|
|
150
|
-
continue
|
|
151
|
-
}
|
|
152
|
-
kept.push(lines[i])
|
|
153
|
-
}
|
|
154
|
-
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim()
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function setScalar(frontmatter, key, value) {
|
|
158
|
-
const line = `${key}: ${value}`
|
|
159
|
-
// String operations rather than a built RegExp: `key` is one of this module's
|
|
160
|
-
// own constants, so the pattern would be safe, but a dynamic regexp here is a
|
|
161
|
-
// ReDoS-shaped pattern that static analysis rightly refuses to clear.
|
|
162
|
-
const lines = frontmatter.split(/\r?\n/)
|
|
163
|
-
const index = lines.findIndex(current => current.startsWith(`${key}:`))
|
|
164
|
-
if (index === -1) {
|
|
165
|
-
return frontmatter ? `${line}\n${frontmatter}` : line
|
|
166
|
-
}
|
|
167
|
-
lines[index] = line
|
|
168
|
-
return lines.join("\n")
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function permissionsYaml(permissions) {
|
|
172
|
-
return [
|
|
173
|
-
"permissions:",
|
|
174
|
-
...permissions.flatMap(p => [
|
|
175
|
-
` - action: ${p.action}`,
|
|
176
|
-
` resource: ${p.resource}`,
|
|
177
|
-
` effect: ${p.effect}`,
|
|
178
|
-
]),
|
|
179
|
-
].join("\n")
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** The colour line an agent should have, or null when the current one is right. */
|
|
183
|
-
function colorLineFor(name, rawCurrent) {
|
|
184
|
-
const current = unquoteColor(rawCurrent)
|
|
185
|
-
const target = shouldDeriveColor(current) ? agentColor(name) : current
|
|
186
|
-
const rendered = yamlColor(target)
|
|
187
|
-
return (rawCurrent ?? "").trim() === rendered ? null : `color: ${rendered}`
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const FULLSTACK_NAME = "fullstack-engineer"
|
|
191
|
-
const FULLSTACK_TEMPLATE = `${FULLSTACK_NAME}.md`
|
|
192
|
-
|
|
193
|
-
/** Rewrite a template's frontmatter to a clean V2 subagent definition. */
|
|
194
|
-
function normalizeTemplate(content, name) {
|
|
195
|
-
const { frontmatter, body } = splitFrontmatter(content)
|
|
196
|
-
if (frontmatter === null) return `---\nmode: subagent\ncolor: ${yamlColor(agentColor(name))}\n---\n\n${content}`
|
|
197
|
-
|
|
198
|
-
let fm = stripLegacyFields(frontmatter)
|
|
199
|
-
fm = setScalar(fm, "mode", "subagent")
|
|
200
|
-
const colorLine = colorLineFor(name, fm.match(/^color:\s*(.+)$/m)?.[1])
|
|
201
|
-
if (colorLine) fm = setScalar(fm, "color", colorLine.slice("color: ".length))
|
|
202
|
-
return `---\n${fm}\n---\n${body}`
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** A tier-pinned copy of a specialist: same body, resolved model. */
|
|
206
|
-
function buildVariant(templateContent, model) {
|
|
207
|
-
const { frontmatter, body } = splitFrontmatter(templateContent)
|
|
208
|
-
let fm = stripLegacyFields(frontmatter ?? "")
|
|
209
|
-
fm = setScalar(fm, "mode", "subagent")
|
|
210
|
-
fm = setScalar(fm, "model", model)
|
|
211
|
-
// Hidden by default: a tier is chosen by whoever writes the task, not picked
|
|
212
|
-
// from the @ menu. Without this the menu lists every specialist once per tier.
|
|
213
|
-
fm = setScalar(fm, "hidden", "true")
|
|
214
|
-
if (!/^color:/m.test(fm)) {
|
|
215
|
-
const name = ""
|
|
216
|
-
fm = setScalar(fm, "color", name ? yamlColor(agentColor(name)) : yamlColor(agentColor("engineer")))
|
|
217
|
-
}
|
|
218
|
-
return `---\n${fm}\n---\n${body}`
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Build a primary from the fullstack body: same identity and the same
|
|
223
|
-
* ## Abilities block, with our own frontmatter on top. The body is taken
|
|
224
|
-
* verbatim so /make-engineer only has to maintain fullstack-engineer.md and
|
|
225
|
-
* both primaries inherit whatever it lists.
|
|
226
|
-
*/
|
|
227
|
-
function buildPrimary(spec, fullstackContent, model) {
|
|
228
|
-
const { body } = splitFrontmatter(fullstackContent)
|
|
229
|
-
const lines = [
|
|
230
|
-
"---",
|
|
231
|
-
`description: ${spec.description}`,
|
|
232
|
-
`mode: ${spec.mode ?? "primary"}`,
|
|
233
|
-
]
|
|
234
|
-
if (model) lines.push(`model: ${model}`)
|
|
235
|
-
lines.push(`color: ${yamlColor(spec.color)}`)
|
|
236
|
-
if (spec.permissions) lines.push(permissionsYaml(spec.permissions))
|
|
237
|
-
lines.push("---")
|
|
238
|
-
return `${lines.join("\n")}\n\n${body.trim()}\n`
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async function readJson(filePath) {
|
|
242
|
-
try {
|
|
243
|
-
return JSON.parse(await fs.readFile(filePath, "utf-8"))
|
|
244
|
-
} catch {
|
|
245
|
-
return null
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function scanEngineers(agentsDir) {
|
|
250
|
-
try {
|
|
251
|
-
const entries = await fs.readdir(agentsDir)
|
|
252
|
-
return {
|
|
253
|
-
templates: entries
|
|
254
|
-
.filter(f => /^[\w-]+-engineer\.md$/.test(f) && f !== FULLSTACK_TEMPLATE)
|
|
255
|
-
.map(f => f.replace(/\.md$/, "")),
|
|
256
|
-
variantFiles: entries.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
|
|
257
|
-
hasFullstack: entries.includes(FULLSTACK_TEMPLATE),
|
|
258
|
-
}
|
|
259
|
-
} catch {
|
|
260
|
-
return { templates: [], variantFiles: [], hasFullstack: false }
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
async function writeIfChanged(filePath, content) {
|
|
265
|
-
try {
|
|
266
|
-
if ((await fs.readFile(filePath, "utf-8")) === content) return
|
|
267
|
-
} catch {
|
|
268
|
-
// file missing, write it
|
|
269
|
-
}
|
|
270
|
-
const tmpPath = `${filePath}.tmp`
|
|
271
|
-
await fs.writeFile(tmpPath, content, "utf-8")
|
|
272
|
-
await fs.rename(tmpPath, filePath)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export default Plugin.define({
|
|
276
|
-
id: "pc.subagent-tiers",
|
|
277
|
-
async setup(ctx) {
|
|
278
|
-
const root = ctx.location.directory
|
|
279
|
-
const agentsDir = path.join(root, ".opencode", "agents")
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
const [user, team] = await Promise.all([
|
|
283
|
-
readJson(path.join(root, ".opencode", "harness.user.json")),
|
|
284
|
-
readJson(path.join(root, ".opencode", "harness.json")),
|
|
285
|
-
])
|
|
286
|
-
const models = {}
|
|
287
|
-
for (const tier of TIERS) {
|
|
288
|
-
models[tier] = user?.models?.[tier] ?? team?.models?.[tier] ?? null
|
|
289
|
-
}
|
|
290
|
-
const available = TIERS.filter(t => models[t])
|
|
291
|
-
|
|
292
|
-
const { templates, variantFiles, hasFullstack } = await scanEngineers(agentsDir)
|
|
293
|
-
|
|
294
|
-
if (hasFullstack) {
|
|
295
|
-
const fullstackPath = path.join(agentsDir, FULLSTACK_TEMPLATE)
|
|
296
|
-
const rawFullstack = await fs.readFile(fullstackPath, "utf-8")
|
|
297
|
-
const fullstack = normalizeTemplate(rawFullstack, FULLSTACK_NAME)
|
|
298
|
-
if (fullstack !== rawFullstack) {
|
|
299
|
-
await writeIfChanged(fullstackPath, fullstack)
|
|
300
|
-
console.error("[pc-subagent-tiers] Normalized fullstack-engineer.md (V2 subagent)")
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
for (const [name, spec] of Object.entries(PRIMARIES)) {
|
|
304
|
-
await writeIfChanged(
|
|
305
|
-
path.join(agentsDir, `${name}.md`),
|
|
306
|
-
buildPrimary(spec, fullstack, models[spec.tier]),
|
|
307
|
-
)
|
|
308
|
-
}
|
|
309
|
-
console.error(`[pc-subagent-tiers] Wrote primaries: build (${models.build ?? "session model"}), plan and planner (${models.plan ?? "session model"})`)
|
|
310
|
-
} else {
|
|
311
|
-
console.error(`[pc-subagent-tiers] ${FULLSTACK_TEMPLATE} missing: build and plan not generated`)
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const keepSet = new Set()
|
|
315
|
-
const variantsToWrite = []
|
|
316
|
-
for (const name of templates) {
|
|
317
|
-
const rawContent = await fs.readFile(path.join(agentsDir, `${name}.md`), "utf-8")
|
|
318
|
-
const content = normalizeTemplate(rawContent, name)
|
|
319
|
-
if (content !== rawContent) {
|
|
320
|
-
await writeIfChanged(path.join(agentsDir, `${name}.md`), content)
|
|
321
|
-
console.error(`[pc-subagent-tiers] Normalized ${name}.md (V2 subagent)`)
|
|
322
|
-
}
|
|
323
|
-
for (const tier of available) {
|
|
324
|
-
const file = `${name}.${tier}.md`
|
|
325
|
-
variantsToWrite.push({ file, path: path.join(agentsDir, file), content: buildVariant(content, models[tier]) })
|
|
326
|
-
keepSet.add(file)
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
await Promise.all(variantsToWrite.map(v => writeIfChanged(v.path, v.content)))
|
|
331
|
-
await Promise.all(
|
|
332
|
-
variantFiles.filter(f => !keepSet.has(f)).map(f => fs.unlink(path.join(agentsDir, f)).catch(() => {})),
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
if (variantsToWrite.length > 0) {
|
|
336
|
-
console.error(`[pc-subagent-tiers] Created ${variantsToWrite.length} variant files (${templates.length} engineers x ${available.length} tiers)`)
|
|
337
|
-
} else if (templates.length > 0) {
|
|
338
|
-
console.error(`[pc-subagent-tiers] No variants created. Models: ${JSON.stringify(models)}`)
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Best effort: if V2 already discovered these agents (same startup), pin
|
|
342
|
-
// the primaries' model and plan's read-only permissions in memory too.
|
|
343
|
-
if (typeof ctx.agent?.transform === "function") {
|
|
344
|
-
await ctx.agent.transform(editor => {
|
|
345
|
-
for (const [id, spec] of Object.entries(PRIMARIES)) {
|
|
346
|
-
const existing = editor.get(id)
|
|
347
|
-
if (!existing) continue
|
|
348
|
-
const model = models[spec.tier]
|
|
349
|
-
if (model) {
|
|
350
|
-
try {
|
|
351
|
-
existing.model = Model.Ref.parse(model)
|
|
352
|
-
} catch {
|
|
353
|
-
// invalid model ref; leave undefined so session model applies
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
existing.mode = spec.mode ?? "primary"
|
|
357
|
-
existing.description = spec.description
|
|
358
|
-
if (spec.permissions) existing.permissions = spec.permissions
|
|
359
|
-
}
|
|
360
|
-
})
|
|
361
|
-
await ctx.agent.reload?.()
|
|
362
|
-
}
|
|
363
|
-
} catch (error) {
|
|
364
|
-
console.error(`[pc-subagent-tiers] Error: ${error?.message}`)
|
|
365
|
-
}
|
|
366
|
-
},
|
|
367
|
-
})
|
|
1
|
+
// pc-subagent-tiers: on startup, generates the primary agents, the spawnable
|
|
2
|
+
// planner alias and the tier variants from *-engineer.md templates, then pins
|
|
3
|
+
// the primaries in memory. The generation itself lives in
|
|
4
|
+
// pc-subagent-tiers-core.js so a CI pre-start step can write the same files
|
|
5
|
+
// before OpenCode builds its task enum.
|
|
6
|
+
|
|
7
|
+
import { Plugin, Model } from "@opencode/plugin";
|
|
8
|
+
import { PRIMARIES, generateAgents } from "./pc-subagent-tiers-core.js";
|
|
9
|
+
|
|
10
|
+
export default Plugin.define({
|
|
11
|
+
id: "pc.subagent-tiers",
|
|
12
|
+
async setup(ctx) {
|
|
13
|
+
const root = ctx.location.directory;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const result = await generateAgents({ root });
|
|
17
|
+
|
|
18
|
+
console.error(
|
|
19
|
+
`[pc-subagent-tiers] Wrote primaries: build (${result.models.build ?? "session model"}), plan and planner (${result.models.plan ?? "session model"})`,
|
|
20
|
+
);
|
|
21
|
+
if (result.variants.length > 0) {
|
|
22
|
+
console.error(
|
|
23
|
+
`[pc-subagent-tiers] Created ${result.variants.length} variant files (${result.templates.length} engineers x ${Object.values(result.models).filter(Boolean).length} tiers)`,
|
|
24
|
+
);
|
|
25
|
+
} else if (result.templates.length > 0) {
|
|
26
|
+
console.error(`[pc-subagent-tiers] No variants created. Models: ${JSON.stringify(result.models)}`);
|
|
27
|
+
}
|
|
28
|
+
for (const warning of result.warnings) {
|
|
29
|
+
console.error(`[pc-subagent-tiers] Warning: ${warning}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof ctx.agent?.transform === "function") {
|
|
33
|
+
await ctx.agent.transform((editor) => {
|
|
34
|
+
for (const [id, spec] of Object.entries(PRIMARIES)) {
|
|
35
|
+
const existing = editor.get(id);
|
|
36
|
+
if (!existing) continue;
|
|
37
|
+
const model = result.models[spec.tier];
|
|
38
|
+
if (model) {
|
|
39
|
+
try {
|
|
40
|
+
existing.model = Model.Ref.parse(model);
|
|
41
|
+
} catch {
|
|
42
|
+
// invalid model ref; leave undefined so the session model applies
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
existing.mode = spec.mode ?? "primary";
|
|
46
|
+
existing.description = spec.description;
|
|
47
|
+
if (spec.permissions) existing.permissions = spec.permissions;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
await ctx.agent.reload?.();
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(`[pc-subagent-tiers] Error: ${error?.message}`);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Print the effective model routing and the generated agents. --strict exits 1
|
|
3
|
+
// on any warning, a missing planner-sub, or a manifest older than its configs.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const rootIndex = args.indexOf("--root");
|
|
10
|
+
const root = rootIndex !== -1 ? path.resolve(args[rootIndex + 1]) : process.cwd();
|
|
11
|
+
const strict = args.includes("--strict");
|
|
12
|
+
const manifestPath = path.join(root, ".opencode", "harness-models.json");
|
|
13
|
+
|
|
14
|
+
function fail(message, code = 1) {
|
|
15
|
+
console.error(message);
|
|
16
|
+
process.exit(code);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(manifestPath)) {
|
|
20
|
+
fail(`harness-doctor: ${manifestPath} is missing; run node .opencode/scripts/sync-agents.mjs`, 2);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let manifest;
|
|
24
|
+
try {
|
|
25
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
fail(`harness-doctor: ${manifestPath} is not valid JSON: ${error.message}`, 2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const models = manifest.models ?? {};
|
|
31
|
+
const sources = manifest.source ?? {};
|
|
32
|
+
const warnings = Array.isArray(manifest.warnings) ? manifest.warnings : [];
|
|
33
|
+
const agents = manifest.agents ?? {};
|
|
34
|
+
const planner = agents["planner-sub"] ?? agents.planner;
|
|
35
|
+
const plannerFile = planner?.file ? path.join(root, ".opencode", "agents", planner.file) : null;
|
|
36
|
+
const plannerExists = plannerFile ? fs.existsSync(plannerFile) : false;
|
|
37
|
+
|
|
38
|
+
console.log("Model routing");
|
|
39
|
+
console.log(` ${"tier".padEnd(6)} ${"effective".padEnd(40)} source`);
|
|
40
|
+
for (const tier of ["plan", "build", "fast"]) {
|
|
41
|
+
console.log(` ${tier.padEnd(6)} ${String(models[tier] ?? "(session)").padEnd(40)} ${sources[tier] ?? "missing"}`);
|
|
42
|
+
}
|
|
43
|
+
console.log(` ${"lead".padEnd(6)} ${"session model (ci-workflow-agent)".padEnd(40)} session`);
|
|
44
|
+
console.log(` ${"planner".padEnd(6)} ${String(planner?.model ?? "(session)").padEnd(40)} ${planner?.mode ?? "missing"}`);
|
|
45
|
+
console.log(` planner file: ${plannerFile ? `${planner.file}${plannerExists ? "" : " (missing)"}` : "missing"}`);
|
|
46
|
+
console.log(` agents: ${Object.keys(agents).length} entries, ${warnings.length} warning(s)`);
|
|
47
|
+
|
|
48
|
+
for (const warning of warnings) console.error(`warning: ${warning}`);
|
|
49
|
+
|
|
50
|
+
if (strict && (!planner || !plannerExists || warnings.length > 0)) {
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const SCRIPT = path.join(HERE, "harness-doctor.mjs");
|
|
10
|
+
let root;
|
|
11
|
+
|
|
12
|
+
function fixture(manifest) {
|
|
13
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "harness-doctor-"));
|
|
14
|
+
const opencode = path.join(root, ".opencode");
|
|
15
|
+
fs.mkdirSync(path.join(opencode, "agents"), { recursive: true });
|
|
16
|
+
fs.writeFileSync(path.join(opencode, "harness-models.json"), JSON.stringify(manifest, null, 2));
|
|
17
|
+
return opencode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (root) fs.rmSync(root, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("harness doctor", () => {
|
|
25
|
+
it("prints the effective routing and the planner alias", () => {
|
|
26
|
+
fixture({
|
|
27
|
+
models: {
|
|
28
|
+
plan: "plainconcepts/glm-5-3",
|
|
29
|
+
build: "plainconcepts/glm-5-2",
|
|
30
|
+
fast: "plainconcepts/glm-5-2",
|
|
31
|
+
},
|
|
32
|
+
source: { plan: "team", build: "team", fast: "team" },
|
|
33
|
+
agents: {
|
|
34
|
+
"planner-sub": { file: "planner-sub.md", mode: "subagent", model: "plainconcepts/glm-5-3" },
|
|
35
|
+
},
|
|
36
|
+
warnings: [],
|
|
37
|
+
});
|
|
38
|
+
fs.writeFileSync(path.join(root, ".opencode", "agents", "planner-sub.md"), "---\nmode: subagent\n---\n");
|
|
39
|
+
|
|
40
|
+
const output = execFileSync(process.execPath, [SCRIPT, "--root", root, "--strict"], { encoding: "utf8" });
|
|
41
|
+
expect(output).toContain("Model routing");
|
|
42
|
+
expect(output).toContain("plainconcepts/glm-5-3");
|
|
43
|
+
expect(output).toContain("planner-sub.md");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fails strict when the planner alias is missing", () => {
|
|
47
|
+
fixture({
|
|
48
|
+
models: { plan: "p/plan", build: "p/build", fast: "p/fast" },
|
|
49
|
+
source: { plan: "team", build: "team", fast: "team" },
|
|
50
|
+
agents: {},
|
|
51
|
+
warnings: [],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const result = spawnSync(process.execPath, [SCRIPT, "--root", root, "--strict"], { encoding: "utf8" });
|
|
55
|
+
expect(result.status).toBe(1);
|
|
56
|
+
expect(result.stdout).toContain("planner file: missing");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generate the build/plan/planner/planner-sub agents and the tier variants
|
|
3
|
+
// before OpenCode starts. CI runs this in a pre-agent step so the task tool's
|
|
4
|
+
// enum includes them from the first turn. Local users can run it too.
|
|
5
|
+
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { generateAgents } from "../plugins/pc-subagent-tiers-core.js";
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const rootIndex = args.indexOf("--root");
|
|
11
|
+
const root = rootIndex !== -1 ? path.resolve(args[rootIndex + 1]) : process.cwd();
|
|
12
|
+
const strict = args.includes("--strict");
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const result = await generateAgents({ root });
|
|
16
|
+
console.log(`Model routing (${path.join(root, ".opencode", "harness-models.json")})`);
|
|
17
|
+
for (const tier of ["plan", "build", "fast"]) {
|
|
18
|
+
console.log(` ${tier.padEnd(6)} ${String(result.models[tier] ?? "(session)").padEnd(40)} ${result.sources[tier]}`);
|
|
19
|
+
}
|
|
20
|
+
console.log(
|
|
21
|
+
` agents ${result.templates.length} engineer template(s), ${result.variants.length} variant(s), ` +
|
|
22
|
+
`${result.hasFullstack ? "planner + planner-sub" : "planner missing (no fullstack)"}`,
|
|
23
|
+
);
|
|
24
|
+
for (const warning of result.warnings) console.error(`warning: ${warning}`);
|
|
25
|
+
if (strict && result.warnings.length > 0) process.exit(1);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(`sync-agents: ${error?.message ?? error}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/agent-harness",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"description": "Installs the Plain Concepts Platform Harness into any codebase, and keeps it up to date. Wires OpenCode, OpenSpec, codegraph, and agentmemory into a multi-agent workflow that runs on native parallel subagents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -51,6 +51,6 @@ An id with no `/` in it is malformed; confirm before writing it:
|
|
|
51
51
|
file: <path written>
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
The change lands on the next opencode start, when `pc-subagent-tiers` reads the configs and rebuilds the tier variants.
|
|
54
|
+
The change lands on the next opencode start, when `pc-subagent-tiers` reads the configs and rebuilds the tier variants. For CI or a non-interactive run, `node .opencode/scripts/sync-agents.mjs` rebuilds the agents and `.opencode/harness-models.json`; `node .opencode/scripts/harness-doctor.mjs --strict` prints and validates the effective routing.
|
|
55
55
|
|
|
56
56
|
Arguments: `$ARGUMENTS`
|
|
@@ -37,13 +37,13 @@ Core rule: push, don't pull. A worker is born with its work: every `task()` spaw
|
|
|
37
37
|
|
|
38
38
|
**1. Branch.** Create `feature/{change-slug}` if not already on one. (Skip this step when the caller passed `start_from: load-plan`.)
|
|
39
39
|
|
|
40
|
-
**2. Load the plan and workers.** Parse `tasks.md`. Each task carries `<!-- agent, depends_on, touches -->` (from `pc-plan-propose`).
|
|
40
|
+
**2. Load the plan and workers.** Parse `tasks.md`. Each task carries `<!-- agent, depends_on, touches -->` (from `pc-plan-propose`). Read `.opencode/harness-models.json` (written by `pc-subagent-tiers` and `node .opencode/scripts/sync-agents.mjs`) for the effective model of every tier and the generated agent files. If the manifest is missing, run `node .opencode/scripts/sync-agents.mjs` once; if it still fails, stop. The tier-suffixed name in an annotation (for example, `backend-engineer.build`) is the worker to spawn; its model is the manifest's, not `harness.json`'s. `harness.user.json` overrides `harness.json`, so never report a model from `harness.json` alone. Read `.opencode/harness.json` -> `agents.maxConcurrent` (the wave cap, 1 to 5, enforced by `pc-subagent-monitor`).
|
|
41
41
|
|
|
42
42
|
When `.platform/contract.yml` declares `reuse` and the change's `proposal.md` has no `## What is reused` section, stop the APPLY stage and report it: the plan never checked what the template already ships. Otherwise every spawn prompt in step 7 carries that section.
|
|
43
43
|
|
|
44
|
-
Before hydrating the Todo board, resolve every task's annotated worker. If any task has a blank agent annotation, its base template is missing,
|
|
44
|
+
Before hydrating the Todo board, resolve every task's annotated worker. If any task has a blank agent annotation, its base template is missing, its tier variant is unavailable, or the manifest has no effective model for its tier, stop the APPLY stage and report the task ID, expected worker, and missing file. Do not replace the worker with `fullstack-engineer`, `general`, or the lead session.
|
|
45
45
|
|
|
46
|
-
**3. Hydrate the Todo board.** `todowrite` one item per task: `pending`. The Todo pane is the visible subagent board (opencode plugins cannot draw a custom pane, so the native Todo widget is the live UI). While a task is in flight, its label must carry the worker: `<agent> · <model
|
|
46
|
+
**3. Hydrate the Todo board.** `todowrite` one item per task: `pending`. The Todo pane is the visible subagent board (opencode plugins cannot draw a custom pane, so the native Todo widget is the live UI). While a task is in flight, its label must carry the worker: `<agent> · <model>`, with the model read from `.opencode/harness-models.json`, so the pane shows which agent on which model is doing what. The Todo list is a projection only: never read it for recovery; rebuild it from `tasks.md` and git and `.opencode/harness-run.json`.
|
|
47
47
|
|
|
48
48
|
**4. Worker context.** Before each wave, derive file-disjointness from `touches:` globs and `git diff`.
|
|
49
49
|
|
|
@@ -7,7 +7,7 @@ Pick a tier for each task based on complexity:
|
|
|
7
7
|
- `fast`: light work: i18n keys, config changes, env variables, navigation links, simple markup, verification runs
|
|
8
8
|
- `plan`: reserved for orchestration, do not use for implementation tasks
|
|
9
9
|
|
|
10
|
-
The tier suffix is appended to the agent name with a dot (e.g. `backend-engineer.build`). This is the agent name you write in the annotation. The `pc-subagent-tiers` plugin resolves the model at startup from `models[<tier>]`.
|
|
10
|
+
The tier suffix is appended to the agent name with a dot (e.g. `backend-engineer.build`). This is the agent name you write in the annotation. The `pc-subagent-tiers` plugin resolves the model at startup from `models[<tier>]`, with `.opencode/harness.user.json` overriding `.opencode/harness.json`, and writes the effective value to `.opencode/harness-models.json`. `tasks.md` names the tier, never a model id.
|
|
11
11
|
|
|
12
12
|
## depends_on
|
|
13
13
|
|