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
package/CONTEXT.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# OpenHermes — Shared Language
|
|
2
2
|
|
|
3
3
|
## Terms
|
|
4
|
-
**OpenHermes** — OpenCode-native orchestration layer for this package.
|
|
5
|
-
**Skill** — A `SKILL.md` loaded on demand through OpenCode's skill tool.
|
|
6
|
-
**Command** — A slash command backed by package-local markdown
|
|
7
|
-
**Agent** — A primary or subagent definition loaded through OpenCode config.
|
|
8
|
-
**Instruction** — Markdown loaded through `AGENTS.md` or `opencode.json` instructions.
|
|
9
|
-
**Bootstrap** — The first-message context injected by the OpenHermes plugin.
|
|
4
|
+
**OpenHermes** — OpenCode-native orchestration layer for this package.
|
|
5
|
+
**Skill** — A `SKILL.md` loaded on demand through OpenCode's skill tool.
|
|
6
|
+
**Command** — A slash command backed by package-local command markdown; legacy compatibility loaders remain only where runtime-backed.
|
|
7
|
+
**Agent** — A primary or subagent definition loaded through OpenCode config.
|
|
8
|
+
**Instruction** — Markdown loaded through `AGENTS.md` or `opencode.json` instructions.
|
|
9
|
+
**Bootstrap** — The first-message context injected by the OpenHermes plugin.
|
|
10
10
|
|
|
11
11
|
### Confidence Gate Terms
|
|
12
12
|
**Confidence Gate** — Phase 0.5 protocol in the autopilot loop that evaluates signal strength before routing. Bounded to 1 conversational exchange max.
|
package/ETHOS.md
CHANGED
|
@@ -8,8 +8,8 @@ OpenCode-native loading over manual copying or hidden state.
|
|
|
8
8
|
## Small Surface
|
|
9
9
|
Every file earns its keep. Prefer markdown when behavior is declarative.
|
|
10
10
|
|
|
11
|
-
## Skills Over Glue
|
|
12
|
-
Behavior lives in `SKILL.md`,
|
|
11
|
+
## Skills Over Glue
|
|
12
|
+
Behavior lives in `SKILL.md`, command markdown, and agent markdown. Legacy command-doc compatibility loaders remain supported only where runtime-backed.
|
|
13
13
|
|
|
14
14
|
## Always Delegate — Never Execute
|
|
15
15
|
OpenHermes orchestrates and reports. Sub-agents execute. OpenHermes never writes code, runs tests, or touches files directly.
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<a href="https://www.npmjs.com/package/openhermes"><img src="https://img.shields.io/npm/v/openhermes?style=for-the-badge&label=version&color=FFD700" alt="v4.11.
|
|
8
|
+
<a href="https://www.npmjs.com/package/openhermes"><img src="https://img.shields.io/npm/v/openhermes?style=for-the-badge&label=version&color=FFD700" alt="v4.11.3"></a>
|
|
9
9
|
<a href="https://github.com/nathwn12/openhermes/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
|
10
10
|
<a href="https://opencode.ai"><img src="https://img.shields.io/badge/runs%20on-OpenCode-6366f1?style=for-the-badge" alt="Runs on OpenCode"></a>
|
|
11
11
|
<a href="https://github.com/nathwn12/openhermes"><img src="https://img.shields.io/badge/⭐%20star%20on-GitHub-181717?style=for-the-badge" alt="Star on GitHub"></a>
|
|
@@ -44,7 +44,7 @@ OpenHermes v4.11 ships with a hardened internal architecture — 8 subsystems wo
|
|
|
44
44
|
| **MVCC Sync** | Atomic writes, version counters, conflict detection. Multiple sub-agents writing the same plan file — no data loss. |
|
|
45
45
|
| **Sanity Checker** | 8 output degeneration detectors — repetition, gibberish, low diversity — with automatic escalation and recovery injection. |
|
|
46
46
|
| **Background Cmd** | Fire-and-forget process spawning with timeout, status polling, and auto-cleanup. Non-blocking long-running tasks. |
|
|
47
|
-
| **
|
|
47
|
+
| **Plan Location** | Resolves plan file paths per project with directory-per-project layout in `~/.local/share/openhermes/plans/`. |
|
|
48
48
|
|
|
49
49
|
---
|
|
50
50
|
|
|
@@ -72,14 +72,14 @@ The loop runs unsupervised because these never turn off:
|
|
|
72
72
|
|---|---|
|
|
73
73
|
| **Self-driving loop** | Type once. OpenHermes classifies, delegates, and routes — no pauses, no asking permission, no verbosity. |
|
|
74
74
|
| **30 specialist skills** | Planning → building → testing → browser → security → review → shipping → retro. Every dev cycle phase. |
|
|
75
|
-
| **Auto-detected user skills** | Drop a skill in `~/.agents/skills/`. OpenHermes finds it. Same name as a built-in? Your version wins. Survives `npm update`. |
|
|
75
|
+
| **Auto-detected user skills** | Drop a skill in `~/.agents/skills/` or `~/.config/opencode/skills/`. OpenHermes finds it. Same name as a built-in? Your version wins. Survives `npm update`. |
|
|
76
76
|
| **Shared operating model** | CHARTER + AUTOPILOT + CONTEXT + ETHOS injected every session. Every interaction grounded in the same rules. |
|
|
77
77
|
| **CORE/DEEP skill format** | Every skill is a two-file system: CORE (SKILL.md) handles 80% of passes in one read. DEEP.md loads on demand for hard cases. |
|
|
78
78
|
| **Plan file storage** | `~/.local/share/openhermes/plans/`. Survives `npm update`. |
|
|
79
|
-
| **8 internal subsystems** |
|
|
79
|
+
| **8 internal subsystems** | Composer, recovery, memory, sync, hooks, plans, sanity, background — all native Node.js / TypeScript. |
|
|
80
80
|
| **Zero npm dependency additions** | All new subsystems use native Node.js and TypeScript only. No new packages. |
|
|
81
81
|
|
|
82
|
-
## 30 skills —
|
|
82
|
+
## 30 skills — four tiers
|
|
83
83
|
|
|
84
84
|
### Tier 4 — Pipeline orchestrators
|
|
85
85
|
Full multi-phase workflows:
|
|
@@ -143,17 +143,17 @@ openhermes-pkg/
|
|
|
143
143
|
├── harness/
|
|
144
144
|
│ ├── agents/ # Agent manifests (OpenHermes primary)
|
|
145
145
|
│ ├── codex/ # CHARTER, AUTOPILOT
|
|
146
|
-
│ ├──
|
|
147
|
-
│ ├── instructions/ # SHELL.md
|
|
146
|
+
│ ├── instructions/ # SHELL.md
|
|
148
147
|
│ ├── lib/ # Internal subsystems
|
|
149
148
|
│ │ ├── composer/ # Prompt fragment composition
|
|
150
149
|
│ │ ├── recovery/ # Auto-recovery with error patterns
|
|
151
150
|
│ │ ├── memory/ # 4-tier hierarchical memory
|
|
152
151
|
│ │ ├── sync/ # MVCC plan synchronization
|
|
153
152
|
│ │ ├── hooks/ # Pluggable hook registry
|
|
153
|
+
│ │ ├── plans/ # Plan file path resolution
|
|
154
154
|
│ │ ├── sanity/ # Output degeneration detection
|
|
155
155
|
│ │ └── background/ # Fire-and-forget command system
|
|
156
|
-
│ └── skills/ # 30 skill SKILL.md files (CORE/DEEP format)
|
|
156
|
+
│ └── skills/ # 30 skill SKILL.md files (CORE/DEEP format)
|
|
157
157
|
├── lib/ # harness-resolver.ts
|
|
158
158
|
└── test/
|
|
159
159
|
└── harness/ # Test utilities (fixture, builders, mocks)
|
package/bootstrap.ts
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
|
-
import path from "node:path"
|
|
2
|
-
import fs from "node:fs"
|
|
3
|
-
import os from "node:os"
|
|
4
|
-
import type { Plugin } from "@opencode-ai/plugin"
|
|
5
|
-
import { getHarnessDir, setHarnessRootForTest, resolveHarnessRoot } from "./lib/harness-resolver.ts"
|
|
6
|
-
import { compose } from "./harness/lib/composer/index.ts"
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
5
|
+
import { getHarnessDir, setHarnessRootForTest, resolveHarnessRoot } from "./lib/harness-resolver.ts"
|
|
6
|
+
import { compose } from "./harness/lib/composer/index.ts"
|
|
7
|
+
import { ensurePlanFile, findLatestPlanFile, planStorageDir, setPlanStorageDirForTest, resolvePlanAccess } from "./harness/lib/plans/plan-location.ts"
|
|
8
|
+
import { clearRuntimeRouteDecision, consumeRouteGuidance, getRuntimeRouteDecision, rememberRuntimeRouteDecision } from "./harness/lib/routing/index.ts"
|
|
7
9
|
|
|
8
10
|
// Hook system — pluggable lifecycle hooks with topological sort
|
|
9
11
|
import {
|
|
10
|
-
HookRegistry,
|
|
11
|
-
HookResult,
|
|
12
|
-
|
|
12
|
+
HookRegistry,
|
|
13
|
+
HookResult,
|
|
14
|
+
nextRouteHook,
|
|
15
|
+
planCheckHook,
|
|
13
16
|
shellDetectHook,
|
|
14
17
|
confidenceGateHook,
|
|
15
18
|
delegationDepthHook,
|
|
16
19
|
resetDepthTracker,
|
|
17
20
|
errorRecoveryHook,
|
|
18
|
-
memorySyncHook,
|
|
19
|
-
sanityCheckHook,
|
|
20
|
-
|
|
21
|
+
memorySyncHook,
|
|
22
|
+
sanityCheckHook,
|
|
23
|
+
dynamicRouteHook,
|
|
24
|
+
routeTrackingHook,
|
|
25
|
+
subagentFailureHook,
|
|
26
|
+
resetSubagentFailures,
|
|
27
|
+
DEFAULT_GUARD_CONFIG,
|
|
21
28
|
} from "./harness/lib/hooks/index.ts"
|
|
29
|
+
import type { HookContext } from "./harness/lib/hooks/index.ts"
|
|
22
30
|
|
|
23
31
|
const OPENHERMES_AGENT = "OpenHermes"
|
|
24
32
|
|
|
@@ -29,18 +37,7 @@ const USER_SKILL_DIRS: ReadonlyArray<string> = [
|
|
|
29
37
|
path.join(os.homedir(), ".claude", "skills"), // Claude Code backward compat
|
|
30
38
|
]
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
let _planStorageOverride: string | undefined
|
|
34
|
-
export function setPlanStorageDirForTest(dir: string | undefined): void { _planStorageOverride = dir }
|
|
35
|
-
function planStorageDir(): string {
|
|
36
|
-
return _planStorageOverride ?? path.join(os.homedir(), ".local", "share", "openhermes", "plans")
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function getProjectName(projectDir: string): string {
|
|
40
|
-
return path.basename(projectDir)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export { resolveHarnessRoot, setHarnessRootForTest, getHarnessDir, ensurePlanFile, findLatestPlanFile }
|
|
40
|
+
export { resolveHarnessRoot, setHarnessRootForTest, getHarnessDir, ensurePlanFile, findLatestPlanFile, setPlanStorageDirForTest }
|
|
44
41
|
|
|
45
42
|
function parseFrontmatter(raw: string | undefined): Record<string, string> {
|
|
46
43
|
const frontmatter: Record<string, string> = {}
|
|
@@ -55,12 +52,12 @@ function parseFrontmatter(raw: string | undefined): Record<string, string> {
|
|
|
55
52
|
return frontmatter
|
|
56
53
|
}
|
|
57
54
|
|
|
58
|
-
interface MarkdownDocument {
|
|
59
|
-
frontmatter: Record<string, string>
|
|
60
|
-
body: string
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function readMarkdownDocument(filePath: string): MarkdownDocument | null {
|
|
55
|
+
interface MarkdownDocument {
|
|
56
|
+
frontmatter: Record<string, string>
|
|
57
|
+
body: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readMarkdownDocument(filePath: string): MarkdownDocument | null {
|
|
64
61
|
if (!fs.existsSync(filePath)) return null
|
|
65
62
|
const source = fs.readFileSync(filePath, "utf8")
|
|
66
63
|
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
|
|
@@ -86,7 +83,7 @@ function readMarkdownDirectory(dir: string): DirEntry[] {
|
|
|
86
83
|
.filter((e): e is DirEntry => e !== null)
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
interface CommandDef {
|
|
86
|
+
interface CommandDef {
|
|
90
87
|
description: string
|
|
91
88
|
template: string
|
|
92
89
|
agent?: string
|
|
@@ -109,7 +106,7 @@ function commandDefinitions(dir: string): Record<string, CommandDef> {
|
|
|
109
106
|
return commands
|
|
110
107
|
}
|
|
111
108
|
|
|
112
|
-
interface AgentDef {
|
|
109
|
+
interface AgentDef {
|
|
113
110
|
description: string
|
|
114
111
|
mode: string
|
|
115
112
|
prompt: string
|
|
@@ -140,47 +137,7 @@ function uniqueStrings(existing: string[] = [], additions: string[] = []): strin
|
|
|
140
137
|
}
|
|
141
138
|
|
|
142
139
|
|
|
143
|
-
function
|
|
144
|
-
const projectName = getProjectName(projectDir)
|
|
145
|
-
const storage = planStorageDir()
|
|
146
|
-
const projectDirPath = path.join(storage, projectName)
|
|
147
|
-
if (!fs.existsSync(projectDirPath)) return null
|
|
148
|
-
let latest: string | null = null
|
|
149
|
-
let highest = -1
|
|
150
|
-
try {
|
|
151
|
-
for (const entry of fs.readdirSync(projectDirPath)) {
|
|
152
|
-
const m = entry.match(/^plan-(\d{3})\.md$/)
|
|
153
|
-
if (m) {
|
|
154
|
-
const n = parseInt(m[1], 10)
|
|
155
|
-
if (n > highest) {
|
|
156
|
-
highest = n
|
|
157
|
-
latest = path.join(projectDirPath, entry)
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
} catch {
|
|
162
|
-
return null
|
|
163
|
-
}
|
|
164
|
-
return latest
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function readPlanFromFile(filePath: string): string | null {
|
|
168
|
-
if (!fs.existsSync(filePath)) return null
|
|
169
|
-
const source = fs.readFileSync(filePath, "utf8")
|
|
170
|
-
const status = source.match(/^Status:\s*(.+)$/m)?.[1]?.trim()
|
|
171
|
-
const objective = source.match(/^Objective:\s*(.+)$/m)?.[1]?.trim()
|
|
172
|
-
if (!status && !objective) return null
|
|
173
|
-
const parts = [status ? `status=${status}` : null, objective ? `objective=${objective}` : null].filter(Boolean)
|
|
174
|
-
return `Active plan: ${parts.join(" | ")}`
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function readPlanSummary(projectDir: string): string | null {
|
|
178
|
-
const planFile = findLatestPlanFile(projectDir)
|
|
179
|
-
if (!planFile) return null
|
|
180
|
-
return readPlanFromFile(planFile)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function ensureDir(dir: string): void {
|
|
140
|
+
function ensureDir(dir: string): void {
|
|
184
141
|
try {
|
|
185
142
|
if (!fs.existsSync(dir)) {
|
|
186
143
|
fs.mkdirSync(dir, { recursive: true })
|
|
@@ -192,77 +149,15 @@ function ensureDir(dir: string): void {
|
|
|
192
149
|
}
|
|
193
150
|
}
|
|
194
151
|
|
|
195
|
-
|
|
196
|
-
* Ensure a plan file exists for the project.
|
|
197
|
-
* Creates a skeleton plan if none exists or if the latest is complete/abandoned.
|
|
198
|
-
* Reuses an existing active or in-progress plan.
|
|
199
|
-
* Returns the path to the plan file.
|
|
200
|
-
*/
|
|
201
|
-
function ensurePlanFile(projectDir: string): string {
|
|
202
|
-
const projectName = getProjectName(projectDir)
|
|
203
|
-
const storage = planStorageDir()
|
|
204
|
-
const projectDirPath = path.join(storage, projectName)
|
|
205
|
-
ensureDir(projectDirPath)
|
|
206
|
-
|
|
207
|
-
// Reuse active or in-progress plan
|
|
208
|
-
const latest = findLatestPlanFile(projectDir)
|
|
209
|
-
if (latest) {
|
|
210
|
-
const content = fs.readFileSync(latest, "utf8")
|
|
211
|
-
const status = content.match(/^Status:\s*(.+)$/m)?.[1]?.trim()
|
|
212
|
-
if (status === "active" || status === "in-progress") {
|
|
213
|
-
return latest
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Determine next sequence number
|
|
218
|
-
let nextSeq = 1
|
|
219
|
-
if (latest) {
|
|
220
|
-
const m = path.basename(latest).match(/^plan-(\d{3})\.md$/)
|
|
221
|
-
if (m) nextSeq = parseInt(m[1], 10) + 1
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const seq = String(nextSeq).padStart(3, "0")
|
|
225
|
-
const planId = `${projectName}/plan-${seq}.md`
|
|
226
|
-
const planPath = path.join(projectDirPath, `plan-${seq}.md`)
|
|
227
|
-
const now = new Date().toISOString().replace("T", " ").slice(0, 16)
|
|
228
|
-
|
|
229
|
-
const content = [
|
|
230
|
-
`# PLAN: ${projectName}`,
|
|
231
|
-
"",
|
|
232
|
-
`Plan ID: ${planId}`,
|
|
233
|
-
`Project: ${projectName}`,
|
|
234
|
-
`Status: active`,
|
|
235
|
-
`Created: ${now}`,
|
|
236
|
-
`Updated: ${now}`,
|
|
237
|
-
`Project Path: ${projectDir}`,
|
|
238
|
-
`Plan Path: ${planPath}`,
|
|
239
|
-
`Objective: (pending classification)`,
|
|
240
|
-
"",
|
|
241
|
-
"## Tasks",
|
|
242
|
-
"",
|
|
243
|
-
"- [ ] (discoverable — pending classification)",
|
|
244
|
-
"",
|
|
245
|
-
].join("\n")
|
|
246
|
-
|
|
247
|
-
try {
|
|
248
|
-
fs.writeFileSync(planPath, content, "utf8")
|
|
249
|
-
} catch (err) {
|
|
250
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
251
|
-
console.error(`[openhermes] Failed to write plan file ${planPath}: ${msg}`)
|
|
252
|
-
// Don't throw — let the plan system degrade gracefully
|
|
253
|
-
}
|
|
254
|
-
return planPath
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export function buildCompactionContext(projectDir: string): string[] {
|
|
152
|
+
export function buildCompactionContext(projectDir: string): string[] {
|
|
258
153
|
const context = [
|
|
259
154
|
"OpenHermes: native-first, verify before claim, always delegate, concise over verbose.",
|
|
260
155
|
"Preserve domain terms: skill, command, agent, bootstrap, compaction.",
|
|
261
156
|
"Preserve blockers, current task, and next steps; do not invent durable state.",
|
|
262
157
|
]
|
|
263
158
|
|
|
264
|
-
const planSummary =
|
|
265
|
-
if (planSummary) context.push(planSummary)
|
|
159
|
+
const planSummary = resolvePlanAccess(projectDir)?.summary
|
|
160
|
+
if (planSummary) context.push(planSummary)
|
|
266
161
|
|
|
267
162
|
return context
|
|
268
163
|
}
|
|
@@ -333,27 +228,38 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
|
|
|
333
228
|
// Ensure plan storage exists
|
|
334
229
|
try { ensureDir(planStorageDir()) } catch {}
|
|
335
230
|
|
|
231
|
+
|
|
232
|
+
|
|
336
233
|
return {
|
|
337
234
|
config: async (config: OpenHermesConfig) => {
|
|
235
|
+
|
|
338
236
|
// ── 1. Hooks System ─────────────────────────────────────────────────
|
|
339
237
|
// Read experimental.hooks config from the raw config object
|
|
340
|
-
const
|
|
238
|
+
const experimental = config.experimental as Record<string, unknown> | undefined;
|
|
239
|
+
const hooksConfig = (experimental?.hooks as
|
|
341
240
|
| Record<string, boolean>
|
|
342
|
-
| undefined
|
|
241
|
+
| undefined)
|
|
343
242
|
const hooksEnabled = (hooksConfig?.enabled ?? true) as boolean
|
|
344
243
|
|
|
345
244
|
if (hooksEnabled) {
|
|
346
245
|
const reg = HookRegistry.getInstance()
|
|
347
246
|
|
|
348
|
-
// Check individual hook flags (default: true if not specified)
|
|
349
|
-
if (hooksConfig?.plan_check ?? true) reg.registerPreTool(planCheckHook)
|
|
350
|
-
if (hooksConfig?.shell_detect ?? true) reg.registerPreTool(shellDetectHook)
|
|
351
|
-
if (hooksConfig?.delegation_depth ?? true) reg.registerPreTool(delegationDepthHook)
|
|
352
|
-
|
|
353
|
-
if (hooksConfig?.
|
|
354
|
-
if (hooksConfig?.
|
|
355
|
-
if (hooksConfig?.
|
|
356
|
-
if (hooksConfig?.
|
|
247
|
+
// Check individual hook flags (default: true if not specified)
|
|
248
|
+
if (hooksConfig?.plan_check ?? true) reg.registerPreTool(planCheckHook)
|
|
249
|
+
if (hooksConfig?.shell_detect ?? true) reg.registerPreTool(shellDetectHook)
|
|
250
|
+
if (hooksConfig?.delegation_depth ?? true) reg.registerPreTool(delegationDepthHook)
|
|
251
|
+
reg.registerRoute(nextRouteHook)
|
|
252
|
+
if (hooksConfig?.confidence_gate ?? true) reg.registerRoute(confidenceGateHook)
|
|
253
|
+
if (hooksConfig?.error_recovery ?? true) reg.registerPostTool(errorRecoveryHook)
|
|
254
|
+
if (hooksConfig?.memory_sync ?? true) reg.registerPostTool(memorySyncHook)
|
|
255
|
+
if (hooksConfig?.sanity_check ?? true) reg.registerPostTool(sanityCheckHook)
|
|
256
|
+
if (hooksConfig?.dynamic_route ?? true) reg.registerPostTool(dynamicRouteHook)
|
|
257
|
+
if (hooksConfig?.route_tracking ?? true) {
|
|
258
|
+
reg.registerRoute(routeTrackingHook)
|
|
259
|
+
} else {
|
|
260
|
+
reg.unregister("route-tracking")
|
|
261
|
+
}
|
|
262
|
+
if (hooksConfig?.subagent_failure ?? true) reg.registerPostTool(subagentFailureHook)
|
|
357
263
|
|
|
358
264
|
await logToOC("info", `hooks: ${reg.getPreToolHooks().length + reg.getPostToolHooks().length + reg.getRouteHooks().length} registered`)
|
|
359
265
|
} else {
|
|
@@ -444,11 +350,11 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
|
|
|
444
350
|
webfetch: "allow", // CAN fetch docs for context
|
|
445
351
|
question: "allow", // CAN ask user questions
|
|
446
352
|
websearch: "allow", // CAN search web for research context
|
|
447
|
-
external_directory: { // CAN read/write plan files outside worktree
|
|
448
|
-
"~/.local/share/
|
|
449
|
-
},
|
|
450
|
-
},
|
|
451
|
-
},
|
|
353
|
+
external_directory: { // CAN read/write plan files outside worktree
|
|
354
|
+
"~/.local/share/openhermes/plans/**": "allow",
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
},
|
|
452
358
|
}
|
|
453
359
|
|
|
454
360
|
config.default_agent = OPENHERMES_AGENT
|
|
@@ -464,9 +370,10 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
|
|
|
464
370
|
// creates plans on demand (see Task Flow step 1 in agent prompt).
|
|
465
371
|
// Auto-creation produced ghost skeletons like plan-004.
|
|
466
372
|
|
|
467
|
-
// Reset delegation depth on session start/error
|
|
373
|
+
// Reset delegation depth and subagent failures on session start/error
|
|
468
374
|
if (typed.type === "session.created" || typed.type === "session.error") {
|
|
469
375
|
resetDepthTracker()
|
|
376
|
+
resetSubagentFailures()
|
|
470
377
|
}
|
|
471
378
|
},
|
|
472
379
|
|
|
@@ -479,27 +386,26 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
|
|
|
479
386
|
if (input.tool === "task") {
|
|
480
387
|
const reg = HookRegistry.getInstance()
|
|
481
388
|
|
|
482
|
-
// Access optional fields from input (SDK may include these at runtime)
|
|
483
|
-
const inputAny = input as Record<string, unknown>
|
|
484
|
-
const agentName = typeof inputAny.agent === "string" ? inputAny.agent : "unknown"
|
|
389
|
+
// Access optional fields from input (SDK may include these at runtime)
|
|
390
|
+
const inputAny = input as Record<string, unknown>
|
|
391
|
+
const agentName = typeof inputAny.agent === "string" ? inputAny.agent : "unknown"
|
|
392
|
+
const pendingNextRoute = getRuntimeRouteDecision(ctx.directory) ?? undefined
|
|
485
393
|
|
|
486
394
|
// Build hook context from input and current session state
|
|
487
|
-
const hookContext = {
|
|
395
|
+
const hookContext: HookContext = {
|
|
488
396
|
sessionId: ctx.directory, // project directory as session key
|
|
489
397
|
agent: agentName,
|
|
490
398
|
directory: ctx.directory,
|
|
491
399
|
sessions: new Map(),
|
|
492
|
-
_confidenceLevel: typeof inputAny.confidence === "string" ? inputAny.confidence : undefined,
|
|
493
|
-
_confidenceExchanges: 0,
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
},
|
|
499
|
-
}
|
|
400
|
+
_confidenceLevel: typeof inputAny.confidence === "string" ? inputAny.confidence : undefined,
|
|
401
|
+
_confidenceExchanges: 0,
|
|
402
|
+
_guardConfig: DEFAULT_GUARD_CONFIG,
|
|
403
|
+
_nextRoute: pendingNextRoute,
|
|
404
|
+
_routingSkillsDir: skillsDir,
|
|
405
|
+
}
|
|
500
406
|
|
|
501
407
|
// Run all registered PreToolUse hooks (plan check, shell detect, delegation depth)
|
|
502
|
-
let preToolResult:
|
|
408
|
+
let preToolResult: { result: HookResult; modifiedContext?: HookContext }
|
|
503
409
|
try {
|
|
504
410
|
preToolResult = await reg.executePreTool(hookContext)
|
|
505
411
|
} catch (err) {
|
|
@@ -515,7 +421,7 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
|
|
|
515
421
|
const errOutput = output as { args: unknown; isError?: boolean; content?: unknown[] }
|
|
516
422
|
errOutput.isError = true
|
|
517
423
|
const message = (preToolResult.modifiedContext?._depthError as string)
|
|
518
|
-
??
|
|
424
|
+
?? `LOOP GUARD: Delegation depth exceeded (max ${DEFAULT_GUARD_CONFIG.maxDelegationDepth}). ` +
|
|
519
425
|
"Surface to orchestrator with findings and stop delegating."
|
|
520
426
|
errOutput.content = [{ type: "text", text: message }]
|
|
521
427
|
return
|
|
@@ -546,7 +452,7 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
|
|
|
546
452
|
// Run all registered RouteHooks — the agent/skill being delegated to IS the route
|
|
547
453
|
// This fires confidence-gate (inject confirm/question on MEDIUM/LOW confidence)
|
|
548
454
|
// and route-tracking (guard against infinite routing loops)
|
|
549
|
-
let routeResult:
|
|
455
|
+
let routeResult: { result: HookResult; modifiedRoute?: string }
|
|
550
456
|
try {
|
|
551
457
|
routeResult = await reg.executeRoute(hookContext, agentName)
|
|
552
458
|
} catch (err) {
|
|
@@ -556,18 +462,27 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
|
|
|
556
462
|
routeResult = { result: HookResult.CONTINUE }
|
|
557
463
|
}
|
|
558
464
|
|
|
559
|
-
if (routeResult.result === HookResult.STOP) {
|
|
560
|
-
// Loop guard triggered by route-tracking hook
|
|
561
|
-
const errOutput = output as { args: unknown; isError?: boolean; content?: unknown[] }
|
|
562
|
-
errOutput.isError = true
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
465
|
+
if (routeResult.result === HookResult.STOP) {
|
|
466
|
+
// Loop guard triggered by route-tracking hook
|
|
467
|
+
const errOutput = output as { args: unknown; isError?: boolean; content?: unknown[] }
|
|
468
|
+
errOutput.isError = true
|
|
469
|
+
const optiReport = hookContext._optiRoute
|
|
470
|
+
? JSON.stringify(hookContext._optiRoute, null, 2)
|
|
471
|
+
: "Route guard: Excessive or unproductive routing detected."
|
|
472
|
+
errOutput.content = [{ type: "text", text: `ROUTE GUARD: ${optiReport}\n\nSurface to orchestrator with findings and stop delegating.` }]
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (routeResult.modifiedRoute) {
|
|
476
|
+
const concreteRoute = routeResult.modifiedRoute.split("?")[0] ?? routeResult.modifiedRoute
|
|
477
|
+
if (concreteRoute && concreteRoute !== agentName) {
|
|
478
|
+
inputAny.agent = concreteRoute
|
|
479
|
+
}
|
|
480
|
+
if (pendingNextRoute?.selected && concreteRoute === pendingNextRoute.selected) {
|
|
481
|
+
clearRuntimeRouteDecision(ctx.directory)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (routeResult.result === HookResult.INJECT && routeResult.modifiedRoute) {
|
|
571
486
|
// Confidence gate wants to inject a confirmation/pause into routing.
|
|
572
487
|
// Parse the modifiedRoute for markers and inject into task description/prompt.
|
|
573
488
|
const modifiedRoute: string = routeResult.modifiedRoute
|
|
@@ -605,19 +520,21 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
|
|
|
605
520
|
const agentName = typeof inputAny.agent === "string" ? inputAny.agent : "unknown"
|
|
606
521
|
|
|
607
522
|
// Build hook context from input and current session state
|
|
608
|
-
const hookContext = {
|
|
609
|
-
sessionId: ctx.directory,
|
|
610
|
-
agent: agentName,
|
|
611
|
-
directory: ctx.directory,
|
|
612
|
-
sessions: new Map(),
|
|
613
|
-
_confidenceLevel: typeof inputAny.confidence === "string" ? inputAny.confidence : undefined,
|
|
614
|
-
_confidenceExchanges: 0,
|
|
615
|
-
|
|
616
|
-
|
|
523
|
+
const hookContext: HookContext = {
|
|
524
|
+
sessionId: ctx.directory,
|
|
525
|
+
agent: agentName,
|
|
526
|
+
directory: ctx.directory,
|
|
527
|
+
sessions: new Map(),
|
|
528
|
+
_confidenceLevel: typeof inputAny.confidence === "string" ? inputAny.confidence : undefined,
|
|
529
|
+
_confidenceExchanges: 0,
|
|
530
|
+
_guardConfig: DEFAULT_GUARD_CONFIG,
|
|
531
|
+
_routingSkillsDir: skillsDir,
|
|
532
|
+
}
|
|
617
533
|
|
|
618
534
|
// Extract output text from tool result
|
|
619
535
|
// output.content may be array of content blocks, or a string, or undefined
|
|
620
|
-
const outputAny = output as Record<string, unknown> | undefined
|
|
536
|
+
const outputAny = output as Record<string, unknown> | undefined
|
|
537
|
+
const mutableOutput = (output ?? {}) as Record<string, unknown>
|
|
621
538
|
let outputText = ""
|
|
622
539
|
if (outputAny?.content) {
|
|
623
540
|
const content = outputAny.content
|
|
@@ -640,12 +557,28 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
|
|
|
640
557
|
}
|
|
641
558
|
|
|
642
559
|
// Log when hooks signal issues (INJECT = anomaly/error detected by a hook)
|
|
643
|
-
if (postToolResult.result === HookResult.INJECT) {
|
|
644
|
-
await logToOC("warn", "PostTool INJECT: hooks detected issues in tool output")
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
560
|
+
if (postToolResult.result === HookResult.INJECT) {
|
|
561
|
+
await logToOC("warn", "PostTool INJECT: hooks detected issues in tool output")
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const routedOutput = consumeRouteGuidance(postToolResult.modifiedOutput ?? outputText)
|
|
565
|
+
const finalOutput = routedOutput.output
|
|
566
|
+
const runtimeNextRoute = rememberRuntimeRouteDecision(ctx.directory, finalOutput)
|
|
567
|
+
|
|
568
|
+
if (finalOutput !== outputText) {
|
|
569
|
+
if (typeof outputAny?.content === "string") {
|
|
570
|
+
mutableOutput.content = finalOutput
|
|
571
|
+
} else {
|
|
572
|
+
mutableOutput.content = [{ type: "text", text: finalOutput }]
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (runtimeNextRoute) {
|
|
577
|
+
mutableOutput._nextRoute = runtimeNextRoute
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// memorySyncHook catches its own errors (best-effort sync),
|
|
581
|
+
// so memory sync failures are already handled gracefully inside the hook
|
|
649
582
|
} catch (err) {
|
|
650
583
|
const msg = err instanceof Error ? err.message : String(err)
|
|
651
584
|
await logToOC("error", `Hook error (PostTool): ${msg}`)
|