opencode-onboard 0.4.4 → 0.4.7
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 +58 -19
- package/content/.agents/agents/basic-engineer.md +5 -5
- package/content/.agents/agents/devops-manager.md +14 -10
- package/content/.agents/skills/ob-global/SKILL.md +9 -7
- package/content/.opencode/commands/ob-create-architecture.md +76 -0
- package/content/.opencode/commands/ob-create-design.md +53 -0
- package/content/.opencode/commands/{create-engineer.md → ob-create-engineer.md} +9 -8
- package/content/.opencode/commands/ob-init.md +8 -0
- package/content/.opencode/commands/{main.md → ob-main.md} +2 -2
- package/content/.opencode/commands/{plan.md → ob-plan.md} +2 -2
- package/content/.opencode/commands/opsx-apply.md +212 -193
- package/content/.opencode/skills/openspec-apply-change/SKILL.md +234 -176
- package/content/AGENTS.md +161 -49
- package/content/ARCHITECTURE.md +16 -327
- package/content/DESIGN.md +16 -26
- package/package.json +1 -1
- package/src/commands/join.js +6 -1
- package/src/commands/single.js +1 -1
- package/src/presets/models.json +2 -2
- package/src/presets/platforms.json +4 -0
- package/src/steps/copy/agents.js +200 -3
- package/src/steps/copy/agents.test.js +45 -0
- package/src/steps/copy/copy.test.js +15 -2
- package/src/steps/copy/index.js +2 -1
- package/src/steps/metadata/index.js +6 -5
- package/src/steps/metadata/metadata.test.js +16 -8
- package/src/steps/models/write.js +17 -4
- package/src/steps/models/write.test.js +57 -56
- package/src/steps/openspec/ensemble.js +81 -54
- package/src/steps/openspec/ensemble.test.js +40 -8
- package/src/steps/optimization/codegraph.js +51 -0
- package/src/steps/optimization/codegraph.test.js +104 -0
- package/src/steps/optimization/global.js +21 -1
- package/src/steps/optimization/global.test.js +3 -0
- package/src/steps/platform/index.js +8 -1
- package/src/steps/platform/platform.test.js +19 -0
- package/content/.opencode/commands/init.md +0 -8
|
@@ -59,59 +59,60 @@ custom_field: custom_value
|
|
|
59
59
|
})
|
|
60
60
|
})
|
|
61
61
|
|
|
62
|
-
describe('writeModelsToConfigs()', () => {
|
|
63
|
-
let tmpDir, agentsDir, opencodeJsonPath
|
|
64
|
-
|
|
65
|
-
beforeEach(() => {
|
|
66
|
-
vi.clearAllMocks()
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
path.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
expect(success).toHaveBeenCalledWith('
|
|
100
|
-
expect(success).toHaveBeenCalledWith('
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('reports success when writing configs', async () => {
|
|
104
|
-
const agentFile = path.join(agentsDir, '
|
|
105
|
-
fs.writeFileSync(agentFile, '---\nname:
|
|
106
|
-
|
|
107
|
-
await writeModelsToConfigs({
|
|
108
|
-
planModel: 'plan-model',
|
|
109
|
-
buildModel: 'build-model',
|
|
110
|
-
fastModel: 'fast-model',
|
|
111
|
-
agentsDir,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
})
|
|
62
|
+
describe('writeModelsToConfigs()', () => {
|
|
63
|
+
let tmpDir, agentsDir, opencodeJsonPath
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.clearAllMocks()
|
|
67
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-config-test-'))
|
|
68
|
+
agentsDir = path.join(tmpDir, '.agents', 'agents')
|
|
69
|
+
fs.mkdirSync(agentsDir, { recursive: true })
|
|
70
|
+
opencodeJsonPath = path.join(tmpDir, '.opencode', 'opencode.json')
|
|
71
|
+
path.join(tmpDir, '.opencode', 'ensemble.json')
|
|
72
|
+
fs.mkdirSync(path.dirname(opencodeJsonPath), { recursive: true })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('writes build model to agent files', async () => {
|
|
80
|
+
fs.writeFileSync(path.join(agentsDir, 'basic-engineer.md'), '---\nname: Basic\n---', 'utf-8')
|
|
81
|
+
fs.writeFileSync(path.join(agentsDir, 'frontend-engineer.md'), '---\nname: Front\n---', 'utf-8')
|
|
82
|
+
fs.writeFileSync(path.join(agentsDir, 'devops-manager.md'), '---\nname: Devops\n---', 'utf-8')
|
|
83
|
+
|
|
84
|
+
await writeModelsToConfigs({
|
|
85
|
+
planModel: 'plan-model',
|
|
86
|
+
buildModel: 'build-model',
|
|
87
|
+
fastModel: 'fast-model',
|
|
88
|
+
agentsDir,
|
|
89
|
+
cwd: tmpDir,
|
|
90
|
+
preset: {
|
|
91
|
+
roles: {
|
|
92
|
+
build: { agents: ['basic-engineer'] },
|
|
93
|
+
fast: { agents: ['devops-manager'] },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(success).toHaveBeenCalledWith('basic-engineer → build-model')
|
|
99
|
+
expect(success).toHaveBeenCalledWith('frontend-engineer → build-model')
|
|
100
|
+
expect(success).toHaveBeenCalledWith('devops-manager → fast-model')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('reports success when writing configs', async () => {
|
|
104
|
+
const agentFile = path.join(agentsDir, 'basic-engineer.md')
|
|
105
|
+
fs.writeFileSync(agentFile, '---\nname: Basic\n---', 'utf-8')
|
|
106
|
+
|
|
107
|
+
await writeModelsToConfigs({
|
|
108
|
+
planModel: 'plan-model',
|
|
109
|
+
buildModel: 'build-model',
|
|
110
|
+
fastModel: 'fast-model',
|
|
111
|
+
agentsDir,
|
|
112
|
+
cwd: tmpDir,
|
|
113
|
+
preset: { roles: { build: { agents: ['basic-engineer'] }, fast: { agents: [] } } },
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(success).toHaveBeenCalled()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -21,22 +21,27 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
|
|
|
21
21
|
\`\`\`
|
|
22
22
|
Announce: "Team running. Monitor at http://localhost:4747/"
|
|
23
23
|
|
|
24
|
-
**Step 6c.** Add ALL tasks to the shared board BEFORE spawning anyone.
|
|
24
|
+
**Step 6c.** Add ALL tasks to the shared board BEFORE spawning anyone, using as many \`team_tasks_add\` calls as needed to wire dependencies correctly.
|
|
25
25
|
Schema: { content: string, priority: "high"|"medium"|"low", depends_on?: string[] }
|
|
26
|
-
|
|
26
|
+
You cannot reference returned task IDs until an earlier \`team_tasks_add\` call finishes, so add tasks in dependency order.
|
|
27
27
|
\`\`\`
|
|
28
28
|
team_tasks_add tasks:[
|
|
29
29
|
{ content: "1.1 <exact task text from tasks.md>", priority: "high" },
|
|
30
|
-
{ content: "1.2 <exact task text>", priority: "high" }
|
|
31
|
-
{ content: "3.1 <task that needs 1.x done first>", priority: "medium", depends_on: ["<id-of-1.1>"] },
|
|
32
|
-
...every task, one entry each...
|
|
30
|
+
{ content: "1.2 <exact task text>", priority: "high" }
|
|
33
31
|
]
|
|
34
32
|
\`\`\`
|
|
35
|
-
Save the
|
|
33
|
+
Save the returned IDs for root tasks.
|
|
34
|
+
\`\`\`
|
|
35
|
+
team_tasks_add tasks:[
|
|
36
|
+
{ content: "2.1 <task that depends on 1.1>", priority: "high", depends_on: ["<real-id-of-1.1>"] },
|
|
37
|
+
{ content: "3.1 <task that depends on 1.2>", priority: "medium", depends_on: ["<real-id-of-1.2>"] }
|
|
38
|
+
]
|
|
39
|
+
\`\`\`
|
|
40
|
+
Repeat until every OpenSpec task is on the board, then pass the literal IDs returned by those calls to agents in step 6d.
|
|
36
41
|
DO NOT call team_claim yourself, only agents claim tasks.
|
|
37
42
|
DO NOT proceed to 6d until team_tasks_add succeeds.
|
|
38
43
|
|
|
39
|
-
**Step 6d.** Discover
|
|
44
|
+
**Step 6d.** Discover available engineers, then spawn specialists with an INITIAL BATCH of tasks.
|
|
40
45
|
|
|
41
46
|
**ROLLING BATCH MODEL:**
|
|
42
47
|
Agents do NOT receive all their tasks upfront. Instead:
|
|
@@ -45,44 +50,60 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
|
|
|
45
50
|
- Repeat until no pending tasks remain on the board.
|
|
46
51
|
- Only shut down an agent when the board has no more tasks for its domain.
|
|
47
52
|
|
|
48
|
-
Before spawning
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
5.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
53
|
+
Before spawning:
|
|
54
|
+
- scan \`.agents/agents/\` and list the engineers that actually exist in this project
|
|
55
|
+
- exclude \`devops-manager\` from implementation selection
|
|
56
|
+
- read each engineer's description and abilities
|
|
57
|
+
- prefer the most specialized custom engineer whose description and abilities match the task
|
|
58
|
+
- use \`basic-engineer\` only when no custom engineer is a clear fit or as a recovery fallback
|
|
59
|
+
- never spawn an engineer name that is not present in \`.agents/agents/\`
|
|
60
|
+
|
|
61
|
+
Each \`team_spawn\` MUST include the agent field (required, causes NOT NULL error if omitted).
|
|
62
|
+
|
|
63
|
+
The spawn prompt must be short and operational. It must contain:
|
|
64
|
+
1. Their name and engineer file on this team
|
|
65
|
+
2. Their initial batch of tasks (up to 3): include the LITERAL task IDs AND the task content. Copy them verbatim from the IDs returned by \`team_tasks_add\`. Do NOT paraphrase or omit IDs.
|
|
66
|
+
3. Key context they need, summarized from context files
|
|
67
|
+
4. Exact verification commands or acceptance checks
|
|
68
|
+
5. Only the mandatory skill names or repo-specific rules they still need after claim
|
|
69
|
+
|
|
70
|
+
Keep spawn prompts short and concrete. Prefer 120-220 tokens. Do NOT paste a generic tool list or long workflow boilerplate the plugin and agent file already provide.
|
|
71
|
+
ALWAYS set \`claim_task\` to the first unblocked task in that agent's initial batch.
|
|
67
72
|
Only spawn agents whose tasks are actually needed by this change. Skip agents with no tasks.
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
Prompt shape:
|
|
70
75
|
\`\`\`
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
You are <worker-name>, <engineer-file>.
|
|
77
|
+
Claim this task immediately as your first action:
|
|
78
|
+
- [task-<id1>] <task text>
|
|
79
|
+
<optional more tasks in the batch>
|
|
80
|
+
|
|
81
|
+
Key context:
|
|
82
|
+
- <short bullets>
|
|
83
|
+
|
|
84
|
+
After claiming:
|
|
85
|
+
1. Load @ob-global
|
|
86
|
+
2. Load only the relevant abilities/skills for this task
|
|
87
|
+
3. Implement the task
|
|
88
|
+
4. Call team_tasks_complete for the same task ID
|
|
89
|
+
5. Send a short team_message with files changed and checks run
|
|
90
|
+
\`\`\`
|
|
91
|
+
|
|
92
|
+
Spawn sequentially, waiting for each result:
|
|
93
|
+
\`\`\`
|
|
94
|
+
team_spawn name:"ui1" agent:"frontend-engineer" prompt:"..."
|
|
74
95
|
(wait for result)
|
|
75
|
-
team_spawn name:"
|
|
96
|
+
team_spawn name:"api1" agent:"backend-engineer" prompt:"..."
|
|
76
97
|
(wait for result)
|
|
77
98
|
\`\`\`
|
|
99
|
+
Replace example agent names with REAL engineers that exist in this project.
|
|
78
100
|
|
|
79
|
-
Then
|
|
101
|
+
Then send each spawned agent a short start message that repeats their exact task IDs if needed:
|
|
80
102
|
\`\`\`
|
|
81
|
-
team_message to:"
|
|
82
|
-
team_message to:"front" text:"Start now. Load skills first. Your tasks: [task-<id3>] <task3 text>. Call team_claim task_id:<id> before starting it."
|
|
83
|
-
team_message to:"infra" text:"Start now. Load skills first. Your tasks: [task-<id4>] <task4 text>. Call team_claim task_id:<id> before starting it."
|
|
103
|
+
team_message to:"ui1" text:"Claim now: [task-<id1>] <task text>."
|
|
84
104
|
\`\`\`
|
|
85
|
-
|
|
105
|
+
Never send a generic "claim your first task" message without the actual IDs.
|
|
106
|
+
If \`claim_task\` already covers the first task, keep the start message minimal. Use follow-up messages mainly for additional tasks in the batch or recovery.
|
|
86
107
|
|
|
87
108
|
**Step 6e.** After sending start messages, tell the user what is running, then STOP and wait.
|
|
88
109
|
Do NOT call team_results, team_status, or team_broadcast in a loop.
|
|
@@ -96,26 +117,31 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
|
|
|
96
117
|
**Step 6f.** When a teammate messages back (rolling re-assignment loop):
|
|
97
118
|
1. Call \`team_results from:"<name>"\` to read full message.
|
|
98
119
|
2. Call \`team_tasks_list\` to check remaining pending/unassigned tasks on the board.
|
|
99
|
-
3.
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
3. If the teammate is idle and has not claimed any assigned task:
|
|
121
|
+
- resend one short claim-only message with the same literal task IDs
|
|
122
|
+
- if they still do not claim, \`team_shutdown member:"<name>" force:true\`
|
|
123
|
+
- respawn once with a shorter prompt and the same first \`claim_task\`
|
|
124
|
+
- if the second spawn also stays idle, stop forcing ensemble for this change and continue in the main session or ask the user whether to retry later
|
|
125
|
+
4. **If there are more unassigned tasks matching this agent's domain:**
|
|
126
|
+
- Pick up to 3 unassigned, unblocked tasks for this agent's domain.
|
|
127
|
+
- Send them via \`team_message to:"<name>" text:"Claim next: [task-<id1>] <desc>, [task-<id2>] <desc>."\`
|
|
128
|
+
- Do NOT shut down the agent. Go back to waiting (step 6e).
|
|
129
|
+
5. **If no more tasks for this agent:**
|
|
130
|
+
- \`team_shutdown member:"<name>"\`
|
|
131
|
+
- \`team_merge member:"<name>"\`
|
|
132
|
+
- If team_merge blocks on local changes: \`git stash\`, retry merge, \`git stash pop\`.
|
|
133
|
+
6. **If ALL agents are shut down and tasks remain unassigned** (new domain, dependencies unblocked):
|
|
134
|
+
- Discover the remaining matching engineers from \`.agents/agents/\` and spawn a new wave (back to step 6d).
|
|
135
|
+
7. **If ALL tasks are done:** proceed to step 7.
|
|
110
136
|
If a teammate reports rate-limit/quota/token exhaustion, immediately shutdown that teammate and respawn with an available model.
|
|
111
137
|
|
|
112
138
|
**ZERO PENDING TASKS GUARANTEE:** Before proceeding to step 7, call \`team_tasks_list\` and verify EVERY task is either \`done\` or \`blocked\`. If any task is \`pending\` and unassigned, assign it to an agent or spawn a new one. Never leave pending tasks orphaned.
|
|
113
139
|
|
|
114
140
|
7. **Quality check**
|
|
115
141
|
|
|
116
|
-
Spawn
|
|
142
|
+
Spawn the best available verification-capable engineer with \`worktree:false\` (for example, a testing-focused custom engineer or \`basic-engineer\` if no better verifier exists):
|
|
117
143
|
\`\`\`
|
|
118
|
-
team_spawn name:"
|
|
144
|
+
team_spawn name:"verify" agent:"<real-verifier-engineer>" worktree:false prompt:"<verification scope, context summary, run tests + build + lint + verify acceptance criteria, no task claiming required in this phase, send results to lead when done>"
|
|
119
145
|
\`\`\`
|
|
120
146
|
Wait for message -> team_results -> fix blockers -> team_shutdown (no team_merge needed, worktree:false)
|
|
121
147
|
|
|
@@ -139,21 +165,22 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
|
|
|
139
165
|
- NEVER implement tasks directly. Always use team_create + team_spawn, no exceptions
|
|
140
166
|
- NEVER touch source files before team_create is called, not even one edit
|
|
141
167
|
- NEVER call team_spawn without the agent field, it is required and will fail without it
|
|
142
|
-
- NEVER call team_spawn before
|
|
168
|
+
- NEVER call team_spawn before all tasks are on the board; use multiple \`team_tasks_add\` calls when dependencies require real IDs from earlier calls
|
|
143
169
|
- NEVER poll team_results or team_status in a loop, wait for teammates to message you
|
|
144
170
|
- NEVER call team_claim or team_tasks_complete as lead, only agents call these tools
|
|
145
171
|
- NEVER leave pending tasks orphaned, always verify board is empty before proceeding to step 7
|
|
146
172
|
- ALWAYS pass the LITERAL task IDs returned by team_tasks_add into each agent's spawn prompt, copy the exact IDs, never paraphrase
|
|
147
173
|
- ALWAYS assign initial batch of up to 3 tasks per agent; re-assign next batch (up to 3) via team_message when agent reports done
|
|
148
174
|
- ALWAYS call team_tasks_list after each agent reports done to check for remaining unassigned tasks
|
|
149
|
-
- ALWAYS repeat the same literal task IDs in
|
|
175
|
+
- ALWAYS repeat the same literal task IDs in any task assignment message, never send a generic "claim your first task" without the actual IDs
|
|
150
176
|
- NEVER send a start message that omits task IDs; if a task ID is missing from the start message, the agent cannot claim
|
|
151
177
|
- NEVER edit files between team_spawn and team_merge, team_merge blocks on overlapping local changes
|
|
152
|
-
- ALWAYS add every task to the board
|
|
178
|
+
- ALWAYS add every task to the board before spawning, using multiple \`team_tasks_add\` calls when dependency wiring requires it
|
|
179
|
+
- ALWAYS discover engineers from \`.agents/agents/\` and prefer matching custom engineers over \`basic-engineer\`
|
|
153
180
|
- ALWAYS spawn agents sequentially (wait for each team_spawn result before the next), then send start messages to all of them together
|
|
154
|
-
- ALWAYS
|
|
181
|
+
- ALWAYS set \`claim_task\` for the first unblocked task in each initial batch and instruct agents to claim before any other work
|
|
155
182
|
- ALWAYS shut down + merge agents only when no more tasks remain for their domain
|
|
156
|
-
- If teammates are stuck, use
|
|
183
|
+
- If teammates are stuck, use one short claim-only message, then one respawn with a shorter prompt. If repeated idle/stall continues, stop forcing ensemble and continue outside it.
|
|
157
184
|
- Mark tasks complete in openspec AFTER specialists finish, not before
|
|
158
185
|
- Pause on errors, blockers, or unclear requirements. Do not guess
|
|
159
186
|
- Use contextFiles from CLI output, do not assume specific file paths
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import os from 'node:os'
|
|
5
|
-
import { patchApplyFile, APPLY_TARGETS } from './ensemble.js'
|
|
5
|
+
import { patchApplyFile, APPLY_TARGETS, ENSEMBLE_SECTION } from './ensemble.js'
|
|
6
6
|
|
|
7
7
|
describe('patchApplyFile()', () => {
|
|
8
8
|
let tmpDir
|
|
@@ -70,10 +70,42 @@ describe('patchApplyFile()', () => {
|
|
|
70
70
|
})
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
-
describe('APPLY_TARGETS', () => {
|
|
74
|
-
it('contains expected OpenSpec apply file paths', () => {
|
|
75
|
-
expect(APPLY_TARGETS).toHaveLength(2)
|
|
76
|
-
expect(APPLY_TARGETS).toContain(path.join('.opencode', 'commands', 'opsx-apply.md'))
|
|
77
|
-
expect(APPLY_TARGETS).toContain(path.join('.opencode', 'skills', 'openspec-apply-change', 'SKILL.md'))
|
|
78
|
-
})
|
|
79
|
-
})
|
|
73
|
+
describe('APPLY_TARGETS', () => {
|
|
74
|
+
it('contains expected OpenSpec apply file paths', () => {
|
|
75
|
+
expect(APPLY_TARGETS).toHaveLength(2)
|
|
76
|
+
expect(APPLY_TARGETS).toContain(path.join('.opencode', 'commands', 'opsx-apply.md'))
|
|
77
|
+
expect(APPLY_TARGETS).toContain(path.join('.opencode', 'skills', 'openspec-apply-change', 'SKILL.md'))
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('ENSEMBLE_SECTION dependency guidance', () => {
|
|
82
|
+
it('instructs multi-call task creation for dependent tasks', () => {
|
|
83
|
+
expect(ENSEMBLE_SECTION).toContain('using as many `team_tasks_add` calls as needed')
|
|
84
|
+
expect(ENSEMBLE_SECTION).toContain('Save the returned IDs for root tasks.')
|
|
85
|
+
expect(ENSEMBLE_SECTION).toContain('Repeat until every OpenSpec task is on the board')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('requires claim_task and idle-teammate recovery guidance', () => {
|
|
89
|
+
expect(ENSEMBLE_SECTION).toContain('ALWAYS set \`claim_task\` to the first unblocked task')
|
|
90
|
+
expect(ENSEMBLE_SECTION).toContain('If the teammate is idle and has not claimed any assigned task')
|
|
91
|
+
expect(ENSEMBLE_SECTION).toContain('stop forcing ensemble for this change and continue in the main session')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('prefers discovered custom engineers over hardcoded role inventory', () => {
|
|
95
|
+
expect(ENSEMBLE_SECTION).toContain('scan `.agents/agents/` and list the engineers that actually exist in this project')
|
|
96
|
+
expect(ENSEMBLE_SECTION).toContain('prefer the most specialized custom engineer')
|
|
97
|
+
expect(ENSEMBLE_SECTION).not.toContain('front-engineer: UI, components, framework skills')
|
|
98
|
+
expect(ENSEMBLE_SECTION).not.toContain('team_spawn name:"front" agent:"front-engineer"')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('keeps the prompt claim-first and minimal', () => {
|
|
102
|
+
expect(ENSEMBLE_SECTION).toContain('Claim this task immediately as your first action:')
|
|
103
|
+
expect(ENSEMBLE_SECTION).toContain('After claiming:')
|
|
104
|
+
expect(ENSEMBLE_SECTION).not.toContain('Available OpenCode tools:')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not include the impossible same-call dependency example', () => {
|
|
108
|
+
expect(ENSEMBLE_SECTION).not.toContain('{ content: "3.1 <task that needs 1.x done first>", priority: "medium", depends_on: ["<id-of-1.1>"] }')
|
|
109
|
+
expect(ENSEMBLE_SECTION).not.toContain('Use depends_on to block tasks that require other tasks first, pass the IDs returned by team_tasks_add.')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
import { execa } from 'execa'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'node:path'
|
|
2
4
|
import { header, success, warn, error, loading } from '../../utils/exec.js'
|
|
3
5
|
|
|
6
|
+
/**
|
|
7
|
+
* After codegraph install, it may create an `opencode.jsonc` at the project root.
|
|
8
|
+
* This project uses `.opencode/opencode.json` instead. Merge any MCP config from
|
|
9
|
+
* the rogue file into the correct location and remove it.
|
|
10
|
+
*/
|
|
11
|
+
export async function fixCodegraphConfig() {
|
|
12
|
+
const cwd = process.cwd()
|
|
13
|
+
const rogueFile = path.join(cwd, 'opencode.jsonc')
|
|
14
|
+
const correctFile = path.join(cwd, '.opencode', 'opencode.json')
|
|
15
|
+
|
|
16
|
+
if (!await fse.pathExists(rogueFile)) return
|
|
17
|
+
|
|
18
|
+
let rogueContent
|
|
19
|
+
try {
|
|
20
|
+
const raw = await fse.readFile(rogueFile, 'utf-8')
|
|
21
|
+
// Strip JSONC comments (single-line // and block /* */) before parsing
|
|
22
|
+
const stripped = raw
|
|
23
|
+
.replace(/\/\/.*$/gm, '')
|
|
24
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
25
|
+
rogueContent = JSON.parse(stripped)
|
|
26
|
+
} catch {
|
|
27
|
+
warn('Could not parse opencode.jsonc, removing it')
|
|
28
|
+
await fse.remove(rogueFile)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let correctContent = {}
|
|
33
|
+
if (await fse.pathExists(correctFile)) {
|
|
34
|
+
try {
|
|
35
|
+
correctContent = await fse.readJson(correctFile)
|
|
36
|
+
} catch {
|
|
37
|
+
correctContent = {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Merge mcpServers from rogue into correct config
|
|
42
|
+
if (rogueContent.mcpServers || rogueContent.mcp) {
|
|
43
|
+
const mcpServers = rogueContent.mcpServers || rogueContent.mcp
|
|
44
|
+
correctContent.mcpServers = { ...(correctContent.mcpServers || {}), ...mcpServers }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await fse.ensureDir(path.dirname(correctFile))
|
|
48
|
+
await fse.writeJson(correctFile, correctContent, { spaces: 2 })
|
|
49
|
+
await fse.remove(rogueFile)
|
|
50
|
+
warn('Migrated codegraph config from opencode.jsonc → .opencode/opencode.json (removed opencode.jsonc)')
|
|
51
|
+
}
|
|
52
|
+
|
|
4
53
|
export async function installCodegraph(options = {}) {
|
|
5
54
|
if (!options.skipHeader) header('Installing codegraph')
|
|
6
55
|
|
|
@@ -23,6 +72,8 @@ export async function installCodegraph(options = {}) {
|
|
|
23
72
|
warn('codegraph install exited with non-zero code')
|
|
24
73
|
return { optedIn: true, installed: false }
|
|
25
74
|
}
|
|
75
|
+
|
|
76
|
+
await fixCodegraphConfig()
|
|
26
77
|
success(`codegraph configured for opencode (${location})`)
|
|
27
78
|
} catch (err) {
|
|
28
79
|
error(`Failed to install codegraph: ${err.message}`)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import fse from 'fs-extra'
|
|
6
|
+
|
|
7
|
+
vi.mock('execa', () => ({ execa: vi.fn() }))
|
|
8
|
+
vi.mock('../../utils/exec.js', () => ({
|
|
9
|
+
header: vi.fn(),
|
|
10
|
+
success: vi.fn(),
|
|
11
|
+
warn: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
loading: vi.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import { warn } from '../../utils/exec.js'
|
|
17
|
+
import { fixCodegraphConfig } from './codegraph.js'
|
|
18
|
+
|
|
19
|
+
describe('fixCodegraphConfig()', () => {
|
|
20
|
+
let tmpDir
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-'))
|
|
24
|
+
vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
29
|
+
vi.restoreAllMocks()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('does nothing when opencode.jsonc does not exist', async () => {
|
|
33
|
+
await fixCodegraphConfig()
|
|
34
|
+
// No error, no file created
|
|
35
|
+
expect(fs.existsSync(path.join(tmpDir, '.opencode', 'opencode.json'))).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('merges mcpServers from opencode.jsonc into .opencode/opencode.json', async () => {
|
|
39
|
+
const rogueContent = {
|
|
40
|
+
mcpServers: {
|
|
41
|
+
codegraph: { command: 'codegraph', args: ['mcp'] }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), JSON.stringify(rogueContent))
|
|
45
|
+
|
|
46
|
+
const opencodeDir = path.join(tmpDir, '.opencode')
|
|
47
|
+
fs.mkdirSync(opencodeDir, { recursive: true })
|
|
48
|
+
fs.writeFileSync(path.join(opencodeDir, 'opencode.json'), JSON.stringify({
|
|
49
|
+
"$schema": "https://opencode.ai/config.json",
|
|
50
|
+
"plugin": ["opencode-plugin-openspec@latest"]
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
await fixCodegraphConfig()
|
|
54
|
+
|
|
55
|
+
expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
|
|
56
|
+
const result = await fse.readJson(path.join(opencodeDir, 'opencode.json'))
|
|
57
|
+
expect(result.mcpServers.codegraph).toEqual({ command: 'codegraph', args: ['mcp'] })
|
|
58
|
+
expect(result.plugin).toEqual(["opencode-plugin-openspec@latest"])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('handles JSONC with comments', async () => {
|
|
62
|
+
const rogueRaw = `{
|
|
63
|
+
// This is a comment
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"codegraph": { "command": "codegraph", "args": ["mcp"] }
|
|
66
|
+
}
|
|
67
|
+
}`
|
|
68
|
+
fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), rogueRaw)
|
|
69
|
+
|
|
70
|
+
const opencodeDir = path.join(tmpDir, '.opencode')
|
|
71
|
+
fs.mkdirSync(opencodeDir, { recursive: true })
|
|
72
|
+
fs.writeFileSync(path.join(opencodeDir, 'opencode.json'), '{}')
|
|
73
|
+
|
|
74
|
+
await fixCodegraphConfig()
|
|
75
|
+
|
|
76
|
+
expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
|
|
77
|
+
const result = await fse.readJson(path.join(opencodeDir, 'opencode.json'))
|
|
78
|
+
expect(result.mcpServers.codegraph.command).toBe('codegraph')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('removes unparseable opencode.jsonc and warns', async () => {
|
|
82
|
+
fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), 'not valid json {{{')
|
|
83
|
+
|
|
84
|
+
await fixCodegraphConfig()
|
|
85
|
+
|
|
86
|
+
expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
|
|
87
|
+
expect(warn).toHaveBeenCalledWith('Could not parse opencode.jsonc, removing it')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('creates .opencode/opencode.json if it does not exist', async () => {
|
|
91
|
+
const rogueContent = {
|
|
92
|
+
mcpServers: {
|
|
93
|
+
codegraph: { command: 'codegraph', args: ['mcp'] }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), JSON.stringify(rogueContent))
|
|
97
|
+
|
|
98
|
+
await fixCodegraphConfig()
|
|
99
|
+
|
|
100
|
+
const result = await fse.readJson(path.join(tmpDir, '.opencode', 'opencode.json'))
|
|
101
|
+
expect(result.mcpServers.codegraph.command).toBe('codegraph')
|
|
102
|
+
expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -29,7 +29,27 @@ function buildSourceRootsSection(sourceRoots, cwd) {
|
|
|
29
29
|
|
|
30
30
|
function buildRtkSection(rtkEnabled) {
|
|
31
31
|
if (!rtkEnabled) return 'RTK was not selected during onboarding. Do not assume `rtk` exists.'
|
|
32
|
-
return
|
|
32
|
+
return [
|
|
33
|
+
'## RTK, MANDATORY',
|
|
34
|
+
'',
|
|
35
|
+
'RTK has NO automatic hook in OpenCode. You MUST explicitly prefix every CLI command with `rtk`. It does not happen automatically.',
|
|
36
|
+
'',
|
|
37
|
+
'Prefix ALL shell commands with `rtk`:',
|
|
38
|
+
'- `rtk git diff` NOT `git diff`',
|
|
39
|
+
'- `rtk git log` NOT `git log`',
|
|
40
|
+
'- `rtk gh` NOT `gh`',
|
|
41
|
+
'- `rtk az` NOT `az`',
|
|
42
|
+
'- `rtk openspec` NOT `openspec`',
|
|
43
|
+
'- `rtk npx tsc --noEmit` NOT `npx tsc --noEmit`',
|
|
44
|
+
'- `rtk pnpm build` NOT `pnpm build`',
|
|
45
|
+
'- `rtk pnpm test` NOT `pnpm test`',
|
|
46
|
+
'- `rtk pnpm lint` NOT `pnpm lint`',
|
|
47
|
+
'- `rtk dotnet build` NOT `dotnet build`',
|
|
48
|
+
'',
|
|
49
|
+
'Light read-only commands that produce minimal output (e.g. `cat`, `ls`, `Get-Content`, `Select-String`) do not need `rtk`.',
|
|
50
|
+
'',
|
|
51
|
+
'If `rtk` is not available, report blocker and stop CLI execution.',
|
|
52
|
+
].join('\n')
|
|
33
53
|
}
|
|
34
54
|
|
|
35
55
|
function buildCavemanSection(cavemanEnabled) {
|
|
@@ -64,6 +64,9 @@ describe('configureObGlobal()', () => {
|
|
|
64
64
|
await configureObGlobal({}, { rtk: { optedIn: true } })
|
|
65
65
|
const content = fs.readFileSync(skillPath, 'utf-8')
|
|
66
66
|
expect(content).toContain('RTK, MANDATORY')
|
|
67
|
+
expect(content).toContain('NO automatic hook in OpenCode')
|
|
68
|
+
expect(content).toContain('rtk pnpm build')
|
|
69
|
+
expect(content).toContain('rtk npx tsc')
|
|
67
70
|
})
|
|
68
71
|
|
|
69
72
|
it('injects RTK not-selected note when rtk is not opted in', async () => {
|
|
@@ -12,6 +12,12 @@ const platformsPreset = await fse.readJson(PLATFORMS_PRESET_PATH)
|
|
|
12
12
|
|
|
13
13
|
export async function checkPlatform(platform) {
|
|
14
14
|
const preset = platformsPreset.find(p => p.value === platform) || platformsPreset[0]
|
|
15
|
+
if (!preset.cli) {
|
|
16
|
+
header(`Step 4, Checking ${preset.name} CLI`)
|
|
17
|
+
info('No platform integration selected, skipping CLI checks.')
|
|
18
|
+
success(`Platform: ${preset.name}`)
|
|
19
|
+
return
|
|
20
|
+
}
|
|
15
21
|
await checkPlatformCli(preset)
|
|
16
22
|
}
|
|
17
23
|
|
|
@@ -23,7 +29,8 @@ export async function choosePlatform() {
|
|
|
23
29
|
choices: platformsPreset.map(p => ({ name: p.name, value: p.value })),
|
|
24
30
|
})
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
const preset = platformsPreset.find(p => p.value === platform)
|
|
33
|
+
success(`Platform: ${preset?.name || platform}`)
|
|
27
34
|
await checkPlatform(platform)
|
|
28
35
|
return platform
|
|
29
36
|
}
|