@doingdev/opencode-claude-manager-plugin 0.1.64 → 0.1.66

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.
Files changed (125) hide show
  1. package/README.md +106 -120
  2. package/dist/claude/claude-agent-sdk-adapter.js +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/manager/team-orchestrator.js +1 -1
  5. package/dist/plugin/agents/common.d.ts +2 -2
  6. package/dist/plugin/agents/common.js +5 -0
  7. package/dist/plugin/claude-manager.plugin.js +104 -0
  8. package/dist/plugin/inbox-ops.d.ts +50 -0
  9. package/dist/plugin/inbox-ops.js +166 -0
  10. package/dist/types/contracts.d.ts +18 -0
  11. package/package.json +13 -13
  12. package/dist/claude/session-live-tailer.d.ts +0 -51
  13. package/dist/claude/session-live-tailer.js +0 -269
  14. package/dist/manager/session-controller.d.ts +0 -41
  15. package/dist/manager/session-controller.js +0 -97
  16. package/dist/metadata/claude-metadata.service.d.ts +0 -12
  17. package/dist/metadata/claude-metadata.service.js +0 -38
  18. package/dist/metadata/repo-claude-config-reader.d.ts +0 -7
  19. package/dist/metadata/repo-claude-config-reader.js +0 -154
  20. package/dist/plugin/orchestrator.plugin.d.ts +0 -2
  21. package/dist/plugin/orchestrator.plugin.js +0 -116
  22. package/dist/providers/claude-code-wrapper.d.ts +0 -13
  23. package/dist/providers/claude-code-wrapper.js +0 -13
  24. package/dist/safety/bash-safety.d.ts +0 -21
  25. package/dist/safety/bash-safety.js +0 -62
  26. package/dist/src/claude/claude-agent-sdk-adapter.d.ts +0 -28
  27. package/dist/src/claude/claude-agent-sdk-adapter.js +0 -559
  28. package/dist/src/claude/claude-session.service.d.ts +0 -9
  29. package/dist/src/claude/claude-session.service.js +0 -15
  30. package/dist/src/claude/session-live-tailer.d.ts +0 -51
  31. package/dist/src/claude/session-live-tailer.js +0 -269
  32. package/dist/src/claude/tool-approval-manager.d.ts +0 -30
  33. package/dist/src/claude/tool-approval-manager.js +0 -279
  34. package/dist/src/index.d.ts +0 -5
  35. package/dist/src/index.js +0 -3
  36. package/dist/src/manager/context-tracker.d.ts +0 -32
  37. package/dist/src/manager/context-tracker.js +0 -103
  38. package/dist/src/manager/git-operations.d.ts +0 -18
  39. package/dist/src/manager/git-operations.js +0 -86
  40. package/dist/src/manager/persistent-manager.d.ts +0 -39
  41. package/dist/src/manager/persistent-manager.js +0 -44
  42. package/dist/src/manager/session-controller.d.ts +0 -41
  43. package/dist/src/manager/session-controller.js +0 -97
  44. package/dist/src/manager/team-orchestrator.d.ts +0 -81
  45. package/dist/src/manager/team-orchestrator.js +0 -612
  46. package/dist/src/plugin/agent-hierarchy.d.ts +0 -1
  47. package/dist/src/plugin/agent-hierarchy.js +0 -2
  48. package/dist/src/plugin/agents/browser-qa.d.ts +0 -14
  49. package/dist/src/plugin/agents/browser-qa.js +0 -31
  50. package/dist/src/plugin/agents/common.d.ts +0 -36
  51. package/dist/src/plugin/agents/common.js +0 -59
  52. package/dist/src/plugin/agents/cto.d.ts +0 -9
  53. package/dist/src/plugin/agents/cto.js +0 -39
  54. package/dist/src/plugin/agents/engineers.d.ts +0 -9
  55. package/dist/src/plugin/agents/engineers.js +0 -11
  56. package/dist/src/plugin/agents/index.d.ts +0 -5
  57. package/dist/src/plugin/agents/index.js +0 -5
  58. package/dist/src/plugin/agents/team-planner.d.ts +0 -10
  59. package/dist/src/plugin/agents/team-planner.js +0 -23
  60. package/dist/src/plugin/claude-manager.plugin.d.ts +0 -10
  61. package/dist/src/plugin/claude-manager.plugin.js +0 -950
  62. package/dist/src/plugin/service-factory.d.ts +0 -38
  63. package/dist/src/plugin/service-factory.js +0 -101
  64. package/dist/src/prompts/registry.d.ts +0 -2
  65. package/dist/src/prompts/registry.js +0 -210
  66. package/dist/src/state/file-run-state-store.d.ts +0 -14
  67. package/dist/src/state/file-run-state-store.js +0 -85
  68. package/dist/src/state/team-state-store.d.ts +0 -14
  69. package/dist/src/state/team-state-store.js +0 -88
  70. package/dist/src/state/transcript-store.d.ts +0 -15
  71. package/dist/src/state/transcript-store.js +0 -44
  72. package/dist/src/team/roster.d.ts +0 -5
  73. package/dist/src/team/roster.js +0 -40
  74. package/dist/src/types/contracts.d.ts +0 -261
  75. package/dist/src/types/contracts.js +0 -2
  76. package/dist/src/util/fs-helpers.d.ts +0 -8
  77. package/dist/src/util/fs-helpers.js +0 -21
  78. package/dist/src/util/project-context.d.ts +0 -10
  79. package/dist/src/util/project-context.js +0 -105
  80. package/dist/src/util/transcript-append.d.ts +0 -7
  81. package/dist/src/util/transcript-append.js +0 -29
  82. package/dist/state/file-run-state-store.d.ts +0 -14
  83. package/dist/state/file-run-state-store.js +0 -85
  84. package/dist/test/claude-agent-sdk-adapter.test.d.ts +0 -1
  85. package/dist/test/claude-agent-sdk-adapter.test.js +0 -707
  86. package/dist/test/claude-manager.plugin.test.d.ts +0 -1
  87. package/dist/test/claude-manager.plugin.test.js +0 -316
  88. package/dist/test/context-tracker.test.d.ts +0 -1
  89. package/dist/test/context-tracker.test.js +0 -130
  90. package/dist/test/cto-active-team.test.d.ts +0 -1
  91. package/dist/test/cto-active-team.test.js +0 -199
  92. package/dist/test/file-run-state-store.test.d.ts +0 -1
  93. package/dist/test/file-run-state-store.test.js +0 -82
  94. package/dist/test/fs-helpers.test.d.ts +0 -1
  95. package/dist/test/fs-helpers.test.js +0 -56
  96. package/dist/test/git-operations.test.d.ts +0 -1
  97. package/dist/test/git-operations.test.js +0 -133
  98. package/dist/test/persistent-manager.test.d.ts +0 -1
  99. package/dist/test/persistent-manager.test.js +0 -48
  100. package/dist/test/project-context.test.d.ts +0 -1
  101. package/dist/test/project-context.test.js +0 -92
  102. package/dist/test/prompt-registry.test.d.ts +0 -1
  103. package/dist/test/prompt-registry.test.js +0 -117
  104. package/dist/test/report-claude-event.test.d.ts +0 -1
  105. package/dist/test/report-claude-event.test.js +0 -304
  106. package/dist/test/session-controller.test.d.ts +0 -1
  107. package/dist/test/session-controller.test.js +0 -149
  108. package/dist/test/session-live-tailer.test.d.ts +0 -1
  109. package/dist/test/session-live-tailer.test.js +0 -313
  110. package/dist/test/team-orchestrator.test.d.ts +0 -1
  111. package/dist/test/team-orchestrator.test.js +0 -583
  112. package/dist/test/team-state-store.test.d.ts +0 -1
  113. package/dist/test/team-state-store.test.js +0 -54
  114. package/dist/test/tool-approval-manager.test.d.ts +0 -1
  115. package/dist/test/tool-approval-manager.test.js +0 -260
  116. package/dist/test/transcript-append.test.d.ts +0 -1
  117. package/dist/test/transcript-append.test.js +0 -37
  118. package/dist/test/transcript-store.test.d.ts +0 -1
  119. package/dist/test/transcript-store.test.js +0 -50
  120. package/dist/test/undo-propagation.test.d.ts +0 -1
  121. package/dist/test/undo-propagation.test.js +0 -837
  122. package/dist/util/project-context.d.ts +0 -10
  123. package/dist/util/project-context.js +0 -105
  124. package/dist/vitest.config.d.ts +0 -2
  125. package/dist/vitest.config.js +0 -11
@@ -1,950 +0,0 @@
1
- import { tool } from '@opencode-ai/plugin';
2
- import { managerPromptRegistry } from '../prompts/registry.js';
3
- import { appendDebugLog } from '../util/fs-helpers.js';
4
- import { isEngineerName } from '../team/roster.js';
5
- import { TeamOrchestrator, createActionableError, getFailureGuidanceText, } from '../manager/team-orchestrator.js';
6
- import { AGENT_BROWSER_QA, AGENT_CTO, AGENT_TEAM_PLANNER, buildBrowserQaAgentConfig, buildCtoAgentConfig, buildEngineerAgentConfig, buildTeamPlannerAgentConfig, denyRestrictedToolsGlobally, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from './agents/index.js';
7
- import { clearLatestRevertProcessed, clearRevertProcessed, getOrCreatePluginServices, getParentSessionId, getSessionTeam, getWrapperSessionMapping, isRevertAlreadyProcessed, markRevertProcessed, registerParentSession, registerSessionTeam, setWrapperSessionMapping, } from './service-factory.js';
8
- const MODEL_ENUM = ['claude-opus-4-6', 'claude-sonnet-4-6'];
9
- const MODE_ENUM = ['explore', 'implement', 'verify'];
10
- export const ClaudeManagerPlugin = async ({ worktree, client }) => {
11
- const services = getOrCreatePluginServices(worktree);
12
- await services.approvalManager.loadPersistedPolicy();
13
- /**
14
- * Resolves the team ID for a brand-new engineer wrapper session.
15
- *
16
- * 1. Walk the cached parentID chain (populated by session.created events).
17
- * 2. On a cache miss, attempt a live client.session.get() lookup and cache
18
- * whatever parentID the SDK returns.
19
- * 3. Fall back to the orphan sentinel (sessionID itself) only when both
20
- * cache and live lookup come up empty.
21
- */
22
- async function resolveTeamId(sessionID) {
23
- let current = sessionID;
24
- const seen = new Set();
25
- while (current && !seen.has(current)) {
26
- seen.add(current);
27
- const team = getSessionTeam(current);
28
- if (team !== undefined)
29
- return team;
30
- // Cache miss on this node's parent: try the live SDK.
31
- if (client && !getParentSessionId(current)) {
32
- try {
33
- const result = await client.session.get({ path: { id: current } });
34
- const parentID = result.data?.parentID;
35
- if (parentID) {
36
- registerParentSession(current, parentID);
37
- }
38
- }
39
- catch {
40
- // Network / auth failure — let the walk continue to orphan.
41
- }
42
- }
43
- current = getParentSessionId(current);
44
- }
45
- return sessionID;
46
- }
47
- /**
48
- * Propagate a CTO-level undo to all engineer wrapper sessions and their inner
49
- * Claude Code sessions that ran work after the reverted CTO message.
50
- *
51
- * Steps per affected engineer:
52
- * 1. Determine how many wrapper exchanges happened after the cutoff.
53
- * 2. Revert the engineer's OpenCode wrapper session to the first message after the cutoff.
54
- * 3. Send `/undo` to the inner Claude Code session once per affected exchange.
55
- * 4. Prune the in-disk wrapper history to remove the stale entries.
56
- *
57
- * Best-effort: one engineer's failure does not prevent others from being processed.
58
- */
59
- async function handleCtoUndoPropagation(ctoSessionId, teamId, revertMessageId) {
60
- if (!client) {
61
- // Throw so the caller's catch clears the dedup marker; transient — should be retried.
62
- throw new Error('no OpenCode client — cannot resolve revert cutoff');
63
- }
64
- // Fetch the CTO message to get a reliable cutoff timestamp.
65
- // Let errors propagate so the caller's catch clears the dedup marker on failure.
66
- const msgResult = await client.session.message({
67
- path: { id: ctoSessionId, messageID: revertMessageId },
68
- });
69
- const created = msgResult.data?.info.time.created;
70
- if (created === undefined) {
71
- throw new Error('reverted CTO message has no creation timestamp');
72
- }
73
- const cutoffMs = created;
74
- const cutoffIso = new Date(cutoffMs).toISOString();
75
- const team = await services.orchestrator.getOrCreateTeam(worktree, teamId);
76
- for (const engineerRecord of team.engineers) {
77
- try {
78
- await undoEngineerTurns(teamId, engineerRecord, cutoffMs, cutoffIso);
79
- }
80
- catch (err) {
81
- // One engineer's failure must not prevent others from being processed.
82
- try {
83
- await appendDebugLog(services.debugLogPath, {
84
- type: 'undo_engineer_error',
85
- ctoSessionId,
86
- teamId,
87
- engineer: engineerRecord.name,
88
- error: err instanceof Error ? err.message : String(err),
89
- });
90
- }
91
- catch {
92
- // Ignore log write failures.
93
- }
94
- }
95
- }
96
- }
97
- /**
98
- * Undo all work an engineer did after the cutoff in both their OpenCode wrapper
99
- * session and their inner Claude Code session, then prune the wrapper history.
100
- */
101
- async function undoEngineerTurns(teamId, engineerRecord, cutoffMs, cutoffIso) {
102
- // Count how many full exchanges (assignment+result pairs) happened after the cutoff.
103
- const undoCount = engineerRecord.wrapperHistory.filter((h) => h.type === 'assignment' && h.timestamp > cutoffIso).length;
104
- if (undoCount === 0) {
105
- return;
106
- }
107
- // 1. Revert the engineer's OpenCode wrapper session.
108
- if (client && engineerRecord.wrapperSessionId) {
109
- try {
110
- await revertWrapperSession(engineerRecord.wrapperSessionId, cutoffMs);
111
- }
112
- catch (err) {
113
- try {
114
- await appendDebugLog(services.debugLogPath, {
115
- type: 'undo_wrapper_revert_error',
116
- teamId,
117
- engineer: engineerRecord.name,
118
- error: err instanceof Error ? err.message : String(err),
119
- });
120
- }
121
- catch {
122
- // Ignore log write failures.
123
- }
124
- }
125
- }
126
- // 2. Undo the corresponding inner Claude Code session turns.
127
- let innerUndoFailed = false;
128
- if (engineerRecord.claudeSessionId) {
129
- for (let i = 0; i < undoCount; i++) {
130
- try {
131
- await services.sessions.runTask({
132
- cwd: worktree,
133
- prompt: '/undo',
134
- resumeSessionId: engineerRecord.claudeSessionId,
135
- persistSession: true,
136
- permissionMode: 'acceptEdits',
137
- maxTurns: 1,
138
- settingSources: ['user', 'project', 'local'],
139
- }, undefined);
140
- }
141
- catch {
142
- // Best-effort: stop further /undo attempts for this engineer on failure.
143
- innerUndoFailed = true;
144
- break;
145
- }
146
- }
147
- }
148
- // If inner undo failed, the Claude session may be in an inconsistent state.
149
- // Reset the session reference and context snapshot so the next assignment starts fresh.
150
- if (innerUndoFailed) {
151
- try {
152
- await services.orchestrator.resetEngineer(worktree, teamId, engineerRecord.name, {
153
- clearSession: true,
154
- clearHistory: false,
155
- });
156
- }
157
- catch (err) {
158
- try {
159
- await appendDebugLog(services.debugLogPath, {
160
- type: 'undo_clear_session_error',
161
- teamId,
162
- engineer: engineerRecord.name,
163
- error: err instanceof Error ? err.message : String(err),
164
- });
165
- }
166
- catch {
167
- // Ignore log write failures.
168
- }
169
- }
170
- }
171
- // 3. Prune the persisted wrapper history.
172
- try {
173
- await services.orchestrator.pruneWrapperHistoryAfter(worktree, teamId, engineerRecord.name, cutoffIso);
174
- }
175
- catch (err) {
176
- try {
177
- await appendDebugLog(services.debugLogPath, {
178
- type: 'undo_prune_error',
179
- teamId,
180
- engineer: engineerRecord.name,
181
- error: err instanceof Error ? err.message : String(err),
182
- });
183
- }
184
- catch {
185
- // Ignore log write failures.
186
- }
187
- }
188
- }
189
- /**
190
- * Revert an engineer's OpenCode wrapper session to just before the first user
191
- * message that was created after cutoffMs.
192
- */
193
- async function revertWrapperSession(wrapperSessionId, cutoffMs) {
194
- if (!client) {
195
- return;
196
- }
197
- const messagesResult = await client.session.messages({
198
- path: { id: wrapperSessionId },
199
- });
200
- const messages = messagesResult.data ?? [];
201
- const firstAffected = messages.find((m) => m.info.role === 'user' && m.info.time.created > cutoffMs);
202
- if (!firstAffected) {
203
- return;
204
- }
205
- await client.session.revert({
206
- path: { id: wrapperSessionId },
207
- body: { messageID: firstAffected.info.id },
208
- });
209
- }
210
- return {
211
- config: async (config) => {
212
- config.agent ??= {};
213
- config.permission ??= {};
214
- denyRestrictedToolsGlobally(config.permission);
215
- config.agent[AGENT_CTO] ??= buildCtoAgentConfig(managerPromptRegistry);
216
- config.agent[AGENT_TEAM_PLANNER] ??= buildTeamPlannerAgentConfig(managerPromptRegistry);
217
- config.agent[AGENT_BROWSER_QA] ??= buildBrowserQaAgentConfig(managerPromptRegistry);
218
- for (const engineer of ENGINEER_AGENT_NAMES) {
219
- config.agent[ENGINEER_AGENT_IDS[engineer]] ??= buildEngineerAgentConfig(managerPromptRegistry, engineer);
220
- }
221
- },
222
- 'chat.message': async (input) => {
223
- if (input.agent === AGENT_CTO) {
224
- // Each CTO session ID is its own team. The session ID is the durable
225
- // identity: no worktree-global active-team state.
226
- registerSessionTeam(input.sessionID, input.sessionID);
227
- return;
228
- }
229
- if (input.agent && isEngineerAgent(input.agent)) {
230
- const engineer = engineerFromAgent(input.agent);
231
- const existing = getWrapperSessionMapping(worktree, input.sessionID);
232
- const persisted = existing ??
233
- (await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID));
234
- const teamId = persisted?.teamId ?? (await resolveTeamId(input.sessionID));
235
- setWrapperSessionMapping(worktree, input.sessionID, {
236
- teamId,
237
- workerName: engineer,
238
- });
239
- await services.orchestrator.recordWrapperSession(worktree, teamId, engineer, input.sessionID);
240
- }
241
- },
242
- event: async ({ event: sdkEvent }) => {
243
- if (sdkEvent.type === 'session.created') {
244
- const session = sdkEvent.properties.info;
245
- if (session.parentID) {
246
- registerParentSession(session.id, session.parentID);
247
- }
248
- return;
249
- }
250
- if (sdkEvent.type === 'session.updated') {
251
- const session = sdkEvent.properties.info;
252
- if (!session.revert) {
253
- // Revert marker cleared — remove the stale dedup entry so a future undo
254
- // of the same message (redo → undo) is processed again.
255
- clearLatestRevertProcessed(session.id);
256
- return;
257
- }
258
- // Only propagate undo for CTO sessions (teamId === sessionId).
259
- if (getSessionTeam(session.id) !== session.id) {
260
- // In-memory miss — check persisted state to survive process restart / cache loss.
261
- const persistedTeam = await services.teamStore.getTeam(worktree, session.id);
262
- if (!persistedTeam) {
263
- return;
264
- }
265
- // Valid CTO team found on disk — register it so future events skip the I/O.
266
- registerSessionTeam(session.id, session.id);
267
- }
268
- const teamId = session.id;
269
- const revertMessageId = session.revert.messageID;
270
- // Deduplicate: multiple session.updated events can fire for the same revert marker.
271
- if (isRevertAlreadyProcessed(session.id, revertMessageId)) {
272
- return;
273
- }
274
- markRevertProcessed(session.id, revertMessageId);
275
- // Best-effort: do not throw from the event hook.
276
- await handleCtoUndoPropagation(session.id, teamId, revertMessageId).catch(async (err) => {
277
- // On total failure, remove the dedup marker so a retry is possible.
278
- clearRevertProcessed(session.id, revertMessageId);
279
- try {
280
- await appendDebugLog(services.debugLogPath, {
281
- type: 'undo_propagation_error',
282
- ctoSessionId: session.id,
283
- revertMessageId,
284
- error: err instanceof Error ? err.message : String(err),
285
- });
286
- }
287
- catch {
288
- // Log write failures must not mask the original error path.
289
- }
290
- });
291
- }
292
- },
293
- 'experimental.chat.system.transform': async (input, output) => {
294
- if (!input.sessionID) {
295
- return;
296
- }
297
- // Try in-memory mapping first
298
- let mapping = getWrapperSessionMapping(worktree, input.sessionID);
299
- // Fall back to persisted lookup if in-memory mapping is absent
300
- if (!mapping) {
301
- // Check if this is an engineer wrapper session
302
- const engineerMatch = await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID);
303
- if (engineerMatch) {
304
- mapping = {
305
- teamId: engineerMatch.teamId,
306
- workerName: engineerMatch.engineer,
307
- };
308
- }
309
- }
310
- if (!mapping) {
311
- return;
312
- }
313
- const wrapperContext = await services.orchestrator.getWrapperSystemContext(worktree, mapping.teamId, mapping.workerName);
314
- if (wrapperContext) {
315
- output.system.push(wrapperContext);
316
- }
317
- },
318
- tool: {
319
- claude: tool({
320
- description: "Run work through a named engineer's persistent Claude Code session. Engineers include general developers (Tom, John, Maya, Sara, Alex) and specialists like browser-qa. The session remembers prior turns.",
321
- args: {
322
- mode: tool.schema.enum(MODE_ENUM),
323
- message: tool.schema.string().min(1),
324
- model: tool.schema.enum(MODEL_ENUM).optional(),
325
- },
326
- async execute(args, context) {
327
- // Handle engineer agents (includes BrowserQA)
328
- const engineer = engineerFromAgent(context.agent);
329
- const existing = getWrapperSessionMapping(context.worktree, context.sessionID);
330
- const persisted = existing ??
331
- (await services.orchestrator.findTeamByWrapperSession(context.worktree, context.sessionID));
332
- const teamId = persisted?.teamId ?? (await resolveTeamId(context.sessionID));
333
- setWrapperSessionMapping(context.worktree, context.sessionID, {
334
- teamId,
335
- workerName: engineer,
336
- });
337
- await services.orchestrator.recordWrapperSession(context.worktree, teamId, engineer, context.sessionID);
338
- const result = await runEngineerAssignment({
339
- teamId,
340
- engineer,
341
- mode: args.mode,
342
- message: args.message,
343
- model: args.model,
344
- }, context);
345
- const capabilities = services.workerCapabilities[engineer];
346
- if (capabilities?.isRuntimeUnavailableResponse?.(result.finalText)) {
347
- const lines = result.finalText.split('\n');
348
- const unavailableLine = lines[0] ?? 'Playwright unavailable (reason unknown)';
349
- context.metadata({
350
- title: capabilities.runtimeUnavailableTitle ?? '❌ Playwright unavailable',
351
- metadata: { unavailable: unavailableLine },
352
- });
353
- }
354
- return result.finalText;
355
- },
356
- }),
357
- team_status: tool({
358
- description: 'Show the current CTO team state: named engineers, wrapper session IDs, Claude session IDs, busy flags, wrapper memory, and context snapshots.',
359
- args: {
360
- teamId: tool.schema.string().optional(),
361
- },
362
- async execute(args, context) {
363
- const teamId = args.teamId ?? context.sessionID;
364
- annotateToolRun(context, 'Reading team status', {
365
- teamId,
366
- });
367
- const team = await services.orchestrator.getOrCreateTeam(context.worktree, teamId);
368
- return JSON.stringify(team, null, 2);
369
- },
370
- }),
371
- plan_with_team: tool({
372
- 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.',
373
- args: {
374
- request: tool.schema.string().min(1),
375
- leadEngineer: tool.schema.enum(['Tom', 'John', 'Maya', 'Sara', 'Alex']).optional(),
376
- challengerEngineer: tool.schema.enum(['Tom', 'John', 'Maya', 'Sara', 'Alex']).optional(),
377
- model: tool.schema.enum(MODEL_ENUM).optional(),
378
- },
379
- async execute(args, context) {
380
- const teamId = context.sessionID;
381
- // Pre-determine engineers for event labeling (using orchestrator selection logic)
382
- const { lead, challenger } = await services.orchestrator.selectPlanEngineers(context.worktree, teamId, args.leadEngineer, args.challengerEngineer);
383
- annotateToolRun(context, 'Running dual-engineer plan synthesis', {
384
- teamId,
385
- lead,
386
- challenger,
387
- });
388
- const result = await services.orchestrator.planWithTeam({
389
- teamId,
390
- cwd: context.worktree,
391
- request: args.request,
392
- leadEngineer: lead,
393
- challengerEngineer: challenger,
394
- model: args.model,
395
- abortSignal: context.abort,
396
- onLeadEvent: (event) => reportClaudeEvent(context, lead, event),
397
- onChallengerEvent: (event) => reportClaudeEvent(context, challenger, event),
398
- onSynthesisEvent: (event) => reportPlanSynthesisEvent(context, event),
399
- });
400
- context.metadata({
401
- title: '✅ Plan synthesis finished',
402
- metadata: {
403
- teamId: result.teamId,
404
- lead: result.leadEngineer,
405
- challenger: result.challengerEngineer,
406
- hasQuestion: result.recommendedQuestion !== null,
407
- },
408
- });
409
- return JSON.stringify({
410
- synthesis: result.synthesis,
411
- recommendedQuestion: result.recommendedQuestion,
412
- recommendedAnswer: result.recommendedAnswer,
413
- }, null, 2);
414
- },
415
- }),
416
- reset_engineer: tool({
417
- description: 'Reset a stuck or corrupted engineer. Clears the busy flag. Optionally clears the Claude session (starts fresh) and/or wrapper history.',
418
- args: {
419
- engineer: tool.schema.enum(['Tom', 'John', 'Maya', 'Sara', 'Alex', 'BrowserQA']),
420
- clearSession: tool.schema.boolean().optional(),
421
- clearHistory: tool.schema.boolean().optional(),
422
- },
423
- async execute(args, context) {
424
- const teamId = context.sessionID;
425
- annotateToolRun(context, `Resetting ${args.engineer}`, {
426
- teamId,
427
- clearSession: args.clearSession,
428
- clearHistory: args.clearHistory,
429
- });
430
- await services.orchestrator.resetEngineer(context.worktree, teamId, args.engineer, {
431
- clearSession: args.clearSession,
432
- clearHistory: args.clearHistory,
433
- });
434
- const team = await services.orchestrator.getOrCreateTeam(context.worktree, teamId);
435
- const engineer = team.engineers.find((e) => e.name === args.engineer);
436
- return JSON.stringify({ reset: true, engineer: engineer ?? args.engineer }, null, 2);
437
- },
438
- }),
439
- git_diff: tool({
440
- description: 'Show diff of uncommitted changes. Use paths to filter to specific files or use ref to compare against another branch, tag, or commit.',
441
- args: {
442
- paths: tool.schema.string().array().optional(),
443
- staged: tool.schema.boolean().optional(),
444
- ref: tool.schema.string().optional(),
445
- },
446
- async execute(args, context) {
447
- annotateToolRun(context, 'Running git diff', {
448
- paths: args.paths,
449
- staged: args.staged,
450
- ref: args.ref,
451
- });
452
- const result = await services.manager.gitDiff({
453
- paths: args.paths?.filter((path) => path !== undefined),
454
- staged: args.staged,
455
- ref: args.ref,
456
- });
457
- return JSON.stringify(result, null, 2);
458
- },
459
- }),
460
- git_commit: tool({
461
- description: 'Create a commit. Stages all changes by default, or only the specified paths if provided.',
462
- args: {
463
- message: tool.schema.string().min(1),
464
- paths: tool.schema.string().array().optional(),
465
- },
466
- async execute(args, context) {
467
- annotateToolRun(context, 'Committing changes', {
468
- message: args.message,
469
- paths: args.paths,
470
- });
471
- const result = await services.manager.gitCommit(args.message, args.paths);
472
- return JSON.stringify(result, null, 2);
473
- },
474
- }),
475
- git_reset: tool({
476
- description: 'Discard all uncommitted changes by running git reset --hard HEAD and git clean -fd.',
477
- args: {},
478
- async execute(_args, context) {
479
- annotateToolRun(context, 'Resetting working directory', {});
480
- const result = await services.manager.gitReset();
481
- return JSON.stringify(result, null, 2);
482
- },
483
- }),
484
- git_status: tool({
485
- description: 'Show working tree status in short format and whether the tree is clean.',
486
- args: {},
487
- async execute(_args, context) {
488
- annotateToolRun(context, 'Checking git status', {});
489
- const result = await services.manager.gitStatus();
490
- return JSON.stringify(result, null, 2);
491
- },
492
- }),
493
- git_log: tool({
494
- description: 'Show recent commits in short format. Defaults to 5 commits.',
495
- args: {
496
- count: tool.schema.number().optional(),
497
- },
498
- async execute(args, context) {
499
- annotateToolRun(context, 'Fetching git log', { count: args.count });
500
- return services.manager.gitLog(args.count ?? 5);
501
- },
502
- }),
503
- list_transcripts: tool({
504
- description: 'List available Claude session transcripts or inspect one transcript by session ID.',
505
- args: {
506
- sessionId: tool.schema.string().optional(),
507
- },
508
- async execute(args, context) {
509
- annotateToolRun(context, 'Inspecting Claude session history', {});
510
- if (args.sessionId) {
511
- const [sdkTranscript, localEvents] = await Promise.all([
512
- services.sessions.getTranscript(args.sessionId, context.worktree),
513
- services.manager.getTranscriptEvents(context.worktree, args.sessionId),
514
- ]);
515
- return JSON.stringify({
516
- sdkTranscript,
517
- localEvents: localEvents.length > 0 ? localEvents : undefined,
518
- }, null, 2);
519
- }
520
- const sessions = await services.sessions.listSessions(context.worktree);
521
- return JSON.stringify(sessions, null, 2);
522
- },
523
- }),
524
- list_history: tool({
525
- description: 'List saved CTO teams for this worktree or inspect one team by ID.',
526
- args: {
527
- teamId: tool.schema.string().optional(),
528
- },
529
- async execute(args, context) {
530
- annotateToolRun(context, 'Reading saved team history', {});
531
- if (args.teamId) {
532
- const team = await services.teamStore.getTeam(context.worktree, args.teamId);
533
- return JSON.stringify(team, null, 2);
534
- }
535
- const teams = await services.orchestrator.listTeams(context.worktree);
536
- return JSON.stringify(teams, null, 2);
537
- },
538
- }),
539
- approval_policy: tool({
540
- description: 'View the current tool approval policy.',
541
- args: {},
542
- async execute(_args, context) {
543
- annotateToolRun(context, 'Reading approval policy', {});
544
- return JSON.stringify(services.approvalManager.getPolicy(), null, 2);
545
- },
546
- }),
547
- approval_decisions: tool({
548
- description: 'View recent tool approval decisions. Use deniedOnly to show only denied calls.',
549
- args: {
550
- limit: tool.schema.number().optional(),
551
- deniedOnly: tool.schema.boolean().optional(),
552
- },
553
- async execute(args, context) {
554
- annotateToolRun(context, 'Reading approval decisions', {});
555
- const decisions = args.deniedOnly
556
- ? services.approvalManager.getDeniedDecisions(args.limit)
557
- : services.approvalManager.getDecisions(args.limit);
558
- return JSON.stringify({ total: decisions.length, decisions }, null, 2);
559
- },
560
- }),
561
- approval_update: tool({
562
- description: 'Update the tool approval policy. Add or remove rules, enable or disable approvals, or clear decision history. Unmatched tools are always allowed; block only with explicit deny rules (defaultAction cannot be set to deny).',
563
- args: {
564
- action: tool.schema.enum([
565
- 'addRule',
566
- 'removeRule',
567
- 'setDefault',
568
- 'setEnabled',
569
- 'clearDecisions',
570
- ]),
571
- ruleId: tool.schema.string().optional(),
572
- toolPattern: tool.schema.string().optional(),
573
- inputPattern: tool.schema.string().optional(),
574
- ruleAction: tool.schema.enum(['allow', 'deny']).optional(),
575
- denyMessage: tool.schema.string().optional(),
576
- description: tool.schema.string().optional(),
577
- position: tool.schema.number().optional(),
578
- defaultAction: tool.schema.enum(['allow', 'deny']).optional(),
579
- enabled: tool.schema.boolean().optional(),
580
- },
581
- async execute(args, context) {
582
- annotateToolRun(context, `Updating approval: ${args.action}`, {});
583
- if (args.action === 'addRule') {
584
- if (!args.ruleId || !args.toolPattern || !args.ruleAction) {
585
- return JSON.stringify({
586
- error: 'addRule requires ruleId, toolPattern, and ruleAction',
587
- });
588
- }
589
- await services.approvalManager.addRule({
590
- id: args.ruleId,
591
- toolPattern: args.toolPattern,
592
- inputPattern: args.inputPattern,
593
- action: args.ruleAction,
594
- denyMessage: args.denyMessage,
595
- description: args.description,
596
- }, args.position);
597
- }
598
- else if (args.action === 'removeRule') {
599
- if (!args.ruleId) {
600
- return JSON.stringify({ error: 'removeRule requires ruleId' });
601
- }
602
- const removed = await services.approvalManager.removeRule(args.ruleId);
603
- return JSON.stringify({ removed }, null, 2);
604
- }
605
- else if (args.action === 'setDefault') {
606
- if (!args.defaultAction) {
607
- return JSON.stringify({ error: 'setDefault requires defaultAction' });
608
- }
609
- if (args.defaultAction === 'deny') {
610
- return JSON.stringify({
611
- error: 'defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.',
612
- });
613
- }
614
- await services.approvalManager.setDefaultAction(args.defaultAction);
615
- }
616
- else if (args.action === 'setEnabled') {
617
- if (args.enabled === undefined) {
618
- return JSON.stringify({ error: 'setEnabled requires enabled' });
619
- }
620
- await services.approvalManager.setEnabled(args.enabled);
621
- }
622
- else if (args.action === 'clearDecisions') {
623
- services.approvalManager.clearDecisions();
624
- }
625
- return JSON.stringify(services.approvalManager.getPolicy(), null, 2);
626
- },
627
- }),
628
- },
629
- };
630
- };
631
- async function runEngineerAssignment(input, context) {
632
- const services = getOrCreatePluginServices(context.worktree);
633
- annotateToolRun(context, `Assigning ${input.engineer}`, {
634
- teamId: input.teamId,
635
- mode: input.mode,
636
- });
637
- let result;
638
- try {
639
- result = await services.orchestrator.dispatchEngineer({
640
- teamId: input.teamId,
641
- cwd: context.worktree,
642
- engineer: input.engineer,
643
- mode: input.mode,
644
- message: input.message,
645
- model: input.model,
646
- abortSignal: context.abort,
647
- onEvent: (event) => reportClaudeEvent(context, input.engineer, event),
648
- });
649
- }
650
- catch (error) {
651
- const failure = TeamOrchestrator.classifyError(error);
652
- failure.teamId = input.teamId;
653
- failure.engineer = input.engineer;
654
- failure.mode = input.mode;
655
- const guidance = getFailureGuidanceText(failure.failureKind);
656
- context.metadata({
657
- title: `❌ ${input.engineer} failed (${failure.failureKind})`,
658
- metadata: {
659
- teamId: failure.teamId,
660
- engineer: failure.engineer,
661
- failureKind: failure.failureKind,
662
- message: failure.message.slice(0, 200),
663
- guidance,
664
- },
665
- });
666
- try {
667
- await appendDebugLog(services.debugLogPath, {
668
- type: 'engineer_failure',
669
- engineer: failure.engineer,
670
- teamId: failure.teamId,
671
- mode: failure.mode,
672
- failureKind: failure.failureKind,
673
- message: failure.message.slice(0, 300),
674
- });
675
- }
676
- catch {
677
- // Log write failures must not mask the original error.
678
- }
679
- throw createActionableError(failure, error);
680
- }
681
- await services.orchestrator.recordWrapperExchange(context.worktree, input.teamId, input.engineer, context.sessionID, input.mode, input.message, result.finalText);
682
- context.metadata({
683
- title: `✅ ${input.engineer} finished`,
684
- metadata: {
685
- teamId: result.teamId,
686
- engineer: result.engineer,
687
- mode: result.mode,
688
- sessionId: result.sessionId,
689
- turns: result.turns,
690
- contextWarning: formatContextWarning(result.context),
691
- },
692
- });
693
- return result;
694
- }
695
- /**
696
- * Normalize an agent ID to its lowercase canonical form.
697
- * Handles both uppercase (e.g., 'Tom') and lowercase (e.g., 'tom') inputs.
698
- */
699
- export function normalizeAgentId(agentId) {
700
- return agentId.toLowerCase();
701
- }
702
- export function engineerFromAgent(agentId) {
703
- const normalized = normalizeAgentId(agentId);
704
- const engineerEntry = Object.entries(ENGINEER_AGENT_IDS).find(([, value]) => value === normalized);
705
- const engineer = engineerEntry?.[0];
706
- if (!engineer || !isEngineerName(engineer)) {
707
- throw new Error(`The claude tool can only be used from a named engineer agent. Received agent ${agentId}.`);
708
- }
709
- return engineer;
710
- }
711
- export function isEngineerAgent(agentId) {
712
- const normalized = normalizeAgentId(agentId);
713
- return Object.values(ENGINEER_AGENT_IDS).some((id) => id === normalized);
714
- }
715
- function formatToolDescription(toolName, toolArgs) {
716
- if (!toolArgs || typeof toolArgs !== 'object')
717
- return undefined;
718
- const args = toolArgs;
719
- switch (toolName) {
720
- case 'Read':
721
- case 'read': {
722
- const filePath = args.file_path;
723
- return typeof filePath === 'string' ? `Reading: ${filePath}` : undefined;
724
- }
725
- case 'Grep':
726
- case 'grep': {
727
- const pattern = args.pattern;
728
- return typeof pattern === 'string' ? `Searching: ${pattern}` : undefined;
729
- }
730
- case 'Write':
731
- case 'write': {
732
- const filePath = args.file_path;
733
- return typeof filePath === 'string' ? `Writing: ${filePath}` : undefined;
734
- }
735
- case 'Edit':
736
- case 'edit': {
737
- const filePath = args.file_path;
738
- return typeof filePath === 'string' ? `Editing: ${filePath}` : undefined;
739
- }
740
- case 'Bash':
741
- case 'bash':
742
- case 'Run':
743
- case 'run': {
744
- const command = args.command;
745
- return typeof command === 'string' ? `Running: ${command.slice(0, 80)}` : undefined;
746
- }
747
- case 'WebFetch':
748
- case 'webfetch': {
749
- const url = args.url;
750
- return typeof url === 'string' ? `Fetching: ${url}` : undefined;
751
- }
752
- case 'Glob':
753
- case 'glob': {
754
- const pattern = args.pattern;
755
- return typeof pattern === 'string' ? `Matching: ${pattern}` : undefined;
756
- }
757
- case 'TodoWrite':
758
- case 'todowrite':
759
- return args.content ? `Updating todos` : undefined;
760
- case 'NotebookEdit':
761
- case 'notebook_edit': {
762
- const cellPath = args.notebook_cell_path;
763
- return typeof cellPath === 'string' ? `Editing notebook: ${cellPath}` : undefined;
764
- }
765
- default:
766
- return undefined;
767
- }
768
- }
769
- function reportClaudeEvent(context, workerName, event) {
770
- const baseMetadata = { workerName, engineer: workerName };
771
- if (event.type === 'error') {
772
- context.metadata({
773
- title: `❌ ${workerName} hit an error`,
774
- metadata: {
775
- ...baseMetadata,
776
- sessionId: event.sessionId,
777
- error: event.text.slice(0, 200),
778
- },
779
- });
780
- return;
781
- }
782
- if (event.type === 'status') {
783
- context.metadata({
784
- title: `ℹ️ ${workerName}: ${event.text}`,
785
- metadata: {
786
- ...baseMetadata,
787
- status: event.text,
788
- },
789
- });
790
- return;
791
- }
792
- if (event.type === 'init') {
793
- context.metadata({
794
- title: `⚡ ${workerName} session ready`,
795
- metadata: {
796
- ...baseMetadata,
797
- sessionId: event.sessionId,
798
- },
799
- });
800
- return;
801
- }
802
- if (event.type === 'tool_call') {
803
- let toolName;
804
- let toolId;
805
- let toolArgs;
806
- try {
807
- const parsed = JSON.parse(event.text);
808
- toolName = parsed.name;
809
- toolId = parsed.id;
810
- // Some SDK versions serialize the input object as a JSON string inside the outer JSON.
811
- // Try to double-decode it so callers always receive a plain object.
812
- if (typeof parsed.input === 'string') {
813
- try {
814
- toolArgs = JSON.parse(parsed.input);
815
- }
816
- catch {
817
- toolArgs = parsed.input;
818
- }
819
- }
820
- else {
821
- toolArgs = parsed.input;
822
- }
823
- }
824
- catch {
825
- // event.text is not valid JSON — fall back to generic title
826
- }
827
- const toolDescription = formatToolDescription(toolName ?? '', toolArgs);
828
- context.metadata({
829
- title: toolDescription
830
- ? `⚡ ${workerName} → ${toolDescription}`
831
- : toolName
832
- ? `⚡ ${workerName} → ${toolName}`
833
- : `⚡ ${workerName} is using Claude Code tools`,
834
- metadata: {
835
- ...baseMetadata,
836
- sessionId: event.sessionId,
837
- ...(toolName !== undefined && { toolName }),
838
- ...(toolId !== undefined && { toolId }),
839
- ...(toolArgs !== undefined && { toolArgs }),
840
- },
841
- });
842
- return;
843
- }
844
- if (event.type === 'assistant' || event.type === 'partial') {
845
- const isThinking = event.text.startsWith('<thinking>');
846
- const stateLabel = event.type === 'partial' && isThinking ? 'is thinking' : 'is working';
847
- context.metadata({
848
- title: `⚡ ${workerName} ${stateLabel}`,
849
- metadata: {
850
- ...baseMetadata,
851
- sessionId: event.sessionId,
852
- preview: event.text.slice(0, 160),
853
- isThinking,
854
- },
855
- });
856
- }
857
- }
858
- function reportPlanSynthesisEvent(context, event) {
859
- if (event.type === 'error') {
860
- context.metadata({
861
- title: `❌ Plan synthesis hit an error`,
862
- metadata: {
863
- sessionId: event.sessionId,
864
- error: event.text.slice(0, 200),
865
- },
866
- });
867
- return;
868
- }
869
- if (event.type === 'init') {
870
- context.metadata({
871
- title: `⚡ Plan synthesis ready`,
872
- metadata: {
873
- sessionId: event.sessionId,
874
- },
875
- });
876
- return;
877
- }
878
- if (event.type === 'tool_call') {
879
- let toolName;
880
- let toolId;
881
- let toolArgs;
882
- try {
883
- const parsed = JSON.parse(event.text);
884
- toolName = parsed.name;
885
- toolId = parsed.id;
886
- if (typeof parsed.input === 'string') {
887
- try {
888
- toolArgs = JSON.parse(parsed.input);
889
- }
890
- catch {
891
- toolArgs = parsed.input;
892
- }
893
- }
894
- else {
895
- toolArgs = parsed.input;
896
- }
897
- }
898
- catch {
899
- // event.text is not valid JSON — fall back to generic title
900
- }
901
- const toolDescription = formatToolDescription(toolName ?? '', toolArgs);
902
- context.metadata({
903
- title: toolDescription
904
- ? `⚡ Plan synthesis → ${toolDescription}`
905
- : toolName
906
- ? `⚡ Plan synthesis → ${toolName}`
907
- : `⚡ Plan synthesis is running`,
908
- metadata: {
909
- sessionId: event.sessionId,
910
- ...(toolName !== undefined && { toolName }),
911
- ...(toolId !== undefined && { toolId }),
912
- ...(toolArgs !== undefined && { toolArgs }),
913
- },
914
- });
915
- return;
916
- }
917
- if (event.type === 'assistant' || event.type === 'partial') {
918
- const isThinking = event.text.startsWith('<thinking>');
919
- const stateLabel = event.type === 'partial' && isThinking ? 'is thinking' : 'is working';
920
- context.metadata({
921
- title: `⚡ Plan synthesis ${stateLabel}`,
922
- metadata: {
923
- sessionId: event.sessionId,
924
- preview: event.text.slice(0, 160),
925
- isThinking,
926
- },
927
- });
928
- }
929
- }
930
- function annotateToolRun(context, title, metadata) {
931
- const agentLabel = context.agent === AGENT_CTO ? 'CTO' : undefined;
932
- context.metadata({
933
- title: agentLabel ? `${agentLabel} → ${title}` : title,
934
- metadata,
935
- });
936
- }
937
- function formatContextWarning(context) {
938
- if (context.warningLevel === 'ok' || context.estimatedContextPercent === null) {
939
- return null;
940
- }
941
- const template = context.warningLevel === 'critical'
942
- ? managerPromptRegistry.contextWarnings.critical
943
- : context.warningLevel === 'high'
944
- ? managerPromptRegistry.contextWarnings.high
945
- : managerPromptRegistry.contextWarnings.moderate;
946
- return template
947
- .replace('{percent}', String(context.estimatedContextPercent))
948
- .replace('{turns}', String(context.totalTurns))
949
- .replace('{cost}', context.totalCostUsd.toFixed(2));
950
- }