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