openhermes 4.12.1 → 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 +6 -6
- package/ETHOS.md +2 -2
- package/README.md +11 -17
- package/bootstrap.ts +118 -126
- 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 +35 -40
- package/harness/codex/CHARTER.md +3 -3
- package/harness/lib/composer/compose.test.ts +29 -29
- package/harness/lib/composer/fragments/02-delegation.md +5 -5
- package/harness/lib/composer/fragments/04-task-flow.md +13 -13
- package/harness/lib/composer/fragments/08-routing.md +1 -1
- package/harness/lib/composer/fragments/09-guardrails.md +25 -25
- package/harness/lib/composer/index.ts +1 -1
- package/harness/lib/guards/guard-config.ts +72 -72
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +9 -9
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +1 -1
- package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -99
- package/harness/lib/hooks/builtins/next-route-hook.ts +24 -24
- package/harness/lib/hooks/builtins/plan-check-hook.ts +5 -5
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +1 -1
- package/harness/lib/hooks/hooks.test.ts +160 -324
- package/harness/lib/hooks/index.ts +38 -42
- package/harness/lib/hooks/registry.ts +309 -416
- package/harness/lib/hooks/types.ts +116 -119
- package/harness/lib/plans/plan-location.ts +134 -134
- package/harness/lib/routing/index.ts +21 -21
- package/harness/lib/routing/route-guidance.ts +147 -147
- package/harness/lib/routing/route-resolver.ts +58 -58
- package/harness/lib/routing/routing.test.ts +195 -195
- package/harness/lib/routing/skill-frontmatter.ts +125 -125
- package/harness/lib/routing/types.ts +52 -52
- package/harness/skills/oh-ascii/SKILL.md +1 -1
- package/harness/skills/oh-fusion/DEEP.md +109 -109
- package/harness/skills/oh-fusion/SKILL.md +47 -47
- package/harness/skills/oh-init/DEEP.md +2 -2
- 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 +5 -5
- package/package.json +56 -53
- package/harness/lib/background/background.test.ts +0 -216
- 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/hooks/builtins/subagent-failure-hook.ts +0 -93
- 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 -485
- package/harness/lib/memory/plan-store.ts +0 -346
- 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 -189
- 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 -175
- package/harness/lib/sync/index.ts +0 -11
- package/harness/lib/sync/interfaces.ts +0 -27
- package/harness/lib/sync/plan-sync.ts +0 -533
- package/harness/lib/sync/sync.test.ts +0 -858
|
@@ -4,45 +4,41 @@
|
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
HookMetadata,
|
|
29
|
-
PreToolUseHook,
|
|
30
|
-
PostToolUseHook,
|
|
31
|
-
RouteHook,
|
|
16
|
+
routeTrackingHook,
|
|
17
|
+
resetRouteTracker,
|
|
18
|
+
getHopHistory,
|
|
19
|
+
dynamicRouteHook,
|
|
20
|
+
} from "./index.ts";
|
|
21
|
+
import type {
|
|
22
|
+
HookContext,
|
|
23
|
+
HookContextPatch,
|
|
24
|
+
HookMetadata,
|
|
25
|
+
PreToolUseHook,
|
|
26
|
+
PostToolUseHook,
|
|
27
|
+
RouteHook,
|
|
32
28
|
SessionHook,
|
|
33
|
-
} from "./types.ts";
|
|
34
|
-
import fs from "node:fs";
|
|
35
|
-
import os from "node:os";
|
|
36
|
-
import path from "node:path";
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import os from "node:os";
|
|
32
|
+
import path from "node:path";
|
|
37
33
|
|
|
38
34
|
// ---------------------------------------------------------------------------
|
|
39
35
|
// Helpers
|
|
40
36
|
// ---------------------------------------------------------------------------
|
|
41
37
|
|
|
42
|
-
function makeContext(overrides?: HookContextPatch): HookContext {
|
|
43
|
-
return {
|
|
44
|
-
sessionId: "test-session",
|
|
45
|
-
agent: "oh-builder",
|
|
38
|
+
function makeContext(overrides?: HookContextPatch): HookContext {
|
|
39
|
+
return {
|
|
40
|
+
sessionId: "test-session",
|
|
41
|
+
agent: "oh-builder",
|
|
46
42
|
directory: "/tmp/test-project",
|
|
47
43
|
sessions: new Map(),
|
|
48
44
|
...overrides,
|
|
@@ -52,13 +48,13 @@ function makeContext(overrides?: HookContextPatch): HookContext {
|
|
|
52
48
|
function makePreToolHook(
|
|
53
49
|
name: string,
|
|
54
50
|
overrides?: Partial<HookMetadata>,
|
|
55
|
-
impl?: (
|
|
56
|
-
ctx: HookContext,
|
|
57
|
-
) => Promise<{
|
|
58
|
-
result: HookResult;
|
|
59
|
-
modifiedContext?: HookContextPatch;
|
|
60
|
-
}>,
|
|
61
|
-
): PreToolUseHook {
|
|
51
|
+
impl?: (
|
|
52
|
+
ctx: HookContext,
|
|
53
|
+
) => Promise<{
|
|
54
|
+
result: HookResult;
|
|
55
|
+
modifiedContext?: HookContextPatch;
|
|
56
|
+
}>,
|
|
57
|
+
): PreToolUseHook {
|
|
62
58
|
return {
|
|
63
59
|
metadata: {
|
|
64
60
|
name,
|
|
@@ -81,7 +77,6 @@ function makePostToolHook(
|
|
|
81
77
|
) => Promise<{
|
|
82
78
|
result: HookResult;
|
|
83
79
|
modifiedOutput?: string;
|
|
84
|
-
injectRecovery?: string;
|
|
85
80
|
}>,
|
|
86
81
|
): PostToolUseHook {
|
|
87
82
|
return {
|
|
@@ -140,18 +135,18 @@ function makeSessionHook(
|
|
|
140
135
|
// Tests
|
|
141
136
|
// ---------------------------------------------------------------------------
|
|
142
137
|
|
|
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();
|
|
138
|
+
describe("HookRegistry", () => {
|
|
139
|
+
const tmpDirs: string[] = [];
|
|
140
|
+
|
|
141
|
+
after(() => {
|
|
142
|
+
for (const dir of tmpDirs) {
|
|
143
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
HookRegistry.resetInstance();
|
|
149
|
+
resetDepthTracker();
|
|
155
150
|
resetRouteTracker();
|
|
156
151
|
});
|
|
157
152
|
|
|
@@ -264,7 +259,7 @@ describe("HookRegistry", () => {
|
|
|
264
259
|
assert.equal(sorted[2].metadata.name, "late-hook");
|
|
265
260
|
});
|
|
266
261
|
|
|
267
|
-
it("
|
|
262
|
+
it("sorts by priority within same phase, ignoring dependencies", () => {
|
|
268
263
|
const reg = HookRegistry.getInstance();
|
|
269
264
|
const a = makePreToolHook("a", {
|
|
270
265
|
phase: HookPhase.EARLY,
|
|
@@ -273,89 +268,27 @@ describe("HookRegistry", () => {
|
|
|
273
268
|
});
|
|
274
269
|
const b = makePreToolHook("b", {
|
|
275
270
|
phase: HookPhase.EARLY,
|
|
276
|
-
priority:
|
|
271
|
+
priority: 70,
|
|
277
272
|
dependencies: ["a"],
|
|
278
273
|
});
|
|
279
|
-
const c = makePreToolHook("c", {
|
|
280
|
-
phase: HookPhase.EARLY,
|
|
281
|
-
priority: 50,
|
|
282
|
-
dependencies: ["b"],
|
|
283
|
-
});
|
|
284
274
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
assert.equal(sorted[
|
|
288
|
-
assert.equal(sorted[
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
it("handles diamond dependencies (A→B→D and A→C→D)", () => {
|
|
292
|
-
const reg = HookRegistry.getInstance();
|
|
293
|
-
const a = makePreToolHook("a", {
|
|
294
|
-
phase: HookPhase.EARLY,
|
|
295
|
-
priority: 50,
|
|
296
|
-
dependencies: [],
|
|
297
|
-
});
|
|
298
|
-
const b = makePreToolHook("b", {
|
|
299
|
-
phase: HookPhase.EARLY,
|
|
300
|
-
priority: 50,
|
|
301
|
-
dependencies: ["a"],
|
|
302
|
-
});
|
|
303
|
-
const c = makePreToolHook("c", {
|
|
304
|
-
phase: HookPhase.EARLY,
|
|
305
|
-
priority: 50,
|
|
306
|
-
dependencies: ["a"],
|
|
307
|
-
});
|
|
308
|
-
const d = makePreToolHook("d", {
|
|
309
|
-
phase: HookPhase.EARLY,
|
|
310
|
-
priority: 50,
|
|
311
|
-
dependencies: ["b", "c"],
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
const sorted = reg.topologicalSort([d, c, b, a]);
|
|
315
|
-
// A must come first, D must come last
|
|
316
|
-
assert.equal(sorted[0].metadata.name, "a");
|
|
317
|
-
assert.equal(sorted[3].metadata.name, "d");
|
|
318
|
-
// B and C can be in any order but must be before D and after A
|
|
319
|
-
const bIdx = sorted.findIndex((h) => h.metadata.name === "b");
|
|
320
|
-
const cIdx = sorted.findIndex((h) => h.metadata.name === "c");
|
|
321
|
-
assert.ok(bIdx > 0);
|
|
322
|
-
assert.ok(cIdx > 0);
|
|
323
|
-
assert.ok(bIdx < 3);
|
|
324
|
-
assert.ok(cIdx < 3);
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
it("throws on circular dependency (A→B→C→A)", () => {
|
|
328
|
-
const reg = HookRegistry.getInstance();
|
|
329
|
-
const a = makePreToolHook("a", {
|
|
330
|
-
phase: HookPhase.EARLY,
|
|
331
|
-
dependencies: ["c"],
|
|
332
|
-
});
|
|
333
|
-
const b = makePreToolHook("b", {
|
|
334
|
-
phase: HookPhase.EARLY,
|
|
335
|
-
dependencies: ["a"],
|
|
336
|
-
});
|
|
337
|
-
const c = makePreToolHook("c", {
|
|
338
|
-
phase: HookPhase.EARLY,
|
|
339
|
-
dependencies: ["b"],
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
assert.throws(
|
|
343
|
-
() => reg.topologicalSort([a, b, c]),
|
|
344
|
-
/Circular dependency detected/,
|
|
345
|
-
);
|
|
275
|
+
// Higher priority first (70 > 50), regardless of dependency declaration
|
|
276
|
+
const sorted = reg.topologicalSort([a, b]);
|
|
277
|
+
assert.equal(sorted[0].metadata.name, "b");
|
|
278
|
+
assert.equal(sorted[1].metadata.name, "a");
|
|
346
279
|
});
|
|
347
280
|
|
|
348
|
-
it("
|
|
281
|
+
it("preserves original order for equal phase and priority", () => {
|
|
349
282
|
const reg = HookRegistry.getInstance();
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
});
|
|
283
|
+
const c = makePreToolHook("c", { phase: HookPhase.EARLY, priority: 50 });
|
|
284
|
+
const a = makePreToolHook("a", { phase: HookPhase.EARLY, priority: 50 });
|
|
285
|
+
const b = makePreToolHook("b", { phase: HookPhase.EARLY, priority: 50 });
|
|
354
286
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
);
|
|
287
|
+
// Stable sort: same phase + priority means original order is preserved
|
|
288
|
+
const sorted = reg.topologicalSort([c, a, b]);
|
|
289
|
+
assert.equal(sorted[0].metadata.name, "c");
|
|
290
|
+
assert.equal(sorted[1].metadata.name, "a");
|
|
291
|
+
assert.equal(sorted[2].metadata.name, "b");
|
|
359
292
|
});
|
|
360
293
|
|
|
361
294
|
it("cross-phase dependencies are ignored (not within same phase)", () => {
|
|
@@ -392,13 +325,13 @@ describe("HookRegistry", () => {
|
|
|
392
325
|
}),
|
|
393
326
|
);
|
|
394
327
|
|
|
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
|
-
});
|
|
328
|
+
const result = await reg.executePreTool(makeContext());
|
|
329
|
+
assert.equal(result.result, HookResult.CONTINUE);
|
|
330
|
+
assert.equal(result.modifiedContext?.sessionId, "test-session");
|
|
331
|
+
assert.equal(result.modifiedContext?.agent, "oh-builder");
|
|
332
|
+
assert.equal(result.modifiedContext?.directory, "/tmp/test-project");
|
|
333
|
+
assert.equal(result.modifiedContext?._track, "ran");
|
|
334
|
+
});
|
|
402
335
|
|
|
403
336
|
it("stops execution on STOP result", async () => {
|
|
404
337
|
const reg = HookRegistry.getInstance();
|
|
@@ -443,7 +376,7 @@ describe("HookRegistry", () => {
|
|
|
443
376
|
});
|
|
444
377
|
});
|
|
445
378
|
|
|
446
|
-
describe("executePostTool", () => {
|
|
379
|
+
describe("executePostTool", () => {
|
|
447
380
|
it("passes through output without modification", async () => {
|
|
448
381
|
const reg = HookRegistry.getInstance();
|
|
449
382
|
reg.registerPostTool(makePostToolHook("pass"));
|
|
@@ -478,98 +411,98 @@ describe("HookRegistry", () => {
|
|
|
478
411
|
assert.equal(result.modifiedOutput, "[[[HELLO]]]");
|
|
479
412
|
});
|
|
480
413
|
|
|
481
|
-
it("injects recovery action", async () => {
|
|
482
|
-
|
|
483
|
-
reg.
|
|
414
|
+
it("injects recovery action (stub)", async () => {
|
|
415
|
+
// Recovery field removed in cleanup — test kept as placeholder
|
|
416
|
+
const reg = HookRegistry.getInstance();
|
|
417
|
+
reg.registerPostTool(
|
|
484
418
|
makePostToolHook("recovery-test", {}, async () => ({
|
|
485
419
|
result: HookResult.INJECT,
|
|
486
|
-
injectRecovery: "retry with backoff",
|
|
487
420
|
})),
|
|
488
421
|
);
|
|
489
422
|
|
|
490
423
|
const result = await reg.executePostTool(
|
|
491
424
|
makeContext(),
|
|
492
425
|
"output",
|
|
493
|
-
);
|
|
494
|
-
assert.equal(result.
|
|
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
|
-
});
|
|
426
|
+
);
|
|
427
|
+
assert.equal(result.result, HookResult.INJECT);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("appends structured route guidance from output evidence", async () => {
|
|
431
|
+
const reg = HookRegistry.getInstance();
|
|
432
|
+
reg.registerPostTool(dynamicRouteHook);
|
|
433
|
+
|
|
434
|
+
const skillsDir = fs.mkdtempSync(path.join(os.tmpdir(), "oh-routing-hook-"));
|
|
435
|
+
tmpDirs.push(skillsDir);
|
|
436
|
+
const skillDir = path.join(skillsDir, "oh-review");
|
|
437
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
438
|
+
fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
|
|
439
|
+
name: oh-review
|
|
440
|
+
route:
|
|
441
|
+
pass:
|
|
442
|
+
- oh-gauntlet
|
|
443
|
+
- oh-ship
|
|
444
|
+
fail: oh-builder
|
|
445
|
+
blocker: surface
|
|
446
|
+
---\n`);
|
|
447
|
+
|
|
448
|
+
const result = await reg.executePostTool(
|
|
449
|
+
makeContext({ agent: "oh-review", _routingSkillsDir: skillsDir }),
|
|
450
|
+
'Review complete\nROUTE_EVIDENCE: {"outcome":"pass","target":"oh-ship"}',
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
assert.equal(result.result, HookResult.INJECT);
|
|
454
|
+
assert.ok(result.modifiedOutput?.includes("Review complete"));
|
|
455
|
+
assert.ok(result.modifiedOutput?.includes("ROUTE_GUIDANCE:"));
|
|
456
|
+
|
|
457
|
+
const guidanceLine = result.modifiedOutput
|
|
458
|
+
?.split(/\r?\n/)
|
|
459
|
+
.find((line) => line.startsWith("ROUTE_GUIDANCE:"));
|
|
460
|
+
assert.ok(guidanceLine);
|
|
461
|
+
assert.deepEqual(JSON.parse(guidanceLine!.slice("ROUTE_GUIDANCE:".length).trim()), {
|
|
462
|
+
outcome: "pass",
|
|
463
|
+
candidates: ["oh-gauntlet", "oh-ship"],
|
|
464
|
+
selected: "oh-ship",
|
|
465
|
+
reason: 'Selected "oh-ship" from output evidence.',
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("ignores malformed structured route evidence safely", async () => {
|
|
470
|
+
const reg = HookRegistry.getInstance();
|
|
471
|
+
reg.registerPostTool(dynamicRouteHook);
|
|
472
|
+
|
|
473
|
+
const skillsDir = fs.mkdtempSync(path.join(os.tmpdir(), "oh-routing-hook-"));
|
|
474
|
+
tmpDirs.push(skillsDir);
|
|
475
|
+
const skillDir = path.join(skillsDir, "oh-review");
|
|
476
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
477
|
+
fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
|
|
478
|
+
name: oh-review
|
|
479
|
+
route:
|
|
480
|
+
pass:
|
|
481
|
+
- oh-gauntlet
|
|
482
|
+
- oh-ship
|
|
483
|
+
fail: oh-builder
|
|
484
|
+
blocker: surface
|
|
485
|
+
---\n`);
|
|
486
|
+
|
|
487
|
+
const output = 'Review complete\nROUTE_EVIDENCE: {"outcome":"pass","verification":"maybe"}';
|
|
488
|
+
const result = await reg.executePostTool(
|
|
489
|
+
makeContext({ agent: "oh-review", _routingSkillsDir: skillsDir }),
|
|
490
|
+
output,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
assert.equal(result.result, HookResult.CONTINUE);
|
|
494
|
+
assert.equal(result.modifiedOutput, output);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("leaves output unchanged when no route evidence is present", async () => {
|
|
498
|
+
const reg = HookRegistry.getInstance();
|
|
499
|
+
reg.registerPostTool(dynamicRouteHook);
|
|
500
|
+
|
|
501
|
+
const result = await reg.executePostTool(makeContext({ agent: "oh-review" }), "plain output");
|
|
502
|
+
assert.equal(result.result, HookResult.CONTINUE);
|
|
503
|
+
assert.equal(result.modifiedOutput, "plain output");
|
|
504
|
+
});
|
|
505
|
+
});
|
|
573
506
|
|
|
574
507
|
describe("executeRoute", () => {
|
|
575
508
|
it("passes route unchanged", async () => {
|
|
@@ -678,18 +611,6 @@ route:
|
|
|
678
611
|
assert.equal(delegationDepthHook.metadata.phase, HookPhase.NORMAL);
|
|
679
612
|
});
|
|
680
613
|
|
|
681
|
-
it("errorRecoveryHook has correct metadata", () => {
|
|
682
|
-
assert.equal(errorRecoveryHook.metadata.name, "error-recovery");
|
|
683
|
-
assert.equal(errorRecoveryHook.metadata.priority, 50);
|
|
684
|
-
assert.equal(errorRecoveryHook.metadata.phase, HookPhase.LATE);
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
it("memorySyncHook has correct metadata", () => {
|
|
688
|
-
assert.equal(memorySyncHook.metadata.name, "memory-sync");
|
|
689
|
-
assert.equal(memorySyncHook.metadata.priority, 40);
|
|
690
|
-
assert.equal(memorySyncHook.metadata.phase, HookPhase.LATE);
|
|
691
|
-
});
|
|
692
|
-
|
|
693
614
|
it("shellDetectHook returns shell context", async () => {
|
|
694
615
|
const result = await shellDetectHook.execute(makeContext());
|
|
695
616
|
assert.equal(result.result, HookResult.CONTINUE);
|
|
@@ -716,24 +637,6 @@ route:
|
|
|
716
637
|
assert.equal(result.modifiedContext?._depthExceeded, true);
|
|
717
638
|
});
|
|
718
639
|
|
|
719
|
-
it("errorRecoveryHook returns CONTINUE for normal output", async () => {
|
|
720
|
-
const result = await errorRecoveryHook.execute(
|
|
721
|
-
makeContext(),
|
|
722
|
-
"Everything completed successfully.",
|
|
723
|
-
);
|
|
724
|
-
assert.equal(result.result, HookResult.CONTINUE);
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
it("errorRecoveryHook detects error output", async () => {
|
|
728
|
-
const result = await errorRecoveryHook.execute(
|
|
729
|
-
makeContext(),
|
|
730
|
-
"Error: Failed to connect to server",
|
|
731
|
-
);
|
|
732
|
-
assert.equal(result.result, HookResult.INJECT);
|
|
733
|
-
assert.ok(result.injectRecovery);
|
|
734
|
-
assert.ok(result.injectRecovery!.includes("Error Recovery"));
|
|
735
|
-
});
|
|
736
|
-
|
|
737
640
|
it("confidenceGateHook passes through without confidence info", async () => {
|
|
738
641
|
const result = await confidenceGateHook.execute(
|
|
739
642
|
makeContext(),
|
|
@@ -758,12 +661,9 @@ route:
|
|
|
758
661
|
reg.registerPreTool(shellDetectHook);
|
|
759
662
|
reg.registerPreTool(delegationDepthHook);
|
|
760
663
|
reg.registerRoute(confidenceGateHook);
|
|
761
|
-
reg.registerPostTool(errorRecoveryHook);
|
|
762
|
-
reg.registerPostTool(memorySyncHook);
|
|
763
664
|
|
|
764
665
|
assert.equal(reg.getPreToolHooks().length, 3);
|
|
765
666
|
assert.equal(reg.getRouteHooks().length, 1);
|
|
766
|
-
assert.equal(reg.getPostToolHooks().length, 2);
|
|
767
667
|
});
|
|
768
668
|
|
|
769
669
|
it("routeTrackingHook has correct metadata", () => {
|
|
@@ -784,9 +684,6 @@ route:
|
|
|
784
684
|
// Route hook
|
|
785
685
|
await confidenceGateHook.execute(ctx, "oh-builder");
|
|
786
686
|
|
|
787
|
-
// Post-tool hooks
|
|
788
|
-
await errorRecoveryHook.execute(ctx, "normal output");
|
|
789
|
-
await memorySyncHook.execute(ctx, "some output");
|
|
790
687
|
// If we got here without throwing, success
|
|
791
688
|
assert.ok(true);
|
|
792
689
|
});
|
|
@@ -815,10 +712,10 @@ route:
|
|
|
815
712
|
// 5th — should STOP (>= maxSkillRepeats=5)
|
|
816
713
|
const result = await routeTrackingHook.execute(ctx, "oh-builder");
|
|
817
714
|
assert.equal(result.result, HookResult.STOP);
|
|
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);
|
|
715
|
+
assert.ok(ctx._optiRoute);
|
|
716
|
+
assert.ok(ctx._optiRoute.reason);
|
|
717
|
+
assert.ok(ctx._optiRoute.reason.includes("oh-builder"));
|
|
718
|
+
assert.ok(ctx._optiRoute.chain.length === 5);
|
|
822
719
|
});
|
|
823
720
|
|
|
824
721
|
it("stops on 8th unproductive hop (default max 8)", async () => {
|
|
@@ -840,8 +737,8 @@ route:
|
|
|
840
737
|
// 8th — should STOP
|
|
841
738
|
const result = await routeTrackingHook.execute(ctx, "oh-builder");
|
|
842
739
|
assert.equal(result.result, HookResult.STOP);
|
|
843
|
-
assert.ok(ctx._optiRoute);
|
|
844
|
-
assert.ok(ctx._optiRoute.reason.includes("unproductive"));
|
|
740
|
+
assert.ok(ctx._optiRoute);
|
|
741
|
+
assert.ok(ctx._optiRoute.reason.includes("unproductive"));
|
|
845
742
|
});
|
|
846
743
|
|
|
847
744
|
it("productive hop resets unproductive counter", async () => {
|
|
@@ -954,9 +851,9 @@ route:
|
|
|
954
851
|
// 3rd unproductive should STOP (>=3)
|
|
955
852
|
const result = await routeTrackingHook.execute(ctx, "oh-gauntlet");
|
|
956
853
|
assert.equal(result.result, HookResult.STOP);
|
|
957
|
-
assert.ok(ctx._optiRoute);
|
|
958
|
-
assert.ok(ctx._optiRoute.reason.includes("unproductive"));
|
|
959
|
-
});
|
|
854
|
+
assert.ok(ctx._optiRoute);
|
|
855
|
+
assert.ok(ctx._optiRoute.reason.includes("unproductive"));
|
|
856
|
+
});
|
|
960
857
|
});
|
|
961
858
|
});
|
|
962
859
|
|
|
@@ -1028,65 +925,4 @@ route:
|
|
|
1028
925
|
});
|
|
1029
926
|
});
|
|
1030
927
|
|
|
1031
|
-
// ---------------------------------------------------------------------------
|
|
1032
|
-
// sanityCheckHook
|
|
1033
|
-
// ---------------------------------------------------------------------------
|
|
1034
|
-
|
|
1035
|
-
describe("sanityCheckHook", () => {
|
|
1036
|
-
beforeEach(() => {
|
|
1037
|
-
AnomalyTracker.getInstance().resetAll();
|
|
1038
|
-
});
|
|
1039
|
-
|
|
1040
|
-
it("passes clean output through unchanged", async () => {
|
|
1041
|
-
const ctx = makeContext();
|
|
1042
|
-
const result = await sanityCheckHook.execute(
|
|
1043
|
-
ctx,
|
|
1044
|
-
"Everything is working fine. The system completed the task successfully.",
|
|
1045
|
-
);
|
|
1046
|
-
assert.equal(result.result, HookResult.CONTINUE);
|
|
1047
|
-
assert.equal(result.modifiedOutput, undefined);
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
it("detects repetitive output", async () => {
|
|
1051
|
-
const ctx = makeContext();
|
|
1052
|
-
const line =
|
|
1053
|
-
"Sphinx of black quartz, judge my vow! The five boxing wizards jump quickly. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
1054
|
-
const repetitiveOutput = Array.from({ length: 20 }, () => line).join("\n");
|
|
1055
|
-
const result = await sanityCheckHook.execute(ctx, repetitiveOutput);
|
|
1056
|
-
// First anomaly — not yet escalated
|
|
1057
|
-
assert.equal(result.result, HookResult.CONTINUE);
|
|
1058
|
-
});
|
|
1059
|
-
|
|
1060
|
-
it("detects box-drawing character flooding", async () => {
|
|
1061
|
-
const ctx = makeContext();
|
|
1062
|
-
const boxArt = "─│┌┐└┘├┤┬┴┼".repeat(50);
|
|
1063
|
-
const result = await sanityCheckHook.execute(ctx, boxArt);
|
|
1064
|
-
assert.equal(result.result, HookResult.CONTINUE);
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
it("detects placeholder patterns", async () => {
|
|
1068
|
-
const ctx = makeContext();
|
|
1069
|
-
// 50× [PLACEHOLDER] = 650 chars, 11 unique → triggers low_diversity check
|
|
1070
|
-
const placeholderText = "[PLACEHOLDER]".repeat(50);
|
|
1071
|
-
const result = await sanityCheckHook.execute(ctx, placeholderText);
|
|
1072
|
-
assert.equal(result.result, HookResult.CONTINUE);
|
|
1073
|
-
});
|
|
1074
|
-
|
|
1075
|
-
it("tracks anomalies across calls", async () => {
|
|
1076
|
-
const ctx = makeContext({ sessionId: "anomaly-escalation-test" });
|
|
1077
|
-
const line =
|
|
1078
|
-
"Sphinx of black quartz, judge my vow! The five boxing wizards jump quickly. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
1079
|
-
const repetitiveOutput = Array.from({ length: 20 }, () => line).join("\n");
|
|
1080
|
-
|
|
1081
|
-
// First call: anomaly detected but below threshold → CONTINUE
|
|
1082
|
-
const firstResult = await sanityCheckHook.execute(ctx, repetitiveOutput);
|
|
1083
|
-
assert.equal(firstResult.result, HookResult.CONTINUE);
|
|
1084
|
-
|
|
1085
|
-
// Second call: threshold reached → INJECT with recovery
|
|
1086
|
-
const secondResult = await sanityCheckHook.execute(ctx, repetitiveOutput);
|
|
1087
|
-
assert.equal(secondResult.result, HookResult.INJECT);
|
|
1088
|
-
assert.ok(secondResult.injectRecovery);
|
|
1089
|
-
assert.equal(secondResult.modifiedOutput, repetitiveOutput);
|
|
1090
|
-
});
|
|
1091
|
-
});
|
|
1092
928
|
});
|