opencode-onboard 0.2.3 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,29 @@ import fs from "node:fs"
2
2
  import path from "node:path"
3
3
 
4
4
  const LOG_FILE = ".agents/session-log.json"
5
+ const SPAWN_MATCH_WINDOW_MS = 15000
6
+ const DEBUG = process.env.SESSION_LOG_DEBUG === "true"
5
7
 
6
- // Per-session state: editCount and loaded skills
8
+ // Per-session state
7
9
  const sessionState = new Map()
8
10
 
11
+ // Lead session -> current team name
12
+ const leadTeamBySession = new Map()
13
+
14
+ // Pending spawn records waiting for a session.created match
15
+ const pendingSpawns = []
16
+
17
+ // Team -> completed session snapshots
18
+ const completedByTeam = new Map()
19
+
9
20
  function ts() {
10
21
  return new Date().toISOString()
11
22
  }
12
23
 
24
+ function nowMs() {
25
+ return Date.now()
26
+ }
27
+
13
28
  function appendEntry(directory, entry) {
14
29
  const logPath = path.join(directory, LOG_FILE)
15
30
  const dir = path.dirname(logPath)
@@ -40,35 +55,148 @@ function addSkillToState(state, skillName) {
40
55
  return true
41
56
  }
42
57
 
43
- function buildTeamSkillsSummary() {
58
+ function toNum(v) {
59
+ if (typeof v === "number" && Number.isFinite(v)) return v
60
+ if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v)
61
+ return null
62
+ }
63
+
64
+ function extractReportedTokens(obj) {
65
+ const out = { input: 0, output: 0, total: 0, found: false }
66
+ const visited = new Set()
67
+
68
+ function walk(node) {
69
+ if (!node || typeof node !== "object") return
70
+ if (visited.has(node)) return
71
+ visited.add(node)
72
+
73
+ for (const [k, v] of Object.entries(node)) {
74
+ const key = String(k).toLowerCase()
75
+ const n = toNum(v)
76
+
77
+ if (n !== null) {
78
+ if ((key.includes("input") || key.includes("prompt")) && key.includes("token")) {
79
+ out.input += n
80
+ out.found = true
81
+ } else if ((key.includes("output") || key.includes("completion")) && key.includes("token")) {
82
+ out.output += n
83
+ out.found = true
84
+ } else if (key.includes("total") && key.includes("token")) {
85
+ out.total += n
86
+ out.found = true
87
+ }
88
+ }
89
+
90
+ if (v && typeof v === "object") walk(v)
91
+ }
92
+ }
93
+
94
+ walk(obj)
95
+ return out
96
+ }
97
+
98
+ function estimateTokens(state) {
99
+ const inTokens = Math.ceil((state.charIn || 0) / 4)
100
+ const outTokens = Math.ceil((state.charOut || 0) / 4)
101
+ const base = inTokens + outTokens + Math.max(0, (state.toolCalls || 0) * 20)
102
+ const low = Math.max(0, Math.floor(base * 0.7))
103
+ const high = Math.max(low, Math.ceil(base * 1.4))
104
+ return { low, high }
105
+ }
106
+
107
+ function usagePayload(state) {
108
+ const est = estimateTokens(state)
109
+ const reportedIn = state.reportedInputTokens || 0
110
+ const reportedOut = state.reportedOutputTokens || 0
111
+ const reportedTotalDirect = state.reportedTotalTokens || 0
112
+ const reportedTotalDerived = reportedIn + reportedOut
113
+ const reportedTotal = reportedTotalDirect || reportedTotalDerived || 0
114
+
115
+ let method = "heuristic"
116
+ if (reportedTotal > 0 && (est.low > 0 || est.high > 0)) method = "mixed"
117
+ else if (reportedTotal > 0) method = "reported"
118
+
119
+ return {
120
+ inputTokensReported: reportedIn || null,
121
+ outputTokensReported: reportedOut || null,
122
+ totalTokensReported: reportedTotal || null,
123
+ tokenEstimateLow: est.low,
124
+ tokenEstimateHigh: est.high,
125
+ method,
126
+ }
127
+ }
128
+
129
+ function buildCompletedSnapshot(state, sessionId) {
130
+ return {
131
+ sessionId,
132
+ agent: state.agentName,
133
+ member: state.member || null,
134
+ agentType: state.agentType || null,
135
+ team: state.teamName || null,
136
+ skills: Array.from(state.skills || []).sort(),
137
+ usage: usagePayload(state),
138
+ filesEdited: state.editCount || 0,
139
+ }
140
+ }
141
+
142
+ function buildTeamSkillsSummary(teamName) {
143
+ const rows = completedByTeam.get(teamName) || []
44
144
  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)
145
+ for (const row of rows) {
146
+ const key = row.member || row.agent || "unknown"
147
+ if (!byAgent[key]) byAgent[key] = { agentType: row.agentType || null, skills: new Set() }
148
+ for (const s of row.skills || []) byAgent[key].skills.add(s)
49
149
  }
150
+ const out = {}
151
+ for (const [k, v] of Object.entries(byAgent)) {
152
+ out[k] = {
153
+ agentType: v.agentType,
154
+ skills: Array.from(v.skills).sort(),
155
+ }
156
+ }
157
+ return out
158
+ }
50
159
 
51
- const summary = {}
52
- for (const [agent, skills] of Object.entries(byAgent)) {
53
- summary[agent] = Array.from(skills).sort()
160
+ function trackCompletedByTeam(snapshot) {
161
+ if (!snapshot.team) return
162
+ if (!completedByTeam.has(snapshot.team)) completedByTeam.set(snapshot.team, [])
163
+ completedByTeam.get(snapshot.team).push(snapshot)
164
+ }
165
+
166
+ function enqueuePendingSpawn(leadSessionId, args) {
167
+ pendingSpawns.push({
168
+ leadSessionId,
169
+ at: nowMs(),
170
+ member: args?.name || null,
171
+ agentType: args?.agent || null,
172
+ teamName: leadTeamBySession.get(leadSessionId) || null,
173
+ })
174
+ }
175
+
176
+ function matchPendingSpawn() {
177
+ const now = nowMs()
178
+ // Drop expired pending spawns first
179
+ for (let i = pendingSpawns.length - 1; i >= 0; i--) {
180
+ if (now - pendingSpawns[i].at > SPAWN_MATCH_WINDOW_MS) pendingSpawns.splice(i, 1)
54
181
  }
55
- return summary
182
+ if (pendingSpawns.length === 0) return null
183
+ return pendingSpawns.shift()
56
184
  }
57
185
 
58
- // Maps ensemble tool name function that extracts the log entry fields from args
186
+ // Maps ensemble tool name -> function that extracts log entry fields from args
59
187
  const ENSEMBLE_TOOL_HANDLERS = {
60
- team_create: (args) => ({ action: "team-created", team: args.name }),
61
- team_spawn: (args) => ({ action: "teammate-spawned", name: args.name, agentType: args.agent }),
62
- team_shutdown: (args) => ({ action: "teammate-shutdown", name: args.name }),
63
- team_merge: (args) => ({ action: "teammate-merged", name: args.name }),
64
- team_cleanup: () => ({ action: "team-cleanup" }),
65
- team_status: () => ({ action: "team-status-checked" }),
66
- team_results: (args) => ({ action: "team-results-read", from: args.from }),
67
- team_message: (args) => ({ action: "team-message", to: args.to ?? "lead", preview: String(args.text ?? "").slice(0, 120) }),
68
- team_broadcast: (args) => ({ action: "team-broadcast", preview: String(args.text ?? "").slice(0, 120) }),
69
- team_tasks_add: (args) => ({ action: "tasks-added", count: Array.isArray(args.tasks) ? args.tasks.length : "?" }),
70
- team_tasks_complete: (args) => ({ action: "task-completed", taskId: args.task_id }),
71
- team_claim: (args) => ({ action: "task-claimed", taskId: args.task_id }),
188
+ team_create: (args) => ({ action: "team-created", team: args.name }),
189
+ team_spawn: (args) => ({ action: "teammate-spawned", name: args.name, agentType: args.agent }),
190
+ team_shutdown: (args) => ({ action: "teammate-shutdown", name: args.name }),
191
+ team_merge: (args) => ({ action: "teammate-merged", name: args.name }),
192
+ team_cleanup: () => ({ action: "team-cleanup" }),
193
+ team_status: () => ({ action: "team-status-checked" }),
194
+ team_results: (args) => ({ action: "team-results-read", from: args.from }),
195
+ team_message: (args) => ({ action: "team-message", to: args.to ?? "lead", preview: String(args.text ?? "").slice(0, 120) }),
196
+ team_broadcast: (args) => ({ action: "team-broadcast", preview: String(args.text ?? "").slice(0, 120) }),
197
+ team_tasks_add: (args) => ({ action: "tasks-added", count: Array.isArray(args.tasks) ? args.tasks.length : "?" }),
198
+ team_tasks_complete: (args) => ({ action: "task-completed", taskId: args.task_id }),
199
+ team_claim: (args) => ({ action: "task-claimed", taskId: args.task_id }),
72
200
  }
73
201
 
74
202
  export const SessionLogPlugin = async ({ client, directory }) => {
@@ -81,10 +209,35 @@ export const SessionLogPlugin = async ({ client, directory }) => {
81
209
 
82
210
  const res = await client.session.get({ path: { id: sessionId } })
83
211
  const session = res?.data
84
- const agentName = resolveAgentName(session)
212
+ const fallbackAgent = resolveAgentName(session)
213
+ const spawnMatch = matchPendingSpawn()
214
+
215
+ const state = {
216
+ agentName: spawnMatch?.member || fallbackAgent,
217
+ member: spawnMatch?.member || null,
218
+ agentType: spawnMatch?.agentType || null,
219
+ teamName: spawnMatch?.teamName || null,
220
+ editCount: 0,
221
+ skills: new Set(),
222
+ startedAtMs: nowMs(),
223
+ toolCalls: 0,
224
+ charIn: 0,
225
+ charOut: 0,
226
+ reportedInputTokens: 0,
227
+ reportedOutputTokens: 0,
228
+ reportedTotalTokens: 0,
229
+ }
85
230
 
86
- sessionState.set(sessionId, { agentName, editCount: 0, skills: new Set() })
87
- appendEntry(directory, { ts: ts(), agent: agentName, action: "started", sessionId })
231
+ sessionState.set(sessionId, state)
232
+ appendEntry(directory, {
233
+ ts: ts(),
234
+ agent: state.agentName,
235
+ member: state.member,
236
+ agentType: state.agentType,
237
+ team: state.teamName,
238
+ action: "started",
239
+ sessionId,
240
+ })
88
241
  }
89
242
 
90
243
  if (event?.type === "file.edited") {
@@ -100,9 +253,21 @@ export const SessionLogPlugin = async ({ client, directory }) => {
100
253
  const state = sessionState.get(sessionId)
101
254
  if (!state) return
102
255
 
103
- const { agentName, editCount } = state
104
- const skills = Array.from(state.skills ?? []).sort()
105
- appendEntry(directory, { ts: ts(), agent: agentName, action: "completed", filesEdited: editCount, skills })
256
+ const skills = Array.from(state.skills || []).sort()
257
+ const usage = usagePayload(state)
258
+ appendEntry(directory, {
259
+ ts: ts(),
260
+ agent: state.agentName,
261
+ member: state.member,
262
+ agentType: state.agentType,
263
+ team: state.teamName,
264
+ action: "completed",
265
+ filesEdited: state.editCount,
266
+ skills,
267
+ usage,
268
+ })
269
+
270
+ trackCompletedByTeam(buildCompletedSnapshot(state, sessionId))
106
271
  sessionState.delete(sessionId)
107
272
  }
108
273
  } catch (_) {}
@@ -117,13 +282,49 @@ export const SessionLogPlugin = async ({ client, directory }) => {
117
282
  if (!state) return
118
283
 
119
284
  const tool = input?.tool
285
+ const args = input?.args ?? {}
286
+
287
+ state.toolCalls++
288
+ state.charIn += JSON.stringify(args).length
289
+ state.charOut += JSON.stringify(output ?? {}).length
290
+
291
+ const reportedIn = extractReportedTokens(input)
292
+ const reportedOut = extractReportedTokens(output)
293
+ if (reportedIn.found) {
294
+ state.reportedInputTokens += reportedIn.input
295
+ state.reportedOutputTokens += reportedIn.output
296
+ state.reportedTotalTokens += reportedIn.total
297
+ }
298
+ if (reportedOut.found) {
299
+ state.reportedInputTokens += reportedOut.input
300
+ state.reportedOutputTokens += reportedOut.output
301
+ state.reportedTotalTokens += reportedOut.total
302
+ }
303
+
304
+ if (DEBUG && !reportedIn.found && !reportedOut.found && tool !== "read") {
305
+ appendEntry(directory, {
306
+ ts: ts(),
307
+ agent: state.agentName,
308
+ action: "debug-no-token-metrics",
309
+ tool,
310
+ })
311
+ }
120
312
 
121
313
  // Track skill loads via skill tool (primary)
122
314
  if (tool === "skill") {
123
315
  const skillName = input?.args?.name
124
316
  const added = addSkillToState(state, skillName)
125
317
  if (added) {
126
- appendEntry(directory, { ts: ts(), agent: state.agentName, action: "skill-loaded", skill: skillName, source: "skill-tool" })
318
+ appendEntry(directory, {
319
+ ts: ts(),
320
+ agent: state.agentName,
321
+ member: state.member,
322
+ agentType: state.agentType,
323
+ team: state.teamName,
324
+ action: "skill-loaded",
325
+ skill: skillName,
326
+ source: "skill-tool",
327
+ })
127
328
  }
128
329
  return
129
330
  }
@@ -136,23 +337,53 @@ export const SessionLogPlugin = async ({ client, directory }) => {
136
337
  const skillName = match[1]
137
338
  const added = addSkillToState(state, skillName)
138
339
  if (added) {
139
- appendEntry(directory, { ts: ts(), agent: state.agentName, action: "skill-loaded", skill: skillName, source: "read-skill-file" })
340
+ appendEntry(directory, {
341
+ ts: ts(),
342
+ agent: state.agentName,
343
+ member: state.member,
344
+ agentType: state.agentType,
345
+ team: state.teamName,
346
+ action: "skill-loaded",
347
+ skill: skillName,
348
+ source: "read-skill-file",
349
+ })
140
350
  }
141
351
  }
142
352
  return
143
353
  }
144
354
 
145
- const args = input?.args ?? {}
146
-
147
355
  // Track ensemble tool calls
148
356
  const ensembleHandler = ENSEMBLE_TOOL_HANDLERS[tool]
149
357
  if (!ensembleHandler) return
150
358
 
151
- const entry = { ts: ts(), agent: state.agentName, ...ensembleHandler(args) }
359
+ const entry = {
360
+ ts: ts(),
361
+ agent: state.agentName,
362
+ member: state.member,
363
+ agentType: state.agentType,
364
+ team: state.teamName,
365
+ ...ensembleHandler(args),
366
+ }
152
367
  appendEntry(directory, entry)
153
368
 
369
+ if (tool === "team_create") {
370
+ leadTeamBySession.set(sessionId, args?.name || null)
371
+ state.teamName = args?.name || state.teamName
372
+ }
373
+
374
+ if (tool === "team_spawn") {
375
+ enqueuePendingSpawn(sessionId, args)
376
+ }
377
+
154
378
  if (tool === "team_cleanup") {
155
- appendEntry(directory, { ts: ts(), agent: state.agentName, action: "team-skills-summary", byAgent: buildTeamSkillsSummary() })
379
+ const teamName = state.teamName || leadTeamBySession.get(sessionId)
380
+ appendEntry(directory, {
381
+ ts: ts(),
382
+ agent: state.agentName,
383
+ action: "team-skills-summary",
384
+ team: teamName || null,
385
+ byAgent: teamName ? buildTeamSkillsSummary(teamName) : {},
386
+ })
156
387
  }
157
388
  } catch (_) {}
158
389
  },
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
  ```
@@ -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.3",
3
+ "version": "0.2.7",
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
@@ -11,8 +11,9 @@ import { chooseSkillsProvider } from './steps/choose-skills-provider.js'
11
11
  import { cleanAiFiles } from './steps/clean-ai-files.js'
12
12
  import { copyContentStep } from './steps/copy-content.js'
13
13
  import { initOpenspec } from './steps/init-openspec.js'
14
- import { patchAgentsMd } from './steps/patch-agents-md.js'
15
- 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'
16
17
 
17
18
  if (process.stdout.isTTY) console.clear()
18
19
  console.log()
@@ -83,16 +84,24 @@ try {
83
84
  await initOpenspec()
84
85
 
85
86
  // 8. Install skills
86
- await chooseSkillsProvider()
87
+ const skillsSelection = await chooseSkillsProvider()
87
88
 
88
89
  // 9. Choose models
89
- await chooseModels()
90
+ const selectedModels = await chooseModels()
90
91
 
91
92
  // 10. Check RTK
92
93
  await checkRtk()
93
94
 
94
95
  // 11. Install opencode-browser
95
96
  await installBrowser()
97
+
98
+ // 12. Write onboarding metadata
99
+ await writeOnboardConfig({
100
+ ...ctx,
101
+ platform,
102
+ ...skillsSelection,
103
+ ...selectedModels,
104
+ })
96
105
 
97
106
  // Done
98
107
  const toGenerate = [
@@ -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
  }
@@ -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
  }
@@ -7,6 +7,66 @@ import { error, header, success } from '../utils/exec.js'
7
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
8
  const CONTENT_DIR = path.resolve(__dirname, '../../content')
9
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
+
10
70
  export async function copyContentStep(platform, ctx = {}) {
11
71
  header('Step 6, Copying opencode-onboard files')
12
72
 
@@ -20,6 +80,7 @@ export async function copyContentStep(platform, ctx = {}) {
20
80
  mode: ctx.sourceMode || 'current',
21
81
  roots: ctx.sourceRoots || [dest],
22
82
  }, { spaces: 2 })
83
+ await patchSourceScopeFiles(dest, ctx)
23
84
  success('Files copied to project root')
24
85
  } catch (err) {
25
86
  error(`Failed to copy content: ${err.message}`)
@@ -73,11 +73,17 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
73
73
  **Step 6e.** After sending start messages, tell the user what is running, then STOP and wait.
74
74
  Do NOT call team_results, team_status, or team_broadcast in a loop.
75
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
76
81
 
77
82
  **Step 6f.** When a teammate messages back, you receive a ping only, the full content is NOT in the notification.
78
83
  Call team_results to read the full message and mark it read. Then for each teammate: team_shutdown -> team_merge.
79
84
  If team_merge blocks ("overlapping local changes"), commit or stash your local changes first, then retry.
80
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.
81
87
 
82
88
  7. **Quality check**
83
89
 
@@ -120,6 +126,7 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
120
126
  - Pause on errors, blockers, or unclear requirements. Do not guess
121
127
  - Use contextFiles from CLI output, do not assume specific file paths
122
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
123
130
  `
124
131
 
125
132
  const STEP_6_START = /^6\.\s+\*\*Implement\b/im
@@ -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
+ }