@doingdev/opencode-claude-manager-plugin 0.1.55 → 0.1.57
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/dist/manager/team-orchestrator.d.ts +10 -3
- package/dist/manager/team-orchestrator.js +108 -17
- package/dist/plugin/agent-hierarchy.js +7 -3
- package/dist/plugin/claude-manager.plugin.d.ts +8 -0
- package/dist/plugin/claude-manager.plugin.js +38 -15
- package/dist/prompts/registry.js +107 -57
- package/dist/src/manager/team-orchestrator.d.ts +12 -5
- package/dist/src/manager/team-orchestrator.js +111 -20
- package/dist/src/plugin/agent-hierarchy.d.ts +2 -2
- package/dist/src/plugin/agent-hierarchy.js +15 -20
- package/dist/src/plugin/claude-manager.plugin.d.ts +8 -0
- package/dist/src/plugin/claude-manager.plugin.js +51 -27
- package/dist/src/plugin/service-factory.js +1 -1
- package/dist/src/prompts/registry.js +115 -57
- package/dist/src/types/contracts.d.ts +4 -1
- package/dist/test/claude-manager.plugin.test.js +94 -13
- package/dist/test/prompt-registry.test.js +26 -12
- package/dist/test/report-claude-event.test.js +16 -3
- package/dist/test/team-orchestrator.test.js +127 -7
- package/dist/types/contracts.d.ts +1 -1
- package/package.json +1 -1
|
@@ -18,8 +18,8 @@ export declare class TeamOrchestrator {
|
|
|
18
18
|
private readonly teamStore;
|
|
19
19
|
private readonly transcriptStore;
|
|
20
20
|
private readonly engineerSessionPrompt;
|
|
21
|
-
private readonly
|
|
22
|
-
constructor(sessions: ClaudeSessionService, teamStore: TeamStateStore, transcriptStore: TranscriptStore, engineerSessionPrompt: string,
|
|
21
|
+
private readonly planSynthesisPrompt;
|
|
22
|
+
constructor(sessions: ClaudeSessionService, teamStore: TeamStateStore, transcriptStore: TranscriptStore, engineerSessionPrompt: string, planSynthesisPrompt: string);
|
|
23
23
|
getOrCreateTeam(cwd: string, teamId: string): Promise<TeamRecord>;
|
|
24
24
|
listTeams(cwd: string): Promise<TeamRecord[]>;
|
|
25
25
|
recordWrapperSession(cwd: string, teamId: string, engineer: EngineerName, wrapperSessionId: string): Promise<void>;
|
|
@@ -33,7 +33,7 @@ export declare class TeamOrchestrator {
|
|
|
33
33
|
clearSession?: boolean;
|
|
34
34
|
clearHistory?: boolean;
|
|
35
35
|
}): Promise<void>;
|
|
36
|
-
dispatchEngineer(input: DispatchEngineerInput): Promise<EngineerTaskResult>;
|
|
36
|
+
dispatchEngineer(input: DispatchEngineerInput, retryCount?: number): Promise<EngineerTaskResult>;
|
|
37
37
|
static classifyError(error: unknown): EngineerFailureResult & {
|
|
38
38
|
cause: unknown;
|
|
39
39
|
};
|
|
@@ -41,8 +41,8 @@ export declare class TeamOrchestrator {
|
|
|
41
41
|
teamId: string;
|
|
42
42
|
cwd: string;
|
|
43
43
|
request: string;
|
|
44
|
-
leadEngineer
|
|
45
|
-
challengerEngineer
|
|
44
|
+
leadEngineer?: EngineerName;
|
|
45
|
+
challengerEngineer?: EngineerName;
|
|
46
46
|
model?: string;
|
|
47
47
|
abortSignal?: AbortSignal;
|
|
48
48
|
onLeadEvent?: ClaudeSessionEventHandler;
|
|
@@ -53,7 +53,14 @@ export declare class TeamOrchestrator {
|
|
|
53
53
|
private reserveEngineer;
|
|
54
54
|
private getEngineerState;
|
|
55
55
|
private normalizeTeamRecord;
|
|
56
|
+
getAvailableEngineers(team: TeamRecord): EngineerName[];
|
|
57
|
+
selectPlanEngineers(cwd: string, teamId: string, preferredLead?: EngineerName, preferredChallenger?: EngineerName): Promise<{
|
|
58
|
+
lead: EngineerName;
|
|
59
|
+
challenger: EngineerName;
|
|
60
|
+
}>;
|
|
56
61
|
private buildSessionSystemPrompt;
|
|
57
62
|
private buildEngineerPrompt;
|
|
58
63
|
}
|
|
64
|
+
export declare function getFailureGuidanceText(failureKind: string): string;
|
|
65
|
+
export declare function createActionableError(failure: EngineerFailureResult, originalError: unknown): Error;
|
|
59
66
|
export {};
|
|
@@ -6,13 +6,13 @@ export class TeamOrchestrator {
|
|
|
6
6
|
teamStore;
|
|
7
7
|
transcriptStore;
|
|
8
8
|
engineerSessionPrompt;
|
|
9
|
-
|
|
10
|
-
constructor(sessions, teamStore, transcriptStore, engineerSessionPrompt,
|
|
9
|
+
planSynthesisPrompt;
|
|
10
|
+
constructor(sessions, teamStore, transcriptStore, engineerSessionPrompt, planSynthesisPrompt) {
|
|
11
11
|
this.sessions = sessions;
|
|
12
12
|
this.teamStore = teamStore;
|
|
13
13
|
this.transcriptStore = transcriptStore;
|
|
14
14
|
this.engineerSessionPrompt = engineerSessionPrompt;
|
|
15
|
-
this.
|
|
15
|
+
this.planSynthesisPrompt = planSynthesisPrompt;
|
|
16
16
|
}
|
|
17
17
|
async getOrCreateTeam(cwd, teamId) {
|
|
18
18
|
const existing = await this.teamStore.getTeam(cwd, teamId);
|
|
@@ -88,7 +88,7 @@ export class TeamOrchestrator {
|
|
|
88
88
|
context: options?.clearSession ? createEmptyEngineerRecord(engineer).context : entry.context,
|
|
89
89
|
}));
|
|
90
90
|
}
|
|
91
|
-
async dispatchEngineer(input) {
|
|
91
|
+
async dispatchEngineer(input, retryCount = 0) {
|
|
92
92
|
const team = await this.getOrCreateTeam(input.cwd, input.teamId);
|
|
93
93
|
const engineerState = this.getEngineerState(team, input.engineer);
|
|
94
94
|
await this.reserveEngineer(input.cwd, input.teamId, input.engineer);
|
|
@@ -106,10 +106,9 @@ export class TeamOrchestrator {
|
|
|
106
106
|
}
|
|
107
107
|
const result = await this.sessions.runTask({
|
|
108
108
|
cwd: input.cwd,
|
|
109
|
-
prompt:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
: this.buildSessionSystemPrompt(input.engineer, input.mode),
|
|
109
|
+
prompt: engineerState.claudeSessionId
|
|
110
|
+
? this.buildEngineerPrompt(input.mode, input.message)
|
|
111
|
+
: `${this.buildSessionSystemPrompt(input.engineer, input.mode)}\n\n${this.buildEngineerPrompt(input.mode, input.message)}`,
|
|
113
112
|
resumeSessionId: engineerState.claudeSessionId ?? undefined,
|
|
114
113
|
persistSession: true,
|
|
115
114
|
includePartialMessages: true,
|
|
@@ -162,6 +161,39 @@ export class TeamOrchestrator {
|
|
|
162
161
|
busy: false,
|
|
163
162
|
busySince: null,
|
|
164
163
|
}));
|
|
164
|
+
// Handle context exhaustion with automatic retry (max 1 retry)
|
|
165
|
+
const classified = TeamOrchestrator.classifyError(error);
|
|
166
|
+
if (classified.failureKind === 'contextExhausted' && retryCount === 0) {
|
|
167
|
+
// Reset the engineer's session and retry once with fresh session
|
|
168
|
+
await this.resetEngineer(input.cwd, input.teamId, input.engineer, {
|
|
169
|
+
clearSession: true,
|
|
170
|
+
clearHistory: false,
|
|
171
|
+
});
|
|
172
|
+
// Emit status event before retry
|
|
173
|
+
await input.onEvent?.({
|
|
174
|
+
type: 'status',
|
|
175
|
+
text: 'Context exhausted; resetting session and retrying once with a fresh session.',
|
|
176
|
+
});
|
|
177
|
+
try {
|
|
178
|
+
// Retry dispatch with fresh session (retryCount=1 prevents infinite loop)
|
|
179
|
+
// Use the exact same assignment message without modification
|
|
180
|
+
return await this.dispatchEngineer(input, 1);
|
|
181
|
+
}
|
|
182
|
+
catch (retryError) {
|
|
183
|
+
// If retry also fails with a different error, preserve retry failure info
|
|
184
|
+
const retryClassified = TeamOrchestrator.classifyError(retryError);
|
|
185
|
+
if (retryClassified.failureKind !== classified.failureKind) {
|
|
186
|
+
// Create an error that shows both failures
|
|
187
|
+
const combinedMessage = `Initial: ${classified.failureKind} (${classified.message})\n` +
|
|
188
|
+
`After retry: ${retryClassified.failureKind} (${retryClassified.message})`;
|
|
189
|
+
const combinedError = new Error(combinedMessage);
|
|
190
|
+
Object.assign(combinedError, { cause: retryError });
|
|
191
|
+
throw combinedError;
|
|
192
|
+
}
|
|
193
|
+
// Same error type on retry, throw the retry error (more recent state)
|
|
194
|
+
throw retryError;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
165
197
|
throw error;
|
|
166
198
|
}
|
|
167
199
|
}
|
|
@@ -193,14 +225,13 @@ export class TeamOrchestrator {
|
|
|
193
225
|
};
|
|
194
226
|
}
|
|
195
227
|
async planWithTeam(input) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
228
|
+
// Auto-select engineers if not provided
|
|
229
|
+
const { lead: leadEngineer, challenger: challengerEngineer } = await this.selectPlanEngineers(input.cwd, input.teamId, input.leadEngineer, input.challengerEngineer);
|
|
199
230
|
const [leadDraft, challengerDraft] = await Promise.all([
|
|
200
231
|
this.dispatchEngineer({
|
|
201
232
|
teamId: input.teamId,
|
|
202
233
|
cwd: input.cwd,
|
|
203
|
-
engineer:
|
|
234
|
+
engineer: leadEngineer,
|
|
204
235
|
mode: 'explore',
|
|
205
236
|
message: buildPlanDraftRequest('lead', input.request),
|
|
206
237
|
model: input.model,
|
|
@@ -210,7 +241,7 @@ export class TeamOrchestrator {
|
|
|
210
241
|
this.dispatchEngineer({
|
|
211
242
|
teamId: input.teamId,
|
|
212
243
|
cwd: input.cwd,
|
|
213
|
-
engineer:
|
|
244
|
+
engineer: challengerEngineer,
|
|
214
245
|
mode: 'explore',
|
|
215
246
|
message: buildPlanDraftRequest('challenger', input.request),
|
|
216
247
|
model: input.model,
|
|
@@ -224,8 +255,7 @@ export class TeamOrchestrator {
|
|
|
224
255
|
];
|
|
225
256
|
const synthesisResult = await this.sessions.runTask({
|
|
226
257
|
cwd: input.cwd,
|
|
227
|
-
prompt: buildSynthesisPrompt(input.request, drafts)
|
|
228
|
-
systemPrompt: buildSynthesisSystemPrompt(this.architectSystemPrompt),
|
|
258
|
+
prompt: `${this.planSynthesisPrompt}\n\n${buildSynthesisPrompt(input.request, drafts)}`,
|
|
229
259
|
persistSession: false,
|
|
230
260
|
includePartialMessages: false,
|
|
231
261
|
permissionMode: 'acceptEdits',
|
|
@@ -239,8 +269,8 @@ export class TeamOrchestrator {
|
|
|
239
269
|
return {
|
|
240
270
|
teamId: input.teamId,
|
|
241
271
|
request: input.request,
|
|
242
|
-
leadEngineer
|
|
243
|
-
challengerEngineer
|
|
272
|
+
leadEngineer,
|
|
273
|
+
challengerEngineer,
|
|
244
274
|
drafts,
|
|
245
275
|
synthesis: parsedSynthesis.synthesis,
|
|
246
276
|
recommendedQuestion: parsedSynthesis.recommendedQuestion,
|
|
@@ -296,6 +326,47 @@ export class TeamOrchestrator {
|
|
|
296
326
|
engineers: createEmptyTeamRecord(team.id, team.cwd).engineers.map((engineer) => engineerMap.get(engineer.name) ?? engineer),
|
|
297
327
|
};
|
|
298
328
|
}
|
|
329
|
+
getAvailableEngineers(team) {
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
return team.engineers
|
|
332
|
+
.filter((engineer) => {
|
|
333
|
+
if (!engineer.busy)
|
|
334
|
+
return true;
|
|
335
|
+
// If an engineer has been marked busy but the lease expired, they're available
|
|
336
|
+
if (engineer.busySince) {
|
|
337
|
+
const leaseExpired = now - new Date(engineer.busySince).getTime() > BUSY_LEASE_MS;
|
|
338
|
+
return leaseExpired;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
})
|
|
342
|
+
.sort((a, b) => {
|
|
343
|
+
// Prefer engineers with lower context pressure and less-recently-used
|
|
344
|
+
const aContext = a.context.estimatedContextPercent ?? 0;
|
|
345
|
+
const bContext = b.context.estimatedContextPercent ?? 0;
|
|
346
|
+
if (aContext !== bContext) {
|
|
347
|
+
return aContext - bContext; // Lower context first
|
|
348
|
+
}
|
|
349
|
+
// If context is equal, prefer less-recently-used
|
|
350
|
+
const aTime = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0;
|
|
351
|
+
const bTime = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0;
|
|
352
|
+
return aTime - bTime; // Earlier usage time first
|
|
353
|
+
})
|
|
354
|
+
.map((engineer) => engineer.name);
|
|
355
|
+
}
|
|
356
|
+
async selectPlanEngineers(cwd, teamId, preferredLead, preferredChallenger) {
|
|
357
|
+
const team = await this.getOrCreateTeam(cwd, teamId);
|
|
358
|
+
const available = this.getAvailableEngineers(team);
|
|
359
|
+
if (available.length < 2) {
|
|
360
|
+
throw new Error(`Not enough available engineers for dual planning. Need 2, found ${available.length}.`);
|
|
361
|
+
}
|
|
362
|
+
const lead = preferredLead ?? available[0];
|
|
363
|
+
const foundChallenger = preferredChallenger ?? available.find((e) => e !== lead);
|
|
364
|
+
const challenger = foundChallenger ?? available[1];
|
|
365
|
+
if (lead === challenger) {
|
|
366
|
+
throw new Error('Cannot use the same engineer for both lead and challenger.');
|
|
367
|
+
}
|
|
368
|
+
return { lead, challenger };
|
|
369
|
+
}
|
|
299
370
|
buildSessionSystemPrompt(engineer, mode) {
|
|
300
371
|
return [
|
|
301
372
|
this.engineerSessionPrompt,
|
|
@@ -365,9 +436,6 @@ function buildPlanDraftRequest(perspective, request) {
|
|
|
365
436
|
`User request: ${request}`,
|
|
366
437
|
].join('\n');
|
|
367
438
|
}
|
|
368
|
-
function buildSynthesisSystemPrompt(architectSystemPrompt) {
|
|
369
|
-
return architectSystemPrompt;
|
|
370
|
-
}
|
|
371
439
|
function buildSynthesisPrompt(request, drafts) {
|
|
372
440
|
return [
|
|
373
441
|
`User request: ${request}`,
|
|
@@ -403,3 +471,26 @@ function normalizeOptionalSection(value) {
|
|
|
403
471
|
function escapeRegExp(value) {
|
|
404
472
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
405
473
|
}
|
|
474
|
+
export function getFailureGuidanceText(failureKind) {
|
|
475
|
+
switch (failureKind) {
|
|
476
|
+
case 'contextExhausted':
|
|
477
|
+
return 'Context exhausted after using all available tokens. The engineer was reset and the assignment retried once. If it still fails, the task may be too large; consider breaking it into smaller steps.';
|
|
478
|
+
case 'engineerBusy':
|
|
479
|
+
return 'This engineer is currently working on another assignment. Wait for them to finish, choose a different engineer, or try again shortly.';
|
|
480
|
+
case 'toolDenied':
|
|
481
|
+
return 'A tool permission was denied during the assignment. Check the approval policy and tool permissions, then retry.';
|
|
482
|
+
case 'aborted':
|
|
483
|
+
return 'The assignment was cancelled by the user or an abort signal was triggered. Review the request and try again.';
|
|
484
|
+
case 'sdkError':
|
|
485
|
+
return 'An SDK error occurred during the assignment. Check logs for details, ensure the Claude session is healthy, and retry.';
|
|
486
|
+
default:
|
|
487
|
+
return 'An unknown error occurred during the assignment. Check logs and retry.';
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
export function createActionableError(failure, originalError) {
|
|
491
|
+
const guidance = getFailureGuidanceText(failure.failureKind);
|
|
492
|
+
const errorMessage = `[${failure.failureKind}] ${failure.message}\n\n` + `Next steps: ${guidance}`;
|
|
493
|
+
const error = new Error(errorMessage);
|
|
494
|
+
Object.assign(error, { cause: originalError });
|
|
495
|
+
return error;
|
|
496
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EngineerName, ManagerPromptRegistry } from '../types/contracts.js';
|
|
2
2
|
export declare const AGENT_CTO = "cto";
|
|
3
|
-
export declare const
|
|
3
|
+
export declare const AGENT_TEAM_PLANNER = "team-planner";
|
|
4
4
|
export declare const ENGINEER_AGENT_IDS: {
|
|
5
5
|
readonly Tom: "tom";
|
|
6
6
|
readonly John: "john";
|
|
@@ -42,7 +42,7 @@ export declare function buildEngineerAgentConfig(prompts: ManagerPromptRegistry,
|
|
|
42
42
|
permission: AgentPermission;
|
|
43
43
|
prompt: string;
|
|
44
44
|
};
|
|
45
|
-
export declare function
|
|
45
|
+
export declare function buildTeamPlannerAgentConfig(prompts: ManagerPromptRegistry): {
|
|
46
46
|
description: string;
|
|
47
47
|
mode: "subagent";
|
|
48
48
|
hidden: boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TEAM_ENGINEERS } from '../team/roster.js';
|
|
2
2
|
export const AGENT_CTO = 'cto';
|
|
3
|
-
export const
|
|
3
|
+
export const AGENT_TEAM_PLANNER = 'team-planner';
|
|
4
4
|
export const ENGINEER_AGENT_IDS = {
|
|
5
5
|
Tom: 'tom',
|
|
6
6
|
John: 'john',
|
|
@@ -38,24 +38,15 @@ const CTO_READONLY_TOOLS = {
|
|
|
38
38
|
todoread: 'allow',
|
|
39
39
|
question: 'allow',
|
|
40
40
|
};
|
|
41
|
-
function
|
|
41
|
+
function buildTeamPlannerPermissions() {
|
|
42
42
|
const denied = {};
|
|
43
43
|
for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
|
|
44
44
|
denied[toolId] = 'deny';
|
|
45
45
|
}
|
|
46
46
|
return {
|
|
47
47
|
'*': 'deny',
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
glob: 'allow',
|
|
51
|
-
list: 'allow',
|
|
52
|
-
codesearch: 'allow',
|
|
53
|
-
webfetch: 'deny',
|
|
54
|
-
websearch: 'deny',
|
|
55
|
-
lsp: 'deny',
|
|
56
|
-
todowrite: 'deny',
|
|
57
|
-
todoread: 'deny',
|
|
58
|
-
question: 'deny',
|
|
48
|
+
plan_with_team: 'allow',
|
|
49
|
+
question: 'allow',
|
|
59
50
|
...denied,
|
|
60
51
|
};
|
|
61
52
|
}
|
|
@@ -70,9 +61,13 @@ function buildCtoPermissions() {
|
|
|
70
61
|
}
|
|
71
62
|
const taskPermissions = { '*': 'deny' };
|
|
72
63
|
for (const engineer of ENGINEER_AGENT_NAMES) {
|
|
73
|
-
|
|
64
|
+
const agentId = ENGINEER_AGENT_IDS[engineer];
|
|
65
|
+
// Support both uppercase (user-friendly) and lowercase (canonical) agent IDs.
|
|
66
|
+
// This ensures both task({ subagent_type: 'Tom' }) and task({ subagent_type: 'tom' }) work.
|
|
67
|
+
taskPermissions[engineer] = 'allow'; // 'Tom', 'John', etc.
|
|
68
|
+
taskPermissions[agentId] = 'allow'; // 'tom', 'john', etc.
|
|
74
69
|
}
|
|
75
|
-
taskPermissions[
|
|
70
|
+
taskPermissions[AGENT_TEAM_PLANNER] = 'allow';
|
|
76
71
|
return {
|
|
77
72
|
'*': 'deny',
|
|
78
73
|
...CTO_READONLY_TOOLS,
|
|
@@ -103,7 +98,7 @@ export function buildCtoAgentConfig(prompts) {
|
|
|
103
98
|
}
|
|
104
99
|
export function buildEngineerAgentConfig(prompts, engineer) {
|
|
105
100
|
return {
|
|
106
|
-
description: `${engineer} is a persistent engineer who works through one Claude Code session and remembers prior turns.`,
|
|
101
|
+
description: `${engineer} is a persistent engineer who works through one Claude Code session and remembers prior turns. Receives structured assignments (goal, mode, context, acceptance criteria, relevant paths, constraints, verification).`,
|
|
107
102
|
mode: 'subagent',
|
|
108
103
|
hidden: false,
|
|
109
104
|
color: '#D97757',
|
|
@@ -111,14 +106,14 @@ export function buildEngineerAgentConfig(prompts, engineer) {
|
|
|
111
106
|
prompt: `You are ${engineer}.\n\n${prompts.engineerAgentPrompt}`,
|
|
112
107
|
};
|
|
113
108
|
}
|
|
114
|
-
export function
|
|
109
|
+
export function buildTeamPlannerAgentConfig(prompts) {
|
|
115
110
|
return {
|
|
116
|
-
description: '
|
|
111
|
+
description: 'Runs dual-engineer planning by calling plan_with_team. Automatically selects two non-overlapping available engineers if engineer names are not provided.',
|
|
117
112
|
mode: 'subagent',
|
|
118
113
|
hidden: false,
|
|
119
114
|
color: '#D97757',
|
|
120
|
-
permission:
|
|
121
|
-
prompt: prompts.
|
|
115
|
+
permission: buildTeamPlannerPermissions(),
|
|
116
|
+
prompt: prompts.teamPlannerPrompt,
|
|
122
117
|
};
|
|
123
118
|
}
|
|
124
119
|
export function denyRestrictedToolsGlobally(permissions) {
|
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
import { type Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
import type { EngineerName } from '../types/contracts.js';
|
|
2
3
|
export declare const ClaudeManagerPlugin: Plugin;
|
|
4
|
+
/**
|
|
5
|
+
* Normalize an agent ID to its lowercase canonical form.
|
|
6
|
+
* Handles both uppercase (e.g., 'Tom') and lowercase (e.g., 'tom') inputs.
|
|
7
|
+
*/
|
|
8
|
+
export declare function normalizeAgentId(agentId: string): string;
|
|
9
|
+
export declare function engineerFromAgent(agentId: string): EngineerName;
|
|
10
|
+
export declare function isEngineerAgent(agentId: string): boolean;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { tool } from '@opencode-ai/plugin';
|
|
2
2
|
import { managerPromptRegistry } from '../prompts/registry.js';
|
|
3
3
|
import { isEngineerName } from '../team/roster.js';
|
|
4
|
-
import { TeamOrchestrator } from '../manager/team-orchestrator.js';
|
|
5
|
-
import { AGENT_CTO,
|
|
4
|
+
import { TeamOrchestrator, createActionableError, getFailureGuidanceText, } from '../manager/team-orchestrator.js';
|
|
5
|
+
import { AGENT_CTO, AGENT_TEAM_PLANNER, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, buildCtoAgentConfig, buildEngineerAgentConfig, buildTeamPlannerAgentConfig, denyRestrictedToolsGlobally, } from './agent-hierarchy.js';
|
|
6
6
|
import { getActiveTeamSession, getOrCreatePluginServices, getPersistedActiveTeam, getWrapperSessionMapping, setActiveTeamSession, setPersistedActiveTeam, setWrapperSessionMapping, } from './service-factory.js';
|
|
7
7
|
const MODEL_ENUM = ['claude-opus-4-6', 'claude-sonnet-4-6'];
|
|
8
8
|
const MODE_ENUM = ['explore', 'implement', 'verify'];
|
|
@@ -15,7 +15,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
15
15
|
config.permission ??= {};
|
|
16
16
|
denyRestrictedToolsGlobally(config.permission);
|
|
17
17
|
config.agent[AGENT_CTO] ??= buildCtoAgentConfig(managerPromptRegistry);
|
|
18
|
-
config.agent[
|
|
18
|
+
config.agent[AGENT_TEAM_PLANNER] ??= buildTeamPlannerAgentConfig(managerPromptRegistry);
|
|
19
19
|
for (const engineer of ENGINEER_AGENT_NAMES) {
|
|
20
20
|
config.agent[ENGINEER_AGENT_IDS[engineer]] ??= buildEngineerAgentConfig(managerPromptRegistry, engineer);
|
|
21
21
|
}
|
|
@@ -105,34 +105,36 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
105
105
|
},
|
|
106
106
|
}),
|
|
107
107
|
plan_with_team: tool({
|
|
108
|
-
description: 'Run dual-engineer plan synthesis. Two engineers explore in parallel (lead + challenger), then their plans are synthesized into one stronger plan.',
|
|
108
|
+
description: 'Run dual-engineer plan synthesis. Two engineers explore in parallel (lead + challenger), then their plans are synthesized into one stronger plan. Automatically selects distinct available engineers if names are not provided.',
|
|
109
109
|
args: {
|
|
110
110
|
request: tool.schema.string().min(1),
|
|
111
|
-
leadEngineer: tool.schema.enum(['Tom', 'John', 'Maya', 'Sara', 'Alex']),
|
|
112
|
-
challengerEngineer: tool.schema.enum(['Tom', 'John', 'Maya', 'Sara', 'Alex']),
|
|
111
|
+
leadEngineer: tool.schema.enum(['Tom', 'John', 'Maya', 'Sara', 'Alex']).optional(),
|
|
112
|
+
challengerEngineer: tool.schema.enum(['Tom', 'John', 'Maya', 'Sara', 'Alex']).optional(),
|
|
113
113
|
model: tool.schema.enum(MODEL_ENUM).optional(),
|
|
114
114
|
},
|
|
115
115
|
async execute(args, context) {
|
|
116
116
|
const teamId = getActiveTeamSession(context.worktree) ?? context.sessionID;
|
|
117
|
+
// Pre-determine engineers for event labeling (using orchestrator selection logic)
|
|
118
|
+
const { lead, challenger } = await services.orchestrator.selectPlanEngineers(context.worktree, teamId, args.leadEngineer, args.challengerEngineer);
|
|
117
119
|
annotateToolRun(context, 'Running dual-engineer plan synthesis', {
|
|
118
120
|
teamId,
|
|
119
|
-
lead
|
|
120
|
-
challenger
|
|
121
|
+
lead,
|
|
122
|
+
challenger,
|
|
121
123
|
});
|
|
122
124
|
const result = await services.orchestrator.planWithTeam({
|
|
123
125
|
teamId,
|
|
124
126
|
cwd: context.worktree,
|
|
125
127
|
request: args.request,
|
|
126
|
-
leadEngineer:
|
|
127
|
-
challengerEngineer:
|
|
128
|
+
leadEngineer: lead,
|
|
129
|
+
challengerEngineer: challenger,
|
|
128
130
|
model: args.model,
|
|
129
131
|
abortSignal: context.abort,
|
|
130
|
-
onLeadEvent: (event) => reportClaudeEvent(context,
|
|
131
|
-
onChallengerEvent: (event) => reportClaudeEvent(context,
|
|
132
|
-
onSynthesisEvent: (event) =>
|
|
132
|
+
onLeadEvent: (event) => reportClaudeEvent(context, lead, event),
|
|
133
|
+
onChallengerEvent: (event) => reportClaudeEvent(context, challenger, event),
|
|
134
|
+
onSynthesisEvent: (event) => reportPlanSynthesisEvent(context, event),
|
|
133
135
|
});
|
|
134
136
|
context.metadata({
|
|
135
|
-
title: '✅
|
|
137
|
+
title: '✅ Plan synthesis finished',
|
|
136
138
|
metadata: {
|
|
137
139
|
teamId: result.teamId,
|
|
138
140
|
lead: result.leadEngineer,
|
|
@@ -386,6 +388,7 @@ async function runEngineerAssignment(input, context) {
|
|
|
386
388
|
failure.teamId = input.teamId;
|
|
387
389
|
failure.engineer = input.engineer;
|
|
388
390
|
failure.mode = input.mode;
|
|
391
|
+
const guidance = getFailureGuidanceText(failure.failureKind);
|
|
389
392
|
context.metadata({
|
|
390
393
|
title: `❌ ${input.engineer} failed (${failure.failureKind})`,
|
|
391
394
|
metadata: {
|
|
@@ -393,9 +396,10 @@ async function runEngineerAssignment(input, context) {
|
|
|
393
396
|
engineer: failure.engineer,
|
|
394
397
|
failureKind: failure.failureKind,
|
|
395
398
|
message: failure.message.slice(0, 200),
|
|
399
|
+
guidance,
|
|
396
400
|
},
|
|
397
401
|
});
|
|
398
|
-
throw error;
|
|
402
|
+
throw createActionableError(failure, error);
|
|
399
403
|
}
|
|
400
404
|
await services.orchestrator.recordWrapperExchange(context.worktree, input.teamId, input.engineer, context.sessionID, input.mode, input.message, result.finalText);
|
|
401
405
|
context.metadata({
|
|
@@ -411,16 +415,25 @@ async function runEngineerAssignment(input, context) {
|
|
|
411
415
|
});
|
|
412
416
|
return result;
|
|
413
417
|
}
|
|
414
|
-
|
|
415
|
-
|
|
418
|
+
/**
|
|
419
|
+
* Normalize an agent ID to its lowercase canonical form.
|
|
420
|
+
* Handles both uppercase (e.g., 'Tom') and lowercase (e.g., 'tom') inputs.
|
|
421
|
+
*/
|
|
422
|
+
export function normalizeAgentId(agentId) {
|
|
423
|
+
return agentId.toLowerCase();
|
|
424
|
+
}
|
|
425
|
+
export function engineerFromAgent(agentId) {
|
|
426
|
+
const normalized = normalizeAgentId(agentId);
|
|
427
|
+
const engineerEntry = Object.entries(ENGINEER_AGENT_IDS).find(([, value]) => value === normalized);
|
|
416
428
|
const engineer = engineerEntry?.[0];
|
|
417
429
|
if (!engineer || !isEngineerName(engineer)) {
|
|
418
430
|
throw new Error(`The claude tool can only be used from a named engineer agent. Received agent ${agentId}.`);
|
|
419
431
|
}
|
|
420
432
|
return engineer;
|
|
421
433
|
}
|
|
422
|
-
function isEngineerAgent(agentId) {
|
|
423
|
-
|
|
434
|
+
export function isEngineerAgent(agentId) {
|
|
435
|
+
const normalized = normalizeAgentId(agentId);
|
|
436
|
+
return Object.values(ENGINEER_AGENT_IDS).some((id) => id === normalized);
|
|
424
437
|
}
|
|
425
438
|
/**
|
|
426
439
|
* Resolves the team ID for an engineer session.
|
|
@@ -496,6 +509,16 @@ function reportClaudeEvent(context, engineer, event) {
|
|
|
496
509
|
});
|
|
497
510
|
return;
|
|
498
511
|
}
|
|
512
|
+
if (event.type === 'status') {
|
|
513
|
+
context.metadata({
|
|
514
|
+
title: `ℹ️ ${engineer}: ${event.text}`,
|
|
515
|
+
metadata: {
|
|
516
|
+
engineer,
|
|
517
|
+
status: event.text,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
499
522
|
if (event.type === 'init') {
|
|
500
523
|
context.metadata({
|
|
501
524
|
title: `⚡ ${engineer} session ready`,
|
|
@@ -562,10 +585,10 @@ function reportClaudeEvent(context, engineer, event) {
|
|
|
562
585
|
});
|
|
563
586
|
}
|
|
564
587
|
}
|
|
565
|
-
function
|
|
588
|
+
function reportPlanSynthesisEvent(context, event) {
|
|
566
589
|
if (event.type === 'error') {
|
|
567
590
|
context.metadata({
|
|
568
|
-
title: `❌
|
|
591
|
+
title: `❌ Plan synthesis hit an error`,
|
|
569
592
|
metadata: {
|
|
570
593
|
sessionId: event.sessionId,
|
|
571
594
|
error: event.text.slice(0, 200),
|
|
@@ -575,7 +598,7 @@ function reportArchitectEvent(context, event) {
|
|
|
575
598
|
}
|
|
576
599
|
if (event.type === 'init') {
|
|
577
600
|
context.metadata({
|
|
578
|
-
title: `⚡
|
|
601
|
+
title: `⚡ Plan synthesis ready`,
|
|
579
602
|
metadata: {
|
|
580
603
|
sessionId: event.sessionId,
|
|
581
604
|
},
|
|
@@ -608,10 +631,10 @@ function reportArchitectEvent(context, event) {
|
|
|
608
631
|
const toolDescription = formatToolDescription(toolName ?? '', toolArgs);
|
|
609
632
|
context.metadata({
|
|
610
633
|
title: toolDescription
|
|
611
|
-
? `⚡
|
|
634
|
+
? `⚡ Plan synthesis → ${toolDescription}`
|
|
612
635
|
: toolName
|
|
613
|
-
? `⚡
|
|
614
|
-
: `⚡
|
|
636
|
+
? `⚡ Plan synthesis → ${toolName}`
|
|
637
|
+
: `⚡ Plan synthesis is running`,
|
|
615
638
|
metadata: {
|
|
616
639
|
sessionId: event.sessionId,
|
|
617
640
|
...(toolName !== undefined && { toolName }),
|
|
@@ -625,7 +648,7 @@ function reportArchitectEvent(context, event) {
|
|
|
625
648
|
const isThinking = event.text.startsWith('<thinking>');
|
|
626
649
|
const stateLabel = event.type === 'partial' && isThinking ? 'is thinking' : 'is working';
|
|
627
650
|
context.metadata({
|
|
628
|
-
title: `⚡
|
|
651
|
+
title: `⚡ Plan synthesis ${stateLabel}`,
|
|
629
652
|
metadata: {
|
|
630
653
|
sessionId: event.sessionId,
|
|
631
654
|
preview: event.text.slice(0, 160),
|
|
@@ -635,8 +658,9 @@ function reportArchitectEvent(context, event) {
|
|
|
635
658
|
}
|
|
636
659
|
}
|
|
637
660
|
function annotateToolRun(context, title, metadata) {
|
|
661
|
+
const agentLabel = context.agent === AGENT_CTO ? 'CTO' : undefined;
|
|
638
662
|
context.metadata({
|
|
639
|
-
title,
|
|
663
|
+
title: agentLabel ? `${agentLabel} → ${title}` : title,
|
|
640
664
|
metadata,
|
|
641
665
|
});
|
|
642
666
|
}
|
|
@@ -24,7 +24,7 @@ export function getOrCreatePluginServices(worktree) {
|
|
|
24
24
|
const teamStore = new TeamStateStore();
|
|
25
25
|
const transcriptStore = new TranscriptStore();
|
|
26
26
|
const manager = new PersistentManager(gitOps, transcriptStore);
|
|
27
|
-
const orchestrator = new TeamOrchestrator(sessionService, teamStore, transcriptStore, managerPromptRegistry.engineerSessionPrompt, managerPromptRegistry.
|
|
27
|
+
const orchestrator = new TeamOrchestrator(sessionService, teamStore, transcriptStore, managerPromptRegistry.engineerSessionPrompt, managerPromptRegistry.planSynthesisPrompt);
|
|
28
28
|
const services = {
|
|
29
29
|
manager,
|
|
30
30
|
sessions: sessionService,
|