olympus-ai 2.4.1 → 2.5.0

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 (147) hide show
  1. package/dist/__tests__/hooks/bundle.test.d.ts +2 -0
  2. package/dist/__tests__/hooks/bundle.test.d.ts.map +1 -0
  3. package/dist/__tests__/hooks/bundle.test.js +120 -0
  4. package/dist/__tests__/hooks/bundle.test.js.map +1 -0
  5. package/dist/__tests__/hooks/integration.test.d.ts +2 -0
  6. package/dist/__tests__/hooks/integration.test.d.ts.map +1 -0
  7. package/dist/__tests__/hooks/integration.test.js +132 -0
  8. package/dist/__tests__/hooks/integration.test.js.map +1 -0
  9. package/dist/__tests__/hooks/performance.test.d.ts +2 -0
  10. package/dist/__tests__/hooks/performance.test.d.ts.map +1 -0
  11. package/dist/__tests__/hooks/performance.test.js +266 -0
  12. package/dist/__tests__/hooks/performance.test.js.map +1 -0
  13. package/dist/__tests__/hooks/router.test.d.ts +2 -0
  14. package/dist/__tests__/hooks/router.test.d.ts.map +1 -0
  15. package/dist/__tests__/hooks/router.test.js +793 -0
  16. package/dist/__tests__/hooks/router.test.js.map +1 -0
  17. package/dist/hooks/config.d.ts +47 -0
  18. package/dist/hooks/config.d.ts.map +1 -0
  19. package/dist/hooks/config.js +120 -0
  20. package/dist/hooks/config.js.map +1 -0
  21. package/dist/hooks/entry.d.ts +17 -0
  22. package/dist/hooks/entry.d.ts.map +1 -0
  23. package/dist/hooks/entry.js +66 -0
  24. package/dist/hooks/entry.js.map +1 -0
  25. package/dist/hooks/index.d.ts +3 -0
  26. package/dist/hooks/index.d.ts.map +1 -1
  27. package/dist/hooks/index.js +6 -0
  28. package/dist/hooks/index.js.map +1 -1
  29. package/dist/hooks/olympus-hooks.cjs +653 -0
  30. package/dist/hooks/registrations/index.d.ts +25 -0
  31. package/dist/hooks/registrations/index.d.ts.map +1 -0
  32. package/dist/hooks/registrations/index.js +43 -0
  33. package/dist/hooks/registrations/index.js.map +1 -0
  34. package/dist/hooks/registrations/messages-transform.d.ts +8 -0
  35. package/dist/hooks/registrations/messages-transform.d.ts.map +1 -0
  36. package/dist/hooks/registrations/messages-transform.js +63 -0
  37. package/dist/hooks/registrations/messages-transform.js.map +1 -0
  38. package/dist/hooks/registrations/notification.d.ts +7 -0
  39. package/dist/hooks/registrations/notification.d.ts.map +1 -0
  40. package/dist/hooks/registrations/notification.js +34 -0
  41. package/dist/hooks/registrations/notification.js.map +1 -0
  42. package/dist/hooks/registrations/post-tool-use.d.ts +18 -0
  43. package/dist/hooks/registrations/post-tool-use.d.ts.map +1 -0
  44. package/dist/hooks/registrations/post-tool-use.js +198 -0
  45. package/dist/hooks/registrations/post-tool-use.js.map +1 -0
  46. package/dist/hooks/registrations/pre-tool-use.d.ts +11 -0
  47. package/dist/hooks/registrations/pre-tool-use.d.ts.map +1 -0
  48. package/dist/hooks/registrations/pre-tool-use.js +102 -0
  49. package/dist/hooks/registrations/pre-tool-use.js.map +1 -0
  50. package/dist/hooks/registrations/session-start.d.ts +7 -0
  51. package/dist/hooks/registrations/session-start.d.ts.map +1 -0
  52. package/dist/hooks/registrations/session-start.js +60 -0
  53. package/dist/hooks/registrations/session-start.js.map +1 -0
  54. package/dist/hooks/registrations/stop.d.ts +8 -0
  55. package/dist/hooks/registrations/stop.d.ts.map +1 -0
  56. package/dist/hooks/registrations/stop.js +28 -0
  57. package/dist/hooks/registrations/stop.js.map +1 -0
  58. package/dist/hooks/registrations/user-prompt-submit.d.ts +7 -0
  59. package/dist/hooks/registrations/user-prompt-submit.d.ts.map +1 -0
  60. package/dist/hooks/registrations/user-prompt-submit.js +114 -0
  61. package/dist/hooks/registrations/user-prompt-submit.js.map +1 -0
  62. package/dist/hooks/registry.d.ts +39 -0
  63. package/dist/hooks/registry.d.ts.map +1 -0
  64. package/dist/hooks/registry.js +58 -0
  65. package/dist/hooks/registry.js.map +1 -0
  66. package/dist/hooks/router.d.ts +31 -0
  67. package/dist/hooks/router.d.ts.map +1 -0
  68. package/dist/hooks/router.js +155 -0
  69. package/dist/hooks/router.js.map +1 -0
  70. package/dist/hooks/types.d.ts +102 -0
  71. package/dist/hooks/types.d.ts.map +1 -0
  72. package/dist/hooks/types.js +8 -0
  73. package/dist/hooks/types.js.map +1 -0
  74. package/dist/installer/default-config.d.ts +112 -0
  75. package/dist/installer/default-config.d.ts.map +1 -0
  76. package/dist/installer/default-config.js +153 -0
  77. package/dist/installer/default-config.js.map +1 -0
  78. package/dist/installer/hooks.d.ts +104 -0
  79. package/dist/installer/hooks.d.ts.map +1 -1
  80. package/dist/installer/hooks.js +80 -0
  81. package/dist/installer/hooks.js.map +1 -1
  82. package/dist/installer/index.d.ts +5 -1
  83. package/dist/installer/index.d.ts.map +1 -1
  84. package/dist/installer/index.js +2108 -2064
  85. package/dist/installer/index.js.map +1 -1
  86. package/dist/installer/migrate.d.ts +28 -0
  87. package/dist/installer/migrate.d.ts.map +1 -0
  88. package/dist/installer/migrate.js +99 -0
  89. package/dist/installer/migrate.js.map +1 -0
  90. package/dist/shared/types.d.ts +60 -0
  91. package/dist/shared/types.d.ts.map +1 -1
  92. package/package.json +3 -1
  93. package/.claude/agents/document-writer.md +0 -152
  94. package/.claude/agents/explore-medium.md +0 -25
  95. package/.claude/agents/explore.md +0 -86
  96. package/.claude/agents/frontend-engineer-high.md +0 -24
  97. package/.claude/agents/frontend-engineer-low.md +0 -23
  98. package/.claude/agents/frontend-engineer.md +0 -89
  99. package/.claude/agents/librarian-low.md +0 -22
  100. package/.claude/agents/librarian.md +0 -70
  101. package/.claude/agents/metis.md +0 -85
  102. package/.claude/agents/momus.md +0 -97
  103. package/.claude/agents/multimodal-looker.md +0 -39
  104. package/.claude/agents/olympian-high.md +0 -39
  105. package/.claude/agents/olympian-low.md +0 -29
  106. package/.claude/agents/olympian.md +0 -71
  107. package/.claude/agents/oracle-low.md +0 -23
  108. package/.claude/agents/oracle-medium.md +0 -28
  109. package/.claude/agents/oracle.md +0 -77
  110. package/.claude/agents/prometheus.md +0 -126
  111. package/.claude/agents/qa-tester.md +0 -220
  112. package/.claude/commands/analyze/skill.md +0 -14
  113. package/.claude/commands/analyze.md +0 -14
  114. package/.claude/commands/ascent/skill.md +0 -152
  115. package/.claude/commands/ascent.md +0 -152
  116. package/.claude/commands/cancel-ascent.md +0 -9
  117. package/.claude/commands/complete-plan.md +0 -101
  118. package/.claude/commands/deepinit.md +0 -114
  119. package/.claude/commands/deepsearch/skill.md +0 -15
  120. package/.claude/commands/deepsearch.md +0 -15
  121. package/.claude/commands/doctor.md +0 -190
  122. package/.claude/commands/olympus/skill.md +0 -82
  123. package/.claude/commands/olympus-default.md +0 -26
  124. package/.claude/commands/plan.md +0 -37
  125. package/.claude/commands/prometheus/skill.md +0 -41
  126. package/.claude/commands/prometheus.md +0 -41
  127. package/.claude/commands/review/skill.md +0 -40
  128. package/.claude/commands/review.md +0 -40
  129. package/.claude/commands/ultrawork/skill.md +0 -90
  130. package/.claude/commands/ultrawork.md +0 -90
  131. package/.claude/commands/update.md +0 -38
  132. package/dist/features/boulder-state/constants.d.ts +0 -20
  133. package/dist/features/boulder-state/constants.d.ts.map +0 -1
  134. package/dist/features/boulder-state/constants.js +0 -20
  135. package/dist/features/boulder-state/constants.js.map +0 -1
  136. package/dist/features/boulder-state/index.d.ts +0 -12
  137. package/dist/features/boulder-state/index.d.ts.map +0 -1
  138. package/dist/features/boulder-state/index.js +0 -13
  139. package/dist/features/boulder-state/index.js.map +0 -1
  140. package/dist/features/boulder-state/storage.d.ts +0 -58
  141. package/dist/features/boulder-state/storage.d.ts.map +0 -1
  142. package/dist/features/boulder-state/storage.js +0 -174
  143. package/dist/features/boulder-state/storage.js.map +0 -1
  144. package/dist/features/boulder-state/types.d.ts +0 -48
  145. package/dist/features/boulder-state/types.d.ts.map +0 -1
  146. package/dist/features/boulder-state/types.js +0 -10
  147. package/dist/features/boulder-state/types.js.map +0 -1
@@ -0,0 +1,793 @@
1
+ /**
2
+ * Hook Router Tests
3
+ *
4
+ * Comprehensive test suite for the hook router system.
5
+ * Tests routing, filtering, aggregation, timeout handling, and error isolation.
6
+ */
7
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
8
+ import { registerHook, clearHooks } from '../../hooks/registry.js';
9
+ import { routeHook, shouldContinue } from '../../hooks/router.js';
10
+ // Mock config loader to avoid file system dependencies
11
+ vi.mock('../../config/loader.js', () => ({
12
+ loadConfig: vi.fn().mockReturnValue({
13
+ hooks: {
14
+ enabled: true,
15
+ hookTimeoutMs: 100
16
+ }
17
+ }),
18
+ DEFAULT_CONFIG: {}
19
+ }));
20
+ describe('Hook Router', () => {
21
+ beforeEach(() => {
22
+ clearHooks();
23
+ vi.clearAllMocks();
24
+ });
25
+ afterEach(() => {
26
+ clearHooks();
27
+ });
28
+ describe('Basic Routing', () => {
29
+ it('routes to registered hooks', async () => {
30
+ registerHook({
31
+ name: 'test',
32
+ event: 'UserPromptSubmit',
33
+ handler: () => ({ continue: true, message: 'test message' })
34
+ });
35
+ const result = await routeHook('UserPromptSubmit', { prompt: 'hello' });
36
+ expect(result.continue).toBe(true);
37
+ expect(result.message).toBe('test message');
38
+ });
39
+ it('returns continue=true when no hooks registered', async () => {
40
+ const result = await routeHook('UserPromptSubmit', {});
41
+ expect(result.continue).toBe(true);
42
+ expect(result.message).toBeUndefined();
43
+ });
44
+ it('routes to multiple hooks for same event', async () => {
45
+ const calls = [];
46
+ registerHook({
47
+ name: 'hook1',
48
+ event: 'UserPromptSubmit',
49
+ handler: () => {
50
+ calls.push('hook1');
51
+ return { continue: true };
52
+ }
53
+ });
54
+ registerHook({
55
+ name: 'hook2',
56
+ event: 'UserPromptSubmit',
57
+ handler: () => {
58
+ calls.push('hook2');
59
+ return { continue: true };
60
+ }
61
+ });
62
+ await routeHook('UserPromptSubmit', {});
63
+ expect(calls).toHaveLength(2);
64
+ expect(calls).toContain('hook1');
65
+ expect(calls).toContain('hook2');
66
+ });
67
+ it('does not route to hooks for different events', async () => {
68
+ const handler = vi.fn().mockReturnValue({ continue: true });
69
+ registerHook({
70
+ name: 'submitHook',
71
+ event: 'UserPromptSubmit',
72
+ handler
73
+ });
74
+ await routeHook('Stop', {});
75
+ expect(handler).not.toHaveBeenCalled();
76
+ });
77
+ });
78
+ describe('Message Aggregation', () => {
79
+ it('aggregates messages from multiple hooks', async () => {
80
+ registerHook({
81
+ name: 'hook1',
82
+ event: 'UserPromptSubmit',
83
+ priority: 10,
84
+ handler: () => ({ continue: true, message: 'message 1' })
85
+ });
86
+ registerHook({
87
+ name: 'hook2',
88
+ event: 'UserPromptSubmit',
89
+ priority: 20,
90
+ handler: () => ({ continue: true, message: 'message 2' })
91
+ });
92
+ const result = await routeHook('UserPromptSubmit', {});
93
+ expect(result.message).toContain('message 1');
94
+ expect(result.message).toContain('message 2');
95
+ expect(result.message).toBe('message 1\n\n---\n\nmessage 2');
96
+ });
97
+ it('handles hooks with no messages', async () => {
98
+ registerHook({
99
+ name: 'noMessage',
100
+ event: 'UserPromptSubmit',
101
+ handler: () => ({ continue: true })
102
+ });
103
+ const result = await routeHook('UserPromptSubmit', {});
104
+ expect(result.message).toBeUndefined();
105
+ });
106
+ it('aggregates only non-empty messages', async () => {
107
+ registerHook({
108
+ name: 'withMessage',
109
+ event: 'UserPromptSubmit',
110
+ priority: 10,
111
+ handler: () => ({ continue: true, message: 'has message' })
112
+ });
113
+ registerHook({
114
+ name: 'noMessage',
115
+ event: 'UserPromptSubmit',
116
+ priority: 20,
117
+ handler: () => ({ continue: true })
118
+ });
119
+ const result = await routeHook('UserPromptSubmit', {});
120
+ expect(result.message).toBe('has message');
121
+ });
122
+ });
123
+ describe('Continue/Block Behavior', () => {
124
+ it('sets continue=false when any hook blocks', async () => {
125
+ registerHook({
126
+ name: 'blocker',
127
+ event: 'Stop',
128
+ handler: () => ({ continue: false, reason: 'tasks remain' })
129
+ });
130
+ const result = await routeHook('Stop', {});
131
+ expect(result.continue).toBe(false);
132
+ expect(result.reason).toBe('tasks remain');
133
+ });
134
+ it('sets continue=false if any hook in chain blocks', async () => {
135
+ registerHook({
136
+ name: 'allows',
137
+ event: 'Stop',
138
+ priority: 10,
139
+ handler: () => ({ continue: true })
140
+ });
141
+ registerHook({
142
+ name: 'blocks',
143
+ event: 'Stop',
144
+ priority: 20,
145
+ handler: () => ({ continue: false, reason: 'blocked' })
146
+ });
147
+ const result = await routeHook('Stop', {});
148
+ expect(result.continue).toBe(false);
149
+ expect(result.reason).toBe('blocked');
150
+ });
151
+ it('continues if all hooks allow', async () => {
152
+ registerHook({
153
+ name: 'allows1',
154
+ event: 'Stop',
155
+ priority: 10,
156
+ handler: () => ({ continue: true })
157
+ });
158
+ registerHook({
159
+ name: 'allows2',
160
+ event: 'Stop',
161
+ priority: 20,
162
+ handler: () => ({ continue: true })
163
+ });
164
+ const result = await routeHook('Stop', {});
165
+ expect(result.continue).toBe(true);
166
+ });
167
+ it('uses shouldContinue helper for simple checks', async () => {
168
+ registerHook({
169
+ name: 'blocker',
170
+ event: 'Stop',
171
+ handler: () => ({ continue: false })
172
+ });
173
+ const result = await shouldContinue('Stop', {});
174
+ expect(result).toBe(false);
175
+ });
176
+ });
177
+ describe('Matcher Filtering', () => {
178
+ it('only runs hooks matching tool name with string matcher', async () => {
179
+ registerHook({
180
+ name: 'editOnly',
181
+ event: 'PostToolUse',
182
+ matcher: 'edit',
183
+ handler: () => ({ continue: true, message: 'edit hook ran' })
184
+ });
185
+ const editResult = await routeHook('PostToolUse', { toolName: 'edit' });
186
+ expect(editResult.message).toBe('edit hook ran');
187
+ const readResult = await routeHook('PostToolUse', { toolName: 'read' });
188
+ expect(readResult.message).toBeUndefined();
189
+ });
190
+ it('only runs hooks matching tool name with regex matcher', async () => {
191
+ registerHook({
192
+ name: 'editOnly',
193
+ event: 'PostToolUse',
194
+ matcher: /^edit$/i,
195
+ handler: () => ({ continue: true, message: 'edit hook ran' })
196
+ });
197
+ const editResult = await routeHook('PostToolUse', { toolName: 'edit' });
198
+ expect(editResult.message).toBe('edit hook ran');
199
+ const readResult = await routeHook('PostToolUse', { toolName: 'read' });
200
+ expect(readResult.message).toBeUndefined();
201
+ });
202
+ it('runs hook when no matcher specified', async () => {
203
+ registerHook({
204
+ name: 'universal',
205
+ event: 'PostToolUse',
206
+ handler: () => ({ continue: true, message: 'ran' })
207
+ });
208
+ const result = await routeHook('PostToolUse', { toolName: 'anything' });
209
+ expect(result.message).toBe('ran');
210
+ });
211
+ it('matches case-insensitively for string matchers', async () => {
212
+ registerHook({
213
+ name: 'editMatcher',
214
+ event: 'PostToolUse',
215
+ matcher: 'edit',
216
+ handler: () => ({ continue: true, message: 'matched' })
217
+ });
218
+ const result = await routeHook('PostToolUse', { toolName: 'EDIT' });
219
+ expect(result.message).toBe('matched');
220
+ });
221
+ it('supports regex patterns for flexible matching', async () => {
222
+ registerHook({
223
+ name: 'fileOps',
224
+ event: 'PostToolUse',
225
+ matcher: /^(read|write|edit)$/i,
226
+ handler: () => ({ continue: true, message: 'file op' })
227
+ });
228
+ const readResult = await routeHook('PostToolUse', { toolName: 'read' });
229
+ expect(readResult.message).toBe('file op');
230
+ const writeResult = await routeHook('PostToolUse', { toolName: 'write' });
231
+ expect(writeResult.message).toBe('file op');
232
+ const bashResult = await routeHook('PostToolUse', { toolName: 'bash' });
233
+ expect(bashResult.message).toBeUndefined();
234
+ });
235
+ it('runs hook when toolName is undefined and no matcher', async () => {
236
+ registerHook({
237
+ name: 'noMatcher',
238
+ event: 'PostToolUse',
239
+ handler: () => ({ continue: true, message: 'ran' })
240
+ });
241
+ const result = await routeHook('PostToolUse', {});
242
+ expect(result.message).toBe('ran');
243
+ });
244
+ });
245
+ describe('Timeout Handling', () => {
246
+ it('handles hook timeout gracefully', async () => {
247
+ registerHook({
248
+ name: 'slow',
249
+ event: 'UserPromptSubmit',
250
+ handler: async () => {
251
+ await new Promise(r => setTimeout(r, 200)); // Exceeds 100ms timeout
252
+ return { continue: true, message: 'slow' };
253
+ }
254
+ });
255
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
256
+ const result = await routeHook('UserPromptSubmit', {});
257
+ // Hook timed out, no message
258
+ expect(result.message).toBeUndefined();
259
+ expect(result.continue).toBe(true); // Default continue state
260
+ // Should log timeout error
261
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('timed out after 100ms'));
262
+ consoleSpy.mockRestore();
263
+ });
264
+ it('continues to next hook after timeout', async () => {
265
+ registerHook({
266
+ name: 'slow',
267
+ event: 'UserPromptSubmit',
268
+ priority: 10,
269
+ handler: async () => {
270
+ await new Promise(r => setTimeout(r, 200));
271
+ return { continue: true, message: 'slow' };
272
+ }
273
+ });
274
+ registerHook({
275
+ name: 'fast',
276
+ event: 'UserPromptSubmit',
277
+ priority: 20,
278
+ handler: () => ({ continue: true, message: 'fast' })
279
+ });
280
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
281
+ const result = await routeHook('UserPromptSubmit', {});
282
+ expect(result.message).toBe('fast');
283
+ consoleSpy.mockRestore();
284
+ });
285
+ it('respects custom timeout from config', async () => {
286
+ // This test verifies that timeouts work with different values
287
+ // Since the mock is set globally to 100ms, we test that a hook
288
+ // timing out respects that configured value
289
+ registerHook({
290
+ name: 'slowHook',
291
+ event: 'UserPromptSubmit',
292
+ handler: async () => {
293
+ await new Promise(r => setTimeout(r, 150)); // Exceeds 100ms default timeout
294
+ return { continue: true, message: 'done' };
295
+ }
296
+ });
297
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
298
+ const result = await routeHook('UserPromptSubmit', {});
299
+ // Hook should timeout with the configured 100ms timeout
300
+ expect(result.message).toBeUndefined();
301
+ expect(consoleSpy).toHaveBeenCalled();
302
+ const errorCall = consoleSpy.mock.calls[0][0];
303
+ expect(errorCall).toContain('slowHook');
304
+ expect(errorCall).toContain('timed out');
305
+ expect(errorCall).toMatch(/\d+ms/); // Contains timeout value
306
+ consoleSpy.mockRestore();
307
+ });
308
+ });
309
+ describe('Error Isolation', () => {
310
+ it('continues to next hook when one throws', async () => {
311
+ registerHook({
312
+ name: 'failing',
313
+ event: 'UserPromptSubmit',
314
+ priority: 10,
315
+ handler: () => {
316
+ throw new Error('intentional fail');
317
+ }
318
+ });
319
+ registerHook({
320
+ name: 'working',
321
+ event: 'UserPromptSubmit',
322
+ priority: 20,
323
+ handler: () => ({ continue: true, message: 'works' })
324
+ });
325
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
326
+ const result = await routeHook('UserPromptSubmit', {});
327
+ expect(result.message).toBe('works');
328
+ expect(result.continue).toBe(true);
329
+ // Should log the error
330
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[hook-router] failing error:'), expect.any(Error));
331
+ consoleSpy.mockRestore();
332
+ });
333
+ it('handles async errors', async () => {
334
+ registerHook({
335
+ name: 'asyncFailing',
336
+ event: 'UserPromptSubmit',
337
+ handler: async () => {
338
+ throw new Error('async fail');
339
+ }
340
+ });
341
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
342
+ const result = await routeHook('UserPromptSubmit', {});
343
+ expect(result.continue).toBe(true);
344
+ expect(result.message).toBeUndefined();
345
+ consoleSpy.mockRestore();
346
+ });
347
+ it('isolates errors to individual hooks', async () => {
348
+ const calls = [];
349
+ registerHook({
350
+ name: 'first',
351
+ event: 'UserPromptSubmit',
352
+ priority: 10,
353
+ handler: () => {
354
+ calls.push('first');
355
+ return { continue: true };
356
+ }
357
+ });
358
+ registerHook({
359
+ name: 'failing',
360
+ event: 'UserPromptSubmit',
361
+ priority: 20,
362
+ handler: () => {
363
+ calls.push('failing');
364
+ throw new Error('fail');
365
+ }
366
+ });
367
+ registerHook({
368
+ name: 'third',
369
+ event: 'UserPromptSubmit',
370
+ priority: 30,
371
+ handler: () => {
372
+ calls.push('third');
373
+ return { continue: true };
374
+ }
375
+ });
376
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
377
+ await routeHook('UserPromptSubmit', {});
378
+ expect(calls).toEqual(['first', 'failing', 'third']);
379
+ consoleSpy.mockRestore();
380
+ });
381
+ });
382
+ describe('Priority Ordering', () => {
383
+ it('executes hooks in priority order', async () => {
384
+ const order = [];
385
+ registerHook({
386
+ name: 'second',
387
+ event: 'UserPromptSubmit',
388
+ priority: 20,
389
+ handler: () => {
390
+ order.push(2);
391
+ return { continue: true };
392
+ }
393
+ });
394
+ registerHook({
395
+ name: 'first',
396
+ event: 'UserPromptSubmit',
397
+ priority: 10,
398
+ handler: () => {
399
+ order.push(1);
400
+ return { continue: true };
401
+ }
402
+ });
403
+ registerHook({
404
+ name: 'third',
405
+ event: 'UserPromptSubmit',
406
+ priority: 30,
407
+ handler: () => {
408
+ order.push(3);
409
+ return { continue: true };
410
+ }
411
+ });
412
+ await routeHook('UserPromptSubmit', {});
413
+ expect(order).toEqual([1, 2, 3]);
414
+ });
415
+ it('uses default priority of 100 when not specified', async () => {
416
+ const order = [];
417
+ registerHook({
418
+ name: 'highPriority',
419
+ event: 'UserPromptSubmit',
420
+ priority: 50,
421
+ handler: () => {
422
+ order.push('high');
423
+ return { continue: true };
424
+ }
425
+ });
426
+ registerHook({
427
+ name: 'defaultPriority',
428
+ event: 'UserPromptSubmit',
429
+ handler: () => {
430
+ order.push('default');
431
+ return { continue: true };
432
+ }
433
+ });
434
+ registerHook({
435
+ name: 'lowPriority',
436
+ event: 'UserPromptSubmit',
437
+ priority: 150,
438
+ handler: () => {
439
+ order.push('low');
440
+ return { continue: true };
441
+ }
442
+ });
443
+ await routeHook('UserPromptSubmit', {});
444
+ expect(order).toEqual(['high', 'default', 'low']);
445
+ });
446
+ it('maintains registration order for same priority', async () => {
447
+ const order = [];
448
+ registerHook({
449
+ name: 'first',
450
+ event: 'UserPromptSubmit',
451
+ priority: 50,
452
+ handler: () => {
453
+ order.push('first');
454
+ return { continue: true };
455
+ }
456
+ });
457
+ registerHook({
458
+ name: 'second',
459
+ event: 'UserPromptSubmit',
460
+ priority: 50,
461
+ handler: () => {
462
+ order.push('second');
463
+ return { continue: true };
464
+ }
465
+ });
466
+ await routeHook('UserPromptSubmit', {});
467
+ expect(order).toEqual(['first', 'second']);
468
+ });
469
+ });
470
+ describe('Input/Message Modification', () => {
471
+ it('passes modified input to subsequent hooks', async () => {
472
+ registerHook({
473
+ name: 'modifier',
474
+ event: 'PreToolUse',
475
+ priority: 10,
476
+ handler: (ctx) => ({
477
+ continue: true,
478
+ modifiedInput: { ...ctx.toolInput, modified: true }
479
+ })
480
+ });
481
+ registerHook({
482
+ name: 'reader',
483
+ event: 'PreToolUse',
484
+ priority: 20,
485
+ handler: (ctx) => ({
486
+ continue: true,
487
+ message: ctx.toolInput?.modified
488
+ ? 'saw modified'
489
+ : 'not modified'
490
+ })
491
+ });
492
+ const result = await routeHook('PreToolUse', { toolInput: { original: true } });
493
+ expect(result.message).toBe('saw modified');
494
+ });
495
+ it('chains multiple input modifications', async () => {
496
+ registerHook({
497
+ name: 'modifier1',
498
+ event: 'PreToolUse',
499
+ priority: 10,
500
+ handler: (ctx) => ({
501
+ continue: true,
502
+ modifiedInput: { ...ctx.toolInput, step1: true }
503
+ })
504
+ });
505
+ registerHook({
506
+ name: 'modifier2',
507
+ event: 'PreToolUse',
508
+ priority: 20,
509
+ handler: (ctx) => ({
510
+ continue: true,
511
+ modifiedInput: { ...ctx.toolInput, step2: true }
512
+ })
513
+ });
514
+ const result = await routeHook('PreToolUse', { toolInput: { original: true } });
515
+ expect(result.modifiedInput).toEqual({
516
+ original: true,
517
+ step1: true,
518
+ step2: true
519
+ });
520
+ });
521
+ it('returns modifiedInput only if changed', async () => {
522
+ registerHook({
523
+ name: 'noModification',
524
+ event: 'PreToolUse',
525
+ handler: () => ({ continue: true })
526
+ });
527
+ const result = await routeHook('PreToolUse', { toolInput: { original: true } });
528
+ expect(result.modifiedInput).toBeUndefined();
529
+ });
530
+ it('handles MessagesTransform modification', async () => {
531
+ const originalMessages = [{ role: 'user', content: 'hello' }];
532
+ registerHook({
533
+ name: 'messageModifier',
534
+ event: 'MessagesTransform',
535
+ handler: (ctx) => ({
536
+ continue: true,
537
+ modifiedMessages: [
538
+ ...ctx.messages,
539
+ { role: 'assistant', content: 'added' }
540
+ ]
541
+ })
542
+ });
543
+ const result = await routeHook('MessagesTransform', { messages: originalMessages });
544
+ expect(result.modifiedMessages).toHaveLength(2);
545
+ expect(result.modifiedMessages).toEqual([
546
+ { role: 'user', content: 'hello' },
547
+ { role: 'assistant', content: 'added' }
548
+ ]);
549
+ });
550
+ it('chains message modifications', async () => {
551
+ const originalMessages = [{ role: 'user', content: 'hello' }];
552
+ registerHook({
553
+ name: 'modifier1',
554
+ event: 'MessagesTransform',
555
+ priority: 10,
556
+ handler: (ctx) => ({
557
+ continue: true,
558
+ modifiedMessages: [...ctx.messages, { added: 'first' }]
559
+ })
560
+ });
561
+ registerHook({
562
+ name: 'modifier2',
563
+ event: 'MessagesTransform',
564
+ priority: 20,
565
+ handler: (ctx) => ({
566
+ continue: true,
567
+ modifiedMessages: [...ctx.messages, { added: 'second' }]
568
+ })
569
+ });
570
+ const result = await routeHook('MessagesTransform', { messages: originalMessages });
571
+ expect(result.modifiedMessages).toHaveLength(3);
572
+ expect(result.modifiedMessages[1]).toEqual({ added: 'first' });
573
+ expect(result.modifiedMessages[2]).toEqual({ added: 'second' });
574
+ });
575
+ });
576
+ describe('Context Propagation', () => {
577
+ it('passes full context to hook handlers', async () => {
578
+ let receivedContext = null;
579
+ registerHook({
580
+ name: 'contextCapture',
581
+ event: 'UserPromptSubmit',
582
+ handler: (ctx) => {
583
+ receivedContext = ctx;
584
+ return { continue: true };
585
+ }
586
+ });
587
+ const inputContext = {
588
+ sessionId: 'test-session',
589
+ directory: '/test/dir',
590
+ prompt: 'test prompt',
591
+ message: { content: 'test', model: { modelId: 'test-model', providerId: 'test' } }
592
+ };
593
+ await routeHook('UserPromptSubmit', inputContext);
594
+ expect(receivedContext).toMatchObject(inputContext);
595
+ });
596
+ it('provides toolName and toolInput for tool hooks', async () => {
597
+ let receivedContext = null;
598
+ registerHook({
599
+ name: 'toolContext',
600
+ event: 'PreToolUse',
601
+ handler: (ctx) => {
602
+ receivedContext = ctx;
603
+ return { continue: true };
604
+ }
605
+ });
606
+ await routeHook('PreToolUse', {
607
+ toolName: 'edit',
608
+ toolInput: { file: 'test.ts', content: 'new content' }
609
+ });
610
+ expect(receivedContext).toMatchObject({
611
+ toolName: 'edit',
612
+ toolInput: { file: 'test.ts', content: 'new content' }
613
+ });
614
+ });
615
+ it('provides toolOutput for PostToolUse hooks', async () => {
616
+ let receivedContext = null;
617
+ registerHook({
618
+ name: 'outputCapture',
619
+ event: 'PostToolUse',
620
+ handler: (ctx) => {
621
+ receivedContext = ctx;
622
+ return { continue: true };
623
+ }
624
+ });
625
+ await routeHook('PostToolUse', {
626
+ toolName: 'read',
627
+ toolOutput: { content: 'file contents' }
628
+ });
629
+ expect(receivedContext?.toolOutput).toEqual({ content: 'file contents' });
630
+ });
631
+ });
632
+ describe('Hook Configuration', () => {
633
+ it('skips disabled hooks via global config', async () => {
634
+ const { loadConfig } = await import('../../config/loader.js');
635
+ vi.mocked(loadConfig).mockReturnValue({
636
+ hooks: {
637
+ enabled: false,
638
+ hookTimeoutMs: 100
639
+ }
640
+ });
641
+ const handler = vi.fn().mockReturnValue({ continue: true });
642
+ registerHook({
643
+ name: 'test',
644
+ event: 'UserPromptSubmit',
645
+ handler
646
+ });
647
+ await routeHook('UserPromptSubmit', {});
648
+ expect(handler).not.toHaveBeenCalled();
649
+ });
650
+ it('skips specifically disabled hooks', async () => {
651
+ const { loadConfig } = await import('../../config/loader.js');
652
+ vi.mocked(loadConfig).mockReturnValue({
653
+ hooks: {
654
+ enabled: true,
655
+ hookTimeoutMs: 100,
656
+ disabledHook: {
657
+ enabled: false
658
+ }
659
+ }
660
+ });
661
+ const disabledHandler = vi.fn().mockReturnValue({ continue: true });
662
+ const enabledHandler = vi.fn().mockReturnValue({ continue: true });
663
+ registerHook({
664
+ name: 'disabledHook',
665
+ event: 'UserPromptSubmit',
666
+ handler: disabledHandler
667
+ });
668
+ registerHook({
669
+ name: 'enabledHook',
670
+ event: 'UserPromptSubmit',
671
+ handler: enabledHandler
672
+ });
673
+ await routeHook('UserPromptSubmit', {});
674
+ expect(disabledHandler).not.toHaveBeenCalled();
675
+ expect(enabledHandler).toHaveBeenCalled();
676
+ });
677
+ it('runs hooks enabled by default when not in config', async () => {
678
+ const { loadConfig } = await import('../../config/loader.js');
679
+ vi.mocked(loadConfig).mockReturnValue({
680
+ hooks: {
681
+ enabled: true,
682
+ hookTimeoutMs: 100
683
+ }
684
+ });
685
+ const handler = vi.fn().mockReturnValue({ continue: true });
686
+ registerHook({
687
+ name: 'notInConfig',
688
+ event: 'UserPromptSubmit',
689
+ handler
690
+ });
691
+ await routeHook('UserPromptSubmit', {});
692
+ expect(handler).toHaveBeenCalled();
693
+ });
694
+ });
695
+ describe('Async Handler Support', () => {
696
+ it('handles async handlers', async () => {
697
+ registerHook({
698
+ name: 'asyncHook',
699
+ event: 'UserPromptSubmit',
700
+ handler: async () => {
701
+ await new Promise(r => setTimeout(r, 10));
702
+ return { continue: true, message: 'async result' };
703
+ }
704
+ });
705
+ const result = await routeHook('UserPromptSubmit', {});
706
+ expect(result.message).toBe('async result');
707
+ });
708
+ it('handles mix of sync and async handlers', async () => {
709
+ const order = [];
710
+ registerHook({
711
+ name: 'sync',
712
+ event: 'UserPromptSubmit',
713
+ priority: 10,
714
+ handler: () => {
715
+ order.push('sync');
716
+ return { continue: true };
717
+ }
718
+ });
719
+ registerHook({
720
+ name: 'async',
721
+ event: 'UserPromptSubmit',
722
+ priority: 20,
723
+ handler: async () => {
724
+ await new Promise(r => setTimeout(r, 10));
725
+ order.push('async');
726
+ return { continue: true };
727
+ }
728
+ });
729
+ await routeHook('UserPromptSubmit', {});
730
+ expect(order).toEqual(['sync', 'async']);
731
+ });
732
+ });
733
+ describe('Edge Cases', () => {
734
+ it('handles empty context object', async () => {
735
+ registerHook({
736
+ name: 'emptyContext',
737
+ event: 'SessionStart',
738
+ handler: () => ({ continue: true })
739
+ });
740
+ const result = await routeHook('SessionStart', {});
741
+ expect(result.continue).toBe(true);
742
+ });
743
+ it('handles null/undefined values in context', async () => {
744
+ let receivedContext = null;
745
+ registerHook({
746
+ name: 'nullContext',
747
+ event: 'UserPromptSubmit',
748
+ handler: (ctx) => {
749
+ receivedContext = ctx;
750
+ return { continue: true };
751
+ }
752
+ });
753
+ await routeHook('UserPromptSubmit', {
754
+ sessionId: undefined,
755
+ directory: undefined,
756
+ prompt: undefined
757
+ });
758
+ expect(receivedContext).toBeDefined();
759
+ expect(receivedContext?.sessionId).toBeUndefined();
760
+ });
761
+ it('handles complex objects in toolInput', async () => {
762
+ const complexInput = {
763
+ nested: { deeply: { value: 42 } },
764
+ array: [1, 2, 3],
765
+ func: () => 'test'
766
+ };
767
+ registerHook({
768
+ name: 'complexInput',
769
+ event: 'PreToolUse',
770
+ handler: (ctx) => {
771
+ expect(ctx.toolInput).toEqual(complexInput);
772
+ return { continue: true };
773
+ }
774
+ });
775
+ await routeHook('PreToolUse', { toolInput: complexInput });
776
+ });
777
+ it('handles very long message aggregation', async () => {
778
+ const hookCount = 10;
779
+ for (let i = 0; i < hookCount; i++) {
780
+ registerHook({
781
+ name: `hook${i}`,
782
+ event: 'UserPromptSubmit',
783
+ handler: () => ({ continue: true, message: `message ${i}` })
784
+ });
785
+ }
786
+ const result = await routeHook('UserPromptSubmit', {});
787
+ expect(result.message?.split('---').length).toBe(hookCount);
788
+ expect(result.message).toContain('message 0');
789
+ expect(result.message).toContain('message 9');
790
+ });
791
+ });
792
+ });
793
+ //# sourceMappingURL=router.test.js.map