@plainconceptsplatform/agent-harness 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/package.json +1 -1
- package/src/commands/migrate.js +294 -0
- package/src/content/.agents/skills/pc-make-engineer/SKILL.md +4 -4
- package/src/content/.agents/skills/pc-make-engineer/template.md +3 -3
- package/src/content/.agents/skills/pc-plan-propose/SKILL.md +1 -1
- package/src/content/.agents/skills/pc-repo-onboard/SKILL.md +1 -1
- package/src/content/.opencode/_gitignore +2 -0
- package/src/content/.opencode/plugins/pc-subagent-tiers.js +117 -15
- package/src/content/AGENTS.md +1 -1
- package/src/content/opencode.jsonc +39 -31
- package/src/index.js +13 -4
- package/src/steps/copy/fullstack-engineer.js +4 -2
- package/src/steps/copy/opencode-json.js +27 -9
- package/src/steps/copy/skills.js +2 -2
- package/src/utils/paths.js +3 -0
package/README.md
CHANGED
|
@@ -164,12 +164,14 @@ Agents define _how to work_. They are universal personas (same behavior across p
|
|
|
164
164
|
Current baseline uses a generic execution model:
|
|
165
165
|
|
|
166
166
|
```
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
build primary. Implements. Full write access. The default.
|
|
168
|
+
plan primary. Same body as build, but cannot edit files.
|
|
169
|
+
fullstack-engineer subagent. The body build and plan share, and the fallback worker.
|
|
170
|
+
*-engineer subagent. User-created specialists, spawned for parallel implementation.
|
|
171
|
+
*-engineer.<tier> subagent. The same specialist pinned to a plan/build/fast model.
|
|
170
172
|
```
|
|
171
173
|
|
|
172
|
-
`fullstack-engineer`
|
|
174
|
+
`build` and `plan` are the only agents a human selects, and they are overrides of opencode's own two primaries rather than new names. The `pc-subagent-tiers` plugin regenerates both from `fullstack-engineer.md` on every startup, so they always carry the current abilities, each on its own tier model. `plan` differs from `build` in one frontmatter line: `edit: deny`. It can still read the tree, shell out to git and openspec, and spawn engineers, so planning works and cannot write. Everything else is `mode: subagent` and reached through `task()`, never picked from the agent list. Project-specific specialization comes from user-created engineers via `/make-engineer`. During `/plan-apply` the lead inspects the engineers that actually exist in `.opencode/agents/` and spawns matching specialists. Prefer a specialist over `fullstack-engineer`; if none matches, create one.
|
|
173
175
|
|
|
174
176
|
### Skills, platform knowledge
|
|
175
177
|
|
|
@@ -289,7 +291,8 @@ your-project/
|
|
|
289
291
|
│ ├── opencode.json ← default model + plugin config
|
|
290
292
|
│ ├── harness.json ← harness config: platform, models, maxConcurrentAgents
|
|
291
293
|
│ ├── harness-managed.json ← hashes of every managed file, so "update" spares your edits
|
|
292
|
-
│ ├── agents/ ←
|
|
294
|
+
│ ├── agents/ ← build.md + plan.md (generated primaries), fullstack-engineer,
|
|
295
|
+
│ │ and user-created *-engineer files (all subagents)
|
|
293
296
|
│ ├── tui.json ← registers the Subagents sidebar panel
|
|
294
297
|
│ ├── tui/
|
|
295
298
|
│ │ └── pc-subagents.tsx ← TUI plugin: live Subagents panel in the sidebar
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/agent-harness",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Installs the Plain Concepts Platform Harness into any codebase, and keeps it up to date. Wires OpenCode, OpenSpec, codegraph, and agentmemory into a multi-agent workflow that runs on native parallel subagents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { execa } from 'execa'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { GENERATABLE_SKILLS, SKILL_RENAME } from '../steps/copy/skills.js'
|
|
7
|
+
import { header, info, success, warn } from '../utils/exec.js'
|
|
8
|
+
import { CONFIG_FILE, MANIFEST_FILE, OPENCODE_DIR, USER_CONFIG_FILE } from '../utils/paths.js'
|
|
9
|
+
import { exit } from '../utils/process.js'
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../content/.agents/skills')
|
|
13
|
+
|
|
14
|
+
// State files, old name to new. The run-state file is deleted rather than moved:
|
|
15
|
+
// it is live wave state owned by the monitor plugin and is rebuilt on demand.
|
|
16
|
+
const STATE_MOVES = [
|
|
17
|
+
['opencode-onboard.json', CONFIG_FILE],
|
|
18
|
+
['opencode-onboard.user.json', USER_CONFIG_FILE],
|
|
19
|
+
['opencode-onboard-managed.json', MANIFEST_FILE],
|
|
20
|
+
]
|
|
21
|
+
const STATE_DELETES = ['.ob-run.json']
|
|
22
|
+
|
|
23
|
+
const TEXT_EXTENSIONS = new Set(['.md', '.mdx', '.json', '.jsonc', '.js', '.ts', '.tsx', '.yaml', '.yml', '.txt'])
|
|
24
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage'])
|
|
25
|
+
|
|
26
|
+
// Archived OpenSpec changes are an audit trail: those proposals really did
|
|
27
|
+
// reference ob-* skills when they were written. Rewriting them would make the
|
|
28
|
+
// record say something that was never true, so the archive is left verbatim.
|
|
29
|
+
const ARCHIVE_SEGMENTS = ['openspec', 'changes', 'archive']
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The skills this package ships, expressed under the old `ob-` prefix.
|
|
33
|
+
*
|
|
34
|
+
* A shipped skill is safe to delete because `update` reinstalls it as `pc-*`
|
|
35
|
+
* with current content, and that is strictly better than renaming a v1 copy we
|
|
36
|
+
* would then have to convince the manifest to overwrite. Anything NOT in this
|
|
37
|
+
* set is project-owned and must be renamed with its content intact.
|
|
38
|
+
*/
|
|
39
|
+
async function shippedLegacyNames() {
|
|
40
|
+
const entries = await fse.readdir(CONTENT_SKILLS_DIR, { withFileTypes: true }).catch(() => [])
|
|
41
|
+
const names = new Set()
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.isDirectory()) continue
|
|
44
|
+
// Platform variants install under a generic name, so both spellings ship.
|
|
45
|
+
for (const name of [entry.name, SKILL_RENAME[entry.name]].filter(Boolean)) {
|
|
46
|
+
if (name.startsWith('pc-')) names.add(`ob-${name.slice(3)}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return names
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Generated skills carry project content even though the placeholder ships. */
|
|
53
|
+
function isGeneratable(legacyName) {
|
|
54
|
+
return GENERATABLE_SKILLS.has(`pc-${legacyName.slice(3)}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isArchived(filePath) {
|
|
58
|
+
// Split on both separators: path.join yields backslashes on Windows, and a
|
|
59
|
+
// separator-blind check here would let the archive be rewritten there only.
|
|
60
|
+
const parts = filePath.split(/[\\/]/)
|
|
61
|
+
const at = parts.indexOf(ARCHIVE_SEGMENTS[0])
|
|
62
|
+
if (at === -1) return false
|
|
63
|
+
return ARCHIVE_SEGMENTS.every((segment, i) => parts[at + i] === segment)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function collectTextFiles(dir, acc = []) {
|
|
67
|
+
const entries = await fse.readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
70
|
+
const full = path.join(dir, entry.name)
|
|
71
|
+
if (isArchived(full)) continue
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
await collectTextFiles(full, acc)
|
|
74
|
+
} else if (TEXT_EXTENSIONS.has(path.extname(entry.name)) || entry.name === '.gitignore') {
|
|
75
|
+
acc.push(full)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return acc
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rewrite the identifiers v2 renamed.
|
|
83
|
+
*
|
|
84
|
+
* `\b` matters: a bare `ob-` also appears inside words such as `{blob-url}` in
|
|
85
|
+
* the shipped GitHub ship fragment, and rewriting that silently corrupts the
|
|
86
|
+
* command it belongs to.
|
|
87
|
+
*/
|
|
88
|
+
function rewriteIdentifiers(text) {
|
|
89
|
+
return text
|
|
90
|
+
.replace(/\bob-/g, 'pc-')
|
|
91
|
+
.replace(/\bOB-/g, 'PC-')
|
|
92
|
+
.replace(/opencode-onboard-managed\.json/g, MANIFEST_FILE)
|
|
93
|
+
.replace(/opencode-onboard\.user\.json/g, USER_CONFIG_FILE)
|
|
94
|
+
.replace(/opencode-onboard\.json/g, CONFIG_FILE)
|
|
95
|
+
.replace(/@plainconceptsplatform\/opencode-onboard/g, '@plainconceptsplatform/agent-harness')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Base engineer templates and fullstack are subagents in v2. */
|
|
99
|
+
function demoteToSubagent(text) {
|
|
100
|
+
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
101
|
+
if (!fm) return text
|
|
102
|
+
if (!/^mode:\s*primary/m.test(fm[1])) return text
|
|
103
|
+
const patched = fm[1].replace(/^mode:.*$/m, 'mode: subagent')
|
|
104
|
+
return `---\n${patched}\n---${text.slice(fm[0].length)}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function isDirty(cwd) {
|
|
108
|
+
const result = await execa('git', ['status', '--porcelain'], { cwd, reject: false })
|
|
109
|
+
if (result.exitCode !== 0) return false
|
|
110
|
+
return result.stdout.trim().length > 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function planMigration(cwd = process.cwd()) {
|
|
114
|
+
const opencodeDir = path.join(cwd, OPENCODE_DIR)
|
|
115
|
+
const skillsDir = path.join(cwd, '.agents', 'skills')
|
|
116
|
+
|
|
117
|
+
const moves = []
|
|
118
|
+
for (const [from, to] of STATE_MOVES) {
|
|
119
|
+
if (await fse.pathExists(path.join(opencodeDir, from))) moves.push([from, to])
|
|
120
|
+
}
|
|
121
|
+
const deletes = []
|
|
122
|
+
for (const name of STATE_DELETES) {
|
|
123
|
+
if (await fse.pathExists(path.join(opencodeDir, name))) deletes.push(name)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const shipped = await shippedLegacyNames()
|
|
127
|
+
const entries = (await fse.readdir(skillsDir).catch(() => [])).filter(e => e.startsWith('ob-'))
|
|
128
|
+
|
|
129
|
+
const skillsToRename = []
|
|
130
|
+
const skillsToDrop = []
|
|
131
|
+
for (const name of entries) {
|
|
132
|
+
const stat = await fse.stat(path.join(skillsDir, name)).catch(() => null)
|
|
133
|
+
if (!stat?.isDirectory()) continue
|
|
134
|
+
// Generated content wins over "it ships": the placeholder shipped, the
|
|
135
|
+
// 150-line project guardrails that replaced it did not.
|
|
136
|
+
if (isGeneratable(name) || !shipped.has(name)) skillsToRename.push([name, `pc-${name.slice(3)}`])
|
|
137
|
+
else skillsToDrop.push(name)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const agentsDir = path.join(cwd, OPENCODE_DIR, 'agents')
|
|
141
|
+
const agentFiles = await fse.readdir(agentsDir).catch(() => [])
|
|
142
|
+
const staleVariants = agentFiles.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f))
|
|
143
|
+
|
|
144
|
+
// Plugins and the TUI panel are shipped code, and update installs the pc-*
|
|
145
|
+
// copies alongside rather than over the ob-* ones. Left in place, both
|
|
146
|
+
// generations load: two tier plugins writing the same variant files and two
|
|
147
|
+
// monitors writing the same run state.
|
|
148
|
+
const stalePlugins = []
|
|
149
|
+
for (const dir of ['plugins', 'tui']) {
|
|
150
|
+
const full = path.join(cwd, OPENCODE_DIR, dir)
|
|
151
|
+
for (const file of await fse.readdir(full).catch(() => [])) {
|
|
152
|
+
if (file.startsWith('ob-')) stalePlugins.push(path.join(dir, file))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { moves, deletes, skillsToRename, skillsToDrop, staleVariants, stalePlugins }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function runMigrate({ cwd = process.cwd(), force = false, dryRun = false } = {}) {
|
|
160
|
+
header('Migrating from opencode-onboard v1')
|
|
161
|
+
|
|
162
|
+
const opencodeDir = path.join(cwd, OPENCODE_DIR)
|
|
163
|
+
if (await fse.pathExists(path.join(opencodeDir, CONFIG_FILE))) {
|
|
164
|
+
success(`${OPENCODE_DIR}/${CONFIG_FILE} already exists: this project is already on v2.`)
|
|
165
|
+
return { migrated: false, alreadyV2: true }
|
|
166
|
+
}
|
|
167
|
+
if (!await fse.pathExists(path.join(opencodeDir, 'opencode-onboard.json'))) {
|
|
168
|
+
warn(`No ${OPENCODE_DIR}/opencode-onboard.json found: nothing to migrate.`)
|
|
169
|
+
return { migrated: false }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The migration rewrites files in place across the repo. A clean tree means
|
|
173
|
+
// `git checkout .` is always a complete undo.
|
|
174
|
+
if (!force && !dryRun && await isDirty(cwd)) {
|
|
175
|
+
warn('Working tree has uncommitted changes.')
|
|
176
|
+
warn('Commit or stash them first so this migration can be reverted with `git checkout .`, or pass --force.')
|
|
177
|
+
return { migrated: false, dirty: true }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const plan = await planMigration(cwd)
|
|
181
|
+
|
|
182
|
+
for (const [from, to] of plan.moves) info(`${from} -> ${to}`)
|
|
183
|
+
for (const name of plan.deletes) info(`${name} -> removed (rebuilt on demand)`)
|
|
184
|
+
for (const [from, to] of plan.skillsToRename) info(`${from}/ -> ${to}/ (content kept)`)
|
|
185
|
+
if (plan.skillsToDrop.length > 0) {
|
|
186
|
+
info(`${plan.skillsToDrop.length} shipped skill(s) removed, reinstalled as pc-* by update`)
|
|
187
|
+
}
|
|
188
|
+
if (plan.staleVariants.length > 0) {
|
|
189
|
+
info(`${plan.staleVariants.length} tier variant(s) removed, regenerated at startup`)
|
|
190
|
+
}
|
|
191
|
+
if (plan.stalePlugins.length > 0) {
|
|
192
|
+
info(`${plan.stalePlugins.length} ob- plugin/tui file(s) removed, replaced by pc-* on update`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (dryRun) {
|
|
196
|
+
console.log()
|
|
197
|
+
info('Dry run: nothing written.')
|
|
198
|
+
return { migrated: false, dryRun: true, plan }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 1. State files. The manifest keeps its hashes; only its keys are renamed,
|
|
202
|
+
// so update still recognizes untouched files and refreshes them.
|
|
203
|
+
for (const [from, to] of plan.moves) {
|
|
204
|
+
const fromPath = path.join(opencodeDir, from)
|
|
205
|
+
const toPath = path.join(opencodeDir, to)
|
|
206
|
+
if (to === MANIFEST_FILE) {
|
|
207
|
+
const manifest = await fse.readJson(fromPath).catch(() => null)
|
|
208
|
+
if (manifest?.files && typeof manifest.files === 'object') {
|
|
209
|
+
manifest.files = Object.fromEntries(
|
|
210
|
+
Object.entries(manifest.files).map(([key, value]) => [rewriteIdentifiers(key), value]),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
await fse.writeJson(toPath, manifest ?? { version: 1, files: {} }, { spaces: 2 })
|
|
214
|
+
await fse.remove(fromPath)
|
|
215
|
+
} else {
|
|
216
|
+
await fse.move(fromPath, toPath, { overwrite: true })
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const name of plan.deletes) await fse.remove(path.join(opencodeDir, name))
|
|
220
|
+
|
|
221
|
+
// 2. Skills: rename what holds project content, drop what update reinstalls.
|
|
222
|
+
const skillsDir = path.join(cwd, '.agents', 'skills')
|
|
223
|
+
for (const [from, to] of plan.skillsToRename) {
|
|
224
|
+
await fse.move(path.join(skillsDir, from), path.join(skillsDir, to), { overwrite: true })
|
|
225
|
+
}
|
|
226
|
+
for (const name of plan.skillsToDrop) await fse.remove(path.join(skillsDir, name))
|
|
227
|
+
|
|
228
|
+
// 3. Stale tier variants: the plugin rebuilds these from the base templates.
|
|
229
|
+
const agentsDir = path.join(opencodeDir, 'agents')
|
|
230
|
+
for (const file of plan.staleVariants) await fse.remove(path.join(agentsDir, file))
|
|
231
|
+
|
|
232
|
+
// 3b. Stale plugins and TUI panel, so only one generation loads.
|
|
233
|
+
for (const relative of plan.stalePlugins) await fse.remove(path.join(opencodeDir, relative))
|
|
234
|
+
|
|
235
|
+
// 4. Rewrite identifiers everywhere that survives, so preserved skills and
|
|
236
|
+
// custom agents stop pointing at names that no longer exist.
|
|
237
|
+
let rewritten = 0
|
|
238
|
+
for (const dir of [skillsDir, opencodeDir, path.join(cwd, 'openspec')]) {
|
|
239
|
+
if (!await fse.pathExists(dir)) continue
|
|
240
|
+
for (const file of await collectTextFiles(dir)) {
|
|
241
|
+
const before = await fse.readFile(file, 'utf-8')
|
|
242
|
+
const after = rewriteIdentifiers(before)
|
|
243
|
+
if (after !== before) {
|
|
244
|
+
await fse.writeFile(file, after, 'utf-8')
|
|
245
|
+
rewritten++
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const name of ['AGENTS.md', 'ARCHITECTURE.md', 'DESIGN.md', 'opencode.jsonc', 'opencode.json']) {
|
|
250
|
+
const file = path.join(cwd, name)
|
|
251
|
+
if (!await fse.pathExists(file)) continue
|
|
252
|
+
const before = await fse.readFile(file, 'utf-8')
|
|
253
|
+
const after = rewriteIdentifiers(before)
|
|
254
|
+
if (after !== before) {
|
|
255
|
+
await fse.writeFile(file, after, 'utf-8')
|
|
256
|
+
rewritten++
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 5. Engineers become subagents; build and plan are the only primaries now.
|
|
261
|
+
// The plugin also does this at startup, but doing it here keeps the
|
|
262
|
+
// migration commit self-consistent.
|
|
263
|
+
let demoted = 0
|
|
264
|
+
for (const file of await fse.readdir(agentsDir).catch(() => [])) {
|
|
265
|
+
if (!file.endsWith('.md')) continue
|
|
266
|
+
const full = path.join(agentsDir, file)
|
|
267
|
+
const before = await fse.readFile(full, 'utf-8')
|
|
268
|
+
const after = demoteToSubagent(before)
|
|
269
|
+
if (after !== before) {
|
|
270
|
+
await fse.writeFile(full, after, 'utf-8')
|
|
271
|
+
demoted++
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
success(`Renamed ${plan.moves.length} state file(s), kept ${plan.skillsToRename.length} project-owned skill(s)`)
|
|
276
|
+
success(`Rewrote identifiers in ${rewritten} file(s), demoted ${demoted} agent(s) to subagent`)
|
|
277
|
+
if (plan.stalePlugins.length > 0) {
|
|
278
|
+
success(`Removed ${plan.stalePlugins.length} superseded plugin/tui file(s)`)
|
|
279
|
+
}
|
|
280
|
+
console.log()
|
|
281
|
+
console.log(chalk.bold('Next: run update to install the current harness.'))
|
|
282
|
+
console.log(chalk.dim(' npx @plainconceptsplatform/agent-harness@latest update'))
|
|
283
|
+
|
|
284
|
+
return { migrated: true, plan, rewritten, demoted }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function runMigrateCommand(args = []) {
|
|
288
|
+
const result = await runMigrate({
|
|
289
|
+
force: args.includes('--force'),
|
|
290
|
+
dryRun: args.includes('--dry-run'),
|
|
291
|
+
})
|
|
292
|
+
if (result.dirty) exit(1)
|
|
293
|
+
return result
|
|
294
|
+
}
|
|
@@ -4,9 +4,9 @@ description: Create a custom engineer agent via persona-driven interactive desig
|
|
|
4
4
|
license: MIT
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
Create one file only: `.opencode/agents/{persona}-engineer.md`. The `pc-subagent-tiers` plugin creates tier variants (`.build.md`, `.fast.md`, `.plan.md`) at startup, so you never write those.
|
|
7
|
+
Create one file only: `.opencode/agents/{persona}-engineer.md`. The `pc-subagent-tiers` plugin creates tier variants (`.build.md`, `.fast.md`, `.plan.md`) at startup, so you never write those. It also generates `build.md` and `plan.md`, the only two primary agents: never create or edit those either.
|
|
8
8
|
|
|
9
|
-
Fidelity to the [template](template.md) is the whole job. The file contains frontmatter plus one identity paragraph plus the `## Abilities` section. No other `##` headings. No expertise notes, architecture details, conventions, file maps, or workflow steps. Those belong in skills. Always set `mode:
|
|
9
|
+
Fidelity to the [template](template.md) is the whole job. The file contains frontmatter plus one identity paragraph plus the `## Abilities` section. No other `##` headings. No expertise notes, architecture details, conventions, file maps, or workflow steps. Those belong in skills. Always set `mode: subagent`. Engineers are reached through `task()`, never picked by a human, so a primary engineer would only clutter the agent picker. Never write `model:`. Only reference skills installed in the project's `.agents/skills/` directory.
|
|
10
10
|
|
|
11
11
|
Skills first: you must complete Step 4 (present the form, discover skills for every detected signal and architecture, let the user confirm the set, then install 5-10 skills) before writing any file.
|
|
12
12
|
|
|
@@ -185,7 +185,7 @@ If either check fails, fix the file and re-validate.
|
|
|
185
185
|
|
|
186
186
|
## Step 7: Update fullstack-engineer.md abilities
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
`fullstack-engineer.md` is `mode: subagent`, but it is also the body that `pc-subagent-tiers` copies into `build.md` and `plan.md` on every startup. So every skill listed here reaches both primary agents, which is why it accumulates all of them: it plans and delegates rather than doing parallel implementation itself.
|
|
189
189
|
|
|
190
190
|
After creating the persona engineer and validating its references, additively merge new skills into fullstack:
|
|
191
191
|
|
|
@@ -216,4 +216,4 @@ Report:
|
|
|
216
216
|
- Skills that failed validation or install (list each with reason)
|
|
217
217
|
- `fullstack-engineer.md` updated (additive, list new skills added)
|
|
218
218
|
- How to use: "This agent will be spawned by the lead during `/plan-apply` for tasks matching its specialty."
|
|
219
|
-
- "Restart opencode for the `pc-subagent-tiers` plugin to pick up the new engineer."
|
|
219
|
+
- "Restart opencode for the `pc-subagent-tiers` plugin to pick up the new engineer and rebuild `build.md` and `plan.md` from the updated fullstack abilities."
|
|
@@ -5,8 +5,8 @@ The agent file is exactly this structure: frontmatter plus one identity paragrap
|
|
|
5
5
|
```markdown
|
|
6
6
|
---
|
|
7
7
|
description: <one sentence naming the persona + top 3-5 detected technologies>
|
|
8
|
-
mode:
|
|
9
|
-
color: <pick: primary|secondary|accent|error|info: avoid colors used by existing agents; warning is reserved for the
|
|
8
|
+
mode: subagent
|
|
9
|
+
color: <pick: primary|secondary|accent|error|info: avoid colors used by existing agents; warning is reserved for the build and plan primaries>
|
|
10
10
|
permission:
|
|
11
11
|
edit: allow
|
|
12
12
|
bash: allow
|
|
@@ -62,7 +62,7 @@ Rules:
|
|
|
62
62
|
|
|
63
63
|
After writing the agent file, verify:
|
|
64
64
|
|
|
65
|
-
1. Frontmatter exists: starts with `---`, has `description`, `mode:
|
|
65
|
+
1. Frontmatter exists: starts with `---`, has `description`, `mode: subagent`, `color`, `permission` block.
|
|
66
66
|
2. No `model:` field in the frontmatter. The `pc-subagent-tiers` plugin injects it.
|
|
67
67
|
3. `## Abilities` is the only `##` heading. No other `##` sections exist in the file.
|
|
68
68
|
4. One identity paragraph before `## Abilities`: 2-3 sentences max, not multiple paragraphs.
|
|
@@ -58,7 +58,7 @@ Load `@openspec-propose` skill and follow its instructions to generate proposal.
|
|
|
58
58
|
- `description:` from the YAML frontmatter: the engineer's specialization summary
|
|
59
59
|
- `## Abilities` section: the skills listed under Development, Testing, Infrastructure (e.g. `@nodejs-backend`, `@secure-nextjs-api-routes`)
|
|
60
60
|
Build a map of `agent-name -> { description, abilities }`.
|
|
61
|
-
2. For each task, compare the task text and domain against every engineer's description AND abilities. Pick the engineer whose combined profile most closely matches. `fullstack-engineer` is `
|
|
61
|
+
2. For each task, compare the task text and domain against every engineer's description AND abilities. Pick the engineer whose combined profile most closely matches. `fullstack-engineer` is the fallback worker and the body behind `build` and `plan`; prefer a real specialist over it, and never annotate a task with `build` or `plan`, which are the user's own primaries. If no specialist matches a task, leave the agent field blank and record the missing specialization in the proposal. An annotated OpenSpec task needs a real subagent; never substitute the lead or an obsolete generic agent name.
|
|
62
62
|
3. Pick a tier, derive `depends_on`, derive `touches`, and annotate each task line. Follow the [task annotation](task-annotation.md) reference for the full tier selection guide, dependency derivation, touches derivation, and annotation format with examples.
|
|
63
63
|
|
|
64
64
|
## Step 3: Show the plan and ask for confirmation (stop)
|
|
@@ -33,7 +33,7 @@ Present as a table:
|
|
|
33
33
|
Then explain the agent selection model:
|
|
34
34
|
- Primary agents appear in Tab and handle direct user interaction
|
|
35
35
|
- Subagent engineers are spawned by the lead for parallel implementation waves
|
|
36
|
-
- Specialist engineers are preferred when their domain matches the task
|
|
36
|
+
- Specialist engineers are preferred when their domain matches the task. `build` and `plan` are the only agents the user selects, and both run the `fullstack-engineer` body; `plan` cannot edit files. Everything else is `mode: subagent` and spawned. If no specialist matches, create one with `/make-engineer`.
|
|
37
37
|
|
|
38
38
|
## Step 3: Command reference
|
|
39
39
|
|
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
// pc-subagent-tiers: on startup, reads *-engineer.md templates and creates
|
|
2
|
-
// tier variant files with the resolved model
|
|
3
|
-
//
|
|
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.
|
|
4
18
|
|
|
5
19
|
import fs from "node:fs/promises"
|
|
6
20
|
import path from "node:path"
|
|
7
21
|
|
|
8
22
|
const TIERS = ["build", "fast", "plan"]
|
|
9
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
|
+
|
|
10
43
|
export const PcSubagentTiers = async ({ directory }) => {
|
|
11
44
|
const root = directory || process.cwd()
|
|
12
45
|
const agentsDir = path.join(root, ".opencode", "agents")
|
|
@@ -35,11 +68,12 @@ export const PcSubagentTiers = async ({ directory }) => {
|
|
|
35
68
|
try {
|
|
36
69
|
const entries = await fs.readdir(agentsDir)
|
|
37
70
|
return {
|
|
38
|
-
templates: entries.filter(f => /^[\w-]+-engineer\.md$/.test(f) && f !==
|
|
71
|
+
templates: entries.filter(f => /^[\w-]+-engineer\.md$/.test(f) && f !== FULLSTACK_TEMPLATE).map(f => f.replace(/\.md$/, "")),
|
|
39
72
|
variantFiles: entries.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
|
|
73
|
+
hasFullstack: entries.includes(FULLSTACK_TEMPLATE),
|
|
40
74
|
}
|
|
41
75
|
} catch {
|
|
42
|
-
return { templates: [], variantFiles: [] }
|
|
76
|
+
return { templates: [], variantFiles: [], hasFullstack: false }
|
|
43
77
|
}
|
|
44
78
|
}
|
|
45
79
|
|
|
@@ -61,11 +95,13 @@ export const PcSubagentTiers = async ({ directory }) => {
|
|
|
61
95
|
return `---\n${fm}\n---${templateContent.slice(fmMatch[0].length)}`
|
|
62
96
|
}
|
|
63
97
|
|
|
64
|
-
// Ensure template files
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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.
|
|
69
105
|
function normalizeTemplate(templateContent) {
|
|
70
106
|
const fmMatch = templateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
71
107
|
if (!fmMatch) return templateContent
|
|
@@ -73,8 +109,8 @@ export const PcSubagentTiers = async ({ directory }) => {
|
|
|
73
109
|
let fm = fmMatch[1]
|
|
74
110
|
let changed = false
|
|
75
111
|
|
|
76
|
-
if (!/^mode:\s*
|
|
77
|
-
fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode:
|
|
112
|
+
if (!/^mode:\s*subagent/m.test(fm)) {
|
|
113
|
+
fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode: subagent') : `mode: subagent\n${fm}`
|
|
78
114
|
changed = true
|
|
79
115
|
}
|
|
80
116
|
if (/^model:/m.test(fm)) {
|
|
@@ -91,6 +127,32 @@ export const PcSubagentTiers = async ({ directory }) => {
|
|
|
91
127
|
return templateContent.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? null
|
|
92
128
|
}
|
|
93
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
|
+
|
|
94
156
|
// Skip writing if the file already has identical content.
|
|
95
157
|
async function writeIfChanged(filePath, content) {
|
|
96
158
|
try {
|
|
@@ -108,7 +170,43 @@ export const PcSubagentTiers = async ({ directory }) => {
|
|
|
108
170
|
try {
|
|
109
171
|
const models = await resolveModels()
|
|
110
172
|
const available = TIERS.filter(t => models[t])
|
|
111
|
-
const { templates, variantFiles } = await scanEngineers()
|
|
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
|
+
}
|
|
112
210
|
|
|
113
211
|
const templateContents = await Promise.all(
|
|
114
212
|
templates.map(async name => {
|
|
@@ -117,7 +215,7 @@ export const PcSubagentTiers = async ({ directory }) => {
|
|
|
117
215
|
// If the template had the wrong mode or a stale model:, persist the fix to disk
|
|
118
216
|
if (content !== rawContent) {
|
|
119
217
|
await writeIfChanged(path.join(agentsDir, `${name}.md`), content)
|
|
120
|
-
console.error(`[pc-subagent-tiers] Normalized ${name}.md (mode:
|
|
218
|
+
console.error(`[pc-subagent-tiers] Normalized ${name}.md (mode: subagent, model: removed)`)
|
|
121
219
|
}
|
|
122
220
|
return { name, content }
|
|
123
221
|
})
|
|
@@ -144,12 +242,16 @@ export const PcSubagentTiers = async ({ directory }) => {
|
|
|
144
242
|
await Promise.all(variantsToWrite.map(v => writeIfChanged(v.path, v.content)))
|
|
145
243
|
|
|
146
244
|
if (cfg?.agent) {
|
|
147
|
-
// Ensure base templates are always mode:
|
|
245
|
+
// Ensure base templates are always mode: subagent in-memory. A repo
|
|
246
|
+
// upgraded from an earlier version may still have primary in config.
|
|
148
247
|
for (const { name } of templateContents) {
|
|
149
248
|
if (cfg.agent[name]) {
|
|
150
|
-
cfg.agent[name].mode = '
|
|
249
|
+
cfg.agent[name].mode = 'subagent'
|
|
151
250
|
}
|
|
152
251
|
}
|
|
252
|
+
if (cfg.agent[FULLSTACK_NAME]) {
|
|
253
|
+
cfg.agent[FULLSTACK_NAME].mode = 'subagent'
|
|
254
|
+
}
|
|
153
255
|
for (const { name, tier, templateContent } of variantsToWrite) {
|
|
154
256
|
const base = cfg.agent[name]
|
|
155
257
|
cfg.agent[`${name}.${tier}`] = base
|
package/src/content/AGENTS.md
CHANGED
|
@@ -38,7 +38,7 @@ Command aliases: OpenSpec skills may reference `/opsx-propose`, `/opsx-apply`, `
|
|
|
38
38
|
|
|
39
39
|
## Engineer selection
|
|
40
40
|
|
|
41
|
-
Inspect `.opencode/agents/*.md` before spawning. Prefer the most specialized custom engineer. `fullstack-engineer` is
|
|
41
|
+
Inspect `.opencode/agents/*.md` before spawning. Prefer the most specialized custom engineer. `build` and `plan` are the only primaries and are never spawned; `fullstack-engineer` is the body they share and the fallback worker, so prefer a specialist over it. If no specialist matches, tell the user to create one with `/make-engineer`. Spawn only engineers present in that directory.
|
|
42
42
|
|
|
43
43
|
The `pc-plan-apply` skill is authoritative for subagent waves, dependency ordering, retries, and concurrency. Read `agents.maxConcurrent` from `.opencode/harness.json` before spawning workers.
|
|
44
44
|
|
|
@@ -1,31 +1,39 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://opencode.ai/config.json",
|
|
3
|
-
"instructions": [
|
|
4
|
-
"AGENTS.md"
|
|
5
|
-
],
|
|
6
|
-
"plugin": [
|
|
7
|
-
"@different-ai/opencode-browser@4.6.1",
|
|
8
|
-
"@mohak34/opencode-notifier@0.2.8"
|
|
9
|
-
],
|
|
10
|
-
"experimental": {
|
|
11
|
-
"mcp_timeout": 300000
|
|
12
|
-
},
|
|
13
|
-
"compaction": {
|
|
14
|
-
"auto": true,
|
|
15
|
-
"prune": true,
|
|
16
|
-
"reserved": 10000
|
|
17
|
-
},
|
|
18
|
-
"permission": {
|
|
19
|
-
"question": "allow",
|
|
20
|
-
"todowrite": "allow",
|
|
21
|
-
"skill": "allow"
|
|
22
|
-
},
|
|
23
|
-
"skills": {
|
|
24
|
-
"paths": [".agents/skills"]
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://opencode.ai/config.json",
|
|
3
|
+
"instructions": [
|
|
4
|
+
"AGENTS.md"
|
|
5
|
+
],
|
|
6
|
+
"plugin": [
|
|
7
|
+
"@different-ai/opencode-browser@4.6.1",
|
|
8
|
+
"@mohak34/opencode-notifier@0.2.8"
|
|
9
|
+
],
|
|
10
|
+
"experimental": {
|
|
11
|
+
"mcp_timeout": 300000
|
|
12
|
+
},
|
|
13
|
+
"compaction": {
|
|
14
|
+
"auto": true,
|
|
15
|
+
"prune": true,
|
|
16
|
+
"reserved": 10000
|
|
17
|
+
},
|
|
18
|
+
"permission": {
|
|
19
|
+
"question": "allow",
|
|
20
|
+
"todowrite": "allow",
|
|
21
|
+
"skill": "allow"
|
|
22
|
+
},
|
|
23
|
+
"skills": {
|
|
24
|
+
"paths": [".agents/skills"]
|
|
25
|
+
},
|
|
26
|
+
// build and plan are the only primaries. The pc-subagent-tiers plugin
|
|
27
|
+
// regenerates .opencode/agents/{build,plan}.md from fullstack-engineer.md on
|
|
28
|
+
// every startup and overrides these two entries with the resolved tier model,
|
|
29
|
+
// so what is here is only the floor if the plugin cannot run. Every engineer
|
|
30
|
+
// is mode: subagent and reached through task(), never picked by a human.
|
|
31
|
+
"default_agent": "build",
|
|
32
|
+
"agent": {
|
|
33
|
+
"build": { "mode": "primary" },
|
|
34
|
+
"plan": {
|
|
35
|
+
"mode": "primary",
|
|
36
|
+
"permission": { "edit": "deny" }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { runUpdate } from './commands/update.js'
|
|
|
6
6
|
import { runSingleCommand } from './commands/single.js'
|
|
7
7
|
import { runWizard } from './commands/wizard.js'
|
|
8
8
|
import { exit } from './utils/process.js'
|
|
9
|
+
import { runMigrateCommand } from './commands/migrate.js'
|
|
9
10
|
import { findLegacyInstall } from './utils/legacy-check.js'
|
|
10
11
|
|
|
11
12
|
function printHelp(version) {
|
|
@@ -17,6 +18,7 @@ function printHelp(version) {
|
|
|
17
18
|
console.log()
|
|
18
19
|
console.log('Commands:')
|
|
19
20
|
console.log(' update Bring the harness up to date from saved config (no prompts)')
|
|
21
|
+
console.log(' migrate Move a v1 (opencode-onboard) project to v2, keeping your content')
|
|
20
22
|
console.log(' join Set up a teammate\'s machine (checks & local installs only)')
|
|
21
23
|
console.log(' clean Run AI files cleanup step')
|
|
22
24
|
console.log(' platform Run platform selection step')
|
|
@@ -59,16 +61,23 @@ async function refuseLegacyInstall() {
|
|
|
59
61
|
console.log('and it does not migrate v1 projects. Continuing would leave the harness')
|
|
60
62
|
console.log('half-patched without reporting an error.')
|
|
61
63
|
console.log()
|
|
62
|
-
console.log('
|
|
63
|
-
console.log(chalk.dim('
|
|
64
|
-
console.log(chalk.dim('
|
|
65
|
-
console.log(
|
|
64
|
+
console.log('Migrate it, keeping your generated skills and custom engineers:')
|
|
65
|
+
console.log(chalk.dim(' npx @plainconceptsplatform/agent-harness migrate'))
|
|
66
|
+
console.log(chalk.dim(' npx @plainconceptsplatform/agent-harness update'))
|
|
67
|
+
console.log()
|
|
68
|
+
console.log(chalk.dim('Add --dry-run to see what migrate would change first.'))
|
|
66
69
|
return true
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
// Ctrl-C out of an @inquirer prompt throws ExitPromptError; that is a normal
|
|
70
73
|
// cancellation, not a crash, so it must not exit non-zero.
|
|
71
74
|
async function main() {
|
|
75
|
+
// migrate is the way out of a v1 project, so it must run before the guard
|
|
76
|
+
// that refuses v1 projects.
|
|
77
|
+
if (args[0] === 'migrate') {
|
|
78
|
+
await runMigrateCommand(args.slice(1))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
72
81
|
if (await refuseLegacyInstall()) {
|
|
73
82
|
exit(1)
|
|
74
83
|
return
|
|
@@ -4,7 +4,7 @@ import { success } from '../../utils/exec.js'
|
|
|
4
4
|
|
|
5
5
|
const FULLSTACK_FILE = 'fullstack-engineer.md'
|
|
6
6
|
const FULLSTACK_DESCRIPTION = 'Default engineer that accumulates skills from all created persona engineers. Use as fallback when no specialist matches: but prefer spawning a specific engineer for deterministic results.'
|
|
7
|
-
const FULLSTACK_IDENTITY = 'You are the default engineer,
|
|
7
|
+
const FULLSTACK_IDENTITY = 'You are the default engineer, and your body is what the build and plan agents run. You are more complete but less accurate than specialized engineers, so prefer spawning a specialist when one matches the task domain.'
|
|
8
8
|
|
|
9
9
|
// The reminder plugin owns ability bootstrap. Remove the old prompt-level
|
|
10
10
|
// directive when regenerating so it cannot duplicate the plugin reminder.
|
|
@@ -56,7 +56,9 @@ export async function generateFullstackEngineer({ cwd = process.cwd(), updateMod
|
|
|
56
56
|
const frontmatter = [
|
|
57
57
|
'---',
|
|
58
58
|
`description: ${FULLSTACK_DESCRIPTION}`,
|
|
59
|
-
|
|
59
|
+
// subagent, not primary: build.md and plan.md are the primaries, and
|
|
60
|
+
// pc-subagent-tiers generates both from this file on every startup.
|
|
61
|
+
'mode: subagent',
|
|
60
62
|
'color: warning',
|
|
61
63
|
'permission:',
|
|
62
64
|
' edit: allow',
|
|
@@ -33,9 +33,19 @@ export async function patchOpencodeJson(cwd = process.cwd()) {
|
|
|
33
33
|
return { patched: false, reason: 'parse error' }
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// build and plan are overridden, not disabled. Earlier versions disabled them
|
|
37
|
+
// and pointed default_agent at fullstack-engineer; now they are the only two
|
|
38
|
+
// primaries and pc-subagent-tiers regenerates their agent files from
|
|
39
|
+
// fullstack-engineer.md. A repo patched by an earlier version still carries
|
|
40
|
+
// `disable: true`, which would hide both agents, so that flag is cleared here.
|
|
41
|
+
const hasStaleDisable = (
|
|
42
|
+
parsed?.agent?.build?.disable !== undefined ||
|
|
43
|
+
parsed?.agent?.plan?.disable !== undefined
|
|
44
|
+
)
|
|
45
|
+
const needsAgentOverride = hasStaleDisable || !(
|
|
46
|
+
parsed?.agent?.build?.mode === 'primary' &&
|
|
47
|
+
parsed?.agent?.plan?.mode === 'primary' &&
|
|
48
|
+
parsed?.agent?.plan?.permission?.edit === 'deny'
|
|
39
49
|
)
|
|
40
50
|
const needsSkillPermission = parsed?.permission?.skill !== 'allow'
|
|
41
51
|
const missingSharedPermissions = SHARED_PERMISSIONS.filter(
|
|
@@ -51,14 +61,22 @@ export async function patchOpencodeJson(cwd = process.cwd()) {
|
|
|
51
61
|
parsed?.compaction?.prune === true
|
|
52
62
|
)
|
|
53
63
|
|
|
54
|
-
if (!
|
|
64
|
+
if (!needsAgentOverride && !needsSkillPermission && missingSharedPermissions.length === 0 && !needsSkillsPaths && !needsCompaction) {
|
|
55
65
|
return { patched: false }
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
// Apply edits sequentially so offsets stay correct
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
if (needsAgentOverride) {
|
|
70
|
+
// undefined removes the key, clearing the disable left by earlier versions.
|
|
71
|
+
if (parsed?.agent?.build?.disable !== undefined) {
|
|
72
|
+
text = applyModify(text, ['agent', 'build', 'disable'], undefined)
|
|
73
|
+
}
|
|
74
|
+
if (parsed?.agent?.plan?.disable !== undefined) {
|
|
75
|
+
text = applyModify(text, ['agent', 'plan', 'disable'], undefined)
|
|
76
|
+
}
|
|
77
|
+
text = applyModify(text, ['agent', 'build', 'mode'], 'primary')
|
|
78
|
+
text = applyModify(text, ['agent', 'plan', 'mode'], 'primary')
|
|
79
|
+
text = applyModify(text, ['agent', 'plan', 'permission', 'edit'], 'deny')
|
|
62
80
|
}
|
|
63
81
|
if (needsSkillPermission) text = applyModify(text, ['permission', 'skill'], 'allow')
|
|
64
82
|
for (const [key, value] of missingSharedPermissions) {
|
|
@@ -82,8 +100,8 @@ export async function patchOpencodeJson(cwd = process.cwd()) {
|
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
await fse.writeFile(opencodePath, text, 'utf-8')
|
|
85
|
-
if (
|
|
86
|
-
success('
|
|
103
|
+
if (needsAgentOverride) {
|
|
104
|
+
success('Set build/plan as the primary agents in opencode.jsonc (plan is read-only)')
|
|
87
105
|
}
|
|
88
106
|
if (needsSkillPermission) {
|
|
89
107
|
success('Allowed skill loading in opencode.jsonc')
|
package/src/steps/copy/skills.js
CHANGED
|
@@ -21,7 +21,7 @@ const BACKLOG_PLATFORM_SKILLS = {
|
|
|
21
21
|
// Platform-specific skills are renamed to their generic form on install.
|
|
22
22
|
// The -gh / -az / -jira / -gl suffix is only needed here to keep all variants in source.
|
|
23
23
|
// After install only one platform is present so no suffix is needed.
|
|
24
|
-
const SKILL_RENAME = {
|
|
24
|
+
export const SKILL_RENAME = {
|
|
25
25
|
'pc-userstory-gh': 'pc-userstory',
|
|
26
26
|
'pc-userstory-az': 'pc-userstory',
|
|
27
27
|
'pc-userstory-jira': 'pc-userstory',
|
|
@@ -38,7 +38,7 @@ function shouldInstallSkill(skill, backlogPlatform, _repoPlatform) {
|
|
|
38
38
|
// these must be preserved when they have already been generated (detected
|
|
39
39
|
// by the <!-- Last updated: marker). Without this check, the update would
|
|
40
40
|
// wipe project-specific guardrails, risk assessments, etc.
|
|
41
|
-
const GENERATABLE_SKILLS = new Set([
|
|
41
|
+
export const GENERATABLE_SKILLS = new Set([
|
|
42
42
|
'pc-guardrails-project',
|
|
43
43
|
'pc-merge-risk-assess',
|
|
44
44
|
])
|
package/src/utils/paths.js
CHANGED
|
@@ -34,6 +34,9 @@ export const IGNORED_ENTRIES = [
|
|
|
34
34
|
MANIFEST_FILE,
|
|
35
35
|
'source-roots.json',
|
|
36
36
|
'*-engineer.*.md',
|
|
37
|
+
// Regenerated from fullstack-engineer.md by pc-subagent-tiers every startup.
|
|
38
|
+
'agents/build.md',
|
|
39
|
+
'agents/plan.md',
|
|
37
40
|
]
|
|
38
41
|
|
|
39
42
|
export function configPath(cwd = process.cwd()) {
|