openhermes 4.3.0 → 4.11.2

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 (143) hide show
  1. package/CONTEXT.md +10 -1
  2. package/README.md +54 -42
  3. package/bootstrap.ts +396 -142
  4. package/harness/agents/oh-browser.md +97 -0
  5. package/harness/agents/oh-builder.md +78 -0
  6. package/harness/agents/oh-facade.md +75 -0
  7. package/harness/agents/oh-fusion.md +45 -0
  8. package/harness/agents/oh-gauntlet.md +71 -0
  9. package/harness/agents/oh-grill.md +71 -0
  10. package/harness/agents/oh-investigate.md +60 -0
  11. package/harness/agents/oh-manifest.md +95 -0
  12. package/harness/agents/oh-plan-review.md +40 -0
  13. package/harness/agents/oh-planner.md +50 -0
  14. package/harness/agents/oh-refactor.md +37 -0
  15. package/harness/agents/oh-retro.md +46 -0
  16. package/harness/agents/oh-review.md +85 -0
  17. package/harness/agents/oh-security.md +83 -0
  18. package/harness/agents/oh-ship.md +76 -0
  19. package/harness/agents/oh-skill-craft.md +38 -0
  20. package/harness/agents/openhermes.md +28 -73
  21. package/harness/codex/AUTOPILOT.md +235 -87
  22. package/harness/codex/CHARTER.md +80 -0
  23. package/harness/instructions/SHELL.md +76 -0
  24. package/harness/lib/background/background.test.ts +197 -0
  25. package/harness/lib/background/index.ts +7 -0
  26. package/harness/lib/background/interfaces.ts +31 -0
  27. package/harness/lib/background/manager.ts +320 -0
  28. package/harness/lib/composer/compose.test.ts +168 -0
  29. package/harness/lib/composer/compose.ts +65 -0
  30. package/harness/lib/composer/fragments/01-identity.md +1 -0
  31. package/harness/lib/composer/fragments/02-delegation.md +6 -0
  32. package/harness/lib/composer/fragments/03-permissions.md +13 -0
  33. package/harness/lib/composer/fragments/04-task-flow.md +15 -0
  34. package/harness/lib/composer/fragments/05-confidence.md +5 -0
  35. package/harness/lib/composer/fragments/06-parallelization.md +17 -0
  36. package/harness/lib/composer/fragments/07-shell.md +41 -0
  37. package/harness/lib/composer/fragments/08-routing.md +8 -0
  38. package/harness/lib/composer/fragments/09-guardrails.md +12 -0
  39. package/harness/lib/composer/index.ts +1 -0
  40. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +70 -0
  41. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +59 -0
  42. package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
  43. package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
  44. package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
  45. package/harness/lib/hooks/builtins/route-tracking-hook.ts +147 -0
  46. package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
  47. package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
  48. package/harness/lib/hooks/hooks.test.ts +1016 -0
  49. package/harness/lib/hooks/index.ts +30 -0
  50. package/harness/lib/hooks/registry.ts +416 -0
  51. package/harness/lib/hooks/types.ts +71 -0
  52. package/harness/lib/memory/index.ts +18 -0
  53. package/harness/lib/memory/interfaces.ts +53 -0
  54. package/harness/lib/memory/memory-manager.ts +205 -0
  55. package/harness/lib/memory/memory.test.ts +491 -0
  56. package/harness/lib/memory/plan-store.ts +366 -0
  57. package/harness/lib/recovery/handler.ts +243 -0
  58. package/harness/lib/recovery/index.ts +14 -0
  59. package/harness/lib/recovery/interfaces.ts +48 -0
  60. package/harness/lib/recovery/patterns.ts +149 -0
  61. package/harness/lib/recovery/recovery.test.ts +312 -0
  62. package/harness/lib/sanity/anomaly-tracker.ts +127 -0
  63. package/harness/lib/sanity/checker.ts +178 -0
  64. package/harness/lib/sanity/index.ts +13 -0
  65. package/harness/lib/sanity/interfaces.ts +24 -0
  66. package/harness/lib/sanity/sanity.test.ts +472 -0
  67. package/harness/lib/sync/file-watcher.ts +174 -0
  68. package/harness/lib/sync/index.ts +11 -0
  69. package/harness/lib/sync/interfaces.ts +27 -0
  70. package/harness/lib/sync/plan-sync.ts +536 -0
  71. package/harness/lib/sync/sync.test.ts +832 -0
  72. package/harness/skills/oh-ascii/DEEP.md +292 -0
  73. package/harness/skills/oh-ascii/SKILL.md +31 -0
  74. package/harness/skills/oh-ascii/scripts/check_ascii_alignment.py +596 -0
  75. package/harness/skills/oh-browser/DEEP.md +54 -0
  76. package/harness/skills/oh-browser/SKILL.md +30 -0
  77. package/harness/skills/oh-builder/DEEP.md +63 -0
  78. package/harness/skills/oh-builder/SKILL.md +12 -90
  79. package/harness/skills/oh-expert/DEEP.md +85 -0
  80. package/harness/skills/oh-expert/SKILL.md +13 -106
  81. package/harness/skills/oh-facade/DEEP.md +182 -0
  82. package/harness/skills/oh-facade/SKILL.md +15 -279
  83. package/harness/skills/oh-freeze/DEEP.md +18 -0
  84. package/harness/skills/oh-freeze/SKILL.md +10 -19
  85. package/harness/skills/oh-full-output/DEEP.md +25 -0
  86. package/harness/skills/oh-full-output/SKILL.md +12 -65
  87. package/harness/skills/oh-fusion/DEEP.md +120 -0
  88. package/harness/skills/oh-fusion/SKILL.md +17 -295
  89. package/harness/skills/oh-gauntlet/DEEP.md +77 -0
  90. package/harness/skills/oh-gauntlet/SKILL.md +13 -105
  91. package/harness/skills/oh-grill/DEEP.md +51 -0
  92. package/harness/skills/oh-grill/SKILL.md +12 -63
  93. package/harness/skills/oh-guard/DEEP.md +19 -0
  94. package/harness/skills/oh-guard/SKILL.md +10 -24
  95. package/harness/skills/oh-handoff/DEEP.md +48 -0
  96. package/harness/skills/oh-handoff/SKILL.md +13 -23
  97. package/harness/skills/oh-health/DEEP.md +74 -0
  98. package/harness/skills/oh-health/SKILL.md +13 -76
  99. package/harness/skills/oh-init/DEEP.md +85 -0
  100. package/harness/skills/oh-init/SKILL.md +13 -127
  101. package/harness/skills/oh-investigate/DEEP.md +171 -0
  102. package/harness/skills/oh-investigate/SKILL.md +13 -66
  103. package/harness/skills/oh-issue/DEEP.md +21 -0
  104. package/harness/skills/oh-issue/SKILL.md +11 -27
  105. package/harness/skills/oh-manifest/DEEP.md +92 -0
  106. package/harness/skills/oh-manifest/SKILL.md +12 -109
  107. package/harness/skills/oh-plan-review/DEEP.md +90 -0
  108. package/harness/skills/oh-plan-review/SKILL.md +13 -115
  109. package/harness/skills/oh-planner/DEEP.md +172 -0
  110. package/harness/skills/oh-planner/SKILL.md +12 -149
  111. package/harness/skills/oh-prd/DEEP.md +45 -0
  112. package/harness/skills/oh-prd/SKILL.md +10 -26
  113. package/harness/skills/oh-refactor/DEEP.md +122 -0
  114. package/harness/skills/oh-refactor/SKILL.md +17 -410
  115. package/harness/skills/oh-retro/DEEP.md +26 -0
  116. package/harness/skills/oh-retro/SKILL.md +12 -24
  117. package/harness/skills/oh-review/DEEP.md +87 -0
  118. package/harness/skills/oh-review/SKILL.md +11 -97
  119. package/harness/skills/oh-security/DEEP.md +83 -0
  120. package/harness/skills/oh-security/SKILL.md +14 -96
  121. package/harness/skills/oh-ship/DEEP.md +141 -0
  122. package/harness/skills/oh-ship/SKILL.md +14 -32
  123. package/harness/skills/oh-skill-craft/DEEP.md +369 -0
  124. package/harness/skills/oh-skill-craft/SKILL.md +13 -177
  125. package/harness/skills/oh-skills-link/DEEP.md +16 -0
  126. package/harness/skills/oh-skills-link/SKILL.md +10 -20
  127. package/harness/skills/oh-skills-list/DEEP.md +20 -0
  128. package/harness/skills/oh-skills-list/SKILL.md +9 -22
  129. package/harness/skills/oh-triage/DEEP.md +23 -0
  130. package/harness/skills/oh-triage/SKILL.md +8 -24
  131. package/harness/skills/oh-worktree/DEEP.md +169 -0
  132. package/harness/skills/oh-worktree/SKILL.md +32 -0
  133. package/lib/harness-resolver.ts +8 -10
  134. package/package.json +7 -5
  135. package/tsconfig.json +1 -1
  136. package/harness/codex/CONSTITUTION.md +0 -73
  137. package/harness/codex/ROUTING.md +0 -92
  138. package/harness/commands/oh-doctor.md +0 -26
  139. package/harness/commands/oh-log.md +0 -18
  140. package/harness/instructions/RUNTIME.md +0 -30
  141. package/harness/skills/oh-caveman/SKILL.md +0 -42
  142. package/harness/skills/oh-learn/SKILL.md +0 -101
  143. package/lib/logger.ts +0 -75
@@ -0,0 +1,1016 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Hook System — comprehensive tests
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { describe, it, before, after, beforeEach } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ HookPhase,
9
+ HookResult,
10
+ HookRegistry,
11
+ planCheckHook,
12
+ shellDetectHook,
13
+ confidenceGateHook,
14
+ delegationDepthHook,
15
+ resetDepthTracker,
16
+ errorRecoveryHook,
17
+ memorySyncHook,
18
+ routeTrackingHook,
19
+ resetRouteTracker,
20
+ getHopHistory,
21
+ sanityCheckHook,
22
+ } from "./index.ts";
23
+ import { AnomalyTracker } from "../sanity/anomaly-tracker.ts";
24
+ import type {
25
+ HookContext,
26
+ HookMetadata,
27
+ PreToolUseHook,
28
+ PostToolUseHook,
29
+ RouteHook,
30
+ SessionHook,
31
+ } from "./types.ts";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function makeContext(overrides?: Partial<HookContext>): HookContext {
38
+ return {
39
+ sessionId: "test-session",
40
+ agent: "oh-builder",
41
+ directory: "/tmp/test-project",
42
+ sessions: new Map(),
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ function makePreToolHook(
48
+ name: string,
49
+ overrides?: Partial<HookMetadata>,
50
+ impl?: (
51
+ ctx: HookContext,
52
+ ) => Promise<{
53
+ result: HookResult;
54
+ modifiedContext?: Partial<HookContext>;
55
+ }>,
56
+ ): PreToolUseHook {
57
+ return {
58
+ metadata: {
59
+ name,
60
+ priority: 50,
61
+ phase: HookPhase.NORMAL,
62
+ dependencies: [],
63
+ errorHandling: "propagate",
64
+ ...overrides,
65
+ },
66
+ execute: impl ?? (async () => ({ result: HookResult.CONTINUE })),
67
+ };
68
+ }
69
+
70
+ function makePostToolHook(
71
+ name: string,
72
+ overrides?: Partial<HookMetadata>,
73
+ impl?: (
74
+ ctx: HookContext,
75
+ output: string,
76
+ ) => Promise<{
77
+ result: HookResult;
78
+ modifiedOutput?: string;
79
+ injectRecovery?: string;
80
+ }>,
81
+ ): PostToolUseHook {
82
+ return {
83
+ metadata: {
84
+ name,
85
+ priority: 50,
86
+ phase: HookPhase.NORMAL,
87
+ dependencies: [],
88
+ errorHandling: "propagate",
89
+ ...overrides,
90
+ },
91
+ execute: impl ?? (async () => ({ result: HookResult.CONTINUE })),
92
+ };
93
+ }
94
+
95
+ function makeRouteHook(
96
+ name: string,
97
+ overrides?: Partial<HookMetadata>,
98
+ impl?: (
99
+ ctx: HookContext,
100
+ route: string,
101
+ ) => Promise<{ result: HookResult; modifiedRoute?: string }>,
102
+ ): RouteHook {
103
+ return {
104
+ metadata: {
105
+ name,
106
+ priority: 50,
107
+ phase: HookPhase.NORMAL,
108
+ dependencies: [],
109
+ errorHandling: "propagate",
110
+ ...overrides,
111
+ },
112
+ execute: impl ?? (async () => ({ result: HookResult.CONTINUE })),
113
+ };
114
+ }
115
+
116
+ function makeSessionHook(
117
+ name: string,
118
+ overrides?: Partial<HookMetadata>,
119
+ ): SessionHook {
120
+ return {
121
+ metadata: {
122
+ name,
123
+ priority: 50,
124
+ phase: HookPhase.NORMAL,
125
+ dependencies: [],
126
+ errorHandling: "propagate",
127
+ ...overrides,
128
+ },
129
+ onSessionStart: async () => {},
130
+ onSessionEnd: async () => {},
131
+ };
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Tests
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe("HookRegistry", () => {
139
+ beforeEach(() => {
140
+ HookRegistry.resetInstance();
141
+ resetDepthTracker();
142
+ resetRouteTracker();
143
+ });
144
+
145
+ // ---- Registration & Unregistration ----
146
+
147
+ describe("registration", () => {
148
+ it("registers a PreToolUseHook", () => {
149
+ const reg = HookRegistry.getInstance();
150
+ const hook = makePreToolHook("test-pre");
151
+ reg.registerPreTool(hook);
152
+ assert.equal(reg.getPreToolHooks().length, 1);
153
+ assert.equal(reg.getPreToolHooks()[0].metadata.name, "test-pre");
154
+ });
155
+
156
+ it("registers a PostToolUseHook", () => {
157
+ const reg = HookRegistry.getInstance();
158
+ const hook = makePostToolHook("test-post");
159
+ reg.registerPostTool(hook);
160
+ assert.equal(reg.getPostToolHooks().length, 1);
161
+ });
162
+
163
+ it("registers a RouteHook", () => {
164
+ const reg = HookRegistry.getInstance();
165
+ const hook = makeRouteHook("test-route");
166
+ reg.registerRoute(hook);
167
+ assert.equal(reg.getRouteHooks().length, 1);
168
+ });
169
+
170
+ it("registers a SessionHook", () => {
171
+ const reg = HookRegistry.getInstance();
172
+ const hook = makeSessionHook("test-session");
173
+ reg.registerSession(hook);
174
+ assert.equal(reg.getSessionHooks().length, 1);
175
+ });
176
+
177
+ it("skips duplicate name silently", () => {
178
+ const reg = HookRegistry.getInstance();
179
+ const first = reg.registerPreTool(makePreToolHook("dup"));
180
+ const second = reg.registerPreTool(makePreToolHook("dup"));
181
+ assert.equal(first, true);
182
+ assert.equal(second, false);
183
+ assert.equal(reg.getPreToolHooks().length, 1);
184
+ });
185
+
186
+ it("same name across different hook types is allowed", () => {
187
+ const reg = HookRegistry.getInstance();
188
+ reg.registerPreTool(makePreToolHook("shared"));
189
+ reg.registerPostTool(makePostToolHook("shared"));
190
+ assert.equal(reg.getPreToolHooks().length, 1);
191
+ assert.equal(reg.getPostToolHooks().length, 1);
192
+ });
193
+ });
194
+
195
+ describe("unregister", () => {
196
+ it("removes a hook by name from pre-tool list", () => {
197
+ const reg = HookRegistry.getInstance();
198
+ reg.registerPreTool(makePreToolHook("remove-me"));
199
+ const result = reg.unregister("remove-me");
200
+ assert.equal(result, true);
201
+ assert.equal(reg.getPreToolHooks().length, 0);
202
+ });
203
+
204
+ it("returns false for unknown name", () => {
205
+ const reg = HookRegistry.getInstance();
206
+ assert.equal(reg.unregister("nonexistent"), false);
207
+ });
208
+
209
+ it("removes from all categories that match the name", () => {
210
+ const reg = HookRegistry.getInstance();
211
+ reg.registerPreTool(makePreToolHook("shared"));
212
+ reg.registerPostTool(makePostToolHook("shared"));
213
+ const result = reg.unregister("shared");
214
+ // Removes from ALL categories that have a hook with this name
215
+ assert.equal(result, true);
216
+ assert.equal(reg.getPreToolHooks().length, 0);
217
+ assert.equal(reg.getPostToolHooks().length, 0);
218
+ });
219
+ });
220
+
221
+ // ---- Topological Sort ----
222
+
223
+ describe("topologicalSort", () => {
224
+ it("returns empty array for empty input", () => {
225
+ const reg = HookRegistry.getInstance();
226
+ const result = reg.topologicalSort([]);
227
+ assert.deepEqual(result, []);
228
+ });
229
+
230
+ it("preserves order when no dependencies", () => {
231
+ const reg = HookRegistry.getInstance();
232
+ const a = makePreToolHook("a", { phase: HookPhase.EARLY, priority: 80 });
233
+ const b = makePreToolHook("b", { phase: HookPhase.EARLY, priority: 70 });
234
+ const c = makePreToolHook("c", { phase: HookPhase.EARLY, priority: 90 });
235
+ // Higher priority = earlier: c (90), a (80), b (70)
236
+ const sorted = reg.topologicalSort([a, b, c]);
237
+ assert.equal(sorted[0].metadata.name, "c");
238
+ assert.equal(sorted[1].metadata.name, "a");
239
+ assert.equal(sorted[2].metadata.name, "b");
240
+ });
241
+
242
+ it("phase ordering: EARLY before NORMAL before LATE", () => {
243
+ const reg = HookRegistry.getInstance();
244
+ const early = makePreToolHook("early-hook", { phase: HookPhase.EARLY, priority: 50 });
245
+ const normal = makePreToolHook("normal-hook", { phase: HookPhase.NORMAL, priority: 50 });
246
+ const late = makePreToolHook("late-hook", { phase: HookPhase.LATE, priority: 50 });
247
+
248
+ const sorted = reg.topologicalSort([late, early, normal]);
249
+ assert.equal(sorted[0].metadata.name, "early-hook");
250
+ assert.equal(sorted[1].metadata.name, "normal-hook");
251
+ assert.equal(sorted[2].metadata.name, "late-hook");
252
+ });
253
+
254
+ it("handles simple linear dependencies", () => {
255
+ const reg = HookRegistry.getInstance();
256
+ const a = makePreToolHook("a", {
257
+ phase: HookPhase.EARLY,
258
+ priority: 50,
259
+ dependencies: [],
260
+ });
261
+ const b = makePreToolHook("b", {
262
+ phase: HookPhase.EARLY,
263
+ priority: 50,
264
+ dependencies: ["a"],
265
+ });
266
+ const c = makePreToolHook("c", {
267
+ phase: HookPhase.EARLY,
268
+ priority: 50,
269
+ dependencies: ["b"],
270
+ });
271
+
272
+ const sorted = reg.topologicalSort([c, a, b]);
273
+ assert.equal(sorted[0].metadata.name, "a");
274
+ assert.equal(sorted[1].metadata.name, "b");
275
+ assert.equal(sorted[2].metadata.name, "c");
276
+ });
277
+
278
+ it("handles diamond dependencies (A→B→D and A→C→D)", () => {
279
+ const reg = HookRegistry.getInstance();
280
+ const a = makePreToolHook("a", {
281
+ phase: HookPhase.EARLY,
282
+ priority: 50,
283
+ dependencies: [],
284
+ });
285
+ const b = makePreToolHook("b", {
286
+ phase: HookPhase.EARLY,
287
+ priority: 50,
288
+ dependencies: ["a"],
289
+ });
290
+ const c = makePreToolHook("c", {
291
+ phase: HookPhase.EARLY,
292
+ priority: 50,
293
+ dependencies: ["a"],
294
+ });
295
+ const d = makePreToolHook("d", {
296
+ phase: HookPhase.EARLY,
297
+ priority: 50,
298
+ dependencies: ["b", "c"],
299
+ });
300
+
301
+ const sorted = reg.topologicalSort([d, c, b, a]);
302
+ // A must come first, D must come last
303
+ assert.equal(sorted[0].metadata.name, "a");
304
+ assert.equal(sorted[3].metadata.name, "d");
305
+ // B and C can be in any order but must be before D and after A
306
+ const bIdx = sorted.findIndex((h) => h.metadata.name === "b");
307
+ const cIdx = sorted.findIndex((h) => h.metadata.name === "c");
308
+ assert.ok(bIdx > 0);
309
+ assert.ok(cIdx > 0);
310
+ assert.ok(bIdx < 3);
311
+ assert.ok(cIdx < 3);
312
+ });
313
+
314
+ it("throws on circular dependency (A→B→C→A)", () => {
315
+ const reg = HookRegistry.getInstance();
316
+ const a = makePreToolHook("a", {
317
+ phase: HookPhase.EARLY,
318
+ dependencies: ["c"],
319
+ });
320
+ const b = makePreToolHook("b", {
321
+ phase: HookPhase.EARLY,
322
+ dependencies: ["a"],
323
+ });
324
+ const c = makePreToolHook("c", {
325
+ phase: HookPhase.EARLY,
326
+ dependencies: ["b"],
327
+ });
328
+
329
+ assert.throws(
330
+ () => reg.topologicalSort([a, b, c]),
331
+ /Circular dependency detected/,
332
+ );
333
+ });
334
+
335
+ it("self-dependency throws", () => {
336
+ const reg = HookRegistry.getInstance();
337
+ const a = makePreToolHook("a", {
338
+ phase: HookPhase.EARLY,
339
+ dependencies: ["a"],
340
+ });
341
+
342
+ assert.throws(
343
+ () => reg.topologicalSort([a]),
344
+ /Circular dependency detected/,
345
+ );
346
+ });
347
+
348
+ it("cross-phase dependencies are ignored (not within same phase)", () => {
349
+ const reg = HookRegistry.getInstance();
350
+ const early = makePreToolHook("early", {
351
+ phase: HookPhase.EARLY,
352
+ priority: 50,
353
+ dependencies: [],
354
+ });
355
+ const normal = makePreToolHook("normal", {
356
+ phase: HookPhase.NORMAL,
357
+ priority: 50,
358
+ dependencies: ["early"], // Dep on different phase — ignored
359
+ });
360
+
361
+ // Should not throw — cross-phase deps are silently ignored
362
+ const sorted = reg.topologicalSort([normal, early]);
363
+ assert.equal(sorted[0].metadata.name, "early");
364
+ assert.equal(sorted[1].metadata.name, "normal");
365
+ });
366
+ });
367
+
368
+ // ---- Execution ----
369
+
370
+ describe("executePreTool", () => {
371
+ it("passes through with CONTINUE when all hooks pass", async () => {
372
+ const reg = HookRegistry.getInstance();
373
+ reg.registerPreTool(
374
+ makePreToolHook("hook1", {}, async (ctx) => {
375
+ return {
376
+ result: HookResult.CONTINUE,
377
+ modifiedContext: { _track: "ran" },
378
+ };
379
+ }),
380
+ );
381
+
382
+ const result = await reg.executePreTool(makeContext());
383
+ assert.equal(result.result, HookResult.CONTINUE);
384
+ assert.equal(result.modifiedContext?._track, "ran");
385
+ });
386
+
387
+ it("stops execution on STOP result", async () => {
388
+ const reg = HookRegistry.getInstance();
389
+ let secondRan = false;
390
+
391
+ reg.registerPreTool(
392
+ makePreToolHook("stopper", { priority: 60 }, async () => ({
393
+ result: HookResult.STOP,
394
+ })),
395
+ );
396
+ reg.registerPreTool(
397
+ makePreToolHook("should-not-run", { priority: 50 }, async () => {
398
+ secondRan = true;
399
+ return { result: HookResult.CONTINUE };
400
+ }),
401
+ );
402
+
403
+ const result = await reg.executePreTool(makeContext());
404
+ assert.equal(result.result, HookResult.STOP);
405
+ assert.equal(secondRan, false);
406
+ });
407
+
408
+ it("accumulates modified context across hooks", async () => {
409
+ const reg = HookRegistry.getInstance();
410
+ reg.registerPreTool(
411
+ makePreToolHook("hook1", { priority: 60 }, async () => ({
412
+ result: HookResult.CONTINUE,
413
+ modifiedContext: { _first: "set" },
414
+ })),
415
+ );
416
+ reg.registerPreTool(
417
+ makePreToolHook("hook2", { priority: 50 }, async () => ({
418
+ result: HookResult.CONTINUE,
419
+ modifiedContext: { _second: "also-set" },
420
+ })),
421
+ );
422
+
423
+ const result = await reg.executePreTool(makeContext());
424
+ assert.equal(result.result, HookResult.CONTINUE);
425
+ assert.equal(result.modifiedContext?._first, "set");
426
+ assert.equal(result.modifiedContext?._second, "also-set");
427
+ });
428
+ });
429
+
430
+ describe("executePostTool", () => {
431
+ it("passes through output without modification", async () => {
432
+ const reg = HookRegistry.getInstance();
433
+ reg.registerPostTool(makePostToolHook("pass"));
434
+
435
+ const result = await reg.executePostTool(
436
+ makeContext(),
437
+ "some output",
438
+ );
439
+ assert.equal(result.result, HookResult.CONTINUE);
440
+ assert.equal(result.modifiedOutput, "some output");
441
+ });
442
+
443
+ it("modifies output in sequence", async () => {
444
+ const reg = HookRegistry.getInstance();
445
+ reg.registerPostTool(
446
+ makePostToolHook("uppercase", { priority: 60 }, async (_ctx, out) => ({
447
+ result: HookResult.CONTINUE,
448
+ modifiedOutput: out.toUpperCase(),
449
+ })),
450
+ );
451
+ reg.registerPostTool(
452
+ makePostToolHook("wrap", { priority: 50 }, async (_ctx, out) => ({
453
+ result: HookResult.CONTINUE,
454
+ modifiedOutput: `[[[${out}]]]`,
455
+ })),
456
+ );
457
+
458
+ const result = await reg.executePostTool(
459
+ makeContext(),
460
+ "hello",
461
+ );
462
+ assert.equal(result.modifiedOutput, "[[[HELLO]]]");
463
+ });
464
+
465
+ it("injects recovery action", async () => {
466
+ const reg = HookRegistry.getInstance();
467
+ reg.registerPostTool(
468
+ makePostToolHook("recovery-test", {}, async () => ({
469
+ result: HookResult.INJECT,
470
+ injectRecovery: "retry with backoff",
471
+ })),
472
+ );
473
+
474
+ const result = await reg.executePostTool(
475
+ makeContext(),
476
+ "output",
477
+ );
478
+ assert.equal(result.recovery, "retry with backoff");
479
+ });
480
+ });
481
+
482
+ describe("executeRoute", () => {
483
+ it("passes route unchanged", async () => {
484
+ const reg = HookRegistry.getInstance();
485
+ reg.registerRoute(makeRouteHook("pass"));
486
+
487
+ const result = await reg.executeRoute(makeContext(), "oh-builder");
488
+ assert.equal(result.result, HookResult.CONTINUE);
489
+ assert.equal(result.modifiedRoute, "oh-builder");
490
+ });
491
+
492
+ it("modifies route destination", async () => {
493
+ const reg = HookRegistry.getInstance();
494
+ reg.registerRoute(
495
+ makeRouteHook("reroute", {}, async (_ctx, _route) => ({
496
+ result: HookResult.CONTINUE,
497
+ modifiedRoute: "oh-gauntlet",
498
+ })),
499
+ );
500
+
501
+ const result = await reg.executeRoute(
502
+ makeContext(),
503
+ "oh-builder",
504
+ );
505
+ assert.equal(result.modifiedRoute, "oh-gauntlet");
506
+ });
507
+
508
+ it("chains multiple route modifications", async () => {
509
+ const reg = HookRegistry.getInstance();
510
+ reg.registerRoute(
511
+ makeRouteHook("first", { priority: 60 }, async (_ctx, route) => ({
512
+ result: HookResult.CONTINUE,
513
+ modifiedRoute: `${route}-step1`,
514
+ })),
515
+ );
516
+ reg.registerRoute(
517
+ makeRouteHook("second", { priority: 50 }, async (_ctx, route) => ({
518
+ result: HookResult.CONTINUE,
519
+ modifiedRoute: `${route}-step2`,
520
+ })),
521
+ );
522
+
523
+ const result = await reg.executeRoute(
524
+ makeContext(),
525
+ "oh-builder",
526
+ );
527
+ assert.equal(result.modifiedRoute, "oh-builder-step1-step2");
528
+ });
529
+ });
530
+
531
+ describe("executeSessionStart / executeSessionEnd", () => {
532
+ it("calls onSessionStart on all session hooks in order", async () => {
533
+ const reg = HookRegistry.getInstance();
534
+ const order: string[] = [];
535
+
536
+ const hook1 = makeSessionHook("first", { priority: 60 });
537
+ hook1.onSessionStart = async () => { order.push("first"); };
538
+
539
+ const hook2 = makeSessionHook("second", { priority: 50 });
540
+ hook2.onSessionStart = async () => { order.push("second"); };
541
+
542
+ reg.registerSession(hook1);
543
+ reg.registerSession(hook2);
544
+
545
+ await reg.executeSessionStart(makeContext());
546
+ assert.deepEqual(order, ["first", "second"]);
547
+ });
548
+
549
+ it("calls onSessionEnd on all session hooks", async () => {
550
+ const reg = HookRegistry.getInstance();
551
+ let called = false;
552
+
553
+ const hook = makeSessionHook("test-end");
554
+ hook.onSessionEnd = async () => { called = true; };
555
+ reg.registerSession(hook);
556
+
557
+ await reg.executeSessionEnd(makeContext());
558
+ assert.equal(called, true);
559
+ });
560
+ });
561
+
562
+ // ---- Built-in Hooks ----
563
+
564
+ describe("built-in hooks", () => {
565
+ it("planCheckHook has correct metadata", () => {
566
+ assert.equal(planCheckHook.metadata.name, "plan-check");
567
+ assert.equal(planCheckHook.metadata.priority, 90);
568
+ assert.equal(planCheckHook.metadata.phase, HookPhase.EARLY);
569
+ });
570
+
571
+ it("shellDetectHook has correct metadata", () => {
572
+ assert.equal(shellDetectHook.metadata.name, "shell-detect");
573
+ assert.equal(shellDetectHook.metadata.priority, 80);
574
+ assert.equal(shellDetectHook.metadata.phase, HookPhase.EARLY);
575
+ });
576
+
577
+ it("confidenceGateHook has correct metadata", () => {
578
+ assert.equal(confidenceGateHook.metadata.name, "confidence-gate");
579
+ assert.equal(confidenceGateHook.metadata.priority, 70);
580
+ assert.equal(confidenceGateHook.metadata.phase, HookPhase.NORMAL);
581
+ });
582
+
583
+ it("delegationDepthHook has correct metadata", () => {
584
+ assert.equal(delegationDepthHook.metadata.name, "delegation-depth");
585
+ assert.equal(delegationDepthHook.metadata.priority, 60);
586
+ assert.equal(delegationDepthHook.metadata.phase, HookPhase.NORMAL);
587
+ });
588
+
589
+ it("errorRecoveryHook has correct metadata", () => {
590
+ assert.equal(errorRecoveryHook.metadata.name, "error-recovery");
591
+ assert.equal(errorRecoveryHook.metadata.priority, 50);
592
+ assert.equal(errorRecoveryHook.metadata.phase, HookPhase.LATE);
593
+ });
594
+
595
+ it("memorySyncHook has correct metadata", () => {
596
+ assert.equal(memorySyncHook.metadata.name, "memory-sync");
597
+ assert.equal(memorySyncHook.metadata.priority, 40);
598
+ assert.equal(memorySyncHook.metadata.phase, HookPhase.LATE);
599
+ });
600
+
601
+ it("shellDetectHook returns shell context", async () => {
602
+ const result = await shellDetectHook.execute(makeContext());
603
+ assert.equal(result.result, HookResult.CONTINUE);
604
+ assert.ok(result.modifiedContext?._shellType);
605
+ assert.ok(result.modifiedContext?._shellPlatform);
606
+ });
607
+
608
+ it("delegationDepthHook increments depth", async () => {
609
+ const ctx = makeContext();
610
+ const result = await delegationDepthHook.execute(ctx);
611
+ assert.equal(result.result, HookResult.CONTINUE);
612
+ assert.equal(result.modifiedContext?._delegationDepth, 1);
613
+ });
614
+
615
+ it("delegationDepthHook stops when depth exceeded", async () => {
616
+ const ctx = makeContext({ _maxDelegationDepth: 2 });
617
+ // First call
618
+ await delegationDepthHook.execute(ctx);
619
+ // Second call
620
+ await delegationDepthHook.execute(ctx);
621
+ // Third call — should exceed
622
+ const result = await delegationDepthHook.execute(ctx);
623
+ assert.equal(result.result, HookResult.STOP);
624
+ assert.equal(result.modifiedContext?._depthExceeded, true);
625
+ });
626
+
627
+ it("errorRecoveryHook returns CONTINUE for normal output", async () => {
628
+ const result = await errorRecoveryHook.execute(
629
+ makeContext(),
630
+ "Everything completed successfully.",
631
+ );
632
+ assert.equal(result.result, HookResult.CONTINUE);
633
+ });
634
+
635
+ it("errorRecoveryHook detects error output", async () => {
636
+ const result = await errorRecoveryHook.execute(
637
+ makeContext(),
638
+ "Error: Failed to connect to server",
639
+ );
640
+ assert.equal(result.result, HookResult.INJECT);
641
+ assert.ok(result.injectRecovery);
642
+ assert.ok(result.injectRecovery!.includes("Error Recovery"));
643
+ });
644
+
645
+ it("confidenceGateHook passes through without confidence info", async () => {
646
+ const result = await confidenceGateHook.execute(
647
+ makeContext(),
648
+ "oh-builder",
649
+ );
650
+ assert.equal(result.result, HookResult.CONTINUE);
651
+ assert.equal(result.modifiedRoute, "oh-builder");
652
+ });
653
+
654
+ it("confidenceGateHook injects with MEDIUM confidence", async () => {
655
+ const result = await confidenceGateHook.execute(
656
+ makeContext({ _confidenceLevel: "MEDIUM", _confidenceExchanges: 0 }),
657
+ "oh-builder",
658
+ );
659
+ assert.equal(result.result, HookResult.INJECT);
660
+ assert.ok(result.modifiedRoute!.includes("echo=confirm"));
661
+ });
662
+
663
+ it("built-in hooks register all in registry", () => {
664
+ const reg = HookRegistry.getInstance();
665
+ reg.registerPreTool(planCheckHook);
666
+ reg.registerPreTool(shellDetectHook);
667
+ reg.registerPreTool(delegationDepthHook);
668
+ reg.registerRoute(confidenceGateHook);
669
+ reg.registerPostTool(errorRecoveryHook);
670
+ reg.registerPostTool(memorySyncHook);
671
+
672
+ assert.equal(reg.getPreToolHooks().length, 3);
673
+ assert.equal(reg.getRouteHooks().length, 1);
674
+ assert.equal(reg.getPostToolHooks().length, 2);
675
+ });
676
+
677
+ it("routeTrackingHook has correct metadata", () => {
678
+ assert.equal(routeTrackingHook.metadata.name, "route-tracking");
679
+ assert.equal(routeTrackingHook.metadata.priority, 55);
680
+ assert.equal(routeTrackingHook.metadata.phase, HookPhase.LATE);
681
+ });
682
+
683
+ it("all built-in hooks execute without throwing", async () => {
684
+ // Verify each built-in hook can execute without unhandled errors
685
+ const ctx = makeContext();
686
+
687
+ // Pre-tool hooks
688
+ await planCheckHook.execute(ctx);
689
+ await shellDetectHook.execute(ctx);
690
+ await delegationDepthHook.execute(ctx);
691
+
692
+ // Route hook
693
+ await confidenceGateHook.execute(ctx, "oh-builder");
694
+
695
+ // Post-tool hooks
696
+ await errorRecoveryHook.execute(ctx, "normal output");
697
+ await memorySyncHook.execute(ctx, "some output");
698
+ // If we got here without throwing, success
699
+ assert.ok(true);
700
+ });
701
+
702
+ describe("route-tracking hook", () => {
703
+ it("tracks a single hop", async () => {
704
+ const ctx = makeContext();
705
+ const result = await routeTrackingHook.execute(ctx, "oh-builder");
706
+ assert.equal(result.result, HookResult.CONTINUE);
707
+ assert.equal(result.modifiedRoute, "oh-builder");
708
+
709
+ const history = getHopHistory(ctx.sessionId);
710
+ assert.equal(history.length, 1);
711
+ assert.equal(history[0].skill, "oh-builder");
712
+ assert.equal(history[0].producedArtifact, false);
713
+ assert.ok(typeof history[0].timestamp === "number");
714
+ });
715
+
716
+ it("stops on 5th hop of the same skill (default max 5)", async () => {
717
+ const ctx = makeContext();
718
+ // 4 passes
719
+ for (let i = 0; i < 4; i++) {
720
+ const result = await routeTrackingHook.execute(ctx, "oh-builder");
721
+ assert.equal(result.result, HookResult.CONTINUE);
722
+ }
723
+ // 5th — should STOP (>= maxSkillRepeats=5)
724
+ const result = await routeTrackingHook.execute(ctx, "oh-builder");
725
+ assert.equal(result.result, HookResult.STOP);
726
+ assert.ok(ctx._optiRoute);
727
+ assert.ok(
728
+ (ctx._optiRoute as Record<string, unknown>).reason as string,
729
+ );
730
+ assert.ok(
731
+ ((ctx._optiRoute as Record<string, unknown>).reason as string).includes(
732
+ "oh-builder",
733
+ ),
734
+ );
735
+ assert.ok(
736
+ ((ctx._optiRoute as Record<string, unknown>).chain as unknown[]).length === 5,
737
+ );
738
+ });
739
+
740
+ it("stops on 8th unproductive hop (default max 8)", async () => {
741
+ const ctx = makeContext();
742
+ const skills = [
743
+ "oh-builder",
744
+ "oh-planner",
745
+ "oh-gauntlet",
746
+ "oh-ship",
747
+ "oh-review",
748
+ "oh-facade",
749
+ "oh-security",
750
+ ];
751
+ // 7 unproductive hops — should pass (>= max 8 triggers)
752
+ for (const skill of skills) {
753
+ const result = await routeTrackingHook.execute(ctx, skill);
754
+ assert.equal(result.result, HookResult.CONTINUE);
755
+ }
756
+ // 8th — should STOP
757
+ const result = await routeTrackingHook.execute(ctx, "oh-builder");
758
+ assert.equal(result.result, HookResult.STOP);
759
+ assert.ok(ctx._optiRoute);
760
+ assert.ok(
761
+ ((ctx._optiRoute as Record<string, unknown>).reason as string).includes(
762
+ "unproductive",
763
+ ),
764
+ );
765
+ });
766
+
767
+ it("productive hop resets unproductive counter", async () => {
768
+ const ctx = makeContext();
769
+
770
+ // 3 unproductive hops
771
+ await routeTrackingHook.execute(ctx, "oh-builder");
772
+ await routeTrackingHook.execute(ctx, "oh-planner");
773
+ await routeTrackingHook.execute(ctx, "oh-gauntlet");
774
+
775
+ // Productive hop via artifactCheck in config
776
+ let checkCount = 0;
777
+ const config = {
778
+ maxSkillRepeats: 5,
779
+ maxUnproductiveHops: 8,
780
+ artifactCheck: (route: string) => {
781
+ checkCount++;
782
+ return route === "oh-review"; // oh-review always produces artifact
783
+ },
784
+ };
785
+ const prodCtx = makeContext({ _routeTrackingConfig: config });
786
+ // Copy the state over by using same sessionId
787
+ prodCtx.sessionId = ctx.sessionId;
788
+
789
+ // This hop should be productive (oh-review returns true from artifactCheck)
790
+ await routeTrackingHook.execute(prodCtx, "oh-review");
791
+
792
+ // From 0 unproductive count after reset — 7 more unproductive passes
793
+ for (let i = 0; i < 7; i++) {
794
+ const result = await routeTrackingHook.execute(prodCtx, `oh-skill-${i}`);
795
+ assert.equal(result.result, HookResult.CONTINUE);
796
+ }
797
+ // 8th unproductive after reset should STOP
798
+ const result = await routeTrackingHook.execute(prodCtx, "oh-final");
799
+ assert.equal(result.result, HookResult.STOP);
800
+ });
801
+
802
+ it("does not track terminal routes (surface, done, oh-handoff)", async () => {
803
+ const ctx = makeContext();
804
+
805
+ await routeTrackingHook.execute(ctx, "surface");
806
+ await routeTrackingHook.execute(ctx, "done");
807
+ await routeTrackingHook.execute(ctx, "oh-handoff");
808
+
809
+ const history = getHopHistory(ctx.sessionId);
810
+ assert.equal(history.length, 0);
811
+ });
812
+
813
+ it("tracks non-terminal routes alongside terminal skips", async () => {
814
+ const ctx = makeContext();
815
+
816
+ await routeTrackingHook.execute(ctx, "surface");
817
+ await routeTrackingHook.execute(ctx, "oh-builder"); // tracked
818
+ await routeTrackingHook.execute(ctx, "done");
819
+ await routeTrackingHook.execute(ctx, "oh-gauntlet"); // tracked
820
+
821
+ const history = getHopHistory(ctx.sessionId);
822
+ assert.equal(history.length, 2);
823
+ assert.equal(history[0].skill, "oh-builder");
824
+ assert.equal(history[1].skill, "oh-gauntlet");
825
+ });
826
+
827
+ it("resetRouteTracker clears all state", async () => {
828
+ const ctx1 = makeContext({ sessionId: "session-a" });
829
+ const ctx2 = makeContext({ sessionId: "session-b" });
830
+
831
+ await routeTrackingHook.execute(ctx1, "oh-builder");
832
+ await routeTrackingHook.execute(ctx2, "oh-planner");
833
+
834
+ assert.equal(getHopHistory("session-a").length, 1);
835
+ assert.equal(getHopHistory("session-b").length, 1);
836
+
837
+ resetRouteTracker();
838
+
839
+ assert.equal(getHopHistory("session-a").length, 0);
840
+ assert.equal(getHopHistory("session-b").length, 0);
841
+ });
842
+
843
+ it("resetRouteTracker clears single session", async () => {
844
+ const ctx1 = makeContext({ sessionId: "session-a" });
845
+ const ctx2 = makeContext({ sessionId: "session-b" });
846
+
847
+ await routeTrackingHook.execute(ctx1, "oh-builder");
848
+ await routeTrackingHook.execute(ctx2, "oh-planner");
849
+
850
+ resetRouteTracker("session-a");
851
+
852
+ assert.equal(getHopHistory("session-a").length, 0);
853
+ assert.equal(getHopHistory("session-b").length, 1);
854
+ });
855
+
856
+ it("configurable via maxSkillRepeats in context", async () => {
857
+ const config = { maxSkillRepeats: 2, maxUnproductiveHops: 10, artifactCheck: () => false };
858
+ const ctx = makeContext({ _routeTrackingConfig: config });
859
+
860
+ const r1 = await routeTrackingHook.execute(ctx, "oh-builder");
861
+ assert.equal(r1.result, HookResult.CONTINUE);
862
+
863
+ const r2 = await routeTrackingHook.execute(ctx, "oh-builder");
864
+ assert.equal(r2.result, HookResult.STOP);
865
+ assert.ok(ctx._optiRoute);
866
+ });
867
+
868
+ it("configurable via maxUnproductiveHops in context", async () => {
869
+ const config = { maxSkillRepeats: 10, maxUnproductiveHops: 3, artifactCheck: () => false };
870
+ const ctx = makeContext({ _routeTrackingConfig: config });
871
+
872
+ await routeTrackingHook.execute(ctx, "oh-builder");
873
+ await routeTrackingHook.execute(ctx, "oh-planner");
874
+ // 3rd unproductive should STOP (>=3)
875
+ const result = await routeTrackingHook.execute(ctx, "oh-gauntlet");
876
+ assert.equal(result.result, HookResult.STOP);
877
+ assert.ok(ctx._optiRoute);
878
+ assert.ok(
879
+ ((ctx._optiRoute as Record<string, unknown>).reason as string).includes(
880
+ "unproductive",
881
+ ),
882
+ );
883
+ });
884
+ });
885
+ });
886
+
887
+ // ---- Integration: Full Hook Lifecycle ----
888
+
889
+ describe("hook lifecycle integration", () => {
890
+ it("executes multiple hook types in phase order", async () => {
891
+ const reg = HookRegistry.getInstance();
892
+ const executionOrder: string[] = [];
893
+
894
+ // Pre-tool: early phase
895
+ reg.registerPreTool(
896
+ makePreToolHook("plan-check", { phase: HookPhase.EARLY, priority: 90 }, async () => {
897
+ executionOrder.push("plan-check");
898
+ return { result: HookResult.CONTINUE };
899
+ }),
900
+ );
901
+
902
+ // Pre-tool: normal phase
903
+ reg.registerPreTool(
904
+ makePreToolHook("delegation-depth", { phase: HookPhase.NORMAL, priority: 60 }, async () => {
905
+ executionOrder.push("delegation-depth");
906
+ return { result: HookResult.CONTINUE };
907
+ }),
908
+ );
909
+
910
+ // Post-tool: late phase
911
+ reg.registerPostTool(
912
+ makePostToolHook("memory-sync", { phase: HookPhase.LATE, priority: 40 }, async () => {
913
+ executionOrder.push("memory-sync");
914
+ return { result: HookResult.CONTINUE };
915
+ }),
916
+ );
917
+
918
+ // Route hook
919
+ reg.registerRoute(
920
+ makeRouteHook("confidence-gate", { phase: HookPhase.NORMAL, priority: 70 }, async () => {
921
+ executionOrder.push("confidence-gate");
922
+ return { result: HookResult.CONTINUE };
923
+ }),
924
+ );
925
+
926
+ // Execute in lifecycle order
927
+ await reg.executePreTool(makeContext());
928
+ await reg.executePostTool(makeContext(), "output");
929
+ await reg.executeRoute(makeContext(), "oh-builder");
930
+
931
+ // Verify phase ordering within each category
932
+ assert.ok(executionOrder.includes("plan-check"));
933
+ assert.ok(executionOrder.includes("delegation-depth"));
934
+ assert.ok(executionOrder.includes("memory-sync"));
935
+ assert.ok(executionOrder.includes("confidence-gate"));
936
+ });
937
+
938
+ it("unregister removes hook from execution", async () => {
939
+ const reg = HookRegistry.getInstance();
940
+ let hookRan = false;
941
+
942
+ reg.registerPreTool(
943
+ makePreToolHook("removable", {}, async () => {
944
+ hookRan = true;
945
+ return { result: HookResult.CONTINUE };
946
+ }),
947
+ );
948
+
949
+ reg.unregister("removable");
950
+ await reg.executePreTool(makeContext());
951
+ assert.equal(hookRan, false);
952
+ });
953
+ });
954
+
955
+ // ---------------------------------------------------------------------------
956
+ // sanityCheckHook
957
+ // ---------------------------------------------------------------------------
958
+
959
+ describe("sanityCheckHook", () => {
960
+ beforeEach(() => {
961
+ AnomalyTracker.getInstance().resetAll();
962
+ });
963
+
964
+ it("passes clean output through unchanged", async () => {
965
+ const ctx = makeContext();
966
+ const result = await sanityCheckHook.execute(
967
+ ctx,
968
+ "Everything is working fine. The system completed the task successfully.",
969
+ );
970
+ assert.equal(result.result, HookResult.CONTINUE);
971
+ assert.equal(result.modifiedOutput, undefined);
972
+ });
973
+
974
+ it("detects repetitive output", async () => {
975
+ const ctx = makeContext();
976
+ const line =
977
+ "Sphinx of black quartz, judge my vow! The five boxing wizards jump quickly. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ";
978
+ const repetitiveOutput = Array.from({ length: 20 }, () => line).join("\n");
979
+ const result = await sanityCheckHook.execute(ctx, repetitiveOutput);
980
+ // First anomaly — not yet escalated
981
+ assert.equal(result.result, HookResult.CONTINUE);
982
+ });
983
+
984
+ it("detects box-drawing character flooding", async () => {
985
+ const ctx = makeContext();
986
+ const boxArt = "─│┌┐└┘├┤┬┴┼".repeat(50);
987
+ const result = await sanityCheckHook.execute(ctx, boxArt);
988
+ assert.equal(result.result, HookResult.CONTINUE);
989
+ });
990
+
991
+ it("detects placeholder patterns", async () => {
992
+ const ctx = makeContext();
993
+ // 50× [PLACEHOLDER] = 650 chars, 11 unique → triggers low_diversity check
994
+ const placeholderText = "[PLACEHOLDER]".repeat(50);
995
+ const result = await sanityCheckHook.execute(ctx, placeholderText);
996
+ assert.equal(result.result, HookResult.CONTINUE);
997
+ });
998
+
999
+ it("tracks anomalies across calls", async () => {
1000
+ const ctx = makeContext({ sessionId: "anomaly-escalation-test" });
1001
+ const line =
1002
+ "Sphinx of black quartz, judge my vow! The five boxing wizards jump quickly. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ";
1003
+ const repetitiveOutput = Array.from({ length: 20 }, () => line).join("\n");
1004
+
1005
+ // First call: anomaly detected but below threshold → CONTINUE
1006
+ const firstResult = await sanityCheckHook.execute(ctx, repetitiveOutput);
1007
+ assert.equal(firstResult.result, HookResult.CONTINUE);
1008
+
1009
+ // Second call: threshold reached → INJECT with recovery
1010
+ const secondResult = await sanityCheckHook.execute(ctx, repetitiveOutput);
1011
+ assert.equal(secondResult.result, HookResult.INJECT);
1012
+ assert.ok(secondResult.injectRecovery);
1013
+ assert.equal(secondResult.modifiedOutput, repetitiveOutput);
1014
+ });
1015
+ });
1016
+ });