opencode-onboard 0.4.2 → 0.4.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.
package/content/AGENTS.md CHANGED
@@ -63,13 +63,30 @@ The output must be a real, populated `ARCHITECTURE.md` based on what you found i
63
63
 
64
64
  ---
65
65
 
66
- ### Step 4, Rewrite this file
66
+ ### Step 4, Populate OpenSpec config
67
+
68
+ Read `openspec/config.yaml`. It contains a template with commented-out examples. Fill in the `context:` field with real project information discovered during steps 1-3:
69
+
70
+ ```yaml
71
+ context: |
72
+ Tech stack: <languages, frameworks, libraries found in the codebase>
73
+ Build system: <build tools, package managers>
74
+ Architecture: <monolith, microservices, monorepo, etc.>
75
+ Conventions: <coding style, commit conventions, branching strategy if found>
76
+ Domain: <what this project does, in one line>
77
+ ```
78
+
79
+ Keep the `schema: spec-driven` line. Add `rules:` only if the codebase has clear conventions worth enforcing (e.g., max task size, proposal format). Do not invent rules that aren't evidenced by the codebase.
80
+
81
+ ---
82
+
83
+ ### Step 5, Rewrite this file
67
84
 
68
85
  Replace the entire contents of this file (`AGENTS.md`) with everything below the line `<!-- AGENTS-TEMPLATE-START -->` in this same file. Delete the bootstrap section and the template marker, the file should contain only the template content when done.
69
86
 
70
87
  ---
71
88
 
72
- ### Step 5, Confirm
89
+ ### Step 6, Confirm
73
90
 
74
91
  Tell the user:
75
92
 
@@ -80,6 +97,7 @@ Tell the user:
80
97
 
81
98
  - ARCHITECTURE.md generated
82
99
  - DESIGN.md generated
100
+ - openspec/config.yaml populated
83
101
  - Project history archived in openspec
84
102
  - AGENTS.md updated with real guidance
85
103
 
@@ -98,7 +116,7 @@ After restarting you are ready to work.
98
116
  - Do NOT create branches or PRs
99
117
  - Do NOT modify any project source files
100
118
  - Do NOT create CLI wrapper files or scripts
101
- - Only read source files for analysis, write only to ARCHITECTURE.md, DESIGN.md, AGENTS.md, and openspec/
119
+ - Only read source files for analysis, write only to ARCHITECTURE.md, DESIGN.md, AGENTS.md, openspec/config.yaml, and openspec/
102
120
 
103
121
  <!-- AGENTS-TEMPLATE-START -->
104
122
  # AGENTS.md
@@ -138,18 +156,25 @@ Trigger patterns, I recognize ALL of these, exact wording does not matter:
138
156
 
139
157
  **Never delegate without a plan. Never write implementation code directly, always spawn specialists, no exceptions. "Small feature", "faster to do it directly", or "environment issues" are not valid reasons to skip ensemble.**
140
158
 
141
- ## Multi-Agent Execution, opencode-ensemble
142
-
143
- Parallel execution uses the `opencode-ensemble` plugin (`team_create`, `team_spawn`, etc.).
144
- Works on **all platforms** (Windows, macOS, Linux) via OpenCode's built-in worktree support.
145
-
146
- Core tools used in this workflow:
147
- - `team_create`, `team_spawn`, `team_shutdown`, `team_merge`, `team_cleanup`
148
- - `team_tasks_add`, `team_tasks_list`, `team_claim`, `team_tasks_complete`
149
- - `team_message`, `team_results`, `team_status`
159
+ ## Multi-Agent Execution, opencode-ensemble
160
+
161
+ Parallel execution uses the `opencode-ensemble` plugin (`team_create`, `team_spawn`, etc.).
162
+ Works on **all platforms** (Windows, macOS, Linux) via OpenCode's built-in worktree support.
163
+
164
+ Core tools used in this workflow:
165
+ - `team_create`, `team_spawn`, `team_shutdown`, `team_merge`, `team_cleanup`
166
+ - `team_tasks_add`, `team_tasks_list`, `team_claim`, `team_tasks_complete`
167
+ - `team_message`, `team_results`, `team_status`
150
168
 
151
169
  **Dashboard**: Monitor running agents at **http://localhost:4747/**
152
170
 
171
+ **Hard limits:**
172
+ - **Max {{MAX_CONCURRENT_AGENTS}} truly concurrent agents.** All {{MAX_CONCURRENT_AGENTS}} must be spawned and running simultaneously, not sequentially. Spawn in waves if more than {{MAX_CONCURRENT_AGENTS}} are needed. Wait for wave N to finish before spawning wave N+1.
173
+ - **Non-overlapping file domains.** Each agent owns exclusive directories. Two agents must NEVER touch the same file.
174
+ - **Immediate shutdown on completion.** The moment an agent's domain has no more pending tasks → `team_shutdown` → `team_merge`. Keep agents alive if more tasks in their domain are pending (rolling batch).
175
+ - **Rolling batch assignment.** Agents receive up to 3 tasks initially. When they complete a batch, lead assigns the next batch of up to 3 from the board. Never leave pending tasks orphaned.
176
+ - **Stall detection at 5 minutes.** No commits after 5 min → nudge message → 2 min grace → force shutdown + respawn.
177
+
153
178
  **Progress inspection commands (tell user explicitly after spawning):**
154
179
  - `team_status` for live team snapshot
155
180
  - `team_tasks_list` for task board state
@@ -159,7 +184,7 @@ Core tools used in this workflow:
159
184
  If a teammate stalls due to model quota/rate-limit exhaustion:
160
185
  1. `team_shutdown name:"<stuck-member>" force:true`
161
186
  2. `team_spawn` same member/task with an available model
162
- 3. `team_message` start instruction with the exact next task ID
187
+ 3. `team_message` start instruction with the exact next task ID
163
188
 
164
189
  ---
165
190
 
@@ -191,15 +216,16 @@ devops-manager (ship mode)
191
216
  5. STOP. Ask user: "Ready to implement? (yes/no)", DO NOT proceed until confirmed.
192
217
  ```
193
218
 
194
- ### Phase 2, Implement
195
-
196
- ```
197
- 1. Run /opsx-apply.
198
- - Lead adds all tasks to board.
199
- - Lead spawns one or more engineers (`basic-engineer` and/or custom engineers) in parallel where safe.
200
- - Each engineer must claim task IDs, load relevant abilities, implement, and complete tasks.
201
- - Lead merges each engineer branch after shutdown, then marks tasks done in tasks.md.
202
- 2. Verify with tests/build/lint according to task scope.
219
+ ### Phase 2, Implement
220
+
221
+ ```
222
+ 1. Run /opsx-apply.
223
+ - Lead adds all tasks to board.
224
+ - Lead spawns engineers with initial batch of up to 3 tasks each (rolling batch model).
225
+ - Each engineer claims tasks, implements, completes, messages lead.
226
+ - Lead assigns next batch (up to 3) to agents that report done. Repeat until board empty.
227
+ - Lead merges each engineer branch after shutdown, then marks tasks done in tasks.md.
228
+ 2. Verify with tests/build/lint according to task scope.
203
229
  ```
204
230
 
205
231
  ### Phase 3, Ship
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-onboard",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Prepare any brownfield codebase for AI agent workflows using OpenCode, OpenSpec, and ensemble orchestration.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -1,15 +1,15 @@
1
1
  import chalk from 'chalk'
2
- import { header, info } from '../utils/exec.js'
3
2
  import { installBrowser } from '../steps/browser/index.js'
4
3
  import { checkRtk } from '../steps/optimization/index.js'
5
- import { choosePlatform, checkPlatform } from '../steps/platform/index.js'
4
+ import { checkPlatform, choosePlatform } from '../steps/platform/index.js'
5
+ import { header, info } from '../utils/exec.js'
6
6
  import { readOnboardConfig } from './shared.js'
7
7
 
8
8
  export async function runJoin() {
9
9
  const logo = chalk.hex('#fe3d57')
10
10
  console.log()
11
11
  console.log(logo(' 🤝 opencode-onboard join'))
12
- console.log(chalk.dim(' New team member setup checks & local installs only'))
12
+ console.log(chalk.dim(' New team member setup, checks & local installs only'))
13
13
  console.log(chalk.dim(' This will NOT modify any project files.'))
14
14
  console.log()
15
15
 
@@ -17,6 +17,7 @@ export async function runSingleCommand(command) {
17
17
  hasOpenspec: !!savedWizard?.preserved?.openspec,
18
18
  sourceMode: savedWizard?.sourceMode ?? 'current',
19
19
  sourceRoots: Array.isArray(savedWizard?.sourceRoots) ? savedWizard.sourceRoots : [],
20
+ maxConcurrentAgents: savedWizard?.maxConcurrentAgents ?? 4,
20
21
  }
21
22
  const platform = savedWizard?.platform
22
23
  const resolvedPlatform = platform === 'azure' || platform === 'github' ? platform : 'github'
@@ -47,6 +48,7 @@ export async function runSingleCommand(command) {
47
48
  await writeOnboardConfig({
48
49
  ...ctx,
49
50
  platform: resolvedPlatform,
51
+ maxConcurrentAgents: savedWizard?.maxConcurrentAgents ?? 4,
50
52
  additionalSkillsProvider: 'npx-skills',
51
53
  planModel: savedWizard?.models?.plan ?? null,
52
54
  buildModel: savedWizard?.models?.build ?? null,
@@ -1,3 +1,4 @@
1
+ import { select as wizardSelect } from '@inquirer/prompts'
1
2
  import chalk from 'chalk'
2
3
  import { chooseSourceScope } from '../steps/source/index.js'
3
4
  import { cleanAiFiles } from '../steps/clean/index.js'
@@ -50,8 +51,20 @@ export async function runWizard(version) {
50
51
 
51
52
  const scope = await chooseSourceScope()
52
53
 
54
+ const maxConcurrentAgents = await wizardSelect({
55
+ message: 'Max concurrent agents:',
56
+ default: 4,
57
+ choices: [
58
+ { name: '2', value: 2, description: 'Conservative — lower resource usage' },
59
+ { name: '3', value: 3, description: 'Moderate parallelism' },
60
+ { name: '4 (default)', value: 4, description: 'Recommended for most projects' },
61
+ { name: '5', value: 5, description: 'High parallelism — requires more resources' },
62
+ { name: '6', value: 6, description: 'Maximum parallelism' },
63
+ ],
64
+ })
65
+
53
66
  const preserve = await cleanAiFiles()
54
- const ctx = { ...preserve, ...scope }
67
+ const ctx = { ...preserve, ...scope, maxConcurrentAgents }
55
68
 
56
69
  const platform = await choosePlatform()
57
70
 
@@ -69,6 +82,7 @@ export async function runWizard(version) {
69
82
  await writeOnboardConfig({
70
83
  ...ctx,
71
84
  platform,
85
+ maxConcurrentAgents,
72
86
  additionalSkillsProvider: 'npx-skills',
73
87
  ...selectedModels,
74
88
  optionalTools: { rtk, quota, caveman },
@@ -11,7 +11,13 @@
11
11
  "name": "Select folders in parent (../)",
12
12
  "value": "parent",
13
13
  "description": "Use when this repo only contains agent config"
14
+ },
15
+ {
16
+ "name": "Select child folders (./*/)",
17
+ "value": "children",
18
+ "description": "Use when source code lives in subdirectories of this repo"
14
19
  }
15
20
  ],
16
- "parentSelectionMessage": "Select source folders from parent directory:"
21
+ "parentSelectionMessage": "Select source folders from parent directory:",
22
+ "childrenSelectionMessage": "Select child folders to include as source:"
17
23
  }
@@ -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) {
@@ -33,6 +33,7 @@ export async function writeOnboardConfig(data) {
33
33
  platform: data.platform,
34
34
  sourceMode: data.sourceMode,
35
35
  sourceRoots: data.sourceRoots,
36
+ maxConcurrentAgents: data.maxConcurrentAgents ?? 4,
36
37
  preserved: {
37
38
  design: !!data.hasDesign,
38
39
  architecture: !!data.hasArchitecture,
@@ -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
@@ -8,6 +8,26 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
8
  const SOURCE_PRESET_PATH = path.resolve(__dirname, '../../presets/source.json')
9
9
  const sourcePreset = await fse.readJson(SOURCE_PRESET_PATH)
10
10
 
11
+ async function listChildFolders(cwd) {
12
+ const entries = await fse.readdir(cwd)
13
+ const dirs = []
14
+
15
+ for (const name of entries) {
16
+ if (name.startsWith('.')) continue
17
+ const abs = path.join(cwd, name)
18
+ try {
19
+ const stat = await fse.stat(abs)
20
+ if (!stat.isDirectory()) continue
21
+ dirs.push({ name, abs })
22
+ } catch {
23
+ // ignore invalid entries
24
+ }
25
+ }
26
+
27
+ dirs.sort((a, b) => a.name.localeCompare(b.name))
28
+ return dirs
29
+ }
30
+
11
31
  async function listParentFolders(cwd) {
12
32
  const parent = path.resolve(cwd, '..')
13
33
  const entries = await fse.readdir(parent)
@@ -47,6 +67,34 @@ export async function chooseSourceScope() {
47
67
  return { sourceMode: 'current', sourceRoots: [cwd] }
48
68
  }
49
69
 
70
+ if (mode === 'children') {
71
+ const childFolders = await listChildFolders(cwd)
72
+ if (childFolders.length === 0) {
73
+ warn('No child folders found in current directory. Falling back to current folder.')
74
+ success(`Source scope: ${cwd}`)
75
+ return { sourceMode: 'current', sourceRoots: [cwd] }
76
+ }
77
+
78
+ const selected = await checkbox({
79
+ message: sourcePreset.childrenSelectionMessage,
80
+ choices: childFolders.map(d => ({
81
+ name: `./${d.name}`,
82
+ value: d.abs,
83
+ checked: true,
84
+ })),
85
+ required: true,
86
+ })
87
+
88
+ if (!selected || selected.length === 0) {
89
+ warn('No folders selected. Falling back to current folder.')
90
+ success(`Source scope: ${cwd}`)
91
+ return { sourceMode: 'current', sourceRoots: [cwd] }
92
+ }
93
+
94
+ success(`Source scope: ${selected.map(p => path.basename(p)).join(', ')}`)
95
+ return { sourceMode: 'children-selected', sourceRoots: selected }
96
+ }
97
+
50
98
  const parentFolders = await listParentFolders(cwd)
51
99
  if (parentFolders.length === 0) {
52
100
  warn('No sibling folders found in parent directory. Falling back to current folder.')
@@ -23,8 +23,10 @@ vi.mock('fs-extra', () => ({
23
23
  choices: [
24
24
  { name: 'Current folder', value: 'current' },
25
25
  { name: 'Parent folder', value: 'parent' },
26
+ { name: 'Child folders', value: 'children' },
26
27
  ],
27
28
  parentSelectionMessage: 'Select sibling folders',
29
+ childrenSelectionMessage: 'Select child folders',
28
30
  }),
29
31
  readdir: vi.fn(),
30
32
  stat: vi.fn().mockResolvedValue({ isDirectory: () => true }),
@@ -88,4 +90,35 @@ describe('chooseSourceScope()', () => {
88
90
 
89
91
  expect(result.sourceMode).toBe('current')
90
92
  })
93
+
94
+ it('lists child folders when user selects children mode', async () => {
95
+ select.mockResolvedValue('children')
96
+ fse.readdir.mockResolvedValue(['packages', 'apps'])
97
+ checkbox.mockResolvedValue([path.join(tmpDir, 'packages')])
98
+
99
+ const result = await chooseSourceScope()
100
+
101
+ expect(checkbox).toHaveBeenCalled()
102
+ expect(result.sourceMode).toBe('children-selected')
103
+ expect(result.sourceRoots).toContain(path.join(tmpDir, 'packages'))
104
+ })
105
+
106
+ it('falls back to current when no child folders found', async () => {
107
+ select.mockResolvedValue('children')
108
+ fse.readdir.mockResolvedValue([])
109
+
110
+ const result = await chooseSourceScope()
111
+
112
+ expect(result.sourceMode).toBe('current')
113
+ })
114
+
115
+ it('falls back to current when no child folders selected', async () => {
116
+ select.mockResolvedValue('children')
117
+ fse.readdir.mockResolvedValue(['packages'])
118
+ checkbox.mockResolvedValue([])
119
+
120
+ const result = await chooseSourceScope()
121
+
122
+ expect(result.sourceMode).toBe('current')
123
+ })
91
124
  })