openhermes 4.9.2 → 4.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +1 -1
- package/README.md +32 -31
- package/bootstrap.ts +262 -45
- package/harness/agents/oh-planner.md +1 -1
- package/harness/agents/openhermes.md +27 -126
- package/harness/codex/AUTOPILOT.md +99 -3
- package/harness/codex/CHARTER.md +3 -4
- package/harness/lib/background/background.test.ts +197 -0
- package/harness/lib/background/index.ts +7 -0
- package/harness/lib/background/interfaces.ts +31 -0
- package/harness/lib/background/manager.ts +320 -0
- package/harness/lib/composer/compose.test.ts +168 -0
- package/harness/lib/composer/compose.ts +65 -0
- package/harness/lib/composer/fragments/01-identity.md +1 -0
- package/harness/lib/composer/fragments/02-delegation.md +6 -0
- package/harness/lib/composer/fragments/03-permissions.md +13 -0
- package/harness/lib/composer/fragments/04-task-flow.md +15 -0
- package/harness/lib/composer/fragments/05-confidence.md +5 -0
- package/harness/lib/composer/fragments/06-parallelization.md +17 -0
- package/harness/lib/composer/fragments/07-shell.md +41 -0
- package/harness/lib/composer/fragments/08-routing.md +8 -0
- package/harness/lib/composer/fragments/09-guardrails.md +12 -0
- package/harness/lib/composer/index.ts +1 -0
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +70 -0
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +59 -0
- package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
- package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
- package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +147 -0
- package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
- package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
- package/harness/lib/hooks/hooks.test.ts +1016 -0
- package/harness/lib/hooks/index.ts +30 -0
- package/harness/lib/hooks/registry.ts +416 -0
- package/harness/lib/hooks/types.ts +71 -0
- package/harness/lib/memory/index.ts +18 -0
- package/harness/lib/memory/interfaces.ts +53 -0
- package/harness/lib/memory/memory-manager.ts +205 -0
- package/harness/lib/memory/memory.test.ts +491 -0
- package/harness/lib/memory/plan-store.ts +366 -0
- package/harness/lib/recovery/handler.ts +243 -0
- package/harness/lib/recovery/index.ts +14 -0
- package/harness/lib/recovery/interfaces.ts +48 -0
- package/harness/lib/recovery/patterns.ts +149 -0
- package/harness/lib/recovery/recovery.test.ts +312 -0
- package/harness/lib/sanity/anomaly-tracker.ts +127 -0
- package/harness/lib/sanity/checker.ts +178 -0
- package/harness/lib/sanity/index.ts +13 -0
- package/harness/lib/sanity/interfaces.ts +24 -0
- package/harness/lib/sanity/sanity.test.ts +472 -0
- package/harness/lib/sync/file-watcher.ts +174 -0
- package/harness/lib/sync/index.ts +11 -0
- package/harness/lib/sync/interfaces.ts +27 -0
- package/harness/lib/sync/plan-sync.ts +536 -0
- package/harness/lib/sync/sync.test.ts +832 -0
- package/harness/skills/oh-init/DEEP.md +2 -2
- package/harness/skills/oh-manifest/SKILL.md +1 -1
- package/harness/skills/oh-plan-review/DEEP.md +1 -1
- package/harness/skills/oh-planner/DEEP.md +3 -3
- package/harness/skills/oh-ship/SKILL.md +1 -1
- package/harness/skills/oh-skill-craft/SKILL.md +1 -4
- package/package.json +5 -5
- package/tsconfig.json +1 -1
- package/harness/commands/oh-doctor.md +0 -205
- package/harness/commands/oh-log.md +0 -18
- package/harness/skills/oh-learn/DEEP.md +0 -44
- package/harness/skills/oh-learn/SKILL.md +0 -30
- package/scripts/count-tokens.mjs +0 -158
- package/scripts/oh-doctor.ps1 +0 -342
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
## Guardrails
|
|
2
|
+
|
|
3
|
+
- Same skill 5+ times in one chain → STOP, write OptiRoute report to plan, surface
|
|
4
|
+
- 5 subagent failures on same task → surface BLOCKER
|
|
5
|
+
- Before routing: if next skill's required input is missing and cannot be discovered → surface
|
|
6
|
+
- Confidence is evaluated once per session, not per routing hop — only re-evaluate when new user input arrives
|
|
7
|
+
- User skills at `~/.agents/skills/` and `~/.config/opencode/skills/` load on demand via skill tool
|
|
8
|
+
- Subagent sessions: give narrow objective, relevant context, boundaries, success criteria. One level deep only. Verify results after return.
|
|
9
|
+
|
|
10
|
+
## Routing
|
|
11
|
+
|
|
12
|
+
After every skill: read its `route:` frontmatter (pass / fail / blocker). Route immediately. Do not ask. Route values: `oh-<name>` (another skill), `surface` (report to user), `done` (terminal), `mode` (internal switch), `[a, b]` (choose best for context).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { compose, composeFragment, listFragments } from "./compose.ts"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ConfidenceGateHook — RouteHook, priority=70, phase=NORMAL
|
|
3
|
+
//
|
|
4
|
+
// Before routing, check if confidence gate needs to pause.
|
|
5
|
+
// Adjust route based on confidence level.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import { HookPhase, HookResult } from "../types.ts";
|
|
9
|
+
import type { HookContext, RouteHook } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
export interface ConfidenceGateState {
|
|
12
|
+
level: "HIGH" | "MEDIUM" | "LOW";
|
|
13
|
+
exchanges: number;
|
|
14
|
+
lastAction: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const confidenceGateHook: RouteHook = {
|
|
18
|
+
metadata: {
|
|
19
|
+
name: "confidence-gate",
|
|
20
|
+
priority: 70,
|
|
21
|
+
phase: HookPhase.NORMAL,
|
|
22
|
+
dependencies: [],
|
|
23
|
+
errorHandling: "isolate",
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async execute(context: HookContext, route: string) {
|
|
27
|
+
// Read confidence state from context if available
|
|
28
|
+
const confidenceLevel: string | undefined = context._confidenceLevel as
|
|
29
|
+
| string
|
|
30
|
+
| undefined;
|
|
31
|
+
|
|
32
|
+
if (!confidenceLevel) {
|
|
33
|
+
// No confidence gate info — pass through unchanged
|
|
34
|
+
return { result: HookResult.CONTINUE, modifiedRoute: route };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Store the confidence assessment for routing decisions
|
|
38
|
+
const state: ConfidenceGateState = {
|
|
39
|
+
level: confidenceLevel as ConfidenceGateState["level"],
|
|
40
|
+
exchanges: (context._confidenceExchanges as number) ?? 0,
|
|
41
|
+
lastAction: "assessed",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// HIGH confidence: proceed without modification
|
|
45
|
+
if (state.level === "HIGH") {
|
|
46
|
+
return {
|
|
47
|
+
result: HookResult.CONTINUE,
|
|
48
|
+
modifiedRoute: route,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// MEDIUM confidence: echo if first pass, otherwise proceed
|
|
53
|
+
if (state.level === "MEDIUM" && state.exchanges === 0) {
|
|
54
|
+
return {
|
|
55
|
+
result: HookResult.INJECT,
|
|
56
|
+
modifiedRoute: `${route}?echo=confirm`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// LOW confidence: pause if first pass, otherwise proceed
|
|
61
|
+
if (state.level === "LOW" && state.exchanges === 0) {
|
|
62
|
+
return {
|
|
63
|
+
result: HookResult.INJECT,
|
|
64
|
+
modifiedRoute: `${route}?question=pause`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { result: HookResult.CONTINUE, modifiedRoute: route };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// DelegationDepthHook — PreToolUse, priority=60, phase=NORMAL
|
|
3
|
+
//
|
|
4
|
+
// Loop guard — track sub-agent call depth.
|
|
5
|
+
// If depth > 5, STOP and escalate.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import { HookPhase, HookResult } from "../types.ts";
|
|
9
|
+
import type { HookContext, PreToolUseHook } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
/** Module-level depth tracker — maps sessionId to current depth */
|
|
12
|
+
const depthTrackers = new Map<string, number>();
|
|
13
|
+
|
|
14
|
+
export function resetDepthTracker(): void {
|
|
15
|
+
depthTrackers.clear();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getDepth(sessionId: string): number {
|
|
19
|
+
return depthTrackers.get(sessionId) ?? 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const delegationDepthHook: PreToolUseHook = {
|
|
23
|
+
metadata: {
|
|
24
|
+
name: "delegation-depth",
|
|
25
|
+
priority: 60,
|
|
26
|
+
phase: HookPhase.NORMAL,
|
|
27
|
+
dependencies: [],
|
|
28
|
+
errorHandling: "propagate",
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async execute(context: HookContext) {
|
|
32
|
+
const sessionId = context.sessionId;
|
|
33
|
+
|
|
34
|
+
// Bump depth
|
|
35
|
+
const currentDepth = (depthTrackers.get(sessionId) ?? 0) + 1;
|
|
36
|
+
depthTrackers.set(sessionId, currentDepth);
|
|
37
|
+
|
|
38
|
+
// The configured limit (can be overridden via context)
|
|
39
|
+
const maxDepth = (context._maxDelegationDepth as number) ?? 5;
|
|
40
|
+
|
|
41
|
+
if (currentDepth >= maxDepth) {
|
|
42
|
+
return {
|
|
43
|
+
result: HookResult.STOP,
|
|
44
|
+
modifiedContext: {
|
|
45
|
+
_depthExceeded: true,
|
|
46
|
+
_depthError: `LOOP GUARD: Delegation depth exceeded (max ${maxDepth}). Surface to orchestrator with findings and stop delegating.`,
|
|
47
|
+
_delegationDepth: currentDepth,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
result: HookResult.CONTINUE,
|
|
54
|
+
modifiedContext: {
|
|
55
|
+
_delegationDepth: currentDepth,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ErrorRecoveryHook — PostToolUse, priority=50, phase=LATE
|
|
3
|
+
//
|
|
4
|
+
// After sub-agent call, check if output indicates error.
|
|
5
|
+
// Use the RecoveryHandler to match patterns and inject recovery actions.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import { HookPhase, HookResult } from "../types.ts";
|
|
9
|
+
import type { HookContext, PostToolUseHook } from "../types.ts";
|
|
10
|
+
import { RecoveryHandler } from "../../recovery/handler.ts";
|
|
11
|
+
import type { ErrorContext } from "../../recovery/interfaces.ts";
|
|
12
|
+
|
|
13
|
+
export const errorRecoveryHook: PostToolUseHook = {
|
|
14
|
+
metadata: {
|
|
15
|
+
name: "error-recovery",
|
|
16
|
+
priority: 50,
|
|
17
|
+
phase: HookPhase.LATE,
|
|
18
|
+
dependencies: [],
|
|
19
|
+
errorHandling: "isolate",
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async execute(context: HookContext, output: string) {
|
|
23
|
+
// Check if the output looks like an error
|
|
24
|
+
const isErrorOutput = looksLikeError(output);
|
|
25
|
+
if (!isErrorOutput) {
|
|
26
|
+
return { result: HookResult.CONTINUE };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Classify the error and get a recovery action
|
|
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
|
+
};
|
|
38
|
+
|
|
39
|
+
const action = handler.handleError(errorContext);
|
|
40
|
+
|
|
41
|
+
// Build a recovery instruction based on the action
|
|
42
|
+
const recoveryInstruction = buildRecoveryInstruction(action);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
result: HookResult.INJECT,
|
|
46
|
+
modifiedOutput: output,
|
|
47
|
+
injectRecovery: recoveryInstruction,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Heuristic check: does the output look like an error?
|
|
54
|
+
* Looks for common error patterns in tool output.
|
|
55
|
+
*/
|
|
56
|
+
function looksLikeError(output: string): boolean {
|
|
57
|
+
if (!output || output.length === 0) return false;
|
|
58
|
+
|
|
59
|
+
const errorPatterns = [
|
|
60
|
+
/error/i,
|
|
61
|
+
/exception/i,
|
|
62
|
+
/failed/i,
|
|
63
|
+
/failure/i,
|
|
64
|
+
/unable to/i,
|
|
65
|
+
/could not/i,
|
|
66
|
+
/not found/i,
|
|
67
|
+
/ECONNREFUSED/i,
|
|
68
|
+
/ETIMEDOUT/i,
|
|
69
|
+
/rate.?limited/i,
|
|
70
|
+
/too many requests/i,
|
|
71
|
+
/context.?length/i,
|
|
72
|
+
/token.?limit/i,
|
|
73
|
+
/parse.?error/i,
|
|
74
|
+
/syntax.?error/i,
|
|
75
|
+
/timeout/i,
|
|
76
|
+
/execution.?timed.?out/i,
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// Check first 2000 chars to avoid false positives in long output
|
|
80
|
+
const head = output.slice(0, 2000);
|
|
81
|
+
return errorPatterns.some((p) => p.test(head));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a recovery instruction string from a RecoveryAction.
|
|
86
|
+
*/
|
|
87
|
+
function buildRecoveryInstruction(
|
|
88
|
+
action: { type: string; delay?: number; maxAttempts?: number; reason: string; modifyPrompt?: string },
|
|
89
|
+
): string {
|
|
90
|
+
const parts: string[] = [
|
|
91
|
+
`[HOOK: Error Recovery]`,
|
|
92
|
+
`Action: ${action.type}`,
|
|
93
|
+
`Reason: ${action.reason}`,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
if (action.delay) {
|
|
97
|
+
parts.push(`Delay: ${action.delay}ms before retry`);
|
|
98
|
+
}
|
|
99
|
+
if (action.maxAttempts) {
|
|
100
|
+
parts.push(`Max attempts: ${action.maxAttempts}`);
|
|
101
|
+
}
|
|
102
|
+
if (action.modifyPrompt) {
|
|
103
|
+
parts.push(`Modification: ${action.modifyPrompt}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return parts.join("\n");
|
|
107
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// MemorySyncHook — PostToolUse, priority=40, phase=LATE
|
|
3
|
+
//
|
|
4
|
+
// After each step, sync memory entries to plan file.
|
|
5
|
+
// Uses MemoryManager from harness/lib/memory/memory-manager.ts
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import { HookPhase, HookResult } from "../types.ts";
|
|
9
|
+
import type { HookContext, PostToolUseHook } from "../types.ts";
|
|
10
|
+
import { MemoryManager } from "../../memory/memory-manager.ts";
|
|
11
|
+
import { PlanStore } from "../../memory/plan-store.ts";
|
|
12
|
+
import { findLatestPlanFile } from "../../../../bootstrap.ts";
|
|
13
|
+
import { MemoryLevel } from "../../memory/interfaces.ts";
|
|
14
|
+
|
|
15
|
+
export const memorySyncHook: PostToolUseHook = {
|
|
16
|
+
metadata: {
|
|
17
|
+
name: "memory-sync",
|
|
18
|
+
priority: 40,
|
|
19
|
+
phase: HookPhase.LATE,
|
|
20
|
+
dependencies: [],
|
|
21
|
+
errorHandling: "isolate",
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async execute(context: HookContext, output: string) {
|
|
25
|
+
// Sync memory entries to plan file
|
|
26
|
+
const planFile = findLatestPlanFile(context.directory);
|
|
27
|
+
if (!planFile) {
|
|
28
|
+
// No plan file to sync to — skip silently
|
|
29
|
+
return { result: HookResult.CONTINUE };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const mem = MemoryManager.getInstance();
|
|
33
|
+
|
|
34
|
+
// Extract findings from the session memory (TASK level)
|
|
35
|
+
const taskEntries = mem.getEntries(MemoryLevel.TASK);
|
|
36
|
+
|
|
37
|
+
// Sync important task entries as plan findings
|
|
38
|
+
for (const entry of taskEntries) {
|
|
39
|
+
if (entry.importance >= 0.6) {
|
|
40
|
+
try {
|
|
41
|
+
await PlanStore.addFinding(planFile, context.sessionId, {
|
|
42
|
+
description: entry.content,
|
|
43
|
+
severity: entry.importance >= 0.8 ? "warning" : "info",
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
// Plan sync is best-effort — don't break execution
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Sync any decisions
|
|
52
|
+
const missionEntries = mem.getEntries(MemoryLevel.MISSION);
|
|
53
|
+
for (const entry of missionEntries) {
|
|
54
|
+
if (entry.metadata?.type === "decision" && entry.importance >= 0.7) {
|
|
55
|
+
try {
|
|
56
|
+
await PlanStore.addDecision(planFile, context.sessionId, {
|
|
57
|
+
description: entry.content,
|
|
58
|
+
rationale: (entry.metadata.rationale as string) ?? "Auto-synced decision",
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
// Best-effort
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
result: HookResult.CONTINUE,
|
|
68
|
+
modifiedContext: {
|
|
69
|
+
_memorySyncCount: taskEntries.filter((e) => e.importance >= 0.6).length,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// PlanCheckHook — PreToolUse, priority=90, phase=EARLY
|
|
3
|
+
//
|
|
4
|
+
// Before any sub-agent call, verify plan file exists at the expected path.
|
|
5
|
+
// If missing, inject "create plan first" instruction.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import { HookPhase, HookResult } from "../types.ts";
|
|
9
|
+
import type { HookContext, PreToolUseHook } from "../types.ts";
|
|
10
|
+
import { findLatestPlanFile } from "../../../../bootstrap.ts";
|
|
11
|
+
|
|
12
|
+
export const planCheckHook: PreToolUseHook = {
|
|
13
|
+
metadata: {
|
|
14
|
+
name: "plan-check",
|
|
15
|
+
priority: 90,
|
|
16
|
+
phase: HookPhase.EARLY,
|
|
17
|
+
dependencies: [],
|
|
18
|
+
errorHandling: "propagate",
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async execute(context: HookContext) {
|
|
22
|
+
const planFile = findLatestPlanFile(context.directory);
|
|
23
|
+
|
|
24
|
+
if (!planFile) {
|
|
25
|
+
return {
|
|
26
|
+
result: HookResult.INJECT,
|
|
27
|
+
modifiedContext: {
|
|
28
|
+
_planCheck: "missing",
|
|
29
|
+
_planCheckInstruction:
|
|
30
|
+
"No plan file found. Create a plan first before proceeding with any sub-agent tasks.",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
result: HookResult.CONTINUE,
|
|
37
|
+
modifiedContext: {
|
|
38
|
+
_planCheck: "found",
|
|
39
|
+
_planFilePath: planFile,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// RouteTrackingHook — RouteHook, priority=55, phase=LATE
|
|
3
|
+
//
|
|
4
|
+
// Loop guard — mechanically enforce two limits:
|
|
5
|
+
// 1. Same skill visited 5+ times in one chain
|
|
6
|
+
// 2. 8+ consecutive unproductive hops
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import { HookPhase, HookResult } from "../types.ts";
|
|
10
|
+
import type { HookContext, RouteHook } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Types
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export interface HopRecord {
|
|
17
|
+
skill: string;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
producedArtifact: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RouteTrackingConfig {
|
|
23
|
+
maxSkillRepeats: number;
|
|
24
|
+
maxUnproductiveHops: number;
|
|
25
|
+
artifactCheck: (route: string) => boolean | Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface RouteTrackingState {
|
|
29
|
+
hops: HopRecord[];
|
|
30
|
+
skillCounts: Map<string, number>;
|
|
31
|
+
unproductiveCount: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Module-level state
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const sessionStates = new Map<string, RouteTrackingState>();
|
|
39
|
+
|
|
40
|
+
export function resetRouteTracker(sessionId?: string): void {
|
|
41
|
+
if (sessionId) {
|
|
42
|
+
sessionStates.delete(sessionId);
|
|
43
|
+
} else {
|
|
44
|
+
sessionStates.clear();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getHopHistory(sessionId: string): HopRecord[] {
|
|
49
|
+
return sessionStates.get(sessionId)?.hops ?? [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Default artifact check (conservative — assumes unproductive)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const defaultArtifactCheck: (route: string) => boolean = () => false;
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Hook
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export const routeTrackingHook: RouteHook = {
|
|
63
|
+
metadata: {
|
|
64
|
+
name: "route-tracking",
|
|
65
|
+
priority: 55,
|
|
66
|
+
phase: HookPhase.LATE,
|
|
67
|
+
dependencies: [],
|
|
68
|
+
errorHandling: "propagate",
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async execute(context: HookContext, route: string) {
|
|
72
|
+
// Skip terminal routes — they don't count as routing hops
|
|
73
|
+
const terminalRoutes = new Set(["surface", "done", "oh-handoff"]);
|
|
74
|
+
if (terminalRoutes.has(route)) {
|
|
75
|
+
return { result: HookResult.CONTINUE, modifiedRoute: route };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sessionId = context.sessionId;
|
|
79
|
+
|
|
80
|
+
// Get or create state for this session
|
|
81
|
+
let state = sessionStates.get(sessionId);
|
|
82
|
+
if (!state) {
|
|
83
|
+
state = {
|
|
84
|
+
hops: [],
|
|
85
|
+
skillCounts: new Map<string, number>(),
|
|
86
|
+
unproductiveCount: 0,
|
|
87
|
+
};
|
|
88
|
+
sessionStates.set(sessionId, state);
|
|
89
|
+
}
|
|
90
|
+
|
|
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;
|
|
98
|
+
|
|
99
|
+
// Record the hop
|
|
100
|
+
const producedArtifact = await artifactCheck(route);
|
|
101
|
+
const hop: HopRecord = {
|
|
102
|
+
skill: route,
|
|
103
|
+
timestamp: Date.now(),
|
|
104
|
+
producedArtifact,
|
|
105
|
+
};
|
|
106
|
+
state.hops.push(hop);
|
|
107
|
+
|
|
108
|
+
// Update skill count
|
|
109
|
+
const currentSkillCount = (state.skillCounts.get(route) ?? 0) + 1;
|
|
110
|
+
state.skillCounts.set(route, currentSkillCount);
|
|
111
|
+
|
|
112
|
+
// Update unproductive counter (resets on any productive hop)
|
|
113
|
+
if (producedArtifact) {
|
|
114
|
+
state.unproductiveCount = 0;
|
|
115
|
+
} else {
|
|
116
|
+
state.unproductiveCount += 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
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,
|
|
126
|
+
maxSkillRepeats,
|
|
127
|
+
maxUnproductiveHops,
|
|
128
|
+
};
|
|
129
|
+
return { result: HookResult.STOP };
|
|
130
|
+
}
|
|
131
|
+
|
|
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,
|
|
139
|
+
maxSkillRepeats,
|
|
140
|
+
maxUnproductiveHops,
|
|
141
|
+
};
|
|
142
|
+
return { result: HookResult.STOP };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { result: HookResult.CONTINUE, modifiedRoute: route };
|
|
146
|
+
},
|
|
147
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// SanityCheckHook — PostToolUse, priority=30, phase=LATE
|
|
3
|
+
//
|
|
4
|
+
// After each tool invocation, check the output for LLM degeneration patterns
|
|
5
|
+
// (repetition, low diversity, gibberish, etc.). Track consecutive anomalies
|
|
6
|
+
// per session. If 2+ consecutive anomalies detected, inject a recovery
|
|
7
|
+
// instruction to compact/refresh context before it cascades.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import { HookPhase, HookResult } from "../types.ts";
|
|
11
|
+
import type { HookContext, PostToolUseHook } from "../types.ts";
|
|
12
|
+
import { checkOutputSanity } from "../../sanity/checker.ts";
|
|
13
|
+
import { AnomalyTracker } from "../../sanity/anomaly-tracker.ts";
|
|
14
|
+
|
|
15
|
+
export const sanityCheckHook: PostToolUseHook = {
|
|
16
|
+
metadata: {
|
|
17
|
+
name: "sanity-check",
|
|
18
|
+
priority: 30,
|
|
19
|
+
phase: HookPhase.LATE,
|
|
20
|
+
dependencies: [],
|
|
21
|
+
errorHandling: "isolate",
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async execute(context: HookContext, output: string) {
|
|
25
|
+
const sessionId = context.sessionId;
|
|
26
|
+
|
|
27
|
+
// Run the sanity checker on the output
|
|
28
|
+
const result = checkOutputSanity(output);
|
|
29
|
+
|
|
30
|
+
// Record the result in the anomaly tracker
|
|
31
|
+
const tracker = AnomalyTracker.getInstance();
|
|
32
|
+
const tracking = tracker.record(sessionId, result);
|
|
33
|
+
|
|
34
|
+
if (result.isHealthy) {
|
|
35
|
+
// Output is healthy — no action needed
|
|
36
|
+
return { result: HookResult.CONTINUE };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Unhealthy output detected
|
|
40
|
+
if (tracking.shouldEscalate) {
|
|
41
|
+
// Escalation threshold reached — inject recovery instruction
|
|
42
|
+
return {
|
|
43
|
+
result: HookResult.INJECT,
|
|
44
|
+
modifiedOutput: output,
|
|
45
|
+
injectRecovery: tracking.recoveryMessage ?? "recovery: compact context",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// First anomaly (below threshold) — let it pass with a warning
|
|
50
|
+
return { result: HookResult.CONTINUE };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ShellDetectHook — PreToolUse, priority=80, phase=EARLY
|
|
3
|
+
//
|
|
4
|
+
// Before sub-agent calls that need CLI, inject SHELL.md preamble.
|
|
5
|
+
// Detect platform, add appropriate shell context.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import { HookPhase, HookResult } from "../types.ts";
|
|
9
|
+
import type { HookContext, PreToolUseHook } from "../types.ts";
|
|
10
|
+
import { getHarnessDir } from "../../../../lib/harness-resolver.ts";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
|
|
15
|
+
export const shellDetectHook: PreToolUseHook = {
|
|
16
|
+
metadata: {
|
|
17
|
+
name: "shell-detect",
|
|
18
|
+
priority: 80,
|
|
19
|
+
phase: HookPhase.EARLY,
|
|
20
|
+
dependencies: [],
|
|
21
|
+
errorHandling: "isolate",
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async execute(context: HookContext) {
|
|
25
|
+
const platform = os.platform();
|
|
26
|
+
const isWindows = platform === "win32";
|
|
27
|
+
|
|
28
|
+
// Detect shell type
|
|
29
|
+
let shellType = "unknown";
|
|
30
|
+
if (isWindows) {
|
|
31
|
+
// On Windows: detect PowerShell, CMD, or Git Bash
|
|
32
|
+
const comSpec = process.env.COMSPEC ?? "";
|
|
33
|
+
if (process.env.PSModulePath || process.env.PSExecutionPolicy) {
|
|
34
|
+
shellType = "powershell";
|
|
35
|
+
} else if (comSpec.toLowerCase().includes("cmd")) {
|
|
36
|
+
shellType = "cmd";
|
|
37
|
+
} else {
|
|
38
|
+
// Could be Git Bash
|
|
39
|
+
shellType = process.env.BASH ? "bash" : "powershell";
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
shellType = "bash";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Try to load SHELL.md
|
|
46
|
+
let shellPreamble = "";
|
|
47
|
+
try {
|
|
48
|
+
const shellDocPath = path.join(getHarnessDir(), "instructions", "SHELL.md");
|
|
49
|
+
await fs.promises.access(shellDocPath);
|
|
50
|
+
shellPreamble = (await fs.promises.readFile(shellDocPath, "utf8")).trim();
|
|
51
|
+
} catch {
|
|
52
|
+
// If SHELL.md can't be read, provide minimal preamble
|
|
53
|
+
shellPreamble = "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
result: HookResult.CONTINUE,
|
|
58
|
+
modifiedContext: {
|
|
59
|
+
_shellPlatform: platform,
|
|
60
|
+
_shellType: shellType,
|
|
61
|
+
_shellPreamble: shellPreamble || getDefaultPreamble(shellType, platform),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function getDefaultPreamble(shellType: string, _platform: string): string {
|
|
68
|
+
if (shellType === "powershell") {
|
|
69
|
+
return [
|
|
70
|
+
"## Shell Environment",
|
|
71
|
+
"",
|
|
72
|
+
"Detected: PowerShell on Windows",
|
|
73
|
+
"- File ops, scoop installs, ps1 scripts, env vars → PowerShell",
|
|
74
|
+
"- git, bun, npm, node → any shell (all work)",
|
|
75
|
+
"- rm -rf, make, unix scripts → Git Bash",
|
|
76
|
+
"",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
if (shellType === "cmd") {
|
|
80
|
+
return [
|
|
81
|
+
"## Shell Environment",
|
|
82
|
+
"",
|
|
83
|
+
"Detected: CMD on Windows",
|
|
84
|
+
"- .bat/.cmd scripts → CMD",
|
|
85
|
+
"- git, bun, npm, node → any shell",
|
|
86
|
+
"",
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
return [
|
|
90
|
+
"## Shell Environment",
|
|
91
|
+
"",
|
|
92
|
+
`Detected: ${shellType}`,
|
|
93
|
+
"- Standard POSIX shell commands available",
|
|
94
|
+
"",
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|