@doingdev/opencode-claude-manager-plugin 0.1.59 → 0.1.61

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 (32) 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.d.ts +10 -1
  5. package/dist/manager/team-orchestrator.js +86 -4
  6. package/dist/plugin/agents/team-planner.js +1 -1
  7. package/dist/plugin/claude-manager.plugin.js +14 -0
  8. package/dist/plugin/service-factory.d.ts +1 -0
  9. package/dist/plugin/service-factory.js +3 -1
  10. package/dist/prompts/registry.js +38 -20
  11. package/dist/src/claude/claude-agent-sdk-adapter.d.ts +3 -1
  12. package/dist/src/claude/claude-agent-sdk-adapter.js +57 -6
  13. package/dist/src/manager/team-orchestrator.d.ts +10 -1
  14. package/dist/src/manager/team-orchestrator.js +86 -4
  15. package/dist/src/plugin/agents/team-planner.js +1 -1
  16. package/dist/src/plugin/claude-manager.plugin.js +14 -0
  17. package/dist/src/plugin/service-factory.d.ts +1 -0
  18. package/dist/src/plugin/service-factory.js +3 -1
  19. package/dist/src/prompts/registry.js +38 -20
  20. package/dist/src/types/contracts.d.ts +22 -3
  21. package/dist/src/util/fs-helpers.d.ts +6 -0
  22. package/dist/src/util/fs-helpers.js +11 -0
  23. package/dist/test/claude-agent-sdk-adapter.test.js +118 -1
  24. package/dist/test/claude-manager.plugin.test.js +47 -3
  25. package/dist/test/fs-helpers.test.d.ts +1 -0
  26. package/dist/test/fs-helpers.test.js +56 -0
  27. package/dist/test/prompt-registry.test.js +54 -6
  28. package/dist/test/team-orchestrator.test.js +176 -2
  29. package/dist/types/contracts.d.ts +22 -3
  30. package/dist/util/fs-helpers.d.ts +6 -0
  31. package/dist/util/fs-helpers.js +11 -0
  32. package/package.json +1 -1
@@ -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,16 +46,31 @@ 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');
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');
61
+ });
62
+ it('engineerAgentPrompt instructs engineers to surface plan deviations', () => {
63
+ expect(managerPromptRegistry.engineerAgentPrompt).toContain('deviation');
64
+ expect(managerPromptRegistry.engineerAgentPrompt).toContain('surface');
47
65
  });
48
- it('ctoSystemPrompt delegates single work to named engineers via task() and dual work to team-planner', () => {
66
+ it('browserQaAgentPrompt instructs browser-qa to report scope mismatches', () => {
67
+ expect(managerPromptRegistry.browserQaAgentPrompt).toContain('scope mismatch');
68
+ });
69
+ it('ctoSystemPrompt delegates single work to named engineers and complex planning to team-planner', () => {
49
70
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('task(subagent_type:');
50
71
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('single-engineer');
51
72
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('team-planner');
52
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('automatically selects');
73
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('live UI activity');
53
74
  });
54
75
  it('ctoSystemPrompt mentions browser-qa for delegation', () => {
55
76
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('browser-qa');
@@ -66,4 +87,31 @@ describe('managerPromptRegistry', () => {
66
87
  expect(managerPromptRegistry.browserQaSessionPrompt).not.toContain('implement');
67
88
  expect(managerPromptRegistry.browserQaSessionPrompt).not.toContain('write code');
68
89
  });
90
+ it('ctoSystemPrompt encodes task size classification (trivial/simple/large)', () => {
91
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('trivial');
92
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('simple');
93
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('large');
94
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('Task size');
95
+ });
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');
99
+ });
100
+ it('ctoSystemPrompt encodes warn-only context policy', () => {
101
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('Context warnings');
102
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('advisory');
103
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('contextExhausted');
104
+ });
105
+ it('contextWarnings reflect warn-only policy for critical level', () => {
106
+ expect(managerPromptRegistry.contextWarnings.critical).toContain('near capacity');
107
+ expect(managerPromptRegistry.contextWarnings.critical).toContain('Warn only');
108
+ });
109
+ it('ctoSystemPrompt uses genuinely vertical slice examples, not horizontal layers', () => {
110
+ expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('"types + contracts"');
111
+ expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('"core logic"');
112
+ expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('"plugin tools"');
113
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('end-to-end');
114
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('user-testable');
115
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('Horizontal layers');
116
+ });
69
117
  });
@@ -44,7 +44,8 @@ describe('TeamOrchestrator', () => {
44
44
  outputTokens: 300,
45
45
  contextWindowSize: 200_000,
46
46
  });
47
- const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
47
+ const store = new TeamStateStore('.state');
48
+ const orchestrator = new TeamOrchestrator({ runTask }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
48
49
  const first = await orchestrator.dispatchEngineer({
49
50
  teamId: 'team-1',
50
51
  cwd: tempRoot,
@@ -70,6 +71,8 @@ describe('TeamOrchestrator', () => {
70
71
  expect(runTask.mock.calls[0]?.[0].prompt).toContain('Base engineer prompt');
71
72
  expect(runTask.mock.calls[0]?.[0].prompt).toContain('Assigned engineer: Tom.');
72
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');
73
76
  expect(runTask.mock.calls[1]?.[0]).toMatchObject({
74
77
  resumeSessionId: 'ses_tom',
75
78
  permissionMode: 'acceptEdits',
@@ -78,6 +81,8 @@ describe('TeamOrchestrator', () => {
78
81
  expect(runTask.mock.calls[1]?.[0].systemPrompt).toBeUndefined();
79
82
  expect(runTask.mock.calls[1]?.[0].prompt).not.toContain('Assigned engineer: Tom.');
80
83
  expect(runTask.mock.calls[1]?.[0].prompt).toContain('Implement the chosen fix');
84
+ // Hybrid workflow: implement mode must include a pre-implementation plan step
85
+ expect(runTask.mock.calls[1]?.[0].prompt).toContain('state a brief implementation plan');
81
86
  const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
82
87
  expect(team.engineers.find((engineer) => engineer.name === 'Tom')).toMatchObject({
83
88
  claudeSessionId: 'ses_tom',
@@ -301,7 +306,8 @@ describe('TeamOrchestrator', () => {
301
306
  contextWindowSize: 200_000,
302
307
  };
303
308
  });
304
- const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
309
+ const store = new TeamStateStore('.state');
310
+ const orchestrator = new TeamOrchestrator({ runTask }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
305
311
  const allEvents = [];
306
312
  const result = await orchestrator.dispatchEngineer({
307
313
  teamId: 'team-1',
@@ -339,6 +345,174 @@ describe('TeamOrchestrator', () => {
339
345
  expect(error.message).toContain('BrowserQA is a browser QA specialist');
340
346
  expect(error.message).toContain('does not support implement mode');
341
347
  });
348
+ it('classifyError returns modeNotSupported for implement-mode rejection', () => {
349
+ const result = TeamOrchestrator.classifyError(new Error('BrowserQA is a browser QA specialist and does not support implement mode. ' +
350
+ 'It can only verify and explore.'));
351
+ expect(result.failureKind).toBe('modeNotSupported');
352
+ });
353
+ it('classifyError still returns sdkError for generic errors', () => {
354
+ const result = TeamOrchestrator.classifyError(new Error('Something unexpected happened'));
355
+ expect(result.failureKind).toBe('sdkError');
356
+ });
357
+ it('getFailureGuidanceText returns actionable guidance for modeNotSupported', async () => {
358
+ const { getFailureGuidanceText } = await import('../src/manager/team-orchestrator.js');
359
+ const guidance = getFailureGuidanceText('modeNotSupported');
360
+ expect(guidance).toContain('explore');
361
+ expect(guidance).toContain('verify');
362
+ expect(guidance).toContain('implement');
363
+ });
364
+ it('setActivePlan persists plan with slices on TeamRecord', async () => {
365
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
366
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
367
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
368
+ const activePlan = await orchestrator.setActivePlan(tempRoot, 'team-1', {
369
+ summary: 'Implement billing refactor in three slices',
370
+ taskSize: 'large',
371
+ slices: ['types + contracts', 'core logic', 'tests'],
372
+ preAuthorized: false,
373
+ });
374
+ expect(activePlan.id).toMatch(/^plan-/);
375
+ expect(activePlan.summary).toBe('Implement billing refactor in three slices');
376
+ expect(activePlan.taskSize).toBe('large');
377
+ expect(activePlan.preAuthorized).toBe(false);
378
+ expect(activePlan.slices).toHaveLength(3);
379
+ expect(activePlan.slices[0]).toMatchObject({
380
+ index: 0,
381
+ description: 'types + contracts',
382
+ status: 'pending',
383
+ });
384
+ expect(activePlan.slices[1]).toMatchObject({
385
+ index: 1,
386
+ description: 'core logic',
387
+ status: 'pending',
388
+ });
389
+ expect(activePlan.slices[2]).toMatchObject({
390
+ index: 2,
391
+ description: 'tests',
392
+ status: 'pending',
393
+ });
394
+ expect(activePlan.currentSliceIndex).toBe(0);
395
+ expect(activePlan.confirmedAt).not.toBeNull();
396
+ const retrieved = await orchestrator.getActivePlan(tempRoot, 'team-1');
397
+ expect(retrieved).toMatchObject({ id: activePlan.id, taskSize: 'large' });
398
+ });
399
+ it('clearActivePlan removes activePlan from TeamRecord', async () => {
400
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
401
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
402
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
403
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
404
+ summary: 'Small task',
405
+ taskSize: 'simple',
406
+ slices: [],
407
+ preAuthorized: false,
408
+ });
409
+ const beforeClear = await orchestrator.getActivePlan(tempRoot, 'team-1');
410
+ expect(beforeClear).not.toBeNull();
411
+ await orchestrator.clearActivePlan(tempRoot, 'team-1');
412
+ const afterClear = await orchestrator.getActivePlan(tempRoot, 'team-1');
413
+ expect(afterClear).toBeNull();
414
+ });
415
+ it('updateActivePlanSlice marks a slice done and advances currentSliceIndex', async () => {
416
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
417
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
418
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
419
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
420
+ summary: 'Two-slice task',
421
+ taskSize: 'large',
422
+ slices: ['slice A', 'slice B'],
423
+ preAuthorized: true,
424
+ });
425
+ await orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done');
426
+ const plan = await orchestrator.getActivePlan(tempRoot, 'team-1');
427
+ expect(plan).not.toBeNull();
428
+ expect(plan.slices[0]).toMatchObject({ index: 0, status: 'done' });
429
+ expect(plan.slices[0].completedAt).toBeDefined();
430
+ expect(plan.slices[1]).toMatchObject({ index: 1, status: 'pending' });
431
+ expect(plan.currentSliceIndex).toBe(1);
432
+ });
433
+ it('updateActivePlanSlice sets currentSliceIndex to null when the final slice is completed', async () => {
434
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
435
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
436
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
437
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
438
+ summary: 'Single-slice task',
439
+ taskSize: 'large',
440
+ slices: ['ship the feature'],
441
+ preAuthorized: true,
442
+ });
443
+ // Complete the only slice (index 0, which is also the last)
444
+ await orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done');
445
+ const plan = await orchestrator.getActivePlan(tempRoot, 'team-1');
446
+ expect(plan).not.toBeNull();
447
+ expect(plan.slices[0]).toMatchObject({ index: 0, status: 'done' });
448
+ expect(plan.currentSliceIndex).toBeNull();
449
+ });
450
+ it('setActivePlan sets currentSliceIndex to null when no slices are provided', async () => {
451
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
452
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
453
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
454
+ const plan = await orchestrator.setActivePlan(tempRoot, 'team-1', {
455
+ summary: 'Simple no-slice task',
456
+ taskSize: 'simple',
457
+ slices: [],
458
+ preAuthorized: false,
459
+ });
460
+ expect(plan.currentSliceIndex).toBeNull();
461
+ expect(plan.slices).toHaveLength(0);
462
+ });
463
+ it('updateActivePlanSlice throws when team has no active plan', async () => {
464
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
465
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
466
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
467
+ await expect(orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done')).rejects.toThrow('has no active plan');
468
+ });
469
+ it('updateActivePlanSlice throws when slice index does not exist', async () => {
470
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
471
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
472
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
473
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
474
+ summary: 'Two-slice task',
475
+ taskSize: 'large',
476
+ slices: ['slice A', 'slice B'],
477
+ preAuthorized: false,
478
+ });
479
+ // Index 5 does not exist (only 0 and 1 exist)
480
+ await expect(orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 5, 'done')).rejects.toThrow('slice index 5 does not exist');
481
+ });
482
+ it('updateActivePlanSlice throws when active plan has no slices', async () => {
483
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
484
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
485
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
486
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
487
+ summary: 'No-slice plan',
488
+ taskSize: 'simple',
489
+ slices: [],
490
+ preAuthorized: false,
491
+ });
492
+ await expect(orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done')).rejects.toThrow('plan has no slices');
493
+ });
494
+ it('getActivePlan returns null for a new team with no active plan', async () => {
495
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
496
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
497
+ const plan = await orchestrator.getActivePlan(tempRoot, 'team-1');
498
+ expect(plan).toBeNull();
499
+ });
500
+ it('normalizeTeamRecord preserves activePlan from persisted records', async () => {
501
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
502
+ const store = new TeamStateStore('.state');
503
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
504
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
505
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
506
+ summary: 'Persist test',
507
+ taskSize: 'simple',
508
+ slices: [],
509
+ preAuthorized: false,
510
+ });
511
+ // Re-read via getOrCreateTeam (triggers normalizeTeamRecord)
512
+ const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
513
+ expect(team.activePlan).toBeDefined();
514
+ expect(team.activePlan.summary).toBe('Persist test');
515
+ });
342
516
  it('selectPlanEngineers excludes BrowserQA from planner selection', async () => {
343
517
  tempRoot = await mkdtemp(join(tmpdir(), 'planner-exclude-browserqa-'));
344
518
  const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, '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;
@@ -116,6 +116,24 @@ export interface SessionContextSnapshot {
116
116
  warningLevel: ContextWarningLevel;
117
117
  compactionCount: number;
118
118
  }
119
+ export type TaskSize = 'trivial' | 'simple' | 'large';
120
+ export interface PlanSlice {
121
+ index: number;
122
+ description: string;
123
+ status: 'pending' | 'in_progress' | 'done' | 'skipped';
124
+ completedAt?: string;
125
+ }
126
+ export interface ActivePlan {
127
+ id: string;
128
+ summary: string;
129
+ taskSize: TaskSize;
130
+ createdAt: string;
131
+ confirmedAt: string | null;
132
+ preAuthorized: boolean;
133
+ slices: PlanSlice[];
134
+ /** Null when the plan has no slices (trivial/simple tasks). */
135
+ currentSliceIndex: number | null;
136
+ }
119
137
  export interface TeamEngineerRecord {
120
138
  name: EngineerName;
121
139
  wrapperSessionId: string | null;
@@ -128,7 +146,7 @@ export interface TeamEngineerRecord {
128
146
  wrapperHistory: WrapperHistoryEntry[];
129
147
  context: SessionContextSnapshot;
130
148
  }
131
- export type EngineerFailureKind = 'sdkError' | 'contextExhausted' | 'toolDenied' | 'aborted' | 'engineerBusy' | 'unknown';
149
+ export type EngineerFailureKind = 'sdkError' | 'contextExhausted' | 'toolDenied' | 'modeNotSupported' | 'aborted' | 'engineerBusy' | 'unknown';
132
150
  export interface EngineerFailureResult {
133
151
  teamId: string;
134
152
  engineer: EngineerName;
@@ -142,6 +160,7 @@ export interface TeamRecord {
142
160
  createdAt: string;
143
161
  updatedAt: string;
144
162
  engineers: TeamEngineerRecord[];
163
+ activePlan?: ActivePlan;
145
164
  }
146
165
  export interface EngineerTaskResult {
147
166
  teamId: string;
@@ -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.59",
3
+ "version": "0.1.61",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",