@plainconceptsplatform/agent-harness 3.2.0 → 3.3.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.
Files changed (141) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +454 -452
  3. package/cli/commands/join.js +259 -259
  4. package/cli/commands/migrate.js +310 -310
  5. package/cli/commands/shared.js +27 -27
  6. package/cli/commands/single.js +79 -79
  7. package/cli/commands/update.js +121 -121
  8. package/cli/commands/wizard.js +134 -134
  9. package/cli/fragments/archive/az.md +97 -97
  10. package/cli/fragments/archive/gh.md +96 -96
  11. package/cli/fragments/archive/gl.md +96 -96
  12. package/cli/fragments/archive/none.md +75 -75
  13. package/cli/fragments/guardrails/codegraph.md +5 -5
  14. package/cli/fragments/guardrails/humanizer.md +4 -4
  15. package/cli/fragments/guardrails/memory.md +4 -4
  16. package/cli/fragments/guardrails/rtk.md +3 -3
  17. package/cli/fragments/guardrails/simple-english.md +4 -4
  18. package/cli/fragments/ops-backlog/az.md +28 -28
  19. package/cli/fragments/ops-backlog/gh.md +28 -28
  20. package/cli/fragments/ops-backlog/jira.md +28 -28
  21. package/cli/fragments/ops-review/az.md +62 -62
  22. package/cli/fragments/ops-review/gh.md +51 -51
  23. package/cli/fragments/ops-review/gl.md +55 -55
  24. package/cli/fragments/ops-ship/az.md +81 -81
  25. package/cli/fragments/ops-ship/gh.md +67 -67
  26. package/cli/fragments/ops-ship/gl.md +84 -84
  27. package/cli/index.js +116 -116
  28. package/cli/presets/agents-content.json +34 -34
  29. package/cli/presets/browser.json +10 -10
  30. package/cli/presets/clean.json +21 -21
  31. package/cli/presets/models.json +68 -68
  32. package/cli/presets/optimization.json +32 -32
  33. package/cli/presets/platforms.json +76 -76
  34. package/cli/presets/source.json +23 -23
  35. package/cli/steps/browser/index.js +48 -48
  36. package/cli/steps/clean/index.js +120 -120
  37. package/cli/steps/copy/agents.js +120 -120
  38. package/cli/steps/copy/commands.js +79 -79
  39. package/cli/steps/copy/fullstack-engineer.js +89 -89
  40. package/cli/steps/copy/index.js +88 -88
  41. package/cli/steps/copy/opencode-json.js +324 -324
  42. package/cli/steps/copy/skills.js +307 -307
  43. package/cli/steps/metadata/index.js +115 -115
  44. package/cli/steps/models/format.js +88 -88
  45. package/cli/steps/models/index.js +64 -64
  46. package/cli/steps/models/write.js +38 -38
  47. package/cli/steps/openspec/index.js +136 -136
  48. package/cli/steps/optimization/codegraph.js +141 -127
  49. package/cli/steps/optimization/detect.js +66 -66
  50. package/cli/steps/optimization/humanizer.js +17 -17
  51. package/cli/steps/optimization/index.js +158 -158
  52. package/cli/steps/optimization/memory.js +91 -88
  53. package/cli/steps/optimization/patch-guardrails.js +111 -111
  54. package/cli/steps/optimization/simple-english.js +17 -17
  55. package/cli/steps/optimization/skills-lock.js +30 -30
  56. package/cli/steps/platform/index.js +109 -109
  57. package/cli/steps/source/index.js +123 -123
  58. package/cli/utils/agent-color.js +111 -111
  59. package/cli/utils/copy.js +134 -134
  60. package/cli/utils/exec-spinner.js +47 -47
  61. package/cli/utils/exec.js +134 -134
  62. package/cli/utils/legacy-check.js +30 -30
  63. package/cli/utils/models-cache.js +58 -58
  64. package/cli/utils/models-pricing.js +42 -42
  65. package/cli/utils/paths.js +80 -80
  66. package/cli/utils/process.js +3 -3
  67. package/cli/utils/terminal.js +6 -6
  68. package/cli/utils/update-manifest.js +75 -75
  69. package/harness/.opencode/_gitignore +9 -9
  70. package/harness/.opencode/commands/init.md +5 -5
  71. package/harness/.opencode/commands/make-architecture.md +5 -5
  72. package/harness/.opencode/commands/make-design.md +5 -5
  73. package/harness/.opencode/commands/make-engineer.md +5 -5
  74. package/harness/.opencode/commands/make-guardrails.md +5 -5
  75. package/harness/.opencode/commands/make-user-model.md +5 -5
  76. package/harness/.opencode/commands/ops-backlog.md +10 -10
  77. package/harness/.opencode/commands/ops-review.md +8 -8
  78. package/harness/.opencode/commands/ops-ship.md +9 -9
  79. package/harness/.opencode/commands/plan-apply.md +9 -9
  80. package/harness/.opencode/commands/plan-archive.md +5 -5
  81. package/harness/.opencode/commands/plan-explore.md +9 -9
  82. package/harness/.opencode/commands/plan-goal.md +5 -5
  83. package/harness/.opencode/commands/plan-propose.md +9 -9
  84. package/harness/.opencode/commands/plan-quick.md +5 -5
  85. package/harness/.opencode/commands/plan-story.md +9 -9
  86. package/harness/.opencode/commands/repo-audit.md +5 -5
  87. package/harness/.opencode/commands/repo-help.md +5 -5
  88. package/harness/.opencode/commands/repo-initialize.md +5 -5
  89. package/harness/.opencode/commands/repo-onboard.md +5 -5
  90. package/harness/.opencode/commands/repo-verify.md +5 -5
  91. package/harness/.opencode/package.json +5 -5
  92. package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
  93. package/harness/.opencode/plugins/pc-subagent-tiers.js +354 -354
  94. package/harness/.opencode/plugins/pc-system-reminders.js +386 -386
  95. package/harness/.opencode/scripts/check-diff.mjs +26 -7
  96. package/harness/.opencode/scripts/code-rules.mjs +288 -280
  97. package/harness/AGENTS.md +49 -49
  98. package/harness/ARCHITECTURE.md +13 -13
  99. package/harness/DESIGN.md +13 -13
  100. package/harness/opencode.jsonc +36 -36
  101. package/harness/openspec/config.yaml +20 -20
  102. package/harness/skills-lock.json +17 -17
  103. package/package.json +14 -12
  104. package/skills/README.md +18 -18
  105. package/skills/agent-harness-cli/SKILL.md +86 -86
  106. package/skills/browser-automation/SKILL.md +72 -72
  107. package/skills/pc-guardrails-project/SKILL.md +8 -8
  108. package/skills/pc-make-architecture/SKILL.md +32 -32
  109. package/skills/pc-make-architecture/structure-template.md +64 -64
  110. package/skills/pc-make-design/SKILL.md +45 -45
  111. package/skills/pc-make-engineer/SKILL.md +59 -59
  112. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  113. package/skills/pc-make-engineer/template.md +36 -36
  114. package/skills/pc-make-guardrails/SKILL.md +44 -44
  115. package/skills/pc-make-guardrails/category-reference.md +112 -112
  116. package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
  117. package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
  118. package/skills/pc-make-user-model/SKILL.md +56 -56
  119. package/skills/pc-ops-ship/SKILL.md +22 -18
  120. package/skills/pc-plan-apply/SKILL.md +86 -86
  121. package/skills/pc-plan-apply/simple-mode.md +21 -21
  122. package/skills/pc-plan-archive/SKILL.md +66 -66
  123. package/skills/pc-plan-explore/SKILL.md +21 -21
  124. package/skills/pc-plan-goal/SKILL.md +91 -91
  125. package/skills/pc-plan-goal/branching.md +39 -39
  126. package/skills/pc-plan-goal/failure-policy.md +30 -30
  127. package/skills/pc-plan-goal/output-mode.md +10 -10
  128. package/skills/pc-plan-goal/output.md +71 -71
  129. package/skills/pc-plan-propose/SKILL.md +125 -125
  130. package/skills/pc-plan-propose/task-annotation.md +39 -39
  131. package/skills/pc-plan-quick/SKILL.md +46 -46
  132. package/skills/pc-plan-story/SKILL.md +93 -93
  133. package/skills/pc-repo-audit/SKILL.md +44 -44
  134. package/skills/pc-repo-help/SKILL.md +87 -87
  135. package/skills/pc-repo-initialize/SKILL.md +119 -119
  136. package/skills/pc-repo-onboard/SKILL.md +32 -32
  137. package/skills/pc-repo-verify/SKILL.md +150 -150
  138. package/skills/pc-userstory-az/SKILL.md +71 -71
  139. package/skills/pc-userstory-browser/SKILL.md +64 -64
  140. package/skills/pc-userstory-gh/SKILL.md +63 -63
  141. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,354 +1,354 @@
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
- // fullstack-engineer.md mode: subagent the shared body of build and plan,
12
- // and the fallback worker.
13
- // *-engineer.md mode: subagent specialists, spawned by subagent().
14
- // *-engineer.<tier>.md mode: subagent the same specialist pinned to a tier.
15
- //
16
- // OpenCode 2 discovers agents from .opencode/agents/*.md and overrides a built-in
17
- // by ID, so the generated files replace the built-in build/plan. The plugin API's
18
- // agent editor can update or remove an existing agent but cannot add one, so file
19
- // generation is the mechanism: writing the files in setup, before agents resolve,
20
- // is what makes them visible.
21
-
22
- import { Plugin, Model } from "@opencode/plugin"
23
- import fs from "node:fs/promises"
24
- import path from "node:path"
25
-
26
- const TIERS = ["build", "fast", "plan"]
27
-
28
- // The two primaries, and the tier each takes its model from. plan denies edit
29
- // and subagent: a planning session cannot mutate the tree itself, and it cannot
30
- // spawn a worker that would do it on its behalf. shell stays allowed because the
31
- // planning skills shell out to git and openspec to read state, and
32
- // pc-system-reminders holds it to inspection commands.
33
- const PRIMARIES = {
34
- build: {
35
- tier: "build",
36
- color: "primary",
37
- description: "Implement changes in this repository. Full write access, spawns specialist engineers for parallel work.",
38
- permissions: null,
39
- },
40
- plan: {
41
- tier: "plan",
42
- color: "warning",
43
- description: "Explore and plan without touching the tree. Read-only: proposes work for build to carry out.",
44
- permissions: [
45
- { action: "edit", resource: "*", effect: "deny" },
46
- { action: "subagent", resource: "*", effect: "deny" },
47
- ],
48
- },
49
- }
50
-
51
- // opencode's theme keywords. Treated as unset so a name-derived hex wins: the
52
- // old /make-engineer template asked whoever ran it to pick one of these and
53
- // avoid collisions by hand, which does not survive more than a few agents.
54
- const THEME_COLORS = new Set(["primary", "secondary", "accent", "success", "warning", "error", "info"])
55
-
56
- // Fields V1 agent frontmatter used that V2 forbids. Stripped on sight so a repo
57
- // upgraded in place does not carry a definition V2 refuses to load.
58
- const LEGACY_FIELDS = [
59
- "model",
60
- "permission",
61
- "prompt",
62
- "disable",
63
- "disabled",
64
- "temperature",
65
- "top_p",
66
- "maxSteps",
67
- "tools",
68
- ]
69
-
70
- // Mirrors cli/utils/agent-color.js in the CLI, duplicated because a plugin has
71
- // to stand alone in the consumer repo. agent-color.test.js asserts the two stay
72
- // in step; change one and change the other.
73
- //
74
- // Only the hue is hashed. Fixed saturation and lightness are what keep every
75
- // result usable: hashing those too would eventually produce a near-grey,
76
- // near-black or near-white agent, invisible against one theme or the other.
77
- function agentColor(name) {
78
- let h = 0x811c9dc5
79
- for (let i = 0; i < name.length; i++) {
80
- h ^= name.charCodeAt(i)
81
- h = Math.imul(h, 0x01000193) >>> 0
82
- }
83
- const hue = (h >>> 0) % 360
84
- const sat = 0.68
85
- const light = 0.52
86
- const c = (1 - Math.abs(2 * light - 1)) * sat
87
- const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
88
- const m = light - c / 2
89
- const [r, g, b] =
90
- hue < 60 ? [c, x, 0] :
91
- hue < 120 ? [x, c, 0] :
92
- hue < 180 ? [0, c, x] :
93
- hue < 240 ? [0, x, c] :
94
- hue < 300 ? [x, 0, c] :
95
- [c, 0, x]
96
- const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, "0")
97
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
98
- }
99
-
100
- /** A theme keyword or a missing value yields to the derived hex; a hex stays. */
101
- function shouldDeriveColor(current) {
102
- if (!current) return true
103
- return THEME_COLORS.has(current.trim().toLowerCase())
104
- }
105
-
106
- /**
107
- * Render a colour for YAML frontmatter.
108
- *
109
- * A hex has to be quoted. `#` opens a comment in YAML, so `color: #D83155`
110
- * parses as null and opencode refuses the agent with "Invalid input color".
111
- * Theme keywords need no quoting and stay bare.
112
- */
113
- function yamlColor(value) {
114
- return value.startsWith("#") ? `"${value}"` : value
115
- }
116
-
117
- function unquoteColor(raw) {
118
- return (raw ?? "").trim().replace(/^["']/, "").replace(/["']$/, "")
119
- }
120
-
121
- function splitFrontmatter(content) {
122
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
123
- if (!match) return { frontmatter: null, body: content }
124
- return { frontmatter: match[1], body: content.slice(match[0].length) }
125
- }
126
-
127
- function stripLegacyFields(frontmatter) {
128
- const lines = frontmatter.split(/\r?\n/)
129
- const kept = []
130
- for (let i = 0; i < lines.length; i++) {
131
- const key = lines[i].match(/^([A-Za-z0-9_-]+):/)?.[1]
132
- if (key && LEGACY_FIELDS.includes(key)) {
133
- // Drop the key and any indented continuation lines that belong to it.
134
- i++
135
- while (i < lines.length && /^\s+\S/.test(lines[i])) i++
136
- i--
137
- continue
138
- }
139
- kept.push(lines[i])
140
- }
141
- return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim()
142
- }
143
-
144
- function setScalar(frontmatter, key, value) {
145
- const line = `${key}: ${value}`
146
- // String operations rather than a built RegExp: `key` is one of this module's
147
- // own constants, so the pattern would be safe, but a dynamic regexp here is a
148
- // ReDoS-shaped pattern that static analysis rightly refuses to clear.
149
- const lines = frontmatter.split(/\r?\n/)
150
- const index = lines.findIndex(current => current.startsWith(`${key}:`))
151
- if (index === -1) {
152
- return frontmatter ? `${line}\n${frontmatter}` : line
153
- }
154
- lines[index] = line
155
- return lines.join("\n")
156
- }
157
-
158
- function permissionsYaml(permissions) {
159
- return [
160
- "permissions:",
161
- ...permissions.flatMap(p => [
162
- ` - action: ${p.action}`,
163
- ` resource: ${p.resource}`,
164
- ` effect: ${p.effect}`,
165
- ]),
166
- ].join("\n")
167
- }
168
-
169
- /** The colour line an agent should have, or null when the current one is right. */
170
- function colorLineFor(name, rawCurrent) {
171
- const current = unquoteColor(rawCurrent)
172
- const target = shouldDeriveColor(current) ? agentColor(name) : current
173
- const rendered = yamlColor(target)
174
- return (rawCurrent ?? "").trim() === rendered ? null : `color: ${rendered}`
175
- }
176
-
177
- const FULLSTACK_NAME = "fullstack-engineer"
178
- const FULLSTACK_TEMPLATE = `${FULLSTACK_NAME}.md`
179
-
180
- /** Rewrite a template's frontmatter to a clean V2 subagent definition. */
181
- function normalizeTemplate(content, name) {
182
- const { frontmatter, body } = splitFrontmatter(content)
183
- if (frontmatter === null) return `---\nmode: subagent\ncolor: ${yamlColor(agentColor(name))}\n---\n\n${content}`
184
-
185
- let fm = stripLegacyFields(frontmatter)
186
- fm = setScalar(fm, "mode", "subagent")
187
- const colorLine = colorLineFor(name, fm.match(/^color:\s*(.+)$/m)?.[1])
188
- if (colorLine) fm = setScalar(fm, "color", colorLine.slice("color: ".length))
189
- return `---\n${fm}\n---\n${body}`
190
- }
191
-
192
- /** A tier-pinned copy of a specialist: same body, resolved model. */
193
- function buildVariant(templateContent, model) {
194
- const { frontmatter, body } = splitFrontmatter(templateContent)
195
- let fm = stripLegacyFields(frontmatter ?? "")
196
- fm = setScalar(fm, "mode", "subagent")
197
- fm = setScalar(fm, "model", model)
198
- // Hidden by default: a tier is chosen by whoever writes the task, not picked
199
- // from the @ menu. Without this the menu lists every specialist once per tier.
200
- fm = setScalar(fm, "hidden", "true")
201
- if (!/^color:/m.test(fm)) {
202
- const name = ""
203
- fm = setScalar(fm, "color", name ? yamlColor(agentColor(name)) : yamlColor(agentColor("engineer")))
204
- }
205
- return `---\n${fm}\n---\n${body}`
206
- }
207
-
208
- /**
209
- * Build a primary from the fullstack body: same identity and the same
210
- * ## Abilities block, with our own frontmatter on top. The body is taken
211
- * verbatim so /make-engineer only has to maintain fullstack-engineer.md and
212
- * both primaries inherit whatever it lists.
213
- */
214
- function buildPrimary(spec, fullstackContent, model) {
215
- const { body } = splitFrontmatter(fullstackContent)
216
- const lines = [
217
- "---",
218
- `description: ${spec.description}`,
219
- "mode: primary",
220
- ]
221
- if (model) lines.push(`model: ${model}`)
222
- lines.push(`color: ${yamlColor(spec.color)}`)
223
- if (spec.permissions) lines.push(permissionsYaml(spec.permissions))
224
- lines.push("---")
225
- return `${lines.join("\n")}\n\n${body.trim()}\n`
226
- }
227
-
228
- async function readJson(filePath) {
229
- try {
230
- return JSON.parse(await fs.readFile(filePath, "utf-8"))
231
- } catch {
232
- return null
233
- }
234
- }
235
-
236
- async function scanEngineers(agentsDir) {
237
- try {
238
- const entries = await fs.readdir(agentsDir)
239
- return {
240
- templates: entries
241
- .filter(f => /^[\w-]+-engineer\.md$/.test(f) && f !== FULLSTACK_TEMPLATE)
242
- .map(f => f.replace(/\.md$/, "")),
243
- variantFiles: entries.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
244
- hasFullstack: entries.includes(FULLSTACK_TEMPLATE),
245
- }
246
- } catch {
247
- return { templates: [], variantFiles: [], hasFullstack: false }
248
- }
249
- }
250
-
251
- async function writeIfChanged(filePath, content) {
252
- try {
253
- if ((await fs.readFile(filePath, "utf-8")) === content) return
254
- } catch {
255
- // file missing, write it
256
- }
257
- const tmpPath = `${filePath}.tmp`
258
- await fs.writeFile(tmpPath, content, "utf-8")
259
- await fs.rename(tmpPath, filePath)
260
- }
261
-
262
- export default Plugin.define({
263
- id: "pc.subagent-tiers",
264
- async setup(ctx) {
265
- const root = ctx.location.directory
266
- const agentsDir = path.join(root, ".opencode", "agents")
267
-
268
- try {
269
- const [user, team] = await Promise.all([
270
- readJson(path.join(root, ".opencode", "harness.user.json")),
271
- readJson(path.join(root, ".opencode", "harness.json")),
272
- ])
273
- const models = {}
274
- for (const tier of TIERS) {
275
- models[tier] = user?.models?.[tier] ?? team?.models?.[tier] ?? null
276
- }
277
- const available = TIERS.filter(t => models[t])
278
-
279
- const { templates, variantFiles, hasFullstack } = await scanEngineers(agentsDir)
280
-
281
- if (hasFullstack) {
282
- const fullstackPath = path.join(agentsDir, FULLSTACK_TEMPLATE)
283
- const rawFullstack = await fs.readFile(fullstackPath, "utf-8")
284
- const fullstack = normalizeTemplate(rawFullstack, FULLSTACK_NAME)
285
- if (fullstack !== rawFullstack) {
286
- await writeIfChanged(fullstackPath, fullstack)
287
- console.error("[pc-subagent-tiers] Normalized fullstack-engineer.md (V2 subagent)")
288
- }
289
-
290
- for (const [name, spec] of Object.entries(PRIMARIES)) {
291
- await writeIfChanged(
292
- path.join(agentsDir, `${name}.md`),
293
- buildPrimary(spec, fullstack, models[spec.tier]),
294
- )
295
- }
296
- console.error(`[pc-subagent-tiers] Wrote primaries: build (${models.build ?? "session model"}), plan (${models.plan ?? "session model"})`)
297
- } else {
298
- console.error(`[pc-subagent-tiers] ${FULLSTACK_TEMPLATE} missing: build and plan not generated`)
299
- }
300
-
301
- const keepSet = new Set()
302
- const variantsToWrite = []
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) {
307
- await writeIfChanged(path.join(agentsDir, `${name}.md`), content)
308
- console.error(`[pc-subagent-tiers] Normalized ${name}.md (V2 subagent)`)
309
- }
310
- for (const tier of available) {
311
- const file = `${name}.${tier}.md`
312
- variantsToWrite.push({ file, path: path.join(agentsDir, file), content: buildVariant(content, models[tier]) })
313
- keepSet.add(file)
314
- }
315
- }
316
-
317
- await Promise.all(variantsToWrite.map(v => writeIfChanged(v.path, v.content)))
318
- await Promise.all(
319
- variantFiles.filter(f => !keepSet.has(f)).map(f => fs.unlink(path.join(agentsDir, f)).catch(() => {})),
320
- )
321
-
322
- if (variantsToWrite.length > 0) {
323
- console.error(`[pc-subagent-tiers] Created ${variantsToWrite.length} variant files (${templates.length} engineers x ${available.length} tiers)`)
324
- } else if (templates.length > 0) {
325
- console.error(`[pc-subagent-tiers] No variants created. Models: ${JSON.stringify(models)}`)
326
- }
327
-
328
- // Best effort: if V2 already discovered these agents (same startup), pin
329
- // the primaries' model and plan's read-only permissions in memory too.
330
- if (typeof ctx.agent?.transform === "function") {
331
- await ctx.agent.transform(editor => {
332
- for (const [id, spec] of Object.entries(PRIMARIES)) {
333
- const existing = editor.get(id)
334
- if (!existing) continue
335
- const model = models[spec.tier]
336
- if (model) {
337
- try {
338
- existing.model = Model.Ref.parse(model)
339
- } catch {
340
- // invalid model ref; leave undefined so session model applies
341
- }
342
- }
343
- existing.mode = "primary"
344
- existing.description = spec.description
345
- if (spec.permissions) existing.permissions = spec.permissions
346
- }
347
- })
348
- await ctx.agent.reload?.()
349
- }
350
- } catch (error) {
351
- console.error(`[pc-subagent-tiers] Error: ${error?.message}`)
352
- }
353
- },
354
- })
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
+ // fullstack-engineer.md mode: subagent the shared body of build and plan,
12
+ // and the fallback worker.
13
+ // *-engineer.md mode: subagent specialists, spawned by subagent().
14
+ // *-engineer.<tier>.md mode: subagent the same specialist pinned to a tier.
15
+ //
16
+ // OpenCode 2 discovers agents from .opencode/agents/*.md and overrides a built-in
17
+ // by ID, so the generated files replace the built-in build/plan. The plugin API's
18
+ // agent editor can update or remove an existing agent but cannot add one, so file
19
+ // generation is the mechanism: writing the files in setup, before agents resolve,
20
+ // is what makes them visible.
21
+
22
+ import { Plugin, Model } from "@opencode/plugin"
23
+ import fs from "node:fs/promises"
24
+ import path from "node:path"
25
+
26
+ const TIERS = ["build", "fast", "plan"]
27
+
28
+ // The two primaries, and the tier each takes its model from. plan denies edit
29
+ // and subagent: a planning session cannot mutate the tree itself, and it cannot
30
+ // spawn a worker that would do it on its behalf. shell stays allowed because the
31
+ // planning skills shell out to git and openspec to read state, and
32
+ // pc-system-reminders holds it to inspection commands.
33
+ const PRIMARIES = {
34
+ build: {
35
+ tier: "build",
36
+ color: "primary",
37
+ description: "Implement changes in this repository. Full write access, spawns specialist engineers for parallel work.",
38
+ permissions: null,
39
+ },
40
+ plan: {
41
+ tier: "plan",
42
+ color: "warning",
43
+ description: "Explore and plan without touching the tree. Read-only: proposes work for build to carry out.",
44
+ permissions: [
45
+ { action: "edit", resource: "*", effect: "deny" },
46
+ { action: "subagent", resource: "*", effect: "deny" },
47
+ ],
48
+ },
49
+ }
50
+
51
+ // opencode's theme keywords. Treated as unset so a name-derived hex wins: the
52
+ // old /make-engineer template asked whoever ran it to pick one of these and
53
+ // avoid collisions by hand, which does not survive more than a few agents.
54
+ const THEME_COLORS = new Set(["primary", "secondary", "accent", "success", "warning", "error", "info"])
55
+
56
+ // Fields V1 agent frontmatter used that V2 forbids. Stripped on sight so a repo
57
+ // upgraded in place does not carry a definition V2 refuses to load.
58
+ const LEGACY_FIELDS = [
59
+ "model",
60
+ "permission",
61
+ "prompt",
62
+ "disable",
63
+ "disabled",
64
+ "temperature",
65
+ "top_p",
66
+ "maxSteps",
67
+ "tools",
68
+ ]
69
+
70
+ // Mirrors cli/utils/agent-color.js in the CLI, duplicated because a plugin has
71
+ // to stand alone in the consumer repo. agent-color.test.js asserts the two stay
72
+ // in step; change one and change the other.
73
+ //
74
+ // Only the hue is hashed. Fixed saturation and lightness are what keep every
75
+ // result usable: hashing those too would eventually produce a near-grey,
76
+ // near-black or near-white agent, invisible against one theme or the other.
77
+ function agentColor(name) {
78
+ let h = 0x811c9dc5
79
+ for (let i = 0; i < name.length; i++) {
80
+ h ^= name.charCodeAt(i)
81
+ h = Math.imul(h, 0x01000193) >>> 0
82
+ }
83
+ const hue = (h >>> 0) % 360
84
+ const sat = 0.68
85
+ const light = 0.52
86
+ const c = (1 - Math.abs(2 * light - 1)) * sat
87
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
88
+ const m = light - c / 2
89
+ const [r, g, b] =
90
+ hue < 60 ? [c, x, 0] :
91
+ hue < 120 ? [x, c, 0] :
92
+ hue < 180 ? [0, c, x] :
93
+ hue < 240 ? [0, x, c] :
94
+ hue < 300 ? [x, 0, c] :
95
+ [c, 0, x]
96
+ const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, "0")
97
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
98
+ }
99
+
100
+ /** A theme keyword or a missing value yields to the derived hex; a hex stays. */
101
+ function shouldDeriveColor(current) {
102
+ if (!current) return true
103
+ return THEME_COLORS.has(current.trim().toLowerCase())
104
+ }
105
+
106
+ /**
107
+ * Render a colour for YAML frontmatter.
108
+ *
109
+ * A hex has to be quoted. `#` opens a comment in YAML, so `color: #D83155`
110
+ * parses as null and opencode refuses the agent with "Invalid input color".
111
+ * Theme keywords need no quoting and stay bare.
112
+ */
113
+ function yamlColor(value) {
114
+ return value.startsWith("#") ? `"${value}"` : value
115
+ }
116
+
117
+ function unquoteColor(raw) {
118
+ return (raw ?? "").trim().replace(/^["']/, "").replace(/["']$/, "")
119
+ }
120
+
121
+ function splitFrontmatter(content) {
122
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
123
+ if (!match) return { frontmatter: null, body: content }
124
+ return { frontmatter: match[1], body: content.slice(match[0].length) }
125
+ }
126
+
127
+ function stripLegacyFields(frontmatter) {
128
+ const lines = frontmatter.split(/\r?\n/)
129
+ const kept = []
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const key = lines[i].match(/^([A-Za-z0-9_-]+):/)?.[1]
132
+ if (key && LEGACY_FIELDS.includes(key)) {
133
+ // Drop the key and any indented continuation lines that belong to it.
134
+ i++
135
+ while (i < lines.length && /^\s+\S/.test(lines[i])) i++
136
+ i--
137
+ continue
138
+ }
139
+ kept.push(lines[i])
140
+ }
141
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim()
142
+ }
143
+
144
+ function setScalar(frontmatter, key, value) {
145
+ const line = `${key}: ${value}`
146
+ // String operations rather than a built RegExp: `key` is one of this module's
147
+ // own constants, so the pattern would be safe, but a dynamic regexp here is a
148
+ // ReDoS-shaped pattern that static analysis rightly refuses to clear.
149
+ const lines = frontmatter.split(/\r?\n/)
150
+ const index = lines.findIndex(current => current.startsWith(`${key}:`))
151
+ if (index === -1) {
152
+ return frontmatter ? `${line}\n${frontmatter}` : line
153
+ }
154
+ lines[index] = line
155
+ return lines.join("\n")
156
+ }
157
+
158
+ function permissionsYaml(permissions) {
159
+ return [
160
+ "permissions:",
161
+ ...permissions.flatMap(p => [
162
+ ` - action: ${p.action}`,
163
+ ` resource: ${p.resource}`,
164
+ ` effect: ${p.effect}`,
165
+ ]),
166
+ ].join("\n")
167
+ }
168
+
169
+ /** The colour line an agent should have, or null when the current one is right. */
170
+ function colorLineFor(name, rawCurrent) {
171
+ const current = unquoteColor(rawCurrent)
172
+ const target = shouldDeriveColor(current) ? agentColor(name) : current
173
+ const rendered = yamlColor(target)
174
+ return (rawCurrent ?? "").trim() === rendered ? null : `color: ${rendered}`
175
+ }
176
+
177
+ const FULLSTACK_NAME = "fullstack-engineer"
178
+ const FULLSTACK_TEMPLATE = `${FULLSTACK_NAME}.md`
179
+
180
+ /** Rewrite a template's frontmatter to a clean V2 subagent definition. */
181
+ function normalizeTemplate(content, name) {
182
+ const { frontmatter, body } = splitFrontmatter(content)
183
+ if (frontmatter === null) return `---\nmode: subagent\ncolor: ${yamlColor(agentColor(name))}\n---\n\n${content}`
184
+
185
+ let fm = stripLegacyFields(frontmatter)
186
+ fm = setScalar(fm, "mode", "subagent")
187
+ const colorLine = colorLineFor(name, fm.match(/^color:\s*(.+)$/m)?.[1])
188
+ if (colorLine) fm = setScalar(fm, "color", colorLine.slice("color: ".length))
189
+ return `---\n${fm}\n---\n${body}`
190
+ }
191
+
192
+ /** A tier-pinned copy of a specialist: same body, resolved model. */
193
+ function buildVariant(templateContent, model) {
194
+ const { frontmatter, body } = splitFrontmatter(templateContent)
195
+ let fm = stripLegacyFields(frontmatter ?? "")
196
+ fm = setScalar(fm, "mode", "subagent")
197
+ fm = setScalar(fm, "model", model)
198
+ // Hidden by default: a tier is chosen by whoever writes the task, not picked
199
+ // from the @ menu. Without this the menu lists every specialist once per tier.
200
+ fm = setScalar(fm, "hidden", "true")
201
+ if (!/^color:/m.test(fm)) {
202
+ const name = ""
203
+ fm = setScalar(fm, "color", name ? yamlColor(agentColor(name)) : yamlColor(agentColor("engineer")))
204
+ }
205
+ return `---\n${fm}\n---\n${body}`
206
+ }
207
+
208
+ /**
209
+ * Build a primary from the fullstack body: same identity and the same
210
+ * ## Abilities block, with our own frontmatter on top. The body is taken
211
+ * verbatim so /make-engineer only has to maintain fullstack-engineer.md and
212
+ * both primaries inherit whatever it lists.
213
+ */
214
+ function buildPrimary(spec, fullstackContent, model) {
215
+ const { body } = splitFrontmatter(fullstackContent)
216
+ const lines = [
217
+ "---",
218
+ `description: ${spec.description}`,
219
+ "mode: primary",
220
+ ]
221
+ if (model) lines.push(`model: ${model}`)
222
+ lines.push(`color: ${yamlColor(spec.color)}`)
223
+ if (spec.permissions) lines.push(permissionsYaml(spec.permissions))
224
+ lines.push("---")
225
+ return `${lines.join("\n")}\n\n${body.trim()}\n`
226
+ }
227
+
228
+ async function readJson(filePath) {
229
+ try {
230
+ return JSON.parse(await fs.readFile(filePath, "utf-8"))
231
+ } catch {
232
+ return null
233
+ }
234
+ }
235
+
236
+ async function scanEngineers(agentsDir) {
237
+ try {
238
+ const entries = await fs.readdir(agentsDir)
239
+ return {
240
+ templates: entries
241
+ .filter(f => /^[\w-]+-engineer\.md$/.test(f) && f !== FULLSTACK_TEMPLATE)
242
+ .map(f => f.replace(/\.md$/, "")),
243
+ variantFiles: entries.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
244
+ hasFullstack: entries.includes(FULLSTACK_TEMPLATE),
245
+ }
246
+ } catch {
247
+ return { templates: [], variantFiles: [], hasFullstack: false }
248
+ }
249
+ }
250
+
251
+ async function writeIfChanged(filePath, content) {
252
+ try {
253
+ if ((await fs.readFile(filePath, "utf-8")) === content) return
254
+ } catch {
255
+ // file missing, write it
256
+ }
257
+ const tmpPath = `${filePath}.tmp`
258
+ await fs.writeFile(tmpPath, content, "utf-8")
259
+ await fs.rename(tmpPath, filePath)
260
+ }
261
+
262
+ export default Plugin.define({
263
+ id: "pc.subagent-tiers",
264
+ async setup(ctx) {
265
+ const root = ctx.location.directory
266
+ const agentsDir = path.join(root, ".opencode", "agents")
267
+
268
+ try {
269
+ const [user, team] = await Promise.all([
270
+ readJson(path.join(root, ".opencode", "harness.user.json")),
271
+ readJson(path.join(root, ".opencode", "harness.json")),
272
+ ])
273
+ const models = {}
274
+ for (const tier of TIERS) {
275
+ models[tier] = user?.models?.[tier] ?? team?.models?.[tier] ?? null
276
+ }
277
+ const available = TIERS.filter(t => models[t])
278
+
279
+ const { templates, variantFiles, hasFullstack } = await scanEngineers(agentsDir)
280
+
281
+ if (hasFullstack) {
282
+ const fullstackPath = path.join(agentsDir, FULLSTACK_TEMPLATE)
283
+ const rawFullstack = await fs.readFile(fullstackPath, "utf-8")
284
+ const fullstack = normalizeTemplate(rawFullstack, FULLSTACK_NAME)
285
+ if (fullstack !== rawFullstack) {
286
+ await writeIfChanged(fullstackPath, fullstack)
287
+ console.error("[pc-subagent-tiers] Normalized fullstack-engineer.md (V2 subagent)")
288
+ }
289
+
290
+ for (const [name, spec] of Object.entries(PRIMARIES)) {
291
+ await writeIfChanged(
292
+ path.join(agentsDir, `${name}.md`),
293
+ buildPrimary(spec, fullstack, models[spec.tier]),
294
+ )
295
+ }
296
+ console.error(`[pc-subagent-tiers] Wrote primaries: build (${models.build ?? "session model"}), plan (${models.plan ?? "session model"})`)
297
+ } else {
298
+ console.error(`[pc-subagent-tiers] ${FULLSTACK_TEMPLATE} missing: build and plan not generated`)
299
+ }
300
+
301
+ const keepSet = new Set()
302
+ const variantsToWrite = []
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) {
307
+ await writeIfChanged(path.join(agentsDir, `${name}.md`), content)
308
+ console.error(`[pc-subagent-tiers] Normalized ${name}.md (V2 subagent)`)
309
+ }
310
+ for (const tier of available) {
311
+ const file = `${name}.${tier}.md`
312
+ variantsToWrite.push({ file, path: path.join(agentsDir, file), content: buildVariant(content, models[tier]) })
313
+ keepSet.add(file)
314
+ }
315
+ }
316
+
317
+ await Promise.all(variantsToWrite.map(v => writeIfChanged(v.path, v.content)))
318
+ await Promise.all(
319
+ variantFiles.filter(f => !keepSet.has(f)).map(f => fs.unlink(path.join(agentsDir, f)).catch(() => {})),
320
+ )
321
+
322
+ if (variantsToWrite.length > 0) {
323
+ console.error(`[pc-subagent-tiers] Created ${variantsToWrite.length} variant files (${templates.length} engineers x ${available.length} tiers)`)
324
+ } else if (templates.length > 0) {
325
+ console.error(`[pc-subagent-tiers] No variants created. Models: ${JSON.stringify(models)}`)
326
+ }
327
+
328
+ // Best effort: if V2 already discovered these agents (same startup), pin
329
+ // the primaries' model and plan's read-only permissions in memory too.
330
+ if (typeof ctx.agent?.transform === "function") {
331
+ await ctx.agent.transform(editor => {
332
+ for (const [id, spec] of Object.entries(PRIMARIES)) {
333
+ const existing = editor.get(id)
334
+ if (!existing) continue
335
+ const model = models[spec.tier]
336
+ if (model) {
337
+ try {
338
+ existing.model = Model.Ref.parse(model)
339
+ } catch {
340
+ // invalid model ref; leave undefined so session model applies
341
+ }
342
+ }
343
+ existing.mode = "primary"
344
+ existing.description = spec.description
345
+ if (spec.permissions) existing.permissions = spec.permissions
346
+ }
347
+ })
348
+ await ctx.agent.reload?.()
349
+ }
350
+ } catch (error) {
351
+ console.error(`[pc-subagent-tiers] Error: ${error?.message}`)
352
+ }
353
+ },
354
+ })