@doingdev/opencode-claude-manager-plugin 0.1.60 → 0.1.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +4 -3
  2. package/dist/claude/claude-agent-sdk-adapter.d.ts +3 -1
  3. package/dist/claude/claude-agent-sdk-adapter.js +57 -6
  4. package/dist/manager/team-orchestrator.js +11 -4
  5. package/dist/plugin/agents/browser-qa.js +4 -0
  6. package/dist/plugin/agents/common.d.ts +2 -2
  7. package/dist/plugin/agents/common.js +0 -2
  8. package/dist/plugin/agents/team-planner.js +1 -1
  9. package/dist/plugin/claude-manager.plugin.js +14 -43
  10. package/dist/plugin/service-factory.d.ts +1 -0
  11. package/dist/plugin/service-factory.js +3 -1
  12. package/dist/prompts/registry.js +30 -30
  13. package/dist/src/claude/claude-agent-sdk-adapter.d.ts +3 -1
  14. package/dist/src/claude/claude-agent-sdk-adapter.js +57 -6
  15. package/dist/src/manager/team-orchestrator.js +11 -4
  16. package/dist/src/plugin/agents/browser-qa.js +4 -0
  17. package/dist/src/plugin/agents/common.d.ts +2 -2
  18. package/dist/src/plugin/agents/common.js +0 -2
  19. package/dist/src/plugin/agents/team-planner.js +1 -1
  20. package/dist/src/plugin/claude-manager.plugin.js +14 -43
  21. package/dist/src/plugin/service-factory.d.ts +1 -0
  22. package/dist/src/plugin/service-factory.js +3 -1
  23. package/dist/src/prompts/registry.js +30 -30
  24. package/dist/src/types/contracts.d.ts +10 -3
  25. package/dist/src/util/fs-helpers.d.ts +6 -0
  26. package/dist/src/util/fs-helpers.js +11 -0
  27. package/dist/test/claude-agent-sdk-adapter.test.js +157 -1
  28. package/dist/test/claude-manager.plugin.test.js +23 -150
  29. package/dist/test/fs-helpers.test.d.ts +1 -0
  30. package/dist/test/fs-helpers.test.js +56 -0
  31. package/dist/test/prompt-registry.test.js +24 -28
  32. package/dist/test/team-orchestrator.test.js +71 -0
  33. package/dist/types/contracts.d.ts +10 -3
  34. package/dist/util/fs-helpers.d.ts +6 -0
  35. package/dist/util/fs-helpers.js +11 -0
  36. package/package.json +1 -1
@@ -1,9 +1,9 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
6
- import { AGENT_CTO, AGENT_TEAM_PLANNER, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from '../src/plugin/agent-hierarchy.js';
6
+ import { AGENT_BROWSER_QA, AGENT_CTO, AGENT_TEAM_PLANNER, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from '../src/plugin/agent-hierarchy.js';
7
7
  import { clearPluginServices } from '../src/plugin/service-factory.js';
8
8
  describe('ClaudeManagerPlugin', () => {
9
9
  it('configures CTO with orchestration tools and question access', async () => {
@@ -31,8 +31,6 @@ describe('ClaudeManagerPlugin', () => {
31
31
  question: 'allow',
32
32
  team_status: 'allow',
33
33
  reset_engineer: 'allow',
34
- confirm_plan: 'allow',
35
- advance_slice: 'allow',
36
34
  git_diff: 'allow',
37
35
  git_commit: 'allow',
38
36
  git_reset: 'allow',
@@ -99,7 +97,7 @@ describe('ClaudeManagerPlugin', () => {
99
97
  expect(agent.permission).not.toHaveProperty('grep');
100
98
  }
101
99
  });
102
- it('configures team-planner as a planning-bridge subagent', async () => {
100
+ it('configures team-planner as a thin planning wrapper subagent', async () => {
103
101
  const plugin = await ClaudeManagerPlugin({
104
102
  worktree: '/tmp/project',
105
103
  });
@@ -118,6 +116,7 @@ describe('ClaudeManagerPlugin', () => {
118
116
  });
119
117
  expect(teamPlanner.permission).not.toHaveProperty('read');
120
118
  expect(teamPlanner.permission).not.toHaveProperty('grep');
119
+ expect(teamPlanner.permission).not.toHaveProperty('glob');
121
120
  });
122
121
  it('registers the named engineer bridge and team status tools', async () => {
123
122
  const plugin = await ClaudeManagerPlugin({
@@ -127,8 +126,6 @@ describe('ClaudeManagerPlugin', () => {
127
126
  expect(tools['claude']).toBeDefined();
128
127
  expect(tools['team_status']).toBeDefined();
129
128
  expect(tools['plan_with_team']).toBeDefined();
130
- expect(tools['confirm_plan']).toBeDefined();
131
- expect(tools['advance_slice']).toBeDefined();
132
129
  expect(tools['reset_engineer']).toBeDefined();
133
130
  expect(tools['assign_engineer']).toBeUndefined();
134
131
  });
@@ -152,62 +149,13 @@ describe('ClaudeManagerPlugin', () => {
152
149
  expect(modelSchema.safeParse(undefined).success).toBe(true);
153
150
  expect(modelSchema.safeParse('claude-haiku-4-5').success).toBe(false);
154
151
  });
155
- it('confirm_plan tool validates taskSize enum and requires summary', async () => {
152
+ it('does not expose explicit plan tracking tools', async () => {
156
153
  const plugin = await ClaudeManagerPlugin({
157
154
  worktree: '/tmp/project',
158
155
  });
159
156
  const tools = plugin.tool;
160
- const confirmPlan = tools['confirm_plan'];
161
- expect(confirmPlan).toBeDefined();
162
- const summarySchema = confirmPlan.args.summary;
163
- const taskSizeSchema = confirmPlan.args.taskSize;
164
- const slicesSchema = confirmPlan.args.slices;
165
- const preAuthorizedSchema = confirmPlan.args.preAuthorized;
166
- expect(summarySchema.safeParse('Billing refactor').success).toBe(true);
167
- expect(summarySchema.safeParse('').success).toBe(false);
168
- expect(taskSizeSchema.safeParse('trivial').success).toBe(true);
169
- expect(taskSizeSchema.safeParse('simple').success).toBe(true);
170
- expect(taskSizeSchema.safeParse('large').success).toBe(true);
171
- expect(taskSizeSchema.safeParse('medium').success).toBe(false);
172
- expect(taskSizeSchema.safeParse('huge').success).toBe(false);
173
- // slices is optional — absent and array both valid
174
- expect(slicesSchema.safeParse(undefined).success).toBe(true);
175
- expect(slicesSchema.safeParse(['slice A', 'slice B']).success).toBe(true);
176
- // preAuthorized is optional boolean
177
- expect(preAuthorizedSchema.safeParse(true).success).toBe(true);
178
- expect(preAuthorizedSchema.safeParse(false).success).toBe(true);
179
- expect(preAuthorizedSchema.safeParse(undefined).success).toBe(true);
180
- });
181
- it('advance_slice tool validates sliceIndex and optional status enum', async () => {
182
- const plugin = await ClaudeManagerPlugin({
183
- worktree: '/tmp/project',
184
- });
185
- const tools = plugin.tool;
186
- const advanceSlice = tools['advance_slice'];
187
- expect(advanceSlice).toBeDefined();
188
- const sliceIndexSchema = advanceSlice.args.sliceIndex;
189
- const statusSchema = advanceSlice.args.status;
190
- expect(sliceIndexSchema.safeParse(0).success).toBe(true);
191
- expect(sliceIndexSchema.safeParse(2).success).toBe(true);
192
- expect(sliceIndexSchema.safeParse('0').success).toBe(false);
193
- expect(statusSchema.safeParse('done').success).toBe(true);
194
- expect(statusSchema.safeParse('skipped').success).toBe(true);
195
- expect(statusSchema.safeParse(undefined).success).toBe(true);
196
- expect(statusSchema.safeParse('in_progress').success).toBe(false);
197
- });
198
- it('confirm_plan and advance_slice are denied for engineers', async () => {
199
- const plugin = await ClaudeManagerPlugin({
200
- worktree: '/tmp/project',
201
- });
202
- const config = {};
203
- await plugin.config?.(config);
204
- const agents = (config.agent ?? {});
205
- for (const engineer of ENGINEER_AGENT_NAMES) {
206
- const agentId = ENGINEER_AGENT_IDS[engineer];
207
- const agent = agents[agentId];
208
- expect(agent.permission['confirm_plan']).toBe('deny');
209
- expect(agent.permission['advance_slice']).toBe('deny');
210
- }
157
+ expect(tools['confirm_plan']).toBeUndefined();
158
+ expect(tools['advance_slice']).toBeUndefined();
211
159
  });
212
160
  it('exposes hooks for CTO team tracking and wrapper memory injection', async () => {
213
161
  const plugin = await ClaudeManagerPlugin({
@@ -335,7 +283,7 @@ describe('Agent ID normalization and lookup helpers', () => {
335
283
  expect(agents['browser-qa']).toBeDefined();
336
284
  });
337
285
  });
338
- describe('confirm_plan and advance_slice tool execution', () => {
286
+ describe('claude tool engineer failure debug log', () => {
339
287
  let tempRoot;
340
288
  afterEach(async () => {
341
289
  clearPluginServices();
@@ -343,101 +291,26 @@ describe('confirm_plan and advance_slice tool execution', () => {
343
291
  await rm(tempRoot, { recursive: true, force: true });
344
292
  }
345
293
  });
346
- it('confirm_plan persists an active plan and returns it as JSON', async () => {
347
- tempRoot = await mkdtemp(join(tmpdir(), 'plugin-exec-'));
348
- const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
349
- const tools = plugin.tool;
350
- const context = {
351
- sessionID: 'cto-sess-confirm',
352
- worktree: tempRoot,
353
- agent: AGENT_CTO,
354
- metadata: vi.fn(),
355
- };
356
- const result = await tools['confirm_plan'].execute({
357
- summary: 'Add billing history',
358
- taskSize: 'large',
359
- slices: ['user can view invoices', 'user can update payment method'],
360
- preAuthorized: false,
361
- }, context);
362
- const activePlan = JSON.parse(result);
363
- expect(activePlan['summary']).toBe('Add billing history');
364
- expect(activePlan['taskSize']).toBe('large');
365
- expect(activePlan['currentSliceIndex']).toBe(0);
366
- expect(activePlan['preAuthorized']).toBe(false);
367
- expect(activePlan['slices'].length).toBe(2);
368
- });
369
- it('advance_slice marks a slice done and returns updated plan state', async () => {
370
- tempRoot = await mkdtemp(join(tmpdir(), 'plugin-exec-'));
371
- const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
372
- const tools = plugin.tool;
373
- const context = {
374
- sessionID: 'cto-sess-advance',
375
- worktree: tempRoot,
376
- agent: AGENT_CTO,
377
- metadata: vi.fn(),
378
- };
379
- // Set up plan with two slices
380
- await tools['confirm_plan'].execute({
381
- summary: 'Two-slice task',
382
- taskSize: 'large',
383
- slices: ['user can log in', 'user can log out'],
384
- preAuthorized: false,
385
- }, context);
386
- // Advance non-final slice 0
387
- const result = await tools['advance_slice'].execute({ sliceIndex: 0, status: 'done' }, context);
388
- const payload = JSON.parse(result);
389
- const slices = payload.activePlan['slices'];
390
- expect(slices[0]['status']).toBe('done');
391
- expect(payload.activePlan['currentSliceIndex']).toBe(1);
392
- });
393
- it('advance_slice sets currentSliceIndex to null when completing the final slice', async () => {
394
- tempRoot = await mkdtemp(join(tmpdir(), 'plugin-exec-'));
395
- const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
396
- const tools = plugin.tool;
397
- const context = {
398
- sessionID: 'cto-sess-final',
399
- worktree: tempRoot,
400
- agent: AGENT_CTO,
401
- metadata: vi.fn(),
402
- };
403
- await tools['confirm_plan'].execute({
404
- summary: 'Single-slice task',
405
- taskSize: 'large',
406
- slices: ['ship the feature'],
407
- preAuthorized: true,
408
- }, context);
409
- const result = await tools['advance_slice'].execute({ sliceIndex: 0, status: 'done' }, context);
410
- const payload = JSON.parse(result);
411
- expect(payload.activePlan['currentSliceIndex']).toBeNull();
412
- });
413
- it('advance_slice throws when there is no active plan', async () => {
414
- tempRoot = await mkdtemp(join(tmpdir(), 'plugin-exec-'));
415
- const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
416
- const tools = plugin.tool;
417
- const context = {
418
- sessionID: 'cto-sess-no-plan',
419
- worktree: tempRoot,
420
- agent: AGENT_CTO,
421
- metadata: vi.fn(),
422
- };
423
- await expect(tools['advance_slice'].execute({ sliceIndex: 0 }, context)).rejects.toThrow('has no active plan');
424
- });
425
- it('advance_slice throws when the slice index is invalid', async () => {
426
- tempRoot = await mkdtemp(join(tmpdir(), 'plugin-exec-'));
294
+ it('appends an engineer_failure entry to debug.log when dispatchEngineer throws', async () => {
295
+ tempRoot = await mkdtemp(join(tmpdir(), 'plugin-failure-log-'));
427
296
  const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
428
297
  const tools = plugin.tool;
429
298
  const context = {
430
- sessionID: 'cto-sess-bad-idx',
299
+ sessionID: 'wrapper-browserqa-fail',
431
300
  worktree: tempRoot,
432
- agent: AGENT_CTO,
301
+ agent: AGENT_BROWSER_QA,
433
302
  metadata: vi.fn(),
434
303
  };
435
- await tools['confirm_plan'].execute({
436
- summary: 'Two-slice task',
437
- taskSize: 'large',
438
- slices: ['slice A', 'slice B'],
439
- preAuthorized: false,
440
- }, context);
441
- await expect(tools['advance_slice'].execute({ sliceIndex: 99 }, context)).rejects.toThrow('slice index 99 does not exist');
304
+ // BrowserQA in implement mode throws synchronously before running a session
305
+ await expect(tools['claude'].execute({ mode: 'implement', message: 'Write a feature' }, context)).rejects.toThrow('modeNotSupported');
306
+ const logPath = join(tempRoot, '.claude-manager', 'debug.log');
307
+ const content = await readFile(logPath, 'utf8');
308
+ const entry = JSON.parse(content.trim().split('\n')[0]);
309
+ expect(entry.type).toBe('engineer_failure');
310
+ expect(entry.engineer).toBe('BrowserQA');
311
+ expect(entry.mode).toBe('implement');
312
+ expect(entry.failureKind).toBe('modeNotSupported');
313
+ expect(typeof entry.message).toBe('string');
314
+ expect(typeof entry.ts).toBe('string');
442
315
  });
443
316
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { appendDebugLog } from '../src/util/fs-helpers.js';
6
+ describe('appendDebugLog', () => {
7
+ let tmpDir;
8
+ afterEach(async () => {
9
+ if (tmpDir) {
10
+ await rm(tmpDir, { recursive: true, force: true });
11
+ }
12
+ });
13
+ it('creates the log file and parent directories if they do not exist', async () => {
14
+ tmpDir = await mkdtemp(join(tmpdir(), 'fs-helpers-'));
15
+ const logPath = join(tmpDir, 'nested', 'dir', 'debug.log');
16
+ await appendDebugLog(logPath, { type: 'test', value: 42 });
17
+ const content = await readFile(logPath, 'utf8');
18
+ expect(content).toBeTruthy();
19
+ });
20
+ it('writes a valid JSON object with a ts field on each line', async () => {
21
+ tmpDir = await mkdtemp(join(tmpdir(), 'fs-helpers-'));
22
+ const logPath = join(tmpDir, 'debug.log');
23
+ await appendDebugLog(logPath, { type: 'tool_denied', toolName: 'Edit' });
24
+ const content = await readFile(logPath, 'utf8');
25
+ const line = content.trim();
26
+ const entry = JSON.parse(line);
27
+ expect(entry.type).toBe('tool_denied');
28
+ expect(entry.toolName).toBe('Edit');
29
+ expect(typeof entry.ts).toBe('string');
30
+ // ts should be a valid ISO date string
31
+ expect(() => new Date(entry.ts).toISOString()).not.toThrow();
32
+ });
33
+ it('appends multiple entries as separate NDJSON lines', async () => {
34
+ tmpDir = await mkdtemp(join(tmpdir(), 'fs-helpers-'));
35
+ const logPath = join(tmpDir, 'debug.log');
36
+ await appendDebugLog(logPath, { type: 'a' });
37
+ await appendDebugLog(logPath, { type: 'b' });
38
+ await appendDebugLog(logPath, { type: 'c' });
39
+ const content = await readFile(logPath, 'utf8');
40
+ const lines = content.trim().split('\n');
41
+ expect(lines).toHaveLength(3);
42
+ const types = lines.map((l) => JSON.parse(l).type);
43
+ expect(types).toEqual(['a', 'b', 'c']);
44
+ });
45
+ it('injected ts field overrides a ts in the entry', async () => {
46
+ tmpDir = await mkdtemp(join(tmpdir(), 'fs-helpers-'));
47
+ const logPath = join(tmpDir, 'debug.log');
48
+ // The spread order means our ts wins over any ts in entry
49
+ await appendDebugLog(logPath, { ts: 'caller-value', type: 'x' });
50
+ const content = await readFile(logPath, 'utf8');
51
+ const entry = JSON.parse(content.trim());
52
+ // ts should be a real ISO date, not 'caller-value'
53
+ expect(entry.ts).not.toBe('caller-value');
54
+ expect(() => new Date(entry.ts).toISOString()).not.toThrow();
55
+ });
56
+ });
@@ -1,15 +1,19 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { managerPromptRegistry } from '../src/prompts/registry.js';
3
3
  describe('managerPromptRegistry', () => {
4
- it('gives the CTO explicit orchestration guidance', () => {
4
+ it('gives the CTO investigation-first orchestration guidance', () => {
5
5
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('You are a principal engineer orchestrating a team of AI-powered engineers');
6
6
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('Operating Loop');
7
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('named engineer');
7
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('Orient → Investigate → Decide → Delegate');
8
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('single-engineer');
8
9
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('team-planner');
9
10
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('question');
10
11
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('Review: Inspect diffs for production safety');
11
12
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('race condition');
12
13
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('contextExhausted');
14
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('adaptive, not rigid');
15
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('revisit earlier steps');
16
+ expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('Orient → Classify → Plan → Confirm → Delegate');
13
17
  expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('clear_session');
14
18
  expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('freshSession');
15
19
  });
@@ -18,11 +22,13 @@ describe('managerPromptRegistry', () => {
18
22
  expect(managerPromptRegistry.engineerAgentPrompt).toContain('claude');
19
23
  expect(managerPromptRegistry.engineerAgentPrompt).toContain('remembers your prior turns');
20
24
  expect(managerPromptRegistry.engineerAgentPrompt).toContain('wrapper context');
25
+ expect(managerPromptRegistry.engineerAgentPrompt).toContain('caller-directed');
21
26
  expect(managerPromptRegistry.engineerAgentPrompt).not.toContain('read/grep/glob');
22
27
  });
23
28
  it('keeps the engineer session prompt direct and repo-aware', () => {
24
29
  expect(managerPromptRegistry.engineerSessionPrompt).toContain('expert software engineer');
25
30
  expect(managerPromptRegistry.engineerSessionPrompt).toContain('Start with the smallest investigation that resolves the key uncertainty');
31
+ expect(managerPromptRegistry.engineerSessionPrompt).toContain('Do not default to a plan unless the caller explicitly asks for one');
26
32
  expect(managerPromptRegistry.engineerSessionPrompt).toContain('Verify your work before reporting done');
27
33
  expect(managerPromptRegistry.engineerSessionPrompt).toContain('Do not run git commit');
28
34
  expect(managerPromptRegistry.engineerSessionPrompt).toContain('rollout');
@@ -40,20 +46,18 @@ describe('managerPromptRegistry', () => {
40
46
  expect(managerPromptRegistry.planSynthesisPrompt).toContain('## Recommended Question');
41
47
  expect(managerPromptRegistry.planSynthesisPrompt).toContain('## Recommended Answer');
42
48
  });
43
- it('teamPlannerPrompt directs the agent to call plan_with_team with autonomous engineer selection', () => {
49
+ it('teamPlannerPrompt keeps the wrapper thin and calls plan_with_team', () => {
44
50
  expect(managerPromptRegistry.teamPlannerPrompt).toContain('plan_with_team');
51
+ expect(managerPromptRegistry.teamPlannerPrompt).toContain('live activity in the UI');
52
+ expect(managerPromptRegistry.teamPlannerPrompt).toContain('Keep the wrapper thin');
45
53
  expect(managerPromptRegistry.teamPlannerPrompt).toContain('auto-select');
46
- expect(managerPromptRegistry.teamPlannerPrompt).toContain('engineer');
47
- });
48
- it('ctoSystemPrompt includes Confirm step in operating loop before Delegate', () => {
49
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('Orient Classify Plan Confirm Delegate');
50
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('Confirm: Get user buy-in before implementing');
51
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('recommendedQuestion');
52
- // Confirm section must appear before Delegate section in the text
53
- const confirmIdx = managerPromptRegistry.ctoSystemPrompt.indexOf('## Confirm:');
54
- const delegateIdx = managerPromptRegistry.ctoSystemPrompt.indexOf('## Delegate:');
55
- expect(confirmIdx).toBeGreaterThan(-1);
56
- expect(confirmIdx).toBeLessThan(delegateIdx);
54
+ expect(managerPromptRegistry.teamPlannerPrompt).toContain('pass the full result back to the CTO unchanged');
55
+ });
56
+ it('ctoSystemPrompt delegates directly when scope is clear and asks for explicit explore outputs', () => {
57
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('For trivial or simple work with clear scope: delegate directly to one engineer');
58
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('expected output shape');
59
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('root cause');
60
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('implementation plan');
57
61
  });
58
62
  it('engineerAgentPrompt instructs engineers to surface plan deviations', () => {
59
63
  expect(managerPromptRegistry.engineerAgentPrompt).toContain('deviation');
@@ -62,14 +66,11 @@ describe('managerPromptRegistry', () => {
62
66
  it('browserQaAgentPrompt instructs browser-qa to report scope mismatches', () => {
63
67
  expect(managerPromptRegistry.browserQaAgentPrompt).toContain('scope mismatch');
64
68
  });
65
- it('teamPlannerPrompt instructs planner to pass synthesis back unchanged', () => {
66
- expect(managerPromptRegistry.teamPlannerPrompt).toContain('unchanged');
67
- });
68
- it('ctoSystemPrompt delegates single work to named engineers via task() and dual work to team-planner', () => {
69
+ it('ctoSystemPrompt delegates single work to named engineers and complex planning to team-planner', () => {
69
70
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('task(subagent_type:');
70
71
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('single-engineer');
71
72
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('team-planner');
72
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('automatically selects');
73
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('live UI activity');
73
74
  });
74
75
  it('ctoSystemPrompt mentions browser-qa for delegation', () => {
75
76
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('browser-qa');
@@ -92,11 +93,9 @@ describe('managerPromptRegistry', () => {
92
93
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('large');
93
94
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('Task size');
94
95
  });
95
- it('ctoSystemPrompt mentions confirm_plan and advance_slice for large task lifecycle', () => {
96
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('confirm_plan');
97
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('advance_slice');
98
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('preAuthorized');
99
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('vertical slice');
96
+ it('ctoSystemPrompt no longer mentions explicit plan-tracking tools', () => {
97
+ expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('confirm_plan');
98
+ expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('advance_slice');
100
99
  });
101
100
  it('ctoSystemPrompt encodes warn-only context policy', () => {
102
101
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('Context warnings');
@@ -108,14 +107,11 @@ describe('managerPromptRegistry', () => {
108
107
  expect(managerPromptRegistry.contextWarnings.critical).toContain('Warn only');
109
108
  });
110
109
  it('ctoSystemPrompt uses genuinely vertical slice examples, not horizontal layers', () => {
111
- // Horizontal layer examples (internal plumbing only) must not appear
112
110
  expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('"types + contracts"');
113
111
  expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('"core logic"');
114
112
  expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('"plugin tools"');
115
- // Prompt must describe the end-to-end / user-testable property of a slice
116
113
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('end-to-end');
117
114
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('user-testable');
118
- // Horizontal layers must be explicitly called out as wrong
119
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('Horizontal layer');
115
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('Horizontal layers');
120
116
  });
121
117
  });
@@ -71,6 +71,8 @@ describe('TeamOrchestrator', () => {
71
71
  expect(runTask.mock.calls[0]?.[0].prompt).toContain('Base engineer prompt');
72
72
  expect(runTask.mock.calls[0]?.[0].prompt).toContain('Assigned engineer: Tom.');
73
73
  expect(runTask.mock.calls[0]?.[0].prompt).toContain('Investigate the auth flow');
74
+ expect(runTask.mock.calls[0]?.[0].prompt).toContain('The caller should specify the desired output');
75
+ expect(runTask.mock.calls[0]?.[0].prompt).not.toContain('Produce a concrete plan');
74
76
  expect(runTask.mock.calls[1]?.[0]).toMatchObject({
75
77
  resumeSessionId: 'ses_tom',
76
78
  permissionMode: 'acceptEdits',
@@ -343,6 +345,75 @@ describe('TeamOrchestrator', () => {
343
345
  expect(error.message).toContain('BrowserQA is a browser QA specialist');
344
346
  expect(error.message).toContain('does not support implement mode');
345
347
  });
348
+ it('forwards sessionAllowedTools to runTask when worker has them configured', async () => {
349
+ tempRoot = await mkdtemp(join(tmpdir(), 'browserqa-allowed-'));
350
+ const runTask = vi.fn().mockResolvedValueOnce({
351
+ sessionId: 'ses_qa',
352
+ events: [],
353
+ finalText: 'Done.',
354
+ turns: 1,
355
+ totalCostUsd: 0.01,
356
+ inputTokens: 500,
357
+ outputTokens: 100,
358
+ contextWindowSize: 200_000,
359
+ });
360
+ const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Engineer prompt', 'Synthesis prompt', {
361
+ BrowserQA: {
362
+ ...BROWSER_QA_TEST_CAPS,
363
+ sessionAllowedTools: ['Skill', 'Bash', 'Read', 'Grep', 'Glob', 'LS', 'ListDirectory'],
364
+ },
365
+ });
366
+ await orchestrator.dispatchEngineer({
367
+ teamId: 'team-1',
368
+ cwd: tempRoot,
369
+ engineer: 'BrowserQA',
370
+ mode: 'explore',
371
+ message: 'Run Playwriter tests',
372
+ });
373
+ expect(runTask).toHaveBeenCalledOnce();
374
+ const taskInput = runTask.mock.calls[0]?.[0];
375
+ expect(taskInput.allowedTools).toEqual(expect.arrayContaining(['Skill', 'Bash', 'Read', 'Grep', 'Glob', 'LS', 'ListDirectory']));
376
+ });
377
+ it('passes undefined allowedTools for standard engineers without sessionAllowedTools', async () => {
378
+ tempRoot = await mkdtemp(join(tmpdir(), 'engineer-no-allowed-'));
379
+ const runTask = vi.fn().mockResolvedValueOnce({
380
+ sessionId: 'ses_tom',
381
+ events: [],
382
+ finalText: 'Done.',
383
+ turns: 1,
384
+ totalCostUsd: 0.01,
385
+ inputTokens: 500,
386
+ outputTokens: 100,
387
+ contextWindowSize: 200_000,
388
+ });
389
+ const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
390
+ await orchestrator.dispatchEngineer({
391
+ teamId: 'team-1',
392
+ cwd: tempRoot,
393
+ engineer: 'Tom',
394
+ mode: 'explore',
395
+ message: 'Investigate something',
396
+ });
397
+ expect(runTask).toHaveBeenCalledOnce();
398
+ const taskInput = runTask.mock.calls[0]?.[0];
399
+ expect(taskInput.allowedTools).toBeUndefined();
400
+ });
401
+ it('classifyError returns modeNotSupported for implement-mode rejection', () => {
402
+ const result = TeamOrchestrator.classifyError(new Error('BrowserQA is a browser QA specialist and does not support implement mode. ' +
403
+ 'It can only verify and explore.'));
404
+ expect(result.failureKind).toBe('modeNotSupported');
405
+ });
406
+ it('classifyError still returns sdkError for generic errors', () => {
407
+ const result = TeamOrchestrator.classifyError(new Error('Something unexpected happened'));
408
+ expect(result.failureKind).toBe('sdkError');
409
+ });
410
+ it('getFailureGuidanceText returns actionable guidance for modeNotSupported', async () => {
411
+ const { getFailureGuidanceText } = await import('../src/manager/team-orchestrator.js');
412
+ const guidance = getFailureGuidanceText('modeNotSupported');
413
+ expect(guidance).toContain('explore');
414
+ expect(guidance).toContain('verify');
415
+ expect(guidance).toContain('implement');
416
+ });
346
417
  it('setActivePlan persists plan with slices on TeamRecord', async () => {
347
418
  tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
348
419
  const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
@@ -4,9 +4,9 @@ export interface ManagerPromptRegistry {
4
4
  engineerSessionPrompt: string;
5
5
  /** Prompt prepended to the user prompt of the synthesis runTask call inside plan_with_team. */
6
6
  planSynthesisPrompt: string;
7
- /** Visible subagent prompt for teamPlanner — thin bridge that calls plan_with_team. */
7
+ /** Visible subagent prompt for teamPlanner — thin wrapper that calls plan_with_team. */
8
8
  teamPlannerPrompt: string;
9
- /** Visible subagent prompt for browserQa — thin bridge that calls claude tool for browser verification. */
9
+ /** Visible subagent prompt for browserQa — thin wrapper that calls claude tool for browser verification. */
10
10
  browserQaAgentPrompt: string;
11
11
  /** Prompt prepended to browser verification task prompts in Claude Code sessions. */
12
12
  browserQaSessionPrompt: string;
@@ -146,7 +146,7 @@ export interface TeamEngineerRecord {
146
146
  wrapperHistory: WrapperHistoryEntry[];
147
147
  context: SessionContextSnapshot;
148
148
  }
149
- export type EngineerFailureKind = 'sdkError' | 'contextExhausted' | 'toolDenied' | 'aborted' | 'engineerBusy' | 'unknown';
149
+ export type EngineerFailureKind = 'sdkError' | 'contextExhausted' | 'toolDenied' | 'modeNotSupported' | 'aborted' | 'engineerBusy' | 'unknown';
150
150
  export interface EngineerFailureResult {
151
151
  teamId: string;
152
152
  engineer: EngineerName;
@@ -206,6 +206,13 @@ export interface WorkerCapabilities {
206
206
  isRuntimeUnavailableResponse?: (finalText: string) => boolean;
207
207
  /** Metadata title for the runtime-unavailable event. */
208
208
  runtimeUnavailableTitle?: string;
209
+ /**
210
+ * Explicit SDK-level pre-approval list for this worker's inner Claude Code session.
211
+ * Tools in this list bypass interactive confirmation prompts (they are still subject to
212
+ * the `canUseTool` write-restriction and approval-policy filters).
213
+ * Absent = falls back to `['Skill']` via the default adapter behaviour.
214
+ */
215
+ sessionAllowedTools?: string[];
209
216
  }
210
217
  export interface GitDiffResult {
211
218
  hasDiff: boolean;
@@ -1,2 +1,8 @@
1
1
  export declare function writeJsonAtomically(filePath: string, data: unknown): Promise<void>;
2
2
  export declare function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException;
3
+ /**
4
+ * Appends a single NDJSON line to a debug log file.
5
+ * Creates the parent directory if it does not exist.
6
+ * A `ts` (ISO timestamp) field is injected automatically.
7
+ */
8
+ export declare function appendDebugLog(logPath: string, entry: Record<string, unknown>): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
3
4
  export async function writeJsonAtomically(filePath, data) {
4
5
  const tempPath = `${filePath}.${randomUUID()}.tmp`;
5
6
  await fs.writeFile(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
@@ -8,3 +9,13 @@ export async function writeJsonAtomically(filePath, data) {
8
9
  export function isFileNotFoundError(error) {
9
10
  return (error instanceof Error && 'code' in error && error.code === 'ENOENT');
10
11
  }
12
+ /**
13
+ * Appends a single NDJSON line to a debug log file.
14
+ * Creates the parent directory if it does not exist.
15
+ * A `ts` (ISO timestamp) field is injected automatically.
16
+ */
17
+ export async function appendDebugLog(logPath, entry) {
18
+ const line = JSON.stringify({ ...entry, ts: new Date().toISOString() }) + '\n';
19
+ await fs.mkdir(path.dirname(logPath), { recursive: true });
20
+ await fs.appendFile(logPath, line, 'utf8');
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doingdev/opencode-claude-manager-plugin",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",