@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 +7 -5
- package/cli/commands/join.js +1 -1
- package/cli/fragments/guardrails/humanizer.md +2 -2
- package/cli/fragments/guardrails/simple-english.md +2 -2
- package/cli/index.js +1 -1
- package/cli/presets/agents-content.json +1 -1
- package/cli/presets/browser.json +4 -4
- package/cli/steps/browser/index.js +15 -11
- package/cli/steps/copy/agents.js +16 -0
- package/cli/steps/copy/fullstack-engineer.js +0 -8
- package/harness/.opencode/_gitignore +2 -0
- package/harness/.opencode/package.json +2 -1
- package/harness/.opencode/plugins/pc-subagent-tiers-core.js +337 -0
- package/harness/.opencode/plugins/pc-subagent-tiers.js +34 -345
- package/harness/.opencode/scripts/harness-doctor.mjs +52 -0
- package/harness/.opencode/scripts/harness-doctor.test.mjs +58 -0
- package/harness/.opencode/scripts/sync-agents.mjs +29 -0
- package/harness/AGENTS.md +7 -1
- package/harness/opencode.jsonc +2 -2
- package/package.json +1 -1
- package/skills/agent-harness-cli/SKILL.md +1 -1
- package/skills/browser-automation/SKILL.md +68 -40
- package/skills/pc-make-user-model/SKILL.md +1 -1
- package/skills/pc-plan-apply/SKILL.md +3 -3
- package/skills/pc-plan-propose/task-annotation.md +1 -1
- package/skills/pc-userstory-browser/SKILL.md +20 -11
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.
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
|
package/cli/commands/join.js
CHANGED
|
@@ -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
|
|
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 (
|
|
1
|
+
## Humanizer (before writing prose)
|
|
2
2
|
|
|
3
|
-
-
|
|
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 (
|
|
1
|
+
## Simple English (before writing prose)
|
|
2
2
|
|
|
3
|
-
-
|
|
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
|
|
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
|
|
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
|
}
|
package/cli/presets/browser.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"installer": {
|
|
3
3
|
"command": "npm",
|
|
4
|
-
"args": ["install", "-g", "
|
|
4
|
+
"args": ["install", "-g", "@opencode/browse@0.0.14"]
|
|
5
5
|
},
|
|
6
|
-
"
|
|
7
|
-
"command": "
|
|
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
|
|
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('
|
|
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('
|
|
26
|
+
success('browse installed')
|
|
27
27
|
} catch (err) {
|
|
28
|
-
error(`Failed to install
|
|
28
|
+
error(`Failed to install browse: ${err.message}`)
|
|
29
29
|
return
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
|
-
const
|
|
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 (
|
|
39
|
-
success('
|
|
40
|
-
if (
|
|
39
|
+
if (setup.exitCode === 0) {
|
|
40
|
+
success('browse paired with your browser')
|
|
41
|
+
if (setup.stdout) info(setup.stdout.trim())
|
|
41
42
|
} else {
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
50
|
+
error(`Failed to set up browse: ${err.message}`)
|
|
47
51
|
}
|
|
48
52
|
}
|
package/cli/steps/copy/agents.js
CHANGED
|
@@ -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
|
|
|
@@ -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
|
+
}
|