@doingdev/opencode-claude-manager-plugin 0.1.64 → 0.1.66

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 (125) hide show
  1. package/README.md +106 -120
  2. package/dist/claude/claude-agent-sdk-adapter.js +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/manager/team-orchestrator.js +1 -1
  5. package/dist/plugin/agents/common.d.ts +2 -2
  6. package/dist/plugin/agents/common.js +5 -0
  7. package/dist/plugin/claude-manager.plugin.js +104 -0
  8. package/dist/plugin/inbox-ops.d.ts +50 -0
  9. package/dist/plugin/inbox-ops.js +166 -0
  10. package/dist/types/contracts.d.ts +18 -0
  11. package/package.json +13 -13
  12. package/dist/claude/session-live-tailer.d.ts +0 -51
  13. package/dist/claude/session-live-tailer.js +0 -269
  14. package/dist/manager/session-controller.d.ts +0 -41
  15. package/dist/manager/session-controller.js +0 -97
  16. package/dist/metadata/claude-metadata.service.d.ts +0 -12
  17. package/dist/metadata/claude-metadata.service.js +0 -38
  18. package/dist/metadata/repo-claude-config-reader.d.ts +0 -7
  19. package/dist/metadata/repo-claude-config-reader.js +0 -154
  20. package/dist/plugin/orchestrator.plugin.d.ts +0 -2
  21. package/dist/plugin/orchestrator.plugin.js +0 -116
  22. package/dist/providers/claude-code-wrapper.d.ts +0 -13
  23. package/dist/providers/claude-code-wrapper.js +0 -13
  24. package/dist/safety/bash-safety.d.ts +0 -21
  25. package/dist/safety/bash-safety.js +0 -62
  26. package/dist/src/claude/claude-agent-sdk-adapter.d.ts +0 -28
  27. package/dist/src/claude/claude-agent-sdk-adapter.js +0 -559
  28. package/dist/src/claude/claude-session.service.d.ts +0 -9
  29. package/dist/src/claude/claude-session.service.js +0 -15
  30. package/dist/src/claude/session-live-tailer.d.ts +0 -51
  31. package/dist/src/claude/session-live-tailer.js +0 -269
  32. package/dist/src/claude/tool-approval-manager.d.ts +0 -30
  33. package/dist/src/claude/tool-approval-manager.js +0 -279
  34. package/dist/src/index.d.ts +0 -5
  35. package/dist/src/index.js +0 -3
  36. package/dist/src/manager/context-tracker.d.ts +0 -32
  37. package/dist/src/manager/context-tracker.js +0 -103
  38. package/dist/src/manager/git-operations.d.ts +0 -18
  39. package/dist/src/manager/git-operations.js +0 -86
  40. package/dist/src/manager/persistent-manager.d.ts +0 -39
  41. package/dist/src/manager/persistent-manager.js +0 -44
  42. package/dist/src/manager/session-controller.d.ts +0 -41
  43. package/dist/src/manager/session-controller.js +0 -97
  44. package/dist/src/manager/team-orchestrator.d.ts +0 -81
  45. package/dist/src/manager/team-orchestrator.js +0 -612
  46. package/dist/src/plugin/agent-hierarchy.d.ts +0 -1
  47. package/dist/src/plugin/agent-hierarchy.js +0 -2
  48. package/dist/src/plugin/agents/browser-qa.d.ts +0 -14
  49. package/dist/src/plugin/agents/browser-qa.js +0 -31
  50. package/dist/src/plugin/agents/common.d.ts +0 -36
  51. package/dist/src/plugin/agents/common.js +0 -59
  52. package/dist/src/plugin/agents/cto.d.ts +0 -9
  53. package/dist/src/plugin/agents/cto.js +0 -39
  54. package/dist/src/plugin/agents/engineers.d.ts +0 -9
  55. package/dist/src/plugin/agents/engineers.js +0 -11
  56. package/dist/src/plugin/agents/index.d.ts +0 -5
  57. package/dist/src/plugin/agents/index.js +0 -5
  58. package/dist/src/plugin/agents/team-planner.d.ts +0 -10
  59. package/dist/src/plugin/agents/team-planner.js +0 -23
  60. package/dist/src/plugin/claude-manager.plugin.d.ts +0 -10
  61. package/dist/src/plugin/claude-manager.plugin.js +0 -950
  62. package/dist/src/plugin/service-factory.d.ts +0 -38
  63. package/dist/src/plugin/service-factory.js +0 -101
  64. package/dist/src/prompts/registry.d.ts +0 -2
  65. package/dist/src/prompts/registry.js +0 -210
  66. package/dist/src/state/file-run-state-store.d.ts +0 -14
  67. package/dist/src/state/file-run-state-store.js +0 -85
  68. package/dist/src/state/team-state-store.d.ts +0 -14
  69. package/dist/src/state/team-state-store.js +0 -88
  70. package/dist/src/state/transcript-store.d.ts +0 -15
  71. package/dist/src/state/transcript-store.js +0 -44
  72. package/dist/src/team/roster.d.ts +0 -5
  73. package/dist/src/team/roster.js +0 -40
  74. package/dist/src/types/contracts.d.ts +0 -261
  75. package/dist/src/types/contracts.js +0 -2
  76. package/dist/src/util/fs-helpers.d.ts +0 -8
  77. package/dist/src/util/fs-helpers.js +0 -21
  78. package/dist/src/util/project-context.d.ts +0 -10
  79. package/dist/src/util/project-context.js +0 -105
  80. package/dist/src/util/transcript-append.d.ts +0 -7
  81. package/dist/src/util/transcript-append.js +0 -29
  82. package/dist/state/file-run-state-store.d.ts +0 -14
  83. package/dist/state/file-run-state-store.js +0 -85
  84. package/dist/test/claude-agent-sdk-adapter.test.d.ts +0 -1
  85. package/dist/test/claude-agent-sdk-adapter.test.js +0 -707
  86. package/dist/test/claude-manager.plugin.test.d.ts +0 -1
  87. package/dist/test/claude-manager.plugin.test.js +0 -316
  88. package/dist/test/context-tracker.test.d.ts +0 -1
  89. package/dist/test/context-tracker.test.js +0 -130
  90. package/dist/test/cto-active-team.test.d.ts +0 -1
  91. package/dist/test/cto-active-team.test.js +0 -199
  92. package/dist/test/file-run-state-store.test.d.ts +0 -1
  93. package/dist/test/file-run-state-store.test.js +0 -82
  94. package/dist/test/fs-helpers.test.d.ts +0 -1
  95. package/dist/test/fs-helpers.test.js +0 -56
  96. package/dist/test/git-operations.test.d.ts +0 -1
  97. package/dist/test/git-operations.test.js +0 -133
  98. package/dist/test/persistent-manager.test.d.ts +0 -1
  99. package/dist/test/persistent-manager.test.js +0 -48
  100. package/dist/test/project-context.test.d.ts +0 -1
  101. package/dist/test/project-context.test.js +0 -92
  102. package/dist/test/prompt-registry.test.d.ts +0 -1
  103. package/dist/test/prompt-registry.test.js +0 -117
  104. package/dist/test/report-claude-event.test.d.ts +0 -1
  105. package/dist/test/report-claude-event.test.js +0 -304
  106. package/dist/test/session-controller.test.d.ts +0 -1
  107. package/dist/test/session-controller.test.js +0 -149
  108. package/dist/test/session-live-tailer.test.d.ts +0 -1
  109. package/dist/test/session-live-tailer.test.js +0 -313
  110. package/dist/test/team-orchestrator.test.d.ts +0 -1
  111. package/dist/test/team-orchestrator.test.js +0 -583
  112. package/dist/test/team-state-store.test.d.ts +0 -1
  113. package/dist/test/team-state-store.test.js +0 -54
  114. package/dist/test/tool-approval-manager.test.d.ts +0 -1
  115. package/dist/test/tool-approval-manager.test.js +0 -260
  116. package/dist/test/transcript-append.test.d.ts +0 -1
  117. package/dist/test/transcript-append.test.js +0 -37
  118. package/dist/test/transcript-store.test.d.ts +0 -1
  119. package/dist/test/transcript-store.test.js +0 -50
  120. package/dist/test/undo-propagation.test.d.ts +0 -1
  121. package/dist/test/undo-propagation.test.js +0 -837
  122. package/dist/util/project-context.d.ts +0 -10
  123. package/dist/util/project-context.js +0 -105
  124. package/dist/vitest.config.d.ts +0 -2
  125. package/dist/vitest.config.js +0 -11
@@ -1,583 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { mkdtemp, rm } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { tmpdir } from 'node:os';
5
- import { TeamOrchestrator } from '../src/manager/team-orchestrator.js';
6
- import { TeamStateStore } from '../src/state/team-state-store.js';
7
- // Full BrowserQA capabilities used in all test orchestrator instances
8
- const BROWSER_QA_TEST_CAPS = {
9
- sessionPrompt: 'Browser QA prompt',
10
- restrictWriteTools: true,
11
- skipModeInstructions: true,
12
- plannerEligible: false,
13
- isRuntimeUnavailableResponse: (text) => text.trimStart().startsWith('PLAYWRIGHT_UNAVAILABLE:'),
14
- runtimeUnavailableTitle: '❌ Playwright unavailable',
15
- };
16
- describe('TeamOrchestrator', () => {
17
- let tempRoot;
18
- afterEach(async () => {
19
- if (tempRoot) {
20
- await rm(tempRoot, { recursive: true, force: true });
21
- }
22
- });
23
- it('dispatches work to a named engineer and persists the Claude session', async () => {
24
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
25
- const runTask = vi
26
- .fn()
27
- .mockResolvedValueOnce({
28
- sessionId: 'ses_tom',
29
- events: [{ type: 'result', text: 'done', turns: 1, totalCostUsd: 0.02 }],
30
- finalText: 'Done.',
31
- turns: 1,
32
- totalCostUsd: 0.02,
33
- inputTokens: 1000,
34
- outputTokens: 200,
35
- contextWindowSize: 200_000,
36
- })
37
- .mockResolvedValueOnce({
38
- sessionId: 'ses_tom',
39
- events: [{ type: 'result', text: 'done again', turns: 2, totalCostUsd: 0.03 }],
40
- finalText: 'Done again.',
41
- turns: 2,
42
- totalCostUsd: 0.03,
43
- inputTokens: 2000,
44
- outputTokens: 300,
45
- contextWindowSize: 200_000,
46
- });
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 });
49
- const first = await orchestrator.dispatchEngineer({
50
- teamId: 'team-1',
51
- cwd: tempRoot,
52
- engineer: 'Tom',
53
- mode: 'explore',
54
- message: 'Investigate the auth flow',
55
- });
56
- const second = await orchestrator.dispatchEngineer({
57
- teamId: 'team-1',
58
- cwd: tempRoot,
59
- engineer: 'Tom',
60
- mode: 'implement',
61
- message: 'Implement the chosen fix',
62
- });
63
- expect(first.sessionId).toBe('ses_tom');
64
- expect(second.sessionId).toBe('ses_tom');
65
- expect(runTask.mock.calls[0]?.[0]).toMatchObject({
66
- resumeSessionId: undefined,
67
- permissionMode: 'acceptEdits',
68
- restrictWriteTools: true,
69
- });
70
- expect(runTask.mock.calls[0]?.[0].systemPrompt).toBeUndefined();
71
- expect(runTask.mock.calls[0]?.[0].prompt).toContain('Base engineer prompt');
72
- expect(runTask.mock.calls[0]?.[0].prompt).toContain('Assigned engineer: Tom.');
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');
76
- expect(runTask.mock.calls[1]?.[0]).toMatchObject({
77
- resumeSessionId: 'ses_tom',
78
- permissionMode: 'acceptEdits',
79
- restrictWriteTools: false,
80
- });
81
- expect(runTask.mock.calls[1]?.[0].systemPrompt).toBeUndefined();
82
- expect(runTask.mock.calls[1]?.[0].prompt).not.toContain('Assigned engineer: Tom.');
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');
86
- const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
87
- expect(team.engineers.find((engineer) => engineer.name === 'Tom')).toMatchObject({
88
- claudeSessionId: 'ses_tom',
89
- busy: false,
90
- lastMode: 'implement',
91
- });
92
- });
93
- it('rejects work when the same engineer is already busy', async () => {
94
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
95
- const store = new TeamStateStore('.state');
96
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
97
- const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
98
- await store.saveTeam({
99
- ...team,
100
- engineers: team.engineers.map((engineer) => engineer.name === 'Tom'
101
- ? {
102
- ...engineer,
103
- busy: true,
104
- }
105
- : engineer),
106
- });
107
- await expect(orchestrator.dispatchEngineer({
108
- teamId: 'team-1',
109
- cwd: tempRoot,
110
- engineer: 'Tom',
111
- mode: 'explore',
112
- message: 'Investigate again',
113
- })).rejects.toThrow('Tom is already working on another assignment.');
114
- });
115
- it('creates two drafts and synthesizes them into one plan', async () => {
116
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
117
- const runTask = vi
118
- .fn()
119
- .mockResolvedValueOnce({
120
- sessionId: 'ses_tom',
121
- events: [],
122
- finalText: '## Objective\nLead plan',
123
- })
124
- .mockResolvedValueOnce({
125
- sessionId: 'ses_maya',
126
- events: [],
127
- finalText: '## Objective\nChallenger plan',
128
- })
129
- .mockResolvedValueOnce({
130
- sessionId: undefined,
131
- events: [],
132
- finalText: '## Synthesis\nCombined plan\n## Recommended Question\nShould we migrate now?\n## Recommended Answer\nNo, defer it.',
133
- });
134
- const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
135
- const result = await orchestrator.planWithTeam({
136
- teamId: 'team-1',
137
- cwd: tempRoot,
138
- request: 'Plan the billing refactor',
139
- leadEngineer: 'Tom',
140
- challengerEngineer: 'Maya',
141
- });
142
- expect(result.drafts).toHaveLength(2);
143
- expect(result.synthesis).toBe('Combined plan');
144
- expect(result.recommendedQuestion).toBe('Should we migrate now?');
145
- expect(result.recommendedAnswer).toBe('No, defer it.');
146
- expect(runTask).toHaveBeenCalledTimes(3);
147
- const synthesisCall = runTask.mock.calls[2]?.[0];
148
- expect(synthesisCall.systemPrompt).toBeUndefined();
149
- expect(synthesisCall.prompt).toContain('Synthesis prompt');
150
- expect(synthesisCall.prompt).toContain('Plan the billing refactor');
151
- });
152
- it('invokes lead, challenger, and synthesis event callbacks', async () => {
153
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
154
- const runTask = vi.fn(async (input, onEvent) => {
155
- // Simulate event callbacks being invoked by the session
156
- if (onEvent) {
157
- await Promise.resolve(onEvent({ type: 'init', text: 'initialized' }));
158
- }
159
- // Return appropriate result based on call count
160
- const calls = runTask.mock.calls.length;
161
- if (calls === 1) {
162
- return {
163
- sessionId: 'ses_tom',
164
- events: [{ type: 'init', text: 'initialized', sessionId: 'ses_tom' }],
165
- finalText: 'Lead plan',
166
- };
167
- }
168
- else if (calls === 2) {
169
- return {
170
- sessionId: 'ses_maya',
171
- events: [{ type: 'init', text: 'initialized', sessionId: 'ses_maya' }],
172
- finalText: 'Challenger plan',
173
- };
174
- }
175
- else {
176
- return {
177
- sessionId: undefined,
178
- events: [{ type: 'init', text: 'initialized', sessionId: undefined }],
179
- finalText: '## Synthesis\nSynthesis\n## Recommended Question\nNONE\n## Recommended Answer\nNONE',
180
- };
181
- }
182
- });
183
- const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
184
- const onLeadEvent = vi.fn();
185
- const onChallengerEvent = vi.fn();
186
- const onSynthesisEvent = vi.fn();
187
- await orchestrator.planWithTeam({
188
- teamId: 'team-1',
189
- cwd: tempRoot,
190
- request: 'Plan the refactor',
191
- leadEngineer: 'Tom',
192
- challengerEngineer: 'Maya',
193
- onLeadEvent,
194
- onChallengerEvent,
195
- onSynthesisEvent,
196
- });
197
- expect(onLeadEvent).toHaveBeenCalled();
198
- expect(onChallengerEvent).toHaveBeenCalled();
199
- expect(onSynthesisEvent).toHaveBeenCalled();
200
- });
201
- it('persists wrapper session memory for an engineer', async () => {
202
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
203
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
204
- await orchestrator.recordWrapperSession(tempRoot, 'team-1', 'Tom', 'wrapper-tom');
205
- await orchestrator.recordWrapperExchange(tempRoot, 'team-1', 'Tom', 'wrapper-tom', 'explore', 'Investigate the auth flow and compare approaches', 'The auth flow uses one shared validator and the cookie refresh path is the main risk.');
206
- const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
207
- expect(team.engineers.find((engineer) => engineer.name === 'Tom')).toMatchObject({
208
- wrapperSessionId: 'wrapper-tom',
209
- lastMode: 'explore',
210
- });
211
- const wrapperContext = await orchestrator.getWrapperSystemContext(tempRoot, 'team-1', 'Tom');
212
- expect(wrapperContext).toContain('Persistent wrapper memory for Tom');
213
- expect(wrapperContext).toContain('assignment [explore]');
214
- expect(wrapperContext).toContain('result [explore]');
215
- await expect(orchestrator.findTeamByWrapperSession(tempRoot, 'wrapper-tom')).resolves.toEqual({
216
- teamId: 'team-1',
217
- engineer: 'Tom',
218
- });
219
- });
220
- it('planWithTeam auto-selects two distinct engineers when names are omitted', async () => {
221
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
222
- const runTask = vi.fn(async (_input, _onEvent) => {
223
- // Return different results for lead vs challenger
224
- const calls = runTask.mock.calls.length;
225
- if (calls === 1) {
226
- return {
227
- sessionId: 'ses_lead',
228
- events: [],
229
- finalText: 'Lead plan',
230
- turns: 1,
231
- totalCostUsd: 0.01,
232
- inputTokens: 100,
233
- outputTokens: 50,
234
- contextWindowSize: 200_000,
235
- };
236
- }
237
- else if (calls === 2) {
238
- return {
239
- sessionId: 'ses_challenger',
240
- events: [],
241
- finalText: 'Challenger plan',
242
- turns: 1,
243
- totalCostUsd: 0.01,
244
- inputTokens: 100,
245
- outputTokens: 50,
246
- contextWindowSize: 200_000,
247
- };
248
- }
249
- else {
250
- return {
251
- sessionId: undefined,
252
- events: [],
253
- finalText: '## Synthesis\nBest plan\n## Recommended Question\nNONE\n## Recommended Answer\nNONE',
254
- };
255
- }
256
- });
257
- const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
258
- const result = await orchestrator.planWithTeam({
259
- teamId: 'team-1',
260
- cwd: tempRoot,
261
- request: 'Plan the refactor',
262
- // NOTE: both leadEngineer and challengerEngineer are omitted
263
- });
264
- expect(result.leadEngineer).toBeDefined();
265
- expect(result.challengerEngineer).toBeDefined();
266
- expect(result.leadEngineer).not.toEqual(result.challengerEngineer);
267
- });
268
- it('throws error when fewer than 2 viable engineers exist for planning', async () => {
269
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
270
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
271
- // Mark all engineers as busy
272
- const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
273
- for (const engineer of team.engineers) {
274
- await orchestrator['updateEngineer'](tempRoot, 'team-1', engineer.name, (e) => ({
275
- ...e,
276
- busy: true,
277
- busySince: new Date().toISOString(),
278
- }));
279
- }
280
- await expect(orchestrator.planWithTeam({
281
- teamId: 'team-1',
282
- cwd: tempRoot,
283
- request: 'Plan something',
284
- })).rejects.toThrow('Not enough available engineers for dual planning');
285
- });
286
- it('context exhaustion retries exactly once with same assignment message and fresh session', async () => {
287
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
288
- let callCount = 0;
289
- let lastInputMessage = '';
290
- const runTask = vi.fn(async (input) => {
291
- callCount++;
292
- lastInputMessage = input.prompt ?? '';
293
- // First call throws context exhaustion, second succeeds
294
- if (callCount === 1) {
295
- const error = new Error('Token limit exceeded: context exhausted');
296
- throw error;
297
- }
298
- return {
299
- sessionId: 'ses_retry',
300
- events: [],
301
- finalText: 'Success after retry',
302
- turns: 1,
303
- totalCostUsd: 0.02,
304
- inputTokens: 100,
305
- outputTokens: 50,
306
- contextWindowSize: 200_000,
307
- };
308
- });
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 });
311
- const allEvents = [];
312
- const result = await orchestrator.dispatchEngineer({
313
- teamId: 'team-1',
314
- cwd: tempRoot,
315
- engineer: 'Tom',
316
- mode: 'implement',
317
- message: 'Fix the bug',
318
- onEvent: (event) => {
319
- allEvents.push({ type: event.type, text: event.text });
320
- },
321
- });
322
- // Verify retry happened exactly once (2 runTask calls total)
323
- expect(callCount).toBe(2);
324
- // Verify status event was emitted for context exhaustion
325
- const statusEvent = allEvents.find((e) => e.type === 'status');
326
- expect(statusEvent?.text).toContain('Context exhausted');
327
- // Verify the retry message is the same (contains the original task)
328
- expect(lastInputMessage).toContain('Fix the bug');
329
- // Verify success result
330
- expect(result.finalText).toBe('Success after retry');
331
- });
332
- it('rejects BrowserQA with implement mode', async () => {
333
- tempRoot = await mkdtemp(join(tmpdir(), 'browserqa-implement-'));
334
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
335
- const error = await orchestrator
336
- .dispatchEngineer({
337
- teamId: 'team-1',
338
- cwd: tempRoot,
339
- engineer: 'BrowserQA',
340
- mode: 'implement',
341
- message: 'Write a feature',
342
- })
343
- .catch((e) => e);
344
- expect(error).toBeInstanceOf(Error);
345
- expect(error.message).toContain('BrowserQA is a browser QA specialist');
346
- expect(error.message).toContain('does not support implement mode');
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
- });
417
- it('setActivePlan persists plan with slices on TeamRecord', async () => {
418
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
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 });
420
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
421
- const activePlan = await orchestrator.setActivePlan(tempRoot, 'team-1', {
422
- summary: 'Implement billing refactor in three slices',
423
- taskSize: 'large',
424
- slices: ['types + contracts', 'core logic', 'tests'],
425
- preAuthorized: false,
426
- });
427
- expect(activePlan.id).toMatch(/^plan-/);
428
- expect(activePlan.summary).toBe('Implement billing refactor in three slices');
429
- expect(activePlan.taskSize).toBe('large');
430
- expect(activePlan.preAuthorized).toBe(false);
431
- expect(activePlan.slices).toHaveLength(3);
432
- expect(activePlan.slices[0]).toMatchObject({
433
- index: 0,
434
- description: 'types + contracts',
435
- status: 'pending',
436
- });
437
- expect(activePlan.slices[1]).toMatchObject({
438
- index: 1,
439
- description: 'core logic',
440
- status: 'pending',
441
- });
442
- expect(activePlan.slices[2]).toMatchObject({
443
- index: 2,
444
- description: 'tests',
445
- status: 'pending',
446
- });
447
- expect(activePlan.currentSliceIndex).toBe(0);
448
- expect(activePlan.confirmedAt).not.toBeNull();
449
- const retrieved = await orchestrator.getActivePlan(tempRoot, 'team-1');
450
- expect(retrieved).toMatchObject({ id: activePlan.id, taskSize: 'large' });
451
- });
452
- it('clearActivePlan removes activePlan from TeamRecord', async () => {
453
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
454
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
455
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
456
- await orchestrator.setActivePlan(tempRoot, 'team-1', {
457
- summary: 'Small task',
458
- taskSize: 'simple',
459
- slices: [],
460
- preAuthorized: false,
461
- });
462
- const beforeClear = await orchestrator.getActivePlan(tempRoot, 'team-1');
463
- expect(beforeClear).not.toBeNull();
464
- await orchestrator.clearActivePlan(tempRoot, 'team-1');
465
- const afterClear = await orchestrator.getActivePlan(tempRoot, 'team-1');
466
- expect(afterClear).toBeNull();
467
- });
468
- it('updateActivePlanSlice marks a slice done and advances currentSliceIndex', async () => {
469
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
470
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
471
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
472
- await orchestrator.setActivePlan(tempRoot, 'team-1', {
473
- summary: 'Two-slice task',
474
- taskSize: 'large',
475
- slices: ['slice A', 'slice B'],
476
- preAuthorized: true,
477
- });
478
- await orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done');
479
- const plan = await orchestrator.getActivePlan(tempRoot, 'team-1');
480
- expect(plan).not.toBeNull();
481
- expect(plan.slices[0]).toMatchObject({ index: 0, status: 'done' });
482
- expect(plan.slices[0].completedAt).toBeDefined();
483
- expect(plan.slices[1]).toMatchObject({ index: 1, status: 'pending' });
484
- expect(plan.currentSliceIndex).toBe(1);
485
- });
486
- it('updateActivePlanSlice sets currentSliceIndex to null when the final slice is completed', async () => {
487
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
488
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
489
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
490
- await orchestrator.setActivePlan(tempRoot, 'team-1', {
491
- summary: 'Single-slice task',
492
- taskSize: 'large',
493
- slices: ['ship the feature'],
494
- preAuthorized: true,
495
- });
496
- // Complete the only slice (index 0, which is also the last)
497
- await orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done');
498
- const plan = await orchestrator.getActivePlan(tempRoot, 'team-1');
499
- expect(plan).not.toBeNull();
500
- expect(plan.slices[0]).toMatchObject({ index: 0, status: 'done' });
501
- expect(plan.currentSliceIndex).toBeNull();
502
- });
503
- it('setActivePlan sets currentSliceIndex to null when no slices are provided', async () => {
504
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
505
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
506
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
507
- const plan = await orchestrator.setActivePlan(tempRoot, 'team-1', {
508
- summary: 'Simple no-slice task',
509
- taskSize: 'simple',
510
- slices: [],
511
- preAuthorized: false,
512
- });
513
- expect(plan.currentSliceIndex).toBeNull();
514
- expect(plan.slices).toHaveLength(0);
515
- });
516
- it('updateActivePlanSlice throws when team has no active plan', async () => {
517
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
518
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
519
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
520
- await expect(orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done')).rejects.toThrow('has no active plan');
521
- });
522
- it('updateActivePlanSlice throws when slice index does not exist', async () => {
523
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
524
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
525
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
526
- await orchestrator.setActivePlan(tempRoot, 'team-1', {
527
- summary: 'Two-slice task',
528
- taskSize: 'large',
529
- slices: ['slice A', 'slice B'],
530
- preAuthorized: false,
531
- });
532
- // Index 5 does not exist (only 0 and 1 exist)
533
- await expect(orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 5, 'done')).rejects.toThrow('slice index 5 does not exist');
534
- });
535
- it('updateActivePlanSlice throws when active plan has no slices', async () => {
536
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
537
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
538
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
539
- await orchestrator.setActivePlan(tempRoot, 'team-1', {
540
- summary: 'No-slice plan',
541
- taskSize: 'simple',
542
- slices: [],
543
- preAuthorized: false,
544
- });
545
- await expect(orchestrator.updateActivePlanSlice(tempRoot, 'team-1', 0, 'done')).rejects.toThrow('plan has no slices');
546
- });
547
- it('getActivePlan returns null for a new team with no active plan', async () => {
548
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
549
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
550
- const plan = await orchestrator.getActivePlan(tempRoot, 'team-1');
551
- expect(plan).toBeNull();
552
- });
553
- it('normalizeTeamRecord preserves activePlan from persisted records', async () => {
554
- tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
555
- const store = new TeamStateStore('.state');
556
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
557
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
558
- await orchestrator.setActivePlan(tempRoot, 'team-1', {
559
- summary: 'Persist test',
560
- taskSize: 'simple',
561
- slices: [],
562
- preAuthorized: false,
563
- });
564
- // Re-read via getOrCreateTeam (triggers normalizeTeamRecord)
565
- const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
566
- expect(team.activePlan).toBeDefined();
567
- expect(team.activePlan.summary).toBe('Persist test');
568
- });
569
- it('selectPlanEngineers excludes BrowserQA from planner selection', async () => {
570
- tempRoot = await mkdtemp(join(tmpdir(), 'planner-exclude-browserqa-'));
571
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
572
- // Create a team with all engineers
573
- await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
574
- // Select plan engineers
575
- const selection = await orchestrator.selectPlanEngineers(tempRoot, 'team-1');
576
- // Both lead and challenger should be from general engineers only
577
- const generalEngineers = ['Tom', 'John', 'Maya', 'Sara', 'Alex'];
578
- expect(generalEngineers).toContain(selection.lead);
579
- expect(generalEngineers).toContain(selection.challenger);
580
- expect(selection.lead).not.toBe('BrowserQA');
581
- expect(selection.challenger).not.toBe('BrowserQA');
582
- });
583
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,54 +0,0 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
2
- import { mkdtemp, rm } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { tmpdir } from 'node:os';
5
- import { TeamStateStore } from '../src/state/team-state-store.js';
6
- import { createEmptyTeamRecord } from '../src/team/roster.js';
7
- describe('TeamStateStore', () => {
8
- let tempRoot;
9
- afterEach(async () => {
10
- if (tempRoot) {
11
- await rm(tempRoot, { recursive: true, force: true });
12
- }
13
- });
14
- it('saves and reads a team record', async () => {
15
- tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
16
- const store = new TeamStateStore('.state');
17
- const team = createEmptyTeamRecord('team-1', tempRoot);
18
- await store.saveTeam(team);
19
- await expect(store.getTeam(tempRoot, 'team-1')).resolves.toEqual(team);
20
- });
21
- it('updates one engineer inside a team record', async () => {
22
- tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
23
- const store = new TeamStateStore('.state');
24
- const team = createEmptyTeamRecord('team-1', tempRoot);
25
- await store.saveTeam(team);
26
- const updated = await store.updateTeam(tempRoot, 'team-1', (existing) => ({
27
- ...existing,
28
- updatedAt: '2026-01-01T00:00:00.000Z',
29
- engineers: existing.engineers.map((engineer) => engineer.name === 'Tom'
30
- ? {
31
- ...engineer,
32
- claudeSessionId: 'ses_tom',
33
- lastTaskSummary: 'Plan the feature',
34
- }
35
- : engineer),
36
- }));
37
- expect(updated.engineers.find((engineer) => engineer.name === 'Tom')?.claudeSessionId).toBe('ses_tom');
38
- expect(updated.engineers.find((engineer) => engineer.name === 'John')?.claudeSessionId).toBe(null);
39
- });
40
- it('lists teams newest first', async () => {
41
- tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
42
- const store = new TeamStateStore('.state');
43
- const older = createEmptyTeamRecord('older', tempRoot);
44
- older.updatedAt = '2026-01-01T00:00:00.000Z';
45
- const newer = createEmptyTeamRecord('newer', tempRoot);
46
- newer.updatedAt = '2026-01-02T00:00:00.000Z';
47
- await store.saveTeam(older);
48
- await store.saveTeam(newer);
49
- await expect(store.listTeams(tempRoot)).resolves.toMatchObject([
50
- { id: 'newer' },
51
- { id: 'older' },
52
- ]);
53
- });
54
- });
@@ -1 +0,0 @@
1
- export {};