@plainconceptsplatform/agent-harness 3.8.2 → 3.9.0-alpha.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.
package/README.md CHANGED
@@ -89,7 +89,7 @@ Reach for individual steps when you want something narrower:
89
89
 
90
90
  > **Upgrading from `opencode-onboard` v1?** v2 renamed the config files and the skill prefix (`ob-` became `pc-`), and it does not migrate v1 projects. The CLI detects one and refuses to run rather than half-patching it. Re-onboard on a clean branch: remove `.opencode/` and `.agents/skills/ob-*`, then run the wizard again.
91
91
 
92
- > **Browser tooling changed in v2.4.** The `@different-ai/opencode-browser` plugin (and its Chrome extension) was replaced by [`agent-browser`](https://github.com/vercel-labs/agent-browser), wired in as an MCP server in `opencode.jsonc`. Running `update` strips the old plugin entry, adds the MCP block, and installs the new binary — no manual unloading of the extension is needed, though you can remove it from `chrome://extensions` yourself. Skills you wrote that call `browser_*` tools directly must switch to the `agent-browser` CLI or the `agent_browser_*` MCP tools. Backlog-browser users: agent-browser runs its own Chrome, so log in once per project using the `--session backlog --restore` flow described in `pc-userstory-browser`.
92
+ > **Browser tooling changed in v2.5.** `agent-browser` (which launched its own isolated Chrome for Testing) was replaced by [`browse`](https://opencode.ai/product/browse), the OpenCode MCP server that drives the user's real Chrome-family browser with their logins, tabs, cookies, and extensions. Agent tabs open in the background in collapsed groups; when only a person can proceed (login, 2FA, captcha, payment), browse hands the tab back and continues once the user clicks Continue. Running `update` replaces the `agent-browser` MCP server entry with `browse`, installs the new CLI, and runs `browse install` which opens the Chrome Web Store for the extension and pairs it. Skills you wrote that call `agent-browser` CLI commands or `agent_browser_*` MCP tools must switch to the `browse` CLI or the `browse.execute` / `browse.session_*` MCP tools. Backlog-browser users: browse uses your real browser, so work-item pages behind your own login are already authenticated — no separate login session is needed.
93
93
 
94
94
  ---
95
95
 
@@ -107,7 +107,7 @@ The first run is a 10-step wizard. It keeps the current step visible, plus the l
107
107
  | **6. Initialize OpenSpec** | Runs `npx @fission-ai/openspec init` silently for structured change management |
108
108
  | **7. Choose models** | Fetches live model list from [models.dev](https://models.dev), lets you pick plan, build, and fast models with cost indicators and canonical pricing |
109
109
  | **8. Token optimization tools** | Optional and recommended. One checklist step for RTK check, opencode-quota setup, Simple English install, codegraph install, agentmemory install, humanizer install, and token-optimization rule injection into guardrails |
110
- | **9. Install browser tooling** | Installs `agent-browser` globally and downloads its Chrome for Testing binary |
110
+ | **9. Install browser tooling** | Installs `@opencode/browse` globally and pairs it with the user's real browser via the browse extension |
111
111
  | **10. Write onboarding metadata** | Writes `.opencode/harness.json` with selected setup details |
112
112
 
113
113
  When it finishes, open OpenCode in your project and type:
@@ -179,7 +179,9 @@ If you choose backlog platform `None`, no userstory skills are injected into the
179
179
  Current loading model:
180
180
 
181
181
  - `pc-guardrails-generic` is mandatory baseline for every agent (git, secrets, quality rules, plus the engineer workflow)
182
+ - `AGENTS.md` states the file paths to read before any edit, shell command or subagent spawn, so agents that do not discover `.agents/skills` natively (Claude Code reads `.claude/skills`) still load the guardrails
182
183
  - Baseline context rules and token-optimization guidance live in `AGENTS.md` (always in context), not in a skill
184
+ - Humanizer and Simple English are read on demand before writing prose; they are not transitive requirements of the guardrails
183
185
 
184
186
  Default `fullstack-engineer` abilities:
185
187
 
@@ -200,7 +202,7 @@ Built-in skills (`pc-` prefix) shipped with agent-harness:
200
202
  | `pc-userstory-az` | Parse an Azure DevOps work item URL |
201
203
  | `pc-userstory-jira` | Parse a Jira issue URL via `acli` CLI |
202
204
  | `pc-userstory-browser` | Parse work item from any URL via browser automation (Linear, Trello, and so on) |
203
- | `browser-automation` | Browser control via `agent-browser` (localhost and browser backlog exception) |
205
+ | `browser-automation` | Browser control via `browse` (user's real browser, localhost + external sites with handoffs) |
204
206
  | `pc-plan-explore` | Read-only exploration procedure behind `/plan-explore`; autonomous mode used by `/plan-goal` |
205
207
  | `pc-plan-propose` | Proposal + task-enrichment procedure behind `/plan-propose`; autonomous mode used by `/plan-goal` |
206
208
  | `pc-plan-apply` | Wave-implementation procedure behind `/plan-apply`; autonomous mode used by `/plan-goal` |
@@ -307,7 +309,7 @@ your-project/
307
309
 
308
310
  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.
309
311
 
310
- 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.
312
+ 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. `AGENTS.md` names the guardrails files by path so the same contract survives in agents that only have a read tool. Token-optimization guidance is injected into `pc-guardrails-generic` marker blocks during onboarding; Humanizer and Simple English are named by path and loaded when prose is written, not held as transitive requirements.
311
313
 
312
314
  ---
313
315
 
@@ -505,7 +507,7 @@ Data the CLI reads lives in two places, split by kind:
505
507
  - `models.json` controls model role prompts and agent assignments
506
508
  - `optimization.json` controls RTK, quota, Simple English, codegraph, agentmemory, and humanizer checklist defaults
507
509
  - `quota.json` controls opencode-quota defaults
508
- - `browser.json` controls the agent-browser installer (npm package + Chrome setup)
510
+ - `browser.json` controls the browse installer (npm package + browser extension pairing)
509
511
 
510
512
  **`cli/fragments/`** holds Markdown injected into the installed harness, one directory per marker family: `archive/`, `guardrails/`, `ops-backlog/`, `ops-review/`, `ops-ship/`. Each file is the platform-specific body that replaces a `<!-- PC-PLATFORM-*-START -->` block.
511
513
 
@@ -193,7 +193,7 @@ export async function runJoin() {
193
193
  await checkRtk({ skipHeader: true, skipPrompt: true })
194
194
 
195
195
  // Step 8: Browser automation
196
- header('Step 8, Installing agent-browser')
196
+ header('Step 8, Installing browse')
197
197
  await installBrowser()
198
198
 
199
199
  // Step 9: Model tier guidance
@@ -1,4 +1,4 @@
1
- ## Humanizer (optimization skill)
1
+ ## Humanizer (before writing prose)
2
2
 
3
- - Load `skill("humanizer")` before writing prose (commit messages, PR descriptions, docs, proposals); unhumanized prose reads as machine-written. Editing, shell and spawning stay blocked until it is loaded (pc-system-reminders).
3
+ - Before writing prose (commit messages, PR descriptions, docs, proposals), read `.agents/skills/humanizer/SKILL.md` and apply it; unhumanized prose reads as machine-written.
4
4
  - Never apply it to code, config files, or terminal output: prose only.
@@ -1,4 +1,4 @@
1
- ## Simple English (optimization skill)
1
+ ## Simple English (before writing prose)
2
2
 
3
- - Apply Simplified Technical English to all prose: `skill("simple-english")`. Editing, shell and spawning stay blocked until it is loaded (pc-system-reminders).
3
+ - Before writing prose, read `.agents/skills/simple-english/SKILL.md` and apply Simplified Technical English.
4
4
  - Keep code blocks, identifiers, CLI commands, file paths, quoted errors, and product names exact.
package/cli/index.js CHANGED
@@ -26,7 +26,7 @@ function printHelp(version) {
26
26
  console.log(' openspec Run OpenSpec initialization step')
27
27
  console.log(' models Run models selection step')
28
28
  console.log(' optimization Run token optimization tools step')
29
- console.log(' browser Run agent-browser installer step')
29
+ console.log(' browser Run browse installer step')
30
30
  console.log(' metadata Write onboarding metadata step')
31
31
  console.log()
32
32
  console.log('Options:')
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "browser": {
30
30
  "workflow": "Any work item URL means run the pipeline, whichever tool it points at, unless the user asked for analysis or context only.\n\n- The URL: `pc-userstory` to open and read the page, `pc-plan-propose` for the plan, confirm, `pc-plan-apply` to build, then ship.\n- An existing OpenSpec change, plus any of `implement` / `start` / `go`: `pc-plan-apply`, then ship.\n\nBrowser is backlog-only: PR work runs on the repo platform in `platform.repo`.",
31
- "skillsGuide": "### Backlog Platform: Others (Browser)\n- `pc-userstory` -> opens work item URLs via agent-browser, reads page content, creates OpenSpec changes. Load when a URL the user provides does not match GitHub/Azure/Jira CLI platforms.\n\n### Repo Platform\n- PR skills are loaded based on `platform.repo` (GitHub / Azure DevOps / GitLab)."
31
+ "skillsGuide": "### Backlog Platform: Others (Browser)\n- `pc-userstory` -> opens work item URLs via browse, reads page content, creates OpenSpec changes. Load when a URL the user provides does not match GitHub/Azure/Jira CLI platforms.\n\n### Repo Platform\n- PR skills are loaded based on `platform.repo` (GitHub / Azure DevOps / GitLab)."
32
32
  }
33
33
  }
34
34
  }
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "installer": {
3
3
  "command": "npm",
4
- "args": ["install", "-g", "agent-browser@0.36.0"]
4
+ "args": ["install", "-g", "@opencode/browse@0.0.14"]
5
5
  },
6
- "chromeSetup": {
7
- "command": "agent-browser",
8
- "args": ["install"]
6
+ "setup": {
7
+ "command": "browse",
8
+ "args": ["install", "--yes"]
9
9
  }
10
10
  }
@@ -10,7 +10,7 @@ const BROWSER_PRESET_PATH = path.resolve(__dirname, '../../presets/browser.json'
10
10
  const browserPreset = await fse.readJson(BROWSER_PRESET_PATH)
11
11
 
12
12
  export async function installBrowser() {
13
- header('Step 9, Installing agent-browser')
13
+ header('Step 9, Installing browse')
14
14
 
15
15
  try {
16
16
  const install = await execa(browserPreset.installer.command, browserPreset.installer.args, {
@@ -19,30 +19,34 @@ export async function installBrowser() {
19
19
  })
20
20
 
21
21
  if (install.exitCode !== 0) {
22
- warn('agent-browser npm install exited with non-zero code')
22
+ warn('browse npm install exited with non-zero code')
23
23
  if (install.stderr) info(install.stderr)
24
24
  return
25
25
  }
26
- success('agent-browser installed')
26
+ success('browse installed')
27
27
  } catch (err) {
28
- error(`Failed to install agent-browser: ${err.message}`)
28
+ error(`Failed to install browse: ${err.message}`)
29
29
  return
30
30
  }
31
31
 
32
32
  try {
33
- const chrome = await execa(browserPreset.chromeSetup.command, browserPreset.chromeSetup.args, {
33
+ const setup = await execa(browserPreset.setup.command, browserPreset.setup.args, {
34
34
  cwd: os.homedir(),
35
35
  reject: false,
36
+ timeout: 180000,
36
37
  })
37
38
 
38
- if (chrome.exitCode === 0) {
39
- success('agent-browser Chrome ready')
40
- if (chrome.stdout) info(chrome.stdout.trim())
39
+ if (setup.exitCode === 0) {
40
+ success('browse paired with your browser')
41
+ if (setup.stdout) info(setup.stdout.trim())
41
42
  } else {
42
- warn('agent-browser install (Chrome setup) exited with non-zero code')
43
- if (chrome.stderr) info(chrome.stderr)
43
+ // browse install opens the Chrome Web Store and waits for the extension to connect.
44
+ // A non-zero exit often just means the user didn't finish pairing in time; browse is
45
+ // still installed and usable once they add the extension and run `browse pair`.
46
+ warn('browse setup did not complete — install the extension and run `browse pair`')
47
+ if (setup.stderr) info(setup.stderr)
44
48
  }
45
49
  } catch (err) {
46
- error(`Failed to set up Chrome for agent-browser: ${err.message}`)
50
+ error(`Failed to set up browse: ${err.message}`)
47
51
  }
48
52
  }
@@ -47,9 +47,13 @@ const PLATFORM_WORKFLOW_START = '<!-- PC-PLATFORM-WORKFLOW-START -->'
47
47
  const PLATFORM_WORKFLOW_END = '<!-- PC-PLATFORM-WORKFLOW-END -->'
48
48
  const PLATFORM_SKILLS_GUIDE_START = '<!-- PC-PLATFORM-SKILLS-GUIDE-START -->'
49
49
  const PLATFORM_SKILLS_GUIDE_END = '<!-- PC-PLATFORM-SKILLS-GUIDE-END -->'
50
+ const PLATFORM_SKILLS_START = '<!-- PC-PLATFORM-SKILLS-START -->'
51
+ const PLATFORM_SKILLS_END = '<!-- PC-PLATFORM-SKILLS-END -->'
50
52
  const PLATFORM_CONTRACT_START = '<!-- PC-PLATFORM-CONTRACT-START -->'
51
53
  const PLATFORM_CONTRACT_END = '<!-- PC-PLATFORM-CONTRACT-END -->'
52
54
 
55
+ const MANDATORY_SKILLS_BODY = 'Mandatory, before any edit, shell command or subagent spawn: read `.agents/skills/pc-guardrails-generic/SKILL.md`, then `.agents/skills/pc-guardrails-project/SKILL.md` if present. They are instructions, not references: a loaded skill may name further skills, and those load too before the work they cover.'
56
+
53
57
  function platformContent(platform, key) {
54
58
  const p = agentsContent.platform[platform] ?? agentsContent.platform.github
55
59
  return p[key] ?? ''
@@ -74,6 +78,17 @@ function replaceBetween(content, start, end, replacement) {
74
78
  return content.replace(pattern, () => (body ? `${start}\n${body}\n${end}` : `${start}\n${end}`))
75
79
  }
76
80
 
81
+ // Reading the file works in every agent, not only the ones that discover
82
+ // `.agents/skills` natively (Claude Code reads `.claude/skills`). Installs from
83
+ // before this block have no marker pair and update never rewrites AGENTS.md, so
84
+ // append a section then; the marker pair keeps later updates idempotent.
85
+ function patchMandatorySkillsBlock(content) {
86
+ if (content.includes(PLATFORM_SKILLS_START)) {
87
+ return replaceBetween(content, PLATFORM_SKILLS_START, PLATFORM_SKILLS_END, MANDATORY_SKILLS_BODY)
88
+ }
89
+ return `${content.replace(/\s*$/, '')}\n\n## Mandatory skills\n\n${PLATFORM_SKILLS_START}\n${MANDATORY_SKILLS_BODY}\n${PLATFORM_SKILLS_END}\n`
90
+ }
91
+
77
92
  export function reuseBlock(contract, templateSource = false) {
78
93
  const { docs = [], packages = [], names = [] } = contract?.reuse ?? {}
79
94
  if (!docs.length && !packages.length && !names.length) return ''
@@ -150,6 +165,7 @@ export async function patchAgentGuidance(backlogPlatform, repoPlatform, cwd = pr
150
165
  let content = await fse.readFile(agentsMdPath, 'utf-8')
151
166
  content = replaceBetween(content, PLATFORM_WORKFLOW_START, PLATFORM_WORKFLOW_END, mixedPlatformContent(backlogPlatform, repo, 'workflow'))
152
167
  content = replaceBetween(content, PLATFORM_SKILLS_GUIDE_START, PLATFORM_SKILLS_GUIDE_END, mixedPlatformContent(backlogPlatform, repo, 'skillsGuide'))
168
+ content = patchMandatorySkillsBlock(content)
153
169
  content = patchContractBlock(content, cwd)
154
170
  await fse.writeFile(agentsMdPath, `${content.replace(/\s*$/, '')}\n`, 'utf-8')
155
171
  const label = backlogPlatform === repo ? repo : `${backlogPlatform}→${repo}`
@@ -64,14 +64,6 @@ export async function generateFullstackEngineer({ cwd = process.cwd(), updateMod
64
64
  // like every other subagent.
65
65
  // Quoted: a bare #hex is a YAML comment, which opencode rejects.
66
66
  `color: ${yamlColor(agentColor('fullstack-engineer'))}`,
67
- 'permission:',
68
- ' edit: allow',
69
- ' bash: allow',
70
- ' read: allow',
71
- ' glob: allow',
72
- ' grep: allow',
73
- ' question: allow',
74
- ' todowrite: allow',
75
67
  '---',
76
68
  ]
77
69
 
@@ -2,9 +2,11 @@ node_modules
2
2
  harness-run.json
3
3
  harness.user.json
4
4
  harness-managed.json
5
+ harness-models.json
5
6
  source-roots.json
6
7
  tmp/
7
8
  *-engineer.*.md
8
9
  agents/build.md
9
10
  agents/plan.md
10
11
  agents/planner.md
12
+ agents/planner-sub.md
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "dependencies": {
3
3
  "@opencode/plugin": "2.0.22"
4
- }
4
+ },
5
+ "type": "module"
5
6
  }
@@ -0,0 +1,337 @@
1
+ // pc-subagent-tiers-core: pure Node agent generation. The plugin and the
2
+ // standalone sync script both call this, so a CI pre-start step can write the
3
+ // agents before OpenCode builds its task enum. No @opencode/plugin import here.
4
+ //
5
+ // Agent topology:
6
+ // build.md / plan.md mode: primary the only agents a human selects.
7
+ // planner.md mode: all fullstack body on the plan tier;
8
+ // commands run on it, it cannot spawn.
9
+ // planner-sub.md mode: subagent spawnable plan-tier alias for the
10
+ // task tool, same body, cannot spawn.
11
+ // fullstack-engineer.md mode: subagent shared body and fallback worker.
12
+ // *-engineer.md mode: subagent specialists.
13
+ // *-engineer.<tier>.md mode: subagent the same specialist pinned to a tier.
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+
18
+ export const TIERS = ["build", "fast", "plan"];
19
+ export const MANIFEST_FILE = "harness-models.json";
20
+ export const FULLSTACK_NAME = "fullstack-engineer";
21
+ export const FULLSTACK_TEMPLATE = `${FULLSTACK_NAME}.md`;
22
+
23
+ export const PRIMARIES = {
24
+ build: {
25
+ tier: "build",
26
+ color: "primary",
27
+ description:
28
+ "Implement changes in this repository. Full write access, spawns specialist engineers for parallel work.",
29
+ permissions: null,
30
+ },
31
+ plan: {
32
+ tier: "plan",
33
+ color: "warning",
34
+ description:
35
+ "Explore and plan without touching the tree. Read-only: proposes work for build to carry out.",
36
+ permissions: [
37
+ { action: "edit", resource: "*", effect: "deny" },
38
+ { action: "subagent", resource: "*", effect: "deny" },
39
+ ],
40
+ },
41
+ planner: {
42
+ tier: "plan",
43
+ mode: "all",
44
+ color: "info",
45
+ description:
46
+ "Write plans, proposals, stories, reviews and archives on the plan model. Edits the tree, never spawns workers.",
47
+ permissions: [{ action: "subagent", resource: "*", effect: "deny" }],
48
+ },
49
+ };
50
+
51
+ const THEME_COLORS = new Set(["primary", "secondary", "accent", "success", "warning", "error", "info"]);
52
+
53
+ const LEGACY_FIELDS = [
54
+ "model",
55
+ "permission",
56
+ "prompt",
57
+ "disable",
58
+ "disabled",
59
+ "temperature",
60
+ "top_p",
61
+ "maxSteps",
62
+ "tools",
63
+ ];
64
+
65
+ function agentColor(name) {
66
+ let h = 0x811c9dc5;
67
+ for (let i = 0; i < name.length; i++) {
68
+ h ^= name.charCodeAt(i);
69
+ h = Math.imul(h, 0x01000193) >>> 0;
70
+ }
71
+ const hue = (h >>> 0) % 360;
72
+ const sat = 0.68;
73
+ const light = 0.52;
74
+ const c = (1 - Math.abs(2 * light - 1)) * sat;
75
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
76
+ const m = light - c / 2;
77
+ const [r, g, b] =
78
+ hue < 60
79
+ ? [c, x, 0]
80
+ : hue < 120
81
+ ? [x, c, 0]
82
+ : hue < 180
83
+ ? [0, c, x]
84
+ : hue < 240
85
+ ? [0, x, c]
86
+ : hue < 300
87
+ ? [x, 0, c]
88
+ : [c, 0, x];
89
+ const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, "0");
90
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
91
+ }
92
+
93
+ function shouldDeriveColor(current) {
94
+ if (!current) return true;
95
+ return THEME_COLORS.has(current.trim().toLowerCase());
96
+ }
97
+
98
+ function yamlColor(value) {
99
+ return value.startsWith("#") ? `"${value}"` : value;
100
+ }
101
+
102
+ function unquoteColor(raw) {
103
+ return (raw ?? "").trim().replace(/^["']/, "").replace(/["']$/, "");
104
+ }
105
+
106
+ function splitFrontmatter(content) {
107
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
108
+ if (!match) return { frontmatter: null, body: content };
109
+ return { frontmatter: match[1], body: content.slice(match[0].length) };
110
+ }
111
+
112
+ function stripLegacyFields(frontmatter) {
113
+ const lines = frontmatter.split(/\r?\n/);
114
+ const kept = [];
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const key = lines[i].match(/^([A-Za-z0-9_-]+):/)?.[1];
117
+ if (key && LEGACY_FIELDS.includes(key)) {
118
+ i++;
119
+ while (i < lines.length && /^\s+\S/.test(lines[i])) i++;
120
+ i--;
121
+ continue;
122
+ }
123
+ kept.push(lines[i]);
124
+ }
125
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
126
+ }
127
+
128
+ function setScalar(frontmatter, key, value) {
129
+ const line = `${key}: ${value}`;
130
+ const lines = frontmatter.split(/\r?\n/);
131
+ const index = lines.findIndex((current) => current.startsWith(`${key}:`));
132
+ if (index === -1) return frontmatter ? `${line}\n${frontmatter}` : line;
133
+ lines[index] = line;
134
+ return lines.join("\n");
135
+ }
136
+
137
+ function permissionsYaml(permissions) {
138
+ return [
139
+ "permissions:",
140
+ ...permissions.flatMap((p) => [
141
+ ` - action: ${p.action}`,
142
+ ` resource: ${p.resource}`,
143
+ ` effect: ${p.effect}`,
144
+ ]),
145
+ ].join("\n");
146
+ }
147
+
148
+ function colorLineFor(name, rawCurrent) {
149
+ const current = unquoteColor(rawCurrent);
150
+ const target = shouldDeriveColor(current) ? agentColor(name) : current;
151
+ const rendered = yamlColor(target);
152
+ return (rawCurrent ?? "").trim() === rendered ? null : `color: ${rendered}`;
153
+ }
154
+
155
+ function normalizeTemplate(content, name) {
156
+ const { frontmatter, body } = splitFrontmatter(content);
157
+ if (frontmatter === null) {
158
+ return `---\nmode: subagent\ncolor: ${yamlColor(agentColor(name))}\n---\n\n${content}`;
159
+ }
160
+ let fm = stripLegacyFields(frontmatter);
161
+ fm = setScalar(fm, "mode", "subagent");
162
+ const colorLine = colorLineFor(name, fm.match(/^color:\s*(.+)$/m)?.[1]);
163
+ if (colorLine) fm = setScalar(fm, "color", colorLine.slice("color: ".length));
164
+ return `---\n${fm}\n---\n${body}`;
165
+ }
166
+
167
+ function buildVariant(templateContent, model) {
168
+ const { frontmatter, body } = splitFrontmatter(templateContent);
169
+ let fm = stripLegacyFields(frontmatter ?? "");
170
+ fm = setScalar(fm, "mode", "subagent");
171
+ fm = setScalar(fm, "model", model);
172
+ fm = setScalar(fm, "hidden", "true");
173
+ if (!/^color:/m.test(fm)) {
174
+ fm = setScalar(fm, "color", yamlColor(agentColor("engineer")));
175
+ }
176
+ return `---\n${fm}\n---\n${body}`;
177
+ }
178
+
179
+ function buildPrimary(spec, fullstackContent, model) {
180
+ const { body } = splitFrontmatter(fullstackContent);
181
+ const lines = ["---", `description: ${spec.description}`, `mode: ${spec.mode ?? "primary"}`];
182
+ if (model) lines.push(`model: ${model}`);
183
+ lines.push(`color: ${yamlColor(spec.color)}`);
184
+ if (spec.permissions) lines.push(permissionsYaml(spec.permissions));
185
+ lines.push("---");
186
+ return `${lines.join("\n")}\n\n${body.trim()}\n`;
187
+ }
188
+
189
+ function buildPlannerSub(fullstackContent, model) {
190
+ const { body } = splitFrontmatter(fullstackContent);
191
+ const lines = [
192
+ "---",
193
+ "description: Spawnable planner alias for the task tool. Uses the plan model and cannot spawn workers.",
194
+ "mode: subagent",
195
+ ];
196
+ if (model) lines.push(`model: ${model}`);
197
+ lines.push(`color: ${yamlColor(agentColor("planner-sub"))}`);
198
+ lines.push("hidden: true");
199
+ lines.push(permissionsYaml([{ action: "subagent", resource: "*", effect: "deny" }]));
200
+ lines.push("---");
201
+ return `${lines.join("\n")}\n\n${body.trim()}\n`;
202
+ }
203
+
204
+ async function readJson(filePath) {
205
+ try {
206
+ return JSON.parse(await fs.readFile(filePath, "utf-8"));
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ async function scanEngineers(agentsDir) {
213
+ try {
214
+ const entries = await fs.readdir(agentsDir);
215
+ return {
216
+ templates: entries
217
+ .filter((f) => /^[\w-]+-engineer\.md$/.test(f) && f !== FULLSTACK_TEMPLATE)
218
+ .map((f) => f.replace(/\.md$/, "")),
219
+ variantFiles: entries.filter((f) => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
220
+ hasFullstack: entries.includes(FULLSTACK_TEMPLATE),
221
+ };
222
+ } catch {
223
+ return { templates: [], variantFiles: [], hasFullstack: false };
224
+ }
225
+ }
226
+
227
+ async function writeIfChanged(filePath, content) {
228
+ try {
229
+ if ((await fs.readFile(filePath, "utf-8")) === content) return false;
230
+ } catch {
231
+ // file missing, write it
232
+ }
233
+ const tmpPath = `${filePath}.tmp`;
234
+ await fs.writeFile(tmpPath, content, "utf-8");
235
+ await fs.rename(tmpPath, filePath);
236
+ return true;
237
+ }
238
+
239
+ function isModelId(value) {
240
+ return typeof value === "string" && /^[^/\s]+\/[^/\s]+$/.test(value);
241
+ }
242
+
243
+ export async function generateAgents({ root, writeManifest = true } = {}) {
244
+ if (!root) throw new Error("generateAgents requires a root directory");
245
+ const agentsDir = path.join(root, ".opencode", "agents");
246
+ const [user, team] = await Promise.all([
247
+ readJson(path.join(root, ".opencode", "harness.user.json")),
248
+ readJson(path.join(root, ".opencode", "harness.json")),
249
+ ]);
250
+
251
+ const models = {};
252
+ const sources = {};
253
+ const warnings = [];
254
+ for (const tier of TIERS) {
255
+ const fromUser = user?.models?.[tier];
256
+ const fromTeam = team?.models?.[tier];
257
+ models[tier] = fromUser ?? fromTeam ?? null;
258
+ sources[tier] = fromUser ? "user" : fromTeam ? "team" : "missing";
259
+ if (!models[tier]) {
260
+ warnings.push(`tier ${tier} has no model; its agents inherit the session model`);
261
+ } else if (!isModelId(models[tier])) {
262
+ warnings.push(`tier ${tier} model "${models[tier]}" is not provider/model`);
263
+ }
264
+ }
265
+
266
+ const available = TIERS.filter((tier) => models[tier]);
267
+ const { templates, variantFiles, hasFullstack } = await scanEngineers(agentsDir);
268
+ const manifest = {
269
+ generatedAt: new Date().toISOString(),
270
+ models,
271
+ source: sources,
272
+ agents: {},
273
+ warnings,
274
+ };
275
+
276
+ if (hasFullstack) {
277
+ const fullstackPath = path.join(agentsDir, FULLSTACK_TEMPLATE);
278
+ const rawFullstack = await fs.readFile(fullstackPath, "utf-8");
279
+ const fullstack = normalizeTemplate(rawFullstack, FULLSTACK_NAME);
280
+ if (fullstack !== rawFullstack) await writeIfChanged(fullstackPath, fullstack);
281
+
282
+ for (const [name, spec] of Object.entries(PRIMARIES)) {
283
+ await writeIfChanged(path.join(agentsDir, `${name}.md`), buildPrimary(spec, fullstack, models[spec.tier]));
284
+ manifest.agents[name] = {
285
+ file: `${name}.md`,
286
+ mode: spec.mode ?? "primary",
287
+ model: models[spec.tier] ?? null,
288
+ };
289
+ }
290
+ await writeIfChanged(path.join(agentsDir, "planner-sub.md"), buildPlannerSub(fullstack, models.plan));
291
+ manifest.agents["planner-sub"] = {
292
+ file: "planner-sub.md",
293
+ mode: "subagent",
294
+ model: models.plan ?? null,
295
+ hidden: true,
296
+ };
297
+ } else {
298
+ warnings.push(`${FULLSTACK_TEMPLATE} missing: build, plan, planner and planner-sub not generated`);
299
+ }
300
+
301
+ const keepSet = new Set();
302
+ const variants = [];
303
+ for (const name of templates) {
304
+ const rawContent = await fs.readFile(path.join(agentsDir, `${name}.md`), "utf-8");
305
+ const content = normalizeTemplate(rawContent, name);
306
+ if (content !== rawContent) await writeIfChanged(path.join(agentsDir, `${name}.md`), content);
307
+ for (const tier of available) {
308
+ const file = `${name}.${tier}.md`;
309
+ variants.push({ file, path: path.join(agentsDir, file), content: buildVariant(content, models[tier]) });
310
+ keepSet.add(file);
311
+ manifest.agents[name] ??= {};
312
+ manifest.agents[name][tier] = { file, model: models[tier], hidden: true };
313
+ }
314
+ }
315
+
316
+ await Promise.all(variants.map((variant) => writeIfChanged(variant.path, variant.content)));
317
+ await Promise.all(
318
+ variantFiles.filter((file) => !keepSet.has(file)).map((file) => fs.unlink(path.join(agentsDir, file)).catch(() => {})),
319
+ );
320
+
321
+ if (templates.length === 0) warnings.push("no *-engineer.md templates found; no tier variants generated");
322
+
323
+ if (writeManifest) {
324
+ await fs.mkdir(path.join(root, ".opencode"), { recursive: true });
325
+ await writeIfChanged(path.join(root, ".opencode", MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
326
+ }
327
+
328
+ return {
329
+ models,
330
+ sources,
331
+ warnings,
332
+ manifest,
333
+ templates,
334
+ variants: variants.map((variant) => variant.file),
335
+ hasFullstack,
336
+ };
337
+ }