opencode-onboard 0.5.1 → 0.5.3

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.
@@ -122,9 +122,16 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
122
122
  - read each engineer's description and abilities
123
123
  - prefer the most specialized custom engineer whose description and abilities match the task
124
124
  - use `basic-engineer` only when no custom engineer is a clear fit or as a recovery fallback
125
- - never spawn an engineer name that is not present in `.opencode/agents/`
126
-
127
- REQUIRED assignment algorithm (do not skip):
125
+ - never spawn an engineer name that is not present in `.opencode/agents/`
126
+
127
+ **Model resolution for spawned agents (do not skip):**
128
+ Before each `team_spawn`, resolve the model for that agent using this priority:
129
+ 1. **Agent file frontmatter** — Read `.opencode/agents/<engineer-file>` and check for a `model:` field in the YAML frontmatter. This is the user's explicit choice for this specific agent.
130
+ 2. **`ensemble.json` `modelsByAgent`** — Read `.opencode/ensemble.json`. If `modelsByAgent` contains a key matching this agent's role (build agents → `build`, devops-manager → `explore`), use that value.
131
+ 3. **Active chat model** — Fall back to the model currently running this conversation (your own model).
132
+ Never hardcode a model ID. Never skip this resolution. Log the result: `[model: <id> ← <source: agent frontmatter / ensemble.json modelsByAgent / active chat>]`
133
+
134
+ REQUIRED assignment algorithm (do not skip):
128
135
  1. Build candidate list from `.opencode/agents/*.md` excluding `devops-manager`.
129
136
  2. Classify each task by domain using task text (api/backend, ui/frontend, infra/devops, testing/qa).
130
137
  3. For each task, score every candidate agent:
@@ -174,13 +181,13 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
174
181
  ALWAYS set `claim_task` to the first unblocked task in that agent's initial batch.
175
182
  Only spawn agents whose tasks are actually needed by this change. Skip agents with no tasks.
176
183
 
177
- Spawn sequentially, waiting for each result:
178
- ```
179
- team_spawn name:"ui1" agent:"frontend-engineer" prompt:"..."
180
- (wait for result)
181
- team_spawn name:"api1" agent:"backend-engineer" prompt:"..."
182
- (wait for result)
183
- ```
184
+ Spawn sequentially, waiting for each result:
185
+ ```
186
+ team_spawn name:"ui1" agent:"frontend-engineer" model:"<resolved-model>" prompt:"..."
187
+ (wait for result)
188
+ team_spawn name:"api1" agent:"backend-engineer" model:"<resolved-model>" prompt:"..."
189
+ (wait for result)
190
+ ```
184
191
  Replace example agent names with REAL engineers that exist in this project.
185
192
 
186
193
  Then send each spawned worker a short start message that repeats their exact task IDs if needed:
@@ -226,7 +233,7 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
226
233
 
227
234
  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):
228
235
  ```
229
- 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>"
236
+ team_spawn name:"verify" agent:"<real-verifier-engineer>" worktree:false model:"<resolved-model>" 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>"
230
237
  ```
231
238
  Wait for message -> team_results -> fix blockers -> team_shutdown (no team_merge needed, worktree:false)
232
239
 
@@ -253,9 +260,10 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
253
260
  - NEVER implement tasks directly. Always use team_create + team_spawn, no exceptions
254
261
  - NEVER touch source files before team_create is called, not even one edit
255
262
  - NEVER call team_spawn without the agent field, it is required and will fail without it
256
- - 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
257
- - NEVER poll team_results or team_status in a loop, wait for teammates to message you
258
- - NEVER call team_claim or team_tasks_complete as lead, only agents call these tools
263
+ - NEVER call team_spawn without a resolved `model:` field; always pass the model resolved from the priority chain
264
+ - 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
265
+ - NEVER poll team_results or team_status in a loop, wait for teammates to message you
266
+ - NEVER call team_claim or team_tasks_complete as lead, only agents call these tools
259
267
  - NEVER edit files between team_spawn and team_merge, team_merge blocks on overlapping local changes
260
268
  - NEVER leave pending tasks orphaned, always verify board is empty before proceeding to step 7
261
269
  - ALWAYS pass the LITERAL task IDs returned by team_tasks_add into each agent's spawn prompt, copy the exact IDs, never paraphrase
@@ -128,9 +128,16 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
128
128
  - read each engineer's description and abilities
129
129
  - prefer the most specialized custom engineer whose description and abilities match the task
130
130
  - use `basic-engineer` only when no custom engineer is a clear fit or as a recovery fallback
131
- - never spawn an engineer name that is not present in `.opencode/agents/`
131
+ - never spawn an engineer name that is not present in `.opencode/agents/`
132
132
 
133
- REQUIRED assignment algorithm (do not skip):
133
+ **Model resolution for spawned agents (do not skip):**
134
+ Before each `team_spawn`, resolve the model for that agent using this priority:
135
+ 1. **Agent file frontmatter** — Read `.opencode/agents/<engineer-file>` and check for a `model:` field in the YAML frontmatter. This is the user's explicit choice for this specific agent.
136
+ 2. **`ensemble.json` `modelsByAgent`** — Read `.opencode/ensemble.json`. If `modelsByAgent` contains a key matching this agent's role (build agents → `build`, devops-manager → `explore`), use that value.
137
+ 3. **Active chat model** — Fall back to the model currently running this conversation (your own model).
138
+ Never hardcode a model ID. Never skip this resolution. Log the result: `[model: <id> ← <source: agent frontmatter / ensemble.json modelsByAgent / active chat>]`
139
+
140
+ REQUIRED assignment algorithm (do not skip):
134
141
  1. Build candidate list from `.opencode/agents/*.md` excluding `devops-manager`.
135
142
  2. Classify each task by domain using task text (api/backend, ui/frontend, infra/devops, testing/qa).
136
143
  3. For each task, score every candidate agent:
@@ -181,13 +188,13 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
181
188
  5. Send a short team_message with files changed and checks run
182
189
  ```
183
190
 
184
- Spawn sequentially, waiting for each result:
185
- ```
186
- team_spawn name:"ui1" agent:"frontend-engineer" prompt:"..."
187
- (wait for result)
188
- team_spawn name:"api1" agent:"backend-engineer" prompt:"..."
189
- (wait for result)
190
- ```
191
+ Spawn sequentially, waiting for each result:
192
+ ```
193
+ team_spawn name:"ui1" agent:"frontend-engineer" model:"<resolved-model>" prompt:"..."
194
+ (wait for result)
195
+ team_spawn name:"api1" agent:"backend-engineer" model:"<resolved-model>" prompt:"..."
196
+ (wait for result)
197
+ ```
191
198
  Replace example agent names with REAL engineers that exist in this project.
192
199
 
193
200
  Then send each spawned worker a short start message that repeats their exact task IDs if needed:
@@ -232,7 +239,7 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
232
239
 
233
240
  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):
234
241
  ```
235
- 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>"
242
+ team_spawn name:"verify" agent:"<real-verifier-engineer>" worktree:false model:"<resolved-model>" 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>"
236
243
  ```
237
244
  Wait for message -> team_results -> fix blockers -> team_shutdown (no team_merge needed, worktree:false)
238
245
 
@@ -259,6 +266,7 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
259
266
  - NEVER implement tasks directly. Always use team_create + team_spawn, no exceptions
260
267
  - NEVER touch source files before team_create is called, not even one edit
261
268
  - NEVER call team_spawn without the agent field, it is required and will fail without it
269
+ - NEVER call team_spawn without a resolved `model:` field; always pass the model resolved from the priority chain
262
270
  - 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
263
271
  - NEVER poll team_results or team_status in a loop, wait for teammates to message you
264
272
  - NEVER call team_claim or team_tasks_complete as lead, only agents call these tools
package/content/AGENTS.md CHANGED
@@ -248,8 +248,9 @@ Core tools used in this workflow:
248
248
 
249
249
  If a teammate stalls due to model quota/rate-limit exhaustion:
250
250
  1. `team_shutdown name:"<stuck-member>" force:true`
251
- 2. `team_spawn` same member/task with an available model
252
- 3. `team_message` start instruction with the exact next task ID
251
+ 2. Resolve a new model using the model resolution priority (agent file frontmatter → `ensemble.json` `modelsByAgent` active chat model). Avoid the model that hit the rate limit.
252
+ 3. `team_spawn` same member/task with the resolved model
253
+ 4. `team_message` start instruction with the exact next task ID
253
254
 
254
255
  ---
255
256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-onboard",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Prepare any codebase for AI agent workflows using OpenCode, OpenSpec, and ensemble orchestration.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -1,3 +1,4 @@
1
+ import { execa } from 'execa'
1
2
  import fse from 'fs-extra'
2
3
  import path from 'node:path'
3
4
 
@@ -10,3 +11,15 @@ export async function readOnboardConfig() {
10
11
  return null
11
12
  }
12
13
  }
14
+
15
+ export async function ensureGitLongpaths(cwd = process.cwd()) {
16
+ try {
17
+ const repoCheck = await execa('git', ['rev-parse', '--is-inside-work-tree'], { cwd, reject: false })
18
+ if (repoCheck.exitCode !== 0 || repoCheck.stdout.trim() !== 'true') return false
19
+
20
+ const configResult = await execa('git', ['config', 'core.longpaths', 'true'], { cwd, reject: false })
21
+ return configResult.exitCode === 0
22
+ } catch {
23
+ return false
24
+ }
25
+ }
@@ -1,13 +1,20 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
1
+ import { describe, it, expect, beforeEach, afterEach, vi } 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 { readOnboardConfig } from './shared.js'
5
+
6
+ vi.mock('execa', () => ({
7
+ execa: vi.fn(),
8
+ }))
9
+
10
+ import { execa } from 'execa'
11
+ import { ensureGitLongpaths, readOnboardConfig } from './shared.js'
6
12
 
7
13
  describe('readOnboardConfig()', () => {
8
14
  let tmpDir, originalCwd
9
15
 
10
16
  beforeEach(() => {
17
+ vi.clearAllMocks()
11
18
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shared-test-'))
12
19
  originalCwd = process.cwd()
13
20
  process.chdir(tmpDir)
@@ -54,3 +61,36 @@ describe('readOnboardConfig()', () => {
54
61
  expect(result).toBeNull()
55
62
  })
56
63
  })
64
+
65
+ describe('ensureGitLongpaths()', () => {
66
+ beforeEach(() => {
67
+ vi.clearAllMocks()
68
+ })
69
+
70
+ it('configures core.longpaths in a git repository', async () => {
71
+ execa
72
+ .mockResolvedValueOnce({ exitCode: 0, stdout: 'true' })
73
+ .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' })
74
+
75
+ const result = await ensureGitLongpaths('C:/repo')
76
+
77
+ expect(result).toBe(true)
78
+ expect(execa).toHaveBeenNthCalledWith(1, 'git', ['rev-parse', '--is-inside-work-tree'], {
79
+ cwd: 'C:/repo',
80
+ reject: false,
81
+ })
82
+ expect(execa).toHaveBeenNthCalledWith(2, 'git', ['config', 'core.longpaths', 'true'], {
83
+ cwd: 'C:/repo',
84
+ reject: false,
85
+ })
86
+ })
87
+
88
+ it('skips configuration outside a git repository', async () => {
89
+ execa.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' })
90
+
91
+ const result = await ensureGitLongpaths('C:/not-a-repo')
92
+
93
+ expect(result).toBe(false)
94
+ expect(execa).toHaveBeenCalledTimes(1)
95
+ })
96
+ })
@@ -1,7 +1,8 @@
1
1
  import { select as wizardSelect } from '@inquirer/prompts'
2
- import chalk from 'chalk'
3
- import { chooseSourceScope } from '../steps/source/index.js'
4
- import { cleanAiFiles } from '../steps/clean/index.js'
2
+ import chalk from 'chalk'
3
+ import { ensureGitLongpaths } from './shared.js'
4
+ import { chooseSourceScope } from '../steps/source/index.js'
5
+ import { cleanAiFiles } from '../steps/clean/index.js'
5
6
  import { choosePlatform } from '../steps/platform/index.js'
6
7
  import { copyContentStep } from '../steps/copy/index.js'
7
8
  import { initOpenspec } from '../steps/openspec/index.js'
@@ -37,7 +38,7 @@ export async function runWizard(version) {
37
38
  console.log()
38
39
 
39
40
  // Only wait for Enter in a real interactive TTY
40
- if (process.stdin.isTTY) {
41
+ if (process.stdin.isTTY) {
41
42
  console.log(chalk.bold(' Press Enter to begin...'))
42
43
  console.log()
43
44
  await new Promise(resolve => {
@@ -46,10 +47,13 @@ export async function runWizard(version) {
46
47
  process.stdin.pause()
47
48
  resolve()
48
49
  })
49
- })
50
- }
51
-
52
- const scope = await chooseSourceScope()
50
+ })
51
+ }
52
+
53
+ const longpathsConfigured = await ensureGitLongpaths()
54
+ if (longpathsConfigured) console.log(chalk.dim(' Git long paths enabled for this repository.'))
55
+
56
+ const scope = await chooseSourceScope()
53
57
 
54
58
  const maxConcurrentAgents = await wizardSelect({
55
59
  message: 'Max concurrent agents:',
@@ -24,8 +24,15 @@ export async function writeOnboardConfig(data) {
24
24
  const opencodeVersion = await detectOpencodeVersion()
25
25
  const cwd = data.cwd ?? process.cwd()
26
26
  const target = path.join(cwd, '.opencode', 'opencode-onboard.json')
27
-
28
- const payload = {
27
+ const selectedModels = Object.fromEntries(
28
+ Object.entries({
29
+ plan: data.planModel,
30
+ build: data.buildModel,
31
+ fast: data.fastModel,
32
+ }).filter(([, value]) => value)
33
+ )
34
+
35
+ const payload = {
29
36
  schema: 1,
30
37
  generatedAt: new Date().toISOString(),
31
38
  onboardVersion,
@@ -39,17 +46,13 @@ export async function writeOnboardConfig(data) {
39
46
  design: !!data.hasDesign,
40
47
  architecture: !!data.hasArchitecture,
41
48
  openspec: !!data.hasOpenspec,
42
- },
43
- openspec: data.openspec,
44
- additionalSkillsProvider: data.additionalSkillsProvider,
45
- models: {
46
- plan: data.planModel,
47
- build: data.buildModel,
48
- fast: data.fastModel,
49
- },
50
- optionalTools: data.optionalTools ?? null,
51
- cavemanGuidance: data.cavemanGuidance ?? null,
52
- },
49
+ },
50
+ openspec: data.openspec,
51
+ additionalSkillsProvider: data.additionalSkillsProvider,
52
+ ...(Object.keys(selectedModels).length > 0 ? { models: selectedModels } : {}),
53
+ optionalTools: data.optionalTools ?? null,
54
+ cavemanGuidance: data.cavemanGuidance ?? null,
55
+ },
53
56
  note: 'Informational file only. Editing this file does not change runtime behavior.',
54
57
  }
55
58
 
@@ -104,4 +104,14 @@ describe('writeOnboardConfig()', () => {
104
104
  const payload = call[1]
105
105
  expect(payload.wizard.platform).toBe('none')
106
106
  })
107
+
108
+ it('omits model metadata when no model is selected', async () => {
109
+ execa.mockResolvedValue({ exitCode: 0, stdout: '1', stderr: '' })
110
+
111
+ await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [], cwd: tmpDir })
112
+
113
+ const call = fse.writeJson.mock.calls[0]
114
+ const payload = call[1]
115
+ expect(payload.wizard.models).toBeUndefined()
116
+ })
107
117
  })
@@ -23,8 +23,8 @@ function formatPrice(price) {
23
23
  return `$${price}/M`;
24
24
  }
25
25
 
26
- export function buildDisplayModels(rawModels) {
27
- return rawModels.map(m => {
26
+ export function buildDisplayModels(rawModels) {
27
+ return rawModels.map(m => {
28
28
  const priceStr = formatPrice(m.cost);
29
29
  const canonicalNote = m.canonicalCost !== undefined
30
30
  ? ` · official price: ${formatPrice(m.canonicalCost)}/M`
@@ -35,27 +35,37 @@ export function buildDisplayModels(rawModels) {
35
35
  label: `${m.name}${costTierDisplay(m.cost, m.canonicalCost)}, ${m.id}`,
36
36
  description: `${priceStr}${canonicalNote} · context: ${context}`,
37
37
  };
38
- });
39
- }
40
-
41
- export function pickModel(message, models) {
42
- return search({
43
- message,
44
- source: (input) => {
45
- const q = (input || '').toLowerCase();
46
- const filtered = q
47
- ? models.filter(m =>
48
- m.label.toLowerCase().includes(q) ||
49
- m.id.toLowerCase().includes(q)
50
- )
51
- : models;
52
- return filtered.slice(0, 50).map(m => ({
53
- name: m.label,
54
- value: m.id,
55
- description: m.description,
56
- }));
57
- },
58
- });
59
- }
38
+ });
39
+ }
40
+
41
+ export function buildModelChoices(input, models) {
42
+ const q = (input || '').toLowerCase();
43
+ const filtered = q
44
+ ? models.filter(m =>
45
+ m.label.toLowerCase().includes(q) ||
46
+ m.id.toLowerCase().includes(q)
47
+ )
48
+ : models;
49
+
50
+ return [
51
+ {
52
+ name: 'None',
53
+ value: null,
54
+ description: 'Leave this model unset',
55
+ },
56
+ ...filtered.slice(0, 50).map(m => ({
57
+ name: m.label,
58
+ value: m.id,
59
+ description: m.description,
60
+ })),
61
+ ];
62
+ }
63
+
64
+ export function pickModel(message, models) {
65
+ return search({
66
+ message,
67
+ source: input => buildModelChoices(input, models),
68
+ });
69
+ }
60
70
 
61
71
  export { modelsPreset };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { buildDisplayModels } from './format.js'
2
+ import { buildDisplayModels, buildModelChoices } from './format.js'
3
3
 
4
4
  describe('buildDisplayModels()', () => {
5
5
  it('adds cost tier label for cheap models', () => {
@@ -72,4 +72,30 @@ describe('buildDisplayModels()', () => {
72
72
  expect(result[1].id).toBe('cheap/model')
73
73
  expect(result[2].id).toBe('mid/model')
74
74
  })
75
- })
75
+
76
+ it('keeps None as the first choice with no search', () => {
77
+ const models = buildDisplayModels([
78
+ { id: 'openai/gpt-4.1', name: 'GPT-4.1', cost: 2, context: 128000 },
79
+ ])
80
+
81
+ const result = buildModelChoices('', models)
82
+
83
+ expect(result[0]).toEqual({
84
+ name: 'None',
85
+ value: null,
86
+ description: 'Leave this model unset',
87
+ })
88
+ })
89
+
90
+ it('keeps None as the first choice during search', () => {
91
+ const models = buildDisplayModels([
92
+ { id: 'openai/gpt-4.1', name: 'GPT-4.1', cost: 2, context: 128000 },
93
+ { id: 'anthropic/claude-3-5-sonnet', name: 'Claude 3.5 Sonnet', cost: 3, context: 200000 },
94
+ ])
95
+
96
+ const result = buildModelChoices('claude', models)
97
+
98
+ expect(result[0].name).toBe('None')
99
+ expect(result[1].value).toBe('anthropic/claude-3-5-sonnet')
100
+ })
101
+ })
@@ -2,12 +2,20 @@ import fse from 'fs-extra'
2
2
  import path from 'path'
3
3
  import { success } from '../../utils/exec.js'
4
4
 
5
+ function updateFrontmatterModel(content, modelId) {
6
+ return content.replace(
7
+ /^(---\n)([\s\S]*?)(\n---)/m,
8
+ (_, start, body, end) => {
9
+ const withoutModel = body.replace(/^model:\s.*\n?/m, '').replace(/\n+$/, '')
10
+ const nextBody = modelId ? `${withoutModel}\nmodel: ${modelId}` : withoutModel
11
+ return `${start}${nextBody}${end}`
12
+ }
13
+ )
14
+ }
15
+
5
16
  export async function writeModelToAgent(agentFile, modelId) {
6
17
  const content = await fse.readFile(agentFile, 'utf-8');
7
- const updated = content.replace(
8
- /^(---\n[\s\S]*?)\n---/m,
9
- `$1\nmodel: ${modelId}\n---`
10
- );
18
+ const updated = updateFrontmatterModel(content, modelId)
11
19
  await fse.writeFile(agentFile, updated, 'utf-8');
12
20
  }
13
21
 
@@ -29,7 +37,7 @@ export async function writeModelsToConfigs({ planModel, buildModel, fastModel, a
29
37
  const file = path.join(agentsDir, `${name}.md`);
30
38
  if (await fse.pathExists(file)) {
31
39
  await writeModelToAgent(file, buildModel);
32
- success(`${name} → ${buildModel}`);
40
+ if (buildModel) success(`${name} → ${buildModel}`);
33
41
  }
34
42
  }
35
43
 
@@ -37,31 +45,37 @@ export async function writeModelsToConfigs({ planModel, buildModel, fastModel, a
37
45
  const file = path.join(agentsDir, `${name}.md`);
38
46
  if (await fse.pathExists(file)) {
39
47
  await writeModelToAgent(file, fastModel);
40
- success(`${name} → ${fastModel}`);
48
+ if (fastModel) success(`${name} → ${fastModel}`);
41
49
  }
42
50
  }
43
51
 
44
52
  const opencodeJsonPath = path.join(cwd, '.opencode', 'opencode.json');
45
53
  if (await fse.pathExists(opencodeJsonPath)) {
46
54
  const config = await fse.readJson(opencodeJsonPath);
47
- config.model = buildModel;
55
+ if (buildModel) config.model = buildModel;
56
+ else delete config.model;
48
57
  await fse.writeJson(opencodeJsonPath, config, { spaces: 2 });
49
- success(`default model -> ${buildModel} (written to .opencode/opencode.json)`);
58
+ if (buildModel) success(`default model -> ${buildModel} (written to .opencode/opencode.json)`);
50
59
  }
51
60
 
52
61
  const ensembleJsonPath = path.join(cwd, '.opencode', 'ensemble.json');
53
62
  if (await fse.pathExists(ensembleJsonPath)) {
54
63
  const ensemble = await fse.readJson(ensembleJsonPath);
55
64
  delete ensemble.defaultModel;
56
- ensemble.modelsByAgent = {
57
- ...ensemble.modelsByAgent,
58
- plan: planModel,
59
- build: buildModel,
60
- explore: fastModel,
61
- };
65
+ const modelsByAgent = { ...ensemble.modelsByAgent }
66
+ if (planModel) modelsByAgent.plan = planModel
67
+ else delete modelsByAgent.plan
68
+ if (buildModel) modelsByAgent.build = buildModel
69
+ else delete modelsByAgent.build
70
+ if (fastModel) modelsByAgent.explore = fastModel
71
+ else delete modelsByAgent.explore
72
+
73
+ if (Object.keys(modelsByAgent).length > 0) ensemble.modelsByAgent = modelsByAgent
74
+ else delete ensemble.modelsByAgent
75
+
62
76
  await fse.writeJson(ensembleJsonPath, ensemble, { spaces: 2 });
63
- success(`plan model -> ${planModel} (written to .opencode/ensemble.json)`);
64
- success(`build model -> ${buildModel} (written to .opencode/ensemble.json)`);
65
- success(`fast model -> ${fastModel} (written to .opencode/ensemble.json)`);
77
+ if (planModel) success(`plan model -> ${planModel} (written to .opencode/ensemble.json)`);
78
+ if (buildModel) success(`build model -> ${buildModel} (written to .opencode/ensemble.json)`);
79
+ if (fastModel) success(`fast model -> ${fastModel} (written to .opencode/ensemble.json)`);
66
80
  }
67
81
  }
@@ -38,7 +38,7 @@ description: A test agent
38
38
  expect(updated).toContain('model: anthropic/claude-3-sonnet')
39
39
  })
40
40
 
41
- it('preserves existing frontmatter fields', async () => {
41
+ it('preserves existing frontmatter fields', async () => {
42
42
  const filePath = path.join(tmpDir, 'test-agent.md')
43
43
  const original = `---
44
44
  name: Test Agent
@@ -54,10 +54,28 @@ custom_field: custom_value
54
54
  const updated = fs.readFileSync(filePath, 'utf-8')
55
55
  expect(updated).toContain('name: Test Agent')
56
56
  expect(updated).toContain('description: A test agent')
57
- expect(updated).toContain('custom_field: custom_value')
58
- expect(updated).toContain('model: test/model')
59
- })
60
- })
57
+ expect(updated).toContain('custom_field: custom_value')
58
+ expect(updated).toContain('model: test/model')
59
+ })
60
+
61
+ it('removes model field when no model is selected', async () => {
62
+ const filePath = path.join(tmpDir, 'test-agent.md')
63
+ const original = `---
64
+ name: Test Agent
65
+ model: existing/model
66
+ description: A test agent
67
+ ---
68
+
69
+ # Test Agent`
70
+ fs.writeFileSync(filePath, original, 'utf-8')
71
+
72
+ await writeModelToAgent(filePath, null)
73
+
74
+ const updated = fs.readFileSync(filePath, 'utf-8')
75
+ expect(updated).not.toContain('model:')
76
+ expect(updated).toContain('description: A test agent')
77
+ })
78
+ })
61
79
 
62
80
  describe('writeModelsToConfigs()', () => {
63
81
  let tmpDir, agentsDir, opencodeJsonPath
@@ -115,4 +133,27 @@ describe('writeModelsToConfigs()', () => {
115
133
 
116
134
  expect(success).toHaveBeenCalled()
117
135
  })
136
+
137
+ it('removes model config entries when None is selected', async () => {
138
+ const agentFile = path.join(agentsDir, 'basic-engineer.md')
139
+ const ensembleJsonPath = path.join(tmpDir, '.opencode', 'ensemble.json')
140
+
141
+ fs.writeFileSync(agentFile, '---\nname: Basic\nmodel: existing/model\n---', 'utf-8')
142
+ fs.writeFileSync(opencodeJsonPath, JSON.stringify({ model: 'existing/model', theme: 'dark' }, null, 2), 'utf-8')
143
+ fs.writeFileSync(ensembleJsonPath, JSON.stringify({ modelsByAgent: { plan: 'a', build: 'b', explore: 'c', keep: 'yes' } }, null, 2), 'utf-8')
144
+
145
+ await writeModelsToConfigs({
146
+ planModel: null,
147
+ buildModel: null,
148
+ fastModel: null,
149
+ agentsDir,
150
+ cwd: tmpDir,
151
+ preset: { roles: { build: { agents: ['basic-engineer'] }, fast: { agents: [] } } },
152
+ })
153
+
154
+ expect(fs.readFileSync(agentFile, 'utf-8')).not.toContain('model:')
155
+ expect(JSON.parse(fs.readFileSync(opencodeJsonPath, 'utf-8'))).toEqual({ theme: 'dark' })
156
+ expect(JSON.parse(fs.readFileSync(ensembleJsonPath, 'utf-8'))).toEqual({ modelsByAgent: { keep: 'yes' } })
157
+ expect(success).not.toHaveBeenCalled()
158
+ })
118
159
  })
@@ -58,6 +58,13 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
58
58
  - use \`basic-engineer\` only when no custom engineer is a clear fit or as a recovery fallback
59
59
  - never spawn an engineer name that is not present in \`.opencode/agents/\`
60
60
 
61
+ **Model resolution for spawned agents (do not skip):**
62
+ Before each \`team_spawn\`, resolve the model for that agent using this priority:
63
+ 1. **Agent file frontmatter** — Read \`.opencode/agents/<engineer-file>\` and check for a \`model:\` field in the YAML frontmatter. This is the user's explicit choice for this specific agent.
64
+ 2. **\`ensemble.json\` \`modelsByAgent\`** — Read \`.opencode/ensemble.json\`. If \`modelsByAgent\` contains a key matching this agent's role (build agents → \`build\`, devops-manager → \`explore\`), use that value.
65
+ 3. **Active chat model** — Fall back to the model currently running this conversation (your own model).
66
+ Never hardcode a model ID. Never skip this resolution. Log the result: \`[model: <id> ← <source: agent frontmatter / ensemble.json modelsByAgent / active chat>]\`
67
+
61
68
  Each \`team_spawn\` MUST include the agent field (required, causes NOT NULL error if omitted).
62
69
 
63
70
  The spawn prompt must be short and operational. It must contain:
@@ -91,9 +98,9 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
91
98
 
92
99
  Spawn sequentially, waiting for each result:
93
100
  \`\`\`
94
- team_spawn name:"ui1" agent:"frontend-engineer" prompt:"..."
101
+ team_spawn name:"ui1" agent:"frontend-engineer" model:"<resolved-model>" prompt:"..."
95
102
  (wait for result)
96
- team_spawn name:"api1" agent:"backend-engineer" prompt:"..."
103
+ team_spawn name:"api1" agent:"backend-engineer" model:"<resolved-model>" prompt:"..."
97
104
  (wait for result)
98
105
  \`\`\`
99
106
  Replace example agent names with REAL engineers that exist in this project.
@@ -133,7 +140,7 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
133
140
  6. **If ALL agents are shut down and tasks remain unassigned** (new domain, dependencies unblocked):
134
141
  - Discover the remaining matching engineers from \`.opencode/agents/\` and spawn a new wave (back to step 6d).
135
142
  7. **If ALL tasks are done:** proceed to step 7.
136
- If a teammate reports rate-limit/quota/token exhaustion, immediately shutdown that teammate and respawn with an available model.
143
+ If a teammate reports rate-limit/quota/token exhaustion, immediately shutdown that teammate and respawn with a different model using the model resolution priority above.
137
144
 
138
145
  **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.
139
146
 
@@ -141,7 +148,7 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
141
148
 
142
149
  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):
143
150
  \`\`\`
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>"
151
+ team_spawn name:"verify" agent:"<real-verifier-engineer>" worktree:false model:"<resolved-model>" 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>"
145
152
  \`\`\`
146
153
  Wait for message -> team_results -> fix blockers -> team_shutdown (no team_merge needed, worktree:false)
147
154
 
@@ -165,6 +172,7 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
165
172
  - NEVER implement tasks directly. Always use team_create + team_spawn, no exceptions
166
173
  - NEVER touch source files before team_create is called, not even one edit
167
174
  - NEVER call team_spawn without the agent field, it is required and will fail without it
175
+ - NEVER call team_spawn without a resolved \`model:\` field; always pass the model resolved from the priority chain
168
176
  - 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
169
177
  - NEVER poll team_results or team_status in a loop, wait for teammates to message you
170
178
  - NEVER call team_claim or team_tasks_complete as lead, only agents call these tools