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.
Files changed (46) hide show
  1. package/CONTEXT.md +6 -6
  2. package/ETHOS.md +2 -2
  3. package/README.md +8 -8
  4. package/bootstrap.ts +131 -198
  5. package/harness/codex/AUTOPILOT.md +39 -27
  6. package/harness/codex/CHARTER.md +1 -1
  7. package/harness/lib/background/background.test.ts +24 -5
  8. package/harness/lib/background/manager.ts +9 -9
  9. package/harness/lib/composer/compose.test.ts +29 -18
  10. package/harness/lib/composer/fragments/02-delegation.md +5 -4
  11. package/harness/lib/composer/fragments/04-task-flow.md +43 -3
  12. package/harness/lib/composer/fragments/09-guardrails.md +25 -12
  13. package/harness/lib/guards/guard-config.ts +72 -0
  14. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +9 -11
  15. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +24 -5
  16. package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -0
  17. package/harness/lib/hooks/builtins/error-recovery-hook.ts +7 -7
  18. package/harness/lib/hooks/builtins/memory-sync-hook.ts +2 -2
  19. package/harness/lib/hooks/builtins/next-route-hook.ts +24 -0
  20. package/harness/lib/hooks/builtins/plan-check-hook.ts +5 -5
  21. package/harness/lib/hooks/builtins/route-tracking-hook.ts +80 -26
  22. package/harness/lib/hooks/builtins/subagent-failure-hook.ts +93 -0
  23. package/harness/lib/hooks/hooks.test.ts +145 -69
  24. package/harness/lib/hooks/index.ts +12 -0
  25. package/harness/lib/hooks/registry.ts +3 -3
  26. package/harness/lib/hooks/types.ts +50 -2
  27. package/harness/lib/memory/memory-manager.ts +2 -2
  28. package/harness/lib/memory/memory.test.ts +0 -6
  29. package/harness/lib/memory/plan-store.ts +1 -21
  30. package/harness/lib/plans/plan-location.ts +134 -0
  31. package/harness/lib/routing/index.ts +21 -0
  32. package/harness/lib/routing/route-guidance.ts +147 -0
  33. package/harness/lib/routing/route-resolver.ts +58 -0
  34. package/harness/lib/routing/routing.test.ts +195 -0
  35. package/harness/lib/routing/skill-frontmatter.ts +125 -0
  36. package/harness/lib/routing/types.ts +52 -0
  37. package/harness/lib/sanity/checker.ts +45 -34
  38. package/harness/lib/sync/file-watcher.ts +26 -25
  39. package/harness/lib/sync/plan-sync.ts +22 -25
  40. package/harness/lib/sync/sync.test.ts +30 -4
  41. package/harness/skills/oh-fusion/DEEP.md +109 -86
  42. package/harness/skills/oh-fusion/SKILL.md +47 -33
  43. package/harness/skills/oh-manifest/SKILL.md +1 -0
  44. package/harness/skills/oh-review/DEEP.md +5 -3
  45. package/harness/skills/oh-review/SKILL.md +1 -0
  46. 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 in `harness/commands/`.
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`, `commands/*.md`, and `agents/*.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.1"></a>
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
- | **Test Harness** | Disposable temp dirs (Symbol.asyncDispose), factory builders, restore-capable mocks. Professional-grade test utilities. |
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** | Compositor, hooks, memory, recovery, sync, sanity checks, background commands, test harness — all native Node.js / TypeScript. |
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 — three tiers
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
- │ ├── commands/ # Slash commands
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
- planCheckHook,
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
- routeTrackingHook,
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
- // Canonical storage under OpenCode's data directory survives npm updates
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 findLatestPlanFile(projectDir: string): string | null {
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 = readPlanSummary(projectDir)
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 hooksConfig = (config.experimental as Record<string, unknown> | undefined)?.hooks as
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
- if (hooksConfig?.confidence_gate ?? true) reg.registerRoute(confidenceGateHook)
353
- if (hooksConfig?.error_recovery ?? true) reg.registerPostTool(errorRecoveryHook)
354
- if (hooksConfig?.memory_sync ?? true) reg.registerPostTool(memorySyncHook)
355
- if (hooksConfig?.sanity_check ?? true) reg.registerPostTool(sanityCheckHook)
356
- if (hooksConfig?.route_tracking ?? true) reg.registerRoute(routeTrackingHook)
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/opencode/openhermes/**": "allow",
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
- _maxDelegationDepth: 25,
495
- _routeTrackingConfig: {
496
- maxSkillRepeats: 5,
497
- maxUnproductiveHops: 30, // higher than max delegation depth (25) so depth guard fires first
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: any
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
- ?? "LOOP GUARD: Delegation depth exceeded (max 25). " +
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: any
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 ctxAny = hookContext as Record<string, unknown>
564
- const optiReport = ctxAny._optiRoute
565
- ? JSON.stringify(ctxAny._optiRoute, null, 2)
566
- : "Route guard: Excessive or unproductive routing detected."
567
- errOutput.content = [{ type: "text", text: `ROUTE GUARD: ${optiReport}\n\nSurface to orchestrator with findings and stop delegating.` }]
568
- }
569
-
570
- if (routeResult.result === HookResult.INJECT && routeResult.modifiedRoute) {
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
- _maxDelegationDepth: 25,
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
- // memorySyncHook catches its own errors (best-effort sync),
648
- // so memory sync failures are already handled gracefully inside the 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
649
582
  } catch (err) {
650
583
  const msg = err instanceof Error ? err.message : String(err)
651
584
  await logToOC("error", `Hook error (PostTool): ${msg}`)