@plainconceptsplatform/agent-harness 2.2.1 → 2.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.
@@ -1,281 +1,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
- const PRIMARIES = {
28
- build: {
29
- tier: "build",
30
- description: "Implement changes in this repository. Full write access, spawns specialist engineers for parallel work.",
31
- permission: null,
32
- },
33
- plan: {
34
- tier: "plan",
35
- description: "Explore and plan without touching the tree. Read-only: proposes work for build to carry out.",
36
- permission: { edit: "deny" },
37
- },
38
- }
39
-
40
- const FULLSTACK_NAME = "fullstack-engineer"
41
- const FULLSTACK_TEMPLATE = `${FULLSTACK_NAME}.md`
42
-
43
- export const PcSubagentTiers = async ({ directory }) => {
44
- const root = directory || process.cwd()
45
- const agentsDir = path.join(root, ".opencode", "agents")
46
-
47
- async function readJson(filePath) {
48
- try {
49
- return JSON.parse(await fs.readFile(filePath, "utf-8"))
50
- } catch {
51
- return null
52
- }
53
- }
54
-
55
- async function resolveModels() {
56
- const [user, team] = await Promise.all([
57
- readJson(path.join(root, ".opencode", "harness.user.json")),
58
- readJson(path.join(root, ".opencode", "harness.json")),
59
- ])
60
- const models = {}
61
- for (const tier of TIERS) {
62
- models[tier] = user?.models?.[tier] ?? team?.models?.[tier] ?? null
63
- }
64
- return models
65
- }
66
-
67
- async function scanEngineers() {
68
- try {
69
- const entries = await fs.readdir(agentsDir)
70
- return {
71
- templates: entries.filter(f => /^[\w-]+-engineer\.md$/.test(f) && f !== FULLSTACK_TEMPLATE).map(f => f.replace(/\.md$/, "")),
72
- variantFiles: entries.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
73
- hasFullstack: entries.includes(FULLSTACK_TEMPLATE),
74
- }
75
- } catch {
76
- return { templates: [], variantFiles: [], hasFullstack: false }
77
- }
78
- }
79
-
80
- function variantFile(name, tier) {
81
- return `${name}.${tier}.md`
82
- }
83
-
84
- function buildVariant(templateContent, model) {
85
- const fmMatch = templateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
86
- const modelLine = `model: ${model}`
87
- if (!fmMatch) return `---\nmode: subagent\n${modelLine}\n---\n\n${templateContent}`
88
-
89
- let fm = fmMatch[1]
90
- fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode: subagent') : `mode: subagent\n${fm}`
91
- fm = /^model:/m.test(fm) ? fm.replace(/^model:.*$/m, modelLine) : `${modelLine}\n${fm}`
92
-
93
- // Slice at frontmatter end: never String.replace with content-derived
94
- // strings, it matches the wrong occurrence and expands $& sequences.
95
- return `---\n${fm}\n---${templateContent.slice(fmMatch[0].length)}`
96
- }
97
-
98
- // Ensure template files are mode: subagent with no stale model: line. Only
99
- // build and plan are primary; every engineer is reached through task(), never
100
- // picked by a human, so a primary engineer would just clutter the picker.
101
- // Templates must never declare a model either: models resolve per tier at
102
- // startup and are injected into generated files only, so a stale model: (from
103
- // a prior `stampAgentModels` run, or from when these were primary) is
104
- // stripped and the agent falls back to the session model in opencode.jsonc.
105
- function normalizeTemplate(templateContent) {
106
- const fmMatch = templateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
107
- if (!fmMatch) return templateContent
108
-
109
- let fm = fmMatch[1]
110
- let changed = false
111
-
112
- if (!/^mode:\s*subagent/m.test(fm)) {
113
- fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode: subagent') : `mode: subagent\n${fm}`
114
- changed = true
115
- }
116
- if (/^model:/m.test(fm)) {
117
- // Remove the model: line and its trailing newline without leaving a
118
- // leading blank line (model: can be the first frontmatter entry).
119
- fm = fm.replace(/^model:[^\n]*\r?\n?/m, '').replace(/^\r?\n/, '').replace(/\n$/, '')
120
- changed = true
121
- }
122
-
123
- return changed ? `---\n${fm}\n---${templateContent.slice(fmMatch[0].length)}` : templateContent
124
- }
125
-
126
- function descriptionOf(templateContent) {
127
- return templateContent.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? null
128
- }
129
-
130
- // Build a primary from the fullstack body: same identity and the same
131
- // ## Abilities block, with our own frontmatter on top. The body is taken
132
- // verbatim so /make-engineer only has to maintain fullstack-engineer.md and
133
- // both primaries inherit whatever it lists.
134
- function buildPrimary(name, spec, fullstackContent, model) {
135
- const body = fullstackContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim()
136
-
137
- const lines = [
138
- '---',
139
- `description: ${spec.description}`,
140
- 'mode: primary',
141
- ]
142
- if (model) lines.push(`model: ${model}`)
143
- lines.push('color: warning')
144
- lines.push('permission:')
145
- // plan denies edit; everything else stays allowed so the planning skills can
146
- // still read the tree, shell out to git and openspec, and spawn engineers.
147
- lines.push(` edit: ${spec.permission?.edit ?? 'allow'}`)
148
- for (const key of ['bash', 'read', 'glob', 'grep', 'question', 'todowrite', 'task', 'skill']) {
149
- lines.push(` ${key}: ${spec.permission?.[key] ?? 'allow'}`)
150
- }
151
- lines.push('---')
152
-
153
- return `${lines.join('\n')}\n\n${body}\n`
154
- }
155
-
156
- // Skip writing if the file already has identical content.
157
- async function writeIfChanged(filePath, content) {
158
- try {
159
- if ((await fs.readFile(filePath, "utf-8")) === content) return
160
- } catch {
161
- // file missing, write it
162
- }
163
- const tmpPath = `${filePath}.tmp`
164
- await fs.writeFile(tmpPath, content, "utf-8")
165
- await fs.rename(tmpPath, filePath)
166
- }
167
-
168
- return {
169
- config: async (cfg) => {
170
- try {
171
- const models = await resolveModels()
172
- const available = TIERS.filter(t => models[t])
173
- const { templates, variantFiles, hasFullstack } = await scanEngineers()
174
-
175
- // build.md and plan.md are regenerated from fullstack-engineer.md every
176
- // startup, which is also how an edit to fullstack propagates to both.
177
- // normalizeTemplate runs on it too, so a repo carrying the old
178
- // mode: primary fullstack is migrated in place on first launch.
179
- if (hasFullstack) {
180
- const fullstackPath = path.join(agentsDir, FULLSTACK_TEMPLATE)
181
- const rawFullstack = await fs.readFile(fullstackPath, "utf-8")
182
- const fullstack = normalizeTemplate(rawFullstack)
183
- if (fullstack !== rawFullstack) {
184
- await writeIfChanged(fullstackPath, fullstack)
185
- console.error(`[pc-subagent-tiers] Normalized ${FULLSTACK_TEMPLATE} (mode: subagent)`)
186
- }
187
-
188
- for (const [name, spec] of Object.entries(PRIMARIES)) {
189
- const model = models[spec.tier]
190
- await writeIfChanged(
191
- path.join(agentsDir, `${name}.md`),
192
- buildPrimary(name, spec, fullstack, model),
193
- )
194
- if (cfg?.agent) {
195
- // Override opencode's built-in agent of the same name rather than
196
- // disabling it, so the picker shows ours with our model.
197
- cfg.agent[name] = {
198
- ...cfg.agent[name],
199
- mode: 'primary',
200
- description: spec.description,
201
- ...(model ? { model } : {}),
202
- ...(spec.permission ? { permission: { ...cfg.agent[name]?.permission, ...spec.permission } } : {}),
203
- }
204
- }
205
- }
206
- console.error(`[pc-subagent-tiers] Wrote primaries: build (${models.build ?? 'session model'}), plan (${models.plan ?? 'session model'})`)
207
- } else {
208
- console.error(`[pc-subagent-tiers] ${FULLSTACK_TEMPLATE} missing: build and plan not generated`)
209
- }
210
-
211
- const templateContents = await Promise.all(
212
- templates.map(async name => {
213
- const rawContent = await fs.readFile(path.join(agentsDir, `${name}.md`), "utf-8")
214
- const content = normalizeTemplate(rawContent)
215
- // If the template had the wrong mode or a stale model:, persist the fix to disk
216
- if (content !== rawContent) {
217
- await writeIfChanged(path.join(agentsDir, `${name}.md`), content)
218
- console.error(`[pc-subagent-tiers] Normalized ${name}.md (mode: subagent, model: removed)`)
219
- }
220
- return { name, content }
221
- })
222
- )
223
-
224
- const keepSet = new Set()
225
- const variantsToWrite = []
226
-
227
- for (const { name, content } of templateContents) {
228
- for (const tier of available) {
229
- const file = variantFile(name, tier)
230
- variantsToWrite.push({
231
- file,
232
- path: path.join(agentsDir, file),
233
- content: buildVariant(content, models[tier]),
234
- name,
235
- tier,
236
- templateContent: content,
237
- })
238
- keepSet.add(file)
239
- }
240
- }
241
-
242
- await Promise.all(variantsToWrite.map(v => writeIfChanged(v.path, v.content)))
243
-
244
- if (cfg?.agent) {
245
- // Ensure base templates are always mode: subagent in-memory. A repo
246
- // upgraded from an earlier version may still have primary in config.
247
- for (const { name } of templateContents) {
248
- if (cfg.agent[name]) {
249
- cfg.agent[name].mode = 'subagent'
250
- }
251
- }
252
- if (cfg.agent[FULLSTACK_NAME]) {
253
- cfg.agent[FULLSTACK_NAME].mode = 'subagent'
254
- }
255
- for (const { name, tier, templateContent } of variantsToWrite) {
256
- const base = cfg.agent[name]
257
- cfg.agent[`${name}.${tier}`] = base
258
- ? { ...base, mode: 'subagent', model: models[tier] }
259
- : {
260
- mode: "subagent",
261
- description: descriptionOf(templateContent) ?? `${name} (${tier} tier)`,
262
- model: models[tier],
263
- }
264
- }
265
- }
266
-
267
- await Promise.all(
268
- variantFiles.filter(f => !keepSet.has(f)).map(f => fs.unlink(path.join(agentsDir, f)).catch(() => {}))
269
- )
270
-
271
- if (variantsToWrite.length > 0) {
272
- console.error(`[pc-subagent-tiers] Created ${variantsToWrite.length} variant files (${templates.length} engineers x ${available.length} tiers)`)
273
- } else if (templates.length > 0) {
274
- console.error(`[pc-subagent-tiers] No variants created. Models: ${JSON.stringify(models)}`)
275
- }
276
- } catch (err) {
277
- console.error(`[pc-subagent-tiers] Error: ${err.message}`)
278
- }
279
- },
280
- }
281
- }
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
+ }