opencode-onboard 0.4.10 → 0.4.14

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
@@ -124,7 +124,7 @@ basic-engineer implementation worker, ability-driven
124
124
  ```
125
125
 
126
126
  `basic-engineer` behavior is composed by abilities, not hardcoded role silos.
127
- Project-specific specialization comes from user-created custom engineers via `/ob-create-engineer`. During `/opsx-apply`, the lead should inspect the engineers that actually exist in `.agents/agents/`, prefer matching custom engineers, and fall back to `basic-engineer` only when no specialist is a clear fit.
127
+ Project-specific specialization comes from user-created custom engineers via `/ob-create-engineer`. During `/opsx-apply`, the lead should inspect the engineers that actually exist in `.opencode/agents/`, prefer matching custom engineers, and fall back to `basic-engineer` only when no specialist is a clear fit.
128
128
 
129
129
  ### Skills, platform knowledge
130
130
 
@@ -191,7 +191,7 @@ devops-manager (load ob-global first)
191
191
 
192
192
  [confirm with user]
193
193
 
194
- basic-engineer + custom-engineer-* (parallel)
194
+ basic-engineer + *-engineer (parallel)
195
195
  claim tasks → load abilities → implement
196
196
 
197
197
  verify (tests/build/lint as needed)
@@ -1,8 +1,7 @@
1
1
  ---
2
2
  description: Basic Engineer Agent.
3
- mode: subagent
3
+ mode: primary
4
4
  color: #68A063
5
- temperature: 0.2
6
5
  permission:
7
6
  edit: allow
8
7
  bash: allow
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Process agent. Reads work items and user stories at pipeline start. Creates PRs, posts screenshots, responds to review comments at pipeline end. Bridges the work tracker and the repository. Platform knowledge comes from skills.
3
- mode: subagent
4
- color: primary
3
+ mode: primary
4
+ color: #690a69
5
5
  permission:
6
6
  edit: allow
7
7
  bash: allow
@@ -25,10 +25,10 @@ Skills are located in `.agents/skills/`. Load required skills explicitly from co
25
25
 
26
26
  Always load `@ob-global` FIRST before any other skill.
27
27
 
28
- Platform skill selection must follow onboarding platform choice from CLI step:
29
- <!-- OB-PLATFORM-SKILLS-START -->
30
- - Platform-specific skill instructions are injected during onboarding copy step.
31
- <!-- OB-PLATFORM-SKILLS-END -->
28
+ Platform skill selection must follow onboarding platform choice from CLI step:
29
+ <!-- OB-PLATFORM-SKILLS-START -->
30
+ - Platform-specific skill instructions are injected during onboarding copy step.
31
+ <!-- OB-PLATFORM-SKILLS-END -->
32
32
 
33
33
  1. If the spawn prompt lists specific skills to load, read those `SKILL.md` files FIRST before any implementation
34
34
  2. Additionally, identify the platform from URLs or context
@@ -39,16 +39,16 @@ Examples of intent → skill mapping:
39
39
  - "PR has comments" or "review feedback" → load platform pullrequest observer skill
40
40
  - URL-based platform inference is fallback-only when onboarding metadata is unavailable
41
41
 
42
- Rules:
43
- - Platform selected in onboarding metadata takes precedence over URL inference when both exist
44
- - Never interact with a platform without loading the matching skill first
45
- - Follow skill instructions exactly, do not partially apply them
46
- - If no skill exists for the platform, report it as a blocker rather than improvising
47
- - Skills listed in the spawn prompt are MANDATORY, not optional
48
-
49
- <!-- OB-PLATFORM-MODE-START -->
50
- This project uses platform-integrated workflow modes described below.
51
- <!-- OB-PLATFORM-MODE-END -->
42
+ Rules:
43
+ - Platform selected in onboarding metadata takes precedence over URL inference when both exist
44
+ - Never interact with a platform without loading the matching skill first
45
+ - Follow skill instructions exactly, do not partially apply them
46
+ - If no skill exists for the platform, report it as a blocker rather than improvising
47
+ - Skills listed in the spawn prompt are MANDATORY, not optional
48
+
49
+ <!-- OB-PLATFORM-MODE-START -->
50
+ This project uses platform-integrated workflow modes described below.
51
+ <!-- OB-PLATFORM-MODE-END -->
52
52
 
53
53
  ## Working Modes
54
54
 
@@ -65,11 +65,10 @@ This project uses platform-integrated workflow modes described below.
65
65
  3. Resolve platform from `.opencode/opencode-onboard.json` (`wizard.platform`) when available; fallback to URL inference only if missing/ambiguous
66
66
  4. Load the matching pullrequest skill for that resolved platform
67
67
  5. Capture screenshots of local running app if UI changes exist
68
- 6. Read `.agents/session-log.json` if it exists, parse the JSON array and include a "Session Activity" section in the PR description with agent names, task counts, and skills used
69
- 7. Commit and push the feature branch. If `## Source Roots` lists multiple roots, each root is a separate git repository, create and push the feature branch in EACH repo that has changes; never assume a single repo
70
- 8. Create the PR following the skill instructions (one PR per repo that has changes)
71
- 9. Post PR comment with screenshots and change summary
72
- 10. Report PR URL to the lead
68
+ 6. Commit and push the feature branch. If `## Source Roots` lists multiple roots, each root is a separate git repository, create and push the feature branch in EACH repo that has changes; never assume a single repo
69
+ 7. Create the PR following the skill instructions (one PR per repo that has changes)
70
+ 8. Post PR comment with screenshots and change summary
71
+ 9. Report PR URL to the lead
73
72
 
74
73
  ### Feedback Mode (PR review loop)
75
74
  1. Load `@ob-global` first
@@ -40,14 +40,13 @@ Example: `/ob-create-engineer frontend-engineer "A frontend engineer specialized
40
40
 
41
41
  4. **Create the agent file**
42
42
 
43
- Create `.agents/agents/<name>.md` with this structure:
43
+ Create `.opencode/agents/<name>.md` with this structure:
44
44
 
45
45
  ```markdown
46
46
  ---
47
47
  description: <description>
48
- mode: subagent
48
+ mode: primary
49
49
  color: <pick a unique hex color>
50
- temperature: 0.2
51
50
  permission:
52
51
  edit: allow
53
52
  bash: allow
@@ -65,17 +64,17 @@ Example: `/ob-create-engineer frontend-engineer "A frontend engineer specialized
65
64
  ## Workflow
66
65
 
67
66
  When spawned by the lead:
68
- 1. Call `team_tasks_list` immediately and identify your assigned task IDs.
69
- 2. Claim the first assigned task that is unblocked with `team_claim task_id:<id>`. If the first assigned task is blocked, claim the next assigned task whose dependencies are already `done`. Do not wait once you have an unblocked assigned task.
70
- 3. After claiming, load `@ob-global` first, then load mandatory ability `Guardrails`.
71
- 4. Load additional abilities from the `## Abilities` section as needed for the claimed task domain (for example: development, testing, infrastructure). Each ability can include one or more skills; load all relevant skills listed under each selected ability.
72
- 5. Send a short `team_message` to lead confirming the claimed task ID and loaded skills.
73
- 6. Implement the task following all loaded skill rules.
74
- 7. Call `team_tasks_complete task_id:<id>` after finishing that task.
75
- 8. Repeat until all currently assigned tasks are completed or blocked.
76
- 9. Message lead with results via `team_message`. Lead may assign more tasks, do NOT stop working or shut down until lead confirms no more tasks for you.
77
- 10. If lead sends new task IDs via `team_message`, treat them as new assignments and go back to step 1.
78
- ```
67
+ 1. Call `team_tasks_list` immediately and identify your assigned task IDs.
68
+ 2. Claim the first assigned task that is unblocked with `team_claim task_id:<id>`. If the first assigned task is blocked, claim the next assigned task whose dependencies are already `done`. Do not wait once you have an unblocked assigned task.
69
+ 3. After claiming, load `@ob-global` first, then load mandatory ability `Guardrails`.
70
+ 4. Load additional abilities from the `## Abilities` section as needed for the claimed task domain (for example: development, testing, infrastructure). Each ability can include one or more skills; load all relevant skills listed under each selected ability.
71
+ 5. Send a short `team_message` to lead confirming the claimed task ID and loaded skills.
72
+ 6. Implement the task following all loaded skill rules.
73
+ 7. Call `team_tasks_complete task_id:<id>` after finishing that task.
74
+ 8. Repeat until all currently assigned tasks are completed or blocked.
75
+ 9. Message lead with results via `team_message`. Lead may assign more tasks, do NOT stop working or shut down until lead confirms no more tasks for you.
76
+ 10. If lead sends new task IDs via `team_message`, treat them as new assignments and go back to step 1.
77
+ ```
79
78
 
80
79
  Place the installed skills under the most relevant ability category:
81
80
  - **Development** — language frameworks, UI libraries, application code skills
@@ -88,13 +87,13 @@ Example: `/ob-create-engineer frontend-engineer "A frontend engineer specialized
88
87
 
89
88
  Add the new agent to the agents table in AGENTS.md:
90
89
  ```
91
- | `<name>` | .agents/agents/<name>.md | <short role description> |
90
+ | `<name>` | .opencode/agents/<name>.md | <short role description> |
92
91
  ```
93
92
 
94
93
  6. **Show summary**
95
94
 
96
95
  Report:
97
- - Agent file created at `.agents/agents/custom-engineer-<name>.md`
96
+ - Agent file created at `.opencode/agents/<name>-engineer.md`
98
97
  - Skills installed (list each with source)
99
98
  - How to use: "This agent will be spawned by the lead during `/opsx-apply` for tasks matching its specialty."
100
99
 
@@ -117,15 +117,15 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
117
117
  - Only shut down an agent when the board has no more tasks for its domain.
118
118
 
119
119
  Before spawning:
120
- - scan `.agents/agents/` and list the engineers that actually exist in this project
120
+ - scan `.opencode/agents/` and list the engineers that actually exist in this project
121
121
  - exclude `devops-manager` from implementation selection
122
122
  - read each engineer's description and abilities
123
123
  - prefer the most specialized custom engineer whose description and abilities match the task
124
124
  - use `basic-engineer` only when no custom engineer is a clear fit or as a recovery fallback
125
- - never spawn an engineer name that is not present in `.agents/agents/`
125
+ - never spawn an engineer name that is not present in `.opencode/agents/`
126
126
 
127
127
  REQUIRED assignment algorithm (do not skip):
128
- 1. Build candidate list from `.agents/agents/*.md` excluding `devops-manager`.
128
+ 1. Build candidate list from `.opencode/agents/*.md` excluding `devops-manager`.
129
129
  2. Classify each task by domain using task text (api/backend, ui/frontend, infra/devops, testing/qa).
130
130
  3. For each task, score every candidate agent:
131
131
  - +3 if agent description explicitly matches domain
@@ -137,7 +137,7 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
137
137
 
138
138
  HARD RULES:
139
139
  - NEVER assign a task to `basic-engineer` if a specialized agent has higher score.
140
- - NEVER skip agent discovery from `.agents/agents/*.md`.
140
+ - NEVER skip agent discovery from `.opencode/agents/*.md`.
141
141
  - ALWAYS include assignment rationale only when it changes task routing or is needed to justify using `basic-engineer`.
142
142
 
143
143
  Skill loading is worker-driven:
@@ -217,7 +217,7 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
217
217
  - `team_merge member:"<name>"`
218
218
  - If team_merge blocks on local changes: `git stash`, retry merge, `git stash pop`.
219
219
  6. **If ALL agents are shut down and tasks remain unassigned** (new domain, dependencies unblocked):
220
- - Discover the remaining matching engineers from `.agents/agents/` and spawn a new wave (back to step 6d).
220
+ - Discover the remaining matching engineers from `.opencode/agents/` and spawn a new wave (back to step 6d).
221
221
  7. **If ALL tasks are done:** proceed to step 7.
222
222
 
223
223
  **ZERO PENDING TASKS GUARANTEE:** Before proceeding to step 7, call `team_tasks_list` and verify EVERY task is either `done` or `blocked`. If any task is `pending` and unassigned, assign it to an agent or spawn a new one. Never leave pending tasks orphaned.
@@ -264,7 +264,7 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
264
264
  - ALWAYS call team_tasks_list after each agent reports done to check for remaining unassigned tasks
265
265
  - ALWAYS repeat the same literal task IDs in any task assignment message, never send a generic "claim your first task" without the actual IDs
266
266
  - NEVER send a start message that omits task IDs; if a task ID is missing from the start message, the agent cannot claim
267
- - ALWAYS discover engineers from `.agents/agents/` and prefer matching custom engineers over `basic-engineer`
267
+ - ALWAYS discover engineers from `.opencode/agents/` and prefer matching custom engineers over `basic-engineer`
268
268
  - ALWAYS spawn agents sequentially (wait for each team_spawn result before the next), then send start messages to all of them together
269
269
  - ALWAYS set `claim_task` for the first unblocked task in each initial batch and instruct agents to claim before any other work
270
270
  - ALWAYS shut down + merge agents only when no more tasks remain for their domain
@@ -123,15 +123,15 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
123
123
  - Only shut down an agent when the board has no more tasks for its domain.
124
124
 
125
125
  Before spawning:
126
- - scan `.agents/agents/` and list the engineers that actually exist in this project
126
+ - scan `.opencode/agents/` and list the engineers that actually exist in this project
127
127
  - exclude `devops-manager` from implementation selection
128
128
  - read each engineer's description and abilities
129
129
  - prefer the most specialized custom engineer whose description and abilities match the task
130
130
  - use `basic-engineer` only when no custom engineer is a clear fit or as a recovery fallback
131
- - never spawn an engineer name that is not present in `.agents/agents/`
131
+ - never spawn an engineer name that is not present in `.opencode/agents/`
132
132
 
133
133
  REQUIRED assignment algorithm (do not skip):
134
- 1. Build candidate list from `.agents/agents/*.md` excluding `devops-manager`.
134
+ 1. Build candidate list from `.opencode/agents/*.md` excluding `devops-manager`.
135
135
  2. Classify each task by domain using task text (api/backend, ui/frontend, infra/devops, testing/qa).
136
136
  3. For each task, score every candidate agent:
137
137
  - +3 if agent description explicitly matches domain
@@ -143,7 +143,7 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
143
143
 
144
144
  HARD RULES:
145
145
  - NEVER assign a task to `basic-engineer` if a specialized agent has higher score.
146
- - NEVER skip agent discovery from `.agents/agents/*.md`.
146
+ - NEVER skip agent discovery from `.opencode/agents/*.md`.
147
147
  - ALWAYS include assignment rationale only when it changes task routing or is needed to justify using `basic-engineer`.
148
148
 
149
149
  Skill loading is worker-driven:
@@ -223,7 +223,7 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
223
223
  - `team_merge member:"<name>"`
224
224
  - If team_merge blocks on local changes: `git stash`, retry merge, `git stash pop`.
225
225
  6. **If ALL agents are shut down and tasks remain unassigned** (new domain, dependencies unblocked):
226
- - Discover the remaining matching engineers from `.agents/agents/` and spawn a new wave (back to step 6d).
226
+ - Discover the remaining matching engineers from `.opencode/agents/` and spawn a new wave (back to step 6d).
227
227
  7. **If ALL tasks are done:** proceed to step 7.
228
228
 
229
229
  **ZERO PENDING TASKS GUARANTEE:** Before proceeding to step 7, call `team_tasks_list` and verify EVERY task is either `done` or `blocked`. If any task is `pending` and unassigned, assign it to an agent or spawn a new one. Never leave pending tasks orphaned.
@@ -270,7 +270,7 @@ Implement tasks from an OpenSpec change using the ensemble agent team.
270
270
  - NEVER send a start message that omits task IDs; if a task ID is missing from the start message, the agent cannot claim
271
271
  - NEVER edit files between team_spawn and team_merge, team_merge blocks on overlapping local changes
272
272
  - ALWAYS add every task to the board before spawning, using multiple `team_tasks_add` calls when dependency wiring requires it
273
- - ALWAYS discover engineers from `.agents/agents/` and prefer matching custom engineers over `basic-engineer`
273
+ - ALWAYS discover engineers from `.opencode/agents/` and prefer matching custom engineers over `basic-engineer`
274
274
  - ALWAYS spawn agents sequentially (wait for each team_spawn result before the next), then send start messages to all of them together
275
275
  - ALWAYS set `claim_task` for the first unblocked task in each initial batch and instruct agents to claim before any other work
276
276
  - ALWAYS shut down + merge agents only when no more tasks remain for their domain
package/content/AGENTS.md CHANGED
@@ -194,11 +194,11 @@ Trigger patterns, I recognize ALL of these, exact wording does not matter:
194
194
  ## Engineer Selection
195
195
 
196
196
  Before spawning implementation workers:
197
- - Inspect `.agents/agents/*.md` and build the list of engineers that actually exist in this project.
197
+ - Inspect `.opencode/agents/*.md` and build the list of engineers that actually exist in this project.
198
198
  - Exclude `devops-manager` from implementation selection.
199
199
  - Prefer the most specialized custom engineer whose description and abilities clearly match the task domain.
200
200
  - Use `basic-engineer` only when no custom engineer is a clear fit or as a recovery fallback.
201
- - Never spawn engineer names that are not present in `.agents/agents/`.
201
+ - Never spawn engineer names that are not present in `.opencode/agents/`.
202
202
  - When multiple engineers could fit, choose the narrower specialist before the generalist.
203
203
 
204
204
  ## Multi-Agent Execution, opencode-ensemble
@@ -248,7 +248,7 @@ devops-manager (lead mode)
248
248
 
249
249
  [confirm with user]
250
250
 
251
- basic-engineer + custom-engineer-* (parallel as needed)
251
+ basic-engineer + *-engineer (parallel as needed)
252
252
  → claim tasks + load abilities + implement
253
253
 
254
254
  devops-manager (ship mode)
@@ -273,7 +273,7 @@ devops-manager (ship mode)
273
273
  - Step 5b: classify cost tier, announce scope, ask user to confirm if ≥4 tasks.
274
274
  - Lead adds all tasks to board.
275
275
  - When dependencies exist, lead uses multiple `team_tasks_add` waves so later tasks can reference real task IDs returned by earlier waves.
276
- - Lead discovers available engineers from `.agents/agents/*.md`, prefers matching custom engineers, then spawns engineers with initial batch of up to 3 tasks each (rolling batch model).
276
+ - Lead discovers available engineers from `.opencode/agents/*.md`, prefers matching custom engineers, then spawns engineers with initial batch of up to 3 tasks each (rolling batch model).
277
277
  - Each engineer claims tasks, implements, completes, messages lead.
278
278
  - Lead assigns next batch (up to 3) to agents that report done. Repeat until board empty.
279
279
  - Lead merges each engineer branch after shutdown, then marks tasks done in tasks.md.
@@ -319,8 +319,8 @@ All agents are universal, no project-specific knowledge. Platform and tech knowl
319
319
 
320
320
  | Agent | File | Role |
321
321
  |-------|------|------|
322
- | `devops-manager` | .agents/agents/devops-manager.md | Reads work items, creates PRs, handles review feedback |
323
- | `basic-engineer` | .agents/agents/basic-engineer.md | Generic implementation worker using ability-loaded skills |
322
+ | `devops-manager` | .opencode/agents/devops-manager.md | Reads work items, creates PRs, handles review feedback |
323
+ | `basic-engineer` | .opencode/agents/basic-engineer.md | Generic implementation worker using ability-loaded skills |
324
324
 
325
325
  User can add more custom engineer agents and run them in parallel. Keep behavior ability-driven via skill mappings. Custom engineers are the primary specialization mechanism; `basic-engineer` is the general fallback when no custom engineer is a clear fit.
326
326
 
@@ -377,7 +377,7 @@ When `## Source Roots` lists multiple roots, each root is an independent git rep
377
377
  │ ├── agents/ # Agent definitions (universal, no project knowledge)
378
378
  │ │ ├── devops-manager.md
379
379
  │ │ ├── basic-engineer.md
380
- │ │ └── custom-engineer-*.md # optional, user-defined workers
380
+ │ │ └── *-engineer.md # optional, user-defined workers
381
381
  │ └── skills/ # Skills (platform/tech specific knowledge)
382
382
  │ ├── ob-global/ ← baseline skill, load first
383
383
  │ ├── ob-default/ ← fallback skill
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-onboard",
3
- "version": "0.4.10",
3
+ "version": "0.4.14",
4
4
  "description": "Prepare any brownfield codebase for AI agent workflows using OpenCode, OpenSpec, and ensemble orchestration.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -123,7 +123,7 @@ function buildPipelineSection(platform) {
123
123
  ' ↓',
124
124
  ' [confirm with user when scope needs it]',
125
125
  ' ↓',
126
- 'basic-engineer + custom-engineer-* (parallel as needed)',
126
+ 'basic-engineer + *-engineer (parallel as needed)',
127
127
  ' → claim tasks + load abilities + implement',
128
128
  ' ↓',
129
129
  'main session',
@@ -148,7 +148,7 @@ function buildPipelineSection(platform) {
148
148
  ' - Step 5b: classify cost tier, announce scope, ask user to confirm if ≥4 tasks.',
149
149
  ' - Lead adds all tasks to board.',
150
150
  ' - When dependencies exist, lead uses multiple `team_tasks_add` waves so later tasks can reference real task IDs returned by earlier waves.',
151
- ' - Lead discovers available engineers from `.agents/agents/*.md`, prefers matching custom engineers, then spawns engineers with initial batch of up to 3 tasks each (rolling batch model).',
151
+ ' - Lead discovers available engineers from `.opencode/agents/*.md`, prefers matching custom engineers, then spawns engineers with initial batch of up to 3 tasks each (rolling batch model).',
152
152
  ' - Each engineer claims tasks, implements, completes, messages lead.',
153
153
  ' - Lead assigns next batch (up to 3) to agents that report done. Repeat until board empty.',
154
154
  ' - Lead merges each engineer branch after shutdown, then marks tasks done in tasks.md.',
@@ -177,7 +177,7 @@ function buildPipelineSection(platform) {
177
177
  ' ↓',
178
178
  ' [confirm with user]',
179
179
  ' ↓',
180
- 'basic-engineer + custom-engineer-* (parallel as needed)',
180
+ 'basic-engineer + *-engineer (parallel as needed)',
181
181
  ' → claim tasks + load abilities + implement',
182
182
  ' ↓',
183
183
  'devops-manager (ship mode)',
@@ -202,7 +202,7 @@ function buildPipelineSection(platform) {
202
202
  ' - Step 5b: classify cost tier, announce scope, ask user to confirm if ≥4 tasks.',
203
203
  ' - Lead adds all tasks to board.',
204
204
  ' - When dependencies exist, lead uses multiple `team_tasks_add` waves so later tasks can reference real task IDs returned by earlier waves.',
205
- ' - Lead discovers available engineers from `.agents/agents/*.md`, prefers matching custom engineers, then spawns engineers with initial batch of up to 3 tasks each (rolling batch model).',
205
+ ' - Lead discovers available engineers from `.opencode/agents/*.md`, prefers matching custom engineers, then spawns engineers with initial batch of up to 3 tasks each (rolling batch model).',
206
206
  ' - Each engineer claims tasks, implements, completes, messages lead.',
207
207
  ' - Lead assigns next batch (up to 3) to agents that report done. Repeat until board empty.',
208
208
  ' - Lead merges each engineer branch after shutdown, then marks tasks done in tasks.md.',
@@ -319,7 +319,7 @@ export async function patchAgentGuidance(platform, cwd = process.cwd()) {
319
319
  }
320
320
 
321
321
  export async function patchDevopsManagerMd(platform, cwd = process.cwd()) {
322
- const devopsPath = path.join(cwd, '.agents', 'agents', 'devops-manager.md')
322
+ const devopsPath = path.join(cwd, '.opencode', 'agents', 'devops-manager.md')
323
323
  if (!await fse.pathExists(devopsPath)) return
324
324
 
325
325
  const resolved = platform === 'azure' ? 'azure' : platform === 'none' ? 'none' : 'github'
@@ -30,8 +30,8 @@ describe('platform patching', () => {
30
30
  })
31
31
 
32
32
  it('patches devops-manager for none mode without platform skills', async () => {
33
- const source = path.join(process.cwd(), 'content', '.agents', 'agents', 'devops-manager.md')
34
- const dest = path.join(tmpDir, '.agents', 'agents', 'devops-manager.md')
33
+ const source = path.join(process.cwd(), 'content', '.opencode', 'agents', 'devops-manager.md')
34
+ const dest = path.join(tmpDir, '.opencode', 'agents', 'devops-manager.md')
35
35
  await fse.ensureDir(path.dirname(dest))
36
36
  await fse.copyFile(source, dest)
37
37
 
@@ -12,7 +12,7 @@ export async function chooseModels() {
12
12
 
13
13
  if (!rawModels) {
14
14
  warn('Could not fetch models (offline and no cache). Skipping model selection.');
15
- warn('Set models later in .agents/agents/<name>.md and .opencode/opencode.json');
15
+ warn('Set models later in .opencode/agents/<name>.md and .opencode/opencode.json');
16
16
  return;
17
17
  }
18
18
 
@@ -26,7 +26,7 @@ export async function chooseModels() {
26
26
  success(`${models.length} models available`);
27
27
  console.log();
28
28
  info('Cost indicators: [$] cheap [$$] mid [$$$] expensive');
29
- info('Type to search. Change selections later in .agents/agents/ and .opencode/opencode.json');
29
+ info('Type to search. Change selections later in .opencode/agents/ and .opencode/opencode.json');
30
30
  console.log();
31
31
 
32
32
  for (const line of modelsPreset.roles.plan.info) info(line);
@@ -41,12 +41,12 @@ export async function chooseModels() {
41
41
  const fastModel = await pickModel(modelsPreset.roles.fast.prompt, models);
42
42
  console.log();
43
43
 
44
- const agentsDir = path.join(process.cwd(), '.agents', 'agents');
44
+ const agentsDir = path.join(process.cwd(), '.opencode', 'agents');
45
45
  await writeModelsToConfigs({ planModel, buildModel, fastModel, agentsDir, preset: modelsPreset });
46
46
 
47
47
  console.log();
48
48
  warn('Make sure you have API access to the selected models.');
49
- warn('Change them anytime in .agents/agents/<name>.md and .opencode/opencode.json');
49
+ warn('Change them anytime in .opencode/agents/<name>.md and .opencode/opencode.json');
50
50
 
51
51
  return { planModel, buildModel, fastModel };
52
52
  }
@@ -51,12 +51,12 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
51
51
  - Only shut down an agent when the board has no more tasks for its domain.
52
52
 
53
53
  Before spawning:
54
- - scan \`.agents/agents/\` and list the engineers that actually exist in this project
54
+ - scan \`.opencode/agents/\` and list the engineers that actually exist in this project
55
55
  - exclude \`devops-manager\` from implementation selection
56
56
  - read each engineer's description and abilities
57
57
  - prefer the most specialized custom engineer whose description and abilities match the task
58
58
  - use \`basic-engineer\` only when no custom engineer is a clear fit or as a recovery fallback
59
- - never spawn an engineer name that is not present in \`.agents/agents/\`
59
+ - never spawn an engineer name that is not present in \`.opencode/agents/\`
60
60
 
61
61
  Each \`team_spawn\` MUST include the agent field (required, causes NOT NULL error if omitted).
62
62
 
@@ -131,7 +131,7 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
131
131
  - \`team_merge member:"<name>"\`
132
132
  - If team_merge blocks on local changes: \`git stash\`, retry merge, \`git stash pop\`.
133
133
  6. **If ALL agents are shut down and tasks remain unassigned** (new domain, dependencies unblocked):
134
- - Discover the remaining matching engineers from \`.agents/agents/\` and spawn a new wave (back to step 6d).
134
+ - Discover the remaining matching engineers from \`.opencode/agents/\` and spawn a new wave (back to step 6d).
135
135
  7. **If ALL tasks are done:** proceed to step 7.
136
136
  If a teammate reports rate-limit/quota/token exhaustion, immediately shutdown that teammate and respawn with an available model.
137
137
 
@@ -176,7 +176,7 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
176
176
  - NEVER send a start message that omits task IDs; if a task ID is missing from the start message, the agent cannot claim
177
177
  - NEVER edit files between team_spawn and team_merge, team_merge blocks on overlapping local changes
178
178
  - ALWAYS add every task to the board before spawning, using multiple \`team_tasks_add\` calls when dependency wiring requires it
179
- - ALWAYS discover engineers from \`.agents/agents/\` and prefer matching custom engineers over \`basic-engineer\`
179
+ - ALWAYS discover engineers from \`.opencode/agents/\` and prefer matching custom engineers over \`basic-engineer\`
180
180
  - ALWAYS spawn agents sequentially (wait for each team_spawn result before the next), then send start messages to all of them together
181
181
  - ALWAYS set \`claim_task\` for the first unblocked task in each initial batch and instruct agents to claim before any other work
182
182
  - ALWAYS shut down + merge agents only when no more tasks remain for their domain
@@ -92,7 +92,7 @@ describe('ENSEMBLE_SECTION dependency guidance', () => {
92
92
  })
93
93
 
94
94
  it('prefers discovered custom engineers over hardcoded role inventory', () => {
95
- expect(ENSEMBLE_SECTION).toContain('scan `.agents/agents/` and list the engineers that actually exist in this project')
95
+ expect(ENSEMBLE_SECTION).toContain('scan \`.opencode/agents/\` and list the engineers that actually exist in this project')
96
96
  expect(ENSEMBLE_SECTION).toContain('prefer the most specialized custom engineer')
97
97
  expect(ENSEMBLE_SECTION).not.toContain('front-engineer: UI, components, framework skills')
98
98
  expect(ENSEMBLE_SECTION).not.toContain('team_spawn name:"front" agent:"front-engineer"')
@@ -43,6 +43,11 @@ export async function fixCodegraphConfig() {
43
43
 
44
44
  const rogueMcp = rogueContent.mcpServers || rogueContent.mcp
45
45
  if (rogueMcp) {
46
+ for (const entry of Object.values(rogueMcp)) {
47
+ if (Array.isArray(entry.command) && entry.command[0] === 'codegraph') {
48
+ entry.command = ['npx', '@colbymchenry/codegraph', ...entry.command.slice(1)]
49
+ }
50
+ }
46
51
  correctContent.mcp = { ...(correctContent.mcp || {}), ...rogueMcp }
47
52
  }
48
53
 
@@ -64,7 +69,7 @@ export async function installCodegraph(options = {}) {
64
69
  try {
65
70
  const installResult = await execa(
66
71
  'npx',
67
- ['@colbymchenry/codegraph', 'install', '--target=opencode', `--location=${location}`, '--yes'],
72
+ ['--yes', '@colbymchenry/codegraph', 'install', '--target=opencode', `--location=${location}`, '--yes'],
68
73
  {
69
74
  cwd: process.cwd(),
70
75
  reject: false,
@@ -93,7 +98,7 @@ export async function installCodegraph(options = {}) {
93
98
  loading('initializing codegraph project index...')
94
99
 
95
100
  try {
96
- const initResult = await execa('npx', ['codegraph', 'init'], {
101
+ const initResult = await execa('npx', ['@colbymchenry/codegraph', 'init', '-i'], {
97
102
  cwd: process.cwd(),
98
103
  reject: false,
99
104
  stdio: 'pipe',
@@ -38,7 +38,7 @@ describe('fixCodegraphConfig()', () => {
38
38
  it('merges mcpServers from opencode.jsonc into .opencode/opencode.json as mcp', async () => {
39
39
  const rogueContent = {
40
40
  mcpServers: {
41
- codegraph: { command: 'codegraph', args: ['mcp'] }
41
+ codegraph: { command: ['codegraph', 'serve', '--mcp'] }
42
42
  }
43
43
  }
44
44
  fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), JSON.stringify(rogueContent))
@@ -55,14 +55,14 @@ describe('fixCodegraphConfig()', () => {
55
55
  expect(result).toBe(true)
56
56
  expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
57
57
  const readResult = await fse.readJson(path.join(opencodeDir, 'opencode.json'))
58
- expect(readResult.mcp.codegraph).toEqual({ command: 'codegraph', args: ['mcp'] })
58
+ expect(readResult.mcp.codegraph).toEqual({ command: ['npx', '@colbymchenry/codegraph', 'serve', '--mcp'] })
59
59
  expect(readResult.plugin).toEqual(["opencode-plugin-openspec@latest"])
60
60
  })
61
61
 
62
62
  it('handles rogue file with mcp key directly', async () => {
63
63
  const rogueContent = {
64
64
  mcp: {
65
- codegraph: { command: 'codegraph', args: ['mcp'] }
65
+ codegraph: { command: ['codegraph', 'serve', '--mcp'] }
66
66
  }
67
67
  }
68
68
  fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), JSON.stringify(rogueContent))
@@ -74,14 +74,14 @@ describe('fixCodegraphConfig()', () => {
74
74
  expect(result).toBe(true)
75
75
  expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
76
76
  const readResult = await fse.readJson(path.join(tmpDir, '.opencode', 'opencode.json'))
77
- expect(readResult.mcp.codegraph.command).toBe('codegraph')
77
+ expect(readResult.mcp.codegraph.command).toEqual(['npx', '@colbymchenry/codegraph', 'serve', '--mcp'])
78
78
  })
79
79
 
80
80
  it('handles JSONC with comments', async () => {
81
81
  const rogueRaw = `{
82
82
  // This is a comment
83
83
  "mcpServers": {
84
- "codegraph": { "command": "codegraph", "args": ["mcp"] }
84
+ "codegraph": { "command": ["codegraph", "serve", "--mcp"] }
85
85
  }
86
86
  }`
87
87
  fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), rogueRaw)
@@ -95,14 +95,14 @@ describe('fixCodegraphConfig()', () => {
95
95
  expect(result).toBe(true)
96
96
  expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
97
97
  const readResult = await fse.readJson(path.join(opencodeDir, 'opencode.json'))
98
- expect(readResult.mcp.codegraph.command).toBe('codegraph')
98
+ expect(readResult.mcp.codegraph.command).toEqual(['npx', '@colbymchenry/codegraph', 'serve', '--mcp'])
99
99
  })
100
100
 
101
101
  it('handles JSONC with URLs containing //', async () => {
102
102
  const rogueRaw = `{
103
103
  "url": "https://example.com/path",
104
104
  "mcpServers": {
105
- "codegraph": { "command": "codegraph", "args": ["mcp"] }
105
+ "codegraph": { "command": ["codegraph", "serve", "--mcp"] }
106
106
  }
107
107
  }`
108
108
  fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), rogueRaw)
@@ -118,7 +118,7 @@ describe('fixCodegraphConfig()', () => {
118
118
  expect(result).toBe(true)
119
119
  expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
120
120
  const readResult = await fse.readJson(path.join(opencodeDir, 'opencode.json'))
121
- expect(readResult.mcp.codegraph.command).toBe('codegraph')
121
+ expect(readResult.mcp.codegraph.command).toEqual(['npx', '@colbymchenry/codegraph', 'serve', '--mcp'])
122
122
  })
123
123
 
124
124
  it('removes unparseable opencode.jsonc, warns, and returns false', async () => {
@@ -134,7 +134,7 @@ describe('fixCodegraphConfig()', () => {
134
134
  it('creates .opencode/opencode.json if it does not exist', async () => {
135
135
  const rogueContent = {
136
136
  mcpServers: {
137
- codegraph: { command: 'codegraph', args: ['mcp'] }
137
+ codegraph: { command: ['codegraph', 'serve', '--mcp'] }
138
138
  }
139
139
  }
140
140
  fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), JSON.stringify(rogueContent))
@@ -143,7 +143,24 @@ describe('fixCodegraphConfig()', () => {
143
143
 
144
144
  expect(result).toBe(true)
145
145
  const readResult = await fse.readJson(path.join(tmpDir, '.opencode', 'opencode.json'))
146
- expect(readResult.mcp.codegraph.command).toBe('codegraph')
146
+ expect(readResult.mcp.codegraph.command).toEqual(['npx', '@colbymchenry/codegraph', 'serve', '--mcp'])
147
147
  expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
148
148
  })
149
+
150
+ it('does not prepend npx to non-codegraph commands', async () => {
151
+ const rogueContent = {
152
+ mcpServers: {
153
+ myMcp: { command: ['my-own-mcp', 'serve'] }
154
+ }
155
+ }
156
+ fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), JSON.stringify(rogueContent))
157
+ fs.mkdirSync(path.join(tmpDir, '.opencode'), { recursive: true })
158
+ fs.writeFileSync(path.join(tmpDir, '.opencode', 'opencode.json'), '{}')
159
+
160
+ const result = await fixCodegraphConfig()
161
+
162
+ expect(result).toBe(true)
163
+ const readResult = await fse.readJson(path.join(tmpDir, '.opencode', 'opencode.json'))
164
+ expect(readResult.mcp.myMcp.command).toEqual(['my-own-mcp', 'serve'])
165
+ })
149
166
  })
@@ -1,41 +0,0 @@
1
- [
2
- {
3
- "ts": "2026-05-04T21:04:09.899Z",
4
- "agent": "lead",
5
- "member": null,
6
- "agentType": null,
7
- "team": null,
8
- "action": "started",
9
- "sessionId": "ses_20b31c36cffeBfsYI2hJ1BNcM5"
10
- },
11
- {
12
- "ts": "2026-05-04T21:04:26.180Z",
13
- "agent": "lead",
14
- "member": null,
15
- "agentType": null,
16
- "team": null,
17
- "action": "skill-loaded",
18
- "skill": "ob-userstory-gh",
19
- "source": "read-skill-file"
20
- },
21
- {
22
- "ts": "2026-05-04T21:04:47.306Z",
23
- "agent": "lead",
24
- "member": null,
25
- "agentType": null,
26
- "team": null,
27
- "action": "completed",
28
- "filesEdited": 0,
29
- "skills": [
30
- "ob-userstory-gh"
31
- ],
32
- "usage": {
33
- "inputTokensReported": null,
34
- "outputTokensReported": null,
35
- "totalTokensReported": null,
36
- "tokenEstimateLow": 3469,
37
- "tokenEstimateHigh": 6940,
38
- "method": "heuristic"
39
- }
40
- }
41
- ]
@@ -1,523 +0,0 @@
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
- }