@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.
- package/README.md +4 -3
- package/dist/claude/claude-agent-sdk-adapter.d.ts +3 -1
- package/dist/claude/claude-agent-sdk-adapter.js +57 -6
- package/dist/manager/team-orchestrator.d.ts +10 -1
- package/dist/manager/team-orchestrator.js +86 -4
- package/dist/plugin/agents/team-planner.js +1 -1
- package/dist/plugin/claude-manager.plugin.js +14 -0
- package/dist/plugin/service-factory.d.ts +1 -0
- package/dist/plugin/service-factory.js +3 -1
- package/dist/prompts/registry.js +38 -20
- package/dist/src/claude/claude-agent-sdk-adapter.d.ts +3 -1
- package/dist/src/claude/claude-agent-sdk-adapter.js +57 -6
- package/dist/src/manager/team-orchestrator.d.ts +10 -1
- package/dist/src/manager/team-orchestrator.js +86 -4
- package/dist/src/plugin/agents/team-planner.js +1 -1
- package/dist/src/plugin/claude-manager.plugin.js +14 -0
- package/dist/src/plugin/service-factory.d.ts +1 -0
- package/dist/src/plugin/service-factory.js +3 -1
- package/dist/src/prompts/registry.js +38 -20
- package/dist/src/types/contracts.d.ts +22 -3
- package/dist/src/util/fs-helpers.d.ts +6 -0
- package/dist/src/util/fs-helpers.js +11 -0
- package/dist/test/claude-agent-sdk-adapter.test.js +118 -1
- package/dist/test/claude-manager.plugin.test.js +47 -3
- package/dist/test/fs-helpers.test.d.ts +1 -0
- package/dist/test/fs-helpers.test.js +56 -0
- package/dist/test/prompt-registry.test.js +54 -6
- package/dist/test/team-orchestrator.test.js +176 -2
- package/dist/types/contracts.d.ts +22 -3
- package/dist/util/fs-helpers.d.ts +6 -0
- package/dist/util/fs-helpers.js +11 -0
- 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
|
|
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('
|
|
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
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
7
|
+
/** Visible subagent prompt for teamPlanner — thin wrapper that calls plan_with_team. */
|
|
8
8
|
teamPlannerPrompt: string;
|
|
9
|
-
/** Visible subagent prompt for browserQa — thin
|
|
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>;
|
package/dist/util/fs-helpers.js
CHANGED
|
@@ -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
|
+
}
|