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.
- package/CONTEXT.md +10 -1
- package/README.md +54 -42
- package/bootstrap.ts +396 -142
- package/harness/agents/oh-browser.md +97 -0
- package/harness/agents/oh-builder.md +78 -0
- package/harness/agents/oh-facade.md +75 -0
- package/harness/agents/oh-fusion.md +45 -0
- package/harness/agents/oh-gauntlet.md +71 -0
- package/harness/agents/oh-grill.md +71 -0
- package/harness/agents/oh-investigate.md +60 -0
- package/harness/agents/oh-manifest.md +95 -0
- package/harness/agents/oh-plan-review.md +40 -0
- package/harness/agents/oh-planner.md +50 -0
- package/harness/agents/oh-refactor.md +37 -0
- package/harness/agents/oh-retro.md +46 -0
- package/harness/agents/oh-review.md +85 -0
- package/harness/agents/oh-security.md +83 -0
- package/harness/agents/oh-ship.md +76 -0
- package/harness/agents/oh-skill-craft.md +38 -0
- package/harness/agents/openhermes.md +28 -73
- package/harness/codex/AUTOPILOT.md +235 -87
- package/harness/codex/CHARTER.md +80 -0
- package/harness/instructions/SHELL.md +76 -0
- package/harness/lib/background/background.test.ts +197 -0
- package/harness/lib/background/index.ts +7 -0
- package/harness/lib/background/interfaces.ts +31 -0
- package/harness/lib/background/manager.ts +320 -0
- package/harness/lib/composer/compose.test.ts +168 -0
- package/harness/lib/composer/compose.ts +65 -0
- package/harness/lib/composer/fragments/01-identity.md +1 -0
- package/harness/lib/composer/fragments/02-delegation.md +6 -0
- package/harness/lib/composer/fragments/03-permissions.md +13 -0
- package/harness/lib/composer/fragments/04-task-flow.md +15 -0
- package/harness/lib/composer/fragments/05-confidence.md +5 -0
- package/harness/lib/composer/fragments/06-parallelization.md +17 -0
- package/harness/lib/composer/fragments/07-shell.md +41 -0
- package/harness/lib/composer/fragments/08-routing.md +8 -0
- package/harness/lib/composer/fragments/09-guardrails.md +12 -0
- package/harness/lib/composer/index.ts +1 -0
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +70 -0
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +59 -0
- package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
- package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
- package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +147 -0
- package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
- package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
- package/harness/lib/hooks/hooks.test.ts +1016 -0
- package/harness/lib/hooks/index.ts +30 -0
- package/harness/lib/hooks/registry.ts +416 -0
- package/harness/lib/hooks/types.ts +71 -0
- package/harness/lib/memory/index.ts +18 -0
- package/harness/lib/memory/interfaces.ts +53 -0
- package/harness/lib/memory/memory-manager.ts +205 -0
- package/harness/lib/memory/memory.test.ts +491 -0
- package/harness/lib/memory/plan-store.ts +366 -0
- package/harness/lib/recovery/handler.ts +243 -0
- package/harness/lib/recovery/index.ts +14 -0
- package/harness/lib/recovery/interfaces.ts +48 -0
- package/harness/lib/recovery/patterns.ts +149 -0
- package/harness/lib/recovery/recovery.test.ts +312 -0
- package/harness/lib/sanity/anomaly-tracker.ts +127 -0
- package/harness/lib/sanity/checker.ts +178 -0
- package/harness/lib/sanity/index.ts +13 -0
- package/harness/lib/sanity/interfaces.ts +24 -0
- package/harness/lib/sanity/sanity.test.ts +472 -0
- package/harness/lib/sync/file-watcher.ts +174 -0
- package/harness/lib/sync/index.ts +11 -0
- package/harness/lib/sync/interfaces.ts +27 -0
- package/harness/lib/sync/plan-sync.ts +536 -0
- package/harness/lib/sync/sync.test.ts +832 -0
- package/harness/skills/oh-ascii/DEEP.md +292 -0
- package/harness/skills/oh-ascii/SKILL.md +31 -0
- package/harness/skills/oh-ascii/scripts/check_ascii_alignment.py +596 -0
- package/harness/skills/oh-browser/DEEP.md +54 -0
- package/harness/skills/oh-browser/SKILL.md +30 -0
- package/harness/skills/oh-builder/DEEP.md +63 -0
- package/harness/skills/oh-builder/SKILL.md +12 -90
- package/harness/skills/oh-expert/DEEP.md +85 -0
- package/harness/skills/oh-expert/SKILL.md +13 -106
- package/harness/skills/oh-facade/DEEP.md +182 -0
- package/harness/skills/oh-facade/SKILL.md +15 -279
- package/harness/skills/oh-freeze/DEEP.md +18 -0
- package/harness/skills/oh-freeze/SKILL.md +10 -19
- package/harness/skills/oh-full-output/DEEP.md +25 -0
- package/harness/skills/oh-full-output/SKILL.md +12 -65
- package/harness/skills/oh-fusion/DEEP.md +120 -0
- package/harness/skills/oh-fusion/SKILL.md +17 -295
- package/harness/skills/oh-gauntlet/DEEP.md +77 -0
- package/harness/skills/oh-gauntlet/SKILL.md +13 -105
- package/harness/skills/oh-grill/DEEP.md +51 -0
- package/harness/skills/oh-grill/SKILL.md +12 -63
- package/harness/skills/oh-guard/DEEP.md +19 -0
- package/harness/skills/oh-guard/SKILL.md +10 -24
- package/harness/skills/oh-handoff/DEEP.md +48 -0
- package/harness/skills/oh-handoff/SKILL.md +13 -23
- package/harness/skills/oh-health/DEEP.md +74 -0
- package/harness/skills/oh-health/SKILL.md +13 -76
- package/harness/skills/oh-init/DEEP.md +85 -0
- package/harness/skills/oh-init/SKILL.md +13 -127
- package/harness/skills/oh-investigate/DEEP.md +171 -0
- package/harness/skills/oh-investigate/SKILL.md +13 -66
- package/harness/skills/oh-issue/DEEP.md +21 -0
- package/harness/skills/oh-issue/SKILL.md +11 -27
- package/harness/skills/oh-manifest/DEEP.md +92 -0
- package/harness/skills/oh-manifest/SKILL.md +12 -109
- package/harness/skills/oh-plan-review/DEEP.md +90 -0
- package/harness/skills/oh-plan-review/SKILL.md +13 -115
- package/harness/skills/oh-planner/DEEP.md +172 -0
- package/harness/skills/oh-planner/SKILL.md +12 -149
- package/harness/skills/oh-prd/DEEP.md +45 -0
- package/harness/skills/oh-prd/SKILL.md +10 -26
- package/harness/skills/oh-refactor/DEEP.md +122 -0
- package/harness/skills/oh-refactor/SKILL.md +17 -410
- package/harness/skills/oh-retro/DEEP.md +26 -0
- package/harness/skills/oh-retro/SKILL.md +12 -24
- package/harness/skills/oh-review/DEEP.md +87 -0
- package/harness/skills/oh-review/SKILL.md +11 -97
- package/harness/skills/oh-security/DEEP.md +83 -0
- package/harness/skills/oh-security/SKILL.md +14 -96
- package/harness/skills/oh-ship/DEEP.md +141 -0
- package/harness/skills/oh-ship/SKILL.md +14 -32
- package/harness/skills/oh-skill-craft/DEEP.md +369 -0
- package/harness/skills/oh-skill-craft/SKILL.md +13 -177
- package/harness/skills/oh-skills-link/DEEP.md +16 -0
- package/harness/skills/oh-skills-link/SKILL.md +10 -20
- package/harness/skills/oh-skills-list/DEEP.md +20 -0
- package/harness/skills/oh-skills-list/SKILL.md +9 -22
- package/harness/skills/oh-triage/DEEP.md +23 -0
- package/harness/skills/oh-triage/SKILL.md +8 -24
- package/harness/skills/oh-worktree/DEEP.md +169 -0
- package/harness/skills/oh-worktree/SKILL.md +32 -0
- package/lib/harness-resolver.ts +8 -10
- package/package.json +7 -5
- package/tsconfig.json +1 -1
- package/harness/codex/CONSTITUTION.md +0 -73
- package/harness/codex/ROUTING.md +0 -92
- package/harness/commands/oh-doctor.md +0 -26
- package/harness/commands/oh-log.md +0 -18
- package/harness/instructions/RUNTIME.md +0 -30
- package/harness/skills/oh-caveman/SKILL.md +0 -42
- package/harness/skills/oh-learn/SKILL.md +0 -101
- 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
|
+
});
|