@plainconceptsplatform/agent-harness 2.3.0 → 2.4.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 (155) hide show
  1. package/README.md +23 -12
  2. package/{src → cli}/commands/join.js +3 -4
  3. package/{src → cli}/commands/migrate.js +1 -1
  4. package/{src → cli}/commands/wizard.js +1 -1
  5. package/{src → cli}/index.js +1 -1
  6. package/{src → cli}/presets/agents-content.json +4 -4
  7. package/cli/presets/browser.json +10 -0
  8. package/{src → cli}/presets/clean.json +21 -21
  9. package/{src → cli}/presets/optimization.json +37 -37
  10. package/{src → cli}/presets/platforms.json +76 -76
  11. package/{src → cli}/presets/quota.json +16 -16
  12. package/{src → cli}/presets/source.json +23 -23
  13. package/cli/steps/browser/index.js +48 -0
  14. package/{src → cli}/steps/clean/index.js +120 -120
  15. package/{src → cli}/steps/copy/agents.js +119 -119
  16. package/{src → cli}/steps/copy/commands.js +95 -95
  17. package/{src → cli}/steps/copy/fullstack-engineer.js +89 -88
  18. package/{src → cli}/steps/copy/index.js +1 -1
  19. package/{src → cli}/steps/copy/opencode-json.js +196 -161
  20. package/{src → cli}/steps/copy/skills.js +2 -2
  21. package/{src → cli}/steps/models/format.js +88 -88
  22. package/{src → cli}/steps/models/index.js +64 -64
  23. package/{src → cli}/steps/openspec/index.js +136 -136
  24. package/{src → cli}/steps/optimization/codegraph.js +127 -127
  25. package/{src → cli}/steps/optimization/detect.js +66 -66
  26. package/{src → cli}/steps/optimization/humanizer.js +17 -17
  27. package/{src → cli}/steps/optimization/index.js +163 -163
  28. package/{src → cli}/steps/optimization/memory.js +88 -88
  29. package/{src → cli}/steps/optimization/patch-guardrails.js +109 -109
  30. package/{src → cli}/steps/optimization/quota.js +119 -119
  31. package/{src → cli}/steps/optimization/simple-english.js +17 -17
  32. package/{src → cli}/steps/optimization/skills-lock.js +30 -30
  33. package/{src → cli}/steps/platform/index.js +109 -109
  34. package/{src → cli}/steps/source/index.js +123 -123
  35. package/{src → cli}/utils/agent-color.js +111 -83
  36. package/{src → cli}/utils/exec-spinner.js +47 -47
  37. package/{src → cli}/utils/exec.js +134 -134
  38. package/{src → cli}/utils/models-pricing.js +42 -42
  39. package/{src → cli}/utils/paths.js +1 -1
  40. package/{src → cli}/utils/process.js +3 -3
  41. package/{src → cli}/utils/terminal.js +6 -6
  42. package/harness/.agents/skills/browser-automation/SKILL.md +72 -0
  43. package/{src/content → harness}/.agents/skills/pc-make-engineer/SKILL.md +219 -219
  44. package/{src/content → harness}/.agents/skills/pc-make-engineer/template.md +80 -80
  45. package/{src/content → harness}/.agents/skills/pc-plan-archive/SKILL.md +3 -0
  46. package/{src/content → harness}/.agents/skills/pc-plan-explore/SKILL.md +3 -0
  47. package/{src/content → harness}/.agents/skills/pc-plan-goal/SKILL.md +5 -16
  48. package/{src/content → harness}/.agents/skills/pc-plan-goal/branching.md +1 -1
  49. package/{src/content → harness}/.agents/skills/pc-plan-goal/output.md +5 -8
  50. package/{src/content → harness}/.agents/skills/pc-plan-story/SKILL.md +3 -0
  51. package/{src/content → harness}/.agents/skills/pc-userstory-browser/SKILL.md +136 -132
  52. package/{src/content → harness}/.opencode/package.json +9 -10
  53. package/{src/content → harness}/.opencode/plugins/pc-subagent-tiers.js +368 -337
  54. package/{src/content → harness}/ARCHITECTURE.md +16 -16
  55. package/{src/content → harness}/DESIGN.md +16 -16
  56. package/{src/content → harness}/opencode.jsonc +47 -41
  57. package/{src/content → harness}/openspec/config.yaml +20 -20
  58. package/{src/content → harness}/skills-lock.json +17 -17
  59. package/package.json +7 -7
  60. package/src/content/.agents/skills/browser-automation/SKILL.md +0 -66
  61. package/src/presets/browser.json +0 -22
  62. package/src/steps/browser/index.js +0 -91
  63. /package/{src → cli}/commands/shared.js +0 -0
  64. /package/{src → cli}/commands/single.js +0 -0
  65. /package/{src → cli}/commands/update.js +0 -0
  66. /package/{src → cli}/fragments/archive/az.md +0 -0
  67. /package/{src → cli}/fragments/archive/gh.md +0 -0
  68. /package/{src → cli}/fragments/archive/gl.md +0 -0
  69. /package/{src → cli}/fragments/archive/none.md +0 -0
  70. /package/{src → cli}/fragments/guardrails/codegraph.md +0 -0
  71. /package/{src → cli}/fragments/guardrails/humanizer.md +0 -0
  72. /package/{src → cli}/fragments/guardrails/memory.md +0 -0
  73. /package/{src → cli}/fragments/guardrails/rtk.md +0 -0
  74. /package/{src → cli}/fragments/guardrails/simple-english.md +0 -0
  75. /package/{src → cli}/fragments/ops-backlog/az.md +0 -0
  76. /package/{src → cli}/fragments/ops-backlog/gh.md +0 -0
  77. /package/{src → cli}/fragments/ops-backlog/jira.md +0 -0
  78. /package/{src → cli}/fragments/ops-evidence/az.md +0 -0
  79. /package/{src → cli}/fragments/ops-evidence/gh.md +0 -0
  80. /package/{src → cli}/fragments/ops-evidence/jira.md +0 -0
  81. /package/{src → cli}/fragments/ops-review/az.md +0 -0
  82. /package/{src → cli}/fragments/ops-review/gh.md +0 -0
  83. /package/{src → cli}/fragments/ops-review/gl.md +0 -0
  84. /package/{src → cli}/fragments/ops-ship/az.md +0 -0
  85. /package/{src → cli}/fragments/ops-ship/gh.md +0 -0
  86. /package/{src → cli}/fragments/ops-ship/gl.md +0 -0
  87. /package/{src → cli}/presets/models.json +0 -0
  88. /package/{src → cli}/presets/openspec.json +0 -0
  89. /package/{src → cli}/steps/metadata/index.js +0 -0
  90. /package/{src → cli}/steps/models/write.js +0 -0
  91. /package/{src → cli}/utils/copy.js +0 -0
  92. /package/{src → cli}/utils/legacy-check.js +0 -0
  93. /package/{src → cli}/utils/models-cache.js +0 -0
  94. /package/{src → cli}/utils/update-manifest.js +0 -0
  95. /package/{src/content → harness}/.agents/skills/pc-guardrails-generic/SKILL.md +0 -0
  96. /package/{src/content → harness}/.agents/skills/pc-guardrails-project/SKILL.md +0 -0
  97. /package/{src/content → harness}/.agents/skills/pc-make-architecture/SKILL.md +0 -0
  98. /package/{src/content → harness}/.agents/skills/pc-make-architecture/structure-template.md +0 -0
  99. /package/{src/content → harness}/.agents/skills/pc-make-design/SKILL.md +0 -0
  100. /package/{src/content → harness}/.agents/skills/pc-make-engineer/signal-mapping.md +0 -0
  101. /package/{src/content → harness}/.agents/skills/pc-make-evidence-scaffold/SKILL.md +0 -0
  102. /package/{src/content → harness}/.agents/skills/pc-make-evidence-scaffold/evidence-contract.md +0 -0
  103. /package/{src/content → harness}/.agents/skills/pc-make-guardrails/SKILL.md +0 -0
  104. /package/{src/content → harness}/.agents/skills/pc-make-guardrails/category-reference.md +0 -0
  105. /package/{src/content → harness}/.agents/skills/pc-make-merge-risk-assess/SKILL.md +0 -0
  106. /package/{src/content → harness}/.agents/skills/pc-make-merge-risk-assess/category-reference.md +0 -0
  107. /package/{src/content → harness}/.agents/skills/pc-make-user-model/SKILL.md +0 -0
  108. /package/{src/content → harness}/.agents/skills/pc-ops-evidence/SKILL.md +0 -0
  109. /package/{src/content → harness}/.agents/skills/pc-ops-ship/SKILL.md +0 -0
  110. /package/{src/content → harness}/.agents/skills/pc-plan-apply/SKILL.md +0 -0
  111. /package/{src/content → harness}/.agents/skills/pc-plan-apply/simple-mode.md +0 -0
  112. /package/{src/content → harness}/.agents/skills/pc-plan-goal/failure-policy.md +0 -0
  113. /package/{src/content → harness}/.agents/skills/pc-plan-goal/output-mode.md +0 -0
  114. /package/{src/content → harness}/.agents/skills/pc-plan-propose/SKILL.md +0 -0
  115. /package/{src/content → harness}/.agents/skills/pc-plan-propose/task-annotation.md +0 -0
  116. /package/{src/content → harness}/.agents/skills/pc-plan-quick/SKILL.md +0 -0
  117. /package/{src/content → harness}/.agents/skills/pc-repo-audit/SKILL.md +0 -0
  118. /package/{src/content → harness}/.agents/skills/pc-repo-help/SKILL.md +0 -0
  119. /package/{src/content → harness}/.agents/skills/pc-repo-initialize/SKILL.md +0 -0
  120. /package/{src/content → harness}/.agents/skills/pc-repo-onboard/SKILL.md +0 -0
  121. /package/{src/content → harness}/.agents/skills/pc-repo-verify/SKILL.md +0 -0
  122. /package/{src/content → harness}/.agents/skills/pc-userstory-az/SKILL.md +0 -0
  123. /package/{src/content → harness}/.agents/skills/pc-userstory-gh/SKILL.md +0 -0
  124. /package/{src/content → harness}/.agents/skills/pc-userstory-jira/SKILL.md +0 -0
  125. /package/{src/content → harness}/.opencode/_gitignore +0 -0
  126. /package/{src/content → harness}/.opencode/commands/init.md +0 -0
  127. /package/{src/content → harness}/.opencode/commands/make-architecture.md +0 -0
  128. /package/{src/content → harness}/.opencode/commands/make-design.md +0 -0
  129. /package/{src/content → harness}/.opencode/commands/make-engineer.md +0 -0
  130. /package/{src/content → harness}/.opencode/commands/make-evidence-scaffold.md +0 -0
  131. /package/{src/content → harness}/.opencode/commands/make-guardrails.md +0 -0
  132. /package/{src/content → harness}/.opencode/commands/make-user-model.md +0 -0
  133. /package/{src/content → harness}/.opencode/commands/ops-backlog.md +0 -0
  134. /package/{src/content → harness}/.opencode/commands/ops-evidence.md +0 -0
  135. /package/{src/content → harness}/.opencode/commands/ops-review.md +0 -0
  136. /package/{src/content → harness}/.opencode/commands/ops-ship.md +0 -0
  137. /package/{src/content → harness}/.opencode/commands/plan-apply.md +0 -0
  138. /package/{src/content → harness}/.opencode/commands/plan-archive.md +0 -0
  139. /package/{src/content → harness}/.opencode/commands/plan-explore.md +0 -0
  140. /package/{src/content → harness}/.opencode/commands/plan-goal.md +0 -0
  141. /package/{src/content → harness}/.opencode/commands/plan-propose.md +0 -0
  142. /package/{src/content → harness}/.opencode/commands/plan-quick.md +0 -0
  143. /package/{src/content → harness}/.opencode/commands/plan-story.md +0 -0
  144. /package/{src/content → harness}/.opencode/commands/repo-audit.md +0 -0
  145. /package/{src/content → harness}/.opencode/commands/repo-help.md +0 -0
  146. /package/{src/content → harness}/.opencode/commands/repo-initialize.md +0 -0
  147. /package/{src/content → harness}/.opencode/commands/repo-onboard.md +0 -0
  148. /package/{src/content → harness}/.opencode/commands/repo-verify.md +0 -0
  149. /package/{src/content → harness}/.opencode/plugins/pc-subagent-monitor.js +0 -0
  150. /package/{src/content → harness}/.opencode/plugins/pc-system-reminders.js +0 -0
  151. /package/{src/content → harness}/.opencode/tui/pc-subagents.tsx +0 -0
  152. /package/{src/content → harness}/.opencode/tui.json +0 -0
  153. /package/{src/content → harness}/AGENTS.md +0 -0
  154. /package/{src/content → harness}/openspec/changes/archive/.gitkeep +0 -0
  155. /package/{src/content → harness}/openspec/specs/.gitkeep +0 -0
@@ -1,337 +1,368 @@
1
- // pc-subagent-tiers: on startup, reads *-engineer.md templates and creates
2
- // tier variant files with the resolved model, plus the two primary agents the
3
- // user actually talks to. Everything generated here is gitignored and rebuilt
4
- // 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 cannot edit.
10
- // fullstack-engineer.md mode: subagent the shared body of build and plan,
11
- // and the fallback worker.
12
- // *-engineer.md mode: subagent specialists, spawned by task().
13
- // *-engineer.<tier>.md mode: subagent the same specialist pinned to a tier.
14
- //
15
- // Overriding opencode's built-in build and plan (rather than disabling them, as
16
- // earlier versions did) means the agent picker offers exactly two entries and
17
- // both carry our prompt and abilities.
18
-
19
- import fs from "node:fs/promises"
20
- import path from "node:path"
21
-
22
- const TIERS = ["build", "fast", "plan"]
23
-
24
- // The two primaries, and the tier each takes its model from. plan denies edit
25
- // so a planning session cannot mutate the tree; bash stays allowed because the
26
- // planning skills shell out to git and openspec to read state.
27
- //
28
- // Their colours are theme keywords rather than derived hexes, and they are
29
- // fixed: these are the two agents a human picks, so they should look the same
30
- // in every project regardless of the theme in use.
31
- const PRIMARIES = {
32
- build: {
33
- tier: "build",
34
- color: "primary",
35
- description: "Implement changes in this repository. Full write access, spawns specialist engineers for parallel work.",
36
- permission: null,
37
- },
38
- plan: {
39
- tier: "plan",
40
- color: "warning",
41
- description: "Explore and plan without touching the tree. Read-only: proposes work for build to carry out.",
42
- permission: { edit: "deny" },
43
- },
44
- }
45
-
46
- // opencode's theme keywords. Treated as unset so a name-derived hex wins: the
47
- // old /make-engineer template asked whoever ran it to pick one of these and
48
- // avoid collisions by hand, which does not survive more than a few agents.
49
- const THEME_COLORS = new Set(["primary", "secondary", "accent", "success", "warning", "error", "info"])
50
-
51
- // Mirrors src/utils/agent-color.js in the CLI, duplicated because a plugin has
52
- // to stand alone in the consumer repo. agent-color.test.js asserts the two stay
53
- // in step; change one and change the other.
54
- //
55
- // Only the hue is hashed. Fixed saturation and lightness are what keep every
56
- // result usable: hashing those too would eventually produce a near-grey,
57
- // near-black or near-white agent, invisible against one theme or the other.
58
- function agentColor(name) {
59
- let h = 0x811c9dc5
60
- for (let i = 0; i < name.length; i++) {
61
- h ^= name.charCodeAt(i)
62
- h = Math.imul(h, 0x01000193) >>> 0
63
- }
64
- const hue = (h >>> 0) % 360
65
- const sat = 0.68
66
- const light = 0.52
67
- const c = (1 - Math.abs(2 * light - 1)) * sat
68
- const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
69
- const m = light - c / 2
70
- const [r, g, b] =
71
- hue < 60 ? [c, x, 0] :
72
- hue < 120 ? [x, c, 0] :
73
- hue < 180 ? [0, c, x] :
74
- hue < 240 ? [0, x, c] :
75
- hue < 300 ? [x, 0, c] :
76
- [c, 0, x]
77
- const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, "0")
78
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
79
- }
80
-
81
- /** A theme keyword or a missing value yields to the derived hex; a hex stays. */
82
- function shouldDeriveColor(current) {
83
- if (!current) return true
84
- return THEME_COLORS.has(current.trim().toLowerCase())
85
- }
86
-
87
- const FULLSTACK_NAME = "fullstack-engineer"
88
- const FULLSTACK_TEMPLATE = `${FULLSTACK_NAME}.md`
89
-
90
- export const PcSubagentTiers = async ({ directory }) => {
91
- const root = directory || process.cwd()
92
- const agentsDir = path.join(root, ".opencode", "agents")
93
-
94
- async function readJson(filePath) {
95
- try {
96
- return JSON.parse(await fs.readFile(filePath, "utf-8"))
97
- } catch {
98
- return null
99
- }
100
- }
101
-
102
- async function resolveModels() {
103
- const [user, team] = await Promise.all([
104
- readJson(path.join(root, ".opencode", "harness.user.json")),
105
- readJson(path.join(root, ".opencode", "harness.json")),
106
- ])
107
- const models = {}
108
- for (const tier of TIERS) {
109
- models[tier] = user?.models?.[tier] ?? team?.models?.[tier] ?? null
110
- }
111
- return models
112
- }
113
-
114
- async function scanEngineers() {
115
- try {
116
- const entries = await fs.readdir(agentsDir)
117
- return {
118
- templates: entries.filter(f => /^[\w-]+-engineer\.md$/.test(f) && f !== FULLSTACK_TEMPLATE).map(f => f.replace(/\.md$/, "")),
119
- variantFiles: entries.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
120
- hasFullstack: entries.includes(FULLSTACK_TEMPLATE),
121
- }
122
- } catch {
123
- return { templates: [], variantFiles: [], hasFullstack: false }
124
- }
125
- }
126
-
127
- function variantFile(name, tier) {
128
- return `${name}.${tier}.md`
129
- }
130
-
131
- function buildVariant(templateContent, model) {
132
- const fmMatch = templateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
133
- const modelLine = `model: ${model}`
134
- if (!fmMatch) return `---\nmode: subagent\n${modelLine}\n---\n\n${templateContent}`
135
-
136
- let fm = fmMatch[1]
137
- fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode: subagent') : `mode: subagent\n${fm}`
138
- fm = /^model:/m.test(fm) ? fm.replace(/^model:.*$/m, modelLine) : `${modelLine}\n${fm}`
139
-
140
- // Slice at frontmatter end: never String.replace with content-derived
141
- // strings, it matches the wrong occurrence and expands $& sequences.
142
- return `---\n${fm}\n---${templateContent.slice(fmMatch[0].length)}`
143
- }
144
-
145
- // Ensure template files are mode: subagent with no stale model: line. Only
146
- // build and plan are primary; every engineer is reached through task(), never
147
- // picked by a human, so a primary engineer would just clutter the picker.
148
- // Templates must never declare a model either: models resolve per tier at
149
- // startup and are injected into generated files only, so a stale model: (from
150
- // a prior `stampAgentModels` run, or from when these were primary) is
151
- // stripped and the agent falls back to the session model in opencode.jsonc.
152
- function normalizeTemplate(templateContent, name) {
153
- const fmMatch = templateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
154
- if (!fmMatch) return templateContent
155
-
156
- let fm = fmMatch[1]
157
- let changed = false
158
-
159
- if (!/^mode:\s*subagent/m.test(fm)) {
160
- fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode: subagent') : `mode: subagent\n${fm}`
161
- changed = true
162
- }
163
- // Give every agent a colour derived from its name, so the same engineer
164
- // reads the same in any project and two agents never collide by accident.
165
- const currentColor = fm.match(/^color:\s*(.+)$/m)?.[1]
166
- if (name && shouldDeriveColor(currentColor)) {
167
- const derived = `color: ${agentColor(name)}`
168
- fm = /^color:/m.test(fm) ? fm.replace(/^color:.*$/m, derived) : `${fm}\n${derived}`
169
- changed = true
170
- }
171
- if (/^model:/m.test(fm)) {
172
- // Remove the model: line and its trailing newline without leaving a
173
- // leading blank line (model: can be the first frontmatter entry).
174
- fm = fm.replace(/^model:[^\n]*\r?\n?/m, '').replace(/^\r?\n/, '').replace(/\n$/, '')
175
- changed = true
176
- }
177
-
178
- return changed ? `---\n${fm}\n---${templateContent.slice(fmMatch[0].length)}` : templateContent
179
- }
180
-
181
- function descriptionOf(templateContent) {
182
- return templateContent.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? null
183
- }
184
-
185
- // Build a primary from the fullstack body: same identity and the same
186
- // ## Abilities block, with our own frontmatter on top. The body is taken
187
- // verbatim so /make-engineer only has to maintain fullstack-engineer.md and
188
- // both primaries inherit whatever it lists.
189
- function buildPrimary(name, spec, fullstackContent, model) {
190
- const body = fullstackContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim()
191
-
192
- const lines = [
193
- '---',
194
- `description: ${spec.description}`,
195
- 'mode: primary',
196
- ]
197
- if (model) lines.push(`model: ${model}`)
198
- lines.push(`color: ${spec.color}`)
199
- lines.push('permission:')
200
- // plan denies edit; everything else stays allowed so the planning skills can
201
- // still read the tree, shell out to git and openspec, and spawn engineers.
202
- lines.push(` edit: ${spec.permission?.edit ?? 'allow'}`)
203
- for (const key of ['bash', 'read', 'glob', 'grep', 'question', 'todowrite', 'task', 'skill']) {
204
- lines.push(` ${key}: ${spec.permission?.[key] ?? 'allow'}`)
205
- }
206
- lines.push('---')
207
-
208
- return `${lines.join('\n')}\n\n${body}\n`
209
- }
210
-
211
- // Skip writing if the file already has identical content.
212
- async function writeIfChanged(filePath, content) {
213
- try {
214
- if ((await fs.readFile(filePath, "utf-8")) === content) return
215
- } catch {
216
- // file missing, write it
217
- }
218
- const tmpPath = `${filePath}.tmp`
219
- await fs.writeFile(tmpPath, content, "utf-8")
220
- await fs.rename(tmpPath, filePath)
221
- }
222
-
223
- return {
224
- config: async (cfg) => {
225
- try {
226
- const models = await resolveModels()
227
- const available = TIERS.filter(t => models[t])
228
- const { templates, variantFiles, hasFullstack } = await scanEngineers()
229
-
230
- // build.md and plan.md are regenerated from fullstack-engineer.md every
231
- // startup, which is also how an edit to fullstack propagates to both.
232
- // normalizeTemplate runs on it too, so a repo carrying the old
233
- // mode: primary fullstack is migrated in place on first launch.
234
- if (hasFullstack) {
235
- const fullstackPath = path.join(agentsDir, FULLSTACK_TEMPLATE)
236
- const rawFullstack = await fs.readFile(fullstackPath, "utf-8")
237
- const fullstack = normalizeTemplate(rawFullstack, FULLSTACK_NAME)
238
- if (fullstack !== rawFullstack) {
239
- await writeIfChanged(fullstackPath, fullstack)
240
- console.error(`[pc-subagent-tiers] Normalized ${FULLSTACK_TEMPLATE} (mode: subagent)`)
241
- }
242
-
243
- for (const [name, spec] of Object.entries(PRIMARIES)) {
244
- const model = models[spec.tier]
245
- await writeIfChanged(
246
- path.join(agentsDir, `${name}.md`),
247
- buildPrimary(name, spec, fullstack, model),
248
- )
249
- if (cfg?.agent) {
250
- // Override opencode's built-in agent of the same name rather than
251
- // disabling it, so the picker shows ours with our model.
252
- cfg.agent[name] = {
253
- ...cfg.agent[name],
254
- mode: 'primary',
255
- description: spec.description,
256
- color: spec.color,
257
- ...(model ? { model } : {}),
258
- ...(spec.permission ? { permission: { ...cfg.agent[name]?.permission, ...spec.permission } } : {}),
259
- }
260
- }
261
- }
262
- console.error(`[pc-subagent-tiers] Wrote primaries: build (${models.build ?? 'session model'}), plan (${models.plan ?? 'session model'})`)
263
- } else {
264
- console.error(`[pc-subagent-tiers] ${FULLSTACK_TEMPLATE} missing: build and plan not generated`)
265
- }
266
-
267
- const templateContents = await Promise.all(
268
- templates.map(async name => {
269
- const rawContent = await fs.readFile(path.join(agentsDir, `${name}.md`), "utf-8")
270
- const content = normalizeTemplate(rawContent, name)
271
- // If the template had the wrong mode or a stale model:, persist the fix to disk
272
- if (content !== rawContent) {
273
- await writeIfChanged(path.join(agentsDir, `${name}.md`), content)
274
- console.error(`[pc-subagent-tiers] Normalized ${name}.md (mode: subagent, model: removed)`)
275
- }
276
- return { name, content }
277
- })
278
- )
279
-
280
- const keepSet = new Set()
281
- const variantsToWrite = []
282
-
283
- for (const { name, content } of templateContents) {
284
- for (const tier of available) {
285
- const file = variantFile(name, tier)
286
- variantsToWrite.push({
287
- file,
288
- path: path.join(agentsDir, file),
289
- content: buildVariant(content, models[tier]),
290
- name,
291
- tier,
292
- templateContent: content,
293
- })
294
- keepSet.add(file)
295
- }
296
- }
297
-
298
- await Promise.all(variantsToWrite.map(v => writeIfChanged(v.path, v.content)))
299
-
300
- if (cfg?.agent) {
301
- // Ensure base templates are always mode: subagent in-memory. A repo
302
- // upgraded from an earlier version may still have primary in config.
303
- for (const { name } of templateContents) {
304
- if (cfg.agent[name]) {
305
- cfg.agent[name].mode = 'subagent'
306
- }
307
- }
308
- if (cfg.agent[FULLSTACK_NAME]) {
309
- cfg.agent[FULLSTACK_NAME].mode = 'subagent'
310
- }
311
- for (const { name, tier, templateContent } of variantsToWrite) {
312
- const base = cfg.agent[name]
313
- cfg.agent[`${name}.${tier}`] = base
314
- ? { ...base, mode: 'subagent', model: models[tier] }
315
- : {
316
- mode: "subagent",
317
- description: descriptionOf(templateContent) ?? `${name} (${tier} tier)`,
318
- model: models[tier],
319
- }
320
- }
321
- }
322
-
323
- await Promise.all(
324
- variantFiles.filter(f => !keepSet.has(f)).map(f => fs.unlink(path.join(agentsDir, f)).catch(() => {}))
325
- )
326
-
327
- if (variantsToWrite.length > 0) {
328
- console.error(`[pc-subagent-tiers] Created ${variantsToWrite.length} variant files (${templates.length} engineers x ${available.length} tiers)`)
329
- } else if (templates.length > 0) {
330
- console.error(`[pc-subagent-tiers] No variants created. Models: ${JSON.stringify(models)}`)
331
- }
332
- } catch (err) {
333
- console.error(`[pc-subagent-tiers] Error: ${err.message}`)
334
- }
335
- },
336
- }
337
- }
1
+ // pc-subagent-tiers: on startup, reads *-engineer.md templates and creates
2
+ // tier variant files with the resolved model, plus the two primary agents the
3
+ // user actually talks to. Everything generated here is gitignored and rebuilt
4
+ // 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 cannot edit.
10
+ // fullstack-engineer.md mode: subagent the shared body of build and plan,
11
+ // and the fallback worker.
12
+ // *-engineer.md mode: subagent specialists, spawned by task().
13
+ // *-engineer.<tier>.md mode: subagent the same specialist pinned to a tier.
14
+ //
15
+ // Overriding opencode's built-in build and plan (rather than disabling them, as
16
+ // earlier versions did) means the agent picker offers exactly two entries and
17
+ // both carry our prompt and abilities.
18
+
19
+ import fs from "node:fs/promises"
20
+ import path from "node:path"
21
+
22
+ const TIERS = ["build", "fast", "plan"]
23
+
24
+ // The two primaries, and the tier each takes its model from. plan denies edit
25
+ // so a planning session cannot mutate the tree; bash stays allowed because the
26
+ // planning skills shell out to git and openspec to read state.
27
+ //
28
+ // Their colours are theme keywords rather than derived hexes, and they are
29
+ // fixed: these are the two agents a human picks, so they should look the same
30
+ // in every project regardless of the theme in use.
31
+ const PRIMARIES = {
32
+ build: {
33
+ tier: "build",
34
+ color: "primary",
35
+ description: "Implement changes in this repository. Full write access, spawns specialist engineers for parallel work.",
36
+ permission: null,
37
+ },
38
+ plan: {
39
+ tier: "plan",
40
+ color: "warning",
41
+ description: "Explore and plan without touching the tree. Read-only: proposes work for build to carry out.",
42
+ permission: { edit: "deny" },
43
+ },
44
+ }
45
+
46
+ // opencode's theme keywords. Treated as unset so a name-derived hex wins: the
47
+ // old /make-engineer template asked whoever ran it to pick one of these and
48
+ // avoid collisions by hand, which does not survive more than a few agents.
49
+ const THEME_COLORS = new Set(["primary", "secondary", "accent", "success", "warning", "error", "info"])
50
+
51
+ // Mirrors cli/utils/agent-color.js in the CLI, duplicated because a plugin has
52
+ // to stand alone in the consumer repo. agent-color.test.js asserts the two stay
53
+ // in step; change one and change the other.
54
+ //
55
+ // Only the hue is hashed. Fixed saturation and lightness are what keep every
56
+ // result usable: hashing those too would eventually produce a near-grey,
57
+ // near-black or near-white agent, invisible against one theme or the other.
58
+ function agentColor(name) {
59
+ let h = 0x811c9dc5
60
+ for (let i = 0; i < name.length; i++) {
61
+ h ^= name.charCodeAt(i)
62
+ h = Math.imul(h, 0x01000193) >>> 0
63
+ }
64
+ const hue = (h >>> 0) % 360
65
+ const sat = 0.68
66
+ const light = 0.52
67
+ const c = (1 - Math.abs(2 * light - 1)) * sat
68
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
69
+ const m = light - c / 2
70
+ const [r, g, b] =
71
+ hue < 60 ? [c, x, 0] :
72
+ hue < 120 ? [x, c, 0] :
73
+ hue < 180 ? [0, c, x] :
74
+ hue < 240 ? [0, x, c] :
75
+ hue < 300 ? [x, 0, c] :
76
+ [c, 0, x]
77
+ const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, "0")
78
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
79
+ }
80
+
81
+ /** A theme keyword or a missing value yields to the derived hex; a hex stays. */
82
+ function shouldDeriveColor(current) {
83
+ if (!current) return true
84
+ return THEME_COLORS.has(current.trim().toLowerCase())
85
+ }
86
+
87
+ /**
88
+ * Render a colour for YAML frontmatter.
89
+ *
90
+ * A hex has to be quoted. `#` opens a comment in YAML, so `color: #D83155`
91
+ * parses as null and opencode refuses the agent with "Invalid input color".
92
+ * Theme keywords need no quoting and stay bare.
93
+ */
94
+ function yamlColor(value) {
95
+ return value.startsWith("#") ? `"${value}"` : value
96
+ }
97
+
98
+ function unquoteColor(raw) {
99
+ return (raw ?? "").trim().replace(/^["']/, "").replace(/["']$/, "")
100
+ }
101
+
102
+ /**
103
+ * The colour line an agent should have, or null when the current one is already
104
+ * right. Handles both choosing the colour and quoting it, so a file written by
105
+ * an earlier version with a bare hex is repaired in place rather than left
106
+ * broken.
107
+ */
108
+ function colorLineFor(name, rawCurrent) {
109
+ const current = unquoteColor(rawCurrent)
110
+ const target = shouldDeriveColor(current) ? agentColor(name) : current
111
+ const rendered = yamlColor(target)
112
+ return (rawCurrent ?? "").trim() === rendered ? null : `color: ${rendered}`
113
+ }
114
+
115
+ const FULLSTACK_NAME = "fullstack-engineer"
116
+ const FULLSTACK_TEMPLATE = `${FULLSTACK_NAME}.md`
117
+
118
+ export const PcSubagentTiers = async ({ directory }) => {
119
+ const root = directory || process.cwd()
120
+ const agentsDir = path.join(root, ".opencode", "agents")
121
+
122
+ async function readJson(filePath) {
123
+ try {
124
+ return JSON.parse(await fs.readFile(filePath, "utf-8"))
125
+ } catch {
126
+ return null
127
+ }
128
+ }
129
+
130
+ async function resolveModels() {
131
+ const [user, team] = await Promise.all([
132
+ readJson(path.join(root, ".opencode", "harness.user.json")),
133
+ readJson(path.join(root, ".opencode", "harness.json")),
134
+ ])
135
+ const models = {}
136
+ for (const tier of TIERS) {
137
+ models[tier] = user?.models?.[tier] ?? team?.models?.[tier] ?? null
138
+ }
139
+ return models
140
+ }
141
+
142
+ async function scanEngineers() {
143
+ try {
144
+ const entries = await fs.readdir(agentsDir)
145
+ return {
146
+ templates: entries.filter(f => /^[\w-]+-engineer\.md$/.test(f) && f !== FULLSTACK_TEMPLATE).map(f => f.replace(/\.md$/, "")),
147
+ variantFiles: entries.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
148
+ hasFullstack: entries.includes(FULLSTACK_TEMPLATE),
149
+ }
150
+ } catch {
151
+ return { templates: [], variantFiles: [], hasFullstack: false }
152
+ }
153
+ }
154
+
155
+ function variantFile(name, tier) {
156
+ return `${name}.${tier}.md`
157
+ }
158
+
159
+ function buildVariant(templateContent, model) {
160
+ const fmMatch = templateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
161
+ const modelLine = `model: ${model}`
162
+ if (!fmMatch) return `---\nmode: subagent\n${modelLine}\n---\n\n${templateContent}`
163
+
164
+ let fm = fmMatch[1]
165
+ fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode: subagent') : `mode: subagent\n${fm}`
166
+ fm = /^model:/m.test(fm) ? fm.replace(/^model:.*$/m, modelLine) : `${modelLine}\n${fm}`
167
+
168
+ // Slice at frontmatter end: never String.replace with content-derived
169
+ // strings, it matches the wrong occurrence and expands $& sequences.
170
+ return `---\n${fm}\n---${templateContent.slice(fmMatch[0].length)}`
171
+ }
172
+
173
+ // Ensure template files are mode: subagent with no stale model: line. Only
174
+ // build and plan are primary; every engineer is reached through task(), never
175
+ // picked by a human, so a primary engineer would just clutter the picker.
176
+ // Templates must never declare a model either: models resolve per tier at
177
+ // startup and are injected into generated files only, so a stale model: (from
178
+ // a prior `stampAgentModels` run, or from when these were primary) is
179
+ // stripped and the agent falls back to the session model in opencode.jsonc.
180
+ function normalizeTemplate(templateContent, name) {
181
+ const fmMatch = templateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
182
+ if (!fmMatch) return templateContent
183
+
184
+ let fm = fmMatch[1]
185
+ let changed = false
186
+
187
+ if (!/^mode:\s*subagent/m.test(fm)) {
188
+ fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode: subagent') : `mode: subagent\n${fm}`
189
+ changed = true
190
+ }
191
+ // Give every agent a colour derived from its name, so the same engineer
192
+ // reads the same in any project and two agents never collide by accident.
193
+ // colorLineFor also repairs a bare #hex written by an earlier version,
194
+ // which YAML read as a comment and opencode rejected outright.
195
+ if (name) {
196
+ const colorLine = colorLineFor(name, fm.match(/^color:\s*(.+)$/m)?.[1])
197
+ if (colorLine) {
198
+ fm = /^color:/m.test(fm) ? fm.replace(/^color:.*$/m, () => colorLine) : `${fm}\n${colorLine}`
199
+ changed = true
200
+ }
201
+ }
202
+ if (/^model:/m.test(fm)) {
203
+ // Remove the model: line and its trailing newline without leaving a
204
+ // leading blank line (model: can be the first frontmatter entry).
205
+ fm = fm.replace(/^model:[^\n]*\r?\n?/m, '').replace(/^\r?\n/, '').replace(/\n$/, '')
206
+ changed = true
207
+ }
208
+
209
+ return changed ? `---\n${fm}\n---${templateContent.slice(fmMatch[0].length)}` : templateContent
210
+ }
211
+
212
+ function descriptionOf(templateContent) {
213
+ return templateContent.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? null
214
+ }
215
+
216
+ // Build a primary from the fullstack body: same identity and the same
217
+ // ## Abilities block, with our own frontmatter on top. The body is taken
218
+ // verbatim so /make-engineer only has to maintain fullstack-engineer.md and
219
+ // both primaries inherit whatever it lists.
220
+ function buildPrimary(name, spec, fullstackContent, model) {
221
+ const body = fullstackContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim()
222
+
223
+ const lines = [
224
+ '---',
225
+ `description: ${spec.description}`,
226
+ 'mode: primary',
227
+ ]
228
+ if (model) lines.push(`model: ${model}`)
229
+ lines.push(`color: ${yamlColor(spec.color)}`)
230
+ lines.push('permission:')
231
+ // plan denies edit; everything else stays allowed so the planning skills can
232
+ // still read the tree, shell out to git and openspec, and spawn engineers.
233
+ lines.push(` edit: ${spec.permission?.edit ?? 'allow'}`)
234
+ for (const key of ['bash', 'read', 'glob', 'grep', 'question', 'todowrite', 'task', 'skill']) {
235
+ lines.push(` ${key}: ${spec.permission?.[key] ?? 'allow'}`)
236
+ }
237
+ lines.push('---')
238
+
239
+ return `${lines.join('\n')}\n\n${body}\n`
240
+ }
241
+
242
+ // Skip writing if the file already has identical content.
243
+ async function writeIfChanged(filePath, content) {
244
+ try {
245
+ if ((await fs.readFile(filePath, "utf-8")) === content) return
246
+ } catch {
247
+ // file missing, write it
248
+ }
249
+ const tmpPath = `${filePath}.tmp`
250
+ await fs.writeFile(tmpPath, content, "utf-8")
251
+ await fs.rename(tmpPath, filePath)
252
+ }
253
+
254
+ return {
255
+ config: async (cfg) => {
256
+ try {
257
+ const models = await resolveModels()
258
+ const available = TIERS.filter(t => models[t])
259
+ const { templates, variantFiles, hasFullstack } = await scanEngineers()
260
+
261
+ // build.md and plan.md are regenerated from fullstack-engineer.md every
262
+ // startup, which is also how an edit to fullstack propagates to both.
263
+ // normalizeTemplate runs on it too, so a repo carrying the old
264
+ // mode: primary fullstack is migrated in place on first launch.
265
+ if (hasFullstack) {
266
+ const fullstackPath = path.join(agentsDir, FULLSTACK_TEMPLATE)
267
+ const rawFullstack = await fs.readFile(fullstackPath, "utf-8")
268
+ const fullstack = normalizeTemplate(rawFullstack, FULLSTACK_NAME)
269
+ if (fullstack !== rawFullstack) {
270
+ await writeIfChanged(fullstackPath, fullstack)
271
+ console.error(`[pc-subagent-tiers] Normalized ${FULLSTACK_TEMPLATE} (mode: subagent)`)
272
+ }
273
+
274
+ for (const [name, spec] of Object.entries(PRIMARIES)) {
275
+ const model = models[spec.tier]
276
+ await writeIfChanged(
277
+ path.join(agentsDir, `${name}.md`),
278
+ buildPrimary(name, spec, fullstack, model),
279
+ )
280
+ if (cfg?.agent) {
281
+ // Override opencode's built-in agent of the same name rather than
282
+ // disabling it, so the picker shows ours with our model.
283
+ cfg.agent[name] = {
284
+ ...cfg.agent[name],
285
+ mode: 'primary',
286
+ description: spec.description,
287
+ color: spec.color,
288
+ ...(model ? { model } : {}),
289
+ ...(spec.permission ? { permission: { ...cfg.agent[name]?.permission, ...spec.permission } } : {}),
290
+ }
291
+ }
292
+ }
293
+ console.error(`[pc-subagent-tiers] Wrote primaries: build (${models.build ?? 'session model'}), plan (${models.plan ?? 'session model'})`)
294
+ } else {
295
+ console.error(`[pc-subagent-tiers] ${FULLSTACK_TEMPLATE} missing: build and plan not generated`)
296
+ }
297
+
298
+ const templateContents = await Promise.all(
299
+ templates.map(async name => {
300
+ const rawContent = await fs.readFile(path.join(agentsDir, `${name}.md`), "utf-8")
301
+ const content = normalizeTemplate(rawContent, name)
302
+ // If the template had the wrong mode or a stale model:, persist the fix to disk
303
+ if (content !== rawContent) {
304
+ await writeIfChanged(path.join(agentsDir, `${name}.md`), content)
305
+ console.error(`[pc-subagent-tiers] Normalized ${name}.md (mode: subagent, model: removed)`)
306
+ }
307
+ return { name, content }
308
+ })
309
+ )
310
+
311
+ const keepSet = new Set()
312
+ const variantsToWrite = []
313
+
314
+ for (const { name, content } of templateContents) {
315
+ for (const tier of available) {
316
+ const file = variantFile(name, tier)
317
+ variantsToWrite.push({
318
+ file,
319
+ path: path.join(agentsDir, file),
320
+ content: buildVariant(content, models[tier]),
321
+ name,
322
+ tier,
323
+ templateContent: content,
324
+ })
325
+ keepSet.add(file)
326
+ }
327
+ }
328
+
329
+ await Promise.all(variantsToWrite.map(v => writeIfChanged(v.path, v.content)))
330
+
331
+ if (cfg?.agent) {
332
+ // Ensure base templates are always mode: subagent in-memory. A repo
333
+ // upgraded from an earlier version may still have primary in config.
334
+ for (const { name } of templateContents) {
335
+ if (cfg.agent[name]) {
336
+ cfg.agent[name].mode = 'subagent'
337
+ }
338
+ }
339
+ if (cfg.agent[FULLSTACK_NAME]) {
340
+ cfg.agent[FULLSTACK_NAME].mode = 'subagent'
341
+ }
342
+ for (const { name, tier, templateContent } of variantsToWrite) {
343
+ const base = cfg.agent[name]
344
+ cfg.agent[`${name}.${tier}`] = base
345
+ ? { ...base, mode: 'subagent', model: models[tier] }
346
+ : {
347
+ mode: "subagent",
348
+ description: descriptionOf(templateContent) ?? `${name} (${tier} tier)`,
349
+ model: models[tier],
350
+ }
351
+ }
352
+ }
353
+
354
+ await Promise.all(
355
+ variantFiles.filter(f => !keepSet.has(f)).map(f => fs.unlink(path.join(agentsDir, f)).catch(() => {}))
356
+ )
357
+
358
+ if (variantsToWrite.length > 0) {
359
+ console.error(`[pc-subagent-tiers] Created ${variantsToWrite.length} variant files (${templates.length} engineers x ${available.length} tiers)`)
360
+ } else if (templates.length > 0) {
361
+ console.error(`[pc-subagent-tiers] No variants created. Models: ${JSON.stringify(models)}`)
362
+ }
363
+ } catch (err) {
364
+ console.error(`[pc-subagent-tiers] Error: ${err.message}`)
365
+ }
366
+ },
367
+ }
368
+ }