opencode-onboard 0.2.6 → 0.2.12

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/README.md CHANGED
@@ -31,13 +31,36 @@ Most codebases have no `AGENTS.md`, no architecture docs agents can read, and no
31
31
  npx opencode-onboard@latest
32
32
  ```
33
33
 
34
- Requires **Node.js 18+**.
34
+ Requires **Node.js 18+**.
35
+
36
+ ### Run specific steps
37
+
38
+ You can also run individual maintenance/setup steps without the full wizard:
39
+
40
+ ```bash
41
+ # Run one step directly
42
+ npx opencode-onboard clean
43
+ npx opencode-onboard platform
44
+ npx opencode-onboard copy
45
+ npx opencode-onboard openspec
46
+ npx opencode-onboard skills
47
+ npx opencode-onboard models
48
+ npx opencode-onboard optimization
49
+ npx opencode-onboard browser
50
+ npx opencode-onboard metadata
51
+
52
+ # Show CLI help and all commands
53
+ npx opencode-onboard --help
54
+ npx opencode-onboard -h
55
+ ```
56
+
57
+ When available, step commands reuse context from `.opencode/opencode-onboard.json`.
35
58
 
36
59
  ---
37
60
 
38
61
  ## How it works
39
62
 
40
- The CLI clears the screen, shows a welcome banner, and walks you through 10 steps. The screen always shows the last 2 completed steps + the current one so you always know where you are.
63
+ The CLI clears the screen, shows a welcome banner, and walks you through 12 steps. The screen always shows the last 2 completed steps + the current one so you always know where you are.
41
64
 
42
65
  | Step | What happens |
43
66
  |------|-------------|
@@ -48,9 +71,10 @@ The CLI clears the screen, shows a welcome banner, and walks you through 10 step
48
71
  | **5. Copy scaffolding** | Drops agents, skills, and bootstrap docs into your project |
49
72
  | **6. Init OpenSpec** | Runs `npx @fission-ai/openspec init` silently for structured change management |
50
73
  | **7. Install skills** | Installs built-in `ob-` skills + optional additional skills provider |
51
- | **8. Choose models** | Fetches live model list from [models.dev](https://models.dev), lets you pick plan / build / fast models with cost indicators and canonical pricing |
52
- | **9. Check RTK** | Verifies `rtk` is on PATH |
53
- | **10. Install browser plugin** | Installs `@different-ai/opencode-browser` globally for agent browser automation |
74
+ | **8. Choose models** | Fetches live model list from [models.dev](https://models.dev), lets you pick plan / build / fast models with cost indicators and canonical pricing |
75
+ | **9. Token optimization tools** | Optional (recommended). One checklist step for RTK check, opencode-quota setup, and caveman install (all preselected) |
76
+ | **11. Install browser plugin** | Installs `@different-ai/opencode-browser` globally for agent browser automation |
77
+ | **12. Write onboarding metadata** | Writes `.opencode/opencode-onboard.json` with selected setup details |
54
78
 
55
79
  When it finishes, open OpenCode in your project and type:
56
80
 
@@ -181,7 +205,7 @@ After this, every agent has accurate, persistent context about your project, no
181
205
  | **Node.js 18+** | Required |
182
206
  | **[OpenCode](https://opencode.ai)** | The agent runtime |
183
207
  | **[OpenCode Ensemble](https://github.com/hueyexe/opencode-ensemble)** | Multi-agent parallel execution |
184
- | **[rtk](https://github.com/rtk-ai/rtk#pre-built-binaries)** | Required for agents to run CLI commands safely |
208
+ | **[rtk](https://github.com/rtk-ai/rtk#pre-built-binaries)** | Recommended for safer agent CLI command execution |
185
209
  | **[gh CLI](https://cli.github.com)** | GitHub platform, must be authenticated |
186
210
  | **[az CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)** + azure-devops extension | Azure DevOps platform |
187
211
 
@@ -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
  },