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.
- package/CONTEXT.md +6 -6
- package/ETHOS.md +2 -2
- package/README.md +8 -8
- package/bootstrap.ts +131 -198
- package/harness/codex/AUTOPILOT.md +39 -27
- package/harness/codex/CHARTER.md +1 -1
- package/harness/lib/background/background.test.ts +24 -5
- package/harness/lib/background/manager.ts +9 -9
- package/harness/lib/composer/compose.test.ts +29 -18
- package/harness/lib/composer/fragments/02-delegation.md +5 -4
- package/harness/lib/composer/fragments/04-task-flow.md +43 -3
- package/harness/lib/composer/fragments/09-guardrails.md +25 -12
- package/harness/lib/guards/guard-config.ts +72 -0
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +9 -11
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +24 -5
- package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -0
- package/harness/lib/hooks/builtins/error-recovery-hook.ts +7 -7
- package/harness/lib/hooks/builtins/memory-sync-hook.ts +2 -2
- package/harness/lib/hooks/builtins/next-route-hook.ts +24 -0
- package/harness/lib/hooks/builtins/plan-check-hook.ts +5 -5
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +80 -26
- package/harness/lib/hooks/builtins/subagent-failure-hook.ts +93 -0
- package/harness/lib/hooks/hooks.test.ts +145 -69
- package/harness/lib/hooks/index.ts +12 -0
- package/harness/lib/hooks/registry.ts +3 -3
- package/harness/lib/hooks/types.ts +50 -2
- package/harness/lib/memory/memory-manager.ts +2 -2
- package/harness/lib/memory/memory.test.ts +0 -6
- package/harness/lib/memory/plan-store.ts +1 -21
- 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/lib/sanity/checker.ts +45 -34
- package/harness/lib/sync/file-watcher.ts +26 -25
- package/harness/lib/sync/plan-sync.ts +22 -25
- package/harness/lib/sync/sync.test.ts +30 -4
- package/harness/skills/oh-fusion/DEEP.md +109 -86
- package/harness/skills/oh-fusion/SKILL.md +47 -33
- package/harness/skills/oh-manifest/SKILL.md +1 -0
- package/harness/skills/oh-review/DEEP.md +5 -3
- package/harness/skills/oh-review/SKILL.md +1 -0
- 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:
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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
|
|
6
|
-
// 2.
|
|
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
|
-
//
|
|
92
|
-
|
|
93
|
-
const
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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?:
|
|
142
|
+
): Promise<{ result: HookResult; modifiedContext?: HookContext }> {
|
|
143
143
|
const sorted = this.topologicalSort(this.preToolHooks);
|
|
144
144
|
let currentContext = context;
|
|
145
145
|
let hasInjection = false;
|