@doingdev/opencode-claude-manager-plugin 0.1.46 → 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/README.md +29 -31
- package/dist/index.d.ts +1 -1
- package/dist/manager/team-orchestrator.d.ts +50 -0
- package/dist/manager/team-orchestrator.js +360 -0
- package/dist/plugin/agent-hierarchy.d.ts +12 -34
- package/dist/plugin/agent-hierarchy.js +36 -129
- package/dist/plugin/claude-manager.plugin.js +233 -421
- package/dist/plugin/service-factory.d.ts +20 -3
- package/dist/plugin/service-factory.js +46 -1
- package/dist/prompts/registry.d.ts +1 -10
- package/dist/prompts/registry.js +42 -261
- package/dist/src/claude/claude-agent-sdk-adapter.js +2 -1
- package/dist/src/claude/session-live-tailer.js +2 -2
- package/dist/src/index.d.ts +1 -1
- package/dist/src/manager/git-operations.d.ts +10 -1
- package/dist/src/manager/git-operations.js +18 -3
- package/dist/src/manager/persistent-manager.d.ts +18 -6
- package/dist/src/manager/persistent-manager.js +19 -13
- package/dist/src/manager/session-controller.d.ts +7 -10
- package/dist/src/manager/session-controller.js +12 -62
- package/dist/src/manager/team-orchestrator.d.ts +50 -0
- package/dist/src/manager/team-orchestrator.js +360 -0
- package/dist/src/plugin/agent-hierarchy.d.ts +12 -26
- package/dist/src/plugin/agent-hierarchy.js +36 -99
- package/dist/src/plugin/claude-manager.plugin.js +257 -391
- package/dist/src/plugin/service-factory.d.ts +20 -3
- package/dist/src/plugin/service-factory.js +47 -9
- package/dist/src/prompts/registry.d.ts +1 -10
- package/dist/src/prompts/registry.js +41 -246
- package/dist/src/state/team-state-store.d.ts +17 -0
- package/dist/src/state/team-state-store.js +107 -0
- package/dist/src/team/roster.d.ts +5 -0
- package/dist/src/team/roster.js +38 -0
- package/dist/src/types/contracts.d.ts +55 -13
- package/dist/src/types/contracts.js +1 -1
- package/dist/state/team-state-store.d.ts +17 -0
- package/dist/state/team-state-store.js +107 -0
- package/dist/team/roster.d.ts +5 -0
- package/dist/team/roster.js +38 -0
- package/dist/test/claude-manager.plugin.test.js +55 -280
- package/dist/test/cto-active-team.test.d.ts +1 -0
- package/dist/test/cto-active-team.test.js +52 -0
- package/dist/test/git-operations.test.js +65 -1
- package/dist/test/persistent-manager.test.js +3 -3
- package/dist/test/prompt-registry.test.js +32 -252
- package/dist/test/report-claude-event.test.d.ts +1 -0
- package/dist/test/report-claude-event.test.js +246 -0
- package/dist/test/session-controller.test.js +27 -27
- package/dist/test/team-orchestrator.test.d.ts +1 -0
- package/dist/test/team-orchestrator.test.js +146 -0
- package/dist/test/team-state-store.test.d.ts +1 -0
- package/dist/test/team-state-store.test.js +72 -0
- package/dist/types/contracts.d.ts +54 -3
- package/dist/types/contracts.js +1 -1
- package/package.json +1 -1
|
@@ -17,7 +17,7 @@ export class PersistentManager {
|
|
|
17
17
|
* Creates a new session if none exists.
|
|
18
18
|
*/
|
|
19
19
|
async sendMessage(cwd, message, options, onEvent) {
|
|
20
|
-
const result = await this.sessionController.sendMessage(
|
|
20
|
+
const result = await this.sessionController.sendMessage(message, options, onEvent);
|
|
21
21
|
if (result.sessionId && result.events.length > 0) {
|
|
22
22
|
await this.transcriptStore.appendEvents(cwd, result.sessionId, result.events);
|
|
23
23
|
}
|
|
@@ -35,8 +35,8 @@ export class PersistentManager {
|
|
|
35
35
|
/**
|
|
36
36
|
* Get the current git diff.
|
|
37
37
|
*/
|
|
38
|
-
async gitDiff() {
|
|
39
|
-
return this.gitOps.diff();
|
|
38
|
+
async gitDiff(options = {}) {
|
|
39
|
+
return this.gitOps.diff(options);
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* Commit all current changes.
|
|
@@ -44,6 +44,18 @@ export class PersistentManager {
|
|
|
44
44
|
async gitCommit(message) {
|
|
45
45
|
return this.gitOps.commit(message);
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Get git status summary.
|
|
49
|
+
*/
|
|
50
|
+
async gitStatus() {
|
|
51
|
+
return this.gitOps.status();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Get recent commit log.
|
|
55
|
+
*/
|
|
56
|
+
async gitLog(count = 5) {
|
|
57
|
+
return this.gitOps.log(count);
|
|
58
|
+
}
|
|
47
59
|
/**
|
|
48
60
|
* Hard reset to discard all uncommitted changes.
|
|
49
61
|
*/
|
|
@@ -59,14 +71,14 @@ export class PersistentManager {
|
|
|
59
71
|
/**
|
|
60
72
|
* Clear the active session. Next send creates a fresh one.
|
|
61
73
|
*/
|
|
62
|
-
async clearSession(
|
|
63
|
-
return this.sessionController.clearSession(
|
|
74
|
+
async clearSession() {
|
|
75
|
+
return this.sessionController.clearSession();
|
|
64
76
|
}
|
|
65
77
|
/**
|
|
66
78
|
* Compact the current session to free context.
|
|
67
79
|
*/
|
|
68
80
|
async compactSession(cwd, onEvent) {
|
|
69
|
-
const result = await this.sessionController.compactSession(
|
|
81
|
+
const result = await this.sessionController.compactSession(onEvent);
|
|
70
82
|
if (result.sessionId && result.events.length > 0) {
|
|
71
83
|
await this.transcriptStore.appendEvents(cwd, result.sessionId, result.events);
|
|
72
84
|
}
|
|
@@ -108,7 +120,7 @@ export class PersistentManager {
|
|
|
108
120
|
await this.stateStore.saveRun(runRecord);
|
|
109
121
|
await onProgress?.(runRecord);
|
|
110
122
|
try {
|
|
111
|
-
const result = await this.sessionController.sendMessage(
|
|
123
|
+
const result = await this.sessionController.sendMessage(task, options, async (event) => {
|
|
112
124
|
// Update run record with progress events
|
|
113
125
|
const currentRun = await this.stateStore.getRun(cwd, runId);
|
|
114
126
|
if (currentRun) {
|
|
@@ -155,12 +167,6 @@ export class PersistentManager {
|
|
|
155
167
|
return { run: failedRun };
|
|
156
168
|
}
|
|
157
169
|
}
|
|
158
|
-
/**
|
|
159
|
-
* Try to restore session state from disk on startup.
|
|
160
|
-
*/
|
|
161
|
-
async tryRestore(cwd) {
|
|
162
|
-
return this.sessionController.tryRestore(cwd);
|
|
163
|
-
}
|
|
164
170
|
listRuns(cwd) {
|
|
165
171
|
return this.stateStore.listRuns(cwd);
|
|
166
172
|
}
|
|
@@ -5,9 +5,11 @@ export declare class SessionController {
|
|
|
5
5
|
private readonly sdkAdapter;
|
|
6
6
|
private readonly contextTracker;
|
|
7
7
|
private readonly sessionPrompt;
|
|
8
|
+
private readonly wrapperType;
|
|
9
|
+
private readonly worktree;
|
|
8
10
|
private readonly modePrefixes;
|
|
9
11
|
private activeSessionId;
|
|
10
|
-
constructor(sdkAdapter: ClaudeAgentSdkAdapter, contextTracker: ContextTracker, sessionPrompt: string, modePrefixes?: {
|
|
12
|
+
constructor(sdkAdapter: ClaudeAgentSdkAdapter, contextTracker: ContextTracker, sessionPrompt: string | undefined, wrapperType: string, worktree: string, modePrefixes?: {
|
|
11
13
|
plan: string;
|
|
12
14
|
free: string;
|
|
13
15
|
});
|
|
@@ -17,28 +19,23 @@ export declare class SessionController {
|
|
|
17
19
|
* Send a message to the persistent session. Creates one if none exists.
|
|
18
20
|
* Returns the session result including usage data.
|
|
19
21
|
*/
|
|
20
|
-
sendMessage(
|
|
22
|
+
sendMessage(message: string, options?: {
|
|
21
23
|
model?: string;
|
|
22
24
|
effort?: 'low' | 'medium' | 'high' | 'max';
|
|
23
25
|
mode?: SessionMode;
|
|
26
|
+
sessionSystemPrompt?: string;
|
|
24
27
|
abortSignal?: AbortSignal;
|
|
25
28
|
}, onEvent?: ClaudeSessionEventHandler): Promise<ClaudeSessionRunResult>;
|
|
26
29
|
/**
|
|
27
30
|
* Send /compact to the current session to compress context.
|
|
28
31
|
*/
|
|
29
|
-
compactSession(
|
|
32
|
+
compactSession(onEvent?: ClaudeSessionEventHandler): Promise<ClaudeSessionRunResult>;
|
|
30
33
|
/**
|
|
31
34
|
* Clear the current session. The next sendMessage will create a fresh one.
|
|
32
35
|
*/
|
|
33
|
-
clearSession(
|
|
36
|
+
clearSession(): Promise<string | null>;
|
|
34
37
|
/**
|
|
35
38
|
* Get current context tracking snapshot.
|
|
36
39
|
*/
|
|
37
40
|
getContextSnapshot(): SessionContextSnapshot;
|
|
38
|
-
/**
|
|
39
|
-
* Try to restore active session from persisted state on startup.
|
|
40
|
-
*/
|
|
41
|
-
tryRestore(cwd: string): Promise<boolean>;
|
|
42
|
-
private persistActiveSession;
|
|
43
|
-
private removeActiveSession;
|
|
44
41
|
}
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
|
-
const ACTIVE_SESSION_FILE = '.claude-manager/active-session.json';
|
|
4
1
|
export class SessionController {
|
|
5
2
|
sdkAdapter;
|
|
6
3
|
contextTracker;
|
|
7
4
|
sessionPrompt;
|
|
5
|
+
wrapperType;
|
|
6
|
+
worktree;
|
|
8
7
|
modePrefixes;
|
|
9
8
|
activeSessionId = null;
|
|
10
|
-
constructor(sdkAdapter, contextTracker, sessionPrompt, modePrefixes = {
|
|
9
|
+
constructor(sdkAdapter, contextTracker, sessionPrompt, wrapperType, worktree, modePrefixes = {
|
|
11
10
|
plan: '',
|
|
12
11
|
free: '',
|
|
13
12
|
}) {
|
|
14
13
|
this.sdkAdapter = sdkAdapter;
|
|
15
14
|
this.contextTracker = contextTracker;
|
|
16
15
|
this.sessionPrompt = sessionPrompt;
|
|
16
|
+
this.wrapperType = wrapperType;
|
|
17
|
+
this.worktree = worktree;
|
|
17
18
|
this.modePrefixes = modePrefixes;
|
|
18
19
|
}
|
|
19
20
|
get isActive() {
|
|
@@ -26,12 +27,12 @@ export class SessionController {
|
|
|
26
27
|
* Send a message to the persistent session. Creates one if none exists.
|
|
27
28
|
* Returns the session result including usage data.
|
|
28
29
|
*/
|
|
29
|
-
async sendMessage(
|
|
30
|
+
async sendMessage(message, options, onEvent) {
|
|
30
31
|
const mode = options?.mode ?? 'free';
|
|
31
32
|
const prefix = this.modePrefixes[mode];
|
|
32
33
|
const prompt = prefix ? `${prefix}\n\n${message}` : message;
|
|
33
34
|
const input = {
|
|
34
|
-
cwd,
|
|
35
|
+
cwd: this.worktree,
|
|
35
36
|
prompt,
|
|
36
37
|
persistSession: true,
|
|
37
38
|
permissionMode: mode === 'plan' ? 'plan' : 'acceptEdits',
|
|
@@ -46,8 +47,8 @@ export class SessionController {
|
|
|
46
47
|
input.resumeSessionId = this.activeSessionId;
|
|
47
48
|
}
|
|
48
49
|
else {
|
|
49
|
-
// New session —
|
|
50
|
-
input.systemPrompt = this.sessionPrompt;
|
|
50
|
+
// New session — prefer dynamically constructed prompt from wrapper, fall back to static default
|
|
51
|
+
input.systemPrompt = options?.sessionSystemPrompt ?? this.sessionPrompt;
|
|
51
52
|
input.model ??= 'claude-opus-4-6';
|
|
52
53
|
input.effort ??= 'high';
|
|
53
54
|
}
|
|
@@ -65,29 +66,26 @@ export class SessionController {
|
|
|
65
66
|
outputTokens: result.outputTokens,
|
|
66
67
|
contextWindowSize: result.contextWindowSize,
|
|
67
68
|
});
|
|
68
|
-
// Persist active session state
|
|
69
|
-
await this.persistActiveSession(cwd);
|
|
70
69
|
return result;
|
|
71
70
|
}
|
|
72
71
|
/**
|
|
73
72
|
* Send /compact to the current session to compress context.
|
|
74
73
|
*/
|
|
75
|
-
async compactSession(
|
|
74
|
+
async compactSession(onEvent) {
|
|
76
75
|
if (!this.activeSessionId) {
|
|
77
76
|
throw new Error('No active session to compact');
|
|
78
77
|
}
|
|
79
|
-
const result = await this.sendMessage(
|
|
78
|
+
const result = await this.sendMessage('/compact', undefined, onEvent);
|
|
80
79
|
this.contextTracker.recordCompaction();
|
|
81
80
|
return result;
|
|
82
81
|
}
|
|
83
82
|
/**
|
|
84
83
|
* Clear the current session. The next sendMessage will create a fresh one.
|
|
85
84
|
*/
|
|
86
|
-
async clearSession(
|
|
85
|
+
async clearSession() {
|
|
87
86
|
const clearedId = this.activeSessionId;
|
|
88
87
|
this.activeSessionId = null;
|
|
89
88
|
this.contextTracker.reset();
|
|
90
|
-
await this.removeActiveSession(cwd);
|
|
91
89
|
return clearedId;
|
|
92
90
|
}
|
|
93
91
|
/**
|
|
@@ -96,52 +94,4 @@ export class SessionController {
|
|
|
96
94
|
getContextSnapshot() {
|
|
97
95
|
return this.contextTracker.snapshot();
|
|
98
96
|
}
|
|
99
|
-
/**
|
|
100
|
-
* Try to restore active session from persisted state on startup.
|
|
101
|
-
*/
|
|
102
|
-
async tryRestore(cwd) {
|
|
103
|
-
const filePath = join(cwd, ACTIVE_SESSION_FILE);
|
|
104
|
-
try {
|
|
105
|
-
const raw = await readFile(filePath, 'utf-8');
|
|
106
|
-
const state = JSON.parse(raw);
|
|
107
|
-
if (state.sessionId && state.cwd === cwd) {
|
|
108
|
-
this.activeSessionId = state.sessionId;
|
|
109
|
-
this.contextTracker.restore(state);
|
|
110
|
-
return true;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
// File doesn't exist or is corrupt — start fresh
|
|
115
|
-
}
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
async persistActiveSession(cwd) {
|
|
119
|
-
if (!this.activeSessionId) {
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const snap = this.contextTracker.snapshot();
|
|
123
|
-
const state = {
|
|
124
|
-
sessionId: this.activeSessionId,
|
|
125
|
-
cwd,
|
|
126
|
-
startedAt: new Date().toISOString(),
|
|
127
|
-
totalTurns: snap.totalTurns,
|
|
128
|
-
totalCostUsd: snap.totalCostUsd,
|
|
129
|
-
estimatedContextPercent: snap.estimatedContextPercent,
|
|
130
|
-
contextWindowSize: snap.contextWindowSize,
|
|
131
|
-
latestInputTokens: snap.latestInputTokens,
|
|
132
|
-
};
|
|
133
|
-
const filePath = join(cwd, ACTIVE_SESSION_FILE);
|
|
134
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
135
|
-
await writeFile(filePath, JSON.stringify(state, null, 2));
|
|
136
|
-
}
|
|
137
|
-
async removeActiveSession(cwd) {
|
|
138
|
-
const filePath = join(cwd, ACTIVE_SESSION_FILE);
|
|
139
|
-
try {
|
|
140
|
-
const { unlink } = await import('node:fs/promises');
|
|
141
|
-
await unlink(filePath);
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
// File doesn't exist — that's fine
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
97
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ClaudeSessionEventHandler } from '../claude/claude-agent-sdk-adapter.js';
|
|
2
|
+
import type { ClaudeSessionService } from '../claude/claude-session.service.js';
|
|
3
|
+
import type { TeamStateStore } from '../state/team-state-store.js';
|
|
4
|
+
import type { TranscriptStore } from '../state/transcript-store.js';
|
|
5
|
+
import type { DiscoveredClaudeFile, EngineerName, EngineerTaskResult, EngineerWorkMode, SynthesizedPlanResult, TeamRecord } from '../types/contracts.js';
|
|
6
|
+
interface DispatchEngineerInput {
|
|
7
|
+
teamId: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
engineer: EngineerName;
|
|
10
|
+
mode: EngineerWorkMode;
|
|
11
|
+
message: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
abortSignal?: AbortSignal;
|
|
14
|
+
onEvent?: ClaudeSessionEventHandler;
|
|
15
|
+
}
|
|
16
|
+
export declare class TeamOrchestrator {
|
|
17
|
+
private readonly sessions;
|
|
18
|
+
private readonly teamStore;
|
|
19
|
+
private readonly transcriptStore;
|
|
20
|
+
private readonly engineerSessionPrompt;
|
|
21
|
+
private readonly projectClaudeFiles;
|
|
22
|
+
constructor(sessions: ClaudeSessionService, teamStore: TeamStateStore, transcriptStore: TranscriptStore, engineerSessionPrompt: string, projectClaudeFiles: DiscoveredClaudeFile[]);
|
|
23
|
+
getOrCreateTeam(cwd: string, teamId: string): Promise<TeamRecord>;
|
|
24
|
+
listTeams(cwd: string): Promise<TeamRecord[]>;
|
|
25
|
+
recordWrapperSession(cwd: string, teamId: string, engineer: EngineerName, wrapperSessionId: string): Promise<void>;
|
|
26
|
+
recordWrapperExchange(cwd: string, teamId: string, engineer: EngineerName, wrapperSessionId: string, mode: EngineerWorkMode, assignment: string, result: string): Promise<void>;
|
|
27
|
+
getWrapperSystemContext(cwd: string, teamId: string, engineer: EngineerName): Promise<string | null>;
|
|
28
|
+
findTeamByWrapperSession(cwd: string, wrapperSessionId: string): Promise<{
|
|
29
|
+
teamId: string;
|
|
30
|
+
engineer: EngineerName;
|
|
31
|
+
} | null>;
|
|
32
|
+
dispatchEngineer(input: DispatchEngineerInput): Promise<EngineerTaskResult>;
|
|
33
|
+
planWithTeam(input: {
|
|
34
|
+
teamId: string;
|
|
35
|
+
cwd: string;
|
|
36
|
+
request: string;
|
|
37
|
+
leadEngineer: EngineerName;
|
|
38
|
+
challengerEngineer: EngineerName;
|
|
39
|
+
model?: string;
|
|
40
|
+
abortSignal?: AbortSignal;
|
|
41
|
+
}): Promise<SynthesizedPlanResult>;
|
|
42
|
+
private updateEngineer;
|
|
43
|
+
private reserveEngineer;
|
|
44
|
+
private getEngineerState;
|
|
45
|
+
private normalizeTeamRecord;
|
|
46
|
+
private buildSessionSystemPrompt;
|
|
47
|
+
private buildEngineerPrompt;
|
|
48
|
+
private mapWorkModeToSessionMode;
|
|
49
|
+
}
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createEmptyEngineerRecord, createEmptyTeamRecord } from '../team/roster.js';
|
|
2
|
+
import { ContextTracker } from './context-tracker.js';
|
|
3
|
+
export class TeamOrchestrator {
|
|
4
|
+
sessions;
|
|
5
|
+
teamStore;
|
|
6
|
+
transcriptStore;
|
|
7
|
+
engineerSessionPrompt;
|
|
8
|
+
projectClaudeFiles;
|
|
9
|
+
constructor(sessions, teamStore, transcriptStore, engineerSessionPrompt, projectClaudeFiles) {
|
|
10
|
+
this.sessions = sessions;
|
|
11
|
+
this.teamStore = teamStore;
|
|
12
|
+
this.transcriptStore = transcriptStore;
|
|
13
|
+
this.engineerSessionPrompt = engineerSessionPrompt;
|
|
14
|
+
this.projectClaudeFiles = projectClaudeFiles;
|
|
15
|
+
}
|
|
16
|
+
async getOrCreateTeam(cwd, teamId) {
|
|
17
|
+
const existing = await this.teamStore.getTeam(cwd, teamId);
|
|
18
|
+
if (existing) {
|
|
19
|
+
return this.normalizeTeamRecord(existing);
|
|
20
|
+
}
|
|
21
|
+
const created = createEmptyTeamRecord(teamId, cwd);
|
|
22
|
+
await this.teamStore.saveTeam(created);
|
|
23
|
+
return created;
|
|
24
|
+
}
|
|
25
|
+
async listTeams(cwd) {
|
|
26
|
+
const teams = await this.teamStore.listTeams(cwd);
|
|
27
|
+
return teams.map((team) => this.normalizeTeamRecord(team));
|
|
28
|
+
}
|
|
29
|
+
async recordWrapperSession(cwd, teamId, engineer, wrapperSessionId) {
|
|
30
|
+
await this.updateEngineer(cwd, teamId, engineer, (entry) => ({
|
|
31
|
+
...entry,
|
|
32
|
+
wrapperSessionId,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
async recordWrapperExchange(cwd, teamId, engineer, wrapperSessionId, mode, assignment, result) {
|
|
36
|
+
const timestamp = new Date().toISOString();
|
|
37
|
+
await this.updateEngineer(cwd, teamId, engineer, (entry) => ({
|
|
38
|
+
...entry,
|
|
39
|
+
wrapperSessionId,
|
|
40
|
+
wrapperHistory: appendWrapperHistoryEntries(entry.wrapperHistory, [
|
|
41
|
+
{ timestamp, type: 'assignment', mode, text: summarizeText(assignment, 320) },
|
|
42
|
+
{ timestamp, type: 'result', mode, text: summarizeText(result, 320) },
|
|
43
|
+
]),
|
|
44
|
+
lastMode: mode,
|
|
45
|
+
lastTaskSummary: summarizeMessage(assignment),
|
|
46
|
+
lastUsedAt: timestamp,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
async getWrapperSystemContext(cwd, teamId, engineer) {
|
|
50
|
+
const team = await this.getOrCreateTeam(cwd, teamId);
|
|
51
|
+
const state = this.getEngineerState(team, engineer);
|
|
52
|
+
if (state.wrapperHistory.length === 0) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const historyLines = state.wrapperHistory.map((entry) => {
|
|
56
|
+
const modeLabel = entry.mode ? ` [${entry.mode}]` : '';
|
|
57
|
+
return `- ${entry.type}${modeLabel}: ${entry.text}`;
|
|
58
|
+
});
|
|
59
|
+
return [
|
|
60
|
+
`Persistent wrapper memory for ${engineer} in CTO team ${teamId}:`,
|
|
61
|
+
'Use this only to improve delegation quality and continuity.',
|
|
62
|
+
'Prefer the current assignment when it conflicts with older context.',
|
|
63
|
+
...historyLines,
|
|
64
|
+
].join('\n');
|
|
65
|
+
}
|
|
66
|
+
async findTeamByWrapperSession(cwd, wrapperSessionId) {
|
|
67
|
+
const teams = await this.listTeams(cwd);
|
|
68
|
+
for (const team of teams) {
|
|
69
|
+
for (const engineer of team.engineers) {
|
|
70
|
+
if (engineer.wrapperSessionId === wrapperSessionId) {
|
|
71
|
+
return {
|
|
72
|
+
teamId: team.id,
|
|
73
|
+
engineer: engineer.name,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
async dispatchEngineer(input) {
|
|
81
|
+
const team = await this.getOrCreateTeam(input.cwd, input.teamId);
|
|
82
|
+
const engineerState = this.getEngineerState(team, input.engineer);
|
|
83
|
+
await this.reserveEngineer(input.cwd, input.teamId, input.engineer);
|
|
84
|
+
try {
|
|
85
|
+
const tracker = new ContextTracker();
|
|
86
|
+
if (engineerState.context.sessionId) {
|
|
87
|
+
tracker.restore({
|
|
88
|
+
sessionId: engineerState.context.sessionId,
|
|
89
|
+
totalTurns: engineerState.context.totalTurns,
|
|
90
|
+
totalCostUsd: engineerState.context.totalCostUsd,
|
|
91
|
+
estimatedContextPercent: engineerState.context.estimatedContextPercent,
|
|
92
|
+
contextWindowSize: engineerState.context.contextWindowSize,
|
|
93
|
+
latestInputTokens: engineerState.context.latestInputTokens,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const result = await this.sessions.runTask({
|
|
97
|
+
cwd: input.cwd,
|
|
98
|
+
prompt: this.buildEngineerPrompt(input.mode, input.message),
|
|
99
|
+
systemPrompt: engineerState.claudeSessionId
|
|
100
|
+
? undefined
|
|
101
|
+
: this.buildSessionSystemPrompt(input.engineer, input.mode),
|
|
102
|
+
resumeSessionId: engineerState.claudeSessionId ?? undefined,
|
|
103
|
+
persistSession: true,
|
|
104
|
+
includePartialMessages: true,
|
|
105
|
+
permissionMode: this.mapWorkModeToSessionMode(input.mode) === 'plan' ? 'plan' : 'acceptEdits',
|
|
106
|
+
model: input.model,
|
|
107
|
+
effort: input.mode === 'implement' ? 'high' : 'medium',
|
|
108
|
+
settingSources: ['user'],
|
|
109
|
+
abortSignal: input.abortSignal,
|
|
110
|
+
}, input.onEvent);
|
|
111
|
+
tracker.recordResult({
|
|
112
|
+
sessionId: result.sessionId,
|
|
113
|
+
turns: result.turns,
|
|
114
|
+
totalCostUsd: result.totalCostUsd,
|
|
115
|
+
inputTokens: result.inputTokens,
|
|
116
|
+
outputTokens: result.outputTokens,
|
|
117
|
+
contextWindowSize: result.contextWindowSize,
|
|
118
|
+
});
|
|
119
|
+
if (result.sessionId && result.events.length > 0) {
|
|
120
|
+
await this.transcriptStore.appendEvents(input.cwd, result.sessionId, result.events);
|
|
121
|
+
}
|
|
122
|
+
const context = tracker.snapshot();
|
|
123
|
+
await this.updateEngineer(input.cwd, input.teamId, input.engineer, (entry) => ({
|
|
124
|
+
...entry,
|
|
125
|
+
claudeSessionId: result.sessionId ?? engineerState.claudeSessionId,
|
|
126
|
+
busy: false,
|
|
127
|
+
lastMode: input.mode,
|
|
128
|
+
lastTaskSummary: summarizeMessage(input.message),
|
|
129
|
+
lastUsedAt: new Date().toISOString(),
|
|
130
|
+
context,
|
|
131
|
+
}));
|
|
132
|
+
return {
|
|
133
|
+
teamId: input.teamId,
|
|
134
|
+
engineer: input.engineer,
|
|
135
|
+
mode: input.mode,
|
|
136
|
+
sessionId: result.sessionId,
|
|
137
|
+
finalText: result.finalText,
|
|
138
|
+
turns: result.turns,
|
|
139
|
+
totalCostUsd: result.totalCostUsd,
|
|
140
|
+
inputTokens: result.inputTokens,
|
|
141
|
+
outputTokens: result.outputTokens,
|
|
142
|
+
contextWindowSize: result.contextWindowSize,
|
|
143
|
+
context,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
await this.updateEngineer(input.cwd, input.teamId, input.engineer, (engineer) => ({
|
|
148
|
+
...engineer,
|
|
149
|
+
busy: false,
|
|
150
|
+
}));
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async planWithTeam(input) {
|
|
155
|
+
if (input.leadEngineer === input.challengerEngineer) {
|
|
156
|
+
throw new Error('Choose two different engineers for plan synthesis.');
|
|
157
|
+
}
|
|
158
|
+
const [leadDraft, challengerDraft] = await Promise.all([
|
|
159
|
+
this.dispatchEngineer({
|
|
160
|
+
teamId: input.teamId,
|
|
161
|
+
cwd: input.cwd,
|
|
162
|
+
engineer: input.leadEngineer,
|
|
163
|
+
mode: 'explore',
|
|
164
|
+
message: buildPlanDraftRequest('lead', input.request),
|
|
165
|
+
model: input.model,
|
|
166
|
+
abortSignal: input.abortSignal,
|
|
167
|
+
}),
|
|
168
|
+
this.dispatchEngineer({
|
|
169
|
+
teamId: input.teamId,
|
|
170
|
+
cwd: input.cwd,
|
|
171
|
+
engineer: input.challengerEngineer,
|
|
172
|
+
mode: 'explore',
|
|
173
|
+
message: buildPlanDraftRequest('challenger', input.request),
|
|
174
|
+
model: input.model,
|
|
175
|
+
abortSignal: input.abortSignal,
|
|
176
|
+
}),
|
|
177
|
+
]);
|
|
178
|
+
const drafts = [
|
|
179
|
+
{ ...leadDraft, request: input.request },
|
|
180
|
+
{ ...challengerDraft, request: input.request },
|
|
181
|
+
];
|
|
182
|
+
const synthesisResult = await this.sessions.runTask({
|
|
183
|
+
cwd: input.cwd,
|
|
184
|
+
prompt: buildSynthesisPrompt(input.request, drafts),
|
|
185
|
+
systemPrompt: buildSynthesisSystemPrompt(),
|
|
186
|
+
persistSession: false,
|
|
187
|
+
includePartialMessages: false,
|
|
188
|
+
permissionMode: 'plan',
|
|
189
|
+
model: input.model,
|
|
190
|
+
effort: 'high',
|
|
191
|
+
settingSources: ['user'],
|
|
192
|
+
abortSignal: input.abortSignal,
|
|
193
|
+
});
|
|
194
|
+
const parsedSynthesis = parseSynthesisResult(synthesisResult.finalText);
|
|
195
|
+
return {
|
|
196
|
+
teamId: input.teamId,
|
|
197
|
+
request: input.request,
|
|
198
|
+
leadEngineer: input.leadEngineer,
|
|
199
|
+
challengerEngineer: input.challengerEngineer,
|
|
200
|
+
drafts,
|
|
201
|
+
synthesis: parsedSynthesis.synthesis,
|
|
202
|
+
recommendedQuestion: parsedSynthesis.recommendedQuestion,
|
|
203
|
+
recommendedAnswer: parsedSynthesis.recommendedAnswer,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async updateEngineer(cwd, teamId, engineerName, update) {
|
|
207
|
+
await this.getOrCreateTeam(cwd, teamId);
|
|
208
|
+
await this.teamStore.updateTeam(cwd, teamId, (team) => {
|
|
209
|
+
const normalized = this.normalizeTeamRecord(team);
|
|
210
|
+
const existing = this.getEngineerState(normalized, engineerName);
|
|
211
|
+
return {
|
|
212
|
+
...normalized,
|
|
213
|
+
updatedAt: new Date().toISOString(),
|
|
214
|
+
engineers: normalized.engineers.map((engineer) => engineer.name === engineerName ? update(existing) : engineer),
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async reserveEngineer(cwd, teamId, engineerName) {
|
|
219
|
+
await this.getOrCreateTeam(cwd, teamId);
|
|
220
|
+
await this.teamStore.updateTeam(cwd, teamId, (team) => {
|
|
221
|
+
const normalized = this.normalizeTeamRecord(team);
|
|
222
|
+
const engineer = this.getEngineerState(normalized, engineerName);
|
|
223
|
+
if (engineer.busy) {
|
|
224
|
+
throw new Error(`${engineerName} is already working on another assignment.`);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
...normalized,
|
|
228
|
+
updatedAt: new Date().toISOString(),
|
|
229
|
+
engineers: normalized.engineers.map((entry) => entry.name === engineerName
|
|
230
|
+
? {
|
|
231
|
+
...entry,
|
|
232
|
+
busy: true,
|
|
233
|
+
}
|
|
234
|
+
: entry),
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
getEngineerState(team, engineerName) {
|
|
239
|
+
return (team.engineers.find((engineer) => engineer.name === engineerName) ??
|
|
240
|
+
createEmptyEngineerRecord(engineerName));
|
|
241
|
+
}
|
|
242
|
+
normalizeTeamRecord(team) {
|
|
243
|
+
const engineerMap = new Map(team.engineers.map((engineer) => [engineer.name, engineer]));
|
|
244
|
+
return {
|
|
245
|
+
...team,
|
|
246
|
+
engineers: createEmptyTeamRecord(team.id, team.cwd).engineers.map((engineer) => engineerMap.get(engineer.name) ?? engineer),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
buildSessionSystemPrompt(engineer, mode) {
|
|
250
|
+
const claudeFileSection = this.projectClaudeFiles.length
|
|
251
|
+
? `\n\nProject Claude Files:\n${this.projectClaudeFiles
|
|
252
|
+
.map((file) => `## ${file.relativePath}\n${file.content}`)
|
|
253
|
+
.join('\n\n')}`
|
|
254
|
+
: '';
|
|
255
|
+
return [
|
|
256
|
+
this.engineerSessionPrompt,
|
|
257
|
+
'',
|
|
258
|
+
`Assigned engineer: ${engineer}.`,
|
|
259
|
+
`Current work mode: ${mode}.`,
|
|
260
|
+
claudeFileSection,
|
|
261
|
+
]
|
|
262
|
+
.join('\n')
|
|
263
|
+
.trim();
|
|
264
|
+
}
|
|
265
|
+
buildEngineerPrompt(mode, message) {
|
|
266
|
+
return `${buildModeInstruction(mode)}\n\n${message}`;
|
|
267
|
+
}
|
|
268
|
+
mapWorkModeToSessionMode(mode) {
|
|
269
|
+
return mode === 'explore' ? 'plan' : 'free';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function buildModeInstruction(mode) {
|
|
273
|
+
switch (mode) {
|
|
274
|
+
case 'explore':
|
|
275
|
+
return 'Work in planning mode. Investigate, reason, and write the plan inline. Do not make file edits.';
|
|
276
|
+
case 'implement':
|
|
277
|
+
return 'Work in implementation mode. Make the changes, verify them, and report clearly.';
|
|
278
|
+
case 'verify':
|
|
279
|
+
return 'Work in verification mode. Run the narrowest useful checks first, then broaden only if needed.';
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function summarizeMessage(message) {
|
|
283
|
+
const compact = message.replace(/\s+/g, ' ').trim();
|
|
284
|
+
return compact.length > 120 ? `${compact.slice(0, 117)}...` : compact;
|
|
285
|
+
}
|
|
286
|
+
function summarizeText(text, limit) {
|
|
287
|
+
const compact = text.replace(/\s+/g, ' ').trim();
|
|
288
|
+
return compact.length > limit ? `${compact.slice(0, limit - 3)}...` : compact;
|
|
289
|
+
}
|
|
290
|
+
function appendWrapperHistoryEntries(existing, nextEntries) {
|
|
291
|
+
return [...existing, ...nextEntries].slice(-12);
|
|
292
|
+
}
|
|
293
|
+
function buildPlanDraftRequest(perspective, request) {
|
|
294
|
+
const posture = perspective === 'lead'
|
|
295
|
+
? 'Propose the most direct workable plan.'
|
|
296
|
+
: 'Challenge weak assumptions, find missing decisions, and propose a stronger alternative if needed.';
|
|
297
|
+
return [
|
|
298
|
+
posture,
|
|
299
|
+
'',
|
|
300
|
+
'Return exactly these sections:',
|
|
301
|
+
'1. Objective',
|
|
302
|
+
'2. Proposed approach',
|
|
303
|
+
'3. Files or systems likely involved',
|
|
304
|
+
'4. Risks and open questions',
|
|
305
|
+
'5. Verification',
|
|
306
|
+
'6. Step-by-step plan',
|
|
307
|
+
'',
|
|
308
|
+
`User request: ${request}`,
|
|
309
|
+
].join('\n');
|
|
310
|
+
}
|
|
311
|
+
function buildSynthesisSystemPrompt() {
|
|
312
|
+
return [
|
|
313
|
+
'You are the CTO synthesis engine.',
|
|
314
|
+
'Combine two independent engineering plans into one better plan.',
|
|
315
|
+
'Prefer the clearest, simplest, highest-leverage path.',
|
|
316
|
+
'If one user decision is still required, surface exactly one recommended question and one recommended answer.',
|
|
317
|
+
'Use this output format exactly:',
|
|
318
|
+
'## Synthesis',
|
|
319
|
+
'<combined plan>',
|
|
320
|
+
'## Recommended Question',
|
|
321
|
+
'<question or NONE>',
|
|
322
|
+
'## Recommended Answer',
|
|
323
|
+
'<answer or NONE>',
|
|
324
|
+
].join('\n');
|
|
325
|
+
}
|
|
326
|
+
function buildSynthesisPrompt(request, drafts) {
|
|
327
|
+
return [
|
|
328
|
+
`User request: ${request}`,
|
|
329
|
+
'',
|
|
330
|
+
`Lead engineer (${drafts[0].engineer}) draft:`,
|
|
331
|
+
drafts[0].finalText,
|
|
332
|
+
'',
|
|
333
|
+
`Challenger engineer (${drafts[1].engineer}) draft:`,
|
|
334
|
+
drafts[1].finalText,
|
|
335
|
+
].join('\n');
|
|
336
|
+
}
|
|
337
|
+
function parseSynthesisResult(text) {
|
|
338
|
+
const synthesis = extractSection(text, 'Synthesis') ?? text.trim();
|
|
339
|
+
const recommendedQuestion = normalizeOptionalSection(extractSection(text, 'Recommended Question'));
|
|
340
|
+
const recommendedAnswer = normalizeOptionalSection(extractSection(text, 'Recommended Answer'));
|
|
341
|
+
return {
|
|
342
|
+
synthesis,
|
|
343
|
+
recommendedQuestion,
|
|
344
|
+
recommendedAnswer,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function extractSection(text, heading) {
|
|
348
|
+
const regex = new RegExp(`## ${escapeRegExp(heading)}\\n([\\s\\S]*?)(?=\\n## |$)`);
|
|
349
|
+
const match = text.match(regex);
|
|
350
|
+
return match?.[1]?.trim() ?? null;
|
|
351
|
+
}
|
|
352
|
+
function normalizeOptionalSection(value) {
|
|
353
|
+
if (!value) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
return value.toUpperCase() === 'NONE' ? null : value;
|
|
357
|
+
}
|
|
358
|
+
function escapeRegExp(value) {
|
|
359
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
360
|
+
}
|