@doingdev/opencode-claude-manager-plugin 0.1.35 → 0.1.43

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 (90) hide show
  1. package/dist/claude/claude-agent-sdk-adapter.js +1 -0
  2. package/dist/manager/git-operations.d.ts +10 -1
  3. package/dist/manager/git-operations.js +18 -3
  4. package/dist/manager/persistent-manager.d.ts +19 -3
  5. package/dist/manager/persistent-manager.js +21 -9
  6. package/dist/manager/session-controller.d.ts +8 -5
  7. package/dist/manager/session-controller.js +25 -20
  8. package/dist/metadata/claude-metadata.service.d.ts +12 -0
  9. package/dist/metadata/claude-metadata.service.js +38 -0
  10. package/dist/metadata/repo-claude-config-reader.d.ts +7 -0
  11. package/dist/metadata/repo-claude-config-reader.js +154 -0
  12. package/dist/plugin/agent-hierarchy.d.ts +9 -9
  13. package/dist/plugin/agent-hierarchy.js +25 -25
  14. package/dist/plugin/claude-manager.plugin.js +83 -46
  15. package/dist/plugin/orchestrator.plugin.d.ts +2 -0
  16. package/dist/plugin/orchestrator.plugin.js +116 -0
  17. package/dist/plugin/service-factory.js +3 -8
  18. package/dist/prompts/registry.js +100 -103
  19. package/dist/providers/claude-code-wrapper.d.ts +13 -0
  20. package/dist/providers/claude-code-wrapper.js +13 -0
  21. package/dist/safety/bash-safety.d.ts +21 -0
  22. package/dist/safety/bash-safety.js +62 -0
  23. package/dist/src/claude/claude-agent-sdk-adapter.d.ts +27 -0
  24. package/dist/src/claude/claude-agent-sdk-adapter.js +517 -0
  25. package/dist/src/claude/claude-session.service.d.ts +10 -0
  26. package/dist/src/claude/claude-session.service.js +18 -0
  27. package/dist/src/claude/session-live-tailer.d.ts +51 -0
  28. package/dist/src/claude/session-live-tailer.js +269 -0
  29. package/dist/src/claude/tool-approval-manager.d.ts +27 -0
  30. package/dist/src/claude/tool-approval-manager.js +232 -0
  31. package/dist/src/index.d.ts +6 -0
  32. package/dist/src/index.js +4 -0
  33. package/dist/src/manager/context-tracker.d.ts +33 -0
  34. package/dist/src/manager/context-tracker.js +106 -0
  35. package/dist/src/manager/git-operations.d.ts +12 -0
  36. package/dist/src/manager/git-operations.js +76 -0
  37. package/dist/src/manager/persistent-manager.d.ts +77 -0
  38. package/dist/src/manager/persistent-manager.js +170 -0
  39. package/dist/src/manager/session-controller.d.ts +44 -0
  40. package/dist/src/manager/session-controller.js +147 -0
  41. package/dist/src/plugin/agent-hierarchy.d.ts +60 -0
  42. package/dist/src/plugin/agent-hierarchy.js +157 -0
  43. package/dist/src/plugin/claude-manager.plugin.d.ts +2 -0
  44. package/dist/src/plugin/claude-manager.plugin.js +563 -0
  45. package/dist/src/plugin/service-factory.d.ts +12 -0
  46. package/dist/src/plugin/service-factory.js +38 -0
  47. package/dist/src/prompts/registry.d.ts +11 -0
  48. package/dist/src/prompts/registry.js +260 -0
  49. package/dist/src/state/file-run-state-store.d.ts +14 -0
  50. package/dist/src/state/file-run-state-store.js +85 -0
  51. package/dist/src/state/transcript-store.d.ts +15 -0
  52. package/dist/src/state/transcript-store.js +44 -0
  53. package/dist/src/types/contracts.d.ts +200 -0
  54. package/dist/src/types/contracts.js +1 -0
  55. package/dist/src/util/fs-helpers.d.ts +2 -0
  56. package/dist/src/util/fs-helpers.js +10 -0
  57. package/dist/src/util/project-context.d.ts +10 -0
  58. package/dist/src/util/project-context.js +105 -0
  59. package/dist/src/util/transcript-append.d.ts +7 -0
  60. package/dist/src/util/transcript-append.js +29 -0
  61. package/dist/test/claude-agent-sdk-adapter.test.d.ts +1 -0
  62. package/dist/test/claude-agent-sdk-adapter.test.js +459 -0
  63. package/dist/test/claude-manager.plugin.test.d.ts +1 -0
  64. package/dist/test/claude-manager.plugin.test.js +331 -0
  65. package/dist/test/context-tracker.test.d.ts +1 -0
  66. package/dist/test/context-tracker.test.js +138 -0
  67. package/dist/test/file-run-state-store.test.d.ts +1 -0
  68. package/dist/test/file-run-state-store.test.js +82 -0
  69. package/dist/test/git-operations.test.d.ts +1 -0
  70. package/dist/test/git-operations.test.js +90 -0
  71. package/dist/test/persistent-manager.test.d.ts +1 -0
  72. package/dist/test/persistent-manager.test.js +208 -0
  73. package/dist/test/project-context.test.d.ts +1 -0
  74. package/dist/test/project-context.test.js +92 -0
  75. package/dist/test/prompt-registry.test.d.ts +1 -0
  76. package/dist/test/prompt-registry.test.js +256 -0
  77. package/dist/test/session-controller.test.d.ts +1 -0
  78. package/dist/test/session-controller.test.js +149 -0
  79. package/dist/test/session-live-tailer.test.d.ts +1 -0
  80. package/dist/test/session-live-tailer.test.js +313 -0
  81. package/dist/test/tool-approval-manager.test.d.ts +1 -0
  82. package/dist/test/tool-approval-manager.test.js +264 -0
  83. package/dist/test/transcript-append.test.d.ts +1 -0
  84. package/dist/test/transcript-append.test.js +37 -0
  85. package/dist/test/transcript-store.test.d.ts +1 -0
  86. package/dist/test/transcript-store.test.js +50 -0
  87. package/dist/types/contracts.d.ts +3 -4
  88. package/dist/vitest.config.d.ts +2 -0
  89. package/dist/vitest.config.js +11 -0
  90. package/package.json +2 -2
@@ -0,0 +1,331 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
6
+ import { AGENT_CTO, AGENT_ENGINEER_BUILD, AGENT_ENGINEER_PLAN, ALL_RESTRICTED_TOOL_IDS, } from '../src/plugin/agent-hierarchy.js';
7
+ import { managerPromptRegistry } from '../src/prompts/registry.js';
8
+ /** All engineer tools — none belong to CTO. */
9
+ const ENGINEER_TOOL_IDS = [
10
+ 'explore',
11
+ 'implement',
12
+ 'compact_context',
13
+ 'clear_session',
14
+ 'session_health',
15
+ 'list_transcripts',
16
+ 'list_history',
17
+ ];
18
+ describe('ClaudeManagerPlugin', () => {
19
+ it('configures cto with git and approval tools but denies all engineer tools', async () => {
20
+ const plugin = await ClaudeManagerPlugin({
21
+ worktree: '/tmp/project',
22
+ });
23
+ const config = {};
24
+ await plugin.config?.(config);
25
+ const agents = (config.agent ?? {});
26
+ const cto = agents[AGENT_CTO];
27
+ expect(cto).toBeDefined();
28
+ expect(cto.mode).toBe('primary');
29
+ // CTO should have read-only codebase and utility tools
30
+ expect(cto.permission).toMatchObject({
31
+ '*': 'deny',
32
+ read: 'allow',
33
+ grep: 'allow',
34
+ glob: 'allow',
35
+ codesearch: 'allow',
36
+ webfetch: 'allow',
37
+ websearch: 'allow',
38
+ todowrite: 'allow',
39
+ todoread: 'allow',
40
+ question: 'allow',
41
+ });
42
+ // CTO should have git tools allowed
43
+ expect(cto.permission).toHaveProperty('git_diff', 'allow');
44
+ expect(cto.permission).toHaveProperty('git_commit', 'allow');
45
+ expect(cto.permission).toHaveProperty('git_reset', 'allow');
46
+ // CTO should have approval tools allowed
47
+ expect(cto.permission).toHaveProperty('approval_policy', 'allow');
48
+ expect(cto.permission).toHaveProperty('approval_decisions', 'allow');
49
+ expect(cto.permission).toHaveProperty('approval_update', 'allow');
50
+ // CTO should have ALL engineer tools DENIED
51
+ for (const toolId of ENGINEER_TOOL_IDS) {
52
+ expect(cto.permission).toHaveProperty(toolId, 'deny');
53
+ }
54
+ // CTO should have task permission to spawn engineer subagents
55
+ expect(cto.permission).toHaveProperty('task');
56
+ expect(cto.permission['task']).toMatchObject({
57
+ '*': 'deny',
58
+ engineer_plan: 'allow',
59
+ engineer_build: 'allow',
60
+ });
61
+ // CTO should NOT have bash or edit
62
+ expect(cto.permission).not.toHaveProperty('bash');
63
+ expect(cto.permission).not.toHaveProperty('edit');
64
+ });
65
+ it('configures engineer_plan wrapper with only plan-mode send tool', async () => {
66
+ const plugin = await ClaudeManagerPlugin({
67
+ worktree: '/tmp/project',
68
+ });
69
+ const config = {};
70
+ await plugin.config?.(config);
71
+ const agents = (config.agent ?? {});
72
+ const engineerPlan = agents[AGENT_ENGINEER_PLAN];
73
+ expect(engineerPlan).toBeDefined();
74
+ expect(engineerPlan.mode).toBe('subagent');
75
+ // Should allow explore and shared session tools
76
+ expect(engineerPlan.permission).toHaveProperty('explore', 'allow');
77
+ expect(engineerPlan.permission).toHaveProperty('compact_context', 'allow');
78
+ expect(engineerPlan.permission).toHaveProperty('session_health', 'allow');
79
+ // Should DENY the build-mode send
80
+ expect(engineerPlan.permission).toHaveProperty('implement', 'deny');
81
+ // Should deny git and approval tools
82
+ expect(engineerPlan.permission).toHaveProperty('git_diff', 'deny');
83
+ expect(engineerPlan.permission).toHaveProperty('approval_policy', 'deny');
84
+ // Should have read-only investigation tools
85
+ expect(engineerPlan.permission).toMatchObject({
86
+ read: 'allow',
87
+ grep: 'allow',
88
+ glob: 'allow',
89
+ codesearch: 'allow',
90
+ webfetch: 'allow',
91
+ websearch: 'allow',
92
+ lsp: 'allow',
93
+ todowrite: 'allow',
94
+ todoread: 'allow',
95
+ question: 'allow',
96
+ });
97
+ });
98
+ it('configures engineer_build wrapper with only build-mode send tool', async () => {
99
+ const plugin = await ClaudeManagerPlugin({
100
+ worktree: '/tmp/project',
101
+ });
102
+ const config = {};
103
+ await plugin.config?.(config);
104
+ const agents = (config.agent ?? {});
105
+ const engineerBuild = agents[AGENT_ENGINEER_BUILD];
106
+ expect(engineerBuild).toBeDefined();
107
+ expect(engineerBuild.mode).toBe('subagent');
108
+ // Should allow implement and shared session tools
109
+ expect(engineerBuild.permission).toHaveProperty('implement', 'allow');
110
+ expect(engineerBuild.permission).toHaveProperty('compact_context', 'allow');
111
+ expect(engineerBuild.permission).toHaveProperty('session_health', 'allow');
112
+ // Should DENY the plan-mode send
113
+ expect(engineerBuild.permission).toHaveProperty('explore', 'deny');
114
+ // Should deny git and approval tools
115
+ expect(engineerBuild.permission).toHaveProperty('git_diff', 'deny');
116
+ expect(engineerBuild.permission).toHaveProperty('approval_policy', 'deny');
117
+ // Should have read-only investigation tools
118
+ expect(engineerBuild.permission).toMatchObject({
119
+ read: 'allow',
120
+ grep: 'allow',
121
+ glob: 'allow',
122
+ codesearch: 'allow',
123
+ webfetch: 'allow',
124
+ websearch: 'allow',
125
+ lsp: 'allow',
126
+ todowrite: 'allow',
127
+ todoread: 'allow',
128
+ question: 'allow',
129
+ });
130
+ });
131
+ it('CTO description reflects delegation-first behavior', async () => {
132
+ const plugin = await ClaudeManagerPlugin({
133
+ worktree: '/tmp/project',
134
+ });
135
+ const config = {};
136
+ await plugin.config?.(config);
137
+ const agents = (config.agent ?? {});
138
+ expect(agents[AGENT_CTO].description).toContain('Delegates by default');
139
+ expect(agents[AGENT_CTO].description).toContain('minimal spot-checks');
140
+ });
141
+ it('engineer_plan description reflects thin wrapper dispatching to Claude Code', async () => {
142
+ const plugin = await ClaudeManagerPlugin({
143
+ worktree: '/tmp/project',
144
+ });
145
+ const config = {};
146
+ await plugin.config?.(config);
147
+ const agents = (config.agent ?? {});
148
+ expect(agents[AGENT_ENGINEER_PLAN].description).toContain('Thin high-judgment wrapper');
149
+ expect(agents[AGENT_ENGINEER_PLAN].description).toContain('dispatches to Claude Code');
150
+ });
151
+ it('engineer_build description reflects thin wrapper dispatching to Claude Code', async () => {
152
+ const plugin = await ClaudeManagerPlugin({
153
+ worktree: '/tmp/project',
154
+ });
155
+ const config = {};
156
+ await plugin.config?.(config);
157
+ const agents = (config.agent ?? {});
158
+ expect(agents[AGENT_ENGINEER_BUILD].description).toContain('Thin high-judgment wrapper');
159
+ expect(agents[AGENT_ENGINEER_BUILD].description).toContain('dispatches to Claude Code');
160
+ });
161
+ it('explore tool description marks it as preferred first step', async () => {
162
+ const plugin = await ClaudeManagerPlugin({
163
+ worktree: '/tmp/project',
164
+ });
165
+ const tools = plugin.tool;
166
+ expect(tools['explore'].description).toContain('Preferred first step before implementation');
167
+ });
168
+ it('implement tool description marks it for code changes', async () => {
169
+ const plugin = await ClaudeManagerPlugin({
170
+ worktree: '/tmp/project',
171
+ });
172
+ const tools = plugin.tool;
173
+ expect(tools['implement'].description).toContain('Implement code changes');
174
+ });
175
+ it('does not register old agent names', async () => {
176
+ const plugin = await ClaudeManagerPlugin({
177
+ worktree: '/tmp/project',
178
+ });
179
+ const config = {};
180
+ await plugin.config?.(config);
181
+ const agents = (config.agent ?? {});
182
+ expect(agents).not.toHaveProperty('claude-manager');
183
+ expect(agents).not.toHaveProperty('claude-manager-research');
184
+ expect(agents).not.toHaveProperty('claude-cto');
185
+ expect(agents).not.toHaveProperty('manager');
186
+ });
187
+ it('does not register any slash commands', async () => {
188
+ const plugin = await ClaudeManagerPlugin({
189
+ worktree: '/tmp/project',
190
+ });
191
+ const config = {};
192
+ await plugin.config?.(config);
193
+ expect(config).not.toHaveProperty('command');
194
+ });
195
+ it('exposes freshSession argument on explore defaulting to false', async () => {
196
+ const plugin = await ClaudeManagerPlugin({
197
+ worktree: '/tmp/project',
198
+ });
199
+ const tools = plugin.tool;
200
+ const exploreTool = tools['explore'];
201
+ expect(exploreTool).toBeDefined();
202
+ const freshSchema = exploreTool.args.freshSession;
203
+ expect(freshSchema).toBeDefined();
204
+ expect(freshSchema.parse(undefined)).toBe(false);
205
+ expect(freshSchema.safeParse(true).success).toBe(true);
206
+ expect(freshSchema.safeParse(false).success).toBe(true);
207
+ });
208
+ it('exposes model argument as enum of allowed models on implement', async () => {
209
+ const plugin = await ClaudeManagerPlugin({
210
+ worktree: '/tmp/project',
211
+ });
212
+ const tools = plugin.tool;
213
+ const implementTool = tools['implement'];
214
+ const modelSchema = implementTool.args.model;
215
+ expect(modelSchema).toBeDefined();
216
+ expect(modelSchema.safeParse('claude-opus-4-6').success).toBe(true);
217
+ expect(modelSchema.safeParse('claude-sonnet-4-6').success).toBe(true);
218
+ expect(modelSchema.safeParse('claude-sonnet-4-5').success).toBe(true);
219
+ expect(modelSchema.safeParse(undefined).success).toBe(true);
220
+ expect(modelSchema.safeParse('claude-haiku-4-5').success).toBe(false);
221
+ expect(modelSchema.safeParse('anything-else').success).toBe(false);
222
+ });
223
+ it('exposes effort argument as enum defaulting to high on explore', async () => {
224
+ const plugin = await ClaudeManagerPlugin({
225
+ worktree: '/tmp/project',
226
+ });
227
+ const tools = plugin.tool;
228
+ const exploreTool = tools['explore'];
229
+ const effortSchema = exploreTool.args.effort;
230
+ expect(effortSchema).toBeDefined();
231
+ expect(effortSchema.safeParse('low').success).toBe(true);
232
+ expect(effortSchema.safeParse('medium').success).toBe(true);
233
+ expect(effortSchema.safeParse('high').success).toBe(true);
234
+ expect(effortSchema.safeParse('max').success).toBe(true);
235
+ expect(effortSchema.parse(undefined)).toBe('high');
236
+ expect(effortSchema.safeParse('turbo').success).toBe(false);
237
+ });
238
+ it('exposes compact_context tool', async () => {
239
+ const plugin = await ClaudeManagerPlugin({
240
+ worktree: '/tmp/project',
241
+ });
242
+ const tools = plugin.tool;
243
+ expect(tools['compact_context']).toBeDefined();
244
+ });
245
+ it('exposes all tool IDs matching the hierarchy constants', async () => {
246
+ const plugin = await ClaudeManagerPlugin({
247
+ worktree: '/tmp/project',
248
+ });
249
+ const tools = plugin.tool;
250
+ for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
251
+ expect(tools).toHaveProperty(toolId);
252
+ }
253
+ });
254
+ it('does not expose old claude_manager_* tool IDs', async () => {
255
+ const plugin = await ClaudeManagerPlugin({
256
+ worktree: '/tmp/project',
257
+ });
258
+ const tools = plugin.tool;
259
+ const oldToolIds = [
260
+ 'claude_manager_send',
261
+ 'claude_manager_compact',
262
+ 'claude_manager_git_diff',
263
+ 'claude_manager_git_commit',
264
+ 'claude_manager_git_reset',
265
+ 'claude_manager_clear',
266
+ 'claude_manager_status',
267
+ 'claude_manager_metadata',
268
+ 'claude_manager_sessions',
269
+ 'claude_manager_runs',
270
+ 'claude_manager_approval_policy',
271
+ 'claude_manager_approval_decisions',
272
+ 'claude_manager_approval_update',
273
+ ];
274
+ for (const oldId of oldToolIds) {
275
+ expect(tools).not.toHaveProperty(oldId);
276
+ }
277
+ });
278
+ describe('wrapper prompts include discovered Claude files', () => {
279
+ const tmpDirs = [];
280
+ async function makeTmp() {
281
+ const d = await mkdtemp(join(tmpdir(), 'plugin-test-'));
282
+ tmpDirs.push(d);
283
+ return d;
284
+ }
285
+ afterEach(async () => {
286
+ for (const d of tmpDirs) {
287
+ await rm(d, { recursive: true, force: true });
288
+ }
289
+ tmpDirs.length = 0;
290
+ });
291
+ it('injects project Claude files into engineer plan/build wrapper prompts', async () => {
292
+ const worktree = await makeTmp();
293
+ await writeFile(join(worktree, 'CLAUDE.md'), 'Use pnpm. No default exports.');
294
+ await mkdir(join(worktree, '.claude'), { recursive: true });
295
+ await writeFile(join(worktree, '.claude/settings.md'), 'Extra settings');
296
+ const plugin = await ClaudeManagerPlugin({ worktree });
297
+ const config = {};
298
+ await plugin.config?.(config);
299
+ const agents = config.agent;
300
+ const planPrompt = agents[AGENT_ENGINEER_PLAN].prompt;
301
+ const buildPrompt = agents[AGENT_ENGINEER_BUILD].prompt;
302
+ // Both wrapper prompts should contain discovered file content
303
+ expect(planPrompt).toContain('## Project Claude Files');
304
+ expect(planPrompt).toContain('### CLAUDE.md');
305
+ expect(planPrompt).toContain('Use pnpm. No default exports.');
306
+ expect(planPrompt).toContain('### .claude/settings.md');
307
+ expect(planPrompt).toContain('Extra settings');
308
+ expect(buildPrompt).toContain('## Project Claude Files');
309
+ expect(buildPrompt).toContain('### CLAUDE.md');
310
+ expect(buildPrompt).toContain('Use pnpm. No default exports.');
311
+ });
312
+ it('CTO prompt stays unchanged regardless of Claude files', async () => {
313
+ const worktree = await makeTmp();
314
+ await writeFile(join(worktree, 'CLAUDE.md'), 'project rules');
315
+ const plugin = await ClaudeManagerPlugin({ worktree });
316
+ const config = {};
317
+ await plugin.config?.(config);
318
+ const agents = config.agent;
319
+ expect(agents[AGENT_CTO].prompt).toBe(managerPromptRegistry.ctoSystemPrompt);
320
+ });
321
+ it('wrapper prompts equal base when no Claude files exist', async () => {
322
+ const worktree = await makeTmp();
323
+ const plugin = await ClaudeManagerPlugin({ worktree });
324
+ const config = {};
325
+ await plugin.config?.(config);
326
+ const agents = config.agent;
327
+ expect(agents[AGENT_ENGINEER_PLAN].prompt).toBe(managerPromptRegistry.engineerPlanPrompt);
328
+ expect(agents[AGENT_ENGINEER_BUILD].prompt).toBe(managerPromptRegistry.engineerBuildPrompt);
329
+ });
330
+ });
331
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ContextTracker } from '../src/manager/context-tracker.js';
3
+ describe('ContextTracker', () => {
4
+ it('starts with null estimates', () => {
5
+ const tracker = new ContextTracker();
6
+ const snap = tracker.snapshot();
7
+ expect(snap.estimatedContextPercent).toBeNull();
8
+ expect(snap.warningLevel).toBe('ok');
9
+ expect(snap.totalTurns).toBe(0);
10
+ expect(snap.totalCostUsd).toBe(0);
11
+ expect(snap.compactionCount).toBe(0);
12
+ });
13
+ it('estimates context % from input tokens (tier 1)', () => {
14
+ const tracker = new ContextTracker();
15
+ tracker.recordResult({
16
+ inputTokens: 100_000,
17
+ contextWindowSize: 200_000,
18
+ turns: 10,
19
+ totalCostUsd: 0.5,
20
+ });
21
+ const snap = tracker.snapshot();
22
+ expect(snap.estimatedContextPercent).toBe(50);
23
+ expect(snap.warningLevel).toBe('moderate');
24
+ });
25
+ it('estimates context % from cost (tier 2) when no token data', () => {
26
+ const tracker = new ContextTracker();
27
+ tracker.recordResult({
28
+ totalCostUsd: 0.5,
29
+ turns: 10,
30
+ });
31
+ const snap = tracker.snapshot();
32
+ // 0.5 * 130_000 = 65_000 / 200_000 = 32.5%
33
+ expect(snap.estimatedContextPercent).toBe(33);
34
+ expect(snap.warningLevel).toBe('ok');
35
+ });
36
+ it('estimates context % from turns (tier 3) when no cost or tokens', () => {
37
+ const tracker = new ContextTracker();
38
+ tracker.recordResult({ turns: 20 });
39
+ const snap = tracker.snapshot();
40
+ // 20 * 6_000 = 120_000 / 200_000 = 60%
41
+ expect(snap.estimatedContextPercent).toBe(60);
42
+ expect(snap.warningLevel).toBe('moderate');
43
+ });
44
+ it('caps at 100%', () => {
45
+ const tracker = new ContextTracker();
46
+ tracker.recordResult({
47
+ inputTokens: 300_000,
48
+ contextWindowSize: 200_000,
49
+ });
50
+ expect(tracker.estimateContextPercent()).toBe(100);
51
+ });
52
+ it('returns critical warning at 85%+', () => {
53
+ const tracker = new ContextTracker();
54
+ tracker.recordResult({
55
+ inputTokens: 180_000,
56
+ contextWindowSize: 200_000,
57
+ });
58
+ expect(tracker.warningLevel()).toBe('critical');
59
+ });
60
+ it('returns high warning at 70-85%', () => {
61
+ const tracker = new ContextTracker();
62
+ tracker.recordResult({
63
+ inputTokens: 150_000,
64
+ contextWindowSize: 200_000,
65
+ });
66
+ expect(tracker.warningLevel()).toBe('high');
67
+ });
68
+ it('detects compaction from input token drop', () => {
69
+ const tracker = new ContextTracker();
70
+ tracker.recordResult({
71
+ inputTokens: 150_000,
72
+ contextWindowSize: 200_000,
73
+ });
74
+ expect(tracker.snapshot().compactionCount).toBe(0);
75
+ // After compaction, input tokens drop significantly
76
+ tracker.recordResult({
77
+ inputTokens: 40_000,
78
+ contextWindowSize: 200_000,
79
+ });
80
+ expect(tracker.snapshot().compactionCount).toBe(1);
81
+ expect(tracker.estimateContextPercent()).toBe(20);
82
+ });
83
+ it('tracks explicit compaction events', () => {
84
+ const tracker = new ContextTracker();
85
+ tracker.recordCompaction();
86
+ tracker.recordCompaction();
87
+ expect(tracker.snapshot().compactionCount).toBe(2);
88
+ });
89
+ it('resets all state', () => {
90
+ const tracker = new ContextTracker();
91
+ tracker.recordResult({
92
+ sessionId: 'ses_123',
93
+ inputTokens: 100_000,
94
+ outputTokens: 5_000,
95
+ contextWindowSize: 200_000,
96
+ turns: 10,
97
+ totalCostUsd: 0.5,
98
+ });
99
+ tracker.recordCompaction();
100
+ tracker.reset();
101
+ const snap = tracker.snapshot();
102
+ expect(snap.sessionId).toBeNull();
103
+ expect(snap.totalTurns).toBe(0);
104
+ expect(snap.totalCostUsd).toBe(0);
105
+ expect(snap.latestInputTokens).toBeNull();
106
+ expect(snap.compactionCount).toBe(0);
107
+ expect(snap.estimatedContextPercent).toBeNull();
108
+ });
109
+ it('restores from persisted state', () => {
110
+ const tracker = new ContextTracker();
111
+ tracker.restore({
112
+ sessionId: 'ses_456',
113
+ totalTurns: 15,
114
+ totalCostUsd: 0.8,
115
+ estimatedContextPercent: 60,
116
+ contextWindowSize: 200_000,
117
+ latestInputTokens: 120_000,
118
+ });
119
+ const snap = tracker.snapshot();
120
+ expect(snap.sessionId).toBe('ses_456');
121
+ expect(snap.totalTurns).toBe(15);
122
+ expect(snap.totalCostUsd).toBe(0.8);
123
+ expect(snap.estimatedContextPercent).toBe(60);
124
+ });
125
+ it('checks token threshold', () => {
126
+ const tracker = new ContextTracker();
127
+ expect(tracker.isAboveTokenThreshold()).toBe(false);
128
+ tracker.recordResult({ inputTokens: 150_000 });
129
+ expect(tracker.isAboveTokenThreshold(200_000)).toBe(false);
130
+ tracker.recordResult({ inputTokens: 210_000 });
131
+ expect(tracker.isAboveTokenThreshold(200_000)).toBe(true);
132
+ });
133
+ it('accumulates session ID from results', () => {
134
+ const tracker = new ContextTracker();
135
+ tracker.recordResult({ sessionId: 'ses_abc' });
136
+ expect(tracker.snapshot().sessionId).toBe('ses_abc');
137
+ });
138
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { FileRunStateStore } from '../src/state/file-run-state-store.js';
6
+ const tempDirectories = [];
7
+ function createRun(cwd, id) {
8
+ return {
9
+ id,
10
+ cwd,
11
+ task: 'Implement feature',
12
+ status: 'completed',
13
+ createdAt: new Date().toISOString(),
14
+ updatedAt: new Date().toISOString(),
15
+ sessionId: null,
16
+ sessionHistory: [],
17
+ messages: [],
18
+ actions: [],
19
+ commits: [],
20
+ context: {
21
+ sessionId: null,
22
+ totalTurns: 0,
23
+ totalCostUsd: 0,
24
+ latestInputTokens: null,
25
+ latestOutputTokens: null,
26
+ contextWindowSize: null,
27
+ estimatedContextPercent: null,
28
+ warningLevel: 'ok',
29
+ compactionCount: 0,
30
+ },
31
+ finalSummary: 'Done.',
32
+ };
33
+ }
34
+ describe('FileRunStateStore', () => {
35
+ afterEach(async () => {
36
+ await Promise.all(tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
37
+ });
38
+ it('persists and reloads run records', async () => {
39
+ const cwd = await mkdtemp(path.join(os.tmpdir(), 'file-run-state-store-'));
40
+ tempDirectories.push(cwd);
41
+ const store = new FileRunStateStore();
42
+ const run = createRun(cwd, 'run_1');
43
+ await store.saveRun(run);
44
+ const loadedRun = await store.getRun(cwd, 'run_1');
45
+ expect(loadedRun).toMatchObject({ id: 'run_1', finalSummary: 'Done.' });
46
+ expect(await store.listRuns(cwd)).toHaveLength(1);
47
+ });
48
+ it('serializes concurrent updates for the same run', async () => {
49
+ const cwd = await mkdtemp(path.join(os.tmpdir(), 'file-run-state-store-'));
50
+ tempDirectories.push(cwd);
51
+ const store = new FileRunStateStore();
52
+ const run = createRun(cwd, 'run_2');
53
+ run.status = 'running';
54
+ await store.saveRun(run);
55
+ await Promise.all([
56
+ store.updateRun(cwd, 'run_2', (currentRun) => ({
57
+ ...currentRun,
58
+ messages: [
59
+ ...currentRun.messages,
60
+ {
61
+ timestamp: new Date().toISOString(),
62
+ direction: 'sent',
63
+ text: 'Task A',
64
+ },
65
+ ],
66
+ })),
67
+ store.updateRun(cwd, 'run_2', (currentRun) => ({
68
+ ...currentRun,
69
+ messages: [
70
+ ...currentRun.messages,
71
+ {
72
+ timestamp: new Date().toISOString(),
73
+ direction: 'sent',
74
+ text: 'Task B',
75
+ },
76
+ ],
77
+ })),
78
+ ]);
79
+ const updatedRun = await store.getRun(cwd, 'run_2');
80
+ expect(updatedRun?.messages.map((m) => m.text).sort()).toEqual(['Task A', 'Task B']);
81
+ });
82
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { mkdtemp, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { GitOperations } from '../src/manager/git-operations.js';
8
+ const execFileAsync = promisify(execFile);
9
+ async function createTestRepo() {
10
+ const dir = await mkdtemp(join(tmpdir(), 'git-ops-test-'));
11
+ await execFileAsync('git', ['init'], { cwd: dir });
12
+ await execFileAsync('git', ['config', 'user.email', 'test@test.com'], {
13
+ cwd: dir,
14
+ });
15
+ await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: dir });
16
+ // Create initial commit
17
+ await writeFile(join(dir, 'README.md'), '# Test\n');
18
+ await execFileAsync('git', ['add', '-A'], { cwd: dir });
19
+ await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: dir });
20
+ return dir;
21
+ }
22
+ describe('GitOperations', () => {
23
+ it('reports no diff on clean repo', async () => {
24
+ const dir = await createTestRepo();
25
+ const git = new GitOperations(dir);
26
+ const result = await git.diff();
27
+ expect(result.hasDiff).toBe(false);
28
+ expect(result.diffText.trim()).toBe('');
29
+ expect(result.stats.filesChanged).toBe(0);
30
+ });
31
+ it('reports diff when files are modified', async () => {
32
+ const dir = await createTestRepo();
33
+ await writeFile(join(dir, 'README.md'), '# Changed\n');
34
+ const git = new GitOperations(dir);
35
+ const result = await git.diff();
36
+ expect(result.hasDiff).toBe(true);
37
+ expect(result.diffText).toContain('Changed');
38
+ expect(result.stats.filesChanged).toBe(1);
39
+ expect(result.stats.insertions).toBeGreaterThan(0);
40
+ });
41
+ it('commits all changes', async () => {
42
+ const dir = await createTestRepo();
43
+ await writeFile(join(dir, 'new-file.ts'), 'export const x = 1;\n');
44
+ const git = new GitOperations(dir);
45
+ const result = await git.commit('add new file');
46
+ expect(result.success).toBe(true);
47
+ expect(result.output).toContain('add new file');
48
+ // Verify clean after commit
49
+ const diff = await git.diff();
50
+ expect(diff.hasDiff).toBe(false);
51
+ });
52
+ it('resets hard to discard changes', async () => {
53
+ const dir = await createTestRepo();
54
+ await writeFile(join(dir, 'README.md'), '# Changed\n');
55
+ await writeFile(join(dir, 'untracked.ts'), 'junk');
56
+ const git = new GitOperations(dir);
57
+ const result = await git.resetHard();
58
+ expect(result.success).toBe(true);
59
+ const diff = await git.diff();
60
+ expect(diff.hasDiff).toBe(false);
61
+ });
62
+ it('returns current branch', async () => {
63
+ const dir = await createTestRepo();
64
+ const git = new GitOperations(dir);
65
+ const branch = await git.currentBranch();
66
+ // Could be 'main' or 'master' depending on git config
67
+ expect(branch.length).toBeGreaterThan(0);
68
+ });
69
+ it('returns recent commits', async () => {
70
+ const dir = await createTestRepo();
71
+ const git = new GitOperations(dir);
72
+ const log = await git.recentCommits(1);
73
+ expect(log).toContain('initial');
74
+ });
75
+ it('returns diffStat output', async () => {
76
+ const dir = await createTestRepo();
77
+ await writeFile(join(dir, 'README.md'), '# Changed\n');
78
+ const git = new GitOperations(dir);
79
+ const stat = await git.diffStat();
80
+ expect(stat).toContain('README.md');
81
+ expect(stat).toContain('changed');
82
+ });
83
+ it('handles commit failure on clean repo', async () => {
84
+ const dir = await createTestRepo();
85
+ const git = new GitOperations(dir);
86
+ const result = await git.commit('nothing to commit');
87
+ expect(result.success).toBe(false);
88
+ expect(result.error).toBeDefined();
89
+ });
90
+ });
@@ -0,0 +1 @@
1
+ export {};