@doingdev/opencode-claude-manager-plugin 0.1.47 → 0.1.49
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/plugin/claude-manager.plugin.js +52 -7
- package/dist/plugin/service-factory.d.ts +2 -0
- package/dist/plugin/service-factory.js +14 -0
- package/dist/src/plugin/claude-manager.plugin.js +52 -7
- package/dist/src/plugin/service-factory.d.ts +2 -0
- package/dist/src/plugin/service-factory.js +14 -0
- package/dist/src/state/team-state-store.d.ts +3 -0
- package/dist/src/state/team-state-store.js +22 -0
- package/dist/state/team-state-store.d.ts +3 -0
- package/dist/state/team-state-store.js +22 -0
- package/dist/test/cto-active-team.test.d.ts +1 -0
- package/dist/test/cto-active-team.test.js +52 -0
- package/dist/test/report-claude-event.test.d.ts +1 -0
- package/dist/test/report-claude-event.test.js +246 -0
- package/dist/test/team-state-store.test.js +18 -0
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { tool } from '@opencode-ai/plugin';
|
|
2
2
|
import { managerPromptRegistry } from '../prompts/registry.js';
|
|
3
|
+
import { isEngineerName } from '../team/roster.js';
|
|
3
4
|
import { discoverProjectClaudeFiles } from '../util/project-context.js';
|
|
4
5
|
import { AGENT_CTO, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, buildCtoAgentConfig, buildEngineerAgentConfig, denyRestrictedToolsGlobally, } from './agent-hierarchy.js';
|
|
5
|
-
import { getActiveTeamSession, getOrCreatePluginServices, getWrapperSessionMapping, setActiveTeamSession, setWrapperSessionMapping, } from './service-factory.js';
|
|
6
|
-
import { isEngineerName } from '../team/roster.js';
|
|
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'];
|
|
9
9
|
export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
@@ -21,7 +21,15 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
21
21
|
},
|
|
22
22
|
'chat.message': async (input) => {
|
|
23
23
|
if (input.agent === AGENT_CTO) {
|
|
24
|
-
|
|
24
|
+
// Adopt the persisted active team if one exists, so a new CTO session
|
|
25
|
+
// does not orphan previously created engineers and wrapper memory.
|
|
26
|
+
const persistedTeamId = await getPersistedActiveTeam(worktree);
|
|
27
|
+
const activeTeamId = persistedTeamId ?? input.sessionID;
|
|
28
|
+
setActiveTeamSession(worktree, activeTeamId);
|
|
29
|
+
if (!persistedTeamId) {
|
|
30
|
+
// First CTO session for this worktree — persist this session as active team.
|
|
31
|
+
await setPersistedActiveTeam(worktree, activeTeamId);
|
|
32
|
+
}
|
|
25
33
|
return;
|
|
26
34
|
}
|
|
27
35
|
if (input.agent && isEngineerAgent(input.agent)) {
|
|
@@ -29,7 +37,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
29
37
|
const existing = getWrapperSessionMapping(worktree, input.sessionID);
|
|
30
38
|
const persisted = existing ??
|
|
31
39
|
(await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID));
|
|
32
|
-
const teamId = persisted?.teamId ??
|
|
40
|
+
const teamId = persisted?.teamId ?? (await resolveTeamId(worktree, input.sessionID));
|
|
33
41
|
setWrapperSessionMapping(worktree, input.sessionID, {
|
|
34
42
|
teamId,
|
|
35
43
|
engineer,
|
|
@@ -65,7 +73,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
65
73
|
const existing = getWrapperSessionMapping(context.worktree, context.sessionID);
|
|
66
74
|
const persisted = existing ??
|
|
67
75
|
(await services.orchestrator.findTeamByWrapperSession(context.worktree, context.sessionID));
|
|
68
|
-
const teamId = persisted?.teamId ??
|
|
76
|
+
const teamId = persisted?.teamId ?? (await resolveTeamId(context.worktree, context.sessionID));
|
|
69
77
|
setWrapperSessionMapping(context.worktree, context.sessionID, {
|
|
70
78
|
teamId,
|
|
71
79
|
engineer,
|
|
@@ -78,7 +86,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
78
86
|
message: args.message,
|
|
79
87
|
model: args.model,
|
|
80
88
|
}, context);
|
|
81
|
-
return
|
|
89
|
+
return result.finalText;
|
|
82
90
|
},
|
|
83
91
|
}),
|
|
84
92
|
team_status: tool({
|
|
@@ -319,6 +327,14 @@ function engineerFromAgent(agentId) {
|
|
|
319
327
|
function isEngineerAgent(agentId) {
|
|
320
328
|
return Object.values(ENGINEER_AGENT_IDS).includes(agentId);
|
|
321
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Resolves the team ID for an engineer session.
|
|
332
|
+
* Reads the persisted active team first (survives process restarts), then
|
|
333
|
+
* falls back to the in-memory registry, then to the raw session ID as a last resort.
|
|
334
|
+
*/
|
|
335
|
+
async function resolveTeamId(worktree, sessionID) {
|
|
336
|
+
return (await getPersistedActiveTeam(worktree)) ?? getActiveTeamSession(worktree) ?? sessionID;
|
|
337
|
+
}
|
|
322
338
|
function reportClaudeEvent(context, engineer, event) {
|
|
323
339
|
if (event.type === 'error') {
|
|
324
340
|
context.metadata({
|
|
@@ -342,11 +358,40 @@ function reportClaudeEvent(context, engineer, event) {
|
|
|
342
358
|
return;
|
|
343
359
|
}
|
|
344
360
|
if (event.type === 'tool_call') {
|
|
361
|
+
let toolName;
|
|
362
|
+
let toolId;
|
|
363
|
+
let toolArgs;
|
|
364
|
+
try {
|
|
365
|
+
const parsed = JSON.parse(event.text);
|
|
366
|
+
toolName = parsed.name;
|
|
367
|
+
toolId = parsed.id;
|
|
368
|
+
// Some SDK versions serialize the input object as a JSON string inside the outer JSON.
|
|
369
|
+
// Try to double-decode it so callers always receive a plain object.
|
|
370
|
+
if (typeof parsed.input === 'string') {
|
|
371
|
+
try {
|
|
372
|
+
toolArgs = JSON.parse(parsed.input);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
toolArgs = parsed.input;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
toolArgs = parsed.input;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// event.text is not valid JSON — fall back to generic title
|
|
384
|
+
}
|
|
345
385
|
context.metadata({
|
|
346
|
-
title:
|
|
386
|
+
title: toolName
|
|
387
|
+
? `⚡ ${engineer} → ${toolName}`
|
|
388
|
+
: `⚡ ${engineer} is using Claude Code tools`,
|
|
347
389
|
metadata: {
|
|
348
390
|
engineer,
|
|
349
391
|
sessionId: event.sessionId,
|
|
392
|
+
...(toolName !== undefined && { toolName }),
|
|
393
|
+
...(toolId !== undefined && { toolId }),
|
|
394
|
+
...(toolArgs !== undefined && { toolArgs }),
|
|
350
395
|
},
|
|
351
396
|
});
|
|
352
397
|
return;
|
|
@@ -17,6 +17,8 @@ export declare function getOrCreatePluginServices(worktree: string, projectClaud
|
|
|
17
17
|
export declare function clearPluginServices(): void;
|
|
18
18
|
export declare function setActiveTeamSession(worktree: string, teamId: string): void;
|
|
19
19
|
export declare function getActiveTeamSession(worktree: string): string | null;
|
|
20
|
+
export declare function getPersistedActiveTeam(worktree: string): Promise<string | null>;
|
|
21
|
+
export declare function setPersistedActiveTeam(worktree: string, teamId: string): Promise<void>;
|
|
20
22
|
export declare function setWrapperSessionMapping(worktree: string, wrapperSessionId: string, mapping: {
|
|
21
23
|
teamId: string;
|
|
22
24
|
engineer: EngineerName;
|
|
@@ -54,6 +54,20 @@ export function setActiveTeamSession(worktree, teamId) {
|
|
|
54
54
|
export function getActiveTeamSession(worktree) {
|
|
55
55
|
return activeTeamRegistry.get(worktree) ?? null;
|
|
56
56
|
}
|
|
57
|
+
export async function getPersistedActiveTeam(worktree) {
|
|
58
|
+
const services = serviceRegistry.get(worktree);
|
|
59
|
+
if (!services) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return services.teamStore.getActiveTeam(worktree);
|
|
63
|
+
}
|
|
64
|
+
export async function setPersistedActiveTeam(worktree, teamId) {
|
|
65
|
+
const services = serviceRegistry.get(worktree);
|
|
66
|
+
if (!services) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await services.teamStore.setActiveTeam(worktree, teamId);
|
|
70
|
+
}
|
|
57
71
|
export function setWrapperSessionMapping(worktree, wrapperSessionId, mapping) {
|
|
58
72
|
wrapperSessionRegistry.set(`${worktree}:${wrapperSessionId}`, mapping);
|
|
59
73
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { tool } from '@opencode-ai/plugin';
|
|
2
2
|
import { managerPromptRegistry } from '../prompts/registry.js';
|
|
3
|
+
import { isEngineerName } from '../team/roster.js';
|
|
3
4
|
import { discoverProjectClaudeFiles } from '../util/project-context.js';
|
|
4
5
|
import { AGENT_CTO, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, buildCtoAgentConfig, buildEngineerAgentConfig, denyRestrictedToolsGlobally, } from './agent-hierarchy.js';
|
|
5
|
-
import { getActiveTeamSession, getOrCreatePluginServices, getWrapperSessionMapping, setActiveTeamSession, setWrapperSessionMapping, } from './service-factory.js';
|
|
6
|
-
import { isEngineerName } from '../team/roster.js';
|
|
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'];
|
|
9
9
|
export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
@@ -21,7 +21,15 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
21
21
|
},
|
|
22
22
|
'chat.message': async (input) => {
|
|
23
23
|
if (input.agent === AGENT_CTO) {
|
|
24
|
-
|
|
24
|
+
// Adopt the persisted active team if one exists, so a new CTO session
|
|
25
|
+
// does not orphan previously created engineers and wrapper memory.
|
|
26
|
+
const persistedTeamId = await getPersistedActiveTeam(worktree);
|
|
27
|
+
const activeTeamId = persistedTeamId ?? input.sessionID;
|
|
28
|
+
setActiveTeamSession(worktree, activeTeamId);
|
|
29
|
+
if (!persistedTeamId) {
|
|
30
|
+
// First CTO session for this worktree — persist this session as active team.
|
|
31
|
+
await setPersistedActiveTeam(worktree, activeTeamId);
|
|
32
|
+
}
|
|
25
33
|
return;
|
|
26
34
|
}
|
|
27
35
|
if (input.agent && isEngineerAgent(input.agent)) {
|
|
@@ -29,7 +37,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
29
37
|
const existing = getWrapperSessionMapping(worktree, input.sessionID);
|
|
30
38
|
const persisted = existing ??
|
|
31
39
|
(await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID));
|
|
32
|
-
const teamId = persisted?.teamId ??
|
|
40
|
+
const teamId = persisted?.teamId ?? (await resolveTeamId(worktree, input.sessionID));
|
|
33
41
|
setWrapperSessionMapping(worktree, input.sessionID, {
|
|
34
42
|
teamId,
|
|
35
43
|
engineer,
|
|
@@ -65,7 +73,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
65
73
|
const existing = getWrapperSessionMapping(context.worktree, context.sessionID);
|
|
66
74
|
const persisted = existing ??
|
|
67
75
|
(await services.orchestrator.findTeamByWrapperSession(context.worktree, context.sessionID));
|
|
68
|
-
const teamId = persisted?.teamId ??
|
|
76
|
+
const teamId = persisted?.teamId ?? (await resolveTeamId(context.worktree, context.sessionID));
|
|
69
77
|
setWrapperSessionMapping(context.worktree, context.sessionID, {
|
|
70
78
|
teamId,
|
|
71
79
|
engineer,
|
|
@@ -78,7 +86,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
78
86
|
message: args.message,
|
|
79
87
|
model: args.model,
|
|
80
88
|
}, context);
|
|
81
|
-
return
|
|
89
|
+
return result.finalText;
|
|
82
90
|
},
|
|
83
91
|
}),
|
|
84
92
|
team_status: tool({
|
|
@@ -319,6 +327,14 @@ function engineerFromAgent(agentId) {
|
|
|
319
327
|
function isEngineerAgent(agentId) {
|
|
320
328
|
return Object.values(ENGINEER_AGENT_IDS).includes(agentId);
|
|
321
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Resolves the team ID for an engineer session.
|
|
332
|
+
* Reads the persisted active team first (survives process restarts), then
|
|
333
|
+
* falls back to the in-memory registry, then to the raw session ID as a last resort.
|
|
334
|
+
*/
|
|
335
|
+
async function resolveTeamId(worktree, sessionID) {
|
|
336
|
+
return (await getPersistedActiveTeam(worktree)) ?? getActiveTeamSession(worktree) ?? sessionID;
|
|
337
|
+
}
|
|
322
338
|
function reportClaudeEvent(context, engineer, event) {
|
|
323
339
|
if (event.type === 'error') {
|
|
324
340
|
context.metadata({
|
|
@@ -342,11 +358,40 @@ function reportClaudeEvent(context, engineer, event) {
|
|
|
342
358
|
return;
|
|
343
359
|
}
|
|
344
360
|
if (event.type === 'tool_call') {
|
|
361
|
+
let toolName;
|
|
362
|
+
let toolId;
|
|
363
|
+
let toolArgs;
|
|
364
|
+
try {
|
|
365
|
+
const parsed = JSON.parse(event.text);
|
|
366
|
+
toolName = parsed.name;
|
|
367
|
+
toolId = parsed.id;
|
|
368
|
+
// Some SDK versions serialize the input object as a JSON string inside the outer JSON.
|
|
369
|
+
// Try to double-decode it so callers always receive a plain object.
|
|
370
|
+
if (typeof parsed.input === 'string') {
|
|
371
|
+
try {
|
|
372
|
+
toolArgs = JSON.parse(parsed.input);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
toolArgs = parsed.input;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
toolArgs = parsed.input;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// event.text is not valid JSON — fall back to generic title
|
|
384
|
+
}
|
|
345
385
|
context.metadata({
|
|
346
|
-
title:
|
|
386
|
+
title: toolName
|
|
387
|
+
? `⚡ ${engineer} → ${toolName}`
|
|
388
|
+
: `⚡ ${engineer} is using Claude Code tools`,
|
|
347
389
|
metadata: {
|
|
348
390
|
engineer,
|
|
349
391
|
sessionId: event.sessionId,
|
|
392
|
+
...(toolName !== undefined && { toolName }),
|
|
393
|
+
...(toolId !== undefined && { toolId }),
|
|
394
|
+
...(toolArgs !== undefined && { toolArgs }),
|
|
350
395
|
},
|
|
351
396
|
});
|
|
352
397
|
return;
|
|
@@ -17,6 +17,8 @@ export declare function getOrCreatePluginServices(worktree: string, projectClaud
|
|
|
17
17
|
export declare function clearPluginServices(): void;
|
|
18
18
|
export declare function setActiveTeamSession(worktree: string, teamId: string): void;
|
|
19
19
|
export declare function getActiveTeamSession(worktree: string): string | null;
|
|
20
|
+
export declare function getPersistedActiveTeam(worktree: string): Promise<string | null>;
|
|
21
|
+
export declare function setPersistedActiveTeam(worktree: string, teamId: string): Promise<void>;
|
|
20
22
|
export declare function setWrapperSessionMapping(worktree: string, wrapperSessionId: string, mapping: {
|
|
21
23
|
teamId: string;
|
|
22
24
|
engineer: EngineerName;
|
|
@@ -54,6 +54,20 @@ export function setActiveTeamSession(worktree, teamId) {
|
|
|
54
54
|
export function getActiveTeamSession(worktree) {
|
|
55
55
|
return activeTeamRegistry.get(worktree) ?? null;
|
|
56
56
|
}
|
|
57
|
+
export async function getPersistedActiveTeam(worktree) {
|
|
58
|
+
const services = serviceRegistry.get(worktree);
|
|
59
|
+
if (!services) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return services.teamStore.getActiveTeam(worktree);
|
|
63
|
+
}
|
|
64
|
+
export async function setPersistedActiveTeam(worktree, teamId) {
|
|
65
|
+
const services = serviceRegistry.get(worktree);
|
|
66
|
+
if (!services) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await services.teamStore.setActiveTeam(worktree, teamId);
|
|
70
|
+
}
|
|
57
71
|
export function setWrapperSessionMapping(worktree, wrapperSessionId, mapping) {
|
|
58
72
|
wrapperSessionRegistry.set(`${worktree}:${wrapperSessionId}`, mapping);
|
|
59
73
|
}
|
|
@@ -7,7 +7,10 @@ export declare class TeamStateStore {
|
|
|
7
7
|
getTeam(cwd: string, teamId: string): Promise<TeamRecord | null>;
|
|
8
8
|
listTeams(cwd: string): Promise<TeamRecord[]>;
|
|
9
9
|
updateTeam(cwd: string, teamId: string, update: (team: TeamRecord) => TeamRecord): Promise<TeamRecord>;
|
|
10
|
+
getActiveTeam(cwd: string): Promise<string | null>;
|
|
11
|
+
setActiveTeam(cwd: string, teamId: string): Promise<void>;
|
|
10
12
|
private getTeamKey;
|
|
13
|
+
private getActiveTeamPath;
|
|
11
14
|
private getTeamsDirectory;
|
|
12
15
|
private getTeamPath;
|
|
13
16
|
private enqueueWrite;
|
|
@@ -59,9 +59,31 @@ export class TeamStateStore {
|
|
|
59
59
|
return updated;
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
+
async getActiveTeam(cwd) {
|
|
63
|
+
const filePath = this.getActiveTeamPath(cwd);
|
|
64
|
+
try {
|
|
65
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
66
|
+
const parsed = JSON.parse(content);
|
|
67
|
+
return parsed.teamId ?? null;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (isFileNotFoundError(error)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async setActiveTeam(cwd, teamId) {
|
|
77
|
+
const filePath = this.getActiveTeamPath(cwd);
|
|
78
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
79
|
+
await writeJsonAtomically(filePath, { teamId });
|
|
80
|
+
}
|
|
62
81
|
getTeamKey(cwd, teamId) {
|
|
63
82
|
return `${cwd}:${teamId}`;
|
|
64
83
|
}
|
|
84
|
+
getActiveTeamPath(cwd) {
|
|
85
|
+
return path.join(cwd, this.baseDirectoryName, 'active-team.json');
|
|
86
|
+
}
|
|
65
87
|
getTeamsDirectory(cwd) {
|
|
66
88
|
return path.join(cwd, this.baseDirectoryName, 'teams');
|
|
67
89
|
}
|
|
@@ -7,7 +7,10 @@ export declare class TeamStateStore {
|
|
|
7
7
|
getTeam(cwd: string, teamId: string): Promise<TeamRecord | null>;
|
|
8
8
|
listTeams(cwd: string): Promise<TeamRecord[]>;
|
|
9
9
|
updateTeam(cwd: string, teamId: string, update: (team: TeamRecord) => TeamRecord): Promise<TeamRecord>;
|
|
10
|
+
getActiveTeam(cwd: string): Promise<string | null>;
|
|
11
|
+
setActiveTeam(cwd: string, teamId: string): Promise<void>;
|
|
10
12
|
private getTeamKey;
|
|
13
|
+
private getActiveTeamPath;
|
|
11
14
|
private getTeamsDirectory;
|
|
12
15
|
private getTeamPath;
|
|
13
16
|
private enqueueWrite;
|
|
@@ -59,9 +59,31 @@ export class TeamStateStore {
|
|
|
59
59
|
return updated;
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
+
async getActiveTeam(cwd) {
|
|
63
|
+
const filePath = this.getActiveTeamPath(cwd);
|
|
64
|
+
try {
|
|
65
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
66
|
+
const parsed = JSON.parse(content);
|
|
67
|
+
return parsed.teamId ?? null;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (isFileNotFoundError(error)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async setActiveTeam(cwd, teamId) {
|
|
77
|
+
const filePath = this.getActiveTeamPath(cwd);
|
|
78
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
79
|
+
await writeJsonAtomically(filePath, { teamId });
|
|
80
|
+
}
|
|
62
81
|
getTeamKey(cwd, teamId) {
|
|
63
82
|
return `${cwd}:${teamId}`;
|
|
64
83
|
}
|
|
84
|
+
getActiveTeamPath(cwd) {
|
|
85
|
+
return path.join(cwd, this.baseDirectoryName, 'active-team.json');
|
|
86
|
+
}
|
|
65
87
|
getTeamsDirectory(cwd) {
|
|
66
88
|
return path.join(cwd, this.baseDirectoryName, 'teams');
|
|
67
89
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
|
|
6
|
+
import { clearPluginServices, getActiveTeamSession } from '../src/plugin/service-factory.js';
|
|
7
|
+
import { AGENT_CTO } from '../src/plugin/agent-hierarchy.js';
|
|
8
|
+
import { TeamStateStore } from '../src/state/team-state-store.js';
|
|
9
|
+
describe('CTO chat.message — persisted active team', () => {
|
|
10
|
+
let tempRoot;
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'cto-team-'));
|
|
13
|
+
clearPluginServices();
|
|
14
|
+
});
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
clearPluginServices();
|
|
17
|
+
if (tempRoot) {
|
|
18
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
it('persists the active team on the first CTO message', async () => {
|
|
22
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
23
|
+
const chatMessage = plugin['chat.message'];
|
|
24
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'session-cto-1' });
|
|
25
|
+
const store = new TeamStateStore('.claude-manager');
|
|
26
|
+
await expect(store.getActiveTeam(tempRoot)).resolves.toBe('session-cto-1');
|
|
27
|
+
expect(getActiveTeamSession(tempRoot)).toBe('session-cto-1');
|
|
28
|
+
});
|
|
29
|
+
it('a new CTO session adopts the already-persisted active team instead of overwriting it', async () => {
|
|
30
|
+
const store = new TeamStateStore('.claude-manager');
|
|
31
|
+
// Simulate a pre-existing persisted active team (e.g., from a previous process run).
|
|
32
|
+
await store.setActiveTeam(tempRoot, 'old-team-id');
|
|
33
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
34
|
+
const chatMessage = plugin['chat.message'];
|
|
35
|
+
// New CTO session with a different session ID.
|
|
36
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'brand-new-cto-session' });
|
|
37
|
+
// The persisted active team must NOT be overwritten.
|
|
38
|
+
await expect(store.getActiveTeam(tempRoot)).resolves.toBe('old-team-id');
|
|
39
|
+
// The in-memory registry must point to the persisted team, NOT the new session.
|
|
40
|
+
expect(getActiveTeamSession(tempRoot)).toBe('old-team-id');
|
|
41
|
+
});
|
|
42
|
+
it('does not overwrite the persisted team across two CTO messages in the same session', async () => {
|
|
43
|
+
const store = new TeamStateStore('.claude-manager');
|
|
44
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
45
|
+
const chatMessage = plugin['chat.message'];
|
|
46
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'session-cto-1' });
|
|
47
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'session-cto-1' });
|
|
48
|
+
// Still the original session — persisted.
|
|
49
|
+
await expect(store.getActiveTeam(tempRoot)).resolves.toBe('session-cto-1');
|
|
50
|
+
expect(getActiveTeamSession(tempRoot)).toBe('session-cto-1');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for reportClaudeEvent via the real plugin onEvent chain,
|
|
3
|
+
* plus integration tests for second-invocation continuity across
|
|
4
|
+
* clearPluginServices() / new plugin instance.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
|
|
11
|
+
import { clearPluginServices, getActiveTeamSession, getOrCreatePluginServices, } from '../src/plugin/service-factory.js';
|
|
12
|
+
import { AGENT_CTO, ENGINEER_AGENT_IDS } from '../src/plugin/agent-hierarchy.js';
|
|
13
|
+
import { TeamStateStore } from '../src/state/team-state-store.js';
|
|
14
|
+
import { TeamOrchestrator } from '../src/manager/team-orchestrator.js';
|
|
15
|
+
function makeContext(worktree, agentId, sessionID) {
|
|
16
|
+
const metadata = vi.fn();
|
|
17
|
+
const ctx = {
|
|
18
|
+
metadata,
|
|
19
|
+
worktree,
|
|
20
|
+
sessionID,
|
|
21
|
+
agent: agentId,
|
|
22
|
+
abort: new AbortController().signal,
|
|
23
|
+
};
|
|
24
|
+
return { metadata, ctx };
|
|
25
|
+
}
|
|
26
|
+
function makeDispatchResult(override = {}) {
|
|
27
|
+
const context = {
|
|
28
|
+
sessionId: 'ses-tom-1',
|
|
29
|
+
totalTurns: 1,
|
|
30
|
+
totalCostUsd: 0.01,
|
|
31
|
+
latestInputTokens: 500,
|
|
32
|
+
latestOutputTokens: 100,
|
|
33
|
+
contextWindowSize: 200_000,
|
|
34
|
+
estimatedContextPercent: 0.5,
|
|
35
|
+
warningLevel: 'ok',
|
|
36
|
+
compactionCount: 0,
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
teamId: 'team-1',
|
|
40
|
+
engineer: 'Tom',
|
|
41
|
+
mode: 'explore',
|
|
42
|
+
sessionId: 'ses-tom-1',
|
|
43
|
+
finalText: 'done',
|
|
44
|
+
turns: 1,
|
|
45
|
+
totalCostUsd: 0.01,
|
|
46
|
+
inputTokens: 500,
|
|
47
|
+
outputTokens: 100,
|
|
48
|
+
contextWindowSize: 200_000,
|
|
49
|
+
context,
|
|
50
|
+
...override,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function executeClaude(plugin, ctx, args = { mode: 'explore', message: 'do work' }) {
|
|
54
|
+
const claudeTool = plugin.tool['claude'];
|
|
55
|
+
return claudeTool.execute(args, ctx);
|
|
56
|
+
}
|
|
57
|
+
// ── reportClaudeEvent via the plugin's onEvent chain ────────────────────────
|
|
58
|
+
describe('reportClaudeEvent — via plugin onEvent chain', () => {
|
|
59
|
+
let tempRoot;
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'report-event-'));
|
|
62
|
+
clearPluginServices();
|
|
63
|
+
});
|
|
64
|
+
afterEach(async () => {
|
|
65
|
+
clearPluginServices();
|
|
66
|
+
if (tempRoot)
|
|
67
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Helper: creates a plugin, sets the active CTO team, then stubs
|
|
71
|
+
* dispatchEngineer so it fires the given events before returning.
|
|
72
|
+
*/
|
|
73
|
+
async function setupPlugin(events) {
|
|
74
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
75
|
+
const chatMessage = plugin['chat.message'];
|
|
76
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
77
|
+
const services = getOrCreatePluginServices(tempRoot);
|
|
78
|
+
vi.spyOn(services.orchestrator, 'dispatchEngineer').mockImplementation(async (input) => {
|
|
79
|
+
for (const event of events) {
|
|
80
|
+
await input.onEvent?.(event);
|
|
81
|
+
}
|
|
82
|
+
return makeDispatchResult();
|
|
83
|
+
});
|
|
84
|
+
vi.spyOn(services.orchestrator, 'recordWrapperExchange').mockResolvedValue(undefined);
|
|
85
|
+
return { plugin, services };
|
|
86
|
+
}
|
|
87
|
+
it('surfaces tool name, args, and toolId from a tool_call event', async () => {
|
|
88
|
+
const event = {
|
|
89
|
+
type: 'tool_call',
|
|
90
|
+
sessionId: 'ses-1',
|
|
91
|
+
text: JSON.stringify({ name: 'read', id: 'call-abc', input: { file_path: '/foo.ts' } }),
|
|
92
|
+
};
|
|
93
|
+
const { plugin } = await setupPlugin([event]);
|
|
94
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Tom, 'wrapper-1');
|
|
95
|
+
await executeClaude(plugin, ctx);
|
|
96
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('→'))?.[0];
|
|
97
|
+
expect(call).toBeDefined();
|
|
98
|
+
expect(call.title).toBe('⚡ Tom → read');
|
|
99
|
+
expect(call.metadata.toolName).toBe('read');
|
|
100
|
+
expect(call.metadata.toolId).toBe('call-abc');
|
|
101
|
+
expect(call.metadata.toolArgs).toEqual({ file_path: '/foo.ts' });
|
|
102
|
+
expect(call.metadata.sessionId).toBe('ses-1');
|
|
103
|
+
expect(call.metadata.engineer).toBe('Tom');
|
|
104
|
+
});
|
|
105
|
+
it('double-decodes a JSON-string input (tool input serialised twice)', async () => {
|
|
106
|
+
// The SDK adapter may serialize `input` as a JSON string inside the outer JSON on
|
|
107
|
+
// some tool calls. The handler should parse the inner string into an object.
|
|
108
|
+
const event = {
|
|
109
|
+
type: 'tool_call',
|
|
110
|
+
sessionId: 'ses-2',
|
|
111
|
+
text: JSON.stringify({
|
|
112
|
+
name: 'bash',
|
|
113
|
+
id: 'call-def',
|
|
114
|
+
input: JSON.stringify({ command: 'ls -la' }),
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
const { plugin } = await setupPlugin([event]);
|
|
118
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Tom, 'wrapper-2');
|
|
119
|
+
await executeClaude(plugin, ctx);
|
|
120
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('→'))?.[0];
|
|
121
|
+
expect(call.title).toBe('⚡ Tom → bash');
|
|
122
|
+
expect(call.metadata.toolArgs).toEqual({ command: 'ls -la' });
|
|
123
|
+
});
|
|
124
|
+
it('falls back to generic title and omits tool fields when event.text is not JSON', async () => {
|
|
125
|
+
const event = {
|
|
126
|
+
type: 'tool_call',
|
|
127
|
+
sessionId: 'ses-3',
|
|
128
|
+
text: 'not-json-at-all',
|
|
129
|
+
};
|
|
130
|
+
const { plugin } = await setupPlugin([event]);
|
|
131
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Tom, 'wrapper-3');
|
|
132
|
+
await executeClaude(plugin, ctx);
|
|
133
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('is using Claude Code tools'))?.[0];
|
|
134
|
+
expect(call).toBeDefined();
|
|
135
|
+
expect(call.title).toBe('⚡ Tom is using Claude Code tools');
|
|
136
|
+
expect(call.metadata).not.toHaveProperty('toolName');
|
|
137
|
+
expect(call.metadata).not.toHaveProperty('toolId');
|
|
138
|
+
expect(call.metadata).not.toHaveProperty('toolArgs');
|
|
139
|
+
});
|
|
140
|
+
it('falls back to generic title when parsed JSON has no name field', async () => {
|
|
141
|
+
const event = {
|
|
142
|
+
type: 'tool_call',
|
|
143
|
+
sessionId: 'ses-4',
|
|
144
|
+
text: JSON.stringify({ id: 'call-xyz', input: {} }),
|
|
145
|
+
};
|
|
146
|
+
const { plugin } = await setupPlugin([event]);
|
|
147
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.John, 'wrapper-4');
|
|
148
|
+
await executeClaude(plugin, ctx);
|
|
149
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('is using Claude Code tools'))?.[0];
|
|
150
|
+
expect(call).toBeDefined();
|
|
151
|
+
expect(call.metadata).not.toHaveProperty('toolName');
|
|
152
|
+
});
|
|
153
|
+
it('includes toolArgs when input is an empty object', async () => {
|
|
154
|
+
const event = {
|
|
155
|
+
type: 'tool_call',
|
|
156
|
+
sessionId: 'ses-5',
|
|
157
|
+
text: JSON.stringify({ name: 'git_status', id: 'call-ghi', input: {} }),
|
|
158
|
+
};
|
|
159
|
+
const { plugin } = await setupPlugin([event]);
|
|
160
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Maya, 'wrapper-5');
|
|
161
|
+
await executeClaude(plugin, ctx);
|
|
162
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('→'))?.[0];
|
|
163
|
+
expect(call.title).toBe('⚡ Maya → git_status');
|
|
164
|
+
expect(call.metadata.toolArgs).toEqual({});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
// ── Second-invocation continuity ─────────────────────────────────────────────
|
|
168
|
+
describe('second invocation continuity', () => {
|
|
169
|
+
let tempRoot;
|
|
170
|
+
beforeEach(async () => {
|
|
171
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'continuity-'));
|
|
172
|
+
clearPluginServices();
|
|
173
|
+
});
|
|
174
|
+
afterEach(async () => {
|
|
175
|
+
clearPluginServices();
|
|
176
|
+
if (tempRoot)
|
|
177
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
178
|
+
});
|
|
179
|
+
it('wrapper memory is injected after clearPluginServices and a new plugin instance', async () => {
|
|
180
|
+
// ── Phase 1: first task via orchestrator (no real SDK needed) ──────────
|
|
181
|
+
const store = new TeamStateStore();
|
|
182
|
+
await store.setActiveTeam(tempRoot, 'cto-1');
|
|
183
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', []);
|
|
184
|
+
await orchestrator.recordWrapperSession(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1');
|
|
185
|
+
await orchestrator.recordWrapperExchange(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1', 'explore', 'Investigate the auth flow', 'Found two race conditions in the token refresh path.');
|
|
186
|
+
// ── Phase 2: process restart ───────────────────────────────────────────
|
|
187
|
+
clearPluginServices();
|
|
188
|
+
// ── Phase 3: new plugin instance, new CTO session ──────────────────────
|
|
189
|
+
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
190
|
+
const chatMessage2 = plugin2['chat.message'];
|
|
191
|
+
const systemTransform2 = plugin2['experimental.chat.system.transform'];
|
|
192
|
+
// New CTO session must adopt the persisted team, not create a new one.
|
|
193
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-2' });
|
|
194
|
+
expect(getActiveTeamSession(tempRoot)).toBe('cto-1');
|
|
195
|
+
// Tom's new wrapper session must be registered under the persisted team.
|
|
196
|
+
await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
|
|
197
|
+
// Transform fires (after chat.message has registered the session mapping).
|
|
198
|
+
const output = { system: [] };
|
|
199
|
+
await systemTransform2({ sessionID: 'wrapper-tom-2', model: 'claude-sonnet-4-6' }, output);
|
|
200
|
+
expect(output.system).toHaveLength(1);
|
|
201
|
+
expect(output.system[0]).toContain('Persistent wrapper memory for Tom');
|
|
202
|
+
expect(output.system[0]).toContain('Investigate the auth flow');
|
|
203
|
+
expect(output.system[0]).toContain('Found two race conditions');
|
|
204
|
+
});
|
|
205
|
+
it('existing engineer Claude session is resumed on second invocation', async () => {
|
|
206
|
+
// ── Phase 1: pre-seed Tom with a claudeSessionId ───────────────────────
|
|
207
|
+
const store = new TeamStateStore();
|
|
208
|
+
await store.setActiveTeam(tempRoot, 'cto-1');
|
|
209
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', []);
|
|
210
|
+
await orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
|
|
211
|
+
await store.updateTeam(tempRoot, 'cto-1', (team) => ({
|
|
212
|
+
...team,
|
|
213
|
+
engineers: team.engineers.map((e) => e.name === 'Tom' ? { ...e, claudeSessionId: 'ses-tom-persisted' } : e),
|
|
214
|
+
}));
|
|
215
|
+
// ── Phase 2: process restart ───────────────────────────────────────────
|
|
216
|
+
clearPluginServices();
|
|
217
|
+
// ── Phase 3: new plugin, new CTO, engineer runs second task ───────────
|
|
218
|
+
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
219
|
+
const chatMessage2 = plugin2['chat.message'];
|
|
220
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-2' });
|
|
221
|
+
await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
|
|
222
|
+
const services2 = getOrCreatePluginServices(tempRoot);
|
|
223
|
+
// Mock at the session level so dispatchEngineer runs its real logic
|
|
224
|
+
// (reads claudeSessionId, passes resumeSessionId to runTask).
|
|
225
|
+
const runTask = vi.spyOn(services2.sessions, 'runTask').mockResolvedValue({
|
|
226
|
+
sessionId: 'ses-tom-persisted',
|
|
227
|
+
events: [],
|
|
228
|
+
finalText: 'resumed result',
|
|
229
|
+
turns: 2,
|
|
230
|
+
totalCostUsd: 0.02,
|
|
231
|
+
inputTokens: 1000,
|
|
232
|
+
outputTokens: 200,
|
|
233
|
+
contextWindowSize: 200_000,
|
|
234
|
+
});
|
|
235
|
+
vi.spyOn(services2.orchestrator, 'recordWrapperExchange').mockResolvedValue(undefined);
|
|
236
|
+
const { ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Tom, 'wrapper-tom-2');
|
|
237
|
+
await executeClaude(plugin2, ctx);
|
|
238
|
+
// dispatchEngineer should have read claudeSessionId='ses-tom-persisted' from
|
|
239
|
+
// the team store and forwarded it as resumeSessionId.
|
|
240
|
+
expect(runTask).toHaveBeenCalledOnce();
|
|
241
|
+
expect(runTask.mock.calls[0]?.[0]).toMatchObject({
|
|
242
|
+
resumeSessionId: 'ses-tom-persisted',
|
|
243
|
+
systemPrompt: undefined, // no new system prompt when resuming
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -51,4 +51,22 @@ describe('TeamStateStore', () => {
|
|
|
51
51
|
{ id: 'older' },
|
|
52
52
|
]);
|
|
53
53
|
});
|
|
54
|
+
it('returns null for active team when no active-team.json exists', async () => {
|
|
55
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
56
|
+
const store = new TeamStateStore('.state');
|
|
57
|
+
await expect(store.getActiveTeam(tempRoot)).resolves.toBeNull();
|
|
58
|
+
});
|
|
59
|
+
it('persists and reads back the active team ID', async () => {
|
|
60
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
61
|
+
const store = new TeamStateStore('.state');
|
|
62
|
+
await store.setActiveTeam(tempRoot, 'team-abc');
|
|
63
|
+
await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-abc');
|
|
64
|
+
});
|
|
65
|
+
it('overwrites the active team ID on subsequent writes', async () => {
|
|
66
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
67
|
+
const store = new TeamStateStore('.state');
|
|
68
|
+
await store.setActiveTeam(tempRoot, 'team-first');
|
|
69
|
+
await store.setActiveTeam(tempRoot, 'team-second');
|
|
70
|
+
await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-second');
|
|
71
|
+
});
|
|
54
72
|
});
|