@plainconceptsplatform/agent-harness 3.8.2 → 3.9.0-alpha.1

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.
@@ -1,367 +1,56 @@
1
- // pc-subagent-tiers: on startup, reads *-engineer.md templates and creates
2
- // tier variant agent files with the resolved model, plus the two primary agents
3
- // the user actually talks to. Everything generated here is gitignored and
4
- // rebuilt every startup. Model resolution: user override > team config.
5
- //
6
- // Agent topology:
7
- // build.md / plan.md mode: primary the only agents a human selects.
8
- // Both are fullstack-engineer with a
9
- // tier model; plan can neither edit
10
- // nor spawn.
11
- // planner.md mode: all fullstack-engineer on the plan tier
12
- // that may write but not spawn. The
13
- // thinking commands run on it, and a
14
- // CI lead spawns it for propose and
15
- // archive, so those steps take the
16
- // plan model whatever the session's is.
17
- // fullstack-engineer.md mode: subagent the shared body of build and plan,
18
- // and the fallback worker.
19
- // *-engineer.md mode: subagent specialists, spawned by subagent().
20
- // *-engineer.<tier>.md mode: subagent the same specialist pinned to a tier.
21
- //
22
- // OpenCode 2 discovers agents from .opencode/agents/*.md and overrides a built-in
23
- // by ID, so the generated files replace the built-in build/plan. The plugin API's
24
- // agent editor can update or remove an existing agent but cannot add one, so file
25
- // generation is the mechanism: writing the files in setup, before agents resolve,
26
- // is what makes them visible.
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.
27
6
 
28
- import { Plugin, Model } from "@opencode/plugin"
29
- import fs from "node:fs/promises"
30
- import path from "node:path"
31
-
32
- const TIERS = ["build", "fast", "plan"]
33
-
34
- // The two primaries, and the tier each takes its model from. plan denies edit
35
- // and subagent: a planning session cannot mutate the tree itself, and it cannot
36
- // spawn a worker that would do it on its behalf. shell stays allowed because the
37
- // planning skills shell out to git and openspec to read state, and
38
- // pc-system-reminders holds it to inspection commands.
39
- const PRIMARIES = {
40
- build: {
41
- tier: "build",
42
- color: "primary",
43
- description: "Implement changes in this repository. Full write access, spawns specialist engineers for parallel work.",
44
- permissions: null,
45
- },
46
- plan: {
47
- tier: "plan",
48
- color: "warning",
49
- description: "Explore and plan without touching the tree. Read-only: proposes work for build to carry out.",
50
- permissions: [
51
- { action: "edit", resource: "*", effect: "deny" },
52
- { action: "subagent", resource: "*", effect: "deny" },
53
- ],
54
- },
55
- planner: {
56
- tier: "plan",
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
- }
7
+ import { Plugin, Model } from "@opencode/plugin";
8
+ import { PRIMARIES, generateAgents } from "./pc-subagent-tiers-core.js";
274
9
 
275
10
  export default Plugin.define({
276
11
  id: "pc.subagent-tiers",
277
12
  async setup(ctx) {
278
- const root = ctx.location.directory
279
- const agentsDir = path.join(root, ".opencode", "agents")
13
+ const root = ctx.location.directory;
280
14
 
281
15
  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
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)}`);
289
27
  }
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)}`)
28
+ for (const warning of result.warnings) {
29
+ console.error(`[pc-subagent-tiers] Warning: ${warning}`);
339
30
  }
340
31
 
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
32
  if (typeof ctx.agent?.transform === "function") {
344
- await ctx.agent.transform(editor => {
33
+ await ctx.agent.transform((editor) => {
345
34
  for (const [id, spec] of Object.entries(PRIMARIES)) {
346
- const existing = editor.get(id)
347
- if (!existing) continue
348
- const model = models[spec.tier]
35
+ const existing = editor.get(id);
36
+ if (!existing) continue;
37
+ const model = result.models[spec.tier];
349
38
  if (model) {
350
39
  try {
351
- existing.model = Model.Ref.parse(model)
40
+ existing.model = Model.Ref.parse(model);
352
41
  } catch {
353
- // invalid model ref; leave undefined so session model applies
42
+ // invalid model ref; leave undefined so the session model applies
354
43
  }
355
44
  }
356
- existing.mode = spec.mode ?? "primary"
357
- existing.description = spec.description
358
- if (spec.permissions) existing.permissions = spec.permissions
45
+ existing.mode = spec.mode ?? "primary";
46
+ existing.description = spec.description;
47
+ if (spec.permissions) existing.permissions = spec.permissions;
359
48
  }
360
- })
361
- await ctx.agent.reload?.()
49
+ });
50
+ await ctx.agent.reload?.();
362
51
  }
363
52
  } catch (error) {
364
- console.error(`[pc-subagent-tiers] Error: ${error?.message}`)
53
+ console.error(`[pc-subagent-tiers] Error: ${error?.message}`);
365
54
  }
366
55
  },
367
- })
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/harness/AGENTS.md CHANGED
@@ -43,7 +43,13 @@ The `pc-plan-apply` skill is authoritative for subagent waves, dependency orderi
43
43
 
44
44
  ## Skills
45
45
 
46
- Skills live in `.agents/skills/`. Always installed: `@pc-guardrails-generic`, `@pc-guardrails-project`, and `@browser-automation`. An entry under `## Abilities` is not a passive reference: call the `skill` tool once per `@skill-name`, guardrails first. Editing, shell and spawning stay blocked until you have (`pc-system-reminders`), and a loaded skill can require further loads.
46
+ Skills live in `.agents/skills/`. Always installed: `@pc-guardrails-generic`, `@pc-guardrails-project`, and `@browser-automation`.
47
+
48
+ <!-- PC-PLATFORM-SKILLS-START -->
49
+ Mandatory, before any edit, shell command or subagent spawn: read `.agents/skills/pc-guardrails-generic/SKILL.md`, then `.agents/skills/pc-guardrails-project/SKILL.md` if present. They are instructions, not references: a loaded skill may name further skills, and those load too before the work they cover.
50
+ <!-- PC-PLATFORM-SKILLS-END -->
51
+
52
+ An entry under `## Abilities` is not a passive reference: call the `skill` tool once per `@skill-name`, guardrails first. Editing, shell and spawning stay blocked until you have (`pc-system-reminders`), and a loaded skill can require further loads.
47
53
 
48
54
  <!-- PC-PLATFORM-SKILLS-GUIDE-START -->
49
55
  <!-- PC-PLATFORM-SKILLS-GUIDE-END -->
@@ -2,9 +2,9 @@
2
2
  "$schema": "https://opencode.ai/config.json",
3
3
  "mcp": {
4
4
  "servers": {
5
- "agent-browser": {
5
+ "browse": {
6
6
  "type": "local",
7
- "command": ["agent-browser", "mcp", "--tools", "core"],
7
+ "command": ["browse", "mcp"],
8
8
  "disabled": false
9
9
  }
10
10
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/agent-harness",
3
- "version": "3.8.2",
3
+ "version": "3.9.0-alpha.1",
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",
@@ -40,7 +40,7 @@ copy Copy agents, skills, commands, docs into the repo
40
40
  openspec Initialize the OpenSpec workspace
41
41
  models Choose plan / build / fast models
42
42
  optimization Configure RTK, quota, Simple English, codegraph, agentmemory, humanizer
43
- browser Install agent-browser (npm package + its Chrome for Testing)
43
+ browser Install browse (npm package + browser extension pairing)
44
44
  metadata Rewrite .opencode/harness.json
45
45
  ```
46
46