@plainconceptsplatform/agent-harness 3.8.2 → 3.9.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 (148) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +551 -549
  3. package/cli/commands/join.js +259 -259
  4. package/cli/commands/migrate.js +310 -310
  5. package/cli/commands/shared.js +27 -27
  6. package/cli/commands/single.js +79 -79
  7. package/cli/commands/update.js +121 -121
  8. package/cli/commands/wizard.js +134 -134
  9. package/cli/fragments/archive/az.md +97 -97
  10. package/cli/fragments/archive/gh.md +96 -96
  11. package/cli/fragments/archive/gl.md +96 -96
  12. package/cli/fragments/archive/none.md +75 -75
  13. package/cli/fragments/guardrails/codegraph.md +5 -5
  14. package/cli/fragments/guardrails/humanizer.md +4 -4
  15. package/cli/fragments/guardrails/memory.md +4 -4
  16. package/cli/fragments/guardrails/rtk.md +3 -3
  17. package/cli/fragments/guardrails/simple-english.md +4 -4
  18. package/cli/fragments/ops-backlog/az.md +28 -28
  19. package/cli/fragments/ops-backlog/gh.md +28 -28
  20. package/cli/fragments/ops-backlog/jira.md +28 -28
  21. package/cli/fragments/ops-review/az.md +62 -62
  22. package/cli/fragments/ops-review/gh.md +51 -51
  23. package/cli/fragments/ops-review/gl.md +55 -55
  24. package/cli/fragments/ops-ship/az.md +81 -81
  25. package/cli/fragments/ops-ship/gh.md +67 -67
  26. package/cli/fragments/ops-ship/gl.md +84 -84
  27. package/cli/index.js +116 -116
  28. package/cli/presets/agents-content.json +34 -34
  29. package/cli/presets/browser.json +10 -10
  30. package/cli/presets/clean.json +21 -21
  31. package/cli/presets/models.json +68 -68
  32. package/cli/presets/optimization.json +32 -32
  33. package/cli/presets/platforms.json +76 -76
  34. package/cli/presets/source.json +23 -23
  35. package/cli/steps/browser/index.js +48 -48
  36. package/cli/steps/clean/index.js +120 -120
  37. package/cli/steps/copy/agents.js +176 -160
  38. package/cli/steps/copy/commands.js +79 -79
  39. package/cli/steps/copy/fullstack-engineer.js +81 -89
  40. package/cli/steps/copy/index.js +88 -88
  41. package/cli/steps/copy/opencode-json.js +324 -324
  42. package/cli/steps/copy/skills.js +316 -316
  43. package/cli/steps/metadata/index.js +115 -115
  44. package/cli/steps/models/format.js +88 -88
  45. package/cli/steps/models/index.js +64 -64
  46. package/cli/steps/models/write.js +38 -38
  47. package/cli/steps/openspec/index.js +136 -136
  48. package/cli/steps/optimization/codegraph.js +141 -141
  49. package/cli/steps/optimization/detect.js +66 -66
  50. package/cli/steps/optimization/humanizer.js +17 -17
  51. package/cli/steps/optimization/index.js +158 -158
  52. package/cli/steps/optimization/memory.js +91 -91
  53. package/cli/steps/optimization/patch-guardrails.js +111 -111
  54. package/cli/steps/optimization/simple-english.js +17 -17
  55. package/cli/steps/optimization/skills-lock.js +30 -30
  56. package/cli/steps/platform/index.js +109 -109
  57. package/cli/steps/source/index.js +123 -123
  58. package/cli/utils/agent-color.js +111 -111
  59. package/cli/utils/copy.js +134 -134
  60. package/cli/utils/exec-spinner.js +47 -47
  61. package/cli/utils/exec.js +134 -134
  62. package/cli/utils/legacy-check.js +30 -30
  63. package/cli/utils/models-cache.js +58 -58
  64. package/cli/utils/models-pricing.js +42 -42
  65. package/cli/utils/paths.js +82 -82
  66. package/cli/utils/process.js +3 -3
  67. package/cli/utils/terminal.js +6 -6
  68. package/cli/utils/update-manifest.js +75 -75
  69. package/harness/.opencode/_gitignore +12 -10
  70. package/harness/.opencode/commands/init.md +5 -5
  71. package/harness/.opencode/commands/make-architecture.md +5 -5
  72. package/harness/.opencode/commands/make-design.md +5 -5
  73. package/harness/.opencode/commands/make-engineer.md +5 -5
  74. package/harness/.opencode/commands/make-guardrails.md +5 -5
  75. package/harness/.opencode/commands/make-user-model.md +5 -5
  76. package/harness/.opencode/commands/ops-backlog.md +10 -10
  77. package/harness/.opencode/commands/ops-review.md +10 -10
  78. package/harness/.opencode/commands/ops-ship.md +9 -9
  79. package/harness/.opencode/commands/plan-apply.md +9 -9
  80. package/harness/.opencode/commands/plan-archive.md +7 -7
  81. package/harness/.opencode/commands/plan-explore.md +11 -11
  82. package/harness/.opencode/commands/plan-goal.md +5 -5
  83. package/harness/.opencode/commands/plan-propose.md +11 -11
  84. package/harness/.opencode/commands/plan-quick.md +7 -7
  85. package/harness/.opencode/commands/plan-story.md +11 -11
  86. package/harness/.opencode/commands/repo-audit.md +7 -7
  87. package/harness/.opencode/commands/repo-help.md +5 -5
  88. package/harness/.opencode/commands/repo-initialize.md +5 -5
  89. package/harness/.opencode/commands/repo-onboard.md +5 -5
  90. package/harness/.opencode/commands/repo-verify.md +5 -5
  91. package/harness/.opencode/package.json +2 -1
  92. package/harness/.opencode/plugins/pc-commit-coauthor.js +41 -41
  93. package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
  94. package/harness/.opencode/plugins/pc-subagent-tiers-core.js +337 -0
  95. package/harness/.opencode/plugins/pc-subagent-tiers.js +34 -345
  96. package/harness/.opencode/plugins/pc-system-reminders.js +424 -424
  97. package/harness/.opencode/scripts/check-diff.mjs +305 -305
  98. package/harness/.opencode/scripts/code-rules.mjs +288 -288
  99. package/harness/.opencode/scripts/harness-doctor.mjs +52 -0
  100. package/harness/.opencode/scripts/harness-doctor.test.mjs +58 -0
  101. package/harness/.opencode/scripts/platform-contract.mjs +366 -366
  102. package/harness/.opencode/scripts/sync-agents.mjs +29 -0
  103. package/harness/AGENTS.md +55 -49
  104. package/harness/ARCHITECTURE.md +13 -13
  105. package/harness/DESIGN.md +13 -13
  106. package/harness/opencode.jsonc +36 -36
  107. package/harness/openspec/config.yaml +20 -20
  108. package/harness/skills-lock.json +17 -17
  109. package/package.json +12 -14
  110. package/skills/README.md +18 -18
  111. package/skills/agent-harness-cli/SKILL.md +86 -86
  112. package/skills/browser-automation/SKILL.md +72 -72
  113. package/skills/pc-guardrails-generic/SKILL.md +53 -53
  114. package/skills/pc-guardrails-project/SKILL.md +8 -8
  115. package/skills/pc-make-architecture/SKILL.md +33 -33
  116. package/skills/pc-make-architecture/structure-template.md +64 -64
  117. package/skills/pc-make-design/SKILL.md +45 -45
  118. package/skills/pc-make-engineer/SKILL.md +59 -59
  119. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  120. package/skills/pc-make-engineer/template.md +36 -36
  121. package/skills/pc-make-guardrails/SKILL.md +44 -44
  122. package/skills/pc-make-guardrails/category-reference.md +112 -112
  123. package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
  124. package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
  125. package/skills/pc-make-user-model/SKILL.md +56 -56
  126. package/skills/pc-ops-ship/SKILL.md +22 -22
  127. package/skills/pc-plan-apply/SKILL.md +90 -90
  128. package/skills/pc-plan-apply/simple-mode.md +21 -21
  129. package/skills/pc-plan-archive/SKILL.md +66 -66
  130. package/skills/pc-plan-explore/SKILL.md +21 -21
  131. package/skills/pc-plan-goal/SKILL.md +91 -91
  132. package/skills/pc-plan-goal/branching.md +39 -39
  133. package/skills/pc-plan-goal/failure-policy.md +30 -30
  134. package/skills/pc-plan-goal/output-mode.md +10 -10
  135. package/skills/pc-plan-goal/output.md +71 -71
  136. package/skills/pc-plan-propose/SKILL.md +127 -127
  137. package/skills/pc-plan-propose/task-annotation.md +39 -39
  138. package/skills/pc-plan-quick/SKILL.md +46 -46
  139. package/skills/pc-plan-story/SKILL.md +93 -93
  140. package/skills/pc-repo-audit/SKILL.md +44 -44
  141. package/skills/pc-repo-help/SKILL.md +87 -87
  142. package/skills/pc-repo-initialize/SKILL.md +119 -119
  143. package/skills/pc-repo-onboard/SKILL.md +32 -32
  144. package/skills/pc-repo-verify/SKILL.md +150 -150
  145. package/skills/pc-userstory-az/SKILL.md +71 -71
  146. package/skills/pc-userstory-browser/SKILL.md +64 -64
  147. package/skills/pc-userstory-gh/SKILL.md +63 -63
  148. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,310 +1,310 @@
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, '../../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
- // .github/workflows is included because GitHub Agentic Workflow definitions
239
- // call the skills by name (skill("pc-repo-audit")), so a stale ob- name there
240
- // fails at run time in CI rather than locally. Their compiled .lock.yml files
241
- // embed the same prompt text, so recompile with `gh aw compile` afterwards.
242
- for (const dir of [skillsDir, opencodeDir, path.join(cwd, 'openspec'), path.join(cwd, '.github', 'workflows')]) {
243
- if (!await fse.pathExists(dir)) continue
244
- for (const file of await collectTextFiles(dir)) {
245
- const before = await fse.readFile(file, 'utf-8')
246
- const after = rewriteIdentifiers(before)
247
- if (after !== before) {
248
- await fse.writeFile(file, after, 'utf-8')
249
- rewritten++
250
- }
251
- }
252
- }
253
- for (const name of ['AGENTS.md', 'ARCHITECTURE.md', 'DESIGN.md', 'opencode.jsonc', 'opencode.json']) {
254
- const file = path.join(cwd, name)
255
- if (!await fse.pathExists(file)) continue
256
- const before = await fse.readFile(file, 'utf-8')
257
- const after = rewriteIdentifiers(before)
258
- if (after !== before) {
259
- await fse.writeFile(file, after, 'utf-8')
260
- rewritten++
261
- }
262
- }
263
-
264
- // 5. Engineers become subagents; build and plan are the only primaries now.
265
- // The plugin also does this at startup, but doing it here keeps the
266
- // migration commit self-consistent.
267
- let demoted = 0
268
- for (const file of await fse.readdir(agentsDir).catch(() => [])) {
269
- if (!file.endsWith('.md')) continue
270
- const full = path.join(agentsDir, file)
271
- const before = await fse.readFile(full, 'utf-8')
272
- const after = demoteToSubagent(before)
273
- if (after !== before) {
274
- await fse.writeFile(full, after, 'utf-8')
275
- demoted++
276
- }
277
- }
278
-
279
- success(`Renamed ${plan.moves.length} state file(s), kept ${plan.skillsToRename.length} project-owned skill(s)`)
280
- success(`Rewrote identifiers in ${rewritten} file(s), demoted ${demoted} agent(s) to subagent`)
281
- if (plan.stalePlugins.length > 0) {
282
- success(`Removed ${plan.stalePlugins.length} superseded plugin/tui file(s)`)
283
- }
284
- console.log()
285
- console.log(chalk.bold('Next: run update to install the current harness.'))
286
- console.log(chalk.dim(' npx @plainconceptsplatform/agent-harness@latest update'))
287
-
288
- // The .md definitions were rewritten above, but the compiled .lock.yml files
289
- // embed the same prompt text and are what CI actually runs.
290
- if (await fse.pathExists(path.join(cwd, '.github', 'workflows'))) {
291
- const locks = (await fse.readdir(path.join(cwd, '.github', 'workflows')).catch(() => []))
292
- .filter(f => f.endsWith('.lock.yml'))
293
- if (locks.length > 0) {
294
- console.log()
295
- console.log(chalk.bold(`Then recompile ${locks.length} agentic workflow(s), whose .lock.yml still holds the old skill names:`))
296
- console.log(chalk.dim(' gh aw compile'))
297
- }
298
- }
299
-
300
- return { migrated: true, plan, rewritten, demoted }
301
- }
302
-
303
- export async function runMigrateCommand(args = []) {
304
- const result = await runMigrate({
305
- force: args.includes('--force'),
306
- dryRun: args.includes('--dry-run'),
307
- })
308
- if (result.dirty) exit(1)
309
- return result
310
- }
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, '../../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
+ // .github/workflows is included because GitHub Agentic Workflow definitions
239
+ // call the skills by name (skill("pc-repo-audit")), so a stale ob- name there
240
+ // fails at run time in CI rather than locally. Their compiled .lock.yml files
241
+ // embed the same prompt text, so recompile with `gh aw compile` afterwards.
242
+ for (const dir of [skillsDir, opencodeDir, path.join(cwd, 'openspec'), path.join(cwd, '.github', 'workflows')]) {
243
+ if (!await fse.pathExists(dir)) continue
244
+ for (const file of await collectTextFiles(dir)) {
245
+ const before = await fse.readFile(file, 'utf-8')
246
+ const after = rewriteIdentifiers(before)
247
+ if (after !== before) {
248
+ await fse.writeFile(file, after, 'utf-8')
249
+ rewritten++
250
+ }
251
+ }
252
+ }
253
+ for (const name of ['AGENTS.md', 'ARCHITECTURE.md', 'DESIGN.md', 'opencode.jsonc', 'opencode.json']) {
254
+ const file = path.join(cwd, name)
255
+ if (!await fse.pathExists(file)) continue
256
+ const before = await fse.readFile(file, 'utf-8')
257
+ const after = rewriteIdentifiers(before)
258
+ if (after !== before) {
259
+ await fse.writeFile(file, after, 'utf-8')
260
+ rewritten++
261
+ }
262
+ }
263
+
264
+ // 5. Engineers become subagents; build and plan are the only primaries now.
265
+ // The plugin also does this at startup, but doing it here keeps the
266
+ // migration commit self-consistent.
267
+ let demoted = 0
268
+ for (const file of await fse.readdir(agentsDir).catch(() => [])) {
269
+ if (!file.endsWith('.md')) continue
270
+ const full = path.join(agentsDir, file)
271
+ const before = await fse.readFile(full, 'utf-8')
272
+ const after = demoteToSubagent(before)
273
+ if (after !== before) {
274
+ await fse.writeFile(full, after, 'utf-8')
275
+ demoted++
276
+ }
277
+ }
278
+
279
+ success(`Renamed ${plan.moves.length} state file(s), kept ${plan.skillsToRename.length} project-owned skill(s)`)
280
+ success(`Rewrote identifiers in ${rewritten} file(s), demoted ${demoted} agent(s) to subagent`)
281
+ if (plan.stalePlugins.length > 0) {
282
+ success(`Removed ${plan.stalePlugins.length} superseded plugin/tui file(s)`)
283
+ }
284
+ console.log()
285
+ console.log(chalk.bold('Next: run update to install the current harness.'))
286
+ console.log(chalk.dim(' npx @plainconceptsplatform/agent-harness@latest update'))
287
+
288
+ // The .md definitions were rewritten above, but the compiled .lock.yml files
289
+ // embed the same prompt text and are what CI actually runs.
290
+ if (await fse.pathExists(path.join(cwd, '.github', 'workflows'))) {
291
+ const locks = (await fse.readdir(path.join(cwd, '.github', 'workflows')).catch(() => []))
292
+ .filter(f => f.endsWith('.lock.yml'))
293
+ if (locks.length > 0) {
294
+ console.log()
295
+ console.log(chalk.bold(`Then recompile ${locks.length} agentic workflow(s), whose .lock.yml still holds the old skill names:`))
296
+ console.log(chalk.dim(' gh aw compile'))
297
+ }
298
+ }
299
+
300
+ return { migrated: true, plan, rewritten, demoted }
301
+ }
302
+
303
+ export async function runMigrateCommand(args = []) {
304
+ const result = await runMigrate({
305
+ force: args.includes('--force'),
306
+ dryRun: args.includes('--dry-run'),
307
+ })
308
+ if (result.dirty) exit(1)
309
+ return result
310
+ }