opencode-onboard 0.2.0 → 0.2.6

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.
@@ -3,7 +3,7 @@ import path from "node:path"
3
3
 
4
4
  const LOG_FILE = ".agents/session-log.json"
5
5
 
6
- // Per-session state: editCount and skills loaded
6
+ // Per-session state: editCount and loaded skills
7
7
  const sessionState = new Map()
8
8
 
9
9
  function ts() {
@@ -32,6 +32,29 @@ function resolveAgentName(session) {
32
32
  return "lead"
33
33
  }
34
34
 
35
+ function addSkillToState(state, skillName) {
36
+ if (!skillName || !state) return false
37
+ if (!state.skills) state.skills = new Set()
38
+ if (state.skills.has(skillName)) return false
39
+ state.skills.add(skillName)
40
+ return true
41
+ }
42
+
43
+ function buildTeamSkillsSummary() {
44
+ const byAgent = {}
45
+ for (const state of sessionState.values()) {
46
+ if (!state?.agentName) continue
47
+ if (!byAgent[state.agentName]) byAgent[state.agentName] = new Set()
48
+ for (const skill of state.skills ?? []) byAgent[state.agentName].add(skill)
49
+ }
50
+
51
+ const summary = {}
52
+ for (const [agent, skills] of Object.entries(byAgent)) {
53
+ summary[agent] = Array.from(skills).sort()
54
+ }
55
+ return summary
56
+ }
57
+
35
58
  // Maps ensemble tool name → function that extracts the log entry fields from args
36
59
  const ENSEMBLE_TOOL_HANDLERS = {
37
60
  team_create: (args) => ({ action: "team-created", team: args.name }),
@@ -60,7 +83,7 @@ export const SessionLogPlugin = async ({ client, directory }) => {
60
83
  const session = res?.data
61
84
  const agentName = resolveAgentName(session)
62
85
 
63
- sessionState.set(sessionId, { agentName, editCount: 0, skills: [] })
86
+ sessionState.set(sessionId, { agentName, editCount: 0, skills: new Set() })
64
87
  appendEntry(directory, { ts: ts(), agent: agentName, action: "started", sessionId })
65
88
  }
66
89
 
@@ -77,7 +100,8 @@ export const SessionLogPlugin = async ({ client, directory }) => {
77
100
  const state = sessionState.get(sessionId)
78
101
  if (!state) return
79
102
 
80
- const { agentName, editCount, skills } = state
103
+ const { agentName, editCount } = state
104
+ const skills = Array.from(state.skills ?? []).sort()
81
105
  appendEntry(directory, { ts: ts(), agent: agentName, action: "completed", filesEdited: editCount, skills })
82
106
  sessionState.delete(sessionId)
83
107
  }
@@ -94,14 +118,26 @@ export const SessionLogPlugin = async ({ client, directory }) => {
94
118
 
95
119
  const tool = input?.tool
96
120
 
97
- // Track skill loads
121
+ // Track skill loads via skill tool (primary)
122
+ if (tool === "skill") {
123
+ const skillName = input?.args?.name
124
+ const added = addSkillToState(state, skillName)
125
+ if (added) {
126
+ appendEntry(directory, { ts: ts(), agent: state.agentName, action: "skill-loaded", skill: skillName, source: "skill-tool" })
127
+ }
128
+ return
129
+ }
130
+
131
+ // Track skill loads via reading SKILL.md (fallback)
98
132
  if (tool === "read") {
99
133
  const filePath = input?.args?.filePath ?? ""
100
134
  const match = filePath.match(/[/\\]skills[/\\]([^/\\]+)[/\\]SKILL\.md$/i)
101
135
  if (match) {
102
136
  const skillName = match[1]
103
- if (!state.skills.includes(skillName)) state.skills.push(skillName)
104
- appendEntry(directory, { ts: ts(), agent: state.agentName, action: "skill-loaded", skill: skillName })
137
+ const added = addSkillToState(state, skillName)
138
+ if (added) {
139
+ appendEntry(directory, { ts: ts(), agent: state.agentName, action: "skill-loaded", skill: skillName, source: "read-skill-file" })
140
+ }
105
141
  }
106
142
  return
107
143
  }
@@ -112,7 +148,12 @@ export const SessionLogPlugin = async ({ client, directory }) => {
112
148
  const ensembleHandler = ENSEMBLE_TOOL_HANDLERS[tool]
113
149
  if (!ensembleHandler) return
114
150
 
115
- appendEntry(directory, { ts: ts(), agent: state.agentName, ...ensembleHandler(args) })
151
+ const entry = { ts: ts(), agent: state.agentName, ...ensembleHandler(args) }
152
+ appendEntry(directory, entry)
153
+
154
+ if (tool === "team_cleanup") {
155
+ appendEntry(directory, { ts: ts(), agent: state.agentName, action: "team-skills-summary", byAgent: buildTeamSkillsSummary() })
156
+ }
116
157
  } catch (_) {}
117
158
  },
118
159
  }
package/content/AGENTS.md CHANGED
@@ -16,6 +16,8 @@ When the user says anything resembling initialization, "init", "initialize", "se
16
16
 
17
17
  Scan the codebase for any existing documentation, changelogs, ADRs, README files, or notable history that describes decisions already made in this project. Create an OpenSpec archive entry that captures this history so agents have context going forward.
18
18
 
19
+ Before scanning, load source roots from `.agents/source-roots.json` when present. Only scan those roots plus this repo's docs/config files.
20
+
19
21
  ```bash
20
22
  openspec new change "project-history"
21
23
  ```
@@ -40,7 +42,7 @@ openspec archive "project-history"
40
42
  1. **Read `DESIGN.md` now** using a file read tool. The file contains a prompt with instructions and an output format.
41
43
  2. **Store the full prompt text** in your context.
42
44
  3. **Overwrite `DESIGN.md` with an empty string** (zero bytes). Do this before generating any content.
43
- 4. **Analyze the actual codebase**: read CSS files, Tailwind config, component files, token definitions. Do not rely on prior knowledge, read the files.
45
+ 4. **Analyze the actual codebase**: use `.agents/source-roots.json` as source roots when present, then read CSS files, Tailwind config, component files, token definitions. Do not rely on prior knowledge, read the files.
44
46
  5. **Write the result into `DESIGN.md`** following exactly the format and sections described in the stored prompt.
45
47
 
46
48
  The output must be a real, populated `DESIGN.md` based on what you found in the codebase, not from memory or assumptions.
@@ -54,7 +56,7 @@ The output must be a real, populated `DESIGN.md` based on what you found in the
54
56
  1. **Read `ARCHITECTURE.md` now** using a file read tool. The file contains a prompt with instructions and an output format.
55
57
  2. **Store the full prompt text** in your context.
56
58
  3. **Overwrite `ARCHITECTURE.md` with an empty string** (zero bytes). Do this before generating any content.
57
- 4. **Analyze the actual codebase**: read folder structure, config files, route definitions, data models, integration points. Do not rely on prior knowledge, read the files.
59
+ 4. **Analyze the actual codebase**: use `.agents/source-roots.json` as source roots when present, then read folder structure, config files, route definitions, data models, integration points. Do not rely on prior knowledge, read the files.
58
60
  5. **Write the result into `ARCHITECTURE.md`** following exactly the format and sections described in the stored prompt.
59
61
 
60
62
  The output must be a real, populated `ARCHITECTURE.md` based on what you found in the codebase, covering all sections the prompt describes.
@@ -112,6 +114,12 @@ This is the agent orchestration layer for your project. It provides:
112
114
  - OpenSpec change management
113
115
  - Skills for platform-specific knowledge
114
116
 
117
+ ## Source Scope
118
+
119
+ - Read source scope from `.agents/source-roots.json`.
120
+ - Use those roots for codebase analysis tasks (design, architecture, project-history, exploration).
121
+ - If missing, default to current folder.
122
+
115
123
  ## I Am the Lead, Full Workflow Ownership
116
124
 
117
125
  When the user provides a work item URL or says "implement the plan" or "I've added comments to the PR", **I own the full lifecycle**. I load the appropriate skill and use ensemble tools to coordinate the agent team.
@@ -121,6 +129,7 @@ Trigger patterns, I recognize ALL of these, exact wording does not matter:
121
129
  - User pastes or mentions an Azure DevOps URL → load `ob-userstory-az` skill → parse work item → run `/opsx-propose` → confirm with user → run `/opsx-apply` → ship
122
130
  - `implement the plan` / `implement` / `start` / `go` → run `/opsx-apply` → ship
123
131
  - `I've added comments to the PR` → read PR comments → fix → update PR
132
+ - Any GitHub/Azure DevOps PR URL in a feedback/fix request (e.g. "check comments", "fix PR feedback") → run PR Feedback Loop
124
133
 
125
134
  **A GitHub or Azure DevOps URL anywhere in the user's message is always a trigger, regardless of surrounding words.**
126
135
 
@@ -149,6 +158,17 @@ Works on **all platforms** (Windows, macOS, Linux) via OpenCode's built-in workt
149
158
 
150
159
  **Dashboard**: Monitor running agents at **http://localhost:4747/**
151
160
 
161
+ **Progress inspection commands (tell user explicitly after spawning):**
162
+ - `team_status` for live team snapshot
163
+ - `team_tasks_list` for task board state
164
+ - `team_view member:"<name>"` to inspect a teammate live session
165
+ - `team_results from:"<name>"` to fetch full teammate report text
166
+
167
+ If a teammate stalls due to model quota/rate-limit exhaustion:
168
+ 1. `team_shutdown name:"<stuck-member>" force:true`
169
+ 2. `team_spawn` same member/task with an available model
170
+ 3. `team_message` start instruction with the exact next task ID
171
+
152
172
  ---
153
173
 
154
174
  ## Pipeline
@@ -220,12 +240,24 @@ devops-manager (ship mode)
220
240
  ### Phase 6, PR Feedback Loop
221
241
 
222
242
  ```
223
- When user says "I've added comments to the PR":
224
- 1. team_spawn devops-manager (feedback mode) → read & classify comments
225
- 2. Wait team_results spawn front/back/infra for code-change items (parallel)
226
- 3. Wait team_results spawn quality-engineer → verify fixes
227
- 4. Wait team_results spawn devops-manager (ship mode) → push & update PR
228
- 5. team_cleanup
243
+ When user says "I've added comments to the PR" or asks to fix PR comments from PR URLs:
244
+ 1. team_create "pr-feedback-<id>-<random>"
245
+ 2. team_tasks_add with at least these lead-managed tasks:
246
+ - Parse and classify PR feedback (devops-manager)
247
+ - Implement Api feedback items (back-engineer, if needed)
248
+ - Implement App feedback items (front-engineer, if needed)
249
+ - Infra feedback items (infra-engineer, if needed)
250
+ - Verify with tests/build (quality-engineer)
251
+ - Push updates and post PR replies (devops-manager)
252
+ 3. team_spawn devops-manager (feedback mode) with explicit task IDs, then team_message "Start now"
253
+ 4. Wait for message → team_results
254
+ 5. Add/update implementation tasks on board from parsed checklist (Api/App/Infra), then spawn needed specialists in parallel with explicit task IDs + team_message "Start now"
255
+ 6. Wait for specialist results → team_shutdown + team_merge per specialist
256
+ 7. team_spawn quality-engineer worktree:false with verification task ID + team_message "Start now"
257
+ 8. Wait → team_results → fix blockers if any
258
+ 9. team_spawn devops-manager (ship mode) with "push + update PR threads" task ID + team_message "Start now"
259
+ 10. Wait → team_results → report what was updated
260
+ 11. team_cleanup
229
261
  ```
230
262
 
231
263
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-onboard",
3
- "version": "0.2.0",
3
+ "version": "0.2.6",
4
4
  "description": "Prepare any brownfield codebase for AI agent workflows using OpenCode, OpenSpec, and ensemble orchestration.",
5
5
  "keywords": [
6
6
  "opencode",
package/src/index.js CHANGED
@@ -5,13 +5,15 @@ import { checkEnv } from './steps/check-env.js'
5
5
  import { checkPlatform } from './steps/check-platform.js'
6
6
  import { checkRtk } from './steps/check-rtk.js'
7
7
  import { chooseModels } from './steps/choose-models.js'
8
- import { choosePlatform } from './steps/choose-platform.js'
9
- import { chooseSkillsProvider } from './steps/choose-skills-provider.js'
8
+ import { choosePlatform } from './steps/choose-platform.js'
9
+ import { chooseSourceScope } from './steps/choose-source-scope.js'
10
+ import { chooseSkillsProvider } from './steps/choose-skills-provider.js'
10
11
  import { cleanAiFiles } from './steps/clean-ai-files.js'
11
12
  import { copyContentStep } from './steps/copy-content.js'
12
13
  import { initOpenspec } from './steps/init-openspec.js'
13
- import { patchAgentsMd } from './steps/patch-agents-md.js'
14
- import { installBrowser } from './steps/install-browser.js'
14
+ import { patchAgentsMd } from './steps/patch-agents-md.js'
15
+ import { installBrowser } from './steps/install-browser.js'
16
+ import { writeOnboardConfig } from './steps/write-onboard-config.js'
15
17
 
16
18
  if (process.stdout.isTTY) console.clear()
17
19
  console.log()
@@ -59,35 +61,47 @@ try {
59
61
  // 1. Check Node + pnpm
60
62
  await checkEnv()
61
63
 
62
- // 2. Clean existing AI config files, detect preserved state
63
- const ctx = await cleanAiFiles()
64
-
65
- // 3. Choose platform
66
- const platform = await choosePlatform()
67
-
68
- // 4. Check platform CLI (az or gh)
69
- await checkPlatform(platform)
70
-
71
- // 5. Copy content
72
- await copyContentStep(platform, ctx)
73
-
74
- // 5b. Patch AGENTS.md to skip steps for already-existing files
75
- await patchAgentsMd(ctx)
76
-
77
- // 6. Init OpenSpec
78
- await initOpenspec()
79
-
80
- // 7. Install skills
81
- await chooseSkillsProvider()
82
-
83
- // 8. Choose models
84
- await chooseModels()
85
-
86
- // 9. Check RTK
87
- await checkRtk()
88
-
89
- // 10. Install opencode-browser
90
- await installBrowser()
64
+ // 2. Choose source code scope for init analysis
65
+ const scope = await chooseSourceScope()
66
+
67
+ // 3. Clean existing AI config files, detect preserved state
68
+ const preserve = await cleanAiFiles()
69
+ const ctx = { ...preserve, ...scope }
70
+
71
+ // 4. Choose platform
72
+ const platform = await choosePlatform()
73
+
74
+ // 5. Check platform CLI (az or gh)
75
+ await checkPlatform(platform)
76
+
77
+ // 6. Copy content
78
+ await copyContentStep(platform, ctx)
79
+
80
+ // 6b. Patch AGENTS.md to skip steps for already-existing files
81
+ await patchAgentsMd(ctx)
82
+
83
+ // 7. Init OpenSpec
84
+ await initOpenspec()
85
+
86
+ // 8. Install skills
87
+ const skillsSelection = await chooseSkillsProvider()
88
+
89
+ // 9. Choose models
90
+ const selectedModels = await chooseModels()
91
+
92
+ // 10. Check RTK
93
+ await checkRtk()
94
+
95
+ // 11. Install opencode-browser
96
+ await installBrowser()
97
+
98
+ // 12. Write onboarding metadata
99
+ await writeOnboardConfig({
100
+ ...ctx,
101
+ platform,
102
+ ...skillsSelection,
103
+ ...selectedModels,
104
+ })
91
105
 
92
106
  // Done
93
107
  const toGenerate = [
@@ -103,13 +117,14 @@ try {
103
117
  console.log(' Open this project in OpenCode and type:')
104
118
  console.log(chalk.bold(' "init"'))
105
119
  console.log()
106
- if (toGenerate.length > 0) {
107
- console.log(` OpenCode will generate ${toGenerate.join(' and ')}`)
108
- console.log(' from your actual codebase, then activate the agent team.')
109
- } else {
110
- console.log(' OpenCode will activate the agent team.')
111
- }
112
- console.log()
120
+ if (toGenerate.length > 0) {
121
+ console.log(` OpenCode will generate ${toGenerate.join(' and ')}`)
122
+ console.log(' from your actual codebase, then activate the agent team.')
123
+ } else {
124
+ console.log(' OpenCode will activate the agent team.')
125
+ }
126
+ console.log(` Source scope: ${ctx.sourceMode === 'parent-selected' ? ctx.sourceRoots.map(p => `../${p.split(/[/\\]/).pop()}`).join(', ') : 'current folder'}`)
127
+ console.log()
113
128
  } catch (err) {
114
129
  if (err.name === 'ExitPromptError') {
115
130
  console.log()
@@ -10,7 +10,7 @@ export async function checkPlatform(platform) {
10
10
  }
11
11
 
12
12
  async function checkAzure() {
13
- header('Step 4, Checking Azure DevOps CLI')
13
+ header('Step 5, Checking Azure DevOps CLI')
14
14
 
15
15
  // Check az is installed
16
16
  const hasAz = await commandExists('az')
@@ -51,7 +51,7 @@ async function checkAzure() {
51
51
  }
52
52
 
53
53
  async function checkGithub() {
54
- header('Step 4, Checking GitHub CLI')
54
+ header('Step 5, Checking GitHub CLI')
55
55
 
56
56
  const hasGh = await commandExists('gh')
57
57
 
@@ -1,7 +1,7 @@
1
1
  import { code, commandExists, header, info, success, warn } from '../utils/exec.js'
2
2
 
3
3
  export async function checkRtk() {
4
- header('Step 9, Checking rtk')
4
+ header('Step 10, Checking rtk')
5
5
 
6
6
  const available = await commandExists('rtk')
7
7
 
@@ -66,7 +66,7 @@ async function writeModelToAgent(agentFile, modelId) {
66
66
  }
67
67
 
68
68
  export async function chooseModels() {
69
- header('Step 8, Choose models')
69
+ header('Step 9, Choose models')
70
70
 
71
71
  info('Fetching available models from models.dev...')
72
72
  const { models: rawModels, source } = await fetchModels()
@@ -154,4 +154,10 @@ export async function chooseModels() {
154
154
  console.log()
155
155
  warn('Make sure you have API access to the selected models.')
156
156
  warn('Change them anytime in .agents/agents/<name>.md and .opencode/opencode.json')
157
+
158
+ return {
159
+ planModel,
160
+ buildModel,
161
+ fastModel,
162
+ }
157
163
  }
@@ -10,7 +10,7 @@ const PLATFORMS_PRESET_PATH = path.resolve(__dirname, '../presets/platforms.json
10
10
  const platformsPreset = await fse.readJson(PLATFORMS_PRESET_PATH)
11
11
 
12
12
  export async function choosePlatform() {
13
- header('Step 3, Version control platform')
13
+ header('Step 4, Version control platform')
14
14
 
15
15
  const platform = await select({
16
16
  message: 'Which platform are you using?',
@@ -28,7 +28,7 @@ async function installObSkills() {
28
28
  }
29
29
 
30
30
  export async function chooseSkillsProvider() {
31
- header('Step 7, Installing skills')
31
+ header('Step 8, Installing skills')
32
32
 
33
33
  // ob-skills are always installed, mandatory
34
34
  info('Installing built-in ob-skills...')
@@ -56,7 +56,7 @@ export async function chooseSkillsProvider() {
56
56
  })
57
57
 
58
58
  if (selected === 'none') {
59
- return
59
+ return { additionalSkillsProvider: 'none' }
60
60
  }
61
61
 
62
62
  if (selected === 'npx-skills') {
@@ -71,5 +71,9 @@ export async function chooseSkillsProvider() {
71
71
  } catch (err) {
72
72
  warn(`npx skills failed: ${err.message}`)
73
73
  }
74
+
75
+ return { additionalSkillsProvider: 'npx-skills' }
74
76
  }
77
+
78
+ return { additionalSkillsProvider: selected }
75
79
  }
@@ -0,0 +1,81 @@
1
+ import { checkbox, select } from '@inquirer/prompts'
2
+ import fse from 'fs-extra'
3
+ import path from 'path'
4
+ import { header, info, success, warn } from '../utils/exec.js'
5
+
6
+ async function listParentFolders(cwd) {
7
+ const parent = path.resolve(cwd, '..')
8
+ const entries = await fse.readdir(parent)
9
+ const dirs = []
10
+
11
+ for (const name of entries) {
12
+ if (name.startsWith('.')) continue
13
+ const abs = path.join(parent, name)
14
+ try {
15
+ const stat = await fse.stat(abs)
16
+ if (!stat.isDirectory()) continue
17
+ if (path.resolve(abs) === path.resolve(cwd)) continue
18
+ dirs.push({ name, abs })
19
+ } catch {
20
+ // ignore invalid entries
21
+ }
22
+ }
23
+
24
+ dirs.sort((a, b) => a.name.localeCompare(b.name))
25
+ return dirs
26
+ }
27
+
28
+ export async function chooseSourceScope() {
29
+ header('Step 2, Source code scope')
30
+
31
+ const cwd = process.cwd()
32
+ info('Choose where agents should read source code from during init analysis.')
33
+
34
+ const mode = await select({
35
+ message: 'Source code location:',
36
+ default: 'current',
37
+ choices: [
38
+ {
39
+ name: 'Current folder (default)',
40
+ value: 'current',
41
+ description: 'Use this repository only',
42
+ },
43
+ {
44
+ name: 'Select folders in parent (../)',
45
+ value: 'parent',
46
+ description: 'Use when this repo only contains agent config',
47
+ },
48
+ ],
49
+ })
50
+
51
+ if (mode === 'current') {
52
+ success(`Source scope: ${cwd}`)
53
+ return { sourceMode: 'current', sourceRoots: [cwd] }
54
+ }
55
+
56
+ const parentFolders = await listParentFolders(cwd)
57
+ if (parentFolders.length === 0) {
58
+ warn('No sibling folders found in parent directory. Falling back to current folder.')
59
+ success(`Source scope: ${cwd}`)
60
+ return { sourceMode: 'current', sourceRoots: [cwd] }
61
+ }
62
+
63
+ const selected = await checkbox({
64
+ message: 'Select source folders from parent directory:',
65
+ choices: parentFolders.map(d => ({
66
+ name: `../${d.name}`,
67
+ value: d.abs,
68
+ checked: false,
69
+ })),
70
+ required: true,
71
+ })
72
+
73
+ if (!selected || selected.length === 0) {
74
+ warn('No folders selected. Falling back to current folder.')
75
+ success(`Source scope: ${cwd}`)
76
+ return { sourceMode: 'current', sourceRoots: [cwd] }
77
+ }
78
+
79
+ success(`Source scope: ${selected.map(p => path.basename(p)).join(', ')}`)
80
+ return { sourceMode: 'parent-selected', sourceRoots: selected }
81
+ }
@@ -53,7 +53,7 @@ async function hasOpenspecHistory(cwd) {
53
53
  }
54
54
 
55
55
  export async function cleanAiFiles() {
56
- header('Step 2, Existing AI config files')
56
+ header('Step 3, Existing AI config files')
57
57
 
58
58
  const cwd = process.cwd()
59
59
 
@@ -1,18 +1,86 @@
1
1
  import path from 'path'
2
2
  import { fileURLToPath } from 'url'
3
+ import fse from 'fs-extra'
3
4
  import { copyContent } from '../utils/copy.js'
4
5
  import { error, header, success } from '../utils/exec.js'
5
6
 
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
8
  const CONTENT_DIR = path.resolve(__dirname, '../../content')
8
9
 
10
+ function formatRootsForText(roots = [], cwd = process.cwd()) {
11
+ if (!roots.length) return ['current folder']
12
+ return roots.map(r => {
13
+ const rel = path.relative(cwd, r)
14
+ if (!rel || rel === '') return 'current folder'
15
+ if (!rel.startsWith('..')) return `./${rel.replace(/\\/g, '/')}`
16
+ return rel.replace(/\\/g, '/')
17
+ })
18
+ }
19
+
20
+ async function patchSourceScopeFiles(dest, ctx) {
21
+ const roots = formatRootsForText(ctx.sourceRoots || [dest], dest)
22
+ const rootsInline = roots.join(', ')
23
+ const rootsBullets = roots.map(r => ` - ${r}`).join('\n')
24
+
25
+ const agentsPath = path.join(dest, 'AGENTS.md')
26
+ if (await fse.pathExists(agentsPath)) {
27
+ let content = await fse.readFile(agentsPath, 'utf-8')
28
+ content = content.replace(
29
+ 'Before scanning, load source roots from `.agents/source-roots.json` when present. Only scan those roots plus this repo\'s docs/config files.',
30
+ `Source roots selected during onboarding (scan these roots plus this repo docs/config): ${rootsInline}.`
31
+ )
32
+ content = content.replace(
33
+ '4. **Analyze the actual codebase**: use `.agents/source-roots.json` as source roots when present, then read CSS files, Tailwind config, component files, token definitions. Do not rely on prior knowledge, read the files.',
34
+ `4. **Analyze the actual codebase** using these source roots:\n${rootsBullets}\n\n Then read CSS files, Tailwind config, component files, token definitions. Do not rely on prior knowledge, read the files.`
35
+ )
36
+ content = content.replace(
37
+ '4. **Analyze the actual codebase**: use `.agents/source-roots.json` as source roots when present, then read folder structure, config files, route definitions, data models, integration points. Do not rely on prior knowledge, read the files.',
38
+ `4. **Analyze the actual codebase** using these source roots:\n${rootsBullets}\n\n Then read folder structure, config files, route definitions, data models, integration points. Do not rely on prior knowledge, read the files.`
39
+ )
40
+ content = content.replace(
41
+ '- Read source scope from `.agents/source-roots.json`.',
42
+ `- Source roots selected during onboarding: ${rootsInline}.`
43
+ )
44
+ await fse.writeFile(agentsPath, content, 'utf-8')
45
+ }
46
+
47
+ const designPath = path.join(dest, 'DESIGN.md')
48
+ if (await fse.pathExists(designPath)) {
49
+ let content = await fse.readFile(designPath, 'utf-8')
50
+ const injection = `\nSource roots selected during onboarding:\n${rootsBullets}\n\nWhen analyzing, read UI/design evidence only from these roots.\n\nIn the generated DESIGN.md, add this section near the top:\n\n## Source Roots Used\n${rootsBullets}\n`
51
+ content = content.replace(
52
+ 'Analyze the design system of this codebase with the goal of creating a DESIGN.md file in the project root and giving the user a file for easy copy & pasting.',
53
+ 'Analyze the design system of this codebase with the goal of creating a DESIGN.md file in the project root and giving the user a file for easy copy & pasting.' + injection
54
+ )
55
+ await fse.writeFile(designPath, content, 'utf-8')
56
+ }
57
+
58
+ const architecturePath = path.join(dest, 'ARCHITECTURE.md')
59
+ if (await fse.pathExists(architecturePath)) {
60
+ let content = await fse.readFile(architecturePath, 'utf-8')
61
+ const injection = `\nSource roots selected during onboarding:\n${rootsBullets}\n\nWhen analyzing, read architecture evidence only from these roots.\n\nIn the generated ARCHITECTURE.md, add this section near the top:\n\n## Source Roots Used\n${rootsBullets}\n`
62
+ content = content.replace(
63
+ 'Analyze the architecture of this codebase with the goal of creating an ARCHITECTURE.md file in the project root and giving the user a file for easy copy & pasting.',
64
+ 'Analyze the architecture of this codebase with the goal of creating an ARCHITECTURE.md file in the project root and giving the user a file for easy copy & pasting.' + injection
65
+ )
66
+ await fse.writeFile(architecturePath, content, 'utf-8')
67
+ }
68
+ }
69
+
9
70
  export async function copyContentStep(platform, ctx = {}) {
10
- header('Step 5, Copying opencode-onboard files')
71
+ header('Step 6, Copying opencode-onboard files')
11
72
 
12
73
  const dest = process.cwd()
13
74
 
14
75
  try {
15
76
  await copyContent(CONTENT_DIR, dest, platform, ctx)
77
+ const rootsFile = path.join(dest, '.agents', 'source-roots.json')
78
+ await fse.ensureDir(path.dirname(rootsFile))
79
+ await fse.writeJson(rootsFile, {
80
+ mode: ctx.sourceMode || 'current',
81
+ roots: ctx.sourceRoots || [dest],
82
+ }, { spaces: 2 })
83
+ await patchSourceScopeFiles(dest, ctx)
16
84
  success('Files copied to project root')
17
85
  } catch (err) {
18
86
  error(`Failed to copy content: ${err.message}`)
@@ -1,28 +1,159 @@
1
1
  import { execa } from 'execa'
2
- import fse from 'fs-extra'
3
2
  import path from 'node:path'
4
- import { fileURLToPath } from 'node:url'
3
+ import fse from 'fs-extra'
5
4
  import { error, header, success, warn } from '../utils/exec.js'
6
5
 
7
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
-
9
- // Our owned apply command and skill, stored in the package content folder.
10
- // After openspec init generates its versions, we delete them and copy ours in.
11
- const OUR_CONTENT_DIR = path.resolve(__dirname, '../../content')
12
-
13
- const APPLY_OVERRIDES = [
14
- {
15
- src: path.join(OUR_CONTENT_DIR, '.opencode', 'commands', 'opsx-apply.md'),
16
- dest: path.join('.opencode', 'commands', 'opsx-apply.md'),
17
- },
18
- {
19
- src: path.join(OUR_CONTENT_DIR, '.opencode', 'skills', 'openspec-apply-change', 'SKILL.md'),
20
- dest: path.join('.opencode', 'skills', 'openspec-apply-change', 'SKILL.md'),
21
- },
6
+ const APPLY_TARGETS = [
7
+ path.join('.opencode', 'commands', 'opsx-apply.md'),
8
+ path.join('.opencode', 'skills', 'openspec-apply-change', 'SKILL.md'),
22
9
  ]
23
10
 
11
+ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
12
+
13
+ NEVER implement tasks directly. Always delegate to specialists via ensemble.
14
+ Do NOT touch any source files before the team is running, not even a single edit.
15
+
16
+ Steps MUST be followed in order. Do not skip any step.
17
+
18
+ **Step 6a.** Create feature branch if not already on one: \`feature/{id}-{slug}\`
19
+
20
+ **Step 6b.** Create the team:
21
+ \`\`\`
22
+ team_create "<change-name>-<random 4 digit number>"
23
+ \`\`\`
24
+ Announce: "Team running. Monitor at http://localhost:4747/"
25
+
26
+ **Step 6c.** Add ALL tasks to the shared board BEFORE spawning anyone.
27
+ Schema: { content: string, priority: "high"|"medium"|"low", depends_on?: string[] }
28
+ Use depends_on to block tasks that require other tasks first, pass the IDs returned by team_tasks_add.
29
+ \`\`\`
30
+ team_tasks_add tasks:[
31
+ { content: "1.1 <exact task text from tasks.md>", priority: "high" },
32
+ { content: "1.2 <exact task text>", priority: "high" },
33
+ { content: "3.1 <task that needs 1.x done first>", priority: "medium", depends_on: ["<id-of-1.1>"] },
34
+ ...every task, one entry each...
35
+ ]
36
+ \`\`\`
37
+ Save the task IDs returned. Pass them to agents in step 6d.
38
+ DO NOT call team_claim yourself, only agents claim tasks.
39
+ DO NOT proceed to 6d until team_tasks_add succeeds.
40
+
41
+ **Step 6d.** Spawn all needed specialists, then kick them off in parallel.
42
+
43
+ Each team_spawn MUST include the agent field (required, causes NOT NULL error if omitted).
44
+
45
+ The spawn prompt must contain exactly:
46
+ 1. Their name and role on this team
47
+ 2. Which tasks are theirs, list the task IDs and content from the board
48
+ 3. Key context they need (summarized from context files, do NOT tell them to read files themselves)
49
+ 4. The 6 OpenCode tools they have available (these are OpenCode tools, NOT shell commands, call them directly as tools, never via bash):
50
+ team_claim, team_tasks_complete, team_tasks_list, team_tasks_add, team_message, team_broadcast
51
+ 5. How to proceed: call team_claim tool with the task_id to claim a task before starting it, call team_tasks_complete tool after finishing it, repeat until all their tasks are done, then call team_message tool to notify lead with results or blockers
52
+
53
+ Keep spawn prompts under 500 tokens. Do not describe team internals or how ensemble works.
54
+ Only spawn agents whose tasks are actually needed by this change. Skip agents with no tasks.
55
+
56
+ First spawn all agents (wait for each team_spawn to confirm before the next):
57
+ \`\`\`
58
+ team_spawn name:"back" agent:"back-engineer" prompt:"..."
59
+ (wait for result)
60
+ team_spawn name:"front" agent:"front-engineer" prompt:"..."
61
+ (wait for result)
62
+ team_spawn name:"infra" agent:"infra-engineer" prompt:"..."
63
+ (wait for result)
64
+ \`\`\`
65
+
66
+ Then immediately send each spawned agent a start message to kick them off:
67
+ \`\`\`
68
+ team_message to:"back" text:"Start now. Claim your first task with team_claim and begin implementing."
69
+ team_message to:"front" text:"Start now. Claim your first task with team_claim and begin implementing."
70
+ team_message to:"infra" text:"Start now. Claim your first task with team_claim and begin implementing."
71
+ \`\`\`
72
+
73
+ **Step 6e.** After sending start messages, tell the user what is running, then STOP and wait.
74
+ Do NOT call team_results, team_status, or team_broadcast in a loop.
75
+ Teammates will message you when done or blocked. Wait for those messages.
76
+ Tell the user exactly how to inspect progress:
77
+ - \`team_status\` for team snapshot
78
+ - \`team_tasks_list\` for board state
79
+ - \`team_view member:"<name>"\` for a teammate live session
80
+ - \`team_results from:"<name>"\` for full teammate report text
81
+
82
+ **Step 6f.** When a teammate messages back, you receive a ping only, the full content is NOT in the notification.
83
+ Call team_results to read the full message and mark it read. Then for each teammate: team_shutdown -> team_merge.
84
+ If team_merge blocks ("overlapping local changes"), commit or stash your local changes first, then retry.
85
+ Fix any other blockers reported.
86
+ If a teammate reports rate-limit/quota/token exhaustion, immediately shutdown that teammate and respawn with an available model.
87
+
88
+ 7. **Quality check**
89
+
90
+ Spawn quality engineer with worktree:false (read-only, no file edits):
91
+ \`\`\`
92
+ team_spawn name:"quality" agent:"quality-engineer" worktree:false prompt:"<task list, context summary, run tests + build + lint + verify acceptance criteria, send results to lead when done>"
93
+ \`\`\`
94
+ Wait for message -> team_results -> fix blockers -> team_shutdown (no team_merge needed, worktree:false)
95
+
96
+ 8. **Mark tasks complete in openspec**
97
+
98
+ Update tasks.md: \`- [ ]\` -> \`- [x]\` for each completed task.
99
+ Run \`rtk openspec status --change "<name>" --json\` to confirm.
100
+
101
+ 9. **Show status, then cleanup**
102
+
103
+ Display:
104
+ - Tasks completed this session
105
+ - Overall progress: "N/M tasks complete"
106
+ - If all done: suggest archive with \`/opsx-archive\`
107
+ - If paused: explain why and wait for guidance
108
+
109
+ Then run \`team_cleanup\`.
110
+
111
+ **Guardrails**
112
+ - NEVER skip or reorder steps 6a-6f
113
+ - NEVER implement tasks directly. Always use team_create + team_spawn, no exceptions
114
+ - NEVER touch source files before team_create is called, not even one edit
115
+ - NEVER call team_spawn without the agent field, it is required and will fail without it
116
+ - NEVER call team_spawn before team_tasks_add, tasks must exist before agents are spawned
117
+ - NEVER poll team_results or team_status in a loop, wait for teammates to message you
118
+ - NEVER call team_claim or team_tasks_complete as lead, only agents call these tools
119
+ - ALWAYS pass the task IDs returned by team_tasks_add to each agent's spawn prompt
120
+ - NEVER edit files between team_spawn and team_merge, team_merge blocks on overlapping local changes
121
+ - ALWAYS add every task to the board with team_tasks_add before spawning
122
+ - ALWAYS spawn agents sequentially (wait for each team_spawn result before the next), then send start messages to all of them together
123
+ - ALWAYS instruct agents to call team_claim before each task and team_tasks_complete after
124
+ - If teammates are stuck, use team_message to resend tasks, then wait, never implement directly
125
+ - Mark tasks complete in openspec AFTER specialists finish, not before
126
+ - Pause on errors, blockers, or unclear requirements. Do not guess
127
+ - Use contextFiles from CLI output, do not assume specific file paths
128
+ - Use \`rtk\` wrapper for ALL CLI commands. Never run openspec, git, gh, or az directly
129
+ - If model quota/rate-limit is exhausted, tell lead immediately via team_message and stop claiming new tasks until respawned
130
+ `
131
+
132
+ const STEP_6_START = /^6\.\s+\*\*Implement\b/im
133
+ const FLUID_SECTION = /^\*\*Fluid Workflow Integration\*\*/im
134
+
135
+ async function patchApplyFile(filePath) {
136
+ if (!await fse.pathExists(filePath)) return { ok: false, reason: 'missing-file' }
137
+
138
+ const original = await fse.readFile(filePath, 'utf-8')
139
+ const startMatch = original.match(STEP_6_START)
140
+ if (!startMatch || startMatch.index === undefined) return { ok: false, reason: 'missing-step-6' }
141
+
142
+ const before = original.slice(0, startMatch.index).replace(/\s*$/, '')
143
+ const fromStep6 = original.slice(startMatch.index)
144
+ const fluidMatch = fromStep6.match(FLUID_SECTION)
145
+
146
+ const after = fluidMatch && fluidMatch.index !== undefined
147
+ ? `\n\n${fromStep6.slice(fluidMatch.index).replace(/^\s*/, '')}`
148
+ : ''
149
+
150
+ const patched = `${before}\n\n${ENSEMBLE_SECTION}${after}`
151
+ await fse.writeFile(filePath, patched, 'utf-8')
152
+ return { ok: true }
153
+ }
154
+
24
155
  export async function initOpenspec() {
25
- header('Step 6, Initializing OpenSpec')
156
+ header('Step 7, Initializing OpenSpec')
26
157
 
27
158
  try {
28
159
  const result = await execa('npx', ['@fission-ai/openspec', 'init', '--tools', 'opencode', '--force'], {
@@ -40,15 +171,18 @@ export async function initOpenspec() {
40
171
  error(`Failed to run openspec init: ${err.message}`)
41
172
  }
42
173
 
43
- // Replace the openspec-generated apply command and skill with our ensemble-native versions.
44
- // The generated files implement tasks directly (solo agent). Ours delegate to the ensemble team.
45
- for (const { src, dest } of APPLY_OVERRIDES) {
46
- const destAbs = path.join(process.cwd(), dest)
174
+ // Keep openspec defaults for selection/status/context steps, replace only implementation + guardrails.
175
+ for (const rel of APPLY_TARGETS) {
176
+ const abs = path.join(process.cwd(), rel)
47
177
  try {
48
- await fse.copy(src, destAbs, { overwrite: true })
49
- success(`Installed ensemble apply → ${dest}`)
178
+ const res = await patchApplyFile(abs)
179
+ if (res.ok) {
180
+ success(`Patched ensemble implementation section in ${rel}`)
181
+ } else {
182
+ warn(`Could not patch ${rel} (${res.reason})`)
183
+ }
50
184
  } catch (err) {
51
- warn(`Could not install ${dest}: ${err.message}`)
185
+ warn(`Could not patch ${rel}: ${err.message}`)
52
186
  }
53
187
  }
54
188
  }
@@ -12,7 +12,7 @@ const AUTO_ANSWERS = [
12
12
  ]
13
13
 
14
14
  export async function installBrowser() {
15
- header('Step 10, Installing opencode-browser')
15
+ header('Step 11, Installing opencode-browser')
16
16
 
17
17
  try {
18
18
  const child = execa('npx', ['@different-ai/opencode-browser', 'install'], {
@@ -0,0 +1,59 @@
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 12, 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
+ },
48
+ note: 'Informational file only. Editing this file does not change runtime behavior.',
49
+ }
50
+
51
+ try {
52
+ await fse.ensureDir(path.dirname(target))
53
+ await fse.writeJson(target, payload, { spaces: 2 })
54
+ success('Wrote .opencode/opencode-onboard.json')
55
+ if (!opencodeVersion) warn('Could not detect opencode version, saved as null')
56
+ } catch (err) {
57
+ warn(`Could not write onboarding metadata: ${err.message}`)
58
+ }
59
+ }