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

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.
@@ -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,
@@ -78,6 +79,8 @@ describe('TeamOrchestrator', () => {
78
79
  expect(runTask.mock.calls[1]?.[0].systemPrompt).toBeUndefined();
79
80
  expect(runTask.mock.calls[1]?.[0].prompt).not.toContain('Assigned engineer: Tom.');
80
81
  expect(runTask.mock.calls[1]?.[0].prompt).toContain('Implement the chosen fix');
82
+ // Hybrid workflow: implement mode must include a pre-implementation plan step
83
+ expect(runTask.mock.calls[1]?.[0].prompt).toContain('state a brief implementation plan');
81
84
  const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
82
85
  expect(team.engineers.find((engineer) => engineer.name === 'Tom')).toMatchObject({
83
86
  claudeSessionId: 'ses_tom',
@@ -301,7 +304,8 @@ describe('TeamOrchestrator', () => {
301
304
  contextWindowSize: 200_000,
302
305
  };
303
306
  });
304
- const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
307
+ const store = new TeamStateStore('.state');
308
+ const orchestrator = new TeamOrchestrator({ runTask }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
305
309
  const allEvents = [];
306
310
  const result = await orchestrator.dispatchEngineer({
307
311
  teamId: 'team-1',
@@ -339,6 +343,158 @@ describe('TeamOrchestrator', () => {
339
343
  expect(error.message).toContain('BrowserQA is a browser QA specialist');
340
344
  expect(error.message).toContain('does not support implement mode');
341
345
  });
346
+ it('setActivePlan persists plan with slices on TeamRecord', async () => {
347
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
348
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
349
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
350
+ const activePlan = await orchestrator.setActivePlan(tempRoot, 'team-1', {
351
+ summary: 'Implement billing refactor in three slices',
352
+ taskSize: 'large',
353
+ slices: ['types + contracts', 'core logic', 'tests'],
354
+ preAuthorized: false,
355
+ });
356
+ expect(activePlan.id).toMatch(/^plan-/);
357
+ expect(activePlan.summary).toBe('Implement billing refactor in three slices');
358
+ expect(activePlan.taskSize).toBe('large');
359
+ expect(activePlan.preAuthorized).toBe(false);
360
+ expect(activePlan.slices).toHaveLength(3);
361
+ expect(activePlan.slices[0]).toMatchObject({
362
+ index: 0,
363
+ description: 'types + contracts',
364
+ status: 'pending',
365
+ });
366
+ expect(activePlan.slices[1]).toMatchObject({
367
+ index: 1,
368
+ description: 'core logic',
369
+ status: 'pending',
370
+ });
371
+ expect(activePlan.slices[2]).toMatchObject({
372
+ index: 2,
373
+ description: 'tests',
374
+ status: 'pending',
375
+ });
376
+ expect(activePlan.currentSliceIndex).toBe(0);
377
+ expect(activePlan.confirmedAt).not.toBeNull();
378
+ const retrieved = await orchestrator.getActivePlan(tempRoot, 'team-1');
379
+ expect(retrieved).toMatchObject({ id: activePlan.id, taskSize: 'large' });
380
+ });
381
+ it('clearActivePlan removes activePlan from TeamRecord', async () => {
382
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
383
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
384
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
385
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
386
+ summary: 'Small task',
387
+ taskSize: 'simple',
388
+ slices: [],
389
+ preAuthorized: false,
390
+ });
391
+ const beforeClear = await orchestrator.getActivePlan(tempRoot, 'team-1');
392
+ expect(beforeClear).not.toBeNull();
393
+ await orchestrator.clearActivePlan(tempRoot, 'team-1');
394
+ const afterClear = await orchestrator.getActivePlan(tempRoot, 'team-1');
395
+ expect(afterClear).toBeNull();
396
+ });
397
+ it('updateActivePlanSlice marks a slice done and advances currentSliceIndex', async () => {
398
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
399
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
400
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
401
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
402
+ summary: 'Two-slice task',
403
+ taskSize: 'large',
404
+ slices: ['slice A', 'slice B'],
405
+ preAuthorized: true,
406
+ });
407
+ await orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done');
408
+ const plan = await orchestrator.getActivePlan(tempRoot, 'team-1');
409
+ expect(plan).not.toBeNull();
410
+ expect(plan.slices[0]).toMatchObject({ index: 0, status: 'done' });
411
+ expect(plan.slices[0].completedAt).toBeDefined();
412
+ expect(plan.slices[1]).toMatchObject({ index: 1, status: 'pending' });
413
+ expect(plan.currentSliceIndex).toBe(1);
414
+ });
415
+ it('updateActivePlanSlice sets currentSliceIndex to null when the final slice is completed', 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: 'Single-slice task',
421
+ taskSize: 'large',
422
+ slices: ['ship the feature'],
423
+ preAuthorized: true,
424
+ });
425
+ // Complete the only slice (index 0, which is also the last)
426
+ await orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done');
427
+ const plan = await orchestrator.getActivePlan(tempRoot, 'team-1');
428
+ expect(plan).not.toBeNull();
429
+ expect(plan.slices[0]).toMatchObject({ index: 0, status: 'done' });
430
+ expect(plan.currentSliceIndex).toBeNull();
431
+ });
432
+ it('setActivePlan sets currentSliceIndex to null when no slices are provided', async () => {
433
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
434
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
435
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
436
+ const plan = await orchestrator.setActivePlan(tempRoot, 'team-1', {
437
+ summary: 'Simple no-slice task',
438
+ taskSize: 'simple',
439
+ slices: [],
440
+ preAuthorized: false,
441
+ });
442
+ expect(plan.currentSliceIndex).toBeNull();
443
+ expect(plan.slices).toHaveLength(0);
444
+ });
445
+ it('updateActivePlanSlice throws when team has no active plan', async () => {
446
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
447
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
448
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
449
+ await expect(orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done')).rejects.toThrow('has no active plan');
450
+ });
451
+ it('updateActivePlanSlice throws when slice index does not exist', async () => {
452
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
453
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
454
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
455
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
456
+ summary: 'Two-slice task',
457
+ taskSize: 'large',
458
+ slices: ['slice A', 'slice B'],
459
+ preAuthorized: false,
460
+ });
461
+ // Index 5 does not exist (only 0 and 1 exist)
462
+ await expect(orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 5, 'done')).rejects.toThrow('slice index 5 does not exist');
463
+ });
464
+ it('updateActivePlanSlice throws when active plan has no slices', async () => {
465
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
466
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
467
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
468
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
469
+ summary: 'No-slice plan',
470
+ taskSize: 'simple',
471
+ slices: [],
472
+ preAuthorized: false,
473
+ });
474
+ await expect(orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done')).rejects.toThrow('plan has no slices');
475
+ });
476
+ it('getActivePlan returns null for a new team with no active plan', async () => {
477
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
478
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
479
+ const plan = await orchestrator.getActivePlan(tempRoot, 'team-1');
480
+ expect(plan).toBeNull();
481
+ });
482
+ it('normalizeTeamRecord preserves activePlan from persisted records', async () => {
483
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
484
+ const store = new TeamStateStore('.state');
485
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
486
+ await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
487
+ await orchestrator.setActivePlan(tempRoot, 'team-1', {
488
+ summary: 'Persist test',
489
+ taskSize: 'simple',
490
+ slices: [],
491
+ preAuthorized: false,
492
+ });
493
+ // Re-read via getOrCreateTeam (triggers normalizeTeamRecord)
494
+ const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
495
+ expect(team.activePlan).toBeDefined();
496
+ expect(team.activePlan.summary).toBe('Persist test');
497
+ });
342
498
  it('selectPlanEngineers excludes BrowserQA from planner selection', async () => {
343
499
  tempRoot = await mkdtemp(join(tmpdir(), 'planner-exclude-browserqa-'));
344
500
  const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
@@ -51,22 +51,4 @@ describe('TeamStateStore', () => {
51
51
  { id: 'older' },
52
52
  ]);
53
53
  });
54
- it('returns null for active team when no active-team.json exists', async () => {
55
- tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
56
- const store = new TeamStateStore('.state');
57
- await expect(store.getActiveTeam(tempRoot)).resolves.toBeNull();
58
- });
59
- it('persists and reads back the active team ID', async () => {
60
- tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
61
- const store = new TeamStateStore('.state');
62
- await store.setActiveTeam(tempRoot, 'team-abc');
63
- await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-abc');
64
- });
65
- it('overwrites the active team ID on subsequent writes', async () => {
66
- tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
67
- const store = new TeamStateStore('.state');
68
- await store.setActiveTeam(tempRoot, 'team-first');
69
- await store.setActiveTeam(tempRoot, 'team-second');
70
- await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-second');
71
- });
72
54
  });
@@ -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;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doingdev/opencode-claude-manager-plugin",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",