@plainconceptsplatform/agent-harness 3.5.0 → 3.7.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 (144) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +471 -454
  3. package/cli/commands/join.js +259 -259
  4. package/cli/commands/migrate.js +310 -310
  5. package/cli/commands/shared.js +27 -27
  6. package/cli/commands/single.js +79 -79
  7. package/cli/commands/update.js +121 -121
  8. package/cli/commands/wizard.js +134 -134
  9. package/cli/fragments/archive/az.md +97 -97
  10. package/cli/fragments/archive/gh.md +96 -96
  11. package/cli/fragments/archive/gl.md +96 -96
  12. package/cli/fragments/archive/none.md +75 -75
  13. package/cli/fragments/guardrails/codegraph.md +5 -5
  14. package/cli/fragments/guardrails/humanizer.md +4 -4
  15. package/cli/fragments/guardrails/memory.md +4 -4
  16. package/cli/fragments/guardrails/rtk.md +3 -3
  17. package/cli/fragments/guardrails/simple-english.md +4 -4
  18. package/cli/fragments/ops-backlog/az.md +28 -28
  19. package/cli/fragments/ops-backlog/gh.md +28 -28
  20. package/cli/fragments/ops-backlog/jira.md +28 -28
  21. package/cli/fragments/ops-review/az.md +62 -62
  22. package/cli/fragments/ops-review/gh.md +51 -51
  23. package/cli/fragments/ops-review/gl.md +55 -55
  24. package/cli/fragments/ops-ship/az.md +81 -81
  25. package/cli/fragments/ops-ship/gh.md +67 -67
  26. package/cli/fragments/ops-ship/gl.md +84 -84
  27. package/cli/index.js +116 -116
  28. package/cli/presets/agents-content.json +34 -34
  29. package/cli/presets/browser.json +10 -10
  30. package/cli/presets/clean.json +21 -21
  31. package/cli/presets/models.json +68 -68
  32. package/cli/presets/optimization.json +32 -32
  33. package/cli/presets/platforms.json +76 -76
  34. package/cli/presets/source.json +23 -23
  35. package/cli/steps/browser/index.js +48 -48
  36. package/cli/steps/clean/index.js +120 -120
  37. package/cli/steps/copy/agents.js +120 -120
  38. package/cli/steps/copy/commands.js +79 -79
  39. package/cli/steps/copy/fullstack-engineer.js +89 -89
  40. package/cli/steps/copy/index.js +88 -88
  41. package/cli/steps/copy/opencode-json.js +324 -324
  42. package/cli/steps/copy/skills.js +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 +81 -81
  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 +10 -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 +5 -5
  92. package/harness/.opencode/plugins/pc-commit-coauthor.js +41 -0
  93. package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
  94. package/harness/.opencode/plugins/pc-subagent-tiers.js +367 -367
  95. package/harness/.opencode/plugins/pc-system-reminders.js +404 -386
  96. package/harness/.opencode/scripts/check-diff.mjs +279 -252
  97. package/harness/.opencode/scripts/code-rules.mjs +288 -288
  98. package/harness/.opencode/scripts/platform-manifest.mjs +112 -0
  99. package/harness/AGENTS.md +49 -49
  100. package/harness/ARCHITECTURE.md +13 -13
  101. package/harness/DESIGN.md +13 -13
  102. package/harness/opencode.jsonc +36 -36
  103. package/harness/openspec/config.yaml +20 -20
  104. package/harness/skills-lock.json +17 -17
  105. package/package.json +14 -12
  106. package/skills/README.md +18 -18
  107. package/skills/agent-harness-cli/SKILL.md +86 -86
  108. package/skills/browser-automation/SKILL.md +72 -72
  109. package/skills/pc-guardrails-generic/SKILL.md +53 -53
  110. package/skills/pc-guardrails-project/SKILL.md +8 -8
  111. package/skills/pc-make-architecture/SKILL.md +33 -32
  112. package/skills/pc-make-architecture/structure-template.md +64 -64
  113. package/skills/pc-make-design/SKILL.md +45 -45
  114. package/skills/pc-make-engineer/SKILL.md +59 -59
  115. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  116. package/skills/pc-make-engineer/template.md +36 -36
  117. package/skills/pc-make-guardrails/SKILL.md +44 -44
  118. package/skills/pc-make-guardrails/category-reference.md +112 -112
  119. package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
  120. package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
  121. package/skills/pc-make-user-model/SKILL.md +56 -56
  122. package/skills/pc-ops-ship/SKILL.md +22 -22
  123. package/skills/pc-plan-apply/SKILL.md +88 -88
  124. package/skills/pc-plan-apply/simple-mode.md +21 -21
  125. package/skills/pc-plan-archive/SKILL.md +66 -66
  126. package/skills/pc-plan-explore/SKILL.md +21 -21
  127. package/skills/pc-plan-goal/SKILL.md +91 -91
  128. package/skills/pc-plan-goal/branching.md +39 -39
  129. package/skills/pc-plan-goal/failure-policy.md +30 -30
  130. package/skills/pc-plan-goal/output-mode.md +10 -10
  131. package/skills/pc-plan-goal/output.md +71 -71
  132. package/skills/pc-plan-propose/SKILL.md +125 -125
  133. package/skills/pc-plan-propose/task-annotation.md +39 -39
  134. package/skills/pc-plan-quick/SKILL.md +46 -46
  135. package/skills/pc-plan-story/SKILL.md +93 -93
  136. package/skills/pc-repo-audit/SKILL.md +44 -44
  137. package/skills/pc-repo-help/SKILL.md +87 -87
  138. package/skills/pc-repo-initialize/SKILL.md +119 -119
  139. package/skills/pc-repo-onboard/SKILL.md +32 -32
  140. package/skills/pc-repo-verify/SKILL.md +150 -150
  141. package/skills/pc-userstory-az/SKILL.md +71 -71
  142. package/skills/pc-userstory-browser/SKILL.md +64 -64
  143. package/skills/pc-userstory-gh/SKILL.md +63 -63
  144. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,324 +1,324 @@
1
- import fse from 'fs-extra'
2
- import { parse } from 'jsonc-parser'
3
- import path from 'node:path'
4
- import { success } from '../../utils/exec.js'
5
-
6
- // The harness targets OpenCode 2. This patcher migrates a repository's
7
- // opencode.jsonc from the V1 shape to the native V2 shape, preserving any keys
8
- // it does not know about. V2 reads V1 config in memory, but the harness wants a
9
- // file that is already native so behaviour does not depend on the compatibility
10
- // layer, and the CI merge step requires comment-free JSON.
11
- //
12
- // V1 -> V2 field map this handles:
13
- // plugin[] -> plugins[]
14
- // permissions object -> ordered permissions[] rules (bash->shell, task->subagent)
15
- // agent{} -> agents{} (prompt->system, permission->permissions)
16
- // skills.paths[] -> skills[]
17
- // mcp.<name> -> mcp.servers.<name> (enabled -> !disabled)
18
- // compaction.prune -> removed
19
- // compaction.reserved -> compaction.buffer
20
- // experimental.mcp_timeout -> mcp.timeout.{catalog,execution}
21
- // instructions -> removed (V2 loads AGENTS.md, not this field)
22
-
23
- const CONFIG_SCHEMA = 'https://opencode.ai/config.json'
24
-
25
- // Third-party plugins the harness configures, and how they move to V2. The
26
- // notifier is dropped: V2 has native attention notifications and sounds.
27
- const DROPPED_PLUGIN_PREFIXES = [
28
- '@opencode-ai/',
29
- '@mohak34/opencode-notifier',
30
- '@different-ai/opencode-browser',
31
- '@slkiser/opencode-quota',
32
- ]
33
-
34
- // V1 permission keys -> V2 permission actions.
35
- const ACTION_RENAMES = {
36
- bash: 'shell',
37
- task: 'subagent',
38
- write: 'edit',
39
- patch: 'edit',
40
- }
41
-
42
- function basePackageName(entry) {
43
- if (typeof entry !== 'string') return ''
44
- const at = entry.lastIndexOf('@')
45
- return at > 0 ? entry.slice(0, at) : entry
46
- }
47
-
48
- // V1 permission keys that are not V2 permission actions. todowrite is a built-in
49
- // tool allowed by the base policy, so it needs no rule.
50
- const NON_ACTION_PERMISSION_KEYS = new Set(['todowrite'])
51
-
52
- function permissionMapToRules(map) {
53
- const rules = []
54
- for (const [key, value] of Object.entries(map ?? {})) {
55
- if (value !== 'allow' && value !== 'deny' && value !== 'ask') continue
56
- if (NON_ACTION_PERMISSION_KEYS.has(key)) continue
57
- rules.push({ action: ACTION_RENAMES[key] ?? key, resource: '*', effect: value })
58
- }
59
- return rules
60
- }
61
-
62
- function upsertRules(existing, additions) {
63
- const merged = [...existing]
64
- for (const rule of additions) {
65
- const index = merged.findIndex(r => r.action === rule.action && r.resource === rule.resource)
66
- if (index === -1) merged.push(rule)
67
- else merged[index] = rule
68
- }
69
- return merged
70
- }
71
-
72
- function migratePluginArray(obj) {
73
- if (!Array.isArray(obj.plugin)) return false
74
- const plugins = []
75
- for (const entry of obj.plugin) {
76
- if (typeof entry !== 'string') {
77
- plugins.push(entry)
78
- continue
79
- }
80
- const base = basePackageName(entry)
81
- if (DROPPED_PLUGIN_PREFIXES.some(prefix => base.startsWith(prefix))) continue
82
- plugins.push(entry)
83
- }
84
- obj.plugins = plugins
85
- delete obj.plugin
86
- return true
87
- }
88
-
89
- function migrateAgents(obj) {
90
- let changed = false
91
- const agents = { ...(obj.agents ?? {}) }
92
-
93
- if (obj.agent && typeof obj.agent === 'object' && !Array.isArray(obj.agent)) {
94
- for (const [id, raw] of Object.entries(obj.agent)) {
95
- if (!raw || typeof raw !== 'object') continue
96
- const agent = { ...raw }
97
- if (agent.prompt !== undefined) {
98
- agent.system = agent.prompt
99
- delete agent.prompt
100
- }
101
- if (agent.disable !== undefined) {
102
- agent.disabled = agent.disable
103
- delete agent.disable
104
- }
105
- if (agent.permission && typeof agent.permission === 'object') {
106
- agent.permissions = upsertRules(agent.permissions ?? [], permissionMapToRules(agent.permission))
107
- delete agent.permission
108
- }
109
- delete agent.maxSteps
110
- agents[id] = agent
111
- }
112
- delete obj.agent
113
- changed = true
114
- }
115
-
116
- // build and plan are the only primaries; plan is read-only. Guarantee both
117
- // even when the repo never had an explicit entry.
118
- const build = { ...(agents.build ?? {}), mode: 'primary' }
119
- delete build.disabled
120
- delete build.disable
121
- if (JSON.stringify(agents.build) !== JSON.stringify(build)) {
122
- agents.build = build
123
- changed = true
124
- }
125
- const plan = {
126
- ...(agents.plan ?? {}),
127
- mode: 'primary',
128
- permissions: upsertRules(agents.plan?.permissions ?? [], [
129
- { action: 'edit', resource: '*', effect: 'deny' },
130
- { action: 'subagent', resource: '*', effect: 'deny' },
131
- ]),
132
- }
133
- delete plan.disabled
134
- delete plan.disable
135
- if (JSON.stringify(agents.plan) !== JSON.stringify(plan)) {
136
- agents.plan = plan
137
- changed = true
138
- }
139
-
140
- if (changed) obj.agents = agents
141
- return changed
142
- }
143
-
144
- function migrateMcp(obj) {
145
- if (!obj.mcp || typeof obj.mcp !== 'object' || Array.isArray(obj.mcp)) return false
146
- if (obj.mcp.servers) return false
147
- const servers = {}
148
- let inheritedTimeout = 300000
149
- for (const [name, raw] of Object.entries(obj.mcp)) {
150
- if (!raw || typeof raw !== 'object') continue
151
- const server = { ...raw }
152
- if ('enabled' in server) {
153
- server.disabled = !server.enabled
154
- delete server.enabled
155
- }
156
- if (typeof server.timeout === 'number') {
157
- inheritedTimeout = server.timeout
158
- server.timeout = { catalog: server.timeout, execution: server.timeout }
159
- }
160
- servers[name] = server
161
- }
162
- obj.mcp = {
163
- servers,
164
- timeout: { catalog: inheritedTimeout, execution: inheritedTimeout },
165
- }
166
- return true
167
- }
168
-
169
- /** Mutate `obj` in place to the native V2 shape; returns true when it changed. */
170
- function migrateToV2(obj) {
171
- let changed = false
172
-
173
- if (obj.$schema !== CONFIG_SCHEMA) {
174
- obj.$schema = CONFIG_SCHEMA
175
- changed = true
176
- }
177
-
178
- if ('instructions' in obj) {
179
- delete obj.instructions
180
- changed = true
181
- }
182
-
183
- if (obj.experimental && typeof obj.experimental === 'object') {
184
- for (const key of ['openTelemetry', 'batch_tool', 'primary_tools', 'continue_loop_on_deny', 'mcp_timeout']) {
185
- if (key in obj.experimental) {
186
- delete obj.experimental[key]
187
- changed = true
188
- }
189
- }
190
- if (Object.keys(obj.experimental).length === 0) {
191
- delete obj.experimental
192
- changed = true
193
- }
194
- }
195
-
196
- const compaction = obj.compaction && typeof obj.compaction === 'object' ? { ...obj.compaction } : {}
197
- if ('prune' in compaction) {
198
- delete compaction.prune
199
- changed = true
200
- }
201
- if (compaction.auto !== true) {
202
- compaction.auto = true
203
- changed = true
204
- }
205
- if ('reserved' in compaction) {
206
- compaction.buffer = compaction.reserved
207
- delete compaction.reserved
208
- changed = true
209
- }
210
- if (!Number.isFinite(compaction.buffer)) {
211
- compaction.buffer = 10000
212
- changed = true
213
- }
214
- if (JSON.stringify(obj.compaction) !== JSON.stringify(compaction)) {
215
- obj.compaction = compaction
216
- changed = true
217
- }
218
-
219
- const additions = []
220
- if (obj.permission && typeof obj.permission === 'object' && !Array.isArray(obj.permission)) {
221
- additions.push(...permissionMapToRules(obj.permission))
222
- delete obj.permission
223
- changed = true
224
- }
225
- if (additions.length > 0) {
226
- const merged = upsertRules(Array.isArray(obj.permissions) ? obj.permissions : [], additions)
227
- if (JSON.stringify(obj.permissions) !== JSON.stringify(merged)) {
228
- obj.permissions = merged
229
- changed = true
230
- }
231
- }
232
-
233
- if (obj.skills && typeof obj.skills === 'object' && !Array.isArray(obj.skills)) {
234
- const paths = Array.isArray(obj.skills.paths) ? obj.skills.paths : []
235
- obj.skills = paths.includes('.agents/skills') ? paths : [...paths, '.agents/skills']
236
- changed = true
237
- } else if (Array.isArray(obj.skills)) {
238
- if (!obj.skills.includes('.agents/skills')) {
239
- obj.skills = [...obj.skills, '.agents/skills']
240
- changed = true
241
- }
242
- } else {
243
- obj.skills = ['.agents/skills']
244
- changed = true
245
- }
246
-
247
- if (migrateAgents(obj)) changed = true
248
- if (migrateMcp(obj)) changed = true
249
- if (migratePluginArray(obj)) changed = true
250
-
251
- // A session starts read-only: fullstack-engineer is a subagent now, so a
252
- // default_agent left pointing at it has no valid primary to open with.
253
- if (obj.default_agent !== 'plan' && obj.default_agent !== 'build') {
254
- obj.default_agent = 'plan'
255
- changed = true
256
- }
257
-
258
- return changed
259
- }
260
-
261
- export async function patchOpencodeJson(cwd = process.cwd()) {
262
- const opencodePath = path.join(cwd, 'opencode.jsonc')
263
-
264
- let text
265
- if (await fse.pathExists(opencodePath)) {
266
- text = await fse.readFile(opencodePath, 'utf-8')
267
- } else {
268
- text = JSON.stringify({ $schema: CONFIG_SCHEMA }, null, 2)
269
- }
270
-
271
- const errors = []
272
- const parsed = parse(text, errors)
273
- if (errors.length > 0 || typeof parsed !== 'object' || parsed === null) {
274
- return { patched: false, reason: 'parse error' }
275
- }
276
-
277
- if (!migrateToV2(parsed)) {
278
- return { patched: false }
279
- }
280
-
281
- await fse.writeFile(opencodePath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8')
282
- success('Migrated opencode.jsonc to the native OpenCode 2 shape')
283
- return { patched: true }
284
- }
285
-
286
- // OpenCode 2 plugin packages. @opencode/plugin is the V2 plugin API; the V1
287
- // @opencode-ai/plugin and the retired TUI peers are removed on update.
288
- const OPENCODE_PACKAGE_DEPENDENCIES = {
289
- '@opencode/plugin': '2.0.22',
290
- }
291
-
292
- const STALE_PACKAGE_DEPENDENCIES = [
293
- '@opencode-ai/plugin',
294
- '@mohak34/opencode-notifier',
295
- '@different-ai/opencode-browser',
296
- '@opentui/core',
297
- '@opentui/solid',
298
- 'solid-js',
299
- ]
300
-
301
- export async function patchOpencodePackage(cwd = process.cwd()) {
302
- const packagePath = path.join(cwd, '.opencode', 'package.json')
303
- if (!await fse.pathExists(packagePath)) return { patched: false }
304
-
305
- const packageJson = await fse.readJson(packagePath).catch(() => null)
306
- if (!packageJson?.dependencies) return { patched: false }
307
-
308
- let patched = false
309
- for (const [name, version] of Object.entries(OPENCODE_PACKAGE_DEPENDENCIES)) {
310
- if (packageJson.dependencies[name] === version) continue
311
- packageJson.dependencies[name] = version
312
- patched = true
313
- }
314
- for (const name of STALE_PACKAGE_DEPENDENCIES) {
315
- if (!(name in packageJson.dependencies)) continue
316
- delete packageJson.dependencies[name]
317
- patched = true
318
- }
319
- if (!patched) return { patched: false }
320
-
321
- await fse.writeJson(packagePath, packageJson, { spaces: 2 })
322
- success('Updated OpenCode plugin dependencies in .opencode/package.json')
323
- return { patched: true }
324
- }
1
+ import fse from 'fs-extra'
2
+ import { parse } from 'jsonc-parser'
3
+ import path from 'node:path'
4
+ import { success } from '../../utils/exec.js'
5
+
6
+ // The harness targets OpenCode 2. This patcher migrates a repository's
7
+ // opencode.jsonc from the V1 shape to the native V2 shape, preserving any keys
8
+ // it does not know about. V2 reads V1 config in memory, but the harness wants a
9
+ // file that is already native so behaviour does not depend on the compatibility
10
+ // layer, and the CI merge step requires comment-free JSON.
11
+ //
12
+ // V1 -> V2 field map this handles:
13
+ // plugin[] -> plugins[]
14
+ // permissions object -> ordered permissions[] rules (bash->shell, task->subagent)
15
+ // agent{} -> agents{} (prompt->system, permission->permissions)
16
+ // skills.paths[] -> skills[]
17
+ // mcp.<name> -> mcp.servers.<name> (enabled -> !disabled)
18
+ // compaction.prune -> removed
19
+ // compaction.reserved -> compaction.buffer
20
+ // experimental.mcp_timeout -> mcp.timeout.{catalog,execution}
21
+ // instructions -> removed (V2 loads AGENTS.md, not this field)
22
+
23
+ const CONFIG_SCHEMA = 'https://opencode.ai/config.json'
24
+
25
+ // Third-party plugins the harness configures, and how they move to V2. The
26
+ // notifier is dropped: V2 has native attention notifications and sounds.
27
+ const DROPPED_PLUGIN_PREFIXES = [
28
+ '@opencode-ai/',
29
+ '@mohak34/opencode-notifier',
30
+ '@different-ai/opencode-browser',
31
+ '@slkiser/opencode-quota',
32
+ ]
33
+
34
+ // V1 permission keys -> V2 permission actions.
35
+ const ACTION_RENAMES = {
36
+ bash: 'shell',
37
+ task: 'subagent',
38
+ write: 'edit',
39
+ patch: 'edit',
40
+ }
41
+
42
+ function basePackageName(entry) {
43
+ if (typeof entry !== 'string') return ''
44
+ const at = entry.lastIndexOf('@')
45
+ return at > 0 ? entry.slice(0, at) : entry
46
+ }
47
+
48
+ // V1 permission keys that are not V2 permission actions. todowrite is a built-in
49
+ // tool allowed by the base policy, so it needs no rule.
50
+ const NON_ACTION_PERMISSION_KEYS = new Set(['todowrite'])
51
+
52
+ function permissionMapToRules(map) {
53
+ const rules = []
54
+ for (const [key, value] of Object.entries(map ?? {})) {
55
+ if (value !== 'allow' && value !== 'deny' && value !== 'ask') continue
56
+ if (NON_ACTION_PERMISSION_KEYS.has(key)) continue
57
+ rules.push({ action: ACTION_RENAMES[key] ?? key, resource: '*', effect: value })
58
+ }
59
+ return rules
60
+ }
61
+
62
+ function upsertRules(existing, additions) {
63
+ const merged = [...existing]
64
+ for (const rule of additions) {
65
+ const index = merged.findIndex(r => r.action === rule.action && r.resource === rule.resource)
66
+ if (index === -1) merged.push(rule)
67
+ else merged[index] = rule
68
+ }
69
+ return merged
70
+ }
71
+
72
+ function migratePluginArray(obj) {
73
+ if (!Array.isArray(obj.plugin)) return false
74
+ const plugins = []
75
+ for (const entry of obj.plugin) {
76
+ if (typeof entry !== 'string') {
77
+ plugins.push(entry)
78
+ continue
79
+ }
80
+ const base = basePackageName(entry)
81
+ if (DROPPED_PLUGIN_PREFIXES.some(prefix => base.startsWith(prefix))) continue
82
+ plugins.push(entry)
83
+ }
84
+ obj.plugins = plugins
85
+ delete obj.plugin
86
+ return true
87
+ }
88
+
89
+ function migrateAgents(obj) {
90
+ let changed = false
91
+ const agents = { ...(obj.agents ?? {}) }
92
+
93
+ if (obj.agent && typeof obj.agent === 'object' && !Array.isArray(obj.agent)) {
94
+ for (const [id, raw] of Object.entries(obj.agent)) {
95
+ if (!raw || typeof raw !== 'object') continue
96
+ const agent = { ...raw }
97
+ if (agent.prompt !== undefined) {
98
+ agent.system = agent.prompt
99
+ delete agent.prompt
100
+ }
101
+ if (agent.disable !== undefined) {
102
+ agent.disabled = agent.disable
103
+ delete agent.disable
104
+ }
105
+ if (agent.permission && typeof agent.permission === 'object') {
106
+ agent.permissions = upsertRules(agent.permissions ?? [], permissionMapToRules(agent.permission))
107
+ delete agent.permission
108
+ }
109
+ delete agent.maxSteps
110
+ agents[id] = agent
111
+ }
112
+ delete obj.agent
113
+ changed = true
114
+ }
115
+
116
+ // build and plan are the only primaries; plan is read-only. Guarantee both
117
+ // even when the repo never had an explicit entry.
118
+ const build = { ...(agents.build ?? {}), mode: 'primary' }
119
+ delete build.disabled
120
+ delete build.disable
121
+ if (JSON.stringify(agents.build) !== JSON.stringify(build)) {
122
+ agents.build = build
123
+ changed = true
124
+ }
125
+ const plan = {
126
+ ...(agents.plan ?? {}),
127
+ mode: 'primary',
128
+ permissions: upsertRules(agents.plan?.permissions ?? [], [
129
+ { action: 'edit', resource: '*', effect: 'deny' },
130
+ { action: 'subagent', resource: '*', effect: 'deny' },
131
+ ]),
132
+ }
133
+ delete plan.disabled
134
+ delete plan.disable
135
+ if (JSON.stringify(agents.plan) !== JSON.stringify(plan)) {
136
+ agents.plan = plan
137
+ changed = true
138
+ }
139
+
140
+ if (changed) obj.agents = agents
141
+ return changed
142
+ }
143
+
144
+ function migrateMcp(obj) {
145
+ if (!obj.mcp || typeof obj.mcp !== 'object' || Array.isArray(obj.mcp)) return false
146
+ if (obj.mcp.servers) return false
147
+ const servers = {}
148
+ let inheritedTimeout = 300000
149
+ for (const [name, raw] of Object.entries(obj.mcp)) {
150
+ if (!raw || typeof raw !== 'object') continue
151
+ const server = { ...raw }
152
+ if ('enabled' in server) {
153
+ server.disabled = !server.enabled
154
+ delete server.enabled
155
+ }
156
+ if (typeof server.timeout === 'number') {
157
+ inheritedTimeout = server.timeout
158
+ server.timeout = { catalog: server.timeout, execution: server.timeout }
159
+ }
160
+ servers[name] = server
161
+ }
162
+ obj.mcp = {
163
+ servers,
164
+ timeout: { catalog: inheritedTimeout, execution: inheritedTimeout },
165
+ }
166
+ return true
167
+ }
168
+
169
+ /** Mutate `obj` in place to the native V2 shape; returns true when it changed. */
170
+ function migrateToV2(obj) {
171
+ let changed = false
172
+
173
+ if (obj.$schema !== CONFIG_SCHEMA) {
174
+ obj.$schema = CONFIG_SCHEMA
175
+ changed = true
176
+ }
177
+
178
+ if ('instructions' in obj) {
179
+ delete obj.instructions
180
+ changed = true
181
+ }
182
+
183
+ if (obj.experimental && typeof obj.experimental === 'object') {
184
+ for (const key of ['openTelemetry', 'batch_tool', 'primary_tools', 'continue_loop_on_deny', 'mcp_timeout']) {
185
+ if (key in obj.experimental) {
186
+ delete obj.experimental[key]
187
+ changed = true
188
+ }
189
+ }
190
+ if (Object.keys(obj.experimental).length === 0) {
191
+ delete obj.experimental
192
+ changed = true
193
+ }
194
+ }
195
+
196
+ const compaction = obj.compaction && typeof obj.compaction === 'object' ? { ...obj.compaction } : {}
197
+ if ('prune' in compaction) {
198
+ delete compaction.prune
199
+ changed = true
200
+ }
201
+ if (compaction.auto !== true) {
202
+ compaction.auto = true
203
+ changed = true
204
+ }
205
+ if ('reserved' in compaction) {
206
+ compaction.buffer = compaction.reserved
207
+ delete compaction.reserved
208
+ changed = true
209
+ }
210
+ if (!Number.isFinite(compaction.buffer)) {
211
+ compaction.buffer = 10000
212
+ changed = true
213
+ }
214
+ if (JSON.stringify(obj.compaction) !== JSON.stringify(compaction)) {
215
+ obj.compaction = compaction
216
+ changed = true
217
+ }
218
+
219
+ const additions = []
220
+ if (obj.permission && typeof obj.permission === 'object' && !Array.isArray(obj.permission)) {
221
+ additions.push(...permissionMapToRules(obj.permission))
222
+ delete obj.permission
223
+ changed = true
224
+ }
225
+ if (additions.length > 0) {
226
+ const merged = upsertRules(Array.isArray(obj.permissions) ? obj.permissions : [], additions)
227
+ if (JSON.stringify(obj.permissions) !== JSON.stringify(merged)) {
228
+ obj.permissions = merged
229
+ changed = true
230
+ }
231
+ }
232
+
233
+ if (obj.skills && typeof obj.skills === 'object' && !Array.isArray(obj.skills)) {
234
+ const paths = Array.isArray(obj.skills.paths) ? obj.skills.paths : []
235
+ obj.skills = paths.includes('.agents/skills') ? paths : [...paths, '.agents/skills']
236
+ changed = true
237
+ } else if (Array.isArray(obj.skills)) {
238
+ if (!obj.skills.includes('.agents/skills')) {
239
+ obj.skills = [...obj.skills, '.agents/skills']
240
+ changed = true
241
+ }
242
+ } else {
243
+ obj.skills = ['.agents/skills']
244
+ changed = true
245
+ }
246
+
247
+ if (migrateAgents(obj)) changed = true
248
+ if (migrateMcp(obj)) changed = true
249
+ if (migratePluginArray(obj)) changed = true
250
+
251
+ // A session starts read-only: fullstack-engineer is a subagent now, so a
252
+ // default_agent left pointing at it has no valid primary to open with.
253
+ if (obj.default_agent !== 'plan' && obj.default_agent !== 'build') {
254
+ obj.default_agent = 'plan'
255
+ changed = true
256
+ }
257
+
258
+ return changed
259
+ }
260
+
261
+ export async function patchOpencodeJson(cwd = process.cwd()) {
262
+ const opencodePath = path.join(cwd, 'opencode.jsonc')
263
+
264
+ let text
265
+ if (await fse.pathExists(opencodePath)) {
266
+ text = await fse.readFile(opencodePath, 'utf-8')
267
+ } else {
268
+ text = JSON.stringify({ $schema: CONFIG_SCHEMA }, null, 2)
269
+ }
270
+
271
+ const errors = []
272
+ const parsed = parse(text, errors)
273
+ if (errors.length > 0 || typeof parsed !== 'object' || parsed === null) {
274
+ return { patched: false, reason: 'parse error' }
275
+ }
276
+
277
+ if (!migrateToV2(parsed)) {
278
+ return { patched: false }
279
+ }
280
+
281
+ await fse.writeFile(opencodePath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8')
282
+ success('Migrated opencode.jsonc to the native OpenCode 2 shape')
283
+ return { patched: true }
284
+ }
285
+
286
+ // OpenCode 2 plugin packages. @opencode/plugin is the V2 plugin API; the V1
287
+ // @opencode-ai/plugin and the retired TUI peers are removed on update.
288
+ const OPENCODE_PACKAGE_DEPENDENCIES = {
289
+ '@opencode/plugin': '2.0.22',
290
+ }
291
+
292
+ const STALE_PACKAGE_DEPENDENCIES = [
293
+ '@opencode-ai/plugin',
294
+ '@mohak34/opencode-notifier',
295
+ '@different-ai/opencode-browser',
296
+ '@opentui/core',
297
+ '@opentui/solid',
298
+ 'solid-js',
299
+ ]
300
+
301
+ export async function patchOpencodePackage(cwd = process.cwd()) {
302
+ const packagePath = path.join(cwd, '.opencode', 'package.json')
303
+ if (!await fse.pathExists(packagePath)) return { patched: false }
304
+
305
+ const packageJson = await fse.readJson(packagePath).catch(() => null)
306
+ if (!packageJson?.dependencies) return { patched: false }
307
+
308
+ let patched = false
309
+ for (const [name, version] of Object.entries(OPENCODE_PACKAGE_DEPENDENCIES)) {
310
+ if (packageJson.dependencies[name] === version) continue
311
+ packageJson.dependencies[name] = version
312
+ patched = true
313
+ }
314
+ for (const name of STALE_PACKAGE_DEPENDENCIES) {
315
+ if (!(name in packageJson.dependencies)) continue
316
+ delete packageJson.dependencies[name]
317
+ patched = true
318
+ }
319
+ if (!patched) return { patched: false }
320
+
321
+ await fse.writeJson(packagePath, packageJson, { spaces: 2 })
322
+ success('Updated OpenCode plugin dependencies in .opencode/package.json')
323
+ return { patched: true }
324
+ }