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 +2 -2
- package/content/{.agents → .opencode}/agents/basic-engineer.md +1 -2
- package/content/{.agents → .opencode}/agents/devops-manager.md +20 -21
- package/content/.opencode/commands/ob-create-engineer.md +15 -16
- package/content/.opencode/commands/opsx-apply.md +6 -6
- package/content/.opencode/skills/openspec-apply-change/SKILL.md +6 -6
- package/content/AGENTS.md +7 -7
- package/package.json +1 -1
- package/src/steps/copy/agents.js +5 -5
- package/src/steps/copy/agents.test.js +2 -2
- package/src/steps/models/index.js +4 -4
- package/src/steps/openspec/ensemble.js +4 -4
- package/src/steps/openspec/ensemble.test.js +1 -1
- package/src/steps/optimization/codegraph.js +7 -2
- package/src/steps/optimization/codegraph.test.js +27 -10
- package/content/.agents/session-log.json +0 -41
- package/content/.opencode/plugins/session-log.js +0 -523
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 `.
|
|
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 +
|
|
194
|
+
basic-engineer + *-engineer (parallel)
|
|
195
195
|
claim tasks → load abilities → implement
|
|
196
196
|
↓
|
|
197
197
|
verify (tests/build/lint as needed)
|
|
@@ -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:
|
|
4
|
-
color:
|
|
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.
|
|
69
|
-
7.
|
|
70
|
-
8.
|
|
71
|
-
9.
|
|
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
|
-
|
|
43
|
+
Create `.opencode/agents/<name>.md` with this structure:
|
|
44
44
|
|
|
45
45
|
```markdown
|
|
46
46
|
---
|
|
47
47
|
description: <description>
|
|
48
|
-
mode:
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
90
|
+
| `<name>` | .opencode/agents/<name>.md | <short role description> |
|
|
92
91
|
```
|
|
93
92
|
|
|
94
93
|
6. **Show summary**
|
|
95
94
|
|
|
96
95
|
Report:
|
|
97
|
-
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 +
|
|
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 `.
|
|
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` | .
|
|
323
|
-
| `basic-engineer` | .
|
|
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
|
-
│ │ └──
|
|
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
package/src/steps/copy/agents.js
CHANGED
|
@@ -123,7 +123,7 @@ function buildPipelineSection(platform) {
|
|
|
123
123
|
' ↓',
|
|
124
124
|
' [confirm with user when scope needs it]',
|
|
125
125
|
' ↓',
|
|
126
|
-
'basic-engineer +
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
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, '.
|
|
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', '.
|
|
34
|
-
const dest = path.join(tmpDir, '.
|
|
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 .
|
|
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 .
|
|
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(), '.
|
|
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 .
|
|
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 \`.
|
|
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 \`.
|
|
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 \`.
|
|
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 \`.
|
|
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
|
|
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',
|
|
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',
|
|
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',
|
|
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).
|
|
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", "
|
|
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).
|
|
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", "
|
|
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).
|
|
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',
|
|
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).
|
|
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
|
-
}
|