opencode-onboard 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +41 -40
  2. package/content/.agents/agents/devops-manager.md +123 -123
  3. package/content/.agents/skills/ob-default/SKILL.md +25 -21
  4. package/content/.agents/skills/ob-generic-guardrails/SKILL.md +36 -32
  5. package/content/.agents/skills/ob-global/SKILL.md +92 -84
  6. package/content/.agents/skills/ob-pullrequest-az/SKILL.md +168 -160
  7. package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +140 -136
  8. package/content/.opencode/commands/create-engineer.md +109 -0
  9. package/content/.opencode/plugins/session-log.js +523 -519
  10. package/content/AGENTS.md +32 -21
  11. package/package.json +1 -1
  12. package/src/commands/wizard.js +124 -113
  13. package/src/presets/browser.json +22 -18
  14. package/src/presets/optimization.json +27 -22
  15. package/src/steps/browser/browser.test.js +115 -81
  16. package/src/steps/browser/index.js +62 -54
  17. package/src/steps/clean/index.js +108 -107
  18. package/src/steps/metadata/index.js +63 -62
  19. package/src/steps/models/format.js +61 -60
  20. package/src/steps/models/write.test.js +117 -117
  21. package/src/steps/openspec/ensemble.test.js +79 -79
  22. package/src/steps/openspec/index.js +121 -32
  23. package/src/steps/openspec/index.test.js +63 -0
  24. package/src/steps/optimization/caveman.js +34 -29
  25. package/src/steps/optimization/codegraph.js +103 -0
  26. package/src/steps/optimization/codegraph.test.js +104 -0
  27. package/src/steps/optimization/global.js +88 -64
  28. package/src/steps/optimization/global.test.js +99 -0
  29. package/src/steps/optimization/index.js +109 -101
  30. package/src/steps/optimization/optimization.test.js +101 -93
  31. package/src/steps/optimization/quota.js +84 -84
  32. package/src/steps/source/source.test.js +124 -124
  33. package/src/utils/__tests__/copy.test.js +117 -117
  34. package/src/utils/exec-spinner.js +47 -47
  35. package/src/utils/exec.js +134 -131
  36. package/src/utils/terminal.js +6 -0
@@ -1,519 +1,523 @@
1
- import fs from "node:fs"
2
- import path from "node:path"
3
-
4
- const LOG_FILE = ".agents/session-log.json"
5
- const SPAWN_MATCH_WINDOW_MS = 30000
6
- const UNMATCHED_SESSION_WARN_MS = 30000
7
- const DEBUG = process.env.SESSION_LOG_DEBUG === "true"
8
-
9
- // Per-session state
10
- const sessionState = new Map()
11
-
12
- // Lead session -> current team name
13
- const leadTeamBySession = new Map()
14
-
15
- // Pending spawn records waiting for a session.created match
16
- // Each entry: { leadSessionId, at, member, agentType, teamName, spawnedSessionId, intendedSkills }
17
- const pendingSpawns = []
18
-
19
- // spawnedSessionId -> pending spawn (direct correlation fast path)
20
- const pendingSpawnBySessionId = new Map()
21
-
22
- // Team -> completed session snapshots
23
- const completedByTeam = new Map()
24
-
25
- function ts() {
26
- return new Date().toISOString()
27
- }
28
-
29
- function nowMs() {
30
- return Date.now()
31
- }
32
-
33
- function appendEntry(directory, entry) {
34
- const logPath = path.join(directory, LOG_FILE)
35
- const dir = path.dirname(logPath)
36
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
37
-
38
- let entries = []
39
- if (fs.existsSync(logPath)) {
40
- try { entries = JSON.parse(fs.readFileSync(logPath, "utf8")) } catch (_) {}
41
- }
42
- entries.push(entry)
43
- fs.writeFileSync(logPath, JSON.stringify(entries, null, 2), "utf8")
44
- }
45
-
46
- function resolveAgentName(session) {
47
- const agentPath = session?.agent
48
- if (agentPath) {
49
- const base = path.basename(agentPath, ".md")
50
- if (base) return base
51
- }
52
- return "lead"
53
- }
54
-
55
- function resolveModel(session) {
56
- // Try common field names used by OpenCode session objects
57
- return session?.model || session?.modelId || session?.model_id || null
58
- }
59
-
60
- function addSkillToState(state, skillName) {
61
- if (!skillName || !state) return false
62
- if (!state.skills) state.skills = new Set()
63
- if (state.skills.has(skillName)) return false
64
- state.skills.add(skillName)
65
- return true
66
- }
67
-
68
- // Extract skill names hinted in a spawn prompt by scanning for known skill-like tokens.
69
- // Matches strings that look like skill directory names (kebab-case words after "skill" keyword
70
- // or adjacent to known skill name patterns).
71
- function extractIntendedSkills(prompt) {
72
- if (!prompt || typeof prompt !== "string") return []
73
- const skills = new Set()
74
-
75
- // Match explicit skill name mentions: e.g. "ob-pullrequest-gh", "browser-automation"
76
- const kebabPattern = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+){1,})\b/g
77
- let m
78
- while ((m = kebabPattern.exec(prompt)) !== null) {
79
- const candidate = m[1]
80
- // Filter to plausible skill names: 2+ segments, known prefixes or suffixes
81
- if (
82
- candidate.startsWith("ob-") ||
83
- candidate.startsWith("openspec-") ||
84
- candidate.startsWith("browser-") ||
85
- candidate.endsWith("-gh") ||
86
- candidate.endsWith("-az") ||
87
- candidate.endsWith("-automation") ||
88
- candidate.endsWith("-change") ||
89
- candidate.endsWith("-engineer") ||
90
- candidate.endsWith("-manager") ||
91
- candidate.endsWith("-auditor")
92
- ) {
93
- skills.add(candidate)
94
- }
95
- }
96
- return Array.from(skills).sort()
97
- }
98
-
99
- function toNum(v) {
100
- if (typeof v === "number" && Number.isFinite(v)) return v
101
- if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v)
102
- return null
103
- }
104
-
105
- function extractReportedTokens(obj) {
106
- const out = { input: 0, output: 0, total: 0, found: false }
107
- const visited = new Set()
108
-
109
- function walk(node) {
110
- if (!node || typeof node !== "object") return
111
- if (visited.has(node)) return
112
- visited.add(node)
113
-
114
- for (const [k, v] of Object.entries(node)) {
115
- const key = String(k).toLowerCase()
116
- const n = toNum(v)
117
-
118
- if (n !== null) {
119
- if ((key.includes("input") || key.includes("prompt")) && key.includes("token")) {
120
- out.input += n
121
- out.found = true
122
- } else if ((key.includes("output") || key.includes("completion")) && key.includes("token")) {
123
- out.output += n
124
- out.found = true
125
- } else if (key.includes("total") && key.includes("token")) {
126
- out.total += n
127
- out.found = true
128
- }
129
- }
130
-
131
- if (v && typeof v === "object") walk(v)
132
- }
133
- }
134
-
135
- walk(obj)
136
- return out
137
- }
138
-
139
- function estimateTokens(state) {
140
- const inTokens = Math.ceil((state.charIn || 0) / 4)
141
- const outTokens = Math.ceil((state.charOut || 0) / 4)
142
- const base = inTokens + outTokens + Math.max(0, (state.toolCalls || 0) * 20)
143
- const low = Math.max(0, Math.floor(base * 0.7))
144
- const high = Math.max(low, Math.ceil(base * 1.4))
145
- return { low, high }
146
- }
147
-
148
- function usagePayload(state) {
149
- const est = estimateTokens(state)
150
- const reportedIn = state.reportedInputTokens || 0
151
- const reportedOut = state.reportedOutputTokens || 0
152
- const reportedTotalDirect = state.reportedTotalTokens || 0
153
- const reportedTotalDerived = reportedIn + reportedOut
154
- const reportedTotal = reportedTotalDirect || reportedTotalDerived || 0
155
-
156
- let method = "heuristic"
157
- if (reportedTotal > 0 && (est.low > 0 || est.high > 0)) method = "mixed"
158
- else if (reportedTotal > 0) method = "reported"
159
-
160
- return {
161
- inputTokensReported: reportedIn || null,
162
- outputTokensReported: reportedOut || null,
163
- totalTokensReported: reportedTotal || null,
164
- tokenEstimateLow: est.low,
165
- tokenEstimateHigh: est.high,
166
- method,
167
- }
168
- }
169
-
170
- function buildCompletedSnapshot(state, sessionId) {
171
- return {
172
- sessionId,
173
- agent: state.agentName,
174
- member: state.member || null,
175
- agentType: state.agentType || null,
176
- team: state.teamName || null,
177
- model: state.model || null,
178
- skills: Array.from(state.skills || []).sort(),
179
- intendedSkills: state.intendedSkills || [],
180
- usage: usagePayload(state),
181
- filesEdited: state.editCount || 0,
182
- }
183
- }
184
-
185
- function buildTeamSkillsSummary(teamName) {
186
- const rows = completedByTeam.get(teamName) || []
187
- const byAgent = {}
188
- for (const row of rows) {
189
- const key = row.member || row.agent || "unknown"
190
- if (!byAgent[key]) byAgent[key] = { agentType: row.agentType || null, model: row.model || null, skills: new Set(), intendedSkills: new Set() }
191
- for (const s of row.skills || []) byAgent[key].skills.add(s)
192
- for (const s of row.intendedSkills || []) byAgent[key].intendedSkills.add(s)
193
- }
194
- const out = {}
195
- for (const [k, v] of Object.entries(byAgent)) {
196
- const skills = Array.from(v.skills).sort()
197
- const intendedSkills = Array.from(v.intendedSkills).sort()
198
- const missingSkills = intendedSkills.filter(s => !v.skills.has(s))
199
- out[k] = {
200
- agentType: v.agentType,
201
- model: v.model,
202
- skills,
203
- intendedSkills,
204
- missingSkills: missingSkills.length > 0 ? missingSkills : undefined,
205
- }
206
- }
207
- return out
208
- }
209
-
210
- function trackCompletedByTeam(snapshot) {
211
- if (!snapshot.team) return
212
- if (!completedByTeam.has(snapshot.team)) completedByTeam.set(snapshot.team, [])
213
- completedByTeam.get(snapshot.team).push(snapshot)
214
- }
215
-
216
- function enqueuePendingSpawn(leadSessionId, args, spawnOutput) {
217
- // Try to extract spawnedSessionId from team_spawn output for direct correlation
218
- const spawnedSessionId =
219
- spawnOutput?.sessionId ||
220
- spawnOutput?.session_id ||
221
- spawnOutput?.id ||
222
- spawnOutput?.data?.sessionId ||
223
- spawnOutput?.data?.session_id ||
224
- spawnOutput?.data?.id ||
225
- null
226
-
227
- const record = {
228
- leadSessionId,
229
- at: nowMs(),
230
- member: args?.name || null,
231
- agentType: args?.agent || null,
232
- teamName: leadTeamBySession.get(leadSessionId) || null,
233
- spawnedSessionId,
234
- intendedSkills: extractIntendedSkills(args?.prompt),
235
- }
236
-
237
- pendingSpawns.push(record)
238
-
239
- if (spawnedSessionId) {
240
- pendingSpawnBySessionId.set(spawnedSessionId, record)
241
- }
242
- }
243
-
244
- function matchPendingSpawn(sessionId) {
245
- const now = nowMs()
246
-
247
- // Fast path: direct session ID correlation from team_spawn output
248
- if (sessionId && pendingSpawnBySessionId.has(sessionId)) {
249
- const record = pendingSpawnBySessionId.get(sessionId)
250
- pendingSpawnBySessionId.delete(sessionId)
251
- const idx = pendingSpawns.indexOf(record)
252
- if (idx !== -1) pendingSpawns.splice(idx, 1)
253
- return record
254
- }
255
-
256
- // Fallback: time-window heuristic (drop expired first)
257
- for (let i = pendingSpawns.length - 1; i >= 0; i--) {
258
- if (now - pendingSpawns[i].at > SPAWN_MATCH_WINDOW_MS) {
259
- pendingSpawnBySessionId.delete(pendingSpawns[i].spawnedSessionId)
260
- pendingSpawns.splice(i, 1)
261
- }
262
- }
263
- if (pendingSpawns.length === 0) return null
264
- return pendingSpawns.shift()
265
- }
266
-
267
- // Maps ensemble tool name -> function that extracts log entry fields from args
268
- const ENSEMBLE_TOOL_HANDLERS = {
269
- team_create: (args) => ({ action: "team-created", team: args.name }),
270
- team_spawn: (args) => ({ action: "teammate-spawned", name: args.name, agentType: args.agent }),
271
- team_shutdown: (args) => ({ action: "teammate-shutdown", name: args.name }),
272
- team_merge: (args) => ({ action: "teammate-merged", name: args.name }),
273
- team_cleanup: () => ({ action: "team-cleanup" }),
274
- team_status: () => ({ action: "team-status-checked" }),
275
- team_results: (args) => ({ action: "team-results-read", from: args.from }),
276
- team_message: (args) => ({ action: "team-message", to: args.to ?? "lead", preview: String(args.text ?? "").slice(0, 120) }),
277
- team_broadcast: (args) => ({ action: "team-broadcast", preview: String(args.text ?? "").slice(0, 120) }),
278
- team_tasks_add: (args) => ({ action: "tasks-added", count: Array.isArray(args.tasks) ? args.tasks.length : "?" }),
279
- team_tasks_complete: (args) => ({ action: "task-completed", taskId: args.task_id }),
280
- team_claim: (args) => ({ action: "task-claimed", taskId: args.task_id }),
281
- }
282
-
283
- export const SessionLogPlugin = async ({ client, directory }) => {
284
- return {
285
- event: async ({ event }) => {
286
- try {
287
- if (event?.type === "session.created") {
288
- const sessionId = event.properties?.id ?? event.properties?.sessionID
289
- if (!sessionId) return
290
-
291
- const res = await client.session.get({ path: { id: sessionId } })
292
- const session = res?.data
293
- const fallbackAgent = resolveAgentName(session)
294
- const model = resolveModel(session)
295
- const spawnMatch = matchPendingSpawn(sessionId)
296
-
297
- const state = {
298
- agentName: spawnMatch?.member || fallbackAgent,
299
- member: spawnMatch?.member || null,
300
- agentType: spawnMatch?.agentType || null,
301
- teamName: spawnMatch?.teamName || null,
302
- model,
303
- intendedSkills: spawnMatch?.intendedSkills || [],
304
- editCount: 0,
305
- skills: new Set(),
306
- startedAtMs: nowMs(),
307
- toolCalls: 0,
308
- charIn: 0,
309
- charOut: 0,
310
- reportedInputTokens: 0,
311
- reportedOutputTokens: 0,
312
- reportedTotalTokens: 0,
313
- // Track whether this session was matched to a spawn record
314
- spawnMatched: !!spawnMatch,
315
- }
316
-
317
- sessionState.set(sessionId, state)
318
-
319
- const startedEntry = {
320
- ts: ts(),
321
- agent: state.agentName,
322
- member: state.member,
323
- agentType: state.agentType,
324
- team: state.teamName,
325
- model: state.model,
326
- action: "started",
327
- sessionId,
328
- }
329
- appendEntry(directory, startedEntry)
330
-
331
- // Emit explicit teammate-registered entry when a spawn is matched
332
- if (spawnMatch) {
333
- appendEntry(directory, {
334
- ts: ts(),
335
- agent: state.agentName,
336
- member: state.member,
337
- agentType: state.agentType,
338
- team: state.teamName,
339
- model: state.model,
340
- intendedSkills: state.intendedSkills,
341
- action: "teammate-registered",
342
- sessionId,
343
- correlationMethod: spawnMatch.spawnedSessionId === sessionId ? "direct" : "time-window",
344
- })
345
- } else {
346
- // No spawn match, schedule an unmatched-session warning
347
- const capturedSessionId = sessionId
348
- setTimeout(() => {
349
- const s = sessionState.get(capturedSessionId)
350
- if (!s || s.spawnMatched) return
351
- appendEntry(directory, {
352
- ts: ts(),
353
- agent: s.agentName,
354
- member: s.member,
355
- team: s.teamName,
356
- model: s.model,
357
- action: "unmatched-session",
358
- sessionId: capturedSessionId,
359
- warning: "Session started with no matching team_spawn record. Agent identity may be inaccurate.",
360
- })
361
- }, UNMATCHED_SESSION_WARN_MS)
362
- }
363
- }
364
-
365
- if (event?.type === "file.edited") {
366
- const sessionId = event.properties?.sessionID ?? event.properties?.id
367
- if (!sessionId) return
368
- const state = sessionState.get(sessionId)
369
- if (state) state.editCount++
370
- }
371
-
372
- if (event?.type === "session.idle") {
373
- const sessionId = event.properties?.id ?? event.properties?.sessionID
374
- if (!sessionId) return
375
- const state = sessionState.get(sessionId)
376
- if (!state) return
377
-
378
- const skills = Array.from(state.skills || []).sort()
379
- const usage = usagePayload(state)
380
- appendEntry(directory, {
381
- ts: ts(),
382
- agent: state.agentName,
383
- member: state.member,
384
- agentType: state.agentType,
385
- team: state.teamName,
386
- model: state.model,
387
- action: "completed",
388
- filesEdited: state.editCount,
389
- skills,
390
- intendedSkills: state.intendedSkills,
391
- usage,
392
- })
393
-
394
- trackCompletedByTeam(buildCompletedSnapshot(state, sessionId))
395
- sessionState.delete(sessionId)
396
- }
397
- } catch (_) {}
398
- },
399
-
400
- "tool.execute.after": async (input, output) => {
401
- try {
402
- const sessionId = input?.sessionID ?? input?.session_id
403
- if (!sessionId) return
404
-
405
- const state = sessionState.get(sessionId)
406
- if (!state) return
407
-
408
- const tool = input?.tool
409
- const args = input?.args ?? {}
410
-
411
- state.toolCalls++
412
- state.charIn += JSON.stringify(args).length
413
- state.charOut += JSON.stringify(output ?? {}).length
414
-
415
- const reportedIn = extractReportedTokens(input)
416
- const reportedOut = extractReportedTokens(output)
417
- if (reportedIn.found) {
418
- state.reportedInputTokens += reportedIn.input
419
- state.reportedOutputTokens += reportedIn.output
420
- state.reportedTotalTokens += reportedIn.total
421
- }
422
- if (reportedOut.found) {
423
- state.reportedInputTokens += reportedOut.input
424
- state.reportedOutputTokens += reportedOut.output
425
- state.reportedTotalTokens += reportedOut.total
426
- }
427
-
428
- if (DEBUG && !reportedIn.found && !reportedOut.found && tool !== "read") {
429
- appendEntry(directory, {
430
- ts: ts(),
431
- agent: state.agentName,
432
- action: "debug-no-token-metrics",
433
- tool,
434
- })
435
- }
436
-
437
- // Track skill loads via skill tool (primary)
438
- if (tool === "skill") {
439
- const skillName = input?.args?.name
440
- const added = addSkillToState(state, skillName)
441
- if (added) {
442
- appendEntry(directory, {
443
- ts: ts(),
444
- agent: state.agentName,
445
- member: state.member,
446
- agentType: state.agentType,
447
- team: state.teamName,
448
- model: state.model,
449
- action: "skill-loaded",
450
- skill: skillName,
451
- source: "skill-tool",
452
- })
453
- }
454
- return
455
- }
456
-
457
- // Track skill loads via reading SKILL.md (fallback)
458
- if (tool === "read") {
459
- const filePath = input?.args?.filePath ?? ""
460
- const match = filePath.match(/[/\\]skills[/\\]([^/\\]+)[/\\]SKILL\.md$/i)
461
- if (match) {
462
- const skillName = match[1]
463
- const added = addSkillToState(state, skillName)
464
- if (added) {
465
- appendEntry(directory, {
466
- ts: ts(),
467
- agent: state.agentName,
468
- member: state.member,
469
- agentType: state.agentType,
470
- team: state.teamName,
471
- model: state.model,
472
- action: "skill-loaded",
473
- skill: skillName,
474
- source: "read-skill-file",
475
- })
476
- }
477
- }
478
- return
479
- }
480
-
481
- // Track ensemble tool calls
482
- const ensembleHandler = ENSEMBLE_TOOL_HANDLERS[tool]
483
- if (!ensembleHandler) return
484
-
485
- const entry = {
486
- ts: ts(),
487
- agent: state.agentName,
488
- member: state.member,
489
- agentType: state.agentType,
490
- team: state.teamName,
491
- model: state.model,
492
- ...ensembleHandler(args),
493
- }
494
- appendEntry(directory, entry)
495
-
496
- if (tool === "team_create") {
497
- leadTeamBySession.set(sessionId, args?.name || null)
498
- state.teamName = args?.name || state.teamName
499
- }
500
-
501
- if (tool === "team_spawn") {
502
- enqueuePendingSpawn(sessionId, args, output)
503
- }
504
-
505
- if (tool === "team_cleanup") {
506
- const teamName = state.teamName || leadTeamBySession.get(sessionId)
507
- appendEntry(directory, {
508
- ts: ts(),
509
- agent: state.agentName,
510
- model: state.model,
511
- action: "team-skills-summary",
512
- team: teamName || null,
513
- byAgent: teamName ? buildTeamSkillsSummary(teamName) : {},
514
- })
515
- }
516
- } catch (_) {}
517
- },
518
- }
519
- }
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+
4
+ const LOG_FILE = ".agents/session-log.json"
5
+ const SPAWN_MATCH_WINDOW_MS = 30000
6
+ const UNMATCHED_SESSION_WARN_MS = 30000
7
+ const DEBUG = process.env.SESSION_LOG_DEBUG === "true"
8
+
9
+ // Per-session state
10
+ const sessionState = new Map()
11
+
12
+ // Lead session -> current team name
13
+ const leadTeamBySession = new Map()
14
+
15
+ // Pending spawn records waiting for a session.created match
16
+ // Each entry: { leadSessionId, at, member, agentType, teamName, spawnedSessionId, intendedSkills }
17
+ const pendingSpawns = []
18
+
19
+ // spawnedSessionId -> pending spawn (direct correlation fast path)
20
+ const pendingSpawnBySessionId = new Map()
21
+
22
+ // Team -> completed session snapshots
23
+ const completedByTeam = new Map()
24
+
25
+ function ts() {
26
+ return new Date().toISOString()
27
+ }
28
+
29
+ function nowMs() {
30
+ return Date.now()
31
+ }
32
+
33
+ function appendEntry(directory, entry) {
34
+ const logPath = path.join(directory, LOG_FILE)
35
+ const dir = path.dirname(logPath)
36
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
37
+
38
+ let entries = []
39
+ if (fs.existsSync(logPath)) {
40
+ try { entries = JSON.parse(fs.readFileSync(logPath, "utf8")) } catch { /* ignore parse errors */ }
41
+ }
42
+ entries.push(entry)
43
+ fs.writeFileSync(logPath, JSON.stringify(entries, null, 2), "utf8")
44
+ }
45
+
46
+ function resolveAgentName(session) {
47
+ const agentPath = session?.agent
48
+ if (agentPath) {
49
+ const base = path.basename(agentPath, ".md")
50
+ if (base) return base
51
+ }
52
+ return "lead"
53
+ }
54
+
55
+ function resolveModel(session) {
56
+ // Try common field names used by OpenCode session objects
57
+ return session?.model || session?.modelId || session?.model_id || null
58
+ }
59
+
60
+ function addSkillToState(state, skillName) {
61
+ if (!skillName || !state) return false
62
+ if (!state.skills) state.skills = new Set()
63
+ if (state.skills.has(skillName)) return false
64
+ state.skills.add(skillName)
65
+ return true
66
+ }
67
+
68
+ // Extract skill names hinted in a spawn prompt by scanning for known skill-like tokens.
69
+ // Matches strings that look like skill directory names (kebab-case words after "skill" keyword
70
+ // or adjacent to known skill name patterns).
71
+ function extractIntendedSkills(prompt) {
72
+ if (!prompt || typeof prompt !== "string") return []
73
+ const skills = new Set()
74
+
75
+ // Match explicit skill name mentions: e.g. "ob-pullrequest-gh", "browser-automation"
76
+ const kebabPattern = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+){1,})\b/g
77
+ let m
78
+ while ((m = kebabPattern.exec(prompt)) !== null) {
79
+ const candidate = m[1]
80
+ // Filter to plausible skill names: 2+ segments, known prefixes or suffixes
81
+ if (
82
+ candidate.startsWith("ob-") ||
83
+ candidate.startsWith("openspec-") ||
84
+ candidate.startsWith("browser-") ||
85
+ candidate.endsWith("-gh") ||
86
+ candidate.endsWith("-az") ||
87
+ candidate.endsWith("-automation") ||
88
+ candidate.endsWith("-change") ||
89
+ candidate.endsWith("-engineer") ||
90
+ candidate.endsWith("-manager") ||
91
+ candidate.endsWith("-auditor")
92
+ ) {
93
+ skills.add(candidate)
94
+ }
95
+ }
96
+ return Array.from(skills).sort()
97
+ }
98
+
99
+ function toNum(v) {
100
+ if (typeof v === "number" && Number.isFinite(v)) return v
101
+ if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v)
102
+ return null
103
+ }
104
+
105
+ function extractReportedTokens(obj) {
106
+ const out = { input: 0, output: 0, total: 0, found: false }
107
+ const visited = new Set()
108
+
109
+ function walk(node) {
110
+ if (!node || typeof node !== "object") return
111
+ if (visited.has(node)) return
112
+ visited.add(node)
113
+
114
+ for (const [k, v] of Object.entries(node)) {
115
+ const key = String(k).toLowerCase()
116
+ const n = toNum(v)
117
+
118
+ if (n !== null) {
119
+ if ((key.includes("input") || key.includes("prompt")) && key.includes("token")) {
120
+ out.input += n
121
+ out.found = true
122
+ } else if ((key.includes("output") || key.includes("completion")) && key.includes("token")) {
123
+ out.output += n
124
+ out.found = true
125
+ } else if (key.includes("total") && key.includes("token")) {
126
+ out.total += n
127
+ out.found = true
128
+ }
129
+ }
130
+
131
+ if (v && typeof v === "object") walk(v)
132
+ }
133
+ }
134
+
135
+ walk(obj)
136
+ return out
137
+ }
138
+
139
+ function estimateTokens(state) {
140
+ const inTokens = Math.ceil((state.charIn || 0) / 4)
141
+ const outTokens = Math.ceil((state.charOut || 0) / 4)
142
+ const base = inTokens + outTokens + Math.max(0, (state.toolCalls || 0) * 20)
143
+ const low = Math.max(0, Math.floor(base * 0.7))
144
+ const high = Math.max(low, Math.ceil(base * 1.4))
145
+ return { low, high }
146
+ }
147
+
148
+ function usagePayload(state) {
149
+ const est = estimateTokens(state)
150
+ const reportedIn = state.reportedInputTokens || 0
151
+ const reportedOut = state.reportedOutputTokens || 0
152
+ const reportedTotalDirect = state.reportedTotalTokens || 0
153
+ const reportedTotalDerived = reportedIn + reportedOut
154
+ const reportedTotal = reportedTotalDirect || reportedTotalDerived || 0
155
+
156
+ let method = "heuristic"
157
+ if (reportedTotal > 0 && (est.low > 0 || est.high > 0)) method = "mixed"
158
+ else if (reportedTotal > 0) method = "reported"
159
+
160
+ return {
161
+ inputTokensReported: reportedIn || null,
162
+ outputTokensReported: reportedOut || null,
163
+ totalTokensReported: reportedTotal || null,
164
+ tokenEstimateLow: est.low,
165
+ tokenEstimateHigh: est.high,
166
+ method,
167
+ }
168
+ }
169
+
170
+ function buildCompletedSnapshot(state, sessionId) {
171
+ return {
172
+ sessionId,
173
+ agent: state.agentName,
174
+ member: state.member || null,
175
+ agentType: state.agentType || null,
176
+ team: state.teamName || null,
177
+ model: state.model || null,
178
+ skills: Array.from(state.skills || []).sort(),
179
+ intendedSkills: state.intendedSkills || [],
180
+ usage: usagePayload(state),
181
+ filesEdited: state.editCount || 0,
182
+ }
183
+ }
184
+
185
+ function buildTeamSkillsSummary(teamName) {
186
+ const rows = completedByTeam.get(teamName) || []
187
+ const byAgent = {}
188
+ for (const row of rows) {
189
+ const key = row.member || row.agent || "unknown"
190
+ if (!byAgent[key]) byAgent[key] = { agentType: row.agentType || null, model: row.model || null, skills: new Set(), intendedSkills: new Set() }
191
+ for (const s of row.skills || []) byAgent[key].skills.add(s)
192
+ for (const s of row.intendedSkills || []) byAgent[key].intendedSkills.add(s)
193
+ }
194
+ const out = {}
195
+ for (const [k, v] of Object.entries(byAgent)) {
196
+ const skills = Array.from(v.skills).sort()
197
+ const intendedSkills = Array.from(v.intendedSkills).sort()
198
+ const missingSkills = intendedSkills.filter(s => !v.skills.has(s))
199
+ out[k] = {
200
+ agentType: v.agentType,
201
+ model: v.model,
202
+ skills,
203
+ intendedSkills,
204
+ missingSkills: missingSkills.length > 0 ? missingSkills : undefined,
205
+ }
206
+ }
207
+ return out
208
+ }
209
+
210
+ function trackCompletedByTeam(snapshot) {
211
+ if (!snapshot.team) return
212
+ if (!completedByTeam.has(snapshot.team)) completedByTeam.set(snapshot.team, [])
213
+ completedByTeam.get(snapshot.team).push(snapshot)
214
+ }
215
+
216
+ function enqueuePendingSpawn(leadSessionId, args, spawnOutput) {
217
+ // Try to extract spawnedSessionId from team_spawn output for direct correlation
218
+ const spawnedSessionId =
219
+ spawnOutput?.sessionId ||
220
+ spawnOutput?.session_id ||
221
+ spawnOutput?.id ||
222
+ spawnOutput?.data?.sessionId ||
223
+ spawnOutput?.data?.session_id ||
224
+ spawnOutput?.data?.id ||
225
+ null
226
+
227
+ const record = {
228
+ leadSessionId,
229
+ at: nowMs(),
230
+ member: args?.name || null,
231
+ agentType: args?.agent || null,
232
+ teamName: leadTeamBySession.get(leadSessionId) || null,
233
+ spawnedSessionId,
234
+ intendedSkills: extractIntendedSkills(args?.prompt),
235
+ }
236
+
237
+ pendingSpawns.push(record)
238
+
239
+ if (spawnedSessionId) {
240
+ pendingSpawnBySessionId.set(spawnedSessionId, record)
241
+ }
242
+ }
243
+
244
+ function matchPendingSpawn(sessionId) {
245
+ const now = nowMs()
246
+
247
+ // Fast path: direct session ID correlation from team_spawn output
248
+ if (sessionId && pendingSpawnBySessionId.has(sessionId)) {
249
+ const record = pendingSpawnBySessionId.get(sessionId)
250
+ pendingSpawnBySessionId.delete(sessionId)
251
+ const idx = pendingSpawns.indexOf(record)
252
+ if (idx !== -1) pendingSpawns.splice(idx, 1)
253
+ return record
254
+ }
255
+
256
+ // Fallback: time-window heuristic (drop expired first)
257
+ for (let i = pendingSpawns.length - 1; i >= 0; i--) {
258
+ if (now - pendingSpawns[i].at > SPAWN_MATCH_WINDOW_MS) {
259
+ pendingSpawnBySessionId.delete(pendingSpawns[i].spawnedSessionId)
260
+ pendingSpawns.splice(i, 1)
261
+ }
262
+ }
263
+ if (pendingSpawns.length === 0) return null
264
+ return pendingSpawns.shift()
265
+ }
266
+
267
+ // Maps ensemble tool name -> function that extracts log entry fields from args
268
+ const ENSEMBLE_TOOL_HANDLERS = {
269
+ team_create: (args) => ({ action: "team-created", team: args.name }),
270
+ team_spawn: (args) => ({ action: "teammate-spawned", name: args.name, agentType: args.agent }),
271
+ team_shutdown: (args) => ({ action: "teammate-shutdown", name: args.name }),
272
+ team_merge: (args) => ({ action: "teammate-merged", name: args.name }),
273
+ team_cleanup: () => ({ action: "team-cleanup" }),
274
+ team_status: () => ({ action: "team-status-checked" }),
275
+ team_results: (args) => ({ action: "team-results-read", from: args.from }),
276
+ team_message: (args) => ({ action: "team-message", to: args.to ?? "lead", preview: String(args.text ?? "").slice(0, 120) }),
277
+ team_broadcast: (args) => ({ action: "team-broadcast", preview: String(args.text ?? "").slice(0, 120) }),
278
+ team_tasks_add: (args) => ({ action: "tasks-added", count: Array.isArray(args.tasks) ? args.tasks.length : "?" }),
279
+ team_tasks_complete: (args) => ({ action: "task-completed", taskId: args.task_id }),
280
+ team_claim: (args) => ({ action: "task-claimed", taskId: args.task_id }),
281
+ }
282
+
283
+ export const SessionLogPlugin = async ({ client, directory }) => {
284
+ return {
285
+ event: async ({ event }) => {
286
+ try {
287
+ if (event?.type === "session.created") {
288
+ const sessionId = event.properties?.id ?? event.properties?.sessionID
289
+ if (!sessionId) return
290
+
291
+ const res = await client.session.get({ path: { id: sessionId } })
292
+ const session = res?.data
293
+ const fallbackAgent = resolveAgentName(session)
294
+ const model = resolveModel(session)
295
+ const spawnMatch = matchPendingSpawn(sessionId)
296
+
297
+ const state = {
298
+ agentName: spawnMatch?.member || fallbackAgent,
299
+ member: spawnMatch?.member || null,
300
+ agentType: spawnMatch?.agentType || null,
301
+ teamName: spawnMatch?.teamName || null,
302
+ model,
303
+ intendedSkills: spawnMatch?.intendedSkills || [],
304
+ editCount: 0,
305
+ skills: new Set(),
306
+ startedAtMs: nowMs(),
307
+ toolCalls: 0,
308
+ charIn: 0,
309
+ charOut: 0,
310
+ reportedInputTokens: 0,
311
+ reportedOutputTokens: 0,
312
+ reportedTotalTokens: 0,
313
+ // Track whether this session was matched to a spawn record
314
+ spawnMatched: !!spawnMatch,
315
+ }
316
+
317
+ sessionState.set(sessionId, state)
318
+
319
+ const startedEntry = {
320
+ ts: ts(),
321
+ agent: state.agentName,
322
+ member: state.member,
323
+ agentType: state.agentType,
324
+ team: state.teamName,
325
+ model: state.model,
326
+ action: "started",
327
+ sessionId,
328
+ }
329
+ appendEntry(directory, startedEntry)
330
+
331
+ // Emit explicit teammate-registered entry when a spawn is matched
332
+ if (spawnMatch) {
333
+ appendEntry(directory, {
334
+ ts: ts(),
335
+ agent: state.agentName,
336
+ member: state.member,
337
+ agentType: state.agentType,
338
+ team: state.teamName,
339
+ model: state.model,
340
+ intendedSkills: state.intendedSkills,
341
+ action: "teammate-registered",
342
+ sessionId,
343
+ correlationMethod: spawnMatch.spawnedSessionId === sessionId ? "direct" : "time-window",
344
+ })
345
+ } else {
346
+ // No spawn match, schedule an unmatched-session warning
347
+ const capturedSessionId = sessionId
348
+ setTimeout(() => {
349
+ const s = sessionState.get(capturedSessionId)
350
+ if (!s || s.spawnMatched) return
351
+ appendEntry(directory, {
352
+ ts: ts(),
353
+ agent: s.agentName,
354
+ member: s.member,
355
+ team: s.teamName,
356
+ model: s.model,
357
+ action: "unmatched-session",
358
+ sessionId: capturedSessionId,
359
+ warning: "Session started with no matching team_spawn record. Agent identity may be inaccurate.",
360
+ })
361
+ }, UNMATCHED_SESSION_WARN_MS)
362
+ }
363
+ }
364
+
365
+ if (event?.type === "file.edited") {
366
+ const sessionId = event.properties?.sessionID ?? event.properties?.id
367
+ if (!sessionId) return
368
+ const state = sessionState.get(sessionId)
369
+ if (state) state.editCount++
370
+ }
371
+
372
+ if (event?.type === "session.idle") {
373
+ const sessionId = event.properties?.id ?? event.properties?.sessionID
374
+ if (!sessionId) return
375
+ const state = sessionState.get(sessionId)
376
+ if (!state) return
377
+
378
+ const skills = Array.from(state.skills || []).sort()
379
+ const usage = usagePayload(state)
380
+ appendEntry(directory, {
381
+ ts: ts(),
382
+ agent: state.agentName,
383
+ member: state.member,
384
+ agentType: state.agentType,
385
+ team: state.teamName,
386
+ model: state.model,
387
+ action: "completed",
388
+ filesEdited: state.editCount,
389
+ skills,
390
+ intendedSkills: state.intendedSkills,
391
+ usage,
392
+ })
393
+
394
+ trackCompletedByTeam(buildCompletedSnapshot(state, sessionId))
395
+ sessionState.delete(sessionId)
396
+ }
397
+ } catch {
398
+ // ignore
399
+ }
400
+ },
401
+
402
+ "tool.execute.after": async (input, output) => {
403
+ try {
404
+ const sessionId = input?.sessionID ?? input?.session_id
405
+ if (!sessionId) return
406
+
407
+ const state = sessionState.get(sessionId)
408
+ if (!state) return
409
+
410
+ const tool = input?.tool
411
+ const args = input?.args ?? {}
412
+
413
+ state.toolCalls++
414
+ state.charIn += JSON.stringify(args).length
415
+ state.charOut += JSON.stringify(output ?? {}).length
416
+
417
+ const reportedIn = extractReportedTokens(input)
418
+ const reportedOut = extractReportedTokens(output)
419
+ if (reportedIn.found) {
420
+ state.reportedInputTokens += reportedIn.input
421
+ state.reportedOutputTokens += reportedIn.output
422
+ state.reportedTotalTokens += reportedIn.total
423
+ }
424
+ if (reportedOut.found) {
425
+ state.reportedInputTokens += reportedOut.input
426
+ state.reportedOutputTokens += reportedOut.output
427
+ state.reportedTotalTokens += reportedOut.total
428
+ }
429
+
430
+ if (DEBUG && !reportedIn.found && !reportedOut.found && tool !== "read") {
431
+ appendEntry(directory, {
432
+ ts: ts(),
433
+ agent: state.agentName,
434
+ action: "debug-no-token-metrics",
435
+ tool,
436
+ })
437
+ }
438
+
439
+ // Track skill loads via skill tool (primary)
440
+ if (tool === "skill") {
441
+ const skillName = input?.args?.name
442
+ const added = addSkillToState(state, skillName)
443
+ if (added) {
444
+ appendEntry(directory, {
445
+ ts: ts(),
446
+ agent: state.agentName,
447
+ member: state.member,
448
+ agentType: state.agentType,
449
+ team: state.teamName,
450
+ model: state.model,
451
+ action: "skill-loaded",
452
+ skill: skillName,
453
+ source: "skill-tool",
454
+ })
455
+ }
456
+ return
457
+ }
458
+
459
+ // Track skill loads via reading SKILL.md (fallback)
460
+ if (tool === "read") {
461
+ const filePath = input?.args?.filePath ?? ""
462
+ const match = filePath.match(/[/\\]skills[/\\]([^/\\]+)[/\\]SKILL\.md$/i)
463
+ if (match) {
464
+ const skillName = match[1]
465
+ const added = addSkillToState(state, skillName)
466
+ if (added) {
467
+ appendEntry(directory, {
468
+ ts: ts(),
469
+ agent: state.agentName,
470
+ member: state.member,
471
+ agentType: state.agentType,
472
+ team: state.teamName,
473
+ model: state.model,
474
+ action: "skill-loaded",
475
+ skill: skillName,
476
+ source: "read-skill-file",
477
+ })
478
+ }
479
+ }
480
+ return
481
+ }
482
+
483
+ // Track ensemble tool calls
484
+ const ensembleHandler = ENSEMBLE_TOOL_HANDLERS[tool]
485
+ if (!ensembleHandler) return
486
+
487
+ const entry = {
488
+ ts: ts(),
489
+ agent: state.agentName,
490
+ member: state.member,
491
+ agentType: state.agentType,
492
+ team: state.teamName,
493
+ model: state.model,
494
+ ...ensembleHandler(args),
495
+ }
496
+ appendEntry(directory, entry)
497
+
498
+ if (tool === "team_create") {
499
+ leadTeamBySession.set(sessionId, args?.name || null)
500
+ state.teamName = args?.name || state.teamName
501
+ }
502
+
503
+ if (tool === "team_spawn") {
504
+ enqueuePendingSpawn(sessionId, args, output)
505
+ }
506
+
507
+ if (tool === "team_cleanup") {
508
+ const teamName = state.teamName || leadTeamBySession.get(sessionId)
509
+ appendEntry(directory, {
510
+ ts: ts(),
511
+ agent: state.agentName,
512
+ model: state.model,
513
+ action: "team-skills-summary",
514
+ team: teamName || null,
515
+ byAgent: teamName ? buildTeamSkillsSummary(teamName) : {},
516
+ })
517
+ }
518
+ } catch {
519
+ // ignore
520
+ }
521
+ }
522
+ }
523
+ }