openhermes 4.11.2 → 4.12.1

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 (46) hide show
  1. package/CONTEXT.md +6 -6
  2. package/ETHOS.md +2 -2
  3. package/README.md +8 -8
  4. package/bootstrap.ts +131 -198
  5. package/harness/codex/AUTOPILOT.md +39 -27
  6. package/harness/codex/CHARTER.md +1 -1
  7. package/harness/lib/background/background.test.ts +24 -5
  8. package/harness/lib/background/manager.ts +9 -9
  9. package/harness/lib/composer/compose.test.ts +29 -18
  10. package/harness/lib/composer/fragments/02-delegation.md +5 -4
  11. package/harness/lib/composer/fragments/04-task-flow.md +43 -3
  12. package/harness/lib/composer/fragments/09-guardrails.md +25 -12
  13. package/harness/lib/guards/guard-config.ts +72 -0
  14. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +9 -11
  15. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +24 -5
  16. package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -0
  17. package/harness/lib/hooks/builtins/error-recovery-hook.ts +7 -7
  18. package/harness/lib/hooks/builtins/memory-sync-hook.ts +2 -2
  19. package/harness/lib/hooks/builtins/next-route-hook.ts +24 -0
  20. package/harness/lib/hooks/builtins/plan-check-hook.ts +5 -5
  21. package/harness/lib/hooks/builtins/route-tracking-hook.ts +80 -26
  22. package/harness/lib/hooks/builtins/subagent-failure-hook.ts +93 -0
  23. package/harness/lib/hooks/hooks.test.ts +145 -69
  24. package/harness/lib/hooks/index.ts +12 -0
  25. package/harness/lib/hooks/registry.ts +3 -3
  26. package/harness/lib/hooks/types.ts +50 -2
  27. package/harness/lib/memory/memory-manager.ts +2 -2
  28. package/harness/lib/memory/memory.test.ts +0 -6
  29. package/harness/lib/memory/plan-store.ts +1 -21
  30. package/harness/lib/plans/plan-location.ts +134 -0
  31. package/harness/lib/routing/index.ts +21 -0
  32. package/harness/lib/routing/route-guidance.ts +147 -0
  33. package/harness/lib/routing/route-resolver.ts +58 -0
  34. package/harness/lib/routing/routing.test.ts +195 -0
  35. package/harness/lib/routing/skill-frontmatter.ts +125 -0
  36. package/harness/lib/routing/types.ts +52 -0
  37. package/harness/lib/sanity/checker.ts +45 -34
  38. package/harness/lib/sync/file-watcher.ts +26 -25
  39. package/harness/lib/sync/plan-sync.ts +22 -25
  40. package/harness/lib/sync/sync.test.ts +30 -4
  41. package/harness/skills/oh-fusion/DEEP.md +109 -86
  42. package/harness/skills/oh-fusion/SKILL.md +47 -33
  43. package/harness/skills/oh-manifest/SKILL.md +1 -0
  44. package/harness/skills/oh-review/DEEP.md +5 -3
  45. package/harness/skills/oh-review/SKILL.md +1 -0
  46. package/package.json +53 -55
@@ -28,13 +28,13 @@ export const errorRecoveryHook: PostToolUseHook = {
28
28
 
29
29
  // Classify the error and get a recovery action
30
30
  const handler = RecoveryHandler.getInstance();
31
- const errorContext: ErrorContext = {
32
- sessionId: context.sessionId,
33
- error: new Error(output.slice(0, 500)), // Truncate for classification
34
- attempt: (context._recoveryAttempt as number) ?? 0,
35
- timestamp: Date.now(),
36
- agent: context.agent,
37
- };
31
+ const errorContext: ErrorContext = {
32
+ sessionId: context.sessionId,
33
+ error: new Error(output.slice(0, 500)), // Truncate for classification
34
+ attempt: context._recoveryAttempt ?? 0,
35
+ timestamp: Date.now(),
36
+ agent: context.agent,
37
+ };
38
38
 
39
39
  const action = handler.handleError(errorContext);
40
40
 
@@ -9,7 +9,7 @@ import { HookPhase, HookResult } from "../types.ts";
9
9
  import type { HookContext, PostToolUseHook } from "../types.ts";
10
10
  import { MemoryManager } from "../../memory/memory-manager.ts";
11
11
  import { PlanStore } from "../../memory/plan-store.ts";
12
- import { findLatestPlanFile } from "../../../../bootstrap.ts";
12
+ import { resolvePlanAccess } from "../../plans/plan-location.ts";
13
13
  import { MemoryLevel } from "../../memory/interfaces.ts";
14
14
 
15
15
  export const memorySyncHook: PostToolUseHook = {
@@ -23,7 +23,7 @@ export const memorySyncHook: PostToolUseHook = {
23
23
 
24
24
  async execute(context: HookContext, output: string) {
25
25
  // Sync memory entries to plan file
26
- const planFile = findLatestPlanFile(context.directory);
26
+ const planFile = resolvePlanAccess(context.directory)?.path ?? null;
27
27
  if (!planFile) {
28
28
  // No plan file to sync to — skip silently
29
29
  return { result: HookResult.CONTINUE };
@@ -0,0 +1,24 @@
1
+ import { HookPhase, HookResult } from "../types.ts";
2
+ import type { HookContext, RouteHook } from "../types.ts";
3
+
4
+ export const nextRouteHook: RouteHook = {
5
+ metadata: {
6
+ name: "next-route",
7
+ priority: 90,
8
+ phase: HookPhase.EARLY,
9
+ dependencies: [],
10
+ errorHandling: "isolate",
11
+ },
12
+
13
+ async execute(context: HookContext, route: string) {
14
+ const nextRoute = context._nextRoute?.selected;
15
+ if (!nextRoute || nextRoute === route) {
16
+ return { result: HookResult.CONTINUE, modifiedRoute: route };
17
+ }
18
+
19
+ return {
20
+ result: HookResult.CONTINUE,
21
+ modifiedRoute: nextRoute,
22
+ };
23
+ },
24
+ };
@@ -5,9 +5,9 @@
5
5
  // If missing, inject "create plan first" instruction.
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
- import { HookPhase, HookResult } from "../types.ts";
9
- import type { HookContext, PreToolUseHook } from "../types.ts";
10
- import { findLatestPlanFile } from "../../../../bootstrap.ts";
8
+ import { HookPhase, HookResult } from "../types.ts";
9
+ import type { HookContext, PreToolUseHook } from "../types.ts";
10
+ import { resolvePlanAccess } from "../../plans/plan-location.ts";
11
11
 
12
12
  export const planCheckHook: PreToolUseHook = {
13
13
  metadata: {
@@ -18,8 +18,8 @@ export const planCheckHook: PreToolUseHook = {
18
18
  errorHandling: "propagate",
19
19
  },
20
20
 
21
- async execute(context: HookContext) {
22
- const planFile = findLatestPlanFile(context.directory);
21
+ async execute(context: HookContext) {
22
+ const planFile = resolvePlanAccess(context.directory)?.path ?? null;
23
23
 
24
24
  if (!planFile) {
25
25
  return {
@@ -2,12 +2,17 @@
2
2
  // RouteTrackingHook — RouteHook, priority=55, phase=LATE
3
3
  //
4
4
  // Loop guard — mechanically enforce two limits:
5
- // 1. Same skill visited 5+ times in one chain
6
- // 2. 8+ consecutive unproductive hops
5
+ // 1. Same skill visited N+ times in one chain (default 5)
6
+ // 2. N+ consecutive unproductive hops (default 8)
7
+ //
8
+ // Config from _guardConfig (centralized) with fallback to _routeTrackingConfig
9
+ // for backward compatibility. Progressive warning at thresholds before hard stop.
7
10
  // ---------------------------------------------------------------------------
8
11
 
9
12
  import { HookPhase, HookResult } from "../types.ts";
10
13
  import type { HookContext, RouteHook } from "../types.ts";
14
+ import type { GuardConfig, GuardProgression } from "../../guards/guard-config.ts";
15
+ import { checkGuardProgression, DEFAULT_GUARD_CONFIG } from "../../guards/guard-config.ts";
11
16
 
12
17
  // ---------------------------------------------------------------------------
13
18
  // Types
@@ -55,6 +60,53 @@ export function getHopHistory(sessionId: string): HopRecord[] {
55
60
 
56
61
  const defaultArtifactCheck: (route: string) => boolean = () => false;
57
62
 
63
+ // ---------------------------------------------------------------------------
64
+ // Resolve max values from guard config with fallbacks
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function resolveMaxValues(context: HookContext): {
68
+ maxSkillRepeats: number;
69
+ maxUnproductiveHops: number;
70
+ artifactCheck: (route: string) => boolean | Promise<boolean>;
71
+ } {
72
+ // Primary: _guardConfig (centralized)
73
+ const gc: GuardConfig = context._guardConfig ?? DEFAULT_GUARD_CONFIG;
74
+ const maxSkillRepeats = gc.maxSkillRepeats;
75
+ const maxUnproductiveHops = gc.maxUnproductiveHops;
76
+
77
+ // Backward compat: _routeTrackingConfig overrides if present
78
+ const legacy = context._routeTrackingConfig as Partial<RouteTrackingConfig> | undefined;
79
+ const artifactCheck = legacy?.artifactCheck ?? defaultArtifactCheck;
80
+ const legacySkillRepeats = legacy?.maxSkillRepeats;
81
+ const legacyUnproductiveHops = legacy?.maxUnproductiveHops;
82
+
83
+ return {
84
+ maxSkillRepeats: legacySkillRepeats ?? maxSkillRepeats,
85
+ maxUnproductiveHops: legacyUnproductiveHops ?? maxUnproductiveHops,
86
+ artifactCheck,
87
+ };
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Build optiRoute report helper
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function buildOptiRouteReport(
95
+ state: RouteTrackingState,
96
+ reason: string,
97
+ maxSkillRepeats: number,
98
+ maxUnproductiveHops: number,
99
+ ) {
100
+ return {
101
+ reason,
102
+ chain: [...state.hops],
103
+ skillCounts: Object.fromEntries(state.skillCounts),
104
+ unproductiveCount: state.unproductiveCount,
105
+ maxSkillRepeats,
106
+ maxUnproductiveHops,
107
+ };
108
+ }
109
+
58
110
  // ---------------------------------------------------------------------------
59
111
  // Hook
60
112
  // ---------------------------------------------------------------------------
@@ -88,13 +140,9 @@ export const routeTrackingHook: RouteHook = {
88
140
  sessionStates.set(sessionId, state);
89
141
  }
90
142
 
91
- // Read config from context (or use defaults)
92
- // Support both `_routeTrackingConfig` and `hooks.route_tracking.*` conventions
93
- const config =
94
- (context._routeTrackingConfig ?? {}) as RouteTrackingConfig;
95
- const maxSkillRepeats = config.maxSkillRepeats ?? 5;
96
- const maxUnproductiveHops = config.maxUnproductiveHops ?? 8;
97
- const artifactCheck = config.artifactCheck ?? defaultArtifactCheck;
143
+ // Resolve config values
144
+ const { maxSkillRepeats, maxUnproductiveHops, artifactCheck } = resolveMaxValues(context);
145
+ const gc: GuardConfig = context._guardConfig ?? DEFAULT_GUARD_CONFIG;
98
146
 
99
147
  // Record the hop
100
148
  const producedArtifact = await artifactCheck(route);
@@ -116,32 +164,38 @@ export const routeTrackingHook: RouteHook = {
116
164
  state.unproductiveCount += 1;
117
165
  }
118
166
 
119
- // Check 1: Same skill repeated too many times
120
- if (currentSkillCount >= maxSkillRepeats) {
121
- context._optiRoute = {
122
- reason: `Same skill "${route}" visited ${currentSkillCount} times (max ${maxSkillRepeats})`,
123
- chain: [...state.hops],
124
- skillCounts: Object.fromEntries(state.skillCounts),
125
- unproductiveCount: state.unproductiveCount,
167
+ // Check 1: Same skill repeated too many times — with progressive warning
168
+ let progression = checkGuardProgression(currentSkillCount, maxSkillRepeats, gc);
169
+ if (progression.level === "warn" || progression.level === "escalate") {
170
+ // Progressive warning annotate context but don't stop
171
+ context._guardProgression = progression;
172
+ }
173
+ if (progression.level === "stop") {
174
+ context._optiRoute = buildOptiRouteReport(
175
+ state,
176
+ `Same skill "${route}" visited ${currentSkillCount} times (max ${maxSkillRepeats})`,
126
177
  maxSkillRepeats,
127
178
  maxUnproductiveHops,
128
- };
179
+ );
129
180
  return { result: HookResult.STOP };
130
181
  }
131
182
 
132
- // Check 2: Too many consecutive unproductive hops
133
- if (state.unproductiveCount >= maxUnproductiveHops) {
134
- context._optiRoute = {
135
- reason: `${state.unproductiveCount} consecutive unproductive hops (max ${maxUnproductiveHops})`,
136
- chain: [...state.hops],
137
- skillCounts: Object.fromEntries(state.skillCounts),
138
- unproductiveCount: state.unproductiveCount,
183
+ // Check 2: Too many consecutive unproductive hops — with progressive warning
184
+ progression = checkGuardProgression(state.unproductiveCount, maxUnproductiveHops, gc);
185
+ if (progression.level === "warn" || progression.level === "escalate") {
186
+ // Progressive warning annotate context but don't stop
187
+ context._guardProgression = progression;
188
+ }
189
+ if (progression.level === "stop") {
190
+ context._optiRoute = buildOptiRouteReport(
191
+ state,
192
+ `${state.unproductiveCount} consecutive unproductive hops (max ${maxUnproductiveHops})`,
139
193
  maxSkillRepeats,
140
194
  maxUnproductiveHops,
141
- };
195
+ );
142
196
  return { result: HookResult.STOP };
143
197
  }
144
198
 
145
199
  return { result: HookResult.CONTINUE, modifiedRoute: route };
146
200
  },
147
- };
201
+ };
@@ -0,0 +1,93 @@
1
+ // ---------------------------------------------------------------------------
2
+ // SubagentFailureHook — PostToolUse, priority=45, phase=LATE
3
+ //
4
+ // Mechanically tracks subagent task failures.
5
+ // At maxSubagentFailures consecutive failures on the same task,
6
+ // surfaces a BLOCKER.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import { HookPhase, HookResult } from "../types.ts";
10
+ import type { HookContext, PostToolUseHook } from "../types.ts";
11
+ import { DEFAULT_GUARD_CONFIG, checkGuardProgression } from "../../guards/guard-config.ts";
12
+ import type { GuardConfig } from "../../guards/guard-config.ts";
13
+
14
+ /** Module-level failure tracker — maps sessionId to consecutive failure count */
15
+ const failureCounters = new Map<string, number>();
16
+
17
+ export function resetSubagentFailures(sessionId?: string): void {
18
+ if (sessionId) {
19
+ failureCounters.delete(sessionId);
20
+ } else {
21
+ failureCounters.clear();
22
+ }
23
+ }
24
+
25
+ export function getSubagentFailureCount(sessionId: string): number {
26
+ return failureCounters.get(sessionId) ?? 0;
27
+ }
28
+
29
+ // Error pattern detection — reuses the same patterns from error-recovery-hook
30
+ function outputLooksLikeFailure(output: string): boolean {
31
+ if (!output || output.length === 0) return true; // Empty output = failure
32
+ const errorPatterns = [
33
+ /error/i, /exception/i, /failed/i, /failure/i,
34
+ /unable to/i, /could not/i, /not found/i,
35
+ /ECONNREFUSED/i, /ETIMEDOUT/i, /rate.?limited/i,
36
+ /too many requests/i, /timeout/i, /execution.?timed.?out/i,
37
+ ];
38
+ const head = output.slice(0, 2000);
39
+ return errorPatterns.some((p) => p.test(head));
40
+ }
41
+
42
+ export const subagentFailureHook: PostToolUseHook = {
43
+ metadata: {
44
+ name: "subagent-failure",
45
+ priority: 45,
46
+ phase: HookPhase.LATE,
47
+ dependencies: [],
48
+ errorHandling: "isolate",
49
+ },
50
+
51
+ async execute(context: HookContext, output: string) {
52
+ const sessionId = context.sessionId;
53
+ const config: GuardConfig = context._guardConfig ?? DEFAULT_GUARD_CONFIG;
54
+ const maxFailures = config.maxSubagentFailures;
55
+
56
+ if (maxFailures <= 0) {
57
+ // Disabled
58
+ return { result: HookResult.CONTINUE };
59
+ }
60
+
61
+ const isFailure = outputLooksLikeFailure(output);
62
+ let currentCount = failureCounters.get(sessionId) ?? 0;
63
+
64
+ if (isFailure) {
65
+ currentCount += 1;
66
+ failureCounters.set(sessionId, currentCount);
67
+ } else {
68
+ // Success — reset counter
69
+ failureCounters.set(sessionId, 0);
70
+ return { result: HookResult.CONTINUE };
71
+ }
72
+
73
+ // Check progression
74
+ const progression = checkGuardProgression(currentCount, maxFailures, config);
75
+
76
+ if (progression.level === "stop") {
77
+ // Surface BLOCKER
78
+ return {
79
+ result: HookResult.INJECT,
80
+ modifiedOutput: output,
81
+ injectRecovery: `[HOOK: BLOCKER] ${currentCount} consecutive subagent failures (max ${maxFailures}). Surface to orchestrator with findings and stop delegating.`,
82
+ };
83
+ }
84
+
85
+ if (progression.level === "warn" || progression.level === "escalate") {
86
+ // Annotate but don't stop
87
+ context._guardProgression = progression;
88
+ context._subagentFailures = currentCount;
89
+ }
90
+
91
+ return { result: HookResult.CONTINUE };
92
+ },
93
+ };
@@ -4,40 +4,45 @@
4
4
 
5
5
  import { describe, it, before, after, beforeEach } from "node:test";
6
6
  import assert from "node:assert/strict";
7
- import {
8
- HookPhase,
9
- HookResult,
10
- HookRegistry,
11
- planCheckHook,
7
+ import {
8
+ HookPhase,
9
+ HookResult,
10
+ HookRegistry,
11
+ planCheckHook,
12
12
  shellDetectHook,
13
13
  confidenceGateHook,
14
14
  delegationDepthHook,
15
15
  resetDepthTracker,
16
16
  errorRecoveryHook,
17
17
  memorySyncHook,
18
- routeTrackingHook,
19
- resetRouteTracker,
20
- getHopHistory,
21
- sanityCheckHook,
22
- } from "./index.ts";
18
+ routeTrackingHook,
19
+ resetRouteTracker,
20
+ getHopHistory,
21
+ sanityCheckHook,
22
+ dynamicRouteHook,
23
+ } from "./index.ts";
23
24
  import { AnomalyTracker } from "../sanity/anomaly-tracker.ts";
24
- import type {
25
- HookContext,
26
- HookMetadata,
27
- PreToolUseHook,
28
- PostToolUseHook,
29
- RouteHook,
25
+ import type {
26
+ HookContext,
27
+ HookContextPatch,
28
+ HookMetadata,
29
+ PreToolUseHook,
30
+ PostToolUseHook,
31
+ RouteHook,
30
32
  SessionHook,
31
- } from "./types.ts";
33
+ } from "./types.ts";
34
+ import fs from "node:fs";
35
+ import os from "node:os";
36
+ import path from "node:path";
32
37
 
33
38
  // ---------------------------------------------------------------------------
34
39
  // Helpers
35
40
  // ---------------------------------------------------------------------------
36
41
 
37
- function makeContext(overrides?: Partial<HookContext>): HookContext {
38
- return {
39
- sessionId: "test-session",
40
- agent: "oh-builder",
42
+ function makeContext(overrides?: HookContextPatch): HookContext {
43
+ return {
44
+ sessionId: "test-session",
45
+ agent: "oh-builder",
41
46
  directory: "/tmp/test-project",
42
47
  sessions: new Map(),
43
48
  ...overrides,
@@ -47,13 +52,13 @@ function makeContext(overrides?: Partial<HookContext>): HookContext {
47
52
  function makePreToolHook(
48
53
  name: string,
49
54
  overrides?: Partial<HookMetadata>,
50
- impl?: (
51
- ctx: HookContext,
52
- ) => Promise<{
53
- result: HookResult;
54
- modifiedContext?: Partial<HookContext>;
55
- }>,
56
- ): PreToolUseHook {
55
+ impl?: (
56
+ ctx: HookContext,
57
+ ) => Promise<{
58
+ result: HookResult;
59
+ modifiedContext?: HookContextPatch;
60
+ }>,
61
+ ): PreToolUseHook {
57
62
  return {
58
63
  metadata: {
59
64
  name,
@@ -135,10 +140,18 @@ function makeSessionHook(
135
140
  // Tests
136
141
  // ---------------------------------------------------------------------------
137
142
 
138
- describe("HookRegistry", () => {
139
- beforeEach(() => {
140
- HookRegistry.resetInstance();
141
- resetDepthTracker();
143
+ describe("HookRegistry", () => {
144
+ const tmpDirs: string[] = [];
145
+
146
+ after(() => {
147
+ for (const dir of tmpDirs) {
148
+ fs.rmSync(dir, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ beforeEach(() => {
153
+ HookRegistry.resetInstance();
154
+ resetDepthTracker();
142
155
  resetRouteTracker();
143
156
  });
144
157
 
@@ -379,10 +392,13 @@ describe("HookRegistry", () => {
379
392
  }),
380
393
  );
381
394
 
382
- const result = await reg.executePreTool(makeContext());
383
- assert.equal(result.result, HookResult.CONTINUE);
384
- assert.equal(result.modifiedContext?._track, "ran");
385
- });
395
+ const result = await reg.executePreTool(makeContext());
396
+ assert.equal(result.result, HookResult.CONTINUE);
397
+ assert.equal(result.modifiedContext?.sessionId, "test-session");
398
+ assert.equal(result.modifiedContext?.agent, "oh-builder");
399
+ assert.equal(result.modifiedContext?.directory, "/tmp/test-project");
400
+ assert.equal(result.modifiedContext?._track, "ran");
401
+ });
386
402
 
387
403
  it("stops execution on STOP result", async () => {
388
404
  const reg = HookRegistry.getInstance();
@@ -427,7 +443,7 @@ describe("HookRegistry", () => {
427
443
  });
428
444
  });
429
445
 
430
- describe("executePostTool", () => {
446
+ describe("executePostTool", () => {
431
447
  it("passes through output without modification", async () => {
432
448
  const reg = HookRegistry.getInstance();
433
449
  reg.registerPostTool(makePostToolHook("pass"));
@@ -462,9 +478,9 @@ describe("HookRegistry", () => {
462
478
  assert.equal(result.modifiedOutput, "[[[HELLO]]]");
463
479
  });
464
480
 
465
- it("injects recovery action", async () => {
466
- const reg = HookRegistry.getInstance();
467
- reg.registerPostTool(
481
+ it("injects recovery action", async () => {
482
+ const reg = HookRegistry.getInstance();
483
+ reg.registerPostTool(
468
484
  makePostToolHook("recovery-test", {}, async () => ({
469
485
  result: HookResult.INJECT,
470
486
  injectRecovery: "retry with backoff",
@@ -474,10 +490,86 @@ describe("HookRegistry", () => {
474
490
  const result = await reg.executePostTool(
475
491
  makeContext(),
476
492
  "output",
477
- );
478
- assert.equal(result.recovery, "retry with backoff");
479
- });
480
- });
493
+ );
494
+ assert.equal(result.recovery, "retry with backoff");
495
+ });
496
+
497
+ it("appends structured route guidance from output evidence", async () => {
498
+ const reg = HookRegistry.getInstance();
499
+ reg.registerPostTool(dynamicRouteHook);
500
+
501
+ const skillsDir = fs.mkdtempSync(path.join(os.tmpdir(), "oh-routing-hook-"));
502
+ tmpDirs.push(skillsDir);
503
+ const skillDir = path.join(skillsDir, "oh-review");
504
+ fs.mkdirSync(skillDir, { recursive: true });
505
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
506
+ name: oh-review
507
+ route:
508
+ pass:
509
+ - oh-gauntlet
510
+ - oh-ship
511
+ fail: oh-builder
512
+ blocker: surface
513
+ ---\n`);
514
+
515
+ const result = await reg.executePostTool(
516
+ makeContext({ agent: "oh-review", _routingSkillsDir: skillsDir }),
517
+ 'Review complete\nROUTE_EVIDENCE: {"outcome":"pass","target":"oh-ship"}',
518
+ );
519
+
520
+ assert.equal(result.result, HookResult.INJECT);
521
+ assert.ok(result.modifiedOutput?.includes("Review complete"));
522
+ assert.ok(result.modifiedOutput?.includes("ROUTE_GUIDANCE:"));
523
+
524
+ const guidanceLine = result.modifiedOutput
525
+ ?.split(/\r?\n/)
526
+ .find((line) => line.startsWith("ROUTE_GUIDANCE:"));
527
+ assert.ok(guidanceLine);
528
+ assert.deepEqual(JSON.parse(guidanceLine!.slice("ROUTE_GUIDANCE:".length).trim()), {
529
+ outcome: "pass",
530
+ candidates: ["oh-gauntlet", "oh-ship"],
531
+ selected: "oh-ship",
532
+ reason: 'Selected "oh-ship" from output evidence.',
533
+ });
534
+ });
535
+
536
+ it("ignores malformed structured route evidence safely", async () => {
537
+ const reg = HookRegistry.getInstance();
538
+ reg.registerPostTool(dynamicRouteHook);
539
+
540
+ const skillsDir = fs.mkdtempSync(path.join(os.tmpdir(), "oh-routing-hook-"));
541
+ tmpDirs.push(skillsDir);
542
+ const skillDir = path.join(skillsDir, "oh-review");
543
+ fs.mkdirSync(skillDir, { recursive: true });
544
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
545
+ name: oh-review
546
+ route:
547
+ pass:
548
+ - oh-gauntlet
549
+ - oh-ship
550
+ fail: oh-builder
551
+ blocker: surface
552
+ ---\n`);
553
+
554
+ const output = 'Review complete\nROUTE_EVIDENCE: {"outcome":"pass","verification":"maybe"}';
555
+ const result = await reg.executePostTool(
556
+ makeContext({ agent: "oh-review", _routingSkillsDir: skillsDir }),
557
+ output,
558
+ );
559
+
560
+ assert.equal(result.result, HookResult.CONTINUE);
561
+ assert.equal(result.modifiedOutput, output);
562
+ });
563
+
564
+ it("leaves output unchanged when no route evidence is present", async () => {
565
+ const reg = HookRegistry.getInstance();
566
+ reg.registerPostTool(dynamicRouteHook);
567
+
568
+ const result = await reg.executePostTool(makeContext({ agent: "oh-review" }), "plain output");
569
+ assert.equal(result.result, HookResult.CONTINUE);
570
+ assert.equal(result.modifiedOutput, "plain output");
571
+ });
572
+ });
481
573
 
482
574
  describe("executeRoute", () => {
483
575
  it("passes route unchanged", async () => {
@@ -723,18 +815,10 @@ describe("HookRegistry", () => {
723
815
  // 5th — should STOP (>= maxSkillRepeats=5)
724
816
  const result = await routeTrackingHook.execute(ctx, "oh-builder");
725
817
  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
- );
818
+ assert.ok(ctx._optiRoute);
819
+ assert.ok(ctx._optiRoute.reason);
820
+ assert.ok(ctx._optiRoute.reason.includes("oh-builder"));
821
+ assert.ok(ctx._optiRoute.chain.length === 5);
738
822
  });
739
823
 
740
824
  it("stops on 8th unproductive hop (default max 8)", async () => {
@@ -756,12 +840,8 @@ describe("HookRegistry", () => {
756
840
  // 8th — should STOP
757
841
  const result = await routeTrackingHook.execute(ctx, "oh-builder");
758
842
  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
- );
843
+ assert.ok(ctx._optiRoute);
844
+ assert.ok(ctx._optiRoute.reason.includes("unproductive"));
765
845
  });
766
846
 
767
847
  it("productive hop resets unproductive counter", async () => {
@@ -874,13 +954,9 @@ describe("HookRegistry", () => {
874
954
  // 3rd unproductive should STOP (>=3)
875
955
  const result = await routeTrackingHook.execute(ctx, "oh-gauntlet");
876
956
  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
- });
957
+ assert.ok(ctx._optiRoute);
958
+ assert.ok(ctx._optiRoute.reason.includes("unproductive"));
959
+ });
884
960
  });
885
961
  });
886
962
 
@@ -7,7 +7,10 @@ export {
7
7
  HookResult,
8
8
  } from "./types.ts";
9
9
  export type {
10
+ HookContextBase,
11
+ HookContextExtras,
10
12
  HookContext,
13
+ HookContextPatch,
11
14
  HookMetadata,
12
15
  PreToolUseHook,
13
16
  PostToolUseHook,
@@ -26,5 +29,14 @@ export { delegationDepthHook, resetDepthTracker } from "./builtins/delegation-de
26
29
  export { errorRecoveryHook } from "./builtins/error-recovery-hook.ts";
27
30
  export { memorySyncHook } from "./builtins/memory-sync-hook.ts";
28
31
  export { sanityCheckHook } from "./builtins/sanity-check-hook.ts";
32
+ export { dynamicRouteHook } from "./builtins/dynamic-route-hook.ts";
33
+ export { nextRouteHook } from "./builtins/next-route-hook.ts";
29
34
  export { routeTrackingHook, resetRouteTracker, getHopHistory } from "./builtins/route-tracking-hook.ts";
30
35
  export type { HopRecord, RouteTrackingConfig } from "./builtins/route-tracking-hook.ts";
36
+
37
+ // Guard configuration
38
+ export type { GuardConfig, GuardProgression, GuardLevel } from "../guards/guard-config.ts";
39
+ export { DEFAULT_GUARD_CONFIG, checkGuardProgression, mergeGuardConfig } from "../guards/guard-config.ts";
40
+
41
+ // Subagent failure hook
42
+ export { subagentFailureHook, resetSubagentFailures, getSubagentFailureCount } from "./builtins/subagent-failure-hook.ts";
@@ -18,7 +18,7 @@ import { HookPhase, HookResult } from "./types.ts";
18
18
  // ---------------------------------------------------------------------------
19
19
 
20
20
  export class HookRegistry {
21
- private static instance: HookRegistry;
21
+ private static instance: HookRegistry | null = null;
22
22
 
23
23
  private preToolHooks: PreToolUseHook[] = [];
24
24
  private postToolHooks: PostToolUseHook[] = [];
@@ -37,7 +37,7 @@ export class HookRegistry {
37
37
 
38
38
  /** Reset singleton — used in tests for isolation. */
39
39
  static resetInstance(): void {
40
- HookRegistry.instance = null as unknown as HookRegistry;
40
+ HookRegistry.instance = null;
41
41
  }
42
42
 
43
43
  // -----------------------------------------------------------------------
@@ -139,7 +139,7 @@ export class HookRegistry {
139
139
  */
140
140
  async executePreTool(
141
141
  context: HookContext,
142
- ): Promise<{ result: HookResult; modifiedContext?: Partial<HookContext> }> {
142
+ ): Promise<{ result: HookResult; modifiedContext?: HookContext }> {
143
143
  const sorted = this.topologicalSort(this.preToolHooks);
144
144
  let currentContext = context;
145
145
  let hasInjection = false;