openhermes 4.9.2 → 4.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CONTEXT.md +7 -7
  2. package/ETHOS.md +2 -2
  3. package/README.md +34 -33
  4. package/bootstrap.ts +310 -160
  5. package/harness/agents/oh-planner.md +1 -1
  6. package/harness/agents/openhermes.md +27 -126
  7. package/harness/codex/AUTOPILOT.md +131 -23
  8. package/harness/codex/CHARTER.md +4 -5
  9. package/harness/lib/background/background.test.ts +216 -0
  10. package/harness/lib/background/index.ts +7 -0
  11. package/harness/lib/background/interfaces.ts +31 -0
  12. package/harness/lib/background/manager.ts +320 -0
  13. package/harness/lib/composer/compose.test.ts +179 -0
  14. package/harness/lib/composer/compose.ts +65 -0
  15. package/harness/lib/composer/fragments/01-identity.md +1 -0
  16. package/harness/lib/composer/fragments/02-delegation.md +7 -0
  17. package/harness/lib/composer/fragments/03-permissions.md +13 -0
  18. package/harness/lib/composer/fragments/04-task-flow.md +55 -0
  19. package/harness/lib/composer/fragments/05-confidence.md +5 -0
  20. package/harness/lib/composer/fragments/06-parallelization.md +17 -0
  21. package/harness/lib/composer/fragments/07-shell.md +41 -0
  22. package/harness/lib/composer/fragments/08-routing.md +8 -0
  23. package/harness/lib/composer/fragments/09-guardrails.md +25 -0
  24. package/harness/lib/composer/index.ts +1 -0
  25. package/harness/lib/guards/guard-config.ts +72 -0
  26. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +68 -0
  27. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +78 -0
  28. package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -0
  29. package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
  30. package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
  31. package/harness/lib/hooks/builtins/next-route-hook.ts +24 -0
  32. package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
  33. package/harness/lib/hooks/builtins/route-tracking-hook.ts +201 -0
  34. package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
  35. package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
  36. package/harness/lib/hooks/builtins/subagent-failure-hook.ts +93 -0
  37. package/harness/lib/hooks/hooks.test.ts +1092 -0
  38. package/harness/lib/hooks/index.ts +42 -0
  39. package/harness/lib/hooks/registry.ts +416 -0
  40. package/harness/lib/hooks/types.ts +119 -0
  41. package/harness/lib/memory/index.ts +18 -0
  42. package/harness/lib/memory/interfaces.ts +53 -0
  43. package/harness/lib/memory/memory-manager.ts +205 -0
  44. package/harness/lib/memory/memory.test.ts +485 -0
  45. package/harness/lib/memory/plan-store.ts +346 -0
  46. package/harness/lib/plans/plan-location.ts +134 -0
  47. package/harness/lib/recovery/handler.ts +243 -0
  48. package/harness/lib/recovery/index.ts +14 -0
  49. package/harness/lib/recovery/interfaces.ts +48 -0
  50. package/harness/lib/recovery/patterns.ts +149 -0
  51. package/harness/lib/recovery/recovery.test.ts +312 -0
  52. package/harness/lib/routing/index.ts +21 -0
  53. package/harness/lib/routing/route-guidance.ts +147 -0
  54. package/harness/lib/routing/route-resolver.ts +58 -0
  55. package/harness/lib/routing/routing.test.ts +195 -0
  56. package/harness/lib/routing/skill-frontmatter.ts +125 -0
  57. package/harness/lib/routing/types.ts +52 -0
  58. package/harness/lib/sanity/anomaly-tracker.ts +127 -0
  59. package/harness/lib/sanity/checker.ts +189 -0
  60. package/harness/lib/sanity/index.ts +13 -0
  61. package/harness/lib/sanity/interfaces.ts +24 -0
  62. package/harness/lib/sanity/sanity.test.ts +472 -0
  63. package/harness/lib/sync/file-watcher.ts +175 -0
  64. package/harness/lib/sync/index.ts +11 -0
  65. package/harness/lib/sync/interfaces.ts +27 -0
  66. package/harness/lib/sync/plan-sync.ts +533 -0
  67. package/harness/lib/sync/sync.test.ts +858 -0
  68. package/harness/skills/oh-fusion/DEEP.md +109 -86
  69. package/harness/skills/oh-fusion/SKILL.md +47 -33
  70. package/harness/skills/oh-init/DEEP.md +2 -2
  71. package/harness/skills/oh-manifest/SKILL.md +2 -1
  72. package/harness/skills/oh-plan-review/DEEP.md +1 -1
  73. package/harness/skills/oh-planner/DEEP.md +3 -3
  74. package/harness/skills/oh-review/DEEP.md +5 -3
  75. package/harness/skills/oh-review/SKILL.md +1 -0
  76. package/harness/skills/oh-ship/SKILL.md +1 -1
  77. package/harness/skills/oh-skill-craft/SKILL.md +1 -4
  78. package/package.json +53 -55
  79. package/tsconfig.json +1 -1
  80. package/harness/commands/oh-doctor.md +0 -205
  81. package/harness/commands/oh-log.md +0 -18
  82. package/harness/skills/oh-learn/DEEP.md +0 -44
  83. package/harness/skills/oh-learn/SKILL.md +0 -30
  84. package/scripts/count-tokens.mjs +0 -158
  85. package/scripts/oh-doctor.ps1 +0 -342
package/bootstrap.ts CHANGED
@@ -1,8 +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"
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"
9
+
10
+ // Hook system — pluggable lifecycle hooks with topological sort
11
+ import {
12
+ HookRegistry,
13
+ HookResult,
14
+ nextRouteHook,
15
+ planCheckHook,
16
+ shellDetectHook,
17
+ confidenceGateHook,
18
+ delegationDepthHook,
19
+ resetDepthTracker,
20
+ errorRecoveryHook,
21
+ memorySyncHook,
22
+ sanityCheckHook,
23
+ dynamicRouteHook,
24
+ routeTrackingHook,
25
+ subagentFailureHook,
26
+ resetSubagentFailures,
27
+ DEFAULT_GUARD_CONFIG,
28
+ } from "./harness/lib/hooks/index.ts"
29
+ import type { HookContext } from "./harness/lib/hooks/index.ts"
6
30
 
7
31
  const OPENHERMES_AGENT = "OpenHermes"
8
32
 
@@ -13,19 +37,7 @@ const USER_SKILL_DIRS: ReadonlyArray<string> = [
13
37
  path.join(os.homedir(), ".claude", "skills"), // Claude Code backward compat
14
38
  ]
15
39
 
16
- // Canonical storage under OpenCode's data directory survives npm updates
17
- let _planStorageOverride: string | undefined
18
- export function setPlanStorageDirForTest(dir: string | undefined): void { _planStorageOverride = dir }
19
- function planStorageDir(): string {
20
- return _planStorageOverride ?? path.join(os.homedir(), ".local", "share", "opencode", "openhermes", "plans")
21
- }
22
-
23
- function getProjectName(projectDir: string): string {
24
- return path.basename(projectDir)
25
- }
26
-
27
-
28
- export { resolveHarnessRoot, setHarnessRootForTest, getHarnessDir, ensurePlanFile }
40
+ export { resolveHarnessRoot, setHarnessRootForTest, getHarnessDir, ensurePlanFile, findLatestPlanFile, setPlanStorageDirForTest }
29
41
 
30
42
  function parseFrontmatter(raw: string | undefined): Record<string, string> {
31
43
  const frontmatter: Record<string, string> = {}
@@ -40,12 +52,12 @@ function parseFrontmatter(raw: string | undefined): Record<string, string> {
40
52
  return frontmatter
41
53
  }
42
54
 
43
- interface MarkdownDocument {
44
- frontmatter: Record<string, string>
45
- body: string
46
- }
47
-
48
- 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 {
49
61
  if (!fs.existsSync(filePath)) return null
50
62
  const source = fs.readFileSync(filePath, "utf8")
51
63
  const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
@@ -71,7 +83,7 @@ function readMarkdownDirectory(dir: string): DirEntry[] {
71
83
  .filter((e): e is DirEntry => e !== null)
72
84
  }
73
85
 
74
- interface CommandDef {
86
+ interface CommandDef {
75
87
  description: string
76
88
  template: string
77
89
  agent?: string
@@ -94,7 +106,7 @@ function commandDefinitions(dir: string): Record<string, CommandDef> {
94
106
  return commands
95
107
  }
96
108
 
97
- interface AgentDef {
109
+ interface AgentDef {
98
110
  description: string
99
111
  mode: string
100
112
  prompt: string
@@ -125,119 +137,27 @@ function uniqueStrings(existing: string[] = [], additions: string[] = []): strin
125
137
  }
126
138
 
127
139
 
128
- function regexEscape(s: string): string {
129
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
130
- }
131
-
132
- function findLatestPlanFile(projectDir: string): string | null {
133
- const projectName = getProjectName(projectDir)
134
- const storage = planStorageDir()
135
- if (!fs.existsSync(storage)) return null
136
- const pattern = new RegExp(`^${regexEscape(projectName)}-plan-(\\d{3})\\.md$`)
137
- let latest: string | null = null
138
- let highest = -1
140
+ function ensureDir(dir: string): void {
139
141
  try {
140
- for (const entry of fs.readdirSync(storage)) {
141
- const m = entry.match(pattern)
142
- if (m) {
143
- const n = parseInt(m[1], 10)
144
- if (n > highest) {
145
- highest = n
146
- latest = path.join(storage, entry)
147
- }
148
- }
142
+ if (!fs.existsSync(dir)) {
143
+ fs.mkdirSync(dir, { recursive: true })
149
144
  }
150
- } catch {
151
- return null
145
+ } catch (err) {
146
+ const msg = err instanceof Error ? err.message : String(err)
147
+ console.error(`[openhermes] Failed to create directory ${dir}: ${msg}`)
148
+ // Don't throw — let the plan system degrade gracefully
152
149
  }
153
- return latest
154
- }
155
-
156
- function readPlanFromFile(filePath: string): string | null {
157
- if (!fs.existsSync(filePath)) return null
158
- const source = fs.readFileSync(filePath, "utf8")
159
- const status = source.match(/^Status:\s*(.+)$/m)?.[1]?.trim()
160
- const objective = source.match(/^Objective:\s*(.+)$/m)?.[1]?.trim()
161
- if (!status && !objective) return null
162
- const parts = [status ? `status=${status}` : null, objective ? `objective=${objective}` : null].filter(Boolean)
163
- return `Active plan: ${parts.join(" | ")}`
164
150
  }
165
151
 
166
- function readPlanSummary(projectDir: string): string | null {
167
- const planFile = findLatestPlanFile(projectDir)
168
- if (!planFile) return null
169
- return readPlanFromFile(planFile)
170
- }
171
-
172
- function ensureDir(dir: string): void {
173
- if (!fs.existsSync(dir)) {
174
- fs.mkdirSync(dir, { recursive: true })
175
- }
176
- }
177
-
178
- /**
179
- * Ensure a plan file exists for the project.
180
- * Creates a skeleton plan if none exists or if the latest is complete/abandoned.
181
- * Reuses an existing active or in-progress plan.
182
- * Returns the path to the plan file.
183
- */
184
- function ensurePlanFile(projectDir: string): string {
185
- const projectName = getProjectName(projectDir)
186
- const storage = planStorageDir()
187
- ensureDir(storage)
188
-
189
- // Reuse active or in-progress plan
190
- const latest = findLatestPlanFile(projectDir)
191
- if (latest) {
192
- const content = fs.readFileSync(latest, "utf8")
193
- const status = content.match(/^Status:\s*(.+)$/m)?.[1]?.trim()
194
- if (status === "active" || status === "in-progress") {
195
- return latest
196
- }
197
- }
198
-
199
- // Determine next sequence number
200
- let nextSeq = 1
201
- if (latest) {
202
- const m = path.basename(latest).match(/-plan-(\d{3})\.md$/)
203
- if (m) nextSeq = parseInt(m[1], 10) + 1
204
- }
205
-
206
- const planId = `${projectName}-plan-${String(nextSeq).padStart(3, "0")}`
207
- const planPath = path.join(storage, `${planId}.md`)
208
- const now = new Date().toISOString().replace("T", " ").slice(0, 16)
209
-
210
- const content = [
211
- `# PLAN: ${projectName}`,
212
- "",
213
- `Plan ID: ${planId}`,
214
- `Project: ${projectName}`,
215
- `Status: active`,
216
- `Created: ${now}`,
217
- `Updated: ${now}`,
218
- `Project Path: ${projectDir}`,
219
- `Plan Path: ${planPath}`,
220
- `Objective: (pending classification)`,
221
- "",
222
- "## Tasks",
223
- "",
224
- "- [ ] (discoverable — pending classification)",
225
- "",
226
- ].join("\n")
227
-
228
- fs.writeFileSync(planPath, content, "utf8")
229
- return planPath
230
- }
231
-
232
- export function buildCompactionContext(projectDir: string): string[] {
152
+ export function buildCompactionContext(projectDir: string): string[] {
233
153
  const context = [
234
154
  "OpenHermes: native-first, verify before claim, always delegate, concise over verbose.",
235
155
  "Preserve domain terms: skill, command, agent, bootstrap, compaction.",
236
156
  "Preserve blockers, current task, and next steps; do not invent durable state.",
237
157
  ]
238
158
 
239
- const planSummary = readPlanSummary(projectDir)
240
- if (planSummary) context.push(planSummary)
159
+ const planSummary = resolvePlanAccess(projectDir)?.summary
160
+ if (planSummary) context.push(planSummary)
241
161
 
242
162
  return context
243
163
  }
@@ -277,6 +197,7 @@ interface OpenHermesConfig {
277
197
  agent?: Record<string, unknown>
278
198
  instructions?: string[]
279
199
  default_agent?: string
200
+ [key: string]: unknown // allow additional SDK properties (experimental, etc.)
280
201
  }
281
202
 
282
203
  export const BootstrapPlugin: Plugin = async (ctx) => {
@@ -298,17 +219,54 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
298
219
  // Auto-detect and wire user skills from ~/.agents/skills and ~/.config/opencode/skills
299
220
  const userSkillPaths: string[] = []
300
221
  for (const userDir of USER_SKILL_DIRS) {
301
- ensureDir(userDir)
222
+ try { ensureDir(userDir) } catch {}
302
223
  userSkillPaths.push(userDir)
303
224
  await logToOC("info", `wired user skills from ${userDir}`)
304
225
  }
305
226
 
306
227
  const compactionContext = buildCompactionContext(ctx.directory)
307
228
  // Ensure plan storage exists
308
- ensureDir(planStorageDir())
229
+ try { ensureDir(planStorageDir()) } catch {}
230
+
231
+
309
232
 
310
233
  return {
311
234
  config: async (config: OpenHermesConfig) => {
235
+
236
+ // ── 1. Hooks System ─────────────────────────────────────────────────
237
+ // Read experimental.hooks config from the raw config object
238
+ const experimental = config.experimental as Record<string, unknown> | undefined;
239
+ const hooksConfig = (experimental?.hooks as
240
+ | Record<string, boolean>
241
+ | undefined)
242
+ const hooksEnabled = (hooksConfig?.enabled ?? true) as boolean
243
+
244
+ if (hooksEnabled) {
245
+ const reg = HookRegistry.getInstance()
246
+
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)
263
+
264
+ await logToOC("info", `hooks: ${reg.getPreToolHooks().length + reg.getPostToolHooks().length + reg.getRouteHooks().length} registered`)
265
+ } else {
266
+ await logToOC("info", "hooks: disabled via config")
267
+ }
268
+
269
+ // ── 2. Skills ──────────────────────────────────────────────────────
312
270
  config.skills = config.skills || {}
313
271
  // Built-in paths first, user paths last → user skills override built-in on name conflict
314
272
  const allPaths = [skillsDir, ...userSkillPaths]
@@ -325,10 +283,17 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
325
283
  config.command = { ...(config.command ?? {}), ...commandDefinitions(commandsDir) }
326
284
 
327
285
  const loadedAgents = agentDefinitions(agentsDir)
328
- const openHermesAgent = loadedAgents[OPENHERMES_AGENT] ?? {
329
- description: "OpenHermes primary orchestrator",
330
- mode: "primary",
331
- prompt: "You are OpenHermes.",
286
+ // Use composer for the OpenHermes agent prompt — assemble from fragments
287
+ let openHermesPrompt: string
288
+ try {
289
+ openHermesPrompt = compose()
290
+ } catch {
291
+ openHermesPrompt = loadedAgents[OPENHERMES_AGENT]?.prompt ?? "You are OpenHermes."
292
+ }
293
+ const openHermesAgent = {
294
+ description: loadedAgents[OPENHERMES_AGENT]?.description ?? "OpenHermes primary orchestrator",
295
+ mode: loadedAgents[OPENHERMES_AGENT]?.mode ?? "primary",
296
+ prompt: openHermesPrompt,
332
297
  }
333
298
 
334
299
  // Subagent permissions — tier-4 and tier-3 get execution access but cannot spawn orchestrators
@@ -336,18 +301,18 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
336
301
  "oh-builder": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
337
302
  "oh-browser": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
338
303
  "oh-facade": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
304
+ "oh-fusion": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
339
305
  "oh-gauntlet": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
340
- "oh-manifest": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
341
- "oh-ship": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
342
- "oh-planner": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
343
306
  "oh-grill": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
344
307
  "oh-investigate": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
308
+ "oh-manifest": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
345
309
  "oh-plan-review": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
346
- "oh-security": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
310
+ "oh-planner": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
347
311
  "oh-refactor": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
348
- "oh-review": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
349
- "oh-fusion": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
350
312
  "oh-retro": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
313
+ "oh-review": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
314
+ "oh-security": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
315
+ "oh-ship": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
351
316
  "oh-skill-craft": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
352
317
  }
353
318
 
@@ -385,11 +350,11 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
385
350
  webfetch: "allow", // CAN fetch docs for context
386
351
  question: "allow", // CAN ask user questions
387
352
  websearch: "allow", // CAN search web for research context
388
- external_directory: { // CAN read/write plan files outside worktree
389
- "~/.local/share/opencode/openhermes/**": "allow",
390
- },
391
- },
392
- },
353
+ external_directory: { // CAN read/write plan files outside worktree
354
+ "~/.local/share/openhermes/plans/**": "allow",
355
+ },
356
+ },
357
+ },
393
358
  }
394
359
 
395
360
  config.default_agent = OPENHERMES_AGENT
@@ -405,9 +370,10 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
405
370
  // creates plans on demand (see Task Flow step 1 in agent prompt).
406
371
  // Auto-creation produced ghost skeletons like plan-004.
407
372
 
408
- // Reset delegation depth on session start/error
373
+ // Reset delegation depth and subagent failures on session start/error
409
374
  if (typed.type === "session.created" || typed.type === "session.error") {
410
- delegationDepths.delete(`delegation:${ctx.directory}`)
375
+ resetDepthTracker()
376
+ resetSubagentFailures()
411
377
  }
412
378
  },
413
379
 
@@ -415,22 +381,208 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
415
381
  output.context.push(...compactionContext)
416
382
  },
417
383
 
418
- // Mechanical delegation loop guard prevents runaway agent nesting
384
+ // Hook-enabled tool executiondelegates to HookRegistry for lifecycle hooks
419
385
  "tool.execute.before": async (input, output) => {
420
386
  if (input.tool === "task") {
421
- // Track delegation depth per project (one session per project at a time)
422
- const depthKey = `delegation:${ctx.directory}`
423
- const currentDepth = (delegationDepths.get(depthKey) ?? 0) + 1
424
- delegationDepths.set(depthKey, currentDepth)
387
+ const reg = HookRegistry.getInstance()
388
+
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
393
+
394
+ // Build hook context from input and current session state
395
+ const hookContext: HookContext = {
396
+ sessionId: ctx.directory, // project directory as session key
397
+ agent: agentName,
398
+ directory: ctx.directory,
399
+ sessions: new Map(),
400
+ _confidenceLevel: typeof inputAny.confidence === "string" ? inputAny.confidence : undefined,
401
+ _confidenceExchanges: 0,
402
+ _guardConfig: DEFAULT_GUARD_CONFIG,
403
+ _nextRoute: pendingNextRoute,
404
+ _routingSkillsDir: skillsDir,
405
+ }
406
+
407
+ // Run all registered PreToolUse hooks (plan check, shell detect, delegation depth)
408
+ let preToolResult: { result: HookResult; modifiedContext?: HookContext }
409
+ try {
410
+ preToolResult = await reg.executePreTool(hookContext)
411
+ } catch (err) {
412
+ const msg = err instanceof Error ? err.message : String(err)
413
+ const errOutput = output as { args: unknown; isError?: boolean; content?: unknown[] }
414
+ errOutput.isError = true
415
+ errOutput.content = [{ type: "text", text: `Hook error (PreTool): ${msg}` }]
416
+ return
417
+ }
425
418
 
426
- if (currentDepth >= 10) {
419
+ if (preToolResult.result === HookResult.STOP) {
420
+ // Depth exceeded or other stop condition
427
421
  const errOutput = output as { args: unknown; isError?: boolean; content?: unknown[] }
428
422
  errOutput.isError = true
429
- errOutput.content = [{
430
- type: "text",
431
- text: "LOOP GUARD: Delegation depth exceeded (max 10). " +
432
- "Surface to orchestrator with findings and stop delegating."
433
- }]
423
+ const message = (preToolResult.modifiedContext?._depthError as string)
424
+ ?? `LOOP GUARD: Delegation depth exceeded (max ${DEFAULT_GUARD_CONFIG.maxDelegationDepth}). ` +
425
+ "Surface to orchestrator with findings and stop delegating."
426
+ errOutput.content = [{ type: "text", text: message }]
427
+ return
428
+ }
429
+
430
+ // Handle INJECT from PreTool hooks (e.g. plan check wants a plan first)
431
+ if (preToolResult.result === HookResult.INJECT) {
432
+ const planInstruction = preToolResult.modifiedContext?._planCheckInstruction as string | undefined
433
+ if (planInstruction) {
434
+ // Plan check hook returned INJECT — inject "create plan" instruction
435
+ const inputAny = input as Record<string, unknown>
436
+ const existingPrompt = (inputAny.description as string) || (inputAny.prompt as string) || ""
437
+ if (inputAny.description) {
438
+ inputAny.description = `${planInstruction}\n\n${existingPrompt}`
439
+ } else if (inputAny.prompt) {
440
+ inputAny.prompt = `${planInstruction}\n\n${existingPrompt}`
441
+ } else {
442
+ inputAny.description = planInstruction
443
+ }
444
+ await logToOC("info", `Plan check: injected plan creation instruction into task for "${agentName}"`)
445
+ } else {
446
+ // Generic INJECT — hooks modified task context
447
+ await logToOC("debug", `PreTool INJECT: hooks modified task context for "${agentName}"`)
448
+ }
449
+ // Continue execution — INJECT is not a stop signal
450
+ }
451
+
452
+ // Run all registered RouteHooks — the agent/skill being delegated to IS the route
453
+ // This fires confidence-gate (inject confirm/question on MEDIUM/LOW confidence)
454
+ // and route-tracking (guard against infinite routing loops)
455
+ let routeResult: { result: HookResult; modifiedRoute?: string }
456
+ try {
457
+ routeResult = await reg.executeRoute(hookContext, agentName)
458
+ } catch (err) {
459
+ const msg = err instanceof Error ? err.message : String(err)
460
+ await logToOC("error", `Hook error (Route): ${msg}`)
461
+ // Route failed — don't change routing decision, just log
462
+ routeResult = { result: HookResult.CONTINUE }
463
+ }
464
+
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) {
486
+ // Confidence gate wants to inject a confirmation/pause into routing.
487
+ // Parse the modifiedRoute for markers and inject into task description/prompt.
488
+ const modifiedRoute: string = routeResult.modifiedRoute
489
+ const inputAny = input as Record<string, unknown>
490
+ const existingPrompt = (inputAny.description as string) || (inputAny.prompt as string) || ""
491
+ let gateMsg: string | undefined
492
+
493
+ if (modifiedRoute.includes("?echo=confirm")) {
494
+ gateMsg = "[CONFIDENCE: MEDIUM] Review your plan and confirm it before executing."
495
+ } else if (modifiedRoute.includes("?question=pause")) {
496
+ gateMsg = "[CONFIDENCE: LOW] Pause and ask the user for approval before proceeding."
497
+ }
498
+
499
+ if (gateMsg) {
500
+ if (inputAny.description) {
501
+ inputAny.description = `${gateMsg}\n${existingPrompt}`
502
+ } else if (inputAny.prompt) {
503
+ inputAny.prompt = `${gateMsg}\n${existingPrompt}`
504
+ } else {
505
+ inputAny.description = gateMsg
506
+ }
507
+ await logToOC("info", `Confidence gate: injected instruction into task to "${agentName}": ${gateMsg}`)
508
+ }
509
+ }
510
+ }
511
+ },
512
+
513
+ // Hook-enabled post-execution — runs PostToolUse hooks
514
+ "tool.execute.after": async (input, output) => {
515
+ if (input.tool === "task") {
516
+ const reg = HookRegistry.getInstance()
517
+
518
+ // Access optional fields from input
519
+ const inputAny = input as Record<string, unknown>
520
+ const agentName = typeof inputAny.agent === "string" ? inputAny.agent : "unknown"
521
+
522
+ // Build hook context from input and current session state
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
+ }
533
+
534
+ // Extract output text from tool result
535
+ // output.content may be array of content blocks, or a string, or undefined
536
+ const outputAny = output as Record<string, unknown> | undefined
537
+ const mutableOutput = (output ?? {}) as Record<string, unknown>
538
+ let outputText = ""
539
+ if (outputAny?.content) {
540
+ const content = outputAny.content
541
+ if (typeof content === "string") {
542
+ outputText = content
543
+ } else if (Array.isArray(content)) {
544
+ outputText = content
545
+ .map((c: Record<string, unknown>) => (typeof c.text === "string" ? c.text : ""))
546
+ .join("\n")
547
+ }
548
+ }
549
+
550
+ // Run all registered PostToolUse hooks
551
+ try {
552
+ const postToolResult = await reg.executePostTool(hookContext, outputText)
553
+
554
+ // Surface recovery instructions from errorRecoveryHook and/or sanityCheckHook
555
+ if (postToolResult.recovery) {
556
+ await logToOC("warn", `PostTool recovery instruction:\n${postToolResult.recovery}`)
557
+ }
558
+
559
+ // Log when hooks signal issues (INJECT = anomaly/error detected by a hook)
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
582
+ } catch (err) {
583
+ const msg = err instanceof Error ? err.message : String(err)
584
+ await logToOC("error", `Hook error (PostTool): ${msg}`)
585
+ // Non-fatal — tool already executed
434
586
  }
435
587
  }
436
588
  },
@@ -438,5 +590,3 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
438
590
  }
439
591
  }
440
592
 
441
- // Module-level delegation depth tracker — reset per project session
442
- const delegationDepths = new Map<string, number>()
@@ -26,7 +26,7 @@ Always know before you go.
26
26
 
27
27
  # oh-planner
28
28
 
29
- ALL-arounder planner. Merges brainstorm, architecture analysis, strategy, and plan review into one skill. Produces plan files in canonical storage (`~/.local/share/opencode/openhermes/plans/`).
29
+ ALL-arounder planner. Merges brainstorm, architecture analysis, strategy, and plan review into one skill. Produces plan files in canonical storage (`~/.local/share/openhermes/plans/`).
30
30
 
31
31
  Load the relevant section based on entry mode:
32
32