openhermes 4.11.2 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +1 -1
- package/ETHOS.md +1 -1
- package/README.md +12 -18
- package/bootstrap.ts +73 -148
- package/docs/HOW-IT-WORKS.md +162 -0
- package/docs/adr/ADR-0001-rebuild-vs-increment.md +30 -0
- package/docs/adr/ADR-0002-routing-graph-vs-linear-chain.md +36 -0
- package/docs/adr/ADR-0003-per-directory-plan-storage.md +34 -0
- package/docs/adr/ADR-0004-composer-fragment-architecture.md +42 -0
- package/docs/adr/ADR-0005-hook-system-design.md +42 -0
- package/docs/adr/README.md +9 -0
- package/harness/codex/AUTOPILOT.md +30 -23
- package/harness/codex/CHARTER.md +3 -3
- package/harness/lib/composer/compose.test.ts +11 -0
- package/harness/lib/composer/fragments/02-delegation.md +2 -1
- package/harness/lib/composer/fragments/04-task-flow.md +42 -2
- package/harness/lib/composer/fragments/08-routing.md +1 -1
- package/harness/lib/composer/fragments/09-guardrails.md +17 -4
- package/harness/lib/composer/index.ts +1 -1
- package/harness/lib/guards/guard-config.ts +72 -0
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +2 -4
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +23 -4
- package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -0
- package/harness/lib/hooks/builtins/next-route-hook.ts +24 -0
- package/harness/lib/hooks/builtins/plan-check-hook.ts +2 -2
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +79 -25
- package/harness/lib/hooks/hooks.test.ts +117 -205
- package/harness/lib/hooks/index.ts +38 -30
- package/harness/lib/hooks/registry.ts +309 -416
- package/harness/lib/hooks/types.ts +116 -71
- package/harness/lib/plans/plan-location.ts +134 -0
- package/harness/lib/routing/index.ts +21 -0
- package/harness/lib/routing/route-guidance.ts +147 -0
- package/harness/lib/routing/route-resolver.ts +58 -0
- package/harness/lib/routing/routing.test.ts +195 -0
- package/harness/lib/routing/skill-frontmatter.ts +125 -0
- package/harness/lib/routing/types.ts +52 -0
- package/harness/skills/oh-ascii/SKILL.md +1 -1
- package/harness/skills/oh-fusion/DEEP.md +56 -33
- package/harness/skills/oh-fusion/SKILL.md +30 -16
- package/harness/skills/oh-init/DEEP.md +2 -2
- package/harness/skills/oh-manifest/SKILL.md +1 -0
- package/harness/skills/oh-plan-review/DEEP.md +1 -1
- package/harness/skills/oh-planner/DEEP.md +3 -3
- package/harness/skills/oh-review/DEEP.md +2 -0
- package/harness/skills/oh-review/SKILL.md +1 -0
- package/package.json +56 -55
- package/harness/lib/background/background.test.ts +0 -197
- package/harness/lib/background/index.ts +0 -7
- package/harness/lib/background/interfaces.ts +0 -31
- package/harness/lib/background/manager.ts +0 -320
- package/harness/lib/hooks/builtins/error-recovery-hook.ts +0 -107
- package/harness/lib/hooks/builtins/memory-sync-hook.ts +0 -73
- package/harness/lib/hooks/builtins/sanity-check-hook.ts +0 -52
- package/harness/lib/memory/index.ts +0 -18
- package/harness/lib/memory/interfaces.ts +0 -53
- package/harness/lib/memory/memory-manager.ts +0 -205
- package/harness/lib/memory/memory.test.ts +0 -491
- package/harness/lib/memory/plan-store.ts +0 -366
- package/harness/lib/recovery/handler.ts +0 -243
- package/harness/lib/recovery/index.ts +0 -14
- package/harness/lib/recovery/interfaces.ts +0 -48
- package/harness/lib/recovery/patterns.ts +0 -149
- package/harness/lib/recovery/recovery.test.ts +0 -312
- package/harness/lib/sanity/anomaly-tracker.ts +0 -127
- package/harness/lib/sanity/checker.ts +0 -178
- package/harness/lib/sanity/index.ts +0 -13
- package/harness/lib/sanity/interfaces.ts +0 -24
- package/harness/lib/sanity/sanity.test.ts +0 -472
- package/harness/lib/sync/file-watcher.ts +0 -174
- package/harness/lib/sync/index.ts +0 -11
- package/harness/lib/sync/interfaces.ts +0 -27
- package/harness/lib/sync/plan-sync.ts +0 -536
- package/harness/lib/sync/sync.test.ts +0 -832
|
@@ -1,416 +1,309 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// HookRegistry — singleton pluggable hook system with topological sort
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
|
|
5
|
-
import type {
|
|
6
|
-
HookContext,
|
|
7
|
-
HookMetadata,
|
|
8
|
-
PreToolUseHook,
|
|
9
|
-
PostToolUseHook,
|
|
10
|
-
RouteHook,
|
|
11
|
-
SessionHook,
|
|
12
|
-
AnyHook,
|
|
13
|
-
} from "./types.ts";
|
|
14
|
-
import { HookPhase, HookResult } from "./types.ts";
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Registry
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
export class HookRegistry {
|
|
21
|
-
private static instance: HookRegistry;
|
|
22
|
-
|
|
23
|
-
private preToolHooks: PreToolUseHook[] = [];
|
|
24
|
-
private postToolHooks: PostToolUseHook[] = [];
|
|
25
|
-
private routeHooks: RouteHook[] = [];
|
|
26
|
-
private sessionHooks: SessionHook[] = [];
|
|
27
|
-
|
|
28
|
-
private constructor() {}
|
|
29
|
-
|
|
30
|
-
/** Get the singleton instance. */
|
|
31
|
-
static getInstance(): HookRegistry {
|
|
32
|
-
if (!HookRegistry.instance) {
|
|
33
|
-
HookRegistry.instance = new HookRegistry();
|
|
34
|
-
}
|
|
35
|
-
return HookRegistry.instance;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Reset singleton — used in tests for isolation. */
|
|
39
|
-
static resetInstance(): void {
|
|
40
|
-
HookRegistry.instance = null
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// -----------------------------------------------------------------------
|
|
44
|
-
// Registration
|
|
45
|
-
// -----------------------------------------------------------------------
|
|
46
|
-
|
|
47
|
-
registerPreTool(hook: PreToolUseHook): boolean {
|
|
48
|
-
if (!this.assertNoNameConflict(hook.metadata.name, this.preToolHooks)) {
|
|
49
|
-
return false; // already registered, skip silently
|
|
50
|
-
}
|
|
51
|
-
this.preToolHooks.push(hook);
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
registerPostTool(hook: PostToolUseHook): boolean {
|
|
56
|
-
if (!this.assertNoNameConflict(hook.metadata.name, this.postToolHooks)) {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
this.postToolHooks.push(hook);
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
registerRoute(hook: RouteHook): boolean {
|
|
64
|
-
if (!this.assertNoNameConflict(hook.metadata.name, this.routeHooks)) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
this.routeHooks.push(hook);
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
registerSession(hook: SessionHook): boolean {
|
|
72
|
-
if (!this.assertNoNameConflict(hook.metadata.name, this.sessionHooks)) {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
this.sessionHooks.push(hook);
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Unregister a hook by name from ALL categories.
|
|
81
|
-
* Returns true if found and removed, false if not found.
|
|
82
|
-
*/
|
|
83
|
-
unregister(name: string): boolean {
|
|
84
|
-
let removed = false;
|
|
85
|
-
|
|
86
|
-
const preIdx = this.preToolHooks.findIndex((h) => h.metadata.name === name);
|
|
87
|
-
if (preIdx >= 0) {
|
|
88
|
-
this.preToolHooks.splice(preIdx, 1);
|
|
89
|
-
removed = true;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const postIdx = this.postToolHooks.findIndex(
|
|
93
|
-
(h) => h.metadata.name === name,
|
|
94
|
-
);
|
|
95
|
-
if (postIdx >= 0) {
|
|
96
|
-
this.postToolHooks.splice(postIdx, 1);
|
|
97
|
-
removed = true;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const routeIdx = this.routeHooks.findIndex(
|
|
101
|
-
(h) => h.metadata.name === name,
|
|
102
|
-
);
|
|
103
|
-
if (routeIdx >= 0) {
|
|
104
|
-
this.routeHooks.splice(routeIdx, 1);
|
|
105
|
-
removed = true;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const sessIdx = this.sessionHooks.findIndex(
|
|
109
|
-
(h) => h.metadata.name === name,
|
|
110
|
-
);
|
|
111
|
-
if (sessIdx >= 0) {
|
|
112
|
-
this.sessionHooks.splice(sessIdx, 1);
|
|
113
|
-
removed = true;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return removed;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Check if a hook with the given name is already registered in the target list.
|
|
121
|
-
* Returns true if the name is available (no conflict), false if already registered.
|
|
122
|
-
* Does NOT throw — silently skips duplicates to support multiple bootstrap calls.
|
|
123
|
-
*/
|
|
124
|
-
private assertNoNameConflict(
|
|
125
|
-
name: string,
|
|
126
|
-
list: { metadata: HookMetadata }[],
|
|
127
|
-
): boolean {
|
|
128
|
-
return !list.some((h) => h.metadata.name === name);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// -----------------------------------------------------------------------
|
|
132
|
-
// Execution
|
|
133
|
-
// -----------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Execute all PreToolUse hooks in sorted order.
|
|
137
|
-
* Stops execution only on STOP. INJECT signals context modification
|
|
138
|
-
* but does not short-circuit subsequent hooks.
|
|
139
|
-
*/
|
|
140
|
-
async executePreTool(
|
|
141
|
-
context: HookContext,
|
|
142
|
-
): Promise<{ result: HookResult; modifiedContext?:
|
|
143
|
-
const sorted = this.topologicalSort(this.preToolHooks);
|
|
144
|
-
let currentContext = context;
|
|
145
|
-
let hasInjection = false;
|
|
146
|
-
|
|
147
|
-
for (const hook of sorted) {
|
|
148
|
-
const result = await hook.execute(currentContext);
|
|
149
|
-
if (result.modifiedContext) {
|
|
150
|
-
currentContext = { ...currentContext, ...result.modifiedContext };
|
|
151
|
-
}
|
|
152
|
-
if (result.result === HookResult.STOP) {
|
|
153
|
-
return { result: HookResult.STOP, modifiedContext: currentContext };
|
|
154
|
-
}
|
|
155
|
-
if (result.result === HookResult.INJECT) {
|
|
156
|
-
hasInjection = true;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
result: hasInjection ? HookResult.INJECT : HookResult.CONTINUE,
|
|
162
|
-
modifiedContext: currentContext,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Execute all PostToolUse hooks in sorted order.
|
|
168
|
-
* Accumulates output modifications across hooks.
|
|
169
|
-
* INJECT does not short-circuit — all hooks run and the result is aggregated.
|
|
170
|
-
*/
|
|
171
|
-
async executePostTool(
|
|
172
|
-
context: HookContext,
|
|
173
|
-
output: string,
|
|
174
|
-
): Promise<{
|
|
175
|
-
result: HookResult;
|
|
176
|
-
modifiedOutput?: string;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
let
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
// Build name-to-hook map for lookup
|
|
311
|
-
const nameToHook = new Map<string, T>();
|
|
312
|
-
for (const hook of hooks) {
|
|
313
|
-
nameToHook.set(hook.metadata.name, hook);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Separate hooks by phase, keeping only those in the input set
|
|
317
|
-
const phaseGroups = new Map<HookPhase, T[]>();
|
|
318
|
-
for (const hook of hooks) {
|
|
319
|
-
const phase = hook.metadata.phase;
|
|
320
|
-
if (!phaseGroups.has(phase)) phaseGroups.set(phase, []);
|
|
321
|
-
phaseGroups.get(phase)!.push(hook);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const result: T[] = [];
|
|
325
|
-
|
|
326
|
-
// Process phases in order: EARLY, NORMAL, LATE
|
|
327
|
-
for (const phase of [HookPhase.EARLY, HookPhase.NORMAL, HookPhase.LATE]) {
|
|
328
|
-
const phaseHooks = phaseGroups.get(phase);
|
|
329
|
-
if (!phaseHooks || phaseHooks.length === 0) continue;
|
|
330
|
-
|
|
331
|
-
// Topologically sort within this phase
|
|
332
|
-
const sorted = this.kahnSort(phaseHooks, nameToHook);
|
|
333
|
-
result.push(...sorted);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return result;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Kahn's algorithm for topological sorting within a phase group.
|
|
341
|
-
* Dependencies are only considered within the hooks passed in.
|
|
342
|
-
*/
|
|
343
|
-
private kahnSort<T extends { metadata: HookMetadata }>(
|
|
344
|
-
hooks: T[],
|
|
345
|
-
_allHooks: Map<string, T>,
|
|
346
|
-
): T[] {
|
|
347
|
-
const nameToHook = new Map<string, T>();
|
|
348
|
-
for (const hook of hooks) {
|
|
349
|
-
nameToHook.set(hook.metadata.name, hook);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Build adjacency list and in-degree map
|
|
353
|
-
const inDegree = new Map<string, number>();
|
|
354
|
-
const adj = new Map<string, string[]>();
|
|
355
|
-
|
|
356
|
-
for (const hook of hooks) {
|
|
357
|
-
const name = hook.metadata.name;
|
|
358
|
-
if (!inDegree.has(name)) inDegree.set(name, 0);
|
|
359
|
-
if (!adj.has(name)) adj.set(name, []);
|
|
360
|
-
|
|
361
|
-
for (const dep of hook.metadata.dependencies) {
|
|
362
|
-
// Only consider dependencies within this phase group
|
|
363
|
-
if (!nameToHook.has(dep)) continue;
|
|
364
|
-
|
|
365
|
-
if (!adj.has(dep)) adj.set(dep, []);
|
|
366
|
-
adj.get(dep)!.push(name);
|
|
367
|
-
inDegree.set(name, (inDegree.get(name) ?? 0) + 1);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Seed queue with zero in-degree nodes, sorted by priority DESC for determinism
|
|
372
|
-
const queue: string[] = [];
|
|
373
|
-
for (const [name, degree] of inDegree) {
|
|
374
|
-
if (degree === 0) queue.push(name);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const sorted: T[] = [];
|
|
378
|
-
|
|
379
|
-
while (queue.length > 0) {
|
|
380
|
-
// Sort by priority DESC for deterministic output
|
|
381
|
-
queue.sort((a, b) => {
|
|
382
|
-
const pa = nameToHook.get(a)?.metadata.priority ?? 0;
|
|
383
|
-
const pb = nameToHook.get(b)?.metadata.priority ?? 0;
|
|
384
|
-
if (pb !== pa) return pb - pa;
|
|
385
|
-
// Tie-break by name for stability
|
|
386
|
-
return a.localeCompare(b);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
const name = queue.shift()!;
|
|
390
|
-
const hook = nameToHook.get(name)!;
|
|
391
|
-
sorted.push(hook);
|
|
392
|
-
|
|
393
|
-
for (const neighbor of adj.get(name) ?? []) {
|
|
394
|
-
const currentDegree = inDegree.get(neighbor) ?? 1;
|
|
395
|
-
const newDegree = currentDegree - 1;
|
|
396
|
-
inDegree.set(neighbor, newDegree);
|
|
397
|
-
if (newDegree === 0) {
|
|
398
|
-
queue.push(neighbor);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Cycle detection
|
|
404
|
-
if (sorted.length !== hooks.length) {
|
|
405
|
-
const sortedNames = new Set(sorted.map((h) => h.metadata.name));
|
|
406
|
-
const unsorted = hooks
|
|
407
|
-
.filter((h) => !sortedNames.has(h.metadata.name))
|
|
408
|
-
.map((h) => h.metadata.name);
|
|
409
|
-
throw new Error(
|
|
410
|
-
`Circular dependency detected: ${unsorted.join(" → ")}`,
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return sorted;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// HookRegistry — singleton pluggable hook system with topological sort
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
HookContext,
|
|
7
|
+
HookMetadata,
|
|
8
|
+
PreToolUseHook,
|
|
9
|
+
PostToolUseHook,
|
|
10
|
+
RouteHook,
|
|
11
|
+
SessionHook,
|
|
12
|
+
AnyHook,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
import { HookPhase, HookResult } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Registry
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export class HookRegistry {
|
|
21
|
+
private static instance: HookRegistry | null = null;
|
|
22
|
+
|
|
23
|
+
private preToolHooks: PreToolUseHook[] = [];
|
|
24
|
+
private postToolHooks: PostToolUseHook[] = [];
|
|
25
|
+
private routeHooks: RouteHook[] = [];
|
|
26
|
+
private sessionHooks: SessionHook[] = [];
|
|
27
|
+
|
|
28
|
+
private constructor() {}
|
|
29
|
+
|
|
30
|
+
/** Get the singleton instance. */
|
|
31
|
+
static getInstance(): HookRegistry {
|
|
32
|
+
if (!HookRegistry.instance) {
|
|
33
|
+
HookRegistry.instance = new HookRegistry();
|
|
34
|
+
}
|
|
35
|
+
return HookRegistry.instance;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Reset singleton — used in tests for isolation. */
|
|
39
|
+
static resetInstance(): void {
|
|
40
|
+
HookRegistry.instance = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// -----------------------------------------------------------------------
|
|
44
|
+
// Registration
|
|
45
|
+
// -----------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
registerPreTool(hook: PreToolUseHook): boolean {
|
|
48
|
+
if (!this.assertNoNameConflict(hook.metadata.name, this.preToolHooks)) {
|
|
49
|
+
return false; // already registered, skip silently
|
|
50
|
+
}
|
|
51
|
+
this.preToolHooks.push(hook);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
registerPostTool(hook: PostToolUseHook): boolean {
|
|
56
|
+
if (!this.assertNoNameConflict(hook.metadata.name, this.postToolHooks)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
this.postToolHooks.push(hook);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
registerRoute(hook: RouteHook): boolean {
|
|
64
|
+
if (!this.assertNoNameConflict(hook.metadata.name, this.routeHooks)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
this.routeHooks.push(hook);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
registerSession(hook: SessionHook): boolean {
|
|
72
|
+
if (!this.assertNoNameConflict(hook.metadata.name, this.sessionHooks)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
this.sessionHooks.push(hook);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Unregister a hook by name from ALL categories.
|
|
81
|
+
* Returns true if found and removed, false if not found.
|
|
82
|
+
*/
|
|
83
|
+
unregister(name: string): boolean {
|
|
84
|
+
let removed = false;
|
|
85
|
+
|
|
86
|
+
const preIdx = this.preToolHooks.findIndex((h) => h.metadata.name === name);
|
|
87
|
+
if (preIdx >= 0) {
|
|
88
|
+
this.preToolHooks.splice(preIdx, 1);
|
|
89
|
+
removed = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const postIdx = this.postToolHooks.findIndex(
|
|
93
|
+
(h) => h.metadata.name === name,
|
|
94
|
+
);
|
|
95
|
+
if (postIdx >= 0) {
|
|
96
|
+
this.postToolHooks.splice(postIdx, 1);
|
|
97
|
+
removed = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const routeIdx = this.routeHooks.findIndex(
|
|
101
|
+
(h) => h.metadata.name === name,
|
|
102
|
+
);
|
|
103
|
+
if (routeIdx >= 0) {
|
|
104
|
+
this.routeHooks.splice(routeIdx, 1);
|
|
105
|
+
removed = true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sessIdx = this.sessionHooks.findIndex(
|
|
109
|
+
(h) => h.metadata.name === name,
|
|
110
|
+
);
|
|
111
|
+
if (sessIdx >= 0) {
|
|
112
|
+
this.sessionHooks.splice(sessIdx, 1);
|
|
113
|
+
removed = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return removed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if a hook with the given name is already registered in the target list.
|
|
121
|
+
* Returns true if the name is available (no conflict), false if already registered.
|
|
122
|
+
* Does NOT throw — silently skips duplicates to support multiple bootstrap calls.
|
|
123
|
+
*/
|
|
124
|
+
private assertNoNameConflict(
|
|
125
|
+
name: string,
|
|
126
|
+
list: { metadata: HookMetadata }[],
|
|
127
|
+
): boolean {
|
|
128
|
+
return !list.some((h) => h.metadata.name === name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// -----------------------------------------------------------------------
|
|
132
|
+
// Execution
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Execute all PreToolUse hooks in sorted order.
|
|
137
|
+
* Stops execution only on STOP. INJECT signals context modification
|
|
138
|
+
* but does not short-circuit subsequent hooks.
|
|
139
|
+
*/
|
|
140
|
+
async executePreTool(
|
|
141
|
+
context: HookContext,
|
|
142
|
+
): Promise<{ result: HookResult; modifiedContext?: HookContext }> {
|
|
143
|
+
const sorted = this.topologicalSort(this.preToolHooks);
|
|
144
|
+
let currentContext = context;
|
|
145
|
+
let hasInjection = false;
|
|
146
|
+
|
|
147
|
+
for (const hook of sorted) {
|
|
148
|
+
const result = await hook.execute(currentContext);
|
|
149
|
+
if (result.modifiedContext) {
|
|
150
|
+
currentContext = { ...currentContext, ...result.modifiedContext };
|
|
151
|
+
}
|
|
152
|
+
if (result.result === HookResult.STOP) {
|
|
153
|
+
return { result: HookResult.STOP, modifiedContext: currentContext };
|
|
154
|
+
}
|
|
155
|
+
if (result.result === HookResult.INJECT) {
|
|
156
|
+
hasInjection = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
result: hasInjection ? HookResult.INJECT : HookResult.CONTINUE,
|
|
162
|
+
modifiedContext: currentContext,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Execute all PostToolUse hooks in sorted order.
|
|
168
|
+
* Accumulates output modifications across hooks.
|
|
169
|
+
* INJECT does not short-circuit — all hooks run and the result is aggregated.
|
|
170
|
+
*/
|
|
171
|
+
async executePostTool(
|
|
172
|
+
context: HookContext,
|
|
173
|
+
output: string,
|
|
174
|
+
): Promise<{
|
|
175
|
+
result: HookResult;
|
|
176
|
+
modifiedOutput?: string;
|
|
177
|
+
}> {
|
|
178
|
+
const sorted = this.topologicalSort(this.postToolHooks);
|
|
179
|
+
let currentOutput = output;
|
|
180
|
+
let hasInjection = false;
|
|
181
|
+
|
|
182
|
+
for (const hook of sorted) {
|
|
183
|
+
const result = await hook.execute(context, currentOutput);
|
|
184
|
+
if (result.modifiedOutput !== undefined) {
|
|
185
|
+
currentOutput = result.modifiedOutput;
|
|
186
|
+
}
|
|
187
|
+
if (result.result === HookResult.STOP) {
|
|
188
|
+
return {
|
|
189
|
+
result: HookResult.STOP,
|
|
190
|
+
modifiedOutput: currentOutput,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (result.result === HookResult.INJECT) {
|
|
194
|
+
hasInjection = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
result: hasInjection ? HookResult.INJECT : HookResult.CONTINUE,
|
|
200
|
+
modifiedOutput: currentOutput,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Execute all Route hooks in sorted order.
|
|
206
|
+
* Each hook can modify the route destination.
|
|
207
|
+
* INJECT does not short-circuit — all hooks run and the result is aggregated.
|
|
208
|
+
*/
|
|
209
|
+
async executeRoute(
|
|
210
|
+
context: HookContext,
|
|
211
|
+
route: string,
|
|
212
|
+
): Promise<{ result: HookResult; modifiedRoute?: string }> {
|
|
213
|
+
const sorted = this.topologicalSort(this.routeHooks);
|
|
214
|
+
let currentRoute = route;
|
|
215
|
+
let hasInjection = false;
|
|
216
|
+
|
|
217
|
+
for (const hook of sorted) {
|
|
218
|
+
const result = await hook.execute(context, currentRoute);
|
|
219
|
+
if (result.modifiedRoute !== undefined) {
|
|
220
|
+
currentRoute = result.modifiedRoute;
|
|
221
|
+
}
|
|
222
|
+
if (result.result === HookResult.STOP) {
|
|
223
|
+
return { result: HookResult.STOP, modifiedRoute: currentRoute };
|
|
224
|
+
}
|
|
225
|
+
if (result.result === HookResult.INJECT) {
|
|
226
|
+
hasInjection = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
result: hasInjection ? HookResult.INJECT : HookResult.CONTINUE,
|
|
232
|
+
modifiedRoute: currentRoute,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Execute onSessionStart for all Session hooks in sorted order.
|
|
238
|
+
*/
|
|
239
|
+
async executeSessionStart(context: HookContext): Promise<void> {
|
|
240
|
+
const sorted = this.topologicalSort(this.sessionHooks);
|
|
241
|
+
for (const hook of sorted) {
|
|
242
|
+
await hook.onSessionStart(context);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Execute onSessionEnd for all Session hooks in sorted order.
|
|
248
|
+
*/
|
|
249
|
+
async executeSessionEnd(context: HookContext): Promise<void> {
|
|
250
|
+
const sorted = this.topologicalSort(this.sessionHooks);
|
|
251
|
+
for (const hook of sorted) {
|
|
252
|
+
await hook.onSessionEnd(context);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// -----------------------------------------------------------------------
|
|
257
|
+
// Accessors (for testing)
|
|
258
|
+
// -----------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
getPreToolHooks(): PreToolUseHook[] {
|
|
261
|
+
return [...this.preToolHooks];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getPostToolHooks(): PostToolUseHook[] {
|
|
265
|
+
return [...this.postToolHooks];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
getRouteHooks(): RouteHook[] {
|
|
269
|
+
return [...this.routeHooks];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
getSessionHooks(): SessionHook[] {
|
|
273
|
+
return [...this.sessionHooks];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
getHookByName(name: string): AnyHook | undefined {
|
|
277
|
+
return (
|
|
278
|
+
this.preToolHooks.find((h) => h.metadata.name === name) ??
|
|
279
|
+
this.postToolHooks.find((h) => h.metadata.name === name) ??
|
|
280
|
+
this.routeHooks.find((h) => h.metadata.name === name) ??
|
|
281
|
+
this.sessionHooks.find((h) => h.metadata.name === name)
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// -----------------------------------------------------------------------
|
|
286
|
+
// Priority Sort
|
|
287
|
+
// -----------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Sort hooks by phase order (EARLY → NORMAL → LATE), then priority DESC.
|
|
291
|
+
* Dependencies are ignored — all current built-in hooks declare no dependencies,
|
|
292
|
+
* so the simpler sort is sufficient and the Kahn complexity is unnecessary.
|
|
293
|
+
*/
|
|
294
|
+
topologicalSort<T extends { metadata: HookMetadata }>(hooks: T[]): T[] {
|
|
295
|
+
if (hooks.length === 0) return [];
|
|
296
|
+
|
|
297
|
+
const phaseOrder: Record<string, number> = {
|
|
298
|
+
[HookPhase.EARLY]: 0,
|
|
299
|
+
[HookPhase.NORMAL]: 1,
|
|
300
|
+
[HookPhase.LATE]: 2,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return [...hooks].sort((a, b) => {
|
|
304
|
+
const phaseDiff = (phaseOrder[a.metadata.phase] ?? 1) - (phaseOrder[b.metadata.phase] ?? 1);
|
|
305
|
+
if (phaseDiff !== 0) return phaseDiff;
|
|
306
|
+
return (b.metadata.priority ?? 0) - (a.metadata.priority ?? 0);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|