opencode-onboard 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +304 -301
  3. package/content/.agents/agents/basic-engineer.md +4 -2
  4. package/content/.agents/agents/devops-manager.md +123 -123
  5. package/content/.agents/skills/ob-default/SKILL.md +25 -21
  6. package/content/.agents/skills/ob-generic-guardrails/SKILL.md +36 -32
  7. package/content/.agents/skills/ob-global/SKILL.md +92 -49
  8. package/content/.agents/skills/ob-pullrequest-az/SKILL.md +168 -160
  9. package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +140 -136
  10. package/content/.opencode/commands/create-engineer.md +109 -0
  11. package/content/.opencode/commands/init.md +1 -1
  12. package/content/.opencode/commands/main.md +1 -1
  13. package/content/.opencode/commands/opsx-apply.md +131 -70
  14. package/content/.opencode/commands/plan.md +1 -1
  15. package/content/.opencode/plugins/session-log.js +523 -519
  16. package/content/.opencode/skills/openspec-apply-change/SKILL.md +86 -64
  17. package/content/AGENTS.md +67 -39
  18. package/package.json +1 -1
  19. package/src/commands/join.js +3 -3
  20. package/src/commands/single.js +2 -0
  21. package/src/commands/wizard.js +124 -99
  22. package/src/presets/browser.json +22 -18
  23. package/src/presets/optimization.json +27 -22
  24. package/src/presets/source.json +7 -1
  25. package/src/steps/browser/browser.test.js +115 -81
  26. package/src/steps/browser/index.js +62 -54
  27. package/src/steps/clean/index.js +108 -107
  28. package/src/steps/copy/agents.js +28 -0
  29. package/src/steps/copy/copy.test.js +1 -0
  30. package/src/steps/copy/index.js +2 -1
  31. package/src/steps/metadata/index.js +63 -61
  32. package/src/steps/models/format.js +61 -60
  33. package/src/steps/models/write.test.js +117 -117
  34. package/src/steps/openspec/ensemble.js +30 -7
  35. package/src/steps/openspec/ensemble.test.js +79 -79
  36. package/src/steps/openspec/index.js +121 -32
  37. package/src/steps/openspec/index.test.js +63 -0
  38. package/src/steps/optimization/caveman.js +34 -29
  39. package/src/steps/optimization/codegraph.js +52 -0
  40. package/src/steps/optimization/global.js +88 -64
  41. package/src/steps/optimization/global.test.js +99 -0
  42. package/src/steps/optimization/index.js +109 -101
  43. package/src/steps/optimization/optimization.test.js +101 -93
  44. package/src/steps/optimization/quota.js +84 -84
  45. package/src/steps/source/index.js +48 -0
  46. package/src/steps/source/source.test.js +124 -91
  47. package/src/utils/__tests__/copy.test.js +117 -117
  48. package/src/utils/exec-spinner.js +47 -47
  49. package/src/utils/exec.js +134 -131
  50. package/src/utils/terminal.js +6 -0
@@ -61,6 +61,34 @@ function replaceBetween(content, start, end, replacement) {
61
61
  return content.replace(pattern, `${start}\n${replacement.trim()}\n${end}`)
62
62
  }
63
63
 
64
+ const CONCURRENCY_PLACEHOLDER = '{{MAX_CONCURRENT_AGENTS}}'
65
+ const DEFAULT_MAX_CONCURRENT_AGENTS = 4
66
+
67
+ export async function patchConcurrency(ctx) {
68
+ const maxAgents = String(ctx.maxConcurrentAgents ?? DEFAULT_MAX_CONCURRENT_AGENTS)
69
+ const cwd = process.cwd()
70
+
71
+ const filesToPatch = [
72
+ 'AGENTS.md',
73
+ path.join('.opencode', 'commands', 'opsx-apply.md'),
74
+ path.join('.opencode', 'skills', 'openspec-apply-change', 'SKILL.md'),
75
+ ]
76
+
77
+ let patched = 0
78
+ for (const rel of filesToPatch) {
79
+ const abs = path.join(cwd, rel)
80
+ if (!await fse.pathExists(abs)) continue
81
+ const content = await fse.readFile(abs, 'utf-8')
82
+ if (!content.includes(CONCURRENCY_PLACEHOLDER)) continue
83
+ await fse.writeFile(abs, content.replaceAll(CONCURRENCY_PLACEHOLDER, maxAgents), 'utf-8')
84
+ patched++
85
+ }
86
+
87
+ if (patched > 0) {
88
+ success(`Concurrency limit set to ${maxAgents} agents in ${patched} file(s)`)
89
+ }
90
+ }
91
+
64
92
  export async function patchAgentsMd(ctx) {
65
93
  const agentsMdPath = path.join(process.cwd(), 'AGENTS.md')
66
94
  if (!await fse.pathExists(agentsMdPath)) return
@@ -12,6 +12,7 @@ vi.mock('../../utils/copy.js', () => ({
12
12
 
13
13
  vi.mock('./agents.js', () => ({
14
14
  patchAgentsMd: vi.fn(),
15
+ patchConcurrency: vi.fn(),
15
16
  patchDevopsManagerMd: vi.fn(),
16
17
  }))
17
18
 
@@ -3,7 +3,7 @@ import path from 'path'
3
3
  import { fileURLToPath } from 'url'
4
4
  import { copyContent } from '../../utils/copy.js'
5
5
  import { error, header, success } from '../../utils/exec.js'
6
- import { patchAgentsMd, patchDevopsManagerMd } from './agents.js'
6
+ import { patchAgentsMd, patchConcurrency, patchDevopsManagerMd } from './agents.js'
7
7
  import { installSkills } from './skills.js'
8
8
 
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -24,6 +24,7 @@ export async function copyContentStep(platform, ctx = {}) {
24
24
  }, { spaces: 2 })
25
25
  await patchDevopsManagerMd(platform)
26
26
  await patchAgentsMd(ctx)
27
+ await patchConcurrency(ctx)
27
28
  await installSkills()
28
29
  success('Files copied to project root')
29
30
  } catch (err) {
@@ -1,61 +1,63 @@
1
- import { execa } from 'execa'
2
- import fse from 'fs-extra'
3
- import path from 'path'
4
- import { createRequire } from 'node:module'
5
- import { header, success, warn } from '../../utils/exec.js'
6
-
7
- const require = createRequire(import.meta.url)
8
- const { version: onboardVersion } = require('../../../package.json')
9
-
10
- async function detectOpencodeVersion() {
11
- try {
12
- const result = await execa('opencode', ['--version'], { reject: false })
13
- if (result.exitCode !== 0) return null
14
- const output = (result.stdout || result.stderr || '').trim()
15
- return output || null
16
- } catch {
17
- return null
18
- }
19
- }
20
-
21
- export async function writeOnboardConfig(data) {
22
- header('Step 10, Writing onboarding metadata')
23
-
24
- const opencodeVersion = await detectOpencodeVersion()
25
- const target = path.join(process.cwd(), '.opencode', 'opencode-onboard.json')
26
-
27
- const payload = {
28
- schema: 1,
29
- generatedAt: new Date().toISOString(),
30
- onboardVersion,
31
- opencodeVersion,
32
- wizard: {
33
- platform: data.platform,
34
- sourceMode: data.sourceMode,
35
- sourceRoots: data.sourceRoots,
36
- preserved: {
37
- design: !!data.hasDesign,
38
- architecture: !!data.hasArchitecture,
39
- openspec: !!data.hasOpenspec,
40
- },
41
- additionalSkillsProvider: data.additionalSkillsProvider,
42
- models: {
43
- plan: data.planModel,
44
- build: data.buildModel,
45
- fast: data.fastModel,
46
- },
47
- optionalTools: data.optionalTools ?? null,
48
- cavemanGuidance: data.cavemanGuidance ?? null,
49
- },
50
- note: 'Informational file only. Editing this file does not change runtime behavior.',
51
- }
52
-
53
- try {
54
- await fse.ensureDir(path.dirname(target))
55
- await fse.writeJson(target, payload, { spaces: 2 })
56
- success('Wrote .opencode/opencode-onboard.json')
57
- if (!opencodeVersion) warn('Could not detect opencode version, saved as null')
58
- } catch (err) {
59
- warn(`Could not write onboarding metadata: ${err.message}`)
60
- }
61
- }
1
+ import { execa } from 'execa'
2
+ import fse from 'fs-extra'
3
+ import path from 'path'
4
+ import { createRequire } from 'node:module'
5
+ import { header, success, warn } from '../../utils/exec.js'
6
+
7
+ const require = createRequire(import.meta.url)
8
+ const { version: onboardVersion } = require('../../../package.json')
9
+
10
+ async function detectOpencodeVersion() {
11
+ try {
12
+ const result = await execa('opencode', ['--version'], { reject: false })
13
+ if (result.exitCode !== 0) return null
14
+ const output = (result.stdout || result.stderr || '').trim()
15
+ return output || null
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ export async function writeOnboardConfig(data) {
22
+ header('Step 10, Writing onboarding metadata')
23
+
24
+ const opencodeVersion = await detectOpencodeVersion()
25
+ const target = path.join(process.cwd(), '.opencode', 'opencode-onboard.json')
26
+
27
+ const payload = {
28
+ schema: 1,
29
+ generatedAt: new Date().toISOString(),
30
+ onboardVersion,
31
+ opencodeVersion,
32
+ wizard: {
33
+ platform: data.platform,
34
+ sourceMode: data.sourceMode,
35
+ sourceRoots: data.sourceRoots,
36
+ maxConcurrentAgents: data.maxConcurrentAgents ?? 4,
37
+ preserved: {
38
+ design: !!data.hasDesign,
39
+ architecture: !!data.hasArchitecture,
40
+ openspec: !!data.hasOpenspec,
41
+ },
42
+ openspec: data.openspec,
43
+ additionalSkillsProvider: data.additionalSkillsProvider,
44
+ models: {
45
+ plan: data.planModel,
46
+ build: data.buildModel,
47
+ fast: data.fastModel,
48
+ },
49
+ optionalTools: data.optionalTools ?? null,
50
+ cavemanGuidance: data.cavemanGuidance ?? null,
51
+ },
52
+ note: 'Informational file only. Editing this file does not change runtime behavior.',
53
+ }
54
+
55
+ try {
56
+ await fse.ensureDir(path.dirname(target))
57
+ await fse.writeJson(target, payload, { spaces: 2 })
58
+ success('Wrote .opencode/opencode-onboard.json')
59
+ if (!opencodeVersion) warn('Could not detect opencode version, saved as null')
60
+ } catch (err) {
61
+ warn(`Could not write onboarding metadata: ${err.message}`)
62
+ }
63
+ }
@@ -1,60 +1,61 @@
1
- import { search } from '@inquirer/prompts'
2
- import fse from 'fs-extra'
3
- import path from 'path'
4
- import { fileURLToPath } from 'url'
5
-
6
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- const MODELS_PRESET_PATH = path.resolve(__dirname, '../../presets/models.json');
8
- const modelsPreset = await fse.readJson(MODELS_PRESET_PATH);
9
-
10
- function costTier(input) {
11
- if (input === undefined || input === null) return '';
12
- const tier = modelsPreset.costTiers.find(t => t.max === undefined || input <= t.max);
13
- return tier ? ` ${tier.label}` : '';
14
- }
15
-
16
- function costTierDisplay(cost, canonicalCost) {
17
- return costTier(canonicalCost !== undefined ? canonicalCost : cost);
18
- }
19
-
20
- function formatPrice(price) {
21
- if (price === undefined || price === null) return '?';
22
- if (price === 0) return '$0 (subscription)';
23
- return `$${price}/M`;
24
- }
25
-
26
- export function buildDisplayModels(rawModels) {
27
- return rawModels.map(m => {
28
- const priceStr = formatPrice(m.cost);
29
- const canonicalNote = m.canonicalCost !== undefined
30
- ? ` · official price: ${formatPrice(m.canonicalCost)}/M`
31
- : '';
32
- return {
33
- ...m,
34
- label: `${m.name}${costTierDisplay(m.cost, m.canonicalCost)}, ${m.id}`,
35
- description: `${priceStr}${canonicalNote} · context: ${m.context ? (m.context / 1000) + 'k' : '?'}`,
36
- };
37
- });
38
- }
39
-
40
- export async function pickModel(message, models) {
41
- return await search({
42
- message,
43
- source: (input) => {
44
- const q = (input || '').toLowerCase();
45
- const filtered = q
46
- ? models.filter(m =>
47
- m.label.toLowerCase().includes(q) ||
48
- m.id.toLowerCase().includes(q)
49
- )
50
- : models;
51
- return filtered.slice(0, 50).map(m => ({
52
- name: m.label,
53
- value: m.id,
54
- description: m.description,
55
- }));
56
- },
57
- });
58
- }
59
-
60
- export { modelsPreset };
1
+ import { search } from '@inquirer/prompts'
2
+ import fse from 'fs-extra'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const MODELS_PRESET_PATH = path.resolve(__dirname, '../../presets/models.json');
8
+ const modelsPreset = await fse.readJson(MODELS_PRESET_PATH);
9
+
10
+ function costTier(input) {
11
+ if (input === undefined || input === null) return '';
12
+ const tier = modelsPreset.costTiers.find(t => t.max === undefined || input <= t.max);
13
+ return tier ? ` ${tier.label}` : '';
14
+ }
15
+
16
+ function costTierDisplay(cost, canonicalCost) {
17
+ return costTier(canonicalCost !== undefined ? canonicalCost : cost);
18
+ }
19
+
20
+ function formatPrice(price) {
21
+ if (price === undefined || price === null) return '?';
22
+ if (price === 0) return '$0 (subscription)';
23
+ return `$${price}/M`;
24
+ }
25
+
26
+ export function buildDisplayModels(rawModels) {
27
+ return rawModels.map(m => {
28
+ const priceStr = formatPrice(m.cost);
29
+ const canonicalNote = m.canonicalCost !== undefined
30
+ ? ` · official price: ${formatPrice(m.canonicalCost)}/M`
31
+ : '';
32
+ const context = m.context ? `${m.context / 1000}k` : '?';
33
+ return {
34
+ ...m,
35
+ label: `${m.name}${costTierDisplay(m.cost, m.canonicalCost)}, ${m.id}`,
36
+ description: `${priceStr}${canonicalNote} · context: ${context}`,
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
+ }
60
+
61
+ export { modelsPreset };
@@ -1,117 +1,117 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import fs from 'node:fs'
3
- import path from 'node:path'
4
- import os from 'node:os'
5
-
6
- vi.mock('../../utils/exec.js', () => ({
7
- success: vi.fn(),
8
- }))
9
-
10
- import { success } from '../../utils/exec.js'
11
- import { writeModelToAgent, writeModelsToConfigs } from './write.js'
12
-
13
- describe('writeModelToAgent()', () => {
14
- let tmpDir
15
-
16
- beforeEach(() => {
17
- vi.clearAllMocks()
18
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-write-test-'))
19
- })
20
-
21
- afterEach(() => {
22
- fs.rmSync(tmpDir, { recursive: true, force: true })
23
- })
24
-
25
- it('adds model field to agent file frontmatter', async () => {
26
- const filePath = path.join(tmpDir, 'test-agent.md')
27
- const original = `---
28
- name: Test Agent
29
- description: A test agent
30
- ---
31
-
32
- # Test Agent`
33
- fs.writeFileSync(filePath, original, 'utf-8')
34
-
35
- await writeModelToAgent(filePath, 'anthropic/claude-3-sonnet')
36
-
37
- const updated = fs.readFileSync(filePath, 'utf-8')
38
- expect(updated).toContain('model: anthropic/claude-3-sonnet')
39
- })
40
-
41
- it('preserves existing frontmatter fields', async () => {
42
- const filePath = path.join(tmpDir, 'test-agent.md')
43
- const original = `---
44
- name: Test Agent
45
- description: A test agent
46
- custom_field: custom_value
47
- ---
48
-
49
- # Test Agent`
50
- fs.writeFileSync(filePath, original, 'utf-8')
51
-
52
- await writeModelToAgent(filePath, 'test/model')
53
-
54
- const updated = fs.readFileSync(filePath, 'utf-8')
55
- expect(updated).toContain('name: Test Agent')
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
- })
61
-
62
- describe('writeModelsToConfigs()', () => {
63
- let tmpDir, agentsDir, opencodeJsonPath, ensembleJsonPath, originalCwd
64
-
65
- beforeEach(() => {
66
- vi.clearAllMocks()
67
- originalCwd = process.cwd()
68
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-config-test-'))
69
- agentsDir = path.join(tmpDir, '.agents', 'agents')
70
- fs.mkdirSync(agentsDir, { recursive: true })
71
- opencodeJsonPath = path.join(tmpDir, '.opencode', 'opencode.json')
72
- ensembleJsonPath = path.join(tmpDir, '.opencode', 'ensemble.json')
73
- fs.mkdirSync(path.dirname(opencodeJsonPath), { recursive: true })
74
- process.chdir(tmpDir)
75
- })
76
-
77
- afterEach(() => {
78
- process.chdir(originalCwd)
79
- fs.rmSync(tmpDir, { recursive: true, force: true })
80
- })
81
-
82
- it('writes build model to agent files', async () => {
83
- fs.writeFileSync(path.join(agentsDir, 'back-engineer.md'), '---\nname: Back\n---', 'utf-8')
84
- fs.writeFileSync(path.join(agentsDir, 'front-engineer.md'), '---\nname: Front\n---', 'utf-8')
85
-
86
- await writeModelsToConfigs({
87
- planModel: 'plan-model',
88
- buildModel: 'build-model',
89
- fastModel: 'fast-model',
90
- agentsDir,
91
- preset: {
92
- roles: {
93
- build: { agents: ['back-engineer'] },
94
- fast: { agents: ['front-engineer'] },
95
- },
96
- },
97
- })
98
-
99
- expect(success).toHaveBeenCalledWith('back-engineer → build-model')
100
- expect(success).toHaveBeenCalledWith('front-engineer → fast-model')
101
- })
102
-
103
- it('reports success when writing configs', async () => {
104
- const agentFile = path.join(agentsDir, 'back-engineer.md')
105
- fs.writeFileSync(agentFile, '---\nname: Back\n---', 'utf-8')
106
-
107
- await writeModelsToConfigs({
108
- planModel: 'plan-model',
109
- buildModel: 'build-model',
110
- fastModel: 'fast-model',
111
- agentsDir,
112
- preset: { roles: { build: { agents: ['back-engineer'] }, fast: { agents: [] } } },
113
- })
114
-
115
- expect(success).toHaveBeenCalled()
116
- })
117
- })
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ vi.mock('../../utils/exec.js', () => ({
7
+ success: vi.fn(),
8
+ }))
9
+
10
+ import { success } from '../../utils/exec.js'
11
+ import { writeModelToAgent, writeModelsToConfigs } from './write.js'
12
+
13
+ describe('writeModelToAgent()', () => {
14
+ let tmpDir
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks()
18
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-write-test-'))
19
+ })
20
+
21
+ afterEach(() => {
22
+ fs.rmSync(tmpDir, { recursive: true, force: true })
23
+ })
24
+
25
+ it('adds model field to agent file frontmatter', async () => {
26
+ const filePath = path.join(tmpDir, 'test-agent.md')
27
+ const original = `---
28
+ name: Test Agent
29
+ description: A test agent
30
+ ---
31
+
32
+ # Test Agent`
33
+ fs.writeFileSync(filePath, original, 'utf-8')
34
+
35
+ await writeModelToAgent(filePath, 'anthropic/claude-3-sonnet')
36
+
37
+ const updated = fs.readFileSync(filePath, 'utf-8')
38
+ expect(updated).toContain('model: anthropic/claude-3-sonnet')
39
+ })
40
+
41
+ it('preserves existing frontmatter fields', async () => {
42
+ const filePath = path.join(tmpDir, 'test-agent.md')
43
+ const original = `---
44
+ name: Test Agent
45
+ description: A test agent
46
+ custom_field: custom_value
47
+ ---
48
+
49
+ # Test Agent`
50
+ fs.writeFileSync(filePath, original, 'utf-8')
51
+
52
+ await writeModelToAgent(filePath, 'test/model')
53
+
54
+ const updated = fs.readFileSync(filePath, 'utf-8')
55
+ expect(updated).toContain('name: Test Agent')
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
+ })
61
+
62
+ describe('writeModelsToConfigs()', () => {
63
+ let tmpDir, agentsDir, opencodeJsonPath, originalCwd
64
+
65
+ beforeEach(() => {
66
+ vi.clearAllMocks()
67
+ originalCwd = process.cwd()
68
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-config-test-'))
69
+ agentsDir = path.join(tmpDir, '.agents', 'agents')
70
+ fs.mkdirSync(agentsDir, { recursive: true })
71
+ opencodeJsonPath = path.join(tmpDir, '.opencode', 'opencode.json')
72
+ path.join(tmpDir, '.opencode', 'ensemble.json')
73
+ fs.mkdirSync(path.dirname(opencodeJsonPath), { recursive: true })
74
+ process.chdir(tmpDir)
75
+ })
76
+
77
+ afterEach(() => {
78
+ process.chdir(originalCwd)
79
+ fs.rmSync(tmpDir, { recursive: true, force: true })
80
+ })
81
+
82
+ it('writes build model to agent files', async () => {
83
+ fs.writeFileSync(path.join(agentsDir, 'back-engineer.md'), '---\nname: Back\n---', 'utf-8')
84
+ fs.writeFileSync(path.join(agentsDir, 'front-engineer.md'), '---\nname: Front\n---', 'utf-8')
85
+
86
+ await writeModelsToConfigs({
87
+ planModel: 'plan-model',
88
+ buildModel: 'build-model',
89
+ fastModel: 'fast-model',
90
+ agentsDir,
91
+ preset: {
92
+ roles: {
93
+ build: { agents: ['back-engineer'] },
94
+ fast: { agents: ['front-engineer'] },
95
+ },
96
+ },
97
+ })
98
+
99
+ expect(success).toHaveBeenCalledWith('back-engineer → build-model')
100
+ expect(success).toHaveBeenCalledWith('front-engineer → fast-model')
101
+ })
102
+
103
+ it('reports success when writing configs', async () => {
104
+ const agentFile = path.join(agentsDir, 'back-engineer.md')
105
+ fs.writeFileSync(agentFile, '---\nname: Back\n---', 'utf-8')
106
+
107
+ await writeModelsToConfigs({
108
+ planModel: 'plan-model',
109
+ buildModel: 'build-model',
110
+ fastModel: 'fast-model',
111
+ agentsDir,
112
+ preset: { roles: { build: { agents: ['back-engineer'] }, fast: { agents: [] } } },
113
+ })
114
+
115
+ expect(success).toHaveBeenCalled()
116
+ })
117
+ })
@@ -36,7 +36,14 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
36
36
  DO NOT call team_claim yourself, only agents claim tasks.
37
37
  DO NOT proceed to 6d until team_tasks_add succeeds.
38
38
 
39
- **Step 6d.** Discover relevant skills, then spawn specialists.
39
+ **Step 6d.** Discover relevant skills, then spawn specialists with an INITIAL BATCH of tasks.
40
+
41
+ **ROLLING BATCH MODEL:**
42
+ Agents do NOT receive all their tasks upfront. Instead:
43
+ - Assign each agent an initial batch of up to 3 unblocked tasks.
44
+ - When an agent completes its batch and messages back, the lead assigns the next batch of up to 3 unassigned tasks from the board that match the agent's domain.
45
+ - Repeat until no pending tasks remain on the board.
46
+ - Only shut down an agent when the board has no more tasks for its domain.
40
47
 
41
48
  Before spawning, scan \`.agents/skills/\` and read each \`SKILL.md\` description line.
42
49
  Match skills to agents by domain:
@@ -49,11 +56,11 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
49
56
 
50
57
  The spawn prompt must contain exactly:
51
58
  1. Their name and role on this team
52
- 2. Which tasks are theirs, include the LITERAL task IDs (e.g. "task-abc123") AND the task content for each. Copy them verbatim from the IDs returned by team_tasks_add. Do NOT paraphrase or omit IDs.
59
+ 2. Their initial batch of tasks (up to 3): include the LITERAL task IDs (e.g. "task-abc123") AND the task content. Copy them verbatim from the IDs returned by team_tasks_add. Do NOT paraphrase or omit IDs.
53
60
  3. Key context they need (summarized from context files, do NOT tell them to read files themselves)
54
61
  4. The 6 OpenCode tools they have available (these are OpenCode tools, NOT shell commands, call them directly as tools, never via bash):
55
62
  team_claim, team_tasks_complete, team_tasks_list, team_tasks_add, team_message, team_broadcast
56
- 5. How to proceed: for EACH task ID listed above, call team_claim tool with that exact task_id before starting it, call team_tasks_complete tool with that task_id after finishing it, then move to the next task. When all tasks are done or blocked, call team_message to notify lead with results or blockers.
63
+ 5. How to proceed: for EACH task ID listed, call team_claim tool with that exact task_id before starting it, call team_tasks_complete tool with that task_id after finishing it, then move to the next task. When all listed tasks are done, message lead with results. Lead may assign more tasks, do NOT shut down until lead confirms no more tasks.
57
64
  6. Which skills to load: list the skill names and paths they MUST read before implementing. Example: "Before starting, read \`.agents/skills/next-best-practices/SKILL.md\` and follow its rules for all Next.js code."
58
65
 
59
66
  Keep spawn prompts under 600 tokens. Do not describe team internals or how ensemble works.
@@ -86,12 +93,24 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
86
93
  - \`team_view member:"<name>"\` for a teammate live session
87
94
  - \`team_results from:"<name>"\` for full teammate report text
88
95
 
89
- **Step 6f.** When a teammate messages back, you receive a ping only, the full content is NOT in the notification.
90
- Call team_results to read the full message and mark it read. Then for each teammate: team_shutdown -> team_merge.
91
- If team_merge blocks ("overlapping local changes"), commit or stash your local changes first, then retry.
92
- Fix any other blockers reported.
96
+ **Step 6f.** When a teammate messages back (rolling re-assignment loop):
97
+ 1. Call \`team_results from:"<name>"\` to read full message.
98
+ 2. Call \`team_tasks_list\` to check remaining pending/unassigned tasks on the board.
99
+ 3. **If there are more unassigned tasks matching this agent's domain:**
100
+ - Pick up to 3 unassigned, unblocked tasks for this agent's domain.
101
+ - Send them via \`team_message to:"<name>" text:"Next tasks: [task-<id1>] <desc>, [task-<id2>] <desc>. Claim each with team_claim before starting."\`
102
+ - Do NOT shut down the agent. Go back to waiting (step 6e).
103
+ 4. **If no more tasks for this agent:**
104
+ - \`team_shutdown member:"<name>"\`
105
+ - \`team_merge member:"<name>"\`
106
+ - If team_merge blocks on local changes: \`git stash\`, retry merge, \`git stash pop\`.
107
+ 5. **If ALL agents are shut down and tasks remain unassigned** (new domain, dependencies unblocked):
108
+ - Spawn new agents for the remaining tasks (back to step 6d).
109
+ 6. **If ALL tasks are done:** proceed to step 7.
93
110
  If a teammate reports rate-limit/quota/token exhaustion, immediately shutdown that teammate and respawn with an available model.
94
111
 
112
+ **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
+
95
114
  7. **Quality check**
96
115
 
97
116
  Spawn quality engineer with worktree:false (read-only, no file edits):
@@ -123,13 +142,17 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
123
142
  - NEVER call team_spawn before team_tasks_add, tasks must exist before agents are spawned
124
143
  - NEVER poll team_results or team_status in a loop, wait for teammates to message you
125
144
  - NEVER call team_claim or team_tasks_complete as lead, only agents call these tools
145
+ - NEVER leave pending tasks orphaned, always verify board is empty before proceeding to step 7
126
146
  - ALWAYS pass the LITERAL task IDs returned by team_tasks_add into each agent's spawn prompt, copy the exact IDs, never paraphrase
147
+ - 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
+ - ALWAYS call team_tasks_list after each agent reports done to check for remaining unassigned tasks
127
149
  - ALWAYS repeat the same literal task IDs in the team_message start trigger, never send a generic "claim your first task" without the actual IDs
128
150
  - NEVER send a start message that omits task IDs; if a task ID is missing from the start message, the agent cannot claim
129
151
  - NEVER edit files between team_spawn and team_merge, team_merge blocks on overlapping local changes
130
152
  - ALWAYS add every task to the board with team_tasks_add before spawning
131
153
  - ALWAYS spawn agents sequentially (wait for each team_spawn result before the next), then send start messages to all of them together
132
154
  - ALWAYS instruct agents to call team_claim before each task and team_tasks_complete after
155
+ - ALWAYS shut down + merge agents only when no more tasks remain for their domain
133
156
  - If teammates are stuck, use team_message to resend tasks, then wait, never implement directly
134
157
  - Mark tasks complete in openspec AFTER specialists finish, not before
135
158
  - Pause on errors, blockers, or unclear requirements. Do not guess