@plainconceptsplatform/agent-harness 2.7.0 → 3.0.1

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 (61) hide show
  1. package/README.md +12 -6
  2. package/cli/commands/migrate.js +1 -1
  3. package/cli/presets/quota.json +6 -2
  4. package/cli/steps/copy/opencode-json.js +260 -135
  5. package/cli/steps/copy/skills.js +12 -2
  6. package/cli/steps/optimization/quota.js +36 -20
  7. package/cli/utils/copy.js +0 -1
  8. package/harness/.opencode/_gitignore +9 -9
  9. package/harness/.opencode/package.json +1 -5
  10. package/harness/.opencode/plugins/pc-subagent-monitor.js +144 -179
  11. package/harness/.opencode/plugins/pc-subagent-tiers.js +216 -248
  12. package/harness/.opencode/plugins/pc-system-reminders.js +386 -434
  13. package/harness/opencode.jsonc +21 -32
  14. package/package.json +3 -1
  15. package/skills/README.md +18 -0
  16. package/skills/agent-harness-cli/SKILL.md +86 -0
  17. package/skills/pc-guardrails-generic/SKILL.md +52 -0
  18. package/{harness/.agents/skills → skills}/pc-make-architecture/SKILL.md +1 -0
  19. package/skills/pc-make-architecture/structure-template.md +64 -0
  20. package/{harness/.agents/skills → skills}/pc-make-engineer/SKILL.md +59 -59
  21. package/{harness/.agents/skills → skills}/pc-make-engineer/template.md +36 -42
  22. package/{harness/.agents/skills → skills}/pc-make-guardrails/SKILL.md +1 -0
  23. package/{harness/.agents/skills → skills}/pc-make-guardrails/category-reference.md +40 -1
  24. package/harness/.agents/skills/pc-guardrails-generic/SKILL.md +0 -47
  25. package/harness/.agents/skills/pc-make-architecture/structure-template.md +0 -38
  26. package/harness/.agents/skills/pc-make-evidence-scaffold/SKILL.md +0 -18
  27. package/harness/.agents/skills/pc-make-evidence-scaffold/evidence-contract.md +0 -29
  28. package/harness/.opencode/commands/make-evidence-scaffold.md +0 -5
  29. package/harness/.opencode/tui/pc-subagents.tsx +0 -98
  30. package/harness/.opencode/tui.json +0 -6
  31. /package/{harness/.agents/skills → skills}/browser-automation/SKILL.md +0 -0
  32. /package/{harness/.agents/skills → skills}/pc-guardrails-project/SKILL.md +0 -0
  33. /package/{harness/.agents/skills → skills}/pc-make-design/SKILL.md +0 -0
  34. /package/{harness/.agents/skills → skills}/pc-make-engineer/signal-mapping.md +0 -0
  35. /package/{harness/.agents/skills → skills}/pc-make-merge-risk-assess/SKILL.md +0 -0
  36. /package/{harness/.agents/skills → skills}/pc-make-merge-risk-assess/category-reference.md +0 -0
  37. /package/{harness/.agents/skills → skills}/pc-make-user-model/SKILL.md +0 -0
  38. /package/{harness/.agents/skills → skills}/pc-ops-evidence/SKILL.md +0 -0
  39. /package/{harness/.agents/skills → skills}/pc-ops-ship/SKILL.md +0 -0
  40. /package/{harness/.agents/skills → skills}/pc-plan-apply/SKILL.md +0 -0
  41. /package/{harness/.agents/skills → skills}/pc-plan-apply/simple-mode.md +0 -0
  42. /package/{harness/.agents/skills → skills}/pc-plan-archive/SKILL.md +0 -0
  43. /package/{harness/.agents/skills → skills}/pc-plan-explore/SKILL.md +0 -0
  44. /package/{harness/.agents/skills → skills}/pc-plan-goal/SKILL.md +0 -0
  45. /package/{harness/.agents/skills → skills}/pc-plan-goal/branching.md +0 -0
  46. /package/{harness/.agents/skills → skills}/pc-plan-goal/failure-policy.md +0 -0
  47. /package/{harness/.agents/skills → skills}/pc-plan-goal/output-mode.md +0 -0
  48. /package/{harness/.agents/skills → skills}/pc-plan-goal/output.md +0 -0
  49. /package/{harness/.agents/skills → skills}/pc-plan-propose/SKILL.md +0 -0
  50. /package/{harness/.agents/skills → skills}/pc-plan-propose/task-annotation.md +0 -0
  51. /package/{harness/.agents/skills → skills}/pc-plan-quick/SKILL.md +0 -0
  52. /package/{harness/.agents/skills → skills}/pc-plan-story/SKILL.md +0 -0
  53. /package/{harness/.agents/skills → skills}/pc-repo-audit/SKILL.md +0 -0
  54. /package/{harness/.agents/skills → skills}/pc-repo-help/SKILL.md +0 -0
  55. /package/{harness/.agents/skills → skills}/pc-repo-initialize/SKILL.md +0 -0
  56. /package/{harness/.agents/skills → skills}/pc-repo-onboard/SKILL.md +0 -0
  57. /package/{harness/.agents/skills → skills}/pc-repo-verify/SKILL.md +0 -0
  58. /package/{harness/.agents/skills → skills}/pc-userstory-az/SKILL.md +0 -0
  59. /package/{harness/.agents/skills → skills}/pc-userstory-browser/SKILL.md +0 -0
  60. /package/{harness/.agents/skills → skills}/pc-userstory-gh/SKILL.md +0 -0
  61. /package/{harness/.agents/skills → skills}/pc-userstory-jira/SKILL.md +0 -0
package/README.md CHANGED
@@ -284,17 +284,15 @@ your-project/
284
284
  ├── AGENTS.md ← bootstrap mode, replaced after first "/repo-initialize"
285
285
  ├── ARCHITECTURE.md ← prompt for agents to fill in from your codebase
286
286
  ├── DESIGN.md ← prompt for agents to fill in from your codebase
287
+ ├── opencode.jsonc ← native OpenCode 2 config: agents, permissions, MCP servers
287
288
  ├── .opencode/
288
- │ ├── opencode.json ← default model + plugin config
289
289
  │ ├── harness.json ← harness config: platform, models, maxConcurrentAgents
290
290
  │ ├── harness-managed.json ← hashes of every managed file, so "update" spares your edits
291
291
  │ ├── agents/ ← build.md + plan.md (generated primaries), fullstack-engineer,
292
292
  │ │ and user-created *-engineer files (all subagents)
293
- │ ├── tui.json ← registers the Subagents sidebar panel
294
- │ ├── tui/
295
- │ │ └── pc-subagents.tsx ← TUI plugin: live Subagents panel in the sidebar
296
293
  │ └── plugins/
297
294
  │ ├── pc-subagent-monitor.js ← server plugin: subagent state → .opencode/harness-run.json, and the wave cap
295
+ │ ├── pc-subagent-tiers.js ← server plugin: derives tier agents and the two primaries
298
296
  │ └── pc-system-reminders.js ← server plugin: gates work on the agent's abilities, denies the never-rules
299
297
  └── .agents/
300
298
  └── skills/
@@ -304,6 +302,8 @@ your-project/
304
302
  └── browser-automation/
305
303
  ```
306
304
 
305
+ On OpenCode 2 the terminal client is configured globally (`~/.config/opencode/cli.json`), so the harness ships no project-local `tui.json`; the native session sidebar shows subagents without a sidecar panel.
306
+
307
307
  Platform skills ship as suffixed variants (`pc-userstory-gh`, `pc-userstory-az`, `pc-userstory-jira`, `pc-userstory-browser`) and the installer copies only the matching one, renamed to its generic name. Platform operations (ship, review, backlog) are injected directly into the `/ops-*` command files from `cli/fragments/ops-*/` during onboarding. Source-roots metadata lands in `.opencode/source-roots.json`. The always-shipped reminder plugin holds editing, shell and spawning until every ability under the agent's `## Abilities` is loaded, guardrails first, and re-arms after compaction. Token-optimization guidance is injected into `pc-guardrails-generic` marker blocks during onboarding.
308
308
 
309
309
  ---
@@ -348,7 +348,7 @@ Long unattended agent sessions can consume significant tokens. Set these control
348
348
 
349
349
  2. **Route models by task type**: use a fast and cheap model (for example `haiku`, `gpt-4o-mini`) for orchestration and status loops; reserve expensive models (for example `sonnet`, `opus`, `gpt-4o`) for implementation tasks only.
350
350
 
351
- 3. **Install the quota plugin**: the [`@slkiser/opencode-quota`](https://www.npmjs.com/package/@slkiser/opencode-quota) plugin adds `/quota` and `/quota_status` commands that surface real-time token usage inside OpenCode sessions.
351
+ 3. **Install the quota plugin**: the [`@slkiser/opencode-quota`](https://www.npmjs.com/package/@slkiser/opencode-quota) plugin adds `/quota` and `/quota_status` commands that surface real-time token usage inside OpenCode sessions. The harness installs it with popups, toasts and reset notifications switched off (`enableToast`, `resetNotifications`), slash reports kept in the chat (`tuiCommandDisplay: "inline"`), and the sidebar panel left on. Settings live in `opencode-quota/quota-toast.jsonc` at the repository root, which is where opencode-quota 5.x reads them.
352
352
 
353
353
  4. **Use `/quota` checkpoints**: run `/quota` before starting any `/plan-apply` session and after each agent wave. Pause at 75 percent consumed; stop at 90 percent.
354
354
 
@@ -400,7 +400,13 @@ Data the CLI reads lives in two places, split by kind:
400
400
 
401
401
  Every filename the harness writes into a project is declared once in `cli/utils/paths.js`. Change it there, and remember the OpenCode plugins under `harness/.opencode/plugins/` read those same names at runtime.
402
402
 
403
- `skills/` holds skills *about this CLI*, for agents that have to operate it. See [skills/README.md](./skills/README.md). They are not published to npm, and they are separate from the `pc-*` skills in `harness/` that get installed into your project.
403
+ `skills/` holds both the installable `pc-*` skills and the `agent-harness-cli` skill for agents operating this CLI. See [skills/README.md](./skills/README.md). All of them ship to npm and are discoverable by `npx skills`, so you can install individual skills into any project without the full harness:
404
+
405
+ ```bash
406
+ npx skills add PlainConceptsPlatform/agent-harness
407
+ # or scope to a single skill:
408
+ npx skills add PlainConceptsPlatform/agent-harness --skill pc-ops-ship
409
+ ```
404
410
 
405
411
  ```bash
406
412
  git clone https://github.com/PlainConceptsPlatform/agent-harness.git
@@ -9,7 +9,7 @@ import { CONFIG_FILE, MANIFEST_FILE, OPENCODE_DIR, USER_CONFIG_FILE } from '../u
9
9
  import { exit } from '../utils/process.js'
10
10
 
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
- const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../harness/.agents/skills')
12
+ const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../skills')
13
13
 
14
14
  // State files, old name to new. The run-state file is deleted rather than moved:
15
15
  // it is live wave state owned by the monitor plugin and is rebuilt on demand.
@@ -1,5 +1,5 @@
1
1
  {
2
- "plugin": "@slkiser/opencode-quota@4.2.0",
2
+ "plugin": "@slkiser/opencode-quota@5.0.1",
3
3
  "prompt": {
4
4
  "message": "Install opencode-quota with recommended defaults?",
5
5
  "default": true,
@@ -11,6 +11,10 @@
11
11
  "percentDisplayMode": "used",
12
12
  "showSessionTokens": true,
13
13
  "enableToast": false,
14
- "tuiCommandDisplay": "inline"
14
+ "resetNotifications": { "enabled": false },
15
+ "tuiCommandDisplay": "inline",
16
+ "tuiCompactStatus": { "enabled": false },
17
+ "tuiPromptBar": { "enabled": false },
18
+ "tuiSidebarPanel": { "enabled": true }
15
19
  }
16
20
  }
@@ -1,178 +1,303 @@
1
1
  import fse from 'fs-extra'
2
- import { applyEdits, modify, parse } from 'jsonc-parser'
2
+ import { parse } from 'jsonc-parser'
3
3
  import path from 'node:path'
4
4
  import { success } from '../../utils/exec.js'
5
5
 
6
- // The reminder plugin loads every agent ability before work, including
7
- // user-installed skills outside the pc-* and openspec-* namespaces.
8
- const SHARED_PERMISSIONS = [
9
- ['question', 'allow'],
10
- ['todowrite', 'allow'],
11
- ]
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'
12
24
 
13
- // agent-browser replaced the @different-ai/opencode-browser plugin; any entry
14
- // with that prefix in the plugin array is stale and gets stripped on update.
15
- const STALE_BROWSER_PLUGIN_PREFIX = '@different-ai/opencode-browser'
16
- const AGENT_BROWSER_MCP = {
17
- type: 'local',
18
- command: ['agent-browser', 'mcp', '--tools', 'core'],
19
- enabled: true,
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 OPENCODE_QUOTA_PACKAGE = '@slkiser/opencode-quota'
28
+ const OPENCODE_QUOTA_VERSION = '5.0.1'
29
+ const DROPPED_PLUGIN_PREFIXES = ['@opencode-ai/', '@mohak34/opencode-notifier', '@different-ai/opencode-browser']
30
+
31
+ // V1 permission keys -> V2 permission actions.
32
+ const ACTION_RENAMES = {
33
+ bash: 'shell',
34
+ task: 'subagent',
35
+ write: 'edit',
36
+ patch: 'edit',
20
37
  }
21
38
 
22
- function applyModify(text, jsonPath, value) {
23
- const edits = modify(text, jsonPath, value, {
24
- formattingOptions: { insertSpaces: true, tabSize: 2 },
25
- })
26
- return applyEdits(text, edits)
39
+ function basePackageName(entry) {
40
+ if (typeof entry !== 'string') return ''
41
+ const at = entry.lastIndexOf('@')
42
+ return at > 0 ? entry.slice(0, at) : entry
27
43
  }
28
44
 
29
- export async function patchOpencodeJson(cwd = process.cwd()) {
30
- const opencodePath = path.join(cwd, 'opencode.jsonc')
45
+ // V1 permission keys that are not V2 permission actions. todowrite is a built-in
46
+ // tool allowed by the base policy, so it needs no rule.
47
+ const NON_ACTION_PERMISSION_KEYS = new Set(['todowrite'])
31
48
 
32
- let text
33
- if (await fse.pathExists(opencodePath)) {
34
- text = await fse.readFile(opencodePath, 'utf-8')
35
- } else {
36
- text = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)
49
+ function permissionMapToRules(map) {
50
+ const rules = []
51
+ for (const [key, value] of Object.entries(map ?? {})) {
52
+ if (value !== 'allow' && value !== 'deny' && value !== 'ask') continue
53
+ if (NON_ACTION_PERMISSION_KEYS.has(key)) continue
54
+ rules.push({ action: ACTION_RENAMES[key] ?? key, resource: '*', effect: value })
37
55
  }
56
+ return rules
57
+ }
38
58
 
39
- const errors = []
40
- const parsed = parse(text, errors)
41
- if (errors.length > 0 || typeof parsed !== 'object') {
42
- return { patched: false, reason: 'parse error' }
59
+ function upsertRules(existing, additions) {
60
+ const merged = [...existing]
61
+ for (const rule of additions) {
62
+ const index = merged.findIndex(r => r.action === rule.action && r.resource === rule.resource)
63
+ if (index === -1) merged.push(rule)
64
+ else merged[index] = rule
43
65
  }
66
+ return merged
67
+ }
44
68
 
45
- // build and plan are overridden, not disabled. Earlier versions disabled them
46
- // and pointed default_agent at fullstack-engineer; now they are the only two
47
- // primaries and pc-subagent-tiers regenerates their agent files from
48
- // fullstack-engineer.md. A repo patched by an earlier version still carries
49
- // `disable: true`, which would hide both agents, so that flag is cleared here.
50
- const hasStaleDisable = (
51
- parsed?.agent?.build?.disable !== undefined ||
52
- parsed?.agent?.plan?.disable !== undefined
53
- )
54
- const needsAgentOverride = hasStaleDisable || !(
55
- parsed?.agent?.build?.mode === 'primary' &&
56
- parsed?.agent?.plan?.mode === 'primary' &&
57
- parsed?.agent?.plan?.permission?.edit === 'deny' &&
58
- parsed?.agent?.plan?.permission?.task === 'deny'
59
- )
60
-
61
- // default_agent pointing at fullstack-engineer is now invalid: it became a
62
- // subagent when build and plan took over as the only primaries, so opencode
63
- // has no valid default to open a session with. A deliberate choice of build
64
- // or plan is left alone; anything else is repointed at plan, so a session
65
- // starts read-only.
66
- const currentDefault = parsed?.default_agent
67
- const needsDefaultAgent = currentDefault !== 'plan' && currentDefault !== 'build'
68
- const needsSkillPermission = parsed?.permission?.skill !== 'allow'
69
- const missingSharedPermissions = SHARED_PERMISSIONS.filter(
70
- ([key, value]) => parsed?.permission?.[key] !== value,
71
- )
72
- // opencode only scans .opencode/skills/ by default, but we install to .agents/skills/
73
- const needsSkillsPaths = !(
74
- Array.isArray(parsed?.skills?.paths) &&
75
- parsed.skills.paths.includes('.agents/skills')
76
- )
77
- const needsCompaction = !(
78
- parsed?.compaction?.auto === true &&
79
- parsed?.compaction?.prune === true
80
- )
81
- const staleBrowserPlugins = (Array.isArray(parsed?.plugin) ? parsed.plugin : [])
82
- .filter(entry => typeof entry === 'string' && entry.startsWith(STALE_BROWSER_PLUGIN_PREFIX))
83
- const needsBrowserMcp = JSON.stringify(parsed?.mcp?.['agent-browser']) !== JSON.stringify(AGENT_BROWSER_MCP)
84
-
85
- if (!needsAgentOverride && !needsDefaultAgent && !needsSkillPermission && missingSharedPermissions.length === 0 && !needsSkillsPaths && !needsCompaction && staleBrowserPlugins.length === 0 && !needsBrowserMcp) {
86
- return { patched: false }
69
+ function migratePluginArray(obj) {
70
+ if (!Array.isArray(obj.plugin)) return false
71
+ const plugins = []
72
+ for (const entry of obj.plugin) {
73
+ if (typeof entry !== 'string') {
74
+ plugins.push(entry)
75
+ continue
76
+ }
77
+ const base = basePackageName(entry)
78
+ if (DROPPED_PLUGIN_PREFIXES.some(prefix => base.startsWith(prefix))) continue
79
+ if (base === OPENCODE_QUOTA_PACKAGE) {
80
+ plugins.push(`${OPENCODE_QUOTA_PACKAGE}@${OPENCODE_QUOTA_VERSION}`)
81
+ continue
82
+ }
83
+ plugins.push(entry)
87
84
  }
85
+ obj.plugins = plugins
86
+ delete obj.plugin
87
+ return true
88
+ }
88
89
 
89
- // Apply edits sequentially so offsets stay correct
90
- if (needsAgentOverride) {
91
- // undefined removes the key, clearing the disable left by earlier versions.
92
- if (parsed?.agent?.build?.disable !== undefined) {
93
- text = applyModify(text, ['agent', 'build', 'disable'], undefined)
90
+ function migrateAgents(obj) {
91
+ let changed = false
92
+ const agents = { ...(obj.agents ?? {}) }
93
+
94
+ if (obj.agent && typeof obj.agent === 'object' && !Array.isArray(obj.agent)) {
95
+ for (const [id, raw] of Object.entries(obj.agent)) {
96
+ if (!raw || typeof raw !== 'object') continue
97
+ const agent = { ...raw }
98
+ if (agent.prompt !== undefined) {
99
+ agent.system = agent.prompt
100
+ delete agent.prompt
101
+ }
102
+ if (agent.disable !== undefined) {
103
+ agent.disabled = agent.disable
104
+ delete agent.disable
105
+ }
106
+ if (agent.permission && typeof agent.permission === 'object') {
107
+ agent.permissions = upsertRules(agent.permissions ?? [], permissionMapToRules(agent.permission))
108
+ delete agent.permission
109
+ }
110
+ delete agent.maxSteps
111
+ agents[id] = agent
94
112
  }
95
- if (parsed?.agent?.plan?.disable !== undefined) {
96
- text = applyModify(text, ['agent', 'plan', 'disable'], undefined)
113
+ delete obj.agent
114
+ changed = true
115
+ }
116
+
117
+ // build and plan are the only primaries; plan is read-only. Guarantee both
118
+ // even when the repo never had an explicit entry.
119
+ const build = { ...(agents.build ?? {}), mode: 'primary' }
120
+ delete build.disabled
121
+ delete build.disable
122
+ if (JSON.stringify(agents.build) !== JSON.stringify(build)) {
123
+ agents.build = build
124
+ changed = true
125
+ }
126
+ const plan = {
127
+ ...(agents.plan ?? {}),
128
+ mode: 'primary',
129
+ permissions: upsertRules(agents.plan?.permissions ?? [], [
130
+ { action: 'edit', resource: '*', effect: 'deny' },
131
+ { action: 'subagent', resource: '*', effect: 'deny' },
132
+ ]),
133
+ }
134
+ delete plan.disabled
135
+ delete plan.disable
136
+ if (JSON.stringify(agents.plan) !== JSON.stringify(plan)) {
137
+ agents.plan = plan
138
+ changed = true
139
+ }
140
+
141
+ if (changed) obj.agents = agents
142
+ return changed
143
+ }
144
+
145
+ function migrateMcp(obj) {
146
+ if (!obj.mcp || typeof obj.mcp !== 'object' || Array.isArray(obj.mcp)) return false
147
+ if (obj.mcp.servers) return false
148
+ const servers = {}
149
+ let inheritedTimeout = 300000
150
+ for (const [name, raw] of Object.entries(obj.mcp)) {
151
+ if (!raw || typeof raw !== 'object') continue
152
+ const server = { ...raw }
153
+ if ('enabled' in server) {
154
+ server.disabled = !server.enabled
155
+ delete server.enabled
97
156
  }
98
- text = applyModify(text, ['agent', 'build', 'mode'], 'primary')
99
- text = applyModify(text, ['agent', 'plan', 'mode'], 'primary')
100
- text = applyModify(text, ['agent', 'plan', 'permission', 'edit'], 'deny')
101
- // Read-only has to include spawning: a plan session that can call task()
102
- // can have a build worker make the change for it.
103
- text = applyModify(text, ['agent', 'plan', 'permission', 'task'], 'deny')
104
- }
105
- if (needsDefaultAgent) {
106
- text = applyModify(text, ['default_agent'], 'plan')
107
- }
108
- if (needsSkillPermission) text = applyModify(text, ['permission', 'skill'], 'allow')
109
- for (const [key, value] of missingSharedPermissions) {
110
- text = applyModify(text, ['permission', key], value)
111
- }
112
-
113
- // Ensure .agents/skills is in skills.paths so opencode discovers installed skills
114
- if (needsSkillsPaths) {
115
- const existingPaths = Array.isArray(parsed?.skills?.paths) ? parsed.skills.paths : []
116
- if (!existingPaths.includes('.agents/skills')) {
117
- const updatedPaths = [...existingPaths, '.agents/skills']
118
- text = applyModify(text, ['skills', 'paths'], updatedPaths)
157
+ if (typeof server.timeout === 'number') {
158
+ inheritedTimeout = server.timeout
159
+ server.timeout = { catalog: server.timeout, execution: server.timeout }
119
160
  }
161
+ servers[name] = server
162
+ }
163
+ obj.mcp = {
164
+ servers,
165
+ timeout: { catalog: inheritedTimeout, execution: inheritedTimeout },
120
166
  }
167
+ return true
168
+ }
169
+
170
+ /** Mutate `obj` in place to the native V2 shape; returns true when it changed. */
171
+ function migrateToV2(obj) {
172
+ let changed = false
121
173
 
122
- // Enable context pruning to reduce token costs in long agent sessions
123
- if (needsCompaction) {
124
- text = applyModify(text, ['compaction', 'auto'], true)
125
- text = applyModify(text, ['compaction', 'prune'], true)
126
- text = applyModify(text, ['compaction', 'reserved'], 10000)
174
+ if (obj.$schema !== CONFIG_SCHEMA) {
175
+ obj.$schema = CONFIG_SCHEMA
176
+ changed = true
127
177
  }
128
178
 
129
- // Swap the retired opencode-browser plugin for the agent-browser MCP server
130
- if (staleBrowserPlugins.length > 0) {
131
- const keptPlugins = parsed.plugin.filter(entry => !staleBrowserPlugins.includes(entry))
132
- text = applyModify(text, ['plugin'], keptPlugins)
179
+ if ('instructions' in obj) {
180
+ delete obj.instructions
181
+ changed = true
133
182
  }
134
- if (needsBrowserMcp) {
135
- text = applyModify(text, ['mcp', 'agent-browser'], AGENT_BROWSER_MCP)
183
+
184
+ if (obj.experimental && typeof obj.experimental === 'object') {
185
+ for (const key of ['openTelemetry', 'batch_tool', 'primary_tools', 'continue_loop_on_deny', 'mcp_timeout']) {
186
+ if (key in obj.experimental) {
187
+ delete obj.experimental[key]
188
+ changed = true
189
+ }
190
+ }
191
+ if (Object.keys(obj.experimental).length === 0) {
192
+ delete obj.experimental
193
+ changed = true
194
+ }
136
195
  }
137
196
 
138
- await fse.writeFile(opencodePath, text, 'utf-8')
139
- if (needsAgentOverride) {
140
- success('Set build/plan as the primary agents in opencode.jsonc (plan is read-only)')
197
+ const compaction = obj.compaction && typeof obj.compaction === 'object' ? { ...obj.compaction } : {}
198
+ if ('prune' in compaction) {
199
+ delete compaction.prune
200
+ changed = true
201
+ }
202
+ if (compaction.auto !== true) {
203
+ compaction.auto = true
204
+ changed = true
205
+ }
206
+ if ('reserved' in compaction) {
207
+ compaction.buffer = compaction.reserved
208
+ delete compaction.reserved
209
+ changed = true
141
210
  }
142
- if (needsDefaultAgent) {
143
- success(`default_agent -> plan${currentDefault ? ` (was ${currentDefault})` : ''}`)
211
+ if (!Number.isFinite(compaction.buffer)) {
212
+ compaction.buffer = 10000
213
+ changed = true
144
214
  }
145
- if (needsSkillPermission) {
146
- success('Allowed skill loading in opencode.jsonc')
215
+ if (JSON.stringify(obj.compaction) !== JSON.stringify(compaction)) {
216
+ obj.compaction = compaction
217
+ changed = true
147
218
  }
148
- if (missingSharedPermissions.length > 0) {
149
- success('Allowed question/todowrite tools in opencode.jsonc')
219
+
220
+ const additions = []
221
+ if (obj.permission && typeof obj.permission === 'object' && !Array.isArray(obj.permission)) {
222
+ additions.push(...permissionMapToRules(obj.permission))
223
+ delete obj.permission
224
+ changed = true
150
225
  }
151
- if (needsSkillsPaths) {
152
- success('Added .agents/skills to skills.paths in opencode.jsonc')
226
+ if (additions.length > 0) {
227
+ const merged = upsertRules(Array.isArray(obj.permissions) ? obj.permissions : [], additions)
228
+ if (JSON.stringify(obj.permissions) !== JSON.stringify(merged)) {
229
+ obj.permissions = merged
230
+ changed = true
231
+ }
153
232
  }
154
- if (needsCompaction) {
155
- success('Enabled context pruning (compaction.prune) in opencode.jsonc')
233
+
234
+ if (obj.skills && typeof obj.skills === 'object' && !Array.isArray(obj.skills)) {
235
+ const paths = Array.isArray(obj.skills.paths) ? obj.skills.paths : []
236
+ obj.skills = paths.includes('.agents/skills') ? paths : [...paths, '.agents/skills']
237
+ changed = true
238
+ } else if (Array.isArray(obj.skills)) {
239
+ if (!obj.skills.includes('.agents/skills')) {
240
+ obj.skills = [...obj.skills, '.agents/skills']
241
+ changed = true
242
+ }
243
+ } else {
244
+ obj.skills = ['.agents/skills']
245
+ changed = true
246
+ }
247
+
248
+ if (migrateAgents(obj)) changed = true
249
+ if (migrateMcp(obj)) changed = true
250
+ if (migratePluginArray(obj)) changed = true
251
+
252
+ // A session starts read-only: fullstack-engineer is a subagent now, so a
253
+ // default_agent left pointing at it has no valid primary to open with.
254
+ if (obj.default_agent !== 'plan' && obj.default_agent !== 'build') {
255
+ obj.default_agent = 'plan'
256
+ changed = true
257
+ }
258
+
259
+ return changed
260
+ }
261
+
262
+ export async function patchOpencodeJson(cwd = process.cwd()) {
263
+ const opencodePath = path.join(cwd, 'opencode.jsonc')
264
+
265
+ let text
266
+ if (await fse.pathExists(opencodePath)) {
267
+ text = await fse.readFile(opencodePath, 'utf-8')
268
+ } else {
269
+ text = JSON.stringify({ $schema: CONFIG_SCHEMA }, null, 2)
156
270
  }
157
- if (staleBrowserPlugins.length > 0) {
158
- success(`Removed ${staleBrowserPlugins.length} stale opencode-browser plugin entr${staleBrowserPlugins.length === 1 ? 'y' : 'ies'} in opencode.jsonc`)
271
+
272
+ const errors = []
273
+ const parsed = parse(text, errors)
274
+ if (errors.length > 0 || typeof parsed !== 'object' || parsed === null) {
275
+ return { patched: false, reason: 'parse error' }
159
276
  }
160
- if (needsBrowserMcp) {
161
- success('Configured agent-browser MCP server in opencode.jsonc')
277
+
278
+ if (!migrateToV2(parsed)) {
279
+ return { patched: false }
162
280
  }
163
281
 
282
+ await fse.writeFile(opencodePath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8')
283
+ success('Migrated opencode.jsonc to the native OpenCode 2 shape')
164
284
  return { patched: true }
165
285
  }
166
286
 
287
+ // OpenCode 2 plugin packages. @opencode/plugin is the V2 plugin API; the V1
288
+ // @opencode-ai/plugin and the retired TUI peers are removed on update.
167
289
  const OPENCODE_PACKAGE_DEPENDENCIES = {
168
- '@opencode-ai/plugin': '1.18.19',
169
- '@opentui/core': '0.5.6',
170
- '@opentui/solid': '0.5.6',
171
- 'solid-js': '1.9.12',
290
+ '@opencode/plugin': '2.0.22',
172
291
  }
173
292
 
174
- // agent-browser replaced this plugin; remove it from consumer installs on update
175
- const STALE_PACKAGE_DEPENDENCIES = ['@different-ai/opencode-browser']
293
+ const STALE_PACKAGE_DEPENDENCIES = [
294
+ '@opencode-ai/plugin',
295
+ '@mohak34/opencode-notifier',
296
+ '@different-ai/opencode-browser',
297
+ '@opentui/core',
298
+ '@opentui/solid',
299
+ 'solid-js',
300
+ ]
176
301
 
177
302
  export async function patchOpencodePackage(cwd = process.cwd()) {
178
303
  const packagePath = path.join(cwd, '.opencode', 'package.json')
@@ -183,7 +308,7 @@ export async function patchOpencodePackage(cwd = process.cwd()) {
183
308
 
184
309
  let patched = false
185
310
  for (const [name, version] of Object.entries(OPENCODE_PACKAGE_DEPENDENCIES)) {
186
- if (!(name in packageJson.dependencies) || packageJson.dependencies[name] === version) continue
311
+ if (packageJson.dependencies[name] === version) continue
187
312
  packageJson.dependencies[name] = version
188
313
  patched = true
189
314
  }
@@ -5,7 +5,7 @@ import { info, success } from '../../utils/exec.js'
5
5
  import { canUpdateManagedFile, readUpdateManifest, recordManagedFile, writeUpdateManifest } from '../../utils/update-manifest.js'
6
6
 
7
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
- const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../../harness/.agents/skills')
8
+ const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../../skills')
9
9
  const CONTENT_SKILLS_LOCK = path.resolve(__dirname, '../../../harness/skills-lock.json')
10
10
 
11
11
  // Userstory skills parse backlog work items: selected by backlogPlatform only.
@@ -28,7 +28,16 @@ export const SKILL_RENAME = {
28
28
  'pc-userstory-browser': 'pc-userstory',
29
29
  }
30
30
 
31
+ // Skills that ship in the source tree but must never be copied into a target
32
+ // project. agent-harness-cli documents the CLI itself for agents working on
33
+ // THIS repo; it has no value in a consumer repo and would clutter .agents/skills.
34
+ // Listed by directory name as read from CONTENT_SKILLS_DIR.
35
+ export const NON_INSTALLABLE_SKILLS = new Set([
36
+ 'agent-harness-cli',
37
+ ])
38
+
31
39
  function shouldInstallSkill(skill, backlogPlatform, _repoPlatform) {
40
+ if (NON_INSTALLABLE_SKILLS.has(skill)) return false
32
41
  if (skill in BACKLOG_PLATFORM_SKILLS) return BACKLOG_PLATFORM_SKILLS[skill] === backlogPlatform
33
42
  return true
34
43
  }
@@ -185,7 +194,8 @@ async function installObSkills(backlogPlatform = 'github', repoPlatform, { force
185
194
  // Build the set of skill names we ship (source dirs, after rename).
186
195
  // Only these may be removed during forceOverwrite — project-generated
187
196
  // skills (pc-merge-risk-assess, custom loops, etc.) must survive.
188
- const contentSkills = await fse.readdir(CONTENT_SKILLS_DIR)
197
+ const contentSkills = (await fse.readdir(CONTENT_SKILLS_DIR))
198
+ .filter(name => !NON_INSTALLABLE_SKILLS.has(name))
189
199
  const shippedNames = new Set()
190
200
  for (const skill of contentSkills) {
191
201
  const stat = await fse.stat(path.join(CONTENT_SKILLS_DIR, skill)).catch(() => null)