@doingdev/opencode-claude-manager-plugin 0.1.47 → 0.1.50

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 (65) hide show
  1. package/dist/claude/claude-agent-sdk-adapter.d.ts +2 -3
  2. package/dist/claude/claude-agent-sdk-adapter.js +0 -44
  3. package/dist/claude/claude-session.service.d.ts +1 -2
  4. package/dist/claude/claude-session.service.js +0 -3
  5. package/dist/claude/tool-approval-manager.d.ts +9 -6
  6. package/dist/claude/tool-approval-manager.js +43 -6
  7. package/dist/index.d.ts +1 -2
  8. package/dist/index.js +0 -1
  9. package/dist/manager/context-tracker.d.ts +0 -1
  10. package/dist/manager/context-tracker.js +0 -3
  11. package/dist/manager/git-operations.d.ts +1 -4
  12. package/dist/manager/git-operations.js +7 -12
  13. package/dist/manager/persistent-manager.d.ts +3 -53
  14. package/dist/manager/persistent-manager.js +3 -135
  15. package/dist/manager/team-orchestrator.d.ts +8 -1
  16. package/dist/manager/team-orchestrator.js +70 -11
  17. package/dist/plugin/agent-hierarchy.d.ts +1 -1
  18. package/dist/plugin/agent-hierarchy.js +3 -1
  19. package/dist/plugin/claude-manager.plugin.js +218 -25
  20. package/dist/plugin/service-factory.d.ts +2 -2
  21. package/dist/plugin/service-factory.js +18 -12
  22. package/dist/prompts/registry.js +42 -37
  23. package/dist/src/claude/claude-agent-sdk-adapter.d.ts +2 -3
  24. package/dist/src/claude/claude-agent-sdk-adapter.js +0 -44
  25. package/dist/src/claude/claude-session.service.d.ts +1 -2
  26. package/dist/src/claude/claude-session.service.js +0 -3
  27. package/dist/src/claude/tool-approval-manager.d.ts +9 -6
  28. package/dist/src/claude/tool-approval-manager.js +43 -6
  29. package/dist/src/index.d.ts +1 -2
  30. package/dist/src/index.js +0 -1
  31. package/dist/src/manager/context-tracker.d.ts +0 -1
  32. package/dist/src/manager/context-tracker.js +0 -3
  33. package/dist/src/manager/git-operations.d.ts +1 -4
  34. package/dist/src/manager/git-operations.js +7 -12
  35. package/dist/src/manager/persistent-manager.d.ts +3 -53
  36. package/dist/src/manager/persistent-manager.js +3 -135
  37. package/dist/src/manager/team-orchestrator.d.ts +8 -1
  38. package/dist/src/manager/team-orchestrator.js +70 -11
  39. package/dist/src/plugin/agent-hierarchy.d.ts +1 -1
  40. package/dist/src/plugin/agent-hierarchy.js +3 -1
  41. package/dist/src/plugin/claude-manager.plugin.js +218 -25
  42. package/dist/src/plugin/service-factory.d.ts +2 -2
  43. package/dist/src/plugin/service-factory.js +18 -12
  44. package/dist/src/prompts/registry.js +42 -37
  45. package/dist/src/state/team-state-store.d.ts +3 -0
  46. package/dist/src/state/team-state-store.js +26 -1
  47. package/dist/src/team/roster.js +1 -0
  48. package/dist/src/types/contracts.d.ts +9 -49
  49. package/dist/state/team-state-store.d.ts +3 -0
  50. package/dist/state/team-state-store.js +26 -1
  51. package/dist/team/roster.js +1 -0
  52. package/dist/test/claude-agent-sdk-adapter.test.js +0 -11
  53. package/dist/test/claude-manager.plugin.test.js +6 -1
  54. package/dist/test/context-tracker.test.js +0 -8
  55. package/dist/test/cto-active-team.test.d.ts +1 -0
  56. package/dist/test/cto-active-team.test.js +52 -0
  57. package/dist/test/git-operations.test.js +0 -21
  58. package/dist/test/persistent-manager.test.js +4 -164
  59. package/dist/test/prompt-registry.test.js +4 -9
  60. package/dist/test/report-claude-event.test.d.ts +1 -0
  61. package/dist/test/report-claude-event.test.js +246 -0
  62. package/dist/test/team-state-store.test.js +18 -0
  63. package/dist/test/tool-approval-manager.test.js +17 -17
  64. package/dist/types/contracts.d.ts +9 -49
  65. package/package.json +1 -1
@@ -1,45 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { PersistentManager } from '../src/manager/persistent-manager.js';
3
- import { ContextTracker } from '../src/manager/context-tracker.js';
4
- function emptyContext() {
5
- return {
6
- sessionId: null,
7
- totalTurns: 0,
8
- totalCostUsd: 0,
9
- latestInputTokens: null,
10
- latestOutputTokens: null,
11
- contextWindowSize: null,
12
- estimatedContextPercent: null,
13
- warningLevel: 'ok',
14
- compactionCount: 0,
15
- };
16
- }
17
- function createMockSessionController() {
18
- return {
19
- isActive: false,
20
- sessionId: null,
21
- sendMessage: vi.fn(async () => ({
22
- sessionId: 'ses_mock',
23
- events: [],
24
- finalText: 'Task completed.',
25
- turns: 2,
26
- totalCostUsd: 0.03,
27
- inputTokens: 20_000,
28
- outputTokens: 1_000,
29
- contextWindowSize: 200_000,
30
- })),
31
- compactSession: vi.fn(async () => ({
32
- sessionId: 'ses_mock',
33
- events: [],
34
- finalText: 'Compacted.',
35
- turns: 3,
36
- totalCostUsd: 0.04,
37
- })),
38
- clearSession: vi.fn(async () => 'ses_mock'),
39
- getContextSnapshot: vi.fn(() => emptyContext()),
40
- tryRestore: vi.fn(async () => false),
41
- };
42
- }
43
3
  function createMockGitOps() {
44
4
  return {
45
5
  diff: vi.fn(async () => ({
@@ -55,28 +15,6 @@ function createMockGitOps() {
55
15
  success: true,
56
16
  output: 'HEAD is now at abc1234',
57
17
  })),
58
- diffStat: vi.fn(async () => '1 file changed, 1 insertion(+)'),
59
- currentBranch: vi.fn(async () => 'main'),
60
- recentCommits: vi.fn(async () => 'abc1234 initial'),
61
- };
62
- }
63
- function createMockStateStore() {
64
- const runs = new Map();
65
- return {
66
- saveRun: vi.fn(async (run) => {
67
- runs.set(run.id, structuredClone(run));
68
- }),
69
- getRun: vi.fn(async (_cwd, runId) => runs.get(runId) ?? null),
70
- listRuns: vi.fn(async () => [...runs.values()]),
71
- updateRun: vi.fn(async (_cwd, runId, update) => {
72
- const existing = runs.get(runId);
73
- if (!existing) {
74
- throw new Error(`Run ${runId} not found`);
75
- }
76
- const updated = update(existing);
77
- runs.set(runId, structuredClone(updated));
78
- return updated;
79
- }),
80
18
  };
81
19
  }
82
20
  function createMockTranscriptStore() {
@@ -86,123 +24,25 @@ function createMockTranscriptStore() {
86
24
  };
87
25
  }
88
26
  describe('PersistentManager', () => {
89
- it('sends a message through the session controller', async () => {
90
- const sessionCtrl = createMockSessionController();
91
- const gitOps = createMockGitOps();
92
- const store = createMockStateStore();
93
- const tracker = new ContextTracker();
94
- const manager = new PersistentManager(sessionCtrl, gitOps, store, tracker, createMockTranscriptStore());
95
- const result = await manager.sendMessage('/tmp', 'implement feature X');
96
- expect(sessionCtrl.sendMessage).toHaveBeenCalledWith('implement feature X', undefined, undefined);
97
- expect(result.finalText).toBe('Task completed.');
98
- expect(result.inputTokens).toBe(20_000);
99
- expect(result.outputTokens).toBe(1_000);
100
- expect(result.contextWindowSize).toBe(200_000);
101
- expect(result.context).toBeDefined();
102
- });
103
- it('persists transcript events on sendMessage', async () => {
104
- const sessionCtrl = createMockSessionController();
105
- sessionCtrl.sendMessage.mockResolvedValueOnce({
106
- sessionId: 'ses_tx',
107
- events: [
108
- { type: 'init', text: 'init' },
109
- { type: 'assistant', text: 'Done.' },
110
- { type: 'result', text: 'Done.', turns: 1, totalCostUsd: 0.01 },
111
- ],
112
- finalText: 'Done.',
113
- turns: 1,
114
- totalCostUsd: 0.01,
115
- });
116
- const transcriptStore = createMockTranscriptStore();
117
- const manager = new PersistentManager(sessionCtrl, createMockGitOps(), createMockStateStore(), new ContextTracker(), transcriptStore);
118
- await manager.sendMessage('/tmp', 'do something');
119
- expect(transcriptStore.appendEvents).toHaveBeenCalledWith('/tmp', 'ses_tx', [
120
- { type: 'init', text: 'init' },
121
- { type: 'assistant', text: 'Done.' },
122
- { type: 'result', text: 'Done.', turns: 1, totalCostUsd: 0.01 },
123
- ]);
124
- });
125
- it('skips transcript persistence when events are empty', async () => {
126
- const transcriptStore = createMockTranscriptStore();
127
- const manager = new PersistentManager(createMockSessionController(), createMockGitOps(), createMockStateStore(), new ContextTracker(), transcriptStore);
128
- await manager.sendMessage('/tmp', 'hello');
129
- expect(transcriptStore.appendEvents).not.toHaveBeenCalled();
130
- });
131
27
  it('delegates git diff to GitOperations', async () => {
132
- const sessionCtrl = createMockSessionController();
133
28
  const gitOps = createMockGitOps();
134
- const store = createMockStateStore();
135
- const tracker = new ContextTracker();
136
- const manager = new PersistentManager(sessionCtrl, gitOps, store, tracker, createMockTranscriptStore());
29
+ const manager = new PersistentManager(gitOps, createMockTranscriptStore());
137
30
  const diff = await manager.gitDiff();
138
31
  expect(diff.hasDiff).toBe(true);
139
32
  expect(diff.stats.filesChanged).toBe(1);
140
33
  });
141
34
  it('delegates git commit to GitOperations', async () => {
142
- const sessionCtrl = createMockSessionController();
143
35
  const gitOps = createMockGitOps();
144
- const store = createMockStateStore();
145
- const tracker = new ContextTracker();
146
- const manager = new PersistentManager(sessionCtrl, gitOps, store, tracker, createMockTranscriptStore());
36
+ const manager = new PersistentManager(gitOps, createMockTranscriptStore());
147
37
  const result = await manager.gitCommit('feat: add X');
148
- expect(gitOps.commit).toHaveBeenCalledWith('feat: add X');
38
+ expect(gitOps.commit).toHaveBeenCalledWith('feat: add X', undefined);
149
39
  expect(result.success).toBe(true);
150
40
  });
151
41
  it('delegates git reset to GitOperations', async () => {
152
- const sessionCtrl = createMockSessionController();
153
42
  const gitOps = createMockGitOps();
154
- const store = createMockStateStore();
155
- const tracker = new ContextTracker();
156
- const manager = new PersistentManager(sessionCtrl, gitOps, store, tracker, createMockTranscriptStore());
43
+ const manager = new PersistentManager(gitOps, createMockTranscriptStore());
157
44
  const result = await manager.gitReset();
158
45
  expect(gitOps.resetHard).toHaveBeenCalled();
159
46
  expect(result.success).toBe(true);
160
47
  });
161
- it('executes a task with run tracking', async () => {
162
- const sessionCtrl = createMockSessionController();
163
- const gitOps = createMockGitOps();
164
- const store = createMockStateStore();
165
- const tracker = new ContextTracker();
166
- const manager = new PersistentManager(sessionCtrl, gitOps, store, tracker, createMockTranscriptStore());
167
- const result = await manager.executeTask('/tmp', 'add tests');
168
- expect(result.run.status).toBe('completed');
169
- expect(result.run.task).toBe('add tests');
170
- expect(result.run.finalSummary).toBe('Task completed.');
171
- expect(result.run.messages).toHaveLength(2);
172
- expect(result.run.messages[0].direction).toBe('sent');
173
- expect(result.run.messages[1].direction).toBe('received');
174
- // Verify state was persisted
175
- expect(store.saveRun).toHaveBeenCalled();
176
- expect(store.updateRun).toHaveBeenCalled();
177
- });
178
- it('marks run as failed when session throws', async () => {
179
- const sessionCtrl = createMockSessionController();
180
- sessionCtrl.sendMessage.mockRejectedValueOnce(new Error('SDK connection failed'));
181
- const gitOps = createMockGitOps();
182
- const store = createMockStateStore();
183
- const tracker = new ContextTracker();
184
- const manager = new PersistentManager(sessionCtrl, gitOps, store, tracker, createMockTranscriptStore());
185
- const result = await manager.executeTask('/tmp', 'failing task');
186
- expect(result.run.status).toBe('failed');
187
- expect(result.run.finalSummary).toBe('SDK connection failed');
188
- });
189
- it('clears session via controller', async () => {
190
- const sessionCtrl = createMockSessionController();
191
- const gitOps = createMockGitOps();
192
- const store = createMockStateStore();
193
- const tracker = new ContextTracker();
194
- const manager = new PersistentManager(sessionCtrl, gitOps, store, tracker, createMockTranscriptStore());
195
- const cleared = await manager.clearSession();
196
- expect(cleared).toBe('ses_mock');
197
- expect(sessionCtrl.clearSession).toHaveBeenCalled();
198
- });
199
- it('returns status from session controller', () => {
200
- const sessionCtrl = createMockSessionController();
201
- const gitOps = createMockGitOps();
202
- const store = createMockStateStore();
203
- const tracker = new ContextTracker();
204
- const manager = new PersistentManager(sessionCtrl, gitOps, store, tracker, createMockTranscriptStore());
205
- const status = manager.getStatus();
206
- expect(status).toEqual(emptyContext());
207
- });
208
48
  });
@@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest';
2
2
  import { managerPromptRegistry } from '../src/prompts/registry.js';
3
3
  describe('managerPromptRegistry', () => {
4
4
  it('gives the CTO explicit orchestration guidance', () => {
5
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('You are the CTO');
5
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('You are a principal engineer orchestrating a team of AI-powered engineers');
6
6
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('Task tool');
7
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('spawn two engineers in parallel');
7
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('dispatch two engineers with complementary perspectives');
8
8
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('question');
9
9
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('Tom, John, Maya, Sara, and Alex');
10
10
  expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('clear_session');
@@ -13,21 +13,16 @@ describe('managerPromptRegistry', () => {
13
13
  it('keeps the engineer wrapper prompt short and tool-focused', () => {
14
14
  expect(managerPromptRegistry.engineerAgentPrompt).toContain('named engineer');
15
15
  expect(managerPromptRegistry.engineerAgentPrompt).toContain('claude');
16
- expect(managerPromptRegistry.engineerAgentPrompt).toContain('remembers prior turns');
16
+ expect(managerPromptRegistry.engineerAgentPrompt).toContain('remembers your prior turns');
17
17
  expect(managerPromptRegistry.engineerAgentPrompt).toContain('wrapper context');
18
18
  expect(managerPromptRegistry.engineerAgentPrompt).not.toContain('read/grep/glob');
19
19
  });
20
20
  it('keeps the engineer session prompt direct and repo-aware', () => {
21
21
  expect(managerPromptRegistry.engineerSessionPrompt).toContain('expert software engineer');
22
- expect(managerPromptRegistry.engineerSessionPrompt).toContain('Execute directly');
22
+ expect(managerPromptRegistry.engineerSessionPrompt).toContain('Start with the smallest investigation that resolves the key uncertainty');
23
23
  expect(managerPromptRegistry.engineerSessionPrompt).toContain('Verify your own work');
24
24
  expect(managerPromptRegistry.engineerSessionPrompt).toContain('Do not run git commit');
25
25
  });
26
- it('still exposes plan and free mode prefixes', () => {
27
- expect(managerPromptRegistry.modePrefixes.plan).toContain('PLAN MODE');
28
- expect(managerPromptRegistry.modePrefixes.plan).toContain('Read-only');
29
- expect(managerPromptRegistry.modePrefixes.free).toBe('');
30
- });
31
26
  it('keeps context warnings available for engineer sessions', () => {
32
27
  expect(managerPromptRegistry.contextWarnings.moderate).toContain('{percent}');
33
28
  expect(managerPromptRegistry.contextWarnings.high).toContain('{turns}');
@@ -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 → Reading: /foo.ts');
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 → Running: ls -la');
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
  });
@@ -143,41 +143,41 @@ describe('ToolApprovalManager', () => {
143
143
  });
144
144
  });
145
145
  describe('policy management', () => {
146
- it('addRule inserts at specified position', () => {
146
+ it('addRule inserts at specified position', async () => {
147
147
  const manager = new ToolApprovalManager({
148
148
  rules: [],
149
149
  enabled: true,
150
150
  defaultAction: 'allow',
151
151
  });
152
- manager.addRule({ id: 'a', toolPattern: 'A', action: 'allow' });
153
- manager.addRule({ id: 'b', toolPattern: 'B', action: 'allow' });
154
- manager.addRule({ id: 'c', toolPattern: 'C', action: 'deny' }, 1);
152
+ await manager.addRule({ id: 'a', toolPattern: 'A', action: 'allow' });
153
+ await manager.addRule({ id: 'b', toolPattern: 'B', action: 'allow' });
154
+ await manager.addRule({ id: 'c', toolPattern: 'C', action: 'deny' }, 1);
155
155
  const rules = manager.getPolicy().rules;
156
156
  expect(rules.map((r) => r.id)).toEqual(['a', 'c', 'b']);
157
157
  });
158
- it('addRule appends to end by default', () => {
158
+ it('addRule appends to end by default', async () => {
159
159
  const manager = new ToolApprovalManager({
160
160
  rules: [],
161
161
  enabled: true,
162
162
  defaultAction: 'allow',
163
163
  });
164
- manager.addRule({ id: 'a', toolPattern: 'A', action: 'allow' });
165
- manager.addRule({ id: 'b', toolPattern: 'B', action: 'allow' });
164
+ await manager.addRule({ id: 'a', toolPattern: 'A', action: 'allow' });
165
+ await manager.addRule({ id: 'b', toolPattern: 'B', action: 'allow' });
166
166
  expect(manager.getPolicy().rules.map((r) => r.id)).toEqual(['a', 'b']);
167
167
  });
168
- it('removeRule removes by ID and returns true', () => {
168
+ it('removeRule removes by ID and returns true', async () => {
169
169
  const manager = new ToolApprovalManager();
170
- const removed = manager.removeRule('allow-read');
170
+ const removed = await manager.removeRule('allow-read');
171
171
  expect(removed).toBe(true);
172
172
  expect(manager.getPolicy().rules.find((r) => r.id === 'allow-read')).toBeUndefined();
173
173
  });
174
- it('removeRule returns false for non-existent ID', () => {
174
+ it('removeRule returns false for non-existent ID', async () => {
175
175
  const manager = new ToolApprovalManager();
176
- expect(manager.removeRule('nonexistent')).toBe(false);
176
+ expect(await manager.removeRule('nonexistent')).toBe(false);
177
177
  });
178
- it('setPolicy replaces entire policy', () => {
178
+ it('setPolicy replaces entire policy', async () => {
179
179
  const manager = new ToolApprovalManager();
180
- manager.setPolicy({
180
+ await manager.setPolicy({
181
181
  rules: [{ id: 'only', toolPattern: '*', action: 'deny' }],
182
182
  defaultAction: 'deny',
183
183
  enabled: true,
@@ -185,16 +185,16 @@ describe('ToolApprovalManager', () => {
185
185
  expect(manager.getPolicy().rules).toHaveLength(1);
186
186
  expect(manager.getPolicy().defaultAction).toBe('deny');
187
187
  });
188
- it('setDefaultAction changes the fallback', () => {
188
+ it('setDefaultAction changes the fallback', async () => {
189
189
  const manager = new ToolApprovalManager({
190
190
  rules: [],
191
191
  enabled: true,
192
192
  defaultAction: 'allow',
193
193
  });
194
- manager.setDefaultAction('deny');
194
+ await manager.setDefaultAction('deny');
195
195
  expect(manager.evaluate('Unknown', {}).behavior).toBe('deny');
196
196
  });
197
- it('setEnabled toggles the manager', () => {
197
+ it('setEnabled toggles the manager', async () => {
198
198
  const manager = new ToolApprovalManager({
199
199
  rules: [
200
200
  {
@@ -208,7 +208,7 @@ describe('ToolApprovalManager', () => {
208
208
  defaultAction: 'deny',
209
209
  });
210
210
  expect(manager.evaluate('Read', {}).behavior).toBe('deny');
211
- manager.setEnabled(false);
211
+ await manager.setEnabled(false);
212
212
  expect(manager.evaluate('Read', {}).behavior).toBe('allow');
213
213
  });
214
214
  });