openhermes 4.3.0 → 4.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CONTEXT.md +10 -1
  2. package/README.md +54 -42
  3. package/bootstrap.ts +396 -142
  4. package/harness/agents/oh-browser.md +97 -0
  5. package/harness/agents/oh-builder.md +78 -0
  6. package/harness/agents/oh-facade.md +75 -0
  7. package/harness/agents/oh-fusion.md +45 -0
  8. package/harness/agents/oh-gauntlet.md +71 -0
  9. package/harness/agents/oh-grill.md +71 -0
  10. package/harness/agents/oh-investigate.md +60 -0
  11. package/harness/agents/oh-manifest.md +95 -0
  12. package/harness/agents/oh-plan-review.md +40 -0
  13. package/harness/agents/oh-planner.md +50 -0
  14. package/harness/agents/oh-refactor.md +37 -0
  15. package/harness/agents/oh-retro.md +46 -0
  16. package/harness/agents/oh-review.md +85 -0
  17. package/harness/agents/oh-security.md +83 -0
  18. package/harness/agents/oh-ship.md +76 -0
  19. package/harness/agents/oh-skill-craft.md +38 -0
  20. package/harness/agents/openhermes.md +28 -73
  21. package/harness/codex/AUTOPILOT.md +235 -87
  22. package/harness/codex/CHARTER.md +80 -0
  23. package/harness/instructions/SHELL.md +76 -0
  24. package/harness/lib/background/background.test.ts +197 -0
  25. package/harness/lib/background/index.ts +7 -0
  26. package/harness/lib/background/interfaces.ts +31 -0
  27. package/harness/lib/background/manager.ts +320 -0
  28. package/harness/lib/composer/compose.test.ts +168 -0
  29. package/harness/lib/composer/compose.ts +65 -0
  30. package/harness/lib/composer/fragments/01-identity.md +1 -0
  31. package/harness/lib/composer/fragments/02-delegation.md +6 -0
  32. package/harness/lib/composer/fragments/03-permissions.md +13 -0
  33. package/harness/lib/composer/fragments/04-task-flow.md +15 -0
  34. package/harness/lib/composer/fragments/05-confidence.md +5 -0
  35. package/harness/lib/composer/fragments/06-parallelization.md +17 -0
  36. package/harness/lib/composer/fragments/07-shell.md +41 -0
  37. package/harness/lib/composer/fragments/08-routing.md +8 -0
  38. package/harness/lib/composer/fragments/09-guardrails.md +12 -0
  39. package/harness/lib/composer/index.ts +1 -0
  40. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +70 -0
  41. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +59 -0
  42. package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
  43. package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
  44. package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
  45. package/harness/lib/hooks/builtins/route-tracking-hook.ts +147 -0
  46. package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
  47. package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
  48. package/harness/lib/hooks/hooks.test.ts +1016 -0
  49. package/harness/lib/hooks/index.ts +30 -0
  50. package/harness/lib/hooks/registry.ts +416 -0
  51. package/harness/lib/hooks/types.ts +71 -0
  52. package/harness/lib/memory/index.ts +18 -0
  53. package/harness/lib/memory/interfaces.ts +53 -0
  54. package/harness/lib/memory/memory-manager.ts +205 -0
  55. package/harness/lib/memory/memory.test.ts +491 -0
  56. package/harness/lib/memory/plan-store.ts +366 -0
  57. package/harness/lib/recovery/handler.ts +243 -0
  58. package/harness/lib/recovery/index.ts +14 -0
  59. package/harness/lib/recovery/interfaces.ts +48 -0
  60. package/harness/lib/recovery/patterns.ts +149 -0
  61. package/harness/lib/recovery/recovery.test.ts +312 -0
  62. package/harness/lib/sanity/anomaly-tracker.ts +127 -0
  63. package/harness/lib/sanity/checker.ts +178 -0
  64. package/harness/lib/sanity/index.ts +13 -0
  65. package/harness/lib/sanity/interfaces.ts +24 -0
  66. package/harness/lib/sanity/sanity.test.ts +472 -0
  67. package/harness/lib/sync/file-watcher.ts +174 -0
  68. package/harness/lib/sync/index.ts +11 -0
  69. package/harness/lib/sync/interfaces.ts +27 -0
  70. package/harness/lib/sync/plan-sync.ts +536 -0
  71. package/harness/lib/sync/sync.test.ts +832 -0
  72. package/harness/skills/oh-ascii/DEEP.md +292 -0
  73. package/harness/skills/oh-ascii/SKILL.md +31 -0
  74. package/harness/skills/oh-ascii/scripts/check_ascii_alignment.py +596 -0
  75. package/harness/skills/oh-browser/DEEP.md +54 -0
  76. package/harness/skills/oh-browser/SKILL.md +30 -0
  77. package/harness/skills/oh-builder/DEEP.md +63 -0
  78. package/harness/skills/oh-builder/SKILL.md +12 -90
  79. package/harness/skills/oh-expert/DEEP.md +85 -0
  80. package/harness/skills/oh-expert/SKILL.md +13 -106
  81. package/harness/skills/oh-facade/DEEP.md +182 -0
  82. package/harness/skills/oh-facade/SKILL.md +15 -279
  83. package/harness/skills/oh-freeze/DEEP.md +18 -0
  84. package/harness/skills/oh-freeze/SKILL.md +10 -19
  85. package/harness/skills/oh-full-output/DEEP.md +25 -0
  86. package/harness/skills/oh-full-output/SKILL.md +12 -65
  87. package/harness/skills/oh-fusion/DEEP.md +120 -0
  88. package/harness/skills/oh-fusion/SKILL.md +17 -295
  89. package/harness/skills/oh-gauntlet/DEEP.md +77 -0
  90. package/harness/skills/oh-gauntlet/SKILL.md +13 -105
  91. package/harness/skills/oh-grill/DEEP.md +51 -0
  92. package/harness/skills/oh-grill/SKILL.md +12 -63
  93. package/harness/skills/oh-guard/DEEP.md +19 -0
  94. package/harness/skills/oh-guard/SKILL.md +10 -24
  95. package/harness/skills/oh-handoff/DEEP.md +48 -0
  96. package/harness/skills/oh-handoff/SKILL.md +13 -23
  97. package/harness/skills/oh-health/DEEP.md +74 -0
  98. package/harness/skills/oh-health/SKILL.md +13 -76
  99. package/harness/skills/oh-init/DEEP.md +85 -0
  100. package/harness/skills/oh-init/SKILL.md +13 -127
  101. package/harness/skills/oh-investigate/DEEP.md +171 -0
  102. package/harness/skills/oh-investigate/SKILL.md +13 -66
  103. package/harness/skills/oh-issue/DEEP.md +21 -0
  104. package/harness/skills/oh-issue/SKILL.md +11 -27
  105. package/harness/skills/oh-manifest/DEEP.md +92 -0
  106. package/harness/skills/oh-manifest/SKILL.md +12 -109
  107. package/harness/skills/oh-plan-review/DEEP.md +90 -0
  108. package/harness/skills/oh-plan-review/SKILL.md +13 -115
  109. package/harness/skills/oh-planner/DEEP.md +172 -0
  110. package/harness/skills/oh-planner/SKILL.md +12 -149
  111. package/harness/skills/oh-prd/DEEP.md +45 -0
  112. package/harness/skills/oh-prd/SKILL.md +10 -26
  113. package/harness/skills/oh-refactor/DEEP.md +122 -0
  114. package/harness/skills/oh-refactor/SKILL.md +17 -410
  115. package/harness/skills/oh-retro/DEEP.md +26 -0
  116. package/harness/skills/oh-retro/SKILL.md +12 -24
  117. package/harness/skills/oh-review/DEEP.md +87 -0
  118. package/harness/skills/oh-review/SKILL.md +11 -97
  119. package/harness/skills/oh-security/DEEP.md +83 -0
  120. package/harness/skills/oh-security/SKILL.md +14 -96
  121. package/harness/skills/oh-ship/DEEP.md +141 -0
  122. package/harness/skills/oh-ship/SKILL.md +14 -32
  123. package/harness/skills/oh-skill-craft/DEEP.md +369 -0
  124. package/harness/skills/oh-skill-craft/SKILL.md +13 -177
  125. package/harness/skills/oh-skills-link/DEEP.md +16 -0
  126. package/harness/skills/oh-skills-link/SKILL.md +10 -20
  127. package/harness/skills/oh-skills-list/DEEP.md +20 -0
  128. package/harness/skills/oh-skills-list/SKILL.md +9 -22
  129. package/harness/skills/oh-triage/DEEP.md +23 -0
  130. package/harness/skills/oh-triage/SKILL.md +8 -24
  131. package/harness/skills/oh-worktree/DEEP.md +169 -0
  132. package/harness/skills/oh-worktree/SKILL.md +32 -0
  133. package/lib/harness-resolver.ts +8 -10
  134. package/package.json +7 -5
  135. package/tsconfig.json +1 -1
  136. package/harness/codex/CONSTITUTION.md +0 -73
  137. package/harness/codex/ROUTING.md +0 -92
  138. package/harness/commands/oh-doctor.md +0 -26
  139. package/harness/commands/oh-log.md +0 -18
  140. package/harness/instructions/RUNTIME.md +0 -30
  141. package/harness/skills/oh-caveman/SKILL.md +0 -42
  142. package/harness/skills/oh-learn/SKILL.md +0 -101
  143. package/lib/logger.ts +0 -75
package/bootstrap.ts CHANGED
@@ -1,35 +1,46 @@
1
1
  import path from "node:path"
2
2
  import fs from "node:fs"
3
3
  import os from "node:os"
4
- import { fileURLToPath } from "node:url"
5
4
  import type { Plugin } from "@opencode-ai/plugin"
6
- import { createLogger } from "./lib/logger.ts"
7
5
  import { getHarnessDir, setHarnessRootForTest, resolveHarnessRoot } from "./lib/harness-resolver.ts"
6
+ import { compose } from "./harness/lib/composer/index.ts"
7
+
8
+ // Hook system — pluggable lifecycle hooks with topological sort
9
+ import {
10
+ HookRegistry,
11
+ HookResult,
12
+ planCheckHook,
13
+ shellDetectHook,
14
+ confidenceGateHook,
15
+ delegationDepthHook,
16
+ resetDepthTracker,
17
+ errorRecoveryHook,
18
+ memorySyncHook,
19
+ sanityCheckHook,
20
+ routeTrackingHook,
21
+ } from "./harness/lib/hooks/index.ts"
8
22
 
9
- const log = createLogger("bootstrap")
10
- const sessionLog = createLogger("session")
11
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
- const BOOTSTRAP_MARKER = "OPENHERMES_BOOTSTRAP"
13
23
  const OPENHERMES_AGENT = "OpenHermes"
14
24
 
25
+ // User skill directories — auto-discovered on every session, survive npm updates
26
+ const USER_SKILL_DIRS: ReadonlyArray<string> = [
27
+ path.join(os.homedir(), ".agents", "skills"),
28
+ path.join(os.homedir(), ".config", "opencode", "skills"),
29
+ path.join(os.homedir(), ".claude", "skills"), // Claude Code backward compat
30
+ ]
31
+
15
32
  // Canonical storage under OpenCode's data directory — survives npm updates
16
33
  let _planStorageOverride: string | undefined
17
34
  export function setPlanStorageDirForTest(dir: string | undefined): void { _planStorageOverride = dir }
18
35
  function planStorageDir(): string {
19
- return _planStorageOverride ?? path.join(os.homedir(), ".local", "share", "opencode", "openhermes", "plans")
36
+ return _planStorageOverride ?? path.join(os.homedir(), ".local", "share", "openhermes", "plans")
20
37
  }
21
38
 
22
39
  function getProjectName(projectDir: string): string {
23
40
  return path.basename(projectDir)
24
41
  }
25
42
 
26
- // User skill directories auto-scanned on every session, survive npm updates
27
- const USER_SKILL_DIRS: ReadonlyArray<string> = [
28
- path.join(os.homedir(), ".agents", "skills"),
29
- path.join(os.homedir(), ".config", "opencode", "skills"),
30
- ]
31
-
32
- export { resolveHarnessRoot, setHarnessRootForTest, getHarnessDir }
43
+ export { resolveHarnessRoot, setHarnessRootForTest, getHarnessDir, ensurePlanFile, findLatestPlanFile }
33
44
 
34
45
  function parseFrontmatter(raw: string | undefined): Record<string, string> {
35
46
  const frontmatter: Record<string, string> = {}
@@ -128,29 +139,22 @@ function uniqueStrings(existing: string[] = [], additions: string[] = []): strin
128
139
  return merged
129
140
  }
130
141
 
131
- function readText(filePath: string): string {
132
- return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""
133
- }
134
-
135
- function regexEscape(s: string): string {
136
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
137
- }
138
142
 
139
143
  function findLatestPlanFile(projectDir: string): string | null {
140
144
  const projectName = getProjectName(projectDir)
141
145
  const storage = planStorageDir()
142
- if (!fs.existsSync(storage)) return null
143
- const pattern = new RegExp(`^${regexEscape(projectName)}-plan-(\\d{3})\\.md$`)
146
+ const projectDirPath = path.join(storage, projectName)
147
+ if (!fs.existsSync(projectDirPath)) return null
144
148
  let latest: string | null = null
145
149
  let highest = -1
146
150
  try {
147
- for (const entry of fs.readdirSync(storage)) {
148
- const m = entry.match(pattern)
151
+ for (const entry of fs.readdirSync(projectDirPath)) {
152
+ const m = entry.match(/^plan-(\d{3})\.md$/)
149
153
  if (m) {
150
154
  const n = parseInt(m[1], 10)
151
155
  if (n > highest) {
152
156
  highest = n
153
- latest = path.join(storage, entry)
157
+ latest = path.join(projectDirPath, entry)
154
158
  }
155
159
  }
156
160
  }
@@ -177,20 +181,77 @@ function readPlanSummary(projectDir: string): string | null {
177
181
  }
178
182
 
179
183
  function ensureDir(dir: string): void {
180
- if (!fs.existsSync(dir)) {
181
- fs.mkdirSync(dir, { recursive: true })
184
+ try {
185
+ if (!fs.existsSync(dir)) {
186
+ fs.mkdirSync(dir, { recursive: true })
187
+ }
188
+ } catch (err) {
189
+ const msg = err instanceof Error ? err.message : String(err)
190
+ console.error(`[openhermes] Failed to create directory ${dir}: ${msg}`)
191
+ // Don't throw — let the plan system degrade gracefully
182
192
  }
183
193
  }
184
194
 
185
- function countSkills(dir: string): number {
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
+
186
247
  try {
187
- return fs.readdirSync(dir).filter(e => {
188
- const full = path.join(dir, e)
189
- return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, "SKILL.md"))
190
- }).length
191
- } catch {
192
- return 0
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
193
253
  }
254
+ return planPath
194
255
  }
195
256
 
196
257
  export function buildCompactionContext(projectDir: string): string[] {
@@ -232,83 +293,8 @@ export function formatSessionEvent(event: SessionLifecycleEvent): { level: "info
232
293
  }
233
294
  }
234
295
 
235
- function parseRouteYaml(raw: string): { pass: string; fail: string; blocker: string } {
236
- const def: { pass: string; fail: string; blocker: string } = { pass: "surface", fail: "surface", blocker: "surface" }
237
- const m = raw.match(/route:\n((?: [^\n]*\n?)*)/)
238
- if (!m) return def
239
- const block = m[1]
240
-
241
- const kv = (key: string): string | undefined => {
242
- // Single-line: pass: oh-builder (horizontal whitespace only, no newlines)
243
- const s = block.match(new RegExp(` ${key}:[ \\t]*(\\S.*)`))
244
- if (s) return s[1].trim()
245
- // Multi-line array: pass:\n - oh-builder\n - oh-gauntlet
246
- const a = block.match(new RegExp(` ${key}:\\n((?: - .+\\n?)*)`))
247
- if (a) {
248
- const items = a[1].match(/ - (.+)/g)?.map(i => i.replace(/ - /, "").trim()) ?? []
249
- return items.length > 0 ? `[${items.join(", ")}]` : undefined
250
- }
251
- return undefined
252
- }
253
296
 
254
- const p = kv("pass")
255
- const f = kv("fail")
256
- const b = kv("blocker")
257
- if (p) def.pass = p
258
- if (f) def.fail = f
259
- if (b) def.blocker = b
260
- return def
261
- }
262
297
 
263
- function buildRoutingInventory(skillDirs: string[]): string {
264
- const rows: string[] = []
265
- for (const dir of skillDirs) {
266
- let entries: string[] = []
267
- try { entries = fs.readdirSync(dir).filter(e => fs.statSync(path.join(dir, e)).isDirectory()) } catch { continue }
268
- for (const name of entries.sort()) {
269
- const skPath = path.join(dir, name, "SKILL.md")
270
- if (!fs.existsSync(skPath)) continue
271
- const raw = fs.readFileSync(skPath, "utf8").replace(/\r\n/g, "\n")
272
- const fm = raw.match(/^---\n([\s\S]*?)\n---/)
273
- if (!fm) continue
274
- const route = parseRouteYaml(fm[1])
275
- rows.push(`| **${name}** | ${route.pass} | ${route.fail} | ${route.blocker} |`)
276
- }
277
- }
278
- if (rows.length === 0) return ""
279
- const header = "## Dynamic Routing Inventory\n\nAll skills and their routes:\n\n| Skill | pass | fail | blocker |\n|---|---|---|---|\n"
280
- return header + rows.join("\n")
281
- }
282
-
283
- function buildBootstrapContent(hDir: string, extraDirs: string[] = []): string {
284
- const parts = [
285
- `<${BOOTSTRAP_MARKER}>`,
286
- `You are OpenHermes.`,
287
- `OpenHermes is OpenCode-native: load skills on demand, always delegate, never execute tasks directly, and keep the surface small.`,
288
- `Durable state is removed for now. Do not invent a persistence layer unless the user explicitly asks for one later.`,
289
- ]
290
-
291
- const autopilot = readText(path.join(hDir, "codex", "AUTOPILOT.md"))
292
- const constitution = readText(path.join(hDir, "codex", "CONSTITUTION.md"))
293
- const runtime = readText(path.join(hDir, "instructions", "RUNTIME.md"))
294
- const context = readText(path.join(__dirname, "CONTEXT.md"))
295
- const ethos = readText(path.join(__dirname, "ETHOS.md"))
296
-
297
- if (autopilot) parts.push(`<AUTOPILOT>\n${autopilot}\n</AUTOPILOT>`)
298
- if (constitution) parts.push(`<CONSTITUTION>\n${constitution}\n</CONSTITUTION>`)
299
- if (runtime) parts.push(`<RUNTIME>\n${runtime}\n</RUNTIME>`)
300
- if (context) parts.push(`<CONTEXT>\n${context}\n</CONTEXT>`)
301
- if (ethos) parts.push(`<ETHOS>\n${ethos}\n</ETHOS>`)
302
-
303
- // Dynamic routing inventory: built-in skills + user skills
304
- const allSkillDirs = [path.join(hDir, "skills"), ...extraDirs.filter(Boolean)]
305
- const inventory = buildRoutingInventory(allSkillDirs)
306
- if (inventory) parts.push(inventory)
307
-
308
- parts.push(`</${BOOTSTRAP_MARKER}>`)
309
-
310
- return parts.join("\n\n")
311
- }
312
298
 
313
299
  interface OpenHermesConfig {
314
300
  skills?: { paths?: string[] }
@@ -316,6 +302,7 @@ interface OpenHermesConfig {
316
302
  agent?: Record<string, unknown>
317
303
  instructions?: string[]
318
304
  default_agent?: string
305
+ [key: string]: unknown // allow additional SDK properties (experimental, etc.)
319
306
  }
320
307
 
321
308
  export const BootstrapPlugin: Plugin = async (ctx) => {
@@ -323,56 +310,143 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
323
310
  const skillsDir = path.join(hDir, "skills")
324
311
  const commandsDir = path.join(hDir, "commands")
325
312
  const agentsDir = path.join(hDir, "agents")
313
+ const client = ctx.client // SDK client for structured logging
314
+
315
+ // Safe logging — uses OpenCode SDK when available, falls back to stdout for tests
316
+ async function logToOC(level: "info" | "warn" | "error" | "debug", message: string): Promise<void> {
317
+ if (client?.app?.log) {
318
+ await client.app.log({ body: { service: "openhermes", level, message } })
319
+ } else {
320
+ console.log(`[openhermes] [${level.toUpperCase()}] ${message}`)
321
+ }
322
+ }
323
+
326
324
  // Auto-detect and wire user skills from ~/.agents/skills and ~/.config/opencode/skills
327
- // (Must happen before bootstrapContent is built so routing inventory includes user skills)
328
325
  const userSkillPaths: string[] = []
329
326
  for (const userDir of USER_SKILL_DIRS) {
330
- ensureDir(userDir)
331
- const count = countSkills(userDir)
332
- if (count > 0) {
333
- userSkillPaths.push(userDir)
334
- log.info(`found ${count} user skill(s) in ${userDir}`)
335
- }
327
+ try { ensureDir(userDir) } catch {}
328
+ userSkillPaths.push(userDir)
329
+ await logToOC("info", `wired user skills from ${userDir}`)
336
330
  }
337
331
 
338
- const bootstrapContent = buildBootstrapContent(hDir, userSkillPaths)
339
332
  const compactionContext = buildCompactionContext(ctx.directory)
340
- const builtInCount = countSkills(skillsDir)
341
- const userCount = userSkillPaths.reduce((sum, d) => sum + countSkills(d), 0)
342
-
343
333
  // Ensure plan storage exists
344
- ensureDir(planStorageDir())
334
+ try { ensureDir(planStorageDir()) } catch {}
345
335
 
346
336
  return {
347
337
  config: async (config: OpenHermesConfig) => {
338
+ // ── 1. Hooks System ─────────────────────────────────────────────────
339
+ // Read experimental.hooks config from the raw config object
340
+ const hooksConfig = (config.experimental as Record<string, unknown> | undefined)?.hooks as
341
+ | Record<string, boolean>
342
+ | undefined
343
+ const hooksEnabled = (hooksConfig?.enabled ?? true) as boolean
344
+
345
+ if (hooksEnabled) {
346
+ const reg = HookRegistry.getInstance()
347
+
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)
357
+
358
+ await logToOC("info", `hooks: ${reg.getPreToolHooks().length + reg.getPostToolHooks().length + reg.getRouteHooks().length} registered`)
359
+ } else {
360
+ await logToOC("info", "hooks: disabled via config")
361
+ }
362
+
363
+ // ── 2. Skills ──────────────────────────────────────────────────────
348
364
  config.skills = config.skills || {}
349
365
  // Built-in paths first, user paths last → user skills override built-in on name conflict
350
366
  const allPaths = [skillsDir, ...userSkillPaths]
351
367
  config.skills.paths = uniqueStrings(config.skills.paths || [], allPaths)
352
368
 
353
- log.info(`skills: ${builtInCount} built-in + ${userCount} user (${allPaths.length} path(s))`)
369
+ await logToOC("info", `skills: ${allPaths.length} path(s)`)
370
+
371
+ // Register harness docs as native OpenCode instructions — no prompt-embedding needed
372
+ config.instructions = uniqueStrings(config.instructions ?? [], [
373
+ path.join(hDir, "codex"),
374
+ path.join(hDir, "instructions"),
375
+ ])
354
376
 
355
377
  config.command = { ...(config.command ?? {}), ...commandDefinitions(commandsDir) }
356
378
 
357
379
  const loadedAgents = agentDefinitions(agentsDir)
358
- const openHermesAgent = loadedAgents[OPENHERMES_AGENT] ?? {
359
- description: "OpenHermes primary orchestrator",
360
- mode: "primary",
361
- prompt: "You are OpenHermes.",
380
+ // Use composer for the OpenHermes agent prompt — assemble from fragments
381
+ let openHermesPrompt: string
382
+ try {
383
+ openHermesPrompt = compose()
384
+ } catch {
385
+ openHermesPrompt = loadedAgents[OPENHERMES_AGENT]?.prompt ?? "You are OpenHermes."
386
+ }
387
+ const openHermesAgent = {
388
+ description: loadedAgents[OPENHERMES_AGENT]?.description ?? "OpenHermes primary orchestrator",
389
+ mode: loadedAgents[OPENHERMES_AGENT]?.mode ?? "primary",
390
+ prompt: openHermesPrompt,
391
+ }
392
+
393
+ // Subagent permissions — tier-4 and tier-3 get execution access but cannot spawn orchestrators
394
+ const SUBAGENT_PERMISSIONS: Record<string, Record<string, unknown>> = {
395
+ "oh-builder": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
396
+ "oh-browser": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
397
+ "oh-facade": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
398
+ "oh-fusion": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
399
+ "oh-gauntlet": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
400
+ "oh-grill": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
401
+ "oh-investigate": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
402
+ "oh-manifest": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
403
+ "oh-plan-review": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
404
+ "oh-planner": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
405
+ "oh-refactor": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
406
+ "oh-retro": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
407
+ "oh-review": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
408
+ "oh-security": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
409
+ "oh-ship": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
410
+ "oh-skill-craft": { bash: { "*": "allow" }, edit: "allow", read: "allow", glob: "allow", grep: "allow", task: { "oh-*": "deny" } },
362
411
  }
363
412
 
364
413
  config.agent = {
365
414
  ...(config.agent ?? {}),
366
415
  ...loadedAgents,
416
+ // Apply permissions + hidden flag to subagents
417
+ ...Object.fromEntries(
418
+ Object.entries(loadedAgents)
419
+ .filter(([name]) => name !== OPENHERMES_AGENT)
420
+ .map(([name, agentDef]) => [
421
+ name,
422
+ {
423
+ ...agentDef,
424
+ permission: SUBAGENT_PERMISSIONS[name] ?? { bash: { "*": "deny" }, edit: "deny", read: "allow" },
425
+ // Hide routing-internal subagents from @-menu
426
+ // Only agents with existing .md files can be hidden — names without files are no-ops
427
+ ...(["oh-planner", "oh-grill", "oh-skill-craft"].includes(name) ? { hidden: true } : {}),
428
+ },
429
+ ])
430
+ ),
367
431
  [OPENHERMES_AGENT]: {
368
432
  ...openHermesAgent,
369
433
  description: openHermesAgent.description || "OpenHermes primary orchestrator",
370
434
  mode: "primary",
435
+ steps: 15, // Max agentic iterations — prevents runaway loops
371
436
  permission: {
372
- bash: { "*": "allow" },
373
- edit: "allow",
374
- read: "allow",
375
- task: { "*": "allow" },
437
+ bash: { "*": "deny" }, // CANNOT execute commands
438
+ edit: "deny", // CANNOT write/edit files
439
+ read: "allow", // CAN read for classification
440
+ glob: "allow", // CAN search for files
441
+ grep: "allow", // CAN search content
442
+ task: { "*": "allow" }, // MUST delegate via subagents
443
+ skill: "allow", // CAN load skill instructions
444
+ webfetch: "allow", // CAN fetch docs for context
445
+ question: "allow", // CAN ask user questions
446
+ 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
+ },
376
450
  },
377
451
  },
378
452
  }
@@ -381,25 +455,205 @@ export const BootstrapPlugin: Plugin = async (ctx) => {
381
455
  },
382
456
 
383
457
  event: async ({ event }) => {
384
- const record = formatSessionEvent(event as SessionLifecycleEvent)
458
+ const typed = event as SessionLifecycleEvent
459
+ const record = formatSessionEvent(typed)
385
460
  if (!record) return
386
- sessionLog[record.level](record.message)
461
+ await logToOC(record.level, record.message)
462
+
463
+ // NOTE: Plan files are NOT auto-created here. The LLM agent
464
+ // creates plans on demand (see Task Flow step 1 in agent prompt).
465
+ // Auto-creation produced ghost skeletons like plan-004.
466
+
467
+ // Reset delegation depth on session start/error
468
+ if (typed.type === "session.created" || typed.type === "session.error") {
469
+ resetDepthTracker()
470
+ }
387
471
  },
388
472
 
389
473
  "experimental.session.compacting": async (_input, output) => {
390
474
  output.context.push(...compactionContext)
391
475
  },
392
476
 
393
- "experimental.chat.messages.transform": async (_input: unknown, output: { messages?: Array<{ info?: { role?: string }; parts?: Array<{ text?: string; type?: string }> }> }) => {
394
- try {
395
- if (!output.messages?.length) return
396
- const firstUser = output.messages.find(m => m?.info?.role === "user")
397
- if (!firstUser?.parts?.length) return
398
- if (firstUser.parts.some(p => p.text?.includes(BOOTSTRAP_MARKER))) return
399
- firstUser.parts.unshift({ type: "text", text: bootstrapContent })
400
- } catch (err: unknown) {
401
- log.error("transform error:", (err as Error)?.message)
477
+ // Hook-enabled tool execution delegates to HookRegistry for lifecycle hooks
478
+ "tool.execute.before": async (input, output) => {
479
+ if (input.tool === "task") {
480
+ const reg = HookRegistry.getInstance()
481
+
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"
485
+
486
+ // Build hook context from input and current session state
487
+ const hookContext = {
488
+ sessionId: ctx.directory, // project directory as session key
489
+ agent: agentName,
490
+ directory: ctx.directory,
491
+ 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
+ }
500
+
501
+ // Run all registered PreToolUse hooks (plan check, shell detect, delegation depth)
502
+ let preToolResult: any
503
+ try {
504
+ preToolResult = await reg.executePreTool(hookContext)
505
+ } catch (err) {
506
+ const msg = err instanceof Error ? err.message : String(err)
507
+ const errOutput = output as { args: unknown; isError?: boolean; content?: unknown[] }
508
+ errOutput.isError = true
509
+ errOutput.content = [{ type: "text", text: `Hook error (PreTool): ${msg}` }]
510
+ return
511
+ }
512
+
513
+ if (preToolResult.result === HookResult.STOP) {
514
+ // Depth exceeded or other stop condition
515
+ const errOutput = output as { args: unknown; isError?: boolean; content?: unknown[] }
516
+ errOutput.isError = true
517
+ const message = (preToolResult.modifiedContext?._depthError as string)
518
+ ?? "LOOP GUARD: Delegation depth exceeded (max 25). " +
519
+ "Surface to orchestrator with findings and stop delegating."
520
+ errOutput.content = [{ type: "text", text: message }]
521
+ return
522
+ }
523
+
524
+ // Handle INJECT from PreTool hooks (e.g. plan check wants a plan first)
525
+ if (preToolResult.result === HookResult.INJECT) {
526
+ const planInstruction = preToolResult.modifiedContext?._planCheckInstruction as string | undefined
527
+ if (planInstruction) {
528
+ // Plan check hook returned INJECT — inject "create plan" instruction
529
+ const inputAny = input as Record<string, unknown>
530
+ const existingPrompt = (inputAny.description as string) || (inputAny.prompt as string) || ""
531
+ if (inputAny.description) {
532
+ inputAny.description = `${planInstruction}\n\n${existingPrompt}`
533
+ } else if (inputAny.prompt) {
534
+ inputAny.prompt = `${planInstruction}\n\n${existingPrompt}`
535
+ } else {
536
+ inputAny.description = planInstruction
537
+ }
538
+ await logToOC("info", `Plan check: injected plan creation instruction into task for "${agentName}"`)
539
+ } else {
540
+ // Generic INJECT — hooks modified task context
541
+ await logToOC("debug", `PreTool INJECT: hooks modified task context for "${agentName}"`)
542
+ }
543
+ // Continue execution — INJECT is not a stop signal
544
+ }
545
+
546
+ // Run all registered RouteHooks — the agent/skill being delegated to IS the route
547
+ // This fires confidence-gate (inject confirm/question on MEDIUM/LOW confidence)
548
+ // and route-tracking (guard against infinite routing loops)
549
+ let routeResult: any
550
+ try {
551
+ routeResult = await reg.executeRoute(hookContext, agentName)
552
+ } catch (err) {
553
+ const msg = err instanceof Error ? err.message : String(err)
554
+ await logToOC("error", `Hook error (Route): ${msg}`)
555
+ // Route failed — don't change routing decision, just log
556
+ routeResult = { result: HookResult.CONTINUE }
557
+ }
558
+
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) {
571
+ // Confidence gate wants to inject a confirmation/pause into routing.
572
+ // Parse the modifiedRoute for markers and inject into task description/prompt.
573
+ const modifiedRoute: string = routeResult.modifiedRoute
574
+ const inputAny = input as Record<string, unknown>
575
+ const existingPrompt = (inputAny.description as string) || (inputAny.prompt as string) || ""
576
+ let gateMsg: string | undefined
577
+
578
+ if (modifiedRoute.includes("?echo=confirm")) {
579
+ gateMsg = "[CONFIDENCE: MEDIUM] Review your plan and confirm it before executing."
580
+ } else if (modifiedRoute.includes("?question=pause")) {
581
+ gateMsg = "[CONFIDENCE: LOW] Pause and ask the user for approval before proceeding."
582
+ }
583
+
584
+ if (gateMsg) {
585
+ if (inputAny.description) {
586
+ inputAny.description = `${gateMsg}\n${existingPrompt}`
587
+ } else if (inputAny.prompt) {
588
+ inputAny.prompt = `${gateMsg}\n${existingPrompt}`
589
+ } else {
590
+ inputAny.description = gateMsg
591
+ }
592
+ await logToOC("info", `Confidence gate: injected instruction into task to "${agentName}": ${gateMsg}`)
593
+ }
594
+ }
402
595
  }
403
596
  },
597
+
598
+ // Hook-enabled post-execution — runs PostToolUse hooks
599
+ "tool.execute.after": async (input, output) => {
600
+ if (input.tool === "task") {
601
+ const reg = HookRegistry.getInstance()
602
+
603
+ // Access optional fields from input
604
+ const inputAny = input as Record<string, unknown>
605
+ const agentName = typeof inputAny.agent === "string" ? inputAny.agent : "unknown"
606
+
607
+ // 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
+ }
617
+
618
+ // Extract output text from tool result
619
+ // output.content may be array of content blocks, or a string, or undefined
620
+ const outputAny = output as Record<string, unknown> | undefined
621
+ let outputText = ""
622
+ if (outputAny?.content) {
623
+ const content = outputAny.content
624
+ if (typeof content === "string") {
625
+ outputText = content
626
+ } else if (Array.isArray(content)) {
627
+ outputText = content
628
+ .map((c: Record<string, unknown>) => (typeof c.text === "string" ? c.text : ""))
629
+ .join("\n")
630
+ }
631
+ }
632
+
633
+ // Run all registered PostToolUse hooks
634
+ try {
635
+ const postToolResult = await reg.executePostTool(hookContext, outputText)
636
+
637
+ // Surface recovery instructions from errorRecoveryHook and/or sanityCheckHook
638
+ if (postToolResult.recovery) {
639
+ await logToOC("warn", `PostTool recovery instruction:\n${postToolResult.recovery}`)
640
+ }
641
+
642
+ // 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
649
+ } catch (err) {
650
+ const msg = err instanceof Error ? err.message : String(err)
651
+ await logToOC("error", `Hook error (PostTool): ${msg}`)
652
+ // Non-fatal — tool already executed
653
+ }
654
+ }
655
+ },
656
+
404
657
  }
405
658
  }
659
+