]`**, reverse only Honeycomb's footprint for one harness, or for every detected harness when no target is given.
-The connector registry (`src/cli/connector-runner.ts`, `createConnectorRegistry`) builds each connector over the real `node:fs`-backed `ConnectorFs` and the user's home. Claude Code is wired by registering its marketplace plugin via the real `claude plugin` CLI (rather than writing top-level `settings.json` hooks); Codex and Cursor are wired by the config-patch path. A new harness is a subclass added to the registry, never a fork of install logic.
+The connector registry (`src/cli/connector-runner.ts`, `createConnectorRegistry`) builds each connector over the real `node:fs`-backed `ConnectorFs` and the user's home. Claude Code is wired by registering its marketplace plugin via the real `claude plugin` CLI (rather than writing top-level `settings.json` hooks); Codex, Cursor, and Hermes are wired by their native config-patch paths. A new harness is a subclass added to the registry, never a fork of install logic.
## The support matrix
@@ -82,21 +83,15 @@ Each harness wires the same logical lifecycle events through its own mechanism;
| Claude Code | Supported | Marketplace plugin + hooks + MCP | Reference connector and reference hook set; model-only context, `legacy` runtime path |
| Codex | Supported | `~/.codex/hooks.json` + hooks + MCP | Nested matcher-block config shape; user-visible context; Bash-only VFS intercept |
| Cursor | Supported | `~/.cursor/hooks.json` + extension + MCP | Flat per-event config shape; first-party editor extension; `Shell`-tool VFS intercept; see [`../frontend/cursor-extension-architecture.md`](../frontend/cursor-extension-architecture.md) |
-| Hermes | In progress | Planned hook + MCP path | Not wired as a production connector path yet |
+| Hermes | Supported | `$HERMES_HOME/config.yaml` shell hooks + MCP | Native 0.19 lifecycle; model-only `pre_llm_call` recall; explicit first-use hook consent |
| pi | In progress | Planned extension + `AGENTS.md` path | Not wired as a production connector path yet |
| OpenClaw | In progress | Planned native-extension path | Not wired as a production connector path yet |
-The differences are real but shallow: native event names and payload fields vary, and the context channel is model-only on some harnesses (Claude Code, Cursor, OpenClaw) and user-visible on others (Codex, Hermes, pi), so each shim normalizes before handing off and renders the context block through its harness's channel.
+The differences are real but shallow: native event names and payload fields vary, and the context channel is model-only on some harnesses (Claude Code, Cursor, Hermes, OpenClaw) and user-visible on others (Codex, pi), so each shim normalizes before handing off and renders the context block through its harness's channel.
## MCP-server-via-install
-For harnesses that speak the Model Context Protocol, the Honeycomb MCP server is registered during install so its `honeycomb_*` tools appear in the harness's native tool list. The server bundle is built by esbuild to `mcp/bundle/server.js` and ships with the package. Hermes, for example, registers it through its `.mcp.json`:
-
-```json
-{ "mcpServers": { "honeycomb": { "command": "node", "args": ["mcp/bundle/server.js"] } } }
-```
-
-and the Hermes shim appends a user-visible mention so the agent knows the tools exist: `(Honeycomb MCP tools available: honeycomb_search, honeycomb_read, honeycomb_index.)`. The same `node mcp/bundle/server.js` stdio entry registers into the other MCP-speaking harnesses during their connect step. The tool surface, the read/resolve and search/mine clusters, and the registration mechanics are documented in [`mcp-and-sdk.md`](mcp-and-sdk.md).
+For harnesses that speak the Model Context Protocol, the Honeycomb MCP server is registered during install so its `honeycomb_*` tools appear in the harness's native tool list. The server bundle is built by esbuild to `mcp/bundle/server.js` and ships with the package. The Hermes connector copies it to `$HERMES_HOME/honeycomb/mcp/server.mjs` and writes a foreign-safe `mcp_servers.honeycomb` stdio entry in `config.yaml`; no repository-local `.mcp.json` is involved. The tool surface, the read/resolve and search/mine clusters, and the registration mechanics are documented in [`mcp-and-sdk.md`](mcp-and-sdk.md).
## The Claude Code plugin: packaging and delivery
diff --git a/library/knowledge/private/integrations/hook-lifecycle.md b/library/knowledge/private/integrations/hook-lifecycle.md
index 2455765e..3ed3df83 100644
--- a/library/knowledge/private/integrations/hook-lifecycle.md
+++ b/library/knowledge/private/integrations/hook-lifecycle.md
@@ -29,12 +29,12 @@ Each harness has its own event vocabulary. The table maps the logical Honeycomb
| Logical event | Claude Code | Codex | Cursor | Hermes | pi | OpenClaw |
|---|---|---|---|---|---|---|
-| Session start / recall inject | `SessionStart` | `SessionStart` | `sessionStart` | `on_session_start` | AGENTS.md static block | `before_agent_start` + `before_prompt_build` |
-| Prompt capture | `UserPromptSubmit` | `UserPromptSubmit` | `beforeSubmitPrompt` | `on_user_message` | (batched) | `agent_end` (batch) |
-| Pre-tool intercept (VFS recall) | `PreToolUse` | `PreToolUse` (Bash) | `beforeShellExecution` (Shell) | `on_tool_use` (terminal only) | N/A | N/A |
-| Tool-call capture | `PostToolUse` | `PostToolUse` | `postToolUse` | `on_tool_use` (terminal only) | N/A | `agent_end` (batch) |
-| Assistant response capture | `Stop` / `SubagentStop` | `Stop` | `afterAgentResponse` / `stop` | N/A | N/A | `agent_end` (batch) |
-| Session end / summary spawn | `SessionEnd` | N/A (periodic only) | `sessionEnd` | `on_session_end` | `agent_end` / `session_shutdown` | `agent_end` (with summary slice) |
+| Session start / recall inject | `SessionStart` | `SessionStart` | `sessionStart` | `on_session_start` + `pre_llm_call` | AGENTS.md static block | `before_agent_start` + `before_prompt_build` |
+| Prompt capture | `UserPromptSubmit` | `UserPromptSubmit` | `beforeSubmitPrompt` | `pre_llm_call` (`extra.user_message`) | (batched) | `agent_end` (batch) |
+| Pre-tool intercept (VFS recall) | `PreToolUse` | `PreToolUse` (Bash) | `beforeShellExecution` (Shell) | N/A | N/A | N/A |
+| Tool-call capture | `PostToolUse` | `PostToolUse` | `postToolUse` | `post_tool_call` (`extra.result`) | N/A | `agent_end` (batch) |
+| Assistant response capture | `Stop` / `SubagentStop` | `Stop` | `afterAgentResponse` / `stop` | `post_llm_call` (`extra.assistant_response`) | N/A | `agent_end` (batch) |
+| Session end / summary spawn | `SessionEnd` | N/A (periodic only) | `sessionEnd` | `on_session_finalize` | `agent_end` / `session_shutdown` | `agent_end` (with summary slice) |
A blank cell means that native event is not available on that harness. The lifecycle is still functionally complete: OpenClaw batches capture across the full conversation in `agent_end` rather than per-event, producing the same rows the daemon would have written incrementally, just grouped into one flush; pi reads its session-start context from the static `AGENTS.md` block rather than a live event.
@@ -45,7 +45,7 @@ Each harness also carries a context channel and a host CLI, both single-sourced
| Claude Code | model-only (`additionalContext`) | `legacy` | `claude -p` |
| Codex | user-visible | `legacy` | `codex exec --dangerously-bypass-approvals-and-sandbox` |
| Cursor | model-only (`additional_context`) | `plugin` | `cursor-agent` → `claude` fallback |
-| Hermes | user-visible (`{ context }` + MCP mention) | `legacy` | `hermes --non-interactive` |
+| Hermes | model-only (`{ context }`) | `legacy` | `hermes chat -Q -q` |
| pi | user-visible | `plugin` | `pi --print --provider --model ` |
| OpenClaw | model-only | `plugin` | native extension slice (no host CLI) |
@@ -134,7 +134,7 @@ The pre-tool-use core is the VFS intercept. It runs before tool execution and lo
- `grep` / `Glob` becomes a hybrid lexical-plus-semantic search through the daemon's grep-direct path.
- `ls` becomes a path-prefix listing; `find` becomes a path-pattern query.
-Write and Edit on a memory path are denied with guidance to use the CLI instead. Commands the VFS cannot model (interpreters, pipes, command substitution) are rewritten to a harmless `echo`. The harnesses differ on coverage: Claude Code and Codex intercept Bash; Cursor normalizes its `Shell` tool to the canonical `Bash` shape so the same intercept applies; Hermes intercepts terminal tools only; pi and OpenClaw have no pre-tool intercept.
+Write and Edit on a memory path are denied with guidance to use the CLI instead. Commands the VFS cannot model (interpreters, pipes, command substitution) are rewritten to a harmless `echo`. The harnesses differ on coverage: Claude Code and Codex intercept Bash; Cursor normalizes its `Shell` tool to the canonical `Bash` shape so the same intercept applies; Hermes, pi, and OpenClaw have no pre-tool intercept.
**This path went live in PRD-075.** It was previously scaffolded but dormant. 075a wires the real daemon-backed `VfsIntercept` and propagates a `PreToolDecision` back out of the shared core, and 075b renders that decision into the Claude Code `PreToolUse` contract as a **block-and-inject**: `permissionDecision: "deny"` plus `hookSpecificOutput.additionalContext` carrying the recalled content, so the model's tool call is intercepted and the memory is handed back in one response. The rendered shape is pinned to the real Claude Code contract by conformance tests (`references/claude-code/pretool-response-schema.ts`) so a harness contract change cannot silently break the inject. This is the **model-commanded recall arm**: the model reaches for a memory path and the hook answers, complementary to the always-on prompt-time floor above.
diff --git a/library/knowledge/private/overview.md b/library/knowledge/private/overview.md
index 31b69808..a7854614 100644
--- a/library/knowledge/private/overview.md
+++ b/library/knowledge/private/overview.md
@@ -22,7 +22,7 @@ Honeycomb is the merger of two systems. Hivemind contributed the broad product:
The result is one daemon that captures everything a harness does, distills it into structured, source-backed memory, and serves it back, all on a DeepLake substrate that a team can share.
-Honeycomb is production ready and live-tested end to end: the capture-to-recall path runs green against live Deeplake (`npm run smoke:golden-path` with credentials), and three harnesses (Claude Code, Cursor, Codex) ship in production today, with Hermes, pi, and OpenClaw in progress. Embeddings, the distillation pipeline, and cross-device sharing are deliberate opt-in and by-design choices (covered below and in the linked operations docs), not gaps.
+Honeycomb is production ready and live-tested end to end: the capture-to-recall path runs green against live Deeplake (`npm run smoke:golden-path` with credentials), and four harnesses (Claude Code, Cursor, Codex, Hermes) ship in production today, with pi and OpenClaw in progress. Embeddings, the distillation pipeline, and cross-device sharing are deliberate opt-in and by-design choices (covered below and in the linked operations docs), not gaps.
## The shape
diff --git a/library/knowledge/public/faqs/faq.md b/library/knowledge/public/faqs/faq.md
index 03c9cf8b..791b395c 100644
--- a/library/knowledge/public/faqs/faq.md
+++ b/library/knowledge/public/faqs/faq.md
@@ -20,7 +20,7 @@ A shared, lasting memory for your AI coding assistants, so what one of them lear
No. You install with one command, click a button, and use plain commands like `remember` and `recall`. The technical machinery is hidden behind a friendly dashboard.
**Which AI coding assistants work with it?**
-Three are supported today: Claude Code, Cursor, and Codex. Three more, Hermes, pi, and OpenClaw, are in progress. Honeycomb plugs underneath whichever supported ones you have installed, and a memory written from one is recalled by the others.
+Four are supported today: Claude Code, Cursor, Codex, and Hermes. Two more, pi and OpenClaw, are in progress. Honeycomb plugs underneath whichever supported ones you have installed, and a memory written from one is recalled by the others.
**Who makes Honeycomb?**
It is a collaboration between Legion Code and Activeloop. Activeloop provides [Deep Lake](https://deeplake.ai) (the database for AI it stores memory in) and [Hivemind](https://github.com/activeloopai/hivemind) (the open-source project it builds on). Legion Code adds the multi-tier memory, skill sharing, the self-tidying loop, and the local helper that ties it together.
diff --git a/library/knowledge/public/overview/glossary.md b/library/knowledge/public/overview/glossary.md
index a1024d76..71005136 100644
--- a/library/knowledge/public/overview/glossary.md
+++ b/library/knowledge/public/overview/glossary.md
@@ -13,7 +13,7 @@ Plain-language definitions of the words you will see around Honeycomb. Each entr
**Honeycomb**: A shared, lasting memory for your AI coding assistants. It remembers what you and your assistants do so the knowledge is there next time, in any tool, on any device.
-**Agent / assistant / harness**: All three words point at the same thing: the AI coding tool you actually use (for example Claude Code, Cursor, or Codex). "Harness" is just the technical word for "the tool Honeycomb plugs underneath." Honeycomb supports three today (Claude Code, Cursor, Codex), with three more (Hermes, pi, OpenClaw) in progress.
+**Agent / assistant / harness**: All three words point at the same thing: the AI coding tool you actually use (for example Claude Code, Cursor, Codex, or Hermes). "Harness" is just the technical word for "the tool Honeycomb plugs underneath." Honeycomb supports four today (Claude Code, Cursor, Codex, Hermes), with two more (pi, OpenClaw) in progress.
**Daemon**: The small helper program that runs quietly in the background on your machine. It is the only part of Honeycomb that touches your memory store, which keeps everything in one safe, consistent place. You rarely interact with it directly; it starts itself when needed.
diff --git a/library/knowledge/public/overview/what-is-honeycomb.md b/library/knowledge/public/overview/what-is-honeycomb.md
index 96f2b852..70d7739e 100644
--- a/library/knowledge/public/overview/what-is-honeycomb.md
+++ b/library/knowledge/public/overview/what-is-honeycomb.md
@@ -24,7 +24,7 @@ Think of it as a shared brain your assistants read from and write to on every tu
## What you actually get
- **Memory that survives.** What you figured out yesterday is waiting for you today, already summarized.
-- **Memory that travels across tools.** A note written while using one assistant is recalled by another. Honeycomb plugs underneath the coding assistants you already use (Claude Code, Cursor, and Codex today, with three more in progress).
+- **Memory that travels across tools.** A note written while using one assistant is recalled by another. Honeycomb plugs underneath the coding assistants you already use (Claude Code, Cursor, Codex, and Hermes today, with two more in progress).
- **Skills that spread.** When you (or a teammate) solve something reusable, Honeycomb can turn it into a shareable "skill" that shows up automatically for everyone, no copy-paste.
- **A memory that gets sharper, not noisier.** Honeycomb periodically tidies its own notes: merging duplicates, dropping junk, and keeping the current version of a fact instead of letting stale ones pile up.
- **A friendly dashboard.** A simple local web page shows what has been remembered, how your tools are wired, and the health of everything. No database knowledge required.
diff --git a/package.json b/package.json
index 604d39cd..5b20036e 100644
--- a/package.json
+++ b/package.json
@@ -78,7 +78,8 @@
"mutation:state": "stryker run stryker/state.json",
"pack:check": "node scripts/pack-check.mjs",
"pack:prepare": "node scripts/prepare-packed-artifact.mjs",
- "test:packed-cli": "node scripts/packed-cli-conformance.mjs",
+ "test:packed-cli": "node scripts/packed-cli-conformance.mjs && node scripts/packed-hermes-conformance.mjs",
+ "test:packed-hermes": "node scripts/packed-hermes-conformance.mjs",
"rebuild:native": "node scripts/ensure-tree-sitter.mjs",
"ensure:embed-deps": "node scripts/ensure-embed-deps.mjs",
"ci": "npm run typecheck && npm run dup && npm run test && npm run audit:sql",
diff --git a/references/hermes/hooks-schema.ts b/references/hermes/hooks-schema.ts
new file mode 100644
index 00000000..9fbb89f9
--- /dev/null
+++ b/references/hermes/hooks-schema.ts
@@ -0,0 +1,95 @@
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+/**
+ * Independent Hermes Agent shell-hook/MCP config oracle.
+ *
+ * Grounded in Hermes Agent 0.19's current hook registry and shell-hook parser:
+ * `hermes_cli/plugins.py`, `agent/shell_hooks.py`, and the official Hooks docs.
+ * This module deliberately does not import Honeycomb connector constants.
+ */
+
+import { z } from "zod";
+
+export const HERMES_HOOK_EVENT_NAMES = [
+ "pre_tool_call",
+ "post_tool_call",
+ "transform_terminal_output",
+ "transform_tool_result",
+ "transform_llm_output",
+ "pre_llm_call",
+ "post_llm_call",
+ "pre_verify",
+ "pre_api_request",
+ "post_api_request",
+ "api_request_error",
+ "on_session_start",
+ "on_session_end",
+ "on_session_finalize",
+ "on_session_reset",
+ "subagent_start",
+ "subagent_stop",
+ "pre_gateway_dispatch",
+ "pre_approval_request",
+ "post_approval_response",
+ "kanban_task_claimed",
+ "kanban_task_completed",
+ "kanban_task_blocked",
+] as const;
+
+const HERMES_EVENTS = new Set(HERMES_HOOK_EVENT_NAMES);
+
+export function isHermesHookEvent(value: string): value is (typeof HERMES_HOOK_EVENT_NAMES)[number] {
+ return HERMES_EVENTS.has(value);
+}
+
+export const hermesHookEntry = z
+ .object({
+ command: z.string().min(1),
+ matcher: z.string().min(1).optional(),
+ timeout: z.number().int().min(1).max(300).optional(),
+ _honeycomb: z.boolean().optional(),
+ })
+ .passthrough();
+
+export const hermesMcpServer = z
+ .object({
+ command: z.string().min(1).optional(),
+ args: z.array(z.string()).optional(),
+ url: z.string().min(1).optional(),
+ enabled: z.boolean().optional(),
+ _honeycomb: z.boolean().optional(),
+ })
+ .passthrough()
+ .refine((server) => server.command !== undefined || server.url !== undefined, {
+ message: "Hermes MCP server requires command or url",
+ });
+
+export const hermesConfig = z
+ .object({
+ hooks: z.record(z.string(), z.array(hermesHookEntry)).optional(),
+ mcp_servers: z.record(z.string(), hermesMcpServer).optional(),
+ })
+ .passthrough()
+ .superRefine((config, ctx) => {
+ for (const event of Object.keys(config.hooks ?? {})) {
+ if (!isHermesHookEvent(event)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["hooks", event],
+ message: `"${event}" is not a Hermes hook event name`,
+ });
+ }
+ }
+ });
+
+export function assertHermesConfigConforms(value: unknown): void {
+ hermesConfig.parse(value);
+}
diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs
index c3cf84f4..792a2ccd 100644
--- a/scripts/pack-check.mjs
+++ b/scripts/pack-check.mjs
@@ -65,7 +65,7 @@ const hits = entries.filter((p) => FORBIDDEN.some((rx) => rx.test(p)));
if (hits.length) {
console.error("Refusing to publish — forbidden filenames in tarball:");
- for (const h of hits) console.error(" " + h);
+ for (const h of hits) console.error(` ${h}`);
process.exit(1);
}
@@ -80,6 +80,10 @@ const REQUIRED = [
/(^|\/)bundle\/cli\.js$/, // the `honeycomb` bin
/(^|\/)daemon\/index\.js$/, // the daemon entry the CLI spawns
/(^|\/)harnesses\/claude-code\/mcp\/bundle\/server\.js$/, // Claude Code plugin-internal MCP server path
+ /(^|\/)harnesses\/hermes\/bundle\/session-start\.mjs$/, // Hermes lifecycle hook alias
+ /(^|\/)harnesses\/hermes\/bundle\/capture\.mjs$/, // Hermes capture + recall hook alias
+ /(^|\/)harnesses\/hermes\/bundle\/session-end\.mjs$/, // Hermes finalization hook alias
+ /(^|\/)mcp\/bundle\/server\.js$/, // copied into $HERMES_HOME/honeycomb/mcp on connect
/(^|\/)assets\/styles\.css$/, // resolveAssetsDir() locator
/(^|\/)assets\/tokens\/base\.css$/, // the DS token CSS the dashboard serves
/(^|\/)assets\/logos\/honeycomb-memory-cluster\.svg$/, // the brand mark the header renders
@@ -88,7 +92,7 @@ const REQUIRED = [
const missing = REQUIRED.filter((rx) => !entries.some((p) => rx.test(p)));
if (missing.length) {
console.error("Refusing to publish — required runtime files missing from tarball:");
- for (const m of missing) console.error(" " + String(m));
+ for (const m of missing) console.error(` ${String(m)}`);
console.error(" (widen package.json's `files` allowlist — the install would be broken)");
process.exit(1);
}
diff --git a/scripts/packed-cli-conformance.mjs b/scripts/packed-cli-conformance.mjs
index 9f014c29..cc85e3ed 100644
--- a/scripts/packed-cli-conformance.mjs
+++ b/scripts/packed-cli-conformance.mjs
@@ -59,6 +59,7 @@ const priorCommands = [
"telemetry",
"update",
"uninstall",
+ "connect",
];
function npmCliPath() {
@@ -168,7 +169,7 @@ try {
const inventoryResult = runFixture("inventory", []);
assertResult(inventoryResult, 0, "packed inventory");
const inventory = JSON.parse(inventoryResult.stdout);
- if (inventory.length !== 42) throw new Error(`packed command inventory expected 42, got ${inventory.length}`);
+ if (inventory.length !== 43) throw new Error(`packed command inventory expected 43, got ${inventory.length}`);
for (const command of priorCommands) {
if (!inventory.includes(command)) throw new Error(`packed command inventory regressed ${command}`);
}
diff --git a/scripts/packed-hermes-conformance.mjs b/scripts/packed-hermes-conformance.mjs
new file mode 100644
index 00000000..87c35213
--- /dev/null
+++ b/scripts/packed-hermes-conformance.mjs
@@ -0,0 +1,235 @@
+#!/usr/bin/env node
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+import { execFileSync, spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
+
+const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
+const work = mkdtempSync(join(tmpdir(), "honeycomb-packed-hermes-"));
+const runtimeNode = process.env.HONEYCOMB_CONFORMANCE_NODE ?? process.execPath;
+if (!isAbsolute(runtimeNode) || !existsSync(runtimeNode)) {
+ throw new Error("HONEYCOMB_CONFORMANCE_NODE must name an existing absolute Node executable");
+}
+
+function npmCliPath() {
+ const fromEnv = process.env.npm_execpath;
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
+ const bin = dirname(process.execPath);
+ for (const candidate of [
+ join(bin, "node_modules", "npm", "bin", "npm-cli.js"),
+ resolve(bin, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
+ ]) {
+ if (existsSync(candidate)) return candidate;
+ }
+ throw new Error("could not locate npm-cli.js");
+}
+
+function assert(result, label) {
+ if (result.status !== 0)
+ throw new Error(`${label}: exit=${result.status}\nstdout=${result.stdout}\nstderr=${result.stderr}`);
+}
+
+let tarball;
+let ownsTarball = false;
+try {
+ const npmCli = npmCliPath();
+ const prebuilt = process.env.HONEYCOMB_PACKED_TARBALL;
+ if (prebuilt !== undefined) {
+ tarball = resolve(prebuilt);
+ const fromWorkspace = relative(resolve("."), tarball);
+ if (
+ isAbsolute(fromWorkspace) ||
+ fromWorkspace.startsWith("..") ||
+ basename(tarball) !== `legioncodeinc-honeycomb-${pkg.version}.tgz`
+ )
+ throw new Error("HONEYCOMB_PACKED_TARBALL must be the current workspace package tarball");
+ if (!existsSync(tarball)) throw new Error("HONEYCOMB_PACKED_TARBALL does not exist");
+ } else {
+ const packed = JSON.parse(execFileSync(process.execPath, [npmCli, "pack", "--json"], { encoding: "utf8" }));
+ tarball = resolve(packed[0].filename);
+ ownsTarball = true;
+ }
+ const install = join(work, "install");
+ execFileSync(process.execPath, [npmCli, "install", "--prefix", install, "--ignore-scripts", tarball], {
+ stdio: "ignore",
+ });
+
+ const packageRoot = join(install, "node_modules", "@legioncodeinc", "honeycomb");
+ const cli =
+ process.platform === "win32"
+ ? join(packageRoot, "bundle", "cli.js")
+ : join(install, "node_modules", ".bin", "honeycomb");
+ const hermesHome = join(work, "hermes");
+ const env = { ...process.env, HOME: join(work, "home"), HERMES_HOME: hermesHome, NO_COLOR: "1" };
+ const configPath = join(hermesHome, "config.yaml");
+ await mkdir(hermesHome, { recursive: true });
+ await writeFile(
+ configPath,
+ [
+ "# foreign comment",
+ "hooks:",
+ " post_tool_call:",
+ " - command: /opt/foreign-audit # preserve",
+ " timeout: 9",
+ "foreign_setting: keep-me",
+ "",
+ ].join("\n"),
+ );
+ const run = (args) => spawnSync(runtimeNode, [cli, ...args], { encoding: "utf8", env });
+
+ assert(run(["--help"]), "packed help");
+ assert(run(["connect", "hermes", "--no-color"]), "packed Hermes connect");
+ const installed = readFileSync(configPath, "utf8");
+ for (const token of [
+ "# foreign comment",
+ "/opt/foreign-audit # preserve",
+ "foreign_setting: keep-me",
+ "on_session_finalize:",
+ "mcp_servers:",
+ "_honeycomb: true",
+ ]) {
+ if (!installed.includes(token)) throw new Error(`packed Hermes install omitted ${token}`);
+ }
+ for (const file of [
+ "honeycomb/manifest.json",
+ "honeycomb/bundle/session-start.mjs",
+ "honeycomb/bundle/capture.mjs",
+ "honeycomb/bundle/session-end.mjs",
+ "honeycomb/mcp/server.mjs",
+ ]) {
+ if (!existsSync(join(hermesHome, file))) throw new Error(`packed Hermes install omitted ${file}`);
+ }
+
+ const sessionId = "packed-hermes-native-protocol";
+ const baseEvent = { session_id: sessionId, cwd: work, transcript_path: join(work, "transcript.jsonl") };
+ const hookCases = [
+ {
+ label: "session start",
+ file: "session-start.mjs",
+ args: [],
+ payload: { ...baseEvent, hook_event_name: "on_session_start", extra: { source: "packed-conformance" } },
+ },
+ {
+ label: "user capture",
+ file: "capture.mjs",
+ args: [],
+ payload: { ...baseEvent, hook_event_name: "pre_llm_call", extra: { user_message: "verify package" } },
+ },
+ {
+ label: "user recall",
+ file: "capture.mjs",
+ args: ["--honeycomb-recall"],
+ payload: { ...baseEvent, hook_event_name: "pre_llm_call", extra: { user_message: "verify package" } },
+ },
+ {
+ label: "tool capture",
+ file: "capture.mjs",
+ args: [],
+ payload: {
+ ...baseEvent,
+ hook_event_name: "post_tool_call",
+ tool_name: "terminal",
+ tool_input: { command: "pwd" },
+ extra: { result: "ok" },
+ },
+ },
+ {
+ label: "assistant capture",
+ file: "capture.mjs",
+ args: [],
+ payload: { ...baseEvent, hook_event_name: "post_llm_call", extra: { assistant_response: "verified" } },
+ },
+ {
+ label: "session finalize",
+ file: "session-end.mjs",
+ args: [],
+ payload: { ...baseEvent, hook_event_name: "on_session_finalize", extra: { reason: "complete" } },
+ },
+ ];
+ for (const hookCase of hookCases) {
+ const result = spawnSync(runtimeNode, [join(hermesHome, "honeycomb", "bundle", hookCase.file), ...hookCase.args], {
+ encoding: "utf8",
+ env,
+ input: JSON.stringify(hookCase.payload),
+ });
+ assert(result, `packed Hermes native ${hookCase.label}`);
+ }
+
+ assert(run(["uninstall", "hermes", "--yes", "--no-color"]), "packed Hermes uninstall");
+ const removed = readFileSync(configPath, "utf8");
+ for (const token of ["# foreign comment", "/opt/foreign-audit # preserve", "foreign_setting: keep-me"]) {
+ if (!removed.includes(token)) throw new Error(`packed Hermes uninstall removed foreign content: ${token}`);
+ }
+ if (removed.includes("_honeycomb: true") || existsSync(join(hermesHome, "honeycomb")))
+ throw new Error("packed Hermes uninstall left empty Honeycomb-owned state behind");
+
+ // A user may have placed their own file below the Honeycomb root. A second uninstall
+ // must preserve it rather than recursively removing the directory we originally created.
+ assert(run(["connect", "hermes", "--no-color"]), "packed Hermes reconnect");
+ const foreignOwnedRootFile = join(hermesHome, "honeycomb", "foreign.keep");
+ await writeFile(foreignOwnedRootFile, "foreign content");
+ assert(run(["uninstall", "hermes", "--yes", "--no-color"]), "packed Hermes foreign-root uninstall");
+ if (!existsSync(foreignOwnedRootFile))
+ throw new Error("packed Hermes uninstall removed a foreign Honeycomb-root file");
+
+ // Managed artifacts are removed only while their content still matches the atomic
+ // ownership manifest. User modifications must survive uninstall.
+ const modifiedHome = join(work, "modified-artifact-hermes");
+ await mkdir(modifiedHome, { recursive: true });
+ const modifiedEnv = { ...env, HERMES_HOME: modifiedHome };
+ const modifiedRun = (args) => spawnSync(runtimeNode, [cli, ...args], { encoding: "utf8", env: modifiedEnv });
+ assert(modifiedRun(["connect", "hermes", "--no-color"]), "packed Hermes modified-artifact connect");
+ const modifiedCapture = join(modifiedHome, "honeycomb", "bundle", "capture.mjs");
+ await writeFile(modifiedCapture, "// user modified\n");
+ assert(modifiedRun(["uninstall", "hermes", "--yes", "--no-color"]), "packed Hermes modified-artifact uninstall");
+ if (readFileSync(modifiedCapture, "utf8") !== "// user modified\n")
+ throw new Error("packed Hermes uninstall removed or changed a modified managed artifact");
+ if (!existsSync(join(modifiedHome, "honeycomb", "manifest.json")))
+ throw new Error("packed Hermes uninstall removed ownership evidence for a modified managed artifact");
+
+ // A pre-existing managed target with no Honeycomb ownership manifest must fail
+ // closed before config or any other artifact is changed.
+ const foreignArtifactHome = join(work, "foreign-artifact-hermes");
+ const foreignCapture = join(foreignArtifactHome, "honeycomb", "bundle", "capture.mjs");
+ await mkdir(dirname(foreignCapture), { recursive: true });
+ await writeFile(foreignCapture, "// foreign capture\n");
+ const foreignArtifact = spawnSync(runtimeNode, [cli, "connect", "hermes", "--no-color"], {
+ encoding: "utf8",
+ env: { ...env, HERMES_HOME: foreignArtifactHome },
+ });
+ if (foreignArtifact.status === 0) throw new Error("packed Hermes connect overwrote an unowned managed artifact");
+ if (readFileSync(foreignCapture, "utf8") !== "// foreign capture\n")
+ throw new Error("packed Hermes foreign-artifact refusal changed the existing artifact");
+ if (existsSync(join(foreignArtifactHome, "config.yaml")))
+ throw new Error("packed Hermes foreign-artifact refusal left a partial config behind");
+
+ // A conflicting foreign MCP key must fail closed before config or artifacts are written.
+ const conflictHome = join(work, "foreign-mcp-hermes");
+ await mkdir(conflictHome, { recursive: true });
+ const conflictConfigPath = join(conflictHome, "config.yaml");
+ const conflictConfig = "mcp_servers:\n honeycomb:\n command: /opt/acme/not-ours\n args: []\n";
+ await writeFile(conflictConfigPath, conflictConfig);
+ const conflict = spawnSync(runtimeNode, [cli, "connect", "hermes", "--no-color"], {
+ encoding: "utf8",
+ env: { ...env, HERMES_HOME: conflictHome },
+ });
+ if (conflict.status === 0) throw new Error("packed Hermes connect accepted a foreign honeycomb MCP server");
+ if (readFileSync(conflictConfigPath, "utf8") !== conflictConfig || existsSync(join(conflictHome, "honeycomb")))
+ throw new Error("packed Hermes foreign MCP refusal left partial state behind");
+
+ console.log(`packed-hermes-conformance OK - ${pkg.name}@${pkg.version} install/uninstall is isolated and reversible`);
+} finally {
+ if (ownsTarball && tarball) rmSync(tarball, { force: true });
+ rmSync(work, { recursive: true, force: true });
+}
diff --git a/src/cli/connector-runner.ts b/src/cli/connector-runner.ts
index 03623a48..69182fd1 100644
--- a/src/cli/connector-runner.ts
+++ b/src/cli/connector-runner.ts
@@ -5,7 +5,7 @@
* 020a's `runConnectorVerb` routes the CLI verb onto the 019a `connectorMain` through the
* {@link ConnectorRunner} seam but left the seam UNBOUND (the deferred-assembly stub). 021b binds it:
* this module builds the real {@link ConnectorRegistry} over a `node:fs`-backed {@link ConnectorFs}
- * and the claude-code + cursor connectors, then adapts `connectorMain`'s result into the
+ * and the supported connectors, then adapts `connectorMain`'s result into the
* `{ exitCode, harnesses }` shape `runConnectorVerb` reports. No install logic is re-implemented —
* every merge / foreign-preserve / idempotency / reversibility rule is the 019a engine's (D-4).
*
@@ -24,11 +24,12 @@ import {
CodexConnector,
type ConnectorFs,
type ConnectorRegistry,
- createClaudePluginRunner,
CursorConnector,
connectorMain,
+ createClaudePluginRunner,
createNodeConnectorFs,
type HarnessConnector,
+ HermesConnector,
} from "../connectors/index.js";
/** Resolve the package root so the connector finds the bundled `harnesses//bundle/` sources. */
@@ -43,14 +44,20 @@ function packageRoot(): string {
return resolve(here, "..");
}
+/** Normalize Hermes' optional profile home exactly as Hermes treats the environment value. */
+export function normalizeHermesHome(value: string | undefined): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
+}
+
/** The bundled hook-handler source dir for a harness slug (`harnesses//bundle`). */
function bundleSourceFor(slug: string): string {
return join(packageRoot(), "harnesses", slug, "bundle");
}
/**
- * The real connector registry (D-4): the two supported hook-protocol connectors (claude-code as the
- * reference, cursor as the sibling), each built over the supplied `node:fs` {@link ConnectorFs} and
+ * The real connector registry (D-4): each supported harness connector is built over the supplied
+ * `node:fs` {@link ConnectorFs} and
* pointed at the bundled handler sources + the user's home. A new harness is a SUBCLASS added here —
* never a fork of install logic (019a a-AC-5).
*/
@@ -59,6 +66,7 @@ export function createConnectorRegistry(home: string = homedir()): ConnectorRegi
// (not by writing top-level settings.json hooks). The runner shells to `claude`; `packageRoot()`
// resolves the dir holding `.claude-plugin/marketplace.json` (the same dir that holds `harnesses/`).
const claudePluginRunner = createClaudePluginRunner();
+ const hermesHome = normalizeHermesHome(process.env.HERMES_HOME);
const builders: Readonly HarnessConnector>> = {
"claude-code": (fs) =>
new ClaudeCodeConnector(fs, {
@@ -70,6 +78,14 @@ export function createConnectorRegistry(home: string = homedir()): ConnectorRegi
}),
codex: (fs) => new CodexConnector(fs, { home, bundleSource: bundleSourceFor("codex") }),
cursor: (fs) => new CursorConnector(fs, { home, bundleSource: bundleSourceFor("cursor") }),
+ hermes: (fs) =>
+ new HermesConnector(fs, {
+ home,
+ ...(hermesHome !== undefined ? { hermesHome } : {}),
+ bundleSource: bundleSourceFor("hermes"),
+ mcpServerPath: join(packageRoot(), "mcp", "bundle", "server.js"),
+ notify: (line) => console.log(line),
+ }),
};
return {
build(harness: string, fs: ConnectorFs): HarnessConnector | undefined {
diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts
index 82fc7ddb..2651006d 100644
--- a/src/commands/contracts.ts
+++ b/src/commands/contracts.ts
@@ -203,6 +203,7 @@ export const VERB_TABLE: readonly VerbSpec[] = Object.freeze([
},
// Setup & system — install/onboard, daemon lifecycle, dashboard, hooks, telemetry, update.
{ verb: "setup", cls: "local", group: "system", summary: "detect assistants, wire hooks, bring up the daemon" },
+ { verb: "connect", cls: "local", group: "system", summary: "wire one supported assistant (`connect `)" },
{
verb: "install",
cls: "local",
diff --git a/src/connectors/contracts.ts b/src/connectors/contracts.ts
index 6bc42df1..291647b9 100644
--- a/src/connectors/contracts.ts
+++ b/src/connectors/contracts.ts
@@ -70,12 +70,16 @@ export interface ConnectorFs {
readFile(path: string): Promise;
/** Write a UTF-8 file, creating parent dirs as needed. */
writeFile(path: string, contents: string): Promise;
+ /** Atomically replace a UTF-8 file from a same-directory temporary file. */
+ writeFileAtomic(path: string, contents: string): Promise;
/** Remove a file. No-op when absent (idempotent uninstall). */
removeFile(path: string): Promise;
/** True when a path exists (file, dir, or symlink). */
exists(path: string): Promise;
/** Ensure a directory exists (mkdir -p). */
ensureDir(path: string): Promise;
+ /** Remove a directory only when empty; never removes foreign contents. */
+ removeEmptyDir(path: string): Promise;
/** Create a symlink `linkPath` → `target`, never clobbering a foreign entry (FR-4 / a-AC-6). */
symlink(target: string, linkPath: string): Promise;
/** Read a symlink's target, or `undefined` when `linkPath` is not a symlink. */
@@ -121,6 +125,10 @@ export function createFakeFs(seed?: { files?: Record; links?: Re
files.set(path, contents);
writes.push(path);
},
+ async writeFileAtomic(path: string, contents: string): Promise {
+ files.set(path, contents);
+ writes.push(path);
+ },
async removeFile(path: string): Promise {
files.delete(path);
},
@@ -130,6 +138,9 @@ export function createFakeFs(seed?: { files?: Record; links?: Re
async ensureDir(): Promise {
/* in-memory: dirs are implicit */
},
+ async removeEmptyDir(): Promise {
+ /* in-memory: dirs are implicit */
+ },
async symlink(target: string, linkPath: string): Promise {
links.set(linkPath, target);
},
@@ -162,6 +173,12 @@ export interface HookHandlerEntry {
readonly async?: boolean;
}
+/** An additional install-time artifact copied alongside hook handlers (for example an MCP server bundle). */
+export interface InstallFileEntry {
+ readonly sourcePath: string;
+ readonly targetPath: string;
+}
+
/**
* One hook entry as it lands inside a harness config event block (FR-2 / FR-3). The Claude
* Code shape (`{ type, command, timeout, async }`) is the lingua franca every hooks-based
@@ -255,6 +272,10 @@ export abstract class HarnessConnector {
protected abstract skillLinkTargets(): readonly SkillLinkTarget[];
/** SEAM 4 — the native event-name map (FR-1). */
protected abstract eventNameMap(): Readonly>;
+ /** Optional non-hook files this connector installs and owns. */
+ protected additionalFiles(): readonly InstallFileEntry[] {
+ return [];
+ }
/**
* SEAM 3.5 (optional) — the config root that PROVES this harness is installed
@@ -356,12 +377,18 @@ export abstract class HarnessConnector {
await this.fs.writeFile(handler.handlerPath, body);
written.push(handler.handlerPath);
}
+ for (const file of this.additionalFiles()) {
+ const body = await this.fs.readFile(file.sourcePath);
+ if (body === undefined) continue;
+ await this.fs.ensureDir(dirOf(file.targetPath));
+ await this.fs.writeFile(file.targetPath, body);
+ written.push(file.targetPath);
+ }
// 2. Patch the config, foreign-preserving + idempotent.
const path = this.configPath();
- const config = parseConfig(await this.fs.readFile(path));
- const patched = this.patchConfig(config, handlers);
- const wroteConfig = await this.writeJsonIfChanged(path, serializeConfig(patched));
+ const patchedText = this.patchConfigText(await this.fs.readFile(path), handlers);
+ const wroteConfig = await this.writeJsonIfChanged(path, patchedText);
// 3. Symlink skills, preserving foreign entries (FR-4 / a-AC-6).
const skillLinks = await this.linkSkills();
@@ -379,18 +406,17 @@ export abstract class HarnessConnector {
*/
async uninstall(): Promise {
const path = this.configPath();
- const config = parseConfig(await this.fs.readFile(path));
- const stripped = this.stripHoneycomb(config);
+ const stripped = this.stripConfigText(await this.fs.readFile(path));
let wroteConfig = false;
- if (this.isConfigEmpty(stripped)) {
+ if (stripped.empty) {
// FR-6: an emptied config is cleanly UNLINKED, not left as `{}`.
if (await this.fs.exists(path)) {
await this.fs.removeFile(path);
wroteConfig = true;
}
} else {
- wroteConfig = await this.writeJsonIfChanged(path, serializeConfig(stripped));
+ wroteConfig = await this.writeJsonIfChanged(path, stripped.text);
}
// Remove the written handler files.
@@ -401,6 +427,12 @@ export abstract class HarnessConnector {
removedHandlers.push(handler.handlerPath);
}
}
+ for (const file of this.additionalFiles()) {
+ if (await this.fs.exists(file.targetPath)) {
+ await this.fs.removeFile(file.targetPath);
+ removedHandlers.push(file.targetPath);
+ }
+ }
// Unlink ONLY Honeycomb's skill symlinks (a foreign entry is never touched).
const removedLinks = await this.unlinkSkills();
@@ -410,6 +442,24 @@ export abstract class HarnessConnector {
// ── Internal patch/link helpers (shared by every connector) ───────────────
+ /**
+ * Parse, patch, and serialize this harness's config text. JSON is the shared
+ * default. A non-JSON harness (Hermes YAML) overrides this text seam while
+ * retaining the base install/uninstall filesystem mechanics.
+ */
+ protected patchConfigText(text: string | undefined, handlers: readonly HookHandlerEntry[]): string {
+ return serializeConfig(this.patchConfig(parseConfig(text), handlers));
+ }
+
+ /**
+ * Strip Honeycomb-owned entries and serialize the remaining config. The
+ * `empty` bit controls whether the base removes the config file entirely.
+ */
+ protected stripConfigText(text: string | undefined): { readonly empty: boolean; readonly text: string } {
+ const stripped = this.stripHoneycomb(parseConfig(text));
+ return { empty: this.isConfigEmpty(stripped), text: serializeConfig(stripped) };
+ }
+
/**
* Append fresh Honeycomb hook entries to the config, foreign-preserving (FR-2 / a-AC-1).
* For each native event the handlers register under: filter out any prior Honeycomb
diff --git a/src/connectors/hermes.ts b/src/connectors/hermes.ts
new file mode 100644
index 00000000..09eab777
--- /dev/null
+++ b/src/connectors/hermes.ts
@@ -0,0 +1,446 @@
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+/**
+ * Hermes Agent connector — wires Honeycomb through Hermes' native shell hooks
+ * and stdio MCP client configuration in `$HERMES_HOME/config.yaml`.
+ *
+ * Hermes uses YAML and a flat `hooks.[]` shape, so this connector
+ * overrides the base class's config-text seams while inheriting all handler,
+ * idempotency, detection, and skill-link filesystem mechanics.
+ */
+
+import { createHash } from "node:crypto";
+import { isAbsolute } from "node:path";
+
+import { isMap, isSeq, parseDocument, YAMLMap, YAMLSeq } from "yaml";
+
+import {
+ type ConfigHookEntry,
+ type ConnectorFs,
+ type ConnectorRunResult,
+ HarnessConnector,
+ HONEYCOMB_ENTRY_KEY,
+ HONEYCOMB_MARKER,
+ type HookHandlerEntry,
+ type InstallFileEntry,
+ type SkillLinkTarget,
+} from "./contracts.js";
+
+export interface HermesConnectorOptions {
+ readonly home: string;
+ /** Active profile root (`$HERMES_HOME`); defaults to `/.hermes`. */
+ readonly hermesHome?: string;
+ readonly pluginRoot?: string;
+ readonly bundleSource: string;
+ readonly mcpServerPath: string;
+ readonly nodeExecutable?: string;
+ readonly skillSources?: readonly string[];
+ readonly notify?: (line: string) => void;
+}
+
+/** Canonical Honeycomb-owned MCP server key in Hermes config. */
+export const HERMES_MCP_SERVER_NAME = "honeycomb" as const;
+
+/** Hermes shell-hook event names, grounded in Hermes' hooks reference. */
+const HERMES_EVENT_MAP: Readonly> = {
+ "session-start": "on_session_start",
+ user_message: "pre_llm_call",
+ user_prompt_recall: "pre_llm_call",
+ post_tool: "post_tool_call",
+ assistant_message: "post_llm_call",
+ "session-end": "on_session_finalize",
+};
+
+const HERMES_HANDLERS: ReadonlyArray<{
+ logical: string;
+ file: string;
+ timeout: number;
+ recall?: boolean;
+}> = [
+ { logical: "session-start", file: "session-start.mjs", timeout: 30 },
+ { logical: "user_prompt_recall", file: "capture.mjs", timeout: 10, recall: true },
+ { logical: "user_message", file: "capture.mjs", timeout: 10 },
+ { logical: "post_tool", file: "capture.mjs", timeout: 15 },
+ { logical: "assistant_message", file: "capture.mjs", timeout: 30 },
+ { logical: "session-end", file: "session-end.mjs", timeout: 60 },
+];
+
+type UnknownRecord = Record;
+
+function asRecord(value: unknown): UnknownRecord {
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as UnknownRecord) : {};
+}
+
+const OWNERSHIP_MANIFEST_VERSION = 1 as const;
+
+interface HermesOwnershipManifest {
+ readonly _honeycomb: true;
+ readonly version: typeof OWNERSHIP_MANIFEST_VERSION;
+ readonly files: Readonly>;
+}
+
+function sha256(contents: string): string {
+ return createHash("sha256").update(contents).digest("hex");
+}
+
+function parseOwnershipManifest(text: string, path: string): HermesOwnershipManifest {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ throw new Error(`HermesConnector: invalid ownership manifest at ${path}; refusing to modify artifacts`);
+ }
+ const record = asRecord(parsed);
+ const rawFiles = record.files;
+ const files = asRecord(rawFiles);
+ if (
+ record._honeycomb !== true ||
+ record.version !== OWNERSHIP_MANIFEST_VERSION ||
+ rawFiles === null ||
+ typeof rawFiles !== "object" ||
+ Array.isArray(rawFiles) ||
+ Object.values(files).some((value) => typeof value !== "string")
+ ) {
+ throw new Error(`HermesConnector: foreign ownership manifest at ${path}; refusing to modify artifacts`);
+ }
+ return {
+ _honeycomb: true,
+ version: OWNERSHIP_MANIFEST_VERSION,
+ files: files as Record,
+ };
+}
+
+function parseHermesDocument(text: string | undefined) {
+ const document = parseDocument(text ?? "{}\n");
+ if (document.errors.length > 0) {
+ throw new Error(`HermesConnector: invalid YAML in config.yaml: ${document.errors[0]?.message ?? "parse error"}`);
+ }
+ const root = document.toJS();
+ if (root !== null && (typeof root !== "object" || Array.isArray(root))) {
+ throw new Error("HermesConnector: config.yaml root must be a mapping");
+ }
+ return document;
+}
+
+function yamlText(document: ReturnType): string {
+ const text = document.toString();
+ return text.endsWith("\n") ? text : `${text}\n`;
+}
+
+function yamlMapAt(
+ document: ReturnType,
+ key: string,
+ create: boolean,
+): YAMLMap | undefined {
+ const current = document.get(key, true);
+ if (current === undefined) {
+ if (!create) return undefined;
+ const map = new YAMLMap(document.schema);
+ document.set(key, map);
+ return map;
+ }
+ if (!isMap(current)) throw new Error(`HermesConnector: ${key} must be a mapping`);
+ return current;
+}
+
+function yamlSeqAt(map: YAMLMap, key: string, create: boolean): YAMLSeq | undefined {
+ const current = map.get(key, true);
+ if (current === undefined) {
+ if (!create) return undefined;
+ const seq = new YAMLSeq(map.schema);
+ map.set(key, seq);
+ return seq;
+ }
+ if (!isSeq(current)) throw new Error(`HermesConnector: hooks.${key} must be a sequence`);
+ return current;
+}
+
+function isOwnedYamlEntry(value: unknown): boolean {
+ return isMap(value) && value.get(HONEYCOMB_ENTRY_KEY) === true;
+}
+
+/** Hermes Agent's native YAML connector. */
+export class HermesConnector extends HarnessConnector {
+ readonly harness = "hermes";
+
+ private readonly opts: {
+ readonly hermesHome: string;
+ readonly pluginRoot: string;
+ readonly bundleSource: string;
+ readonly mcpServerPath: string;
+ readonly nodeExecutable: string;
+ readonly skillSources: readonly string[];
+ readonly notify: ((line: string) => void) | undefined;
+ };
+
+ constructor(fs: ConnectorFs, opts: HermesConnectorOptions) {
+ super(fs);
+ const hermesHome = opts.hermesHome ?? `${opts.home}/.hermes`;
+ if (hermesHome.length === 0 || hermesHome.includes("\0") || !isAbsolute(hermesHome)) {
+ throw new Error("HermesConnector: HERMES_HOME must be a non-empty absolute path without NUL bytes");
+ }
+ const pluginRoot = opts.pluginRoot ?? `${hermesHome}/${HONEYCOMB_MARKER}`;
+ if (pluginRoot.length === 0 || pluginRoot.includes("\0") || !isAbsolute(pluginRoot)) {
+ throw new Error("HermesConnector: plugin root must be a non-empty absolute path without NUL bytes");
+ }
+ this.opts = {
+ hermesHome,
+ pluginRoot,
+ bundleSource: opts.bundleSource,
+ mcpServerPath: opts.mcpServerPath,
+ nodeExecutable: opts.nodeExecutable ?? process.execPath,
+ skillSources: opts.skillSources ?? [],
+ notify: opts.notify,
+ };
+ }
+
+ private ownershipManifestPath(): string {
+ return `${this.opts.pluginRoot}/manifest.json`;
+ }
+
+ private managedFiles(): readonly InstallFileEntry[] {
+ const files = new Map();
+ for (const handler of this.hookHandlers()) {
+ files.set(handler.handlerPath, { sourcePath: handler.sourcePath, targetPath: handler.handlerPath });
+ }
+ for (const file of this.additionalFiles()) files.set(file.targetPath, file);
+ return [...files.values()];
+ }
+
+ private async assertInstallTreeIsNotSymlinked(): Promise {
+ const paths = new Set([
+ this.opts.pluginRoot,
+ `${this.opts.pluginRoot}/bundle`,
+ `${this.opts.pluginRoot}/mcp`,
+ this.ownershipManifestPath(),
+ ...this.managedFiles().map((file) => file.targetPath),
+ ]);
+ for (const path of paths) {
+ if ((await this.fs.readlink(path)) !== undefined) {
+ throw new Error(`HermesConnector: refusing to use symlinked owned path: ${path}`);
+ }
+ }
+ }
+
+ async install(): Promise {
+ const handlers = this.hookHandlers();
+ const managedFiles = this.managedFiles();
+ const sourceBodies = new Map();
+ for (const file of managedFiles) {
+ const body = await this.fs.readFile(file.sourcePath);
+ if (body === undefined) throw new Error(`HermesConnector: required bundle is missing: ${file.sourcePath}`);
+ sourceBodies.set(file.targetPath, body);
+ }
+
+ // Parse and structurally patch in memory before any artifact write. Malformed or
+ // conflicting user config fails closed without leaving a partial installation.
+ const configPath = this.configPath();
+ const patchedConfig = this.patchConfigText(await this.fs.readFile(configPath), handlers);
+ await this.assertInstallTreeIsNotSymlinked();
+
+ const manifestPath = this.ownershipManifestPath();
+ const manifestText = await this.fs.readFile(manifestPath);
+ let priorManifest: HermesOwnershipManifest | undefined;
+ if (manifestText === undefined) {
+ if (await this.fs.exists(this.opts.pluginRoot)) {
+ throw new Error(
+ `HermesConnector: foreign plugin root exists without an ownership manifest: ${this.opts.pluginRoot}`,
+ );
+ }
+ for (const file of managedFiles) {
+ if (await this.fs.exists(file.targetPath)) {
+ throw new Error(`HermesConnector: refusing to overwrite foreign managed artifact: ${file.targetPath}`);
+ }
+ }
+ } else {
+ priorManifest = parseOwnershipManifest(manifestText, manifestPath);
+ for (const file of managedFiles) {
+ const current = await this.fs.readFile(file.targetPath);
+ if (current === undefined) continue;
+ const expected = priorManifest.files[file.targetPath];
+ if (expected === undefined || sha256(current) !== expected) {
+ throw new Error(`HermesConnector: owned artifact was modified; refusing to overwrite: ${file.targetPath}`);
+ }
+ }
+ }
+
+ const written: string[] = [];
+ for (const file of managedFiles) {
+ const body = sourceBodies.get(file.targetPath) as string;
+ if ((await this.fs.readFile(file.targetPath)) === body) continue;
+ await this.fs.ensureDir(file.targetPath.slice(0, file.targetPath.lastIndexOf("/")));
+ await this.fs.writeFile(file.targetPath, body);
+ written.push(file.targetPath);
+ }
+
+ const manifest: HermesOwnershipManifest = {
+ _honeycomb: true,
+ version: OWNERSHIP_MANIFEST_VERSION,
+ files: Object.fromEntries(
+ managedFiles.map((file) => [file.targetPath, sha256(sourceBodies.get(file.targetPath) as string)]),
+ ),
+ };
+ const nextManifestText = `${JSON.stringify(manifest, null, 2)}\n`;
+ if (manifestText !== nextManifestText) await this.fs.writeFileAtomic(manifestPath, nextManifestText);
+
+ const wroteConfig = await this.writeJsonIfChanged(configPath, patchedConfig);
+ const skillLinks = await this.linkSkills();
+ if (wroteConfig) {
+ this.opts.notify?.(
+ "Hermes hooks installed. Hermes requires first-use consent; approve the Honeycomb hook commands at the next interactive Hermes start. Non-interactive runs skip unapproved hooks.",
+ );
+ }
+ return { harness: this.harness, wroteConfig, handlers: written, skillLinks };
+ }
+
+ async uninstall(): Promise {
+ await this.assertInstallTreeIsNotSymlinked();
+ const configPath = this.configPath();
+ const stripped = this.stripConfigText(await this.fs.readFile(configPath));
+ const manifestPath = this.ownershipManifestPath();
+ const manifestText = await this.fs.readFile(manifestPath);
+ const manifest = manifestText === undefined ? undefined : parseOwnershipManifest(manifestText, manifestPath);
+ const managedFiles = this.managedFiles();
+ const removable: string[] = [];
+ let modifiedArtifact = false;
+ if (manifest !== undefined) {
+ for (const file of managedFiles) {
+ const current = await this.fs.readFile(file.targetPath);
+ if (current === undefined) continue;
+ const expected = manifest.files[file.targetPath];
+ if (expected !== undefined && sha256(current) === expected) removable.push(file.targetPath);
+ else modifiedArtifact = true;
+ }
+ }
+
+ let wroteConfig = false;
+ if (stripped.empty) {
+ if (await this.fs.exists(configPath)) {
+ await this.fs.removeFile(configPath);
+ wroteConfig = true;
+ }
+ } else {
+ wroteConfig = await this.writeJsonIfChanged(configPath, stripped.text);
+ }
+
+ for (const path of removable) await this.fs.removeFile(path);
+ if (manifest !== undefined && !modifiedArtifact) await this.fs.removeFile(manifestPath);
+ const removedLinks = await this.unlinkSkills();
+ for (const dir of [`${this.opts.pluginRoot}/bundle`, `${this.opts.pluginRoot}/mcp`, this.opts.pluginRoot]) {
+ await this.fs.removeEmptyDir(dir);
+ }
+ return { harness: this.harness, wroteConfig, handlers: removable, skillLinks: removedLinks };
+ }
+
+ protected configPath(): string {
+ return `${this.opts.hermesHome}/config.yaml`;
+ }
+
+ protected configRoot(): string {
+ return this.opts.hermesHome;
+ }
+
+ protected eventNameMap(): Readonly> {
+ return HERMES_EVENT_MAP;
+ }
+
+ protected hookHandlers(): readonly HookHandlerEntry[] {
+ const events = this.eventNameMap();
+ return HERMES_HANDLERS.map((handler) => {
+ const handlerPath = `${this.opts.pluginRoot}/bundle/${handler.file}`;
+ return {
+ event: events[handler.logical] as string,
+ handlerPath,
+ sourcePath: `${this.opts.bundleSource}/${handler.file}`,
+ command: `${JSON.stringify(this.opts.nodeExecutable)} ${JSON.stringify(handlerPath)}${handler.recall === true ? " --honeycomb-recall" : ""}`,
+ timeout: handler.timeout,
+ };
+ });
+ }
+
+ protected additionalFiles(): readonly InstallFileEntry[] {
+ return [
+ {
+ sourcePath: this.opts.mcpServerPath,
+ targetPath: `${this.opts.pluginRoot}/mcp/server.mjs`,
+ },
+ ];
+ }
+
+ protected skillLinkTargets(): readonly SkillLinkTarget[] {
+ return this.opts.skillSources.map((source) => ({ dir: `${this.opts.hermesHome}/skills`, source }));
+ }
+
+ protected toConfigEntry(handler: HookHandlerEntry): ConfigHookEntry {
+ return {
+ type: "command",
+ command: handler.command,
+ ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
+ [HONEYCOMB_ENTRY_KEY]: true,
+ };
+ }
+
+ protected patchConfigText(text: string | undefined, handlers: readonly HookHandlerEntry[]): string {
+ const document = parseHermesDocument(text);
+ const hooks = yamlMapAt(document, "hooks", true) as YAMLMap;
+
+ for (const event of Object.keys(asRecord(asRecord(document.toJS()).hooks))) {
+ const seq = yamlSeqAt(hooks, event, false);
+ if (seq !== undefined) seq.items = seq.items.filter((entry) => !isOwnedYamlEntry(entry));
+ }
+ for (const handler of handlers) {
+ const seq = yamlSeqAt(hooks, handler.event, true) as YAMLSeq;
+ seq.add(document.createNode(this.toConfigEntry(handler)));
+ }
+
+ const servers = yamlMapAt(document, "mcp_servers", true) as YAMLMap;
+ const current = servers.get(HERMES_MCP_SERVER_NAME, true);
+ if (current !== undefined && !isOwnedYamlEntry(current)) {
+ throw new Error(
+ `HermesConnector: foreign MCP server "${HERMES_MCP_SERVER_NAME}" already exists; refusing to overwrite it`,
+ );
+ }
+ servers.set(
+ HERMES_MCP_SERVER_NAME,
+ document.createNode({
+ command: this.opts.nodeExecutable,
+ args: [`${this.opts.pluginRoot}/mcp/server.mjs`],
+ enabled: true,
+ [HONEYCOMB_ENTRY_KEY]: true,
+ }),
+ );
+
+ return yamlText(document);
+ }
+
+ protected stripConfigText(text: string | undefined): { readonly empty: boolean; readonly text: string } {
+ const document = parseHermesDocument(text);
+ const hooks = yamlMapAt(document, "hooks", false);
+ if (hooks !== undefined) {
+ for (const event of Object.keys(asRecord(asRecord(document.toJS()).hooks))) {
+ const seq = yamlSeqAt(hooks, event, false);
+ if (seq === undefined) continue;
+ seq.items = seq.items.filter((entry) => !isOwnedYamlEntry(entry));
+ if (seq.items.length === 0) hooks.delete(event);
+ }
+ if (hooks.items.length === 0) document.delete("hooks");
+ }
+
+ const servers = yamlMapAt(document, "mcp_servers", false);
+ if (servers !== undefined) {
+ if (isOwnedYamlEntry(servers.get(HERMES_MCP_SERVER_NAME, true))) servers.delete(HERMES_MCP_SERVER_NAME);
+ if (servers.items.length === 0) document.delete("mcp_servers");
+ }
+
+ const remaining = asRecord(document.toJS());
+ return { empty: Object.keys(remaining).length === 0, text: yamlText(document) };
+ }
+}
diff --git a/src/connectors/index.ts b/src/connectors/index.ts
index cec00797..3e62cd5b 100644
--- a/src/connectors/index.ts
+++ b/src/connectors/index.ts
@@ -41,10 +41,12 @@ export {
HONEYCOMB_ENTRY_KEY,
HONEYCOMB_MARKER,
type HookHandlerEntry,
+ type InstallFileEntry,
notImplemented,
type SkillLinkTarget,
} from "./contracts.js";
export { CursorConnector, type CursorConnectorOptions } from "./cursor.js";
+export { HERMES_MCP_SERVER_NAME, HermesConnector, type HermesConnectorOptions } from "./hermes.js";
export { createNodeConnectorFs } from "./node-fs.js";
export {
createClaudePluginRunner,
diff --git a/src/connectors/node-fs.ts b/src/connectors/node-fs.ts
index 37a69a91..fd179070 100644
--- a/src/connectors/node-fs.ts
+++ b/src/connectors/node-fs.ts
@@ -14,7 +14,18 @@
* (Windows without the privilege), mirroring the connector's foreign-preserving posture.
*/
-import { mkdir, readFile, rm, stat, symlink as fsSymlink, readlink as fsReadlink, writeFile } from "node:fs/promises";
+import { randomUUID } from "node:crypto";
+import {
+ readlink as fsReadlink,
+ symlink as fsSymlink,
+ mkdir,
+ readFile,
+ rename,
+ rm,
+ rmdir,
+ stat,
+ writeFile,
+} from "node:fs/promises";
import { dirname } from "node:path";
import type { ConnectorFs } from "./contracts.js";
@@ -50,6 +61,16 @@ export function createNodeConnectorFs(): ConnectorFs {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, "utf8");
},
+ async writeFileAtomic(path: string, contents: string): Promise {
+ await mkdir(dirname(path), { recursive: true });
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
+ try {
+ await writeFile(temporary, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
+ await rename(temporary, path);
+ } finally {
+ await rm(temporary, { force: true });
+ }
+ },
async removeFile(path: string): Promise {
await rm(path, { force: true });
},
@@ -74,6 +95,15 @@ export function createNodeConnectorFs(): ConnectorFs {
async ensureDir(path: string): Promise {
await mkdir(path, { recursive: true });
},
+ async removeEmptyDir(path: string): Promise {
+ try {
+ await rmdir(path);
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException)?.code;
+ if (code === "ENOENT" || code === "ENOTEMPTY" || code === "EEXIST") return;
+ throw err;
+ }
+ },
async symlink(target: string, linkPath: string): Promise {
await mkdir(dirname(linkPath), { recursive: true });
try {
diff --git a/src/daemon/runtime/dashboard/harness-detect.ts b/src/daemon/runtime/dashboard/harness-detect.ts
index 76aed188..d80ad6c8 100644
--- a/src/daemon/runtime/dashboard/harness-detect.ts
+++ b/src/daemon/runtime/dashboard/harness-detect.ts
@@ -83,11 +83,9 @@ const HARNESS_MARKERS: readonly HarnessMarker[] = [
},
{
name: "hermes",
- // No honeycomb connector wires hermes yet (only claude-code/codex/cursor have connectors). The
- // marker is the honeycomb-NAMESPACED dir honeycomb would write, NEVER the dead hivemind-v1
- // `~/.hermes/config.yaml` / `~/.hermes/hivemind` leftovers — so an old ~/.hermes no longer
- // masquerades as installed. It reads absent until honeycomb actually wires hermes.
- paths: (h) => [join(h, ".hermes", "honeycomb")],
+ // The Hermes connector writes this concrete handler and removes it on uninstall. Checking the
+ // file—not the parent directory—prevents an empty leftover directory from reading as wired.
+ paths: (h) => [join(h, ".hermes", "honeycomb", "bundle", "capture.js")],
},
{
name: "pi",
diff --git a/src/daemon/runtime/dashboard/harness-registry.ts b/src/daemon/runtime/dashboard/harness-registry.ts
index b66b701a..96b0eaee 100644
--- a/src/daemon/runtime/dashboard/harness-registry.ts
+++ b/src/daemon/runtime/dashboard/harness-registry.ts
@@ -31,19 +31,19 @@
import {
CLAUDE_CODE_EVENT_MAP,
CODEX_EVENT_MAP,
+ type ContextChannel,
CURSOR_EVENT_MAP,
- HERMES_EVENT_MAP,
- OPENCLAW_EVENT_MAP,
- PI_EVENT_MAP,
createClaudeCodeShim,
createCodexShim,
createCursorShim,
createHermesShim,
createOpenClawShim,
createPiShim,
- type ContextChannel,
type HarnessShim,
+ HERMES_EVENT_MAP,
type HostCli,
+ OPENCLAW_EVENT_MAP,
+ PI_EVENT_MAP,
} from "../../../hooks/index.js";
import type { RuntimePath } from "../../../hooks/shared/contracts.js";
@@ -134,8 +134,8 @@ const HARNESS_SPECIFICS: Readonly>>
agents: { kind: "cursor-agent", binary: "cursor-agent", fallbackBin: "claude" },
workspaceRoots: true,
},
- // Hermes, pi, and OpenClaw remain in-progress in production (C-1 claim-reduction).
- hermes: {},
+ // pi and OpenClaw remain in-progress in production (C-1 claim-reduction).
+ hermes: { mcpRegistration: true },
openclaw: {},
pi: {},
// Codex surfaces only the brief user-visible login line (CODEX_LOGIN_LINE, codex/shim.ts).
@@ -144,7 +144,7 @@ const HARNESS_SPECIFICS: Readonly>>
"claude-code": {},
});
-const SUPPORTED_HARNESSES = new Set(["claude-code", "codex", "cursor"]);
+const SUPPORTED_HARNESSES = new Set(["claude-code", "codex", "cursor", "hermes"]);
/** Build one harness's capability descriptor from its shim statics + the grounded specifics. */
function buildCapabilities(shim: HarnessShim): HarnessCapabilities {
diff --git a/src/hooks/CONVENTIONS.md b/src/hooks/CONVENTIONS.md
index 6f60a55b..37e80d9b 100644
--- a/src/hooks/CONVENTIONS.md
+++ b/src/hooks/CONVENTIONS.md
@@ -72,7 +72,7 @@ carry the same logical content; only the routing + any `renderUserVisible` conde
| codex | user-visible | legacy | `codex exec --dangerously-bypass-approvals-and-sandbox` | detached setup, brief login line, Bash-only (FR-3 / c-AC-4) |
| cursor | model-only | plugin | `cursor-agent` → `claude` fallback | `additional_context` key, `workspace_roots` cwd, `Shell` intercept (FR-4) |
| openclaw | model-only | plugin | (native extension; no host-CLI exec) | batches at `agent_end`, new-slice only, no PreToolUse (FR-5 / c-AC-3) |
-| hermes | user-visible | legacy | `hermes --non-interactive` | `{ context }` output + MCP mention, terminal-only tools (FR-6) |
+| hermes | model-only | legacy | `hermes chat -Q -q` | native `{ context }` output; no pre-tool interception |
| pi | user-visible | plugin | `pi --print --provider --model ` | static `AGENTS.md` block, no PreToolUse; ext source at `harnesses/pi/extension-source/honeycomb.ts` (FR-7) |
OpenCode / Gemini CLI / Oh My Pi (FR-8) are DOCUMENTED FUTURE shims, not implemented this
@@ -85,8 +85,8 @@ config + a parametrized entry in the c-AC-1 equivalence table — no engine chan
## Context channel (FR-10 / c-AC-5 — PRD open question, recorded not resolved)
`model-only` lands in the model's context but is not shown to the user (Claude Code, Cursor,
-OpenClaw); `user-visible` is rendered in the transcript (Codex's login line, Hermes's
-`{ context }`, pi's `AGENTS.md`). The shim normalizes the SAME logical block to its channel
+Hermes, OpenClaw); `user-visible` is rendered in the transcript (Codex's login line and
+pi's `AGENTS.md`). The shim normalizes the SAME logical block to its channel
before handoff. Whether to NORMALIZE the difference or SURFACE it per harness is the PRD's
open question — left to Wave 2 / a later decision, not pre-decided here.
diff --git a/src/hooks/hermes/shim.ts b/src/hooks/hermes/shim.ts
index 7e137198..51fd0c87 100644
--- a/src/hooks/hermes/shim.ts
+++ b/src/hooks/hermes/shim.ts
@@ -1,24 +1,16 @@
/**
- * Hermes shim — PRD-019c Wave 2 (FR-6).
+ * Hermes shell-hook shim.
*
- * Hermes (skill + shell hooks + MCP) maps `on_session_start`, `on_user_message`,
- * `on_tool_use` (terminal ONLY), `on_session_end`. Divergences from the reference:
- * 1. `on_tool_use` is captured ONLY for terminal tools — a non-terminal tool is
- * dropped (returns no normalized data), per FR-6.
- * 2. context is USER-VISIBLE as a `{ context: "..." }` output carrying the FULL
- * block PLUS an MCP-tools mention (so the user knows the `honeycomb_*` tools are
- * available). {@link hermesRenderUserVisible} appends the mention;
- * {@link hermesContextOutput} wraps it in the `{ context }` shape Hermes emits.
- * 3. host CLI `hermes --non-interactive`; runtime path `legacy` (shell hooks).
- *
- * References gate (FR-11 / D-3 / c-AC-6): cited at `references/hermes/`.
- *
- * THIN OVERRIDE: shares the `createShim` engine; the daemon call lives in the 019b
- * core. No SQL, no DeepLake (D-2).
+ * Hermes exposes lifecycle hooks through `$HERMES_HOME/config.yaml`. Each hook receives
+ * a JSON envelope on stdin with event-specific fields under `extra`. Honeycomb runs
+ * the pre-LLM hook twice: capture mode records the user message; recall mode performs
+ * synchronous per-turn recall and returns Hermes' native `{ context }` response.
+ * References gate: `references/hermes/` mirrors the authoritative Hermes shell-hook protocol.
*/
import type { ContextChannel, HarnessShim, HostCli, RuntimePath } from "../contracts.js";
import {
+ asRecord,
assistantMessageData,
createShim,
nested,
@@ -30,77 +22,92 @@ import {
} from "../normalize.js";
import type { HookSessionMeta, LogicalEvent } from "../shared/contracts.js";
+/** Current Hermes shell-hook events used by the capture-mode adapter. */
export const HERMES_EVENT_MAP: Readonly> = {
on_session_start: "session-start",
- on_user_message: "user_message",
- on_tool_use: "tool_call",
- on_session_end: "session-end",
+ pre_llm_call: "user_message",
+ post_tool_call: "tool_call",
+ post_llm_call: "assistant_message",
+ on_session_finalize: "session-end",
};
-export const HERMES_CONTEXT_CHANNEL: ContextChannel = "user-visible";
-export const HERMES_RUNTIME_PATH: RuntimePath = "legacy";
-export const HERMES_HOST_CLI: HostCli = { bin: "hermes", args: ["--non-interactive"] };
-export const HERMES_REFERENCES = "references/hermes/" as const;
+/** Recall-mode map: only the synchronous pre-LLM injector is active. */
+export const HERMES_RECALL_EVENT_MAP: Readonly> = {
+ pre_llm_call: "user_prompt_recall",
+};
-/** The MCP-tools mention appended to Hermes's user-visible context block (FR-6). */
-export const HERMES_MCP_MENTION =
- "\n\n(Honeycomb MCP tools available: honeycomb_search, honeycomb_read, honeycomb_index.)" as const;
+export type HermesHookMode = "capture" | "recall";
+export const HERMES_RECALL_HOOK_ARG = "--honeycomb-recall" as const;
-/** True when a Hermes tool name is a terminal tool (the only tools captured, FR-6). */
-export function hermesIsTerminalTool(tool: string): boolean {
- return tool === "terminal" || tool === "Terminal" || tool === "shell" || tool === "Shell" || tool === "Bash";
+export function detectHermesHookMode(argv: readonly string[] = process.argv): HermesHookMode {
+ return argv.slice(2).includes(HERMES_RECALL_HOOK_ARG) ? "recall" : "capture";
}
-/**
- * Append the MCP-tools mention to the full context block for Hermes's user-visible
- * channel (FR-6 / c-AC-5). An empty block (signed-out / read-only) renders nothing —
- * the mention only rides a non-empty recall block.
- */
+export const HERMES_CONTEXT_CHANNEL: ContextChannel = "model-only";
+export const HERMES_RUNTIME_PATH: RuntimePath = "legacy";
+export const HERMES_HOST_CLI: HostCli = { bin: "hermes", args: ["chat", "-Q", "-q"] };
+export const HERMES_REFERENCES = "references/hermes/" as const;
+
export function hermesRenderUserVisible(block: string): string {
- return block.trim() === "" ? "" : block + HERMES_MCP_MENTION;
+ return block;
}
-/** Wrap Hermes's user-visible context text in the `{ context: "..." }` output shape (FR-6). */
export function hermesContextOutput(text: string): { readonly context: string } {
return { context: text };
}
/**
- * Lower a Hermes native payload into the CANONICAL normalized data (c-AC-1). The
- * terminal-ONLY tool filter lives here: a non-terminal `on_tool_use` returns
- * `undefined` (dropped, FR-6). Every other event reuses the canonical `*Data`
- * builders, so Hermes's normalized output matches the reference's.
+ * Hermes consumes injected context only from `pre_llm_call`. Session-start recall is
+ * still run for Honeycomb's setup/notification lifecycle, but its stdout is a benign
+ * no-op because Hermes ignores context on that event.
*/
+export function hermesRenderHookResponse(nativeEventName: string, block: string): unknown | undefined {
+ if (nativeEventName === "pre_llm_call") return hermesContextOutput(hermesRenderUserVisible(block));
+ if (nativeEventName === "on_session_start") return {};
+ return undefined;
+}
+
+function extra(raw: unknown): Record {
+ return asRecord(nested(raw, "extra"));
+}
+
+function extraString(raw: unknown, ...keys: readonly string[]): string {
+ return pickString(extra(raw), ...keys);
+}
+
+/** Lower the current Hermes shell-hook envelope into canonical Honeycomb data. */
export function hermesExtractData(raw: unknown, logical: LogicalEvent): unknown | undefined {
switch (logical) {
case "session-start":
- return sessionStartData(pickString(raw, "source") || "startup");
+ return sessionStartData(extraString(raw, "source") || "startup");
case "user_message":
- return userMessageData(pickString(raw, "message", "prompt", "text"));
+ case "user_prompt_recall":
+ return userMessageData(extraString(raw, "user_message", "message", "prompt", "text"));
+
case "tool_call": {
const tool = pickString(raw, "tool_name", "tool");
- if (!hermesIsTerminalTool(tool)) return undefined; // terminal-only (FR-6).
- return toolCallData(tool, nested(raw, "tool_input"), nested(raw, "tool_response"));
+ return toolCallData(tool, nested(raw, "tool_input"), nested(extra(raw), "result"));
}
- case "session-end":
- return sessionEndData(pickString(raw, "reason") || "on_session_end");
case "assistant_message":
- return assistantMessageData(pickString(raw, "text", "message"));
+ return assistantMessageData(extraString(raw, "assistant_response", "response", "text", "message"));
+ case "session-end":
+ return sessionEndData(extraString(raw, "reason") || "on_session_finalize");
default:
return undefined;
}
}
-/** Construct the Hermes shim (FR-6). Terminal-only tools + `{ context }` + MCP mention. */
-export function createHermesShim(): HarnessShim {
+export function createHermesShim(options: { readonly mode?: HermesHookMode } = {}): HarnessShim {
+ const mode = options.mode ?? detectHermesHookMode();
return createShim({
harness: "hermes",
runtimePath: HERMES_RUNTIME_PATH,
contextChannel: HERMES_CONTEXT_CHANNEL,
hostCli: HERMES_HOST_CLI,
references: HERMES_REFERENCES,
- eventMap: HERMES_EVENT_MAP,
+ eventMap: mode === "recall" ? HERMES_RECALL_EVENT_MAP : HERMES_EVENT_MAP,
renderUserVisible: hermesRenderUserVisible,
+ renderHookResponse: hermesRenderHookResponse,
extractData(raw: unknown, logical: LogicalEvent, _meta: HookSessionMeta): unknown | undefined {
void _meta;
return hermesExtractData(raw, logical);
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 3efb1899..ae03b318 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -9,50 +9,12 @@
* See `shared/CONVENTIONS.md` and `CONVENTIONS.md`.
*/
-export * from "./shared/index.js";
-
-// ── PRD-021c shared hook runtime (c-AC-5 / c-AC-6) ──────────────────────────────
-export {
- createHookRuntime,
- type HookEventOutcome,
- type HookRuntime,
- type HookRuntimeOptions,
- type NativeHookEvent,
-} from "./runtime.js";
-
// ── PRD-021c shared hook-binary stdin driver (c-AC-5 / c-AC-6) ──────────────────
export {
type BinaryIo,
- runHookBinary,
type RunHookBinaryOptions,
+ runHookBinary,
} from "./binary.js";
-
-export {
- type CliFallback,
- CONTEXT_CHANNELS,
- type ContextChannel,
- type ContextEnvelope,
- createFakeCliFallback,
- type HarnessShim,
- type HostCli,
- type NativeEvent,
-} from "./contracts.js";
-
-export {
- asRecord,
- assistantMessageData,
- createShim,
- extractTurnUsage,
- type NormalizedTurnUsage,
- pickString,
- preToolData,
- sessionEndData,
- sessionStartData,
- type ShimSpec,
- toolCallData,
- userMessageData,
-} from "./normalize.js";
-
// ── Claude Code REFERENCE shim (FR-1 / D-4 / c-AC-1) ────────────────────────────
export {
CLAUDE_CODE_CONTEXT_CHANNEL,
@@ -67,14 +29,12 @@ export {
detectClaudeUserPromptMode,
RECALL_HOOK_ARG,
} from "./claude-code/shim.js";
-
// ── PRD-060 ROI fix: the Claude Code transcript reader (per-turn usage + model) ──
export {
parseTurnUsage,
readTranscriptTurnUsage,
type TranscriptTurnUsage,
} from "./claude-code/transcript.js";
-
// ── Codex shim (FR-3 / c-AC-4) ──────────────────────────────────────────────────
export {
CODEX_CONTEXT_CHANNEL,
@@ -88,7 +48,16 @@ export {
codexSessionStartSetup,
createCodexShim,
} from "./codex/shim.js";
-
+export {
+ type CliFallback,
+ CONTEXT_CHANNELS,
+ type ContextChannel,
+ type ContextEnvelope,
+ createFakeCliFallback,
+ type HarnessShim,
+ type HostCli,
+ type NativeEvent,
+} from "./contracts.js";
// ── Cursor shim (FR-4) ──────────────────────────────────────────────────────────
export {
CURSOR_CONTEXT_CHANNEL,
@@ -100,7 +69,36 @@ export {
createCursorShim,
cursorDeriveMeta,
} from "./cursor/shim.js";
-
+// ── Hermes shim ────────────────────────────────────────────────────────────────
+export {
+ createHermesShim,
+ detectHermesHookMode,
+ HERMES_CONTEXT_CHANNEL,
+ HERMES_EVENT_MAP,
+ HERMES_HOST_CLI,
+ HERMES_RECALL_EVENT_MAP,
+ HERMES_RECALL_HOOK_ARG,
+ HERMES_REFERENCES,
+ HERMES_RUNTIME_PATH,
+ type HermesHookMode,
+ hermesContextOutput,
+ hermesRenderHookResponse,
+ hermesRenderUserVisible,
+} from "./hermes/shim.js";
+export {
+ asRecord,
+ assistantMessageData,
+ createShim,
+ extractTurnUsage,
+ type NormalizedTurnUsage,
+ pickString,
+ preToolData,
+ type ShimSpec,
+ sessionEndData,
+ sessionStartData,
+ toolCallData,
+ userMessageData,
+} from "./normalize.js";
// ── OpenClaw shim (FR-5 / c-AC-3 / c-AC-2) ──────────────────────────────────────
export {
createOpenClawShim,
@@ -110,26 +108,12 @@ export {
OPENCLAW_HOST_CLI,
OPENCLAW_REFERENCES,
OPENCLAW_RUNTIME_PATH,
+ type OpenClawMessage,
openclawDeriveMeta,
openclawExpandBatch,
openclawGoalKpiFallback,
- type OpenClawMessage,
openclawSliceSinceLastFlush,
} from "./openclaw/shim.js";
-
-// ── Hermes shim (FR-6) ──────────────────────────────────────────────────────────
-export {
- createHermesShim,
- HERMES_CONTEXT_CHANNEL,
- HERMES_EVENT_MAP,
- HERMES_HOST_CLI,
- HERMES_MCP_MENTION,
- HERMES_REFERENCES,
- HERMES_RUNTIME_PATH,
- hermesContextOutput,
- hermesRenderUserVisible,
-} from "./hermes/shim.js";
-
// ── pi shim (FR-7 / c-AC-2) ─────────────────────────────────────────────────────
export {
createPiShim,
@@ -142,3 +126,12 @@ export {
piGoalKpiFallback,
piResolveHostCli,
} from "./pi/shim.js";
+// ── PRD-021c shared hook runtime (c-AC-5 / c-AC-6) ──────────────────────────────
+export {
+ createHookRuntime,
+ type HookEventOutcome,
+ type HookRuntime,
+ type HookRuntimeOptions,
+ type NativeHookEvent,
+} from "./runtime.js";
+export * from "./shared/index.js";
diff --git a/tests/cli/connector-runner.test.ts b/tests/cli/connector-runner.test.ts
new file mode 100644
index 00000000..c8099e87
--- /dev/null
+++ b/tests/cli/connector-runner.test.ts
@@ -0,0 +1,22 @@
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+import { describe, expect, it } from "vitest";
+
+import { normalizeHermesHome } from "../../src/cli/connector-runner.js";
+
+describe("normalizeHermesHome", () => {
+ it("treats blank environment values as unset and trims explicit homes", () => {
+ expect(normalizeHermesHome(undefined)).toBeUndefined();
+ expect(normalizeHermesHome("")).toBeUndefined();
+ expect(normalizeHermesHome(" \t ")).toBeUndefined();
+ expect(normalizeHermesHome(" /profiles/work ")).toBe("/profiles/work");
+ });
+});
diff --git a/tests/cli/harness-reconcile.test.ts b/tests/cli/harness-reconcile.test.ts
index 0a011855..27f32591 100644
--- a/tests/cli/harness-reconcile.test.ts
+++ b/tests/cli/harness-reconcile.test.ts
@@ -298,6 +298,7 @@ describe("PRD-006b b-AC-5 - reuse, not fork", () => {
expect(wiring).toBeDefined();
expect(typeof wiring?.wire).toBe("function");
expect(typeof wiring?.unwire).toBe("function");
+ expect(buildConnectorWiring("hermes", home)).toBeDefined();
expect(buildConnectorWiring("not-a-real-harness", home)).toBeUndefined();
});
diff --git a/tests/commands/lifecycle-verbs.test.ts b/tests/commands/lifecycle-verbs.test.ts
index bec5a8f1..0414bf46 100644
--- a/tests/commands/lifecycle-verbs.test.ts
+++ b/tests/commands/lifecycle-verbs.test.ts
@@ -179,6 +179,25 @@ function recordingConnector(harnesses: string[] = ["cursor"]): ConnectorRunner &
};
}
+describe("top-level connect routing", () => {
+ it("routes `honeycomb connect hermes` through the connector engine", async () => {
+ const connector = recordingConnector(["hermes"]);
+ const deps: LocalDeps = {
+ daemon: createFakeDaemonClient(),
+ connector,
+ out: () => {},
+ };
+ const dispatcher = createDispatcher();
+
+ const result = await dispatcher.dispatch(dispatcher.parse(["connect", "hermes"]), deps);
+
+ expect(result.exitCode).toBe(0);
+ expect(connector.runs).toEqual(["connect hermes"]);
+ expect(lookupVerb("connect")?.cls).toBe("local");
+ expect(usageText()).toContain("connect");
+ });
+});
+
/** Recording UninstallLifecycleSteps: records order + returns scripted results (or throws). */
function recordingSteps(
script: {
diff --git a/tests/conformance/hermes-hooks-conformance.test.ts b/tests/conformance/hermes-hooks-conformance.test.ts
new file mode 100644
index 00000000..c3392fc0
--- /dev/null
+++ b/tests/conformance/hermes-hooks-conformance.test.ts
@@ -0,0 +1,77 @@
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+import { describe, expect, it } from "vitest";
+import { parse } from "yaml";
+
+import {
+ assertHermesConfigConforms,
+ HERMES_HOOK_EVENT_NAMES,
+ isHermesHookEvent,
+} from "../../references/hermes/hooks-schema.js";
+import { createFakeFs, HermesConnector } from "../../src/connectors/index.js";
+
+const HOME = "/home/dev";
+const BUNDLE = "/repo/harnesses/hermes/bundle";
+const MCP = "/repo/mcp/bundle/server.js";
+const CONFIG = `${HOME}/.hermes/config.yaml`;
+
+function fsWith(config?: string) {
+ return createFakeFs({
+ files: {
+ [`${HOME}/.hermes`]: "",
+ [`${BUNDLE}/session-start.mjs`]: "x",
+ [`${BUNDLE}/capture.mjs`]: "x",
+ [`${BUNDLE}/session-end.mjs`]: "x",
+ [MCP]: "x",
+ ...(config === undefined ? {} : { [CONFIG]: config }),
+ },
+ });
+}
+
+function build(fs = fsWith()) {
+ return new HermesConnector(fs, { home: HOME, bundleSource: BUNDLE, mcpServerPath: MCP });
+}
+
+describe("conformance: HermesConnector emits Hermes Agent 0.19 shell-hook YAML", () => {
+ it("uses only native Hermes events and a schema-valid flat hook/MCP shape", async () => {
+ const fs = fsWith();
+ await build(fs).install();
+ const config = parse(fs.files.get(CONFIG) as string);
+
+ expect(() => assertHermesConfigConforms(config)).not.toThrow();
+ const events = Object.keys((config as { hooks: Record }).hooks);
+ for (const event of events) expect(isHermesHookEvent(event)).toBe(true);
+ expect(events).toContain("on_session_finalize");
+ expect(events).not.toContain("on_session_end");
+ });
+
+ it("preserves a conformant foreign hook and remains conformant after uninstall", async () => {
+ const seeded = `hooks:\n pre_llm_call:\n - command: /opt/acme/guard.py\n timeout: 5\n`;
+ const fs = fsWith(seeded);
+ const c = build(fs);
+ await c.install();
+ expect(() => assertHermesConfigConforms(parse(fs.files.get(CONFIG) as string))).not.toThrow();
+
+ await c.uninstall();
+ const restored = parse(fs.files.get(CONFIG) as string) as { hooks: Record };
+ expect(() => assertHermesConfigConforms(restored)).not.toThrow();
+ expect(restored.hooks.pre_llm_call).toEqual([{ command: "/opt/acme/guard.py", timeout: 5 }]);
+ });
+
+ it("the oracle rejects typoed events, malformed entries, and out-of-range timeouts", () => {
+ expect(HERMES_HOOK_EVENT_NAMES.length).toBeGreaterThan(5);
+ expect(() => assertHermesConfigConforms({ hooks: { on_session_finalized: [{ command: "node x" }] } })).toThrow();
+ expect(() => assertHermesConfigConforms({ hooks: { pre_llm_call: [{ timeout: 5 }] } })).toThrow();
+ expect(() =>
+ assertHermesConfigConforms({ hooks: { pre_llm_call: [{ command: "node x", timeout: 999 }] } }),
+ ).toThrow();
+ });
+});
diff --git a/tests/connectors/hermes.test.ts b/tests/connectors/hermes.test.ts
new file mode 100644
index 00000000..c48d324a
--- /dev/null
+++ b/tests/connectors/hermes.test.ts
@@ -0,0 +1,270 @@
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+import { describe, expect, it } from "vitest";
+import { parse } from "yaml";
+
+import { createFakeFs, HarnessConnector, HermesConnector } from "../../src/connectors/index.js";
+
+const HOME = "/home/dev";
+const BUNDLE = "/repo/harnesses/hermes/bundle";
+const MCP = "/repo/mcp/bundle/server.js";
+const CONFIG = `${HOME}/.hermes/config.yaml`;
+const PLUGIN = `${HOME}/.hermes/honeycomb/bundle`;
+const INSTALLED_MCP = `${HOME}/.hermes/honeycomb/mcp/server.mjs`;
+const MANIFEST = `${HOME}/.hermes/honeycomb/manifest.json`;
+
+function seedFs(config?: string) {
+ return createFakeFs({
+ files: {
+ [`${HOME}/.hermes`]: "",
+ [`${BUNDLE}/session-start.mjs`]: "// session-start",
+ [`${BUNDLE}/capture.mjs`]: "// capture",
+ [`${BUNDLE}/pre-tool-use.js`]: "// pre-tool-use",
+ [`${BUNDLE}/session-end.mjs`]: "// session-end",
+ [MCP]: "// mcp server",
+ ...(config === undefined ? {} : { [CONFIG]: config }),
+ },
+ });
+}
+
+function connector(fs = seedFs(), skillSources: readonly string[] = []) {
+ return new HermesConnector(fs, {
+ home: HOME,
+ bundleSource: BUNDLE,
+ mcpServerPath: MCP,
+ skillSources,
+ });
+}
+
+interface HermesConfig {
+ hooks?: Record>;
+ mcp_servers?: Record;
+ model?: string;
+}
+
+describe("HermesConnector", () => {
+ it("extends the shared connector base and wires Hermes' native YAML hook + MCP contract", async () => {
+ const fs = seedFs();
+ const c = connector(fs);
+ expect(c).toBeInstanceOf(HarnessConnector);
+ expect(c.harness).toBe("hermes");
+
+ const result = await c.install();
+ expect(result.wroteConfig).toBe(true);
+ expect(result.handlers).toContain(`${PLUGIN}/session-start.mjs`);
+ expect(result.handlers).toContain(`${PLUGIN}/capture.mjs`);
+ expect(result.handlers).toContain(`${PLUGIN}/session-end.mjs`);
+ expect(result.handlers).toContain(INSTALLED_MCP);
+
+ const config = parse(fs.files.get(CONFIG) as string) as HermesConfig;
+ expect(Object.keys(config.hooks ?? {})).toEqual(
+ expect.arrayContaining([
+ "on_session_start",
+ "pre_llm_call",
+ "post_tool_call",
+ "post_llm_call",
+ "on_session_finalize",
+ ]),
+ );
+ expect(config.hooks?.pre_llm_call).toHaveLength(2);
+ expect(config.hooks?.pre_llm_call?.[0]?.command).toContain("--honeycomb-recall");
+ expect(config.hooks?.pre_llm_call?.[1]?.command).not.toContain("--honeycomb-recall");
+ expect(config.hooks?.pre_tool_call).toBeUndefined();
+ expect(config.hooks?.on_session_end).toBeUndefined();
+ expect(config.hooks?.post_tool_call?.every((h) => h.matcher === undefined)).toBe(true);
+ expect(config.mcp_servers?.honeycomb).toMatchObject({
+ command: process.execPath,
+ args: [INSTALLED_MCP],
+ enabled: true,
+ _honeycomb: true,
+ });
+ });
+
+ it("preserves foreign YAML, comments, hooks, and unrelated MCP servers", async () => {
+ const seeded = `# keep this operator comment\nmodel: anthropic/claude-sonnet-4\nhooks:\n # keep this event comment\n pre_llm_call:\n - command: /opt/acme/recall.py # keep this hook comment\n timeout: 7\nmcp_servers:\n acme:\n command: /opt/acme/server\n args: []\n`;
+ const fs = seedFs(seeded);
+
+ await connector(fs).install();
+ const text = fs.files.get(CONFIG) as string;
+ const config = parse(text) as HermesConfig & { mcp_servers?: HermesConfig["mcp_servers"] & { acme?: unknown } };
+
+ expect(text).toContain("# keep this operator comment");
+ expect(text).toContain("# keep this event comment");
+ expect(text).toContain("# keep this hook comment");
+ expect(config.model).toBe("anthropic/claude-sonnet-4");
+ expect(config.hooks?.pre_llm_call?.some((h) => h.command === "/opt/acme/recall.py")).toBe(true);
+ expect(config.mcp_servers?.acme).toEqual({ command: "/opt/acme/server", args: [] });
+ expect(config.mcp_servers?.honeycomb?._honeycomb).toBe(true);
+ });
+
+ it("refuses a foreign honeycomb-named MCP server before writing owned artifacts", async () => {
+ const seeded = `mcp_servers:\n honeycomb:\n command: /opt/acme/not-ours\n args: []\n`;
+ const fs = seedFs(seeded);
+ const writesBefore = fs.writes.length;
+
+ await expect(connector(fs).install()).rejects.toThrow(/foreign.*mcp|mcp.*foreign|already exists/iu);
+ expect(fs.writes).toHaveLength(writesBefore);
+ expect(fs.files.has(`${PLUGIN}/capture.mjs`)).toBe(false);
+ expect(fs.files.has(INSTALLED_MCP)).toBe(false);
+ expect(fs.files.get(CONFIG)).toBe(seeded);
+ });
+
+ it("is idempotent and uninstall removes only Honeycomb-owned YAML entries", async () => {
+ const seeded = `# preserved\nhooks:\n post_tool_call:\n - command: /opt/acme/audit.py\n matcher: terminal\nother_setting: true\n`;
+ const fs = seedFs(seeded);
+ const c = connector(fs, ["/repo/skills/org"]);
+
+ await c.install();
+ const first = fs.files.get(CONFIG) as string;
+ const writesAfterFirst = fs.writes.length;
+ const second = await c.install();
+ expect(second.wroteConfig).toBe(false);
+ expect(fs.writes).toHaveLength(writesAfterFirst);
+ expect(fs.files.get(CONFIG)).toBe(first);
+ expect(fs.files.has(MANIFEST)).toBe(true);
+ expect(fs.links.get(`${HOME}/.hermes/skills/org`)).toBe("/repo/skills/org");
+
+ await c.uninstall();
+ const text = fs.files.get(CONFIG) as string;
+ const config = parse(text) as HermesConfig & { other_setting?: boolean };
+ expect(text).toContain("# preserved");
+ expect(config.other_setting).toBe(true);
+ expect(config.hooks?.post_tool_call).toEqual([{ command: "/opt/acme/audit.py", matcher: "terminal" }]);
+ expect(config.hooks?.pre_llm_call).toBeUndefined();
+ expect(config.mcp_servers?.honeycomb).toBeUndefined();
+ expect(fs.files.has(`${PLUGIN}/capture.mjs`)).toBe(false);
+ expect(fs.files.has(INSTALLED_MCP)).toBe(false);
+ expect(fs.files.has(MANIFEST)).toBe(false);
+ expect(fs.links.has(`${HOME}/.hermes/skills/org`)).toBe(false);
+ });
+
+ it("refuses to overwrite a pre-existing managed artifact without an ownership manifest", async () => {
+ const fs = seedFs();
+ await fs.writeFile(`${PLUGIN}/capture.mjs`, "// foreign capture");
+ const writesBefore = fs.writes.length;
+
+ await expect(connector(fs).install()).rejects.toThrow(/ownership|foreign|refus/iu);
+ expect(fs.files.get(`${PLUGIN}/capture.mjs`)).toBe("// foreign capture");
+ expect(fs.files.has(MANIFEST)).toBe(false);
+ expect(fs.files.has(CONFIG)).toBe(false);
+ expect(fs.writes).toHaveLength(writesBefore);
+ });
+
+ it("refuses malformed ownership manifests", async () => {
+ const fs = seedFs();
+ await fs.writeFile(MANIFEST, '{"_honeycomb":true,"version":1,"files":[]}\n');
+
+ await expect(connector(fs).install()).rejects.toThrow(/ownership manifest/iu);
+ expect(fs.files.has(`${PLUGIN}/capture.mjs`)).toBe(false);
+ });
+
+ it("refuses symlink substitution of an owned artifact", async () => {
+ const fs = seedFs();
+ const c = connector(fs);
+ await c.install();
+ await fs.symlink("/tmp/foreign-target", `${PLUGIN}/capture.mjs`);
+
+ await expect(c.install()).rejects.toThrow(/symlink/iu);
+ await expect(c.uninstall()).rejects.toThrow(/symlink/iu);
+ expect(fs.links.get(`${PLUGIN}/capture.mjs`)).toBe("/tmp/foreign-target");
+ });
+
+ it("preserves a managed artifact modified after installation during uninstall", async () => {
+ const fs = seedFs();
+ const c = connector(fs);
+ await c.install();
+ await fs.writeFile(`${PLUGIN}/capture.mjs`, "// user modified");
+
+ await c.uninstall();
+ expect(fs.files.get(`${PLUGIN}/capture.mjs`)).toBe("// user modified");
+ expect(fs.files.has(MANIFEST)).toBe(true);
+ expect(fs.files.has(INSTALLED_MCP)).toBe(false);
+ });
+
+ it("rejects empty or relative explicit Hermes homes", () => {
+ for (const hermesHome of ["", "relative/profile", "bad\0profile"]) {
+ expect(
+ () =>
+ new HermesConnector(seedFs(), {
+ home: HOME,
+ hermesHome,
+ bundleSource: BUNDLE,
+ mcpServerPath: MCP,
+ }),
+ ).toThrow(/HERMES_HOME|absolute|empty|invalid/iu);
+ }
+ });
+
+ it("detects Hermes from ~/.hermes and links skills into ~/.hermes/skills", async () => {
+ const fs = seedFs();
+ const c = connector(fs, ["/repo/skills/org"]);
+ expect((await c.detectPlatforms()).map((p) => p.harness)).toEqual(["hermes"]);
+
+ const result = await c.install();
+ expect(result.skillLinks).toContain(`${HOME}/.hermes/skills/org`);
+ });
+
+ it("honors an explicit profile-aware Hermes home", async () => {
+ const hermesHome = "/profiles/work";
+ const fs = seedFs();
+ await fs.writeFile(hermesHome, "");
+ const c = new HermesConnector(fs, {
+ home: HOME,
+ hermesHome,
+ bundleSource: BUNDLE,
+ mcpServerPath: MCP,
+ });
+
+ expect((await c.detectPlatforms()).map((p) => p.configRoot)).toEqual([hermesHome]);
+ await c.install();
+ expect(fs.files.has(`${hermesHome}/config.yaml`)).toBe(true);
+ expect(fs.files.has(`${hermesHome}/honeycomb/bundle/capture.mjs`)).toBe(true);
+ });
+
+ it("surfaces Hermes' first-use consent requirement without auto-approving hooks", async () => {
+ const fs = seedFs();
+ const notices: string[] = [];
+ const c = new HermesConnector(fs, {
+ home: HOME,
+ bundleSource: BUNDLE,
+ mcpServerPath: MCP,
+ notify: (line) => notices.push(line),
+ });
+
+ await c.install();
+ expect(notices.join(" ")).toContain("first-use consent");
+ expect(fs.files.has(`${HOME}/.hermes/shell-hooks-allowlist.json`)).toBe(false);
+ });
+
+ it("fails before writing any artifact when the user's YAML is malformed", async () => {
+ const fs = seedFs("hooks: [unterminated\n");
+ await expect(connector(fs).install()).rejects.toThrow(/invalid YAML/);
+ expect(fs.writes).toEqual([]);
+ expect(fs.files.has(`${PLUGIN}/capture.mjs`)).toBe(false);
+ expect(fs.files.has(INSTALLED_MCP)).toBe(false);
+ });
+
+ it("pins hook and MCP launches to the installing Node executable", async () => {
+ const fs = seedFs();
+ const nodeExecutable = "/Applications/Node Runtime/bin/node";
+ await new HermesConnector(fs, {
+ home: HOME,
+ bundleSource: BUNDLE,
+ mcpServerPath: MCP,
+ nodeExecutable,
+ }).install();
+ const config = parse(fs.files.get(CONFIG) as string) as HermesConfig;
+ expect(config.hooks?.pre_llm_call?.every((entry) => entry.command.startsWith(JSON.stringify(nodeExecutable)))).toBe(
+ true,
+ );
+ expect(config.mcp_servers?.honeycomb?.command).toBe(nodeExecutable);
+ });
+});
diff --git a/tests/daemon/runtime/dashboard/harness-api.test.ts b/tests/daemon/runtime/dashboard/harness-api.test.ts
index b7895b73..83f761a3 100644
--- a/tests/daemon/runtime/dashboard/harness-api.test.ts
+++ b/tests/daemon/runtime/dashboard/harness-api.test.ts
@@ -370,10 +370,10 @@ describe("PRD-039c c-AC-4 (server-folded descriptor): Cursor carries `agents`, C
expect(cursor?.capabilities.supportStatus).toBe("supported");
expect(claude?.capabilities.supportStatus).toBe("supported");
expect(harnesses.find((h) => h.name === "codex")?.capabilities.supportStatus).toBe("supported");
- expect(hermes?.capabilities.supportStatus).toBe("in-progress");
+ expect(hermes?.capabilities.supportStatus).toBe("supported");
expect(openclaw?.capabilities.supportStatus).toBe("in-progress");
expect(harnesses.find((h) => h.name === "pi")?.capabilities.supportStatus).toBe("in-progress");
- expect(hermes?.capabilities.mcpRegistration).toBeUndefined();
+ expect(hermes?.capabilities.mcpRegistration).toBe(true);
expect(openclaw?.capabilities.contractedTools).toBeUndefined();
});
diff --git a/tests/daemon/runtime/dashboard/harness-detect.test.ts b/tests/daemon/runtime/dashboard/harness-detect.test.ts
index 834ab1f7..e9213ebb 100644
--- a/tests/daemon/runtime/dashboard/harness-detect.test.ts
+++ b/tests/daemon/runtime/dashboard/harness-detect.test.ts
@@ -77,8 +77,8 @@ describe("PRD-039a a-AC-3: detectInstalledHarnesses — markers present → in t
expect(detectInstalledHarnesses(home, home).has("codex")).toBe(true);
});
- it("hermes honeycomb marker (~/.hermes/honeycomb) → hermes in the set", () => {
- touchDir(".hermes", "honeycomb");
+ it("Hermes installed hook bundle marker → hermes in the set", () => {
+ touchFile(".hermes", "honeycomb", "bundle", "capture.js");
expect(detectInstalledHarnesses(home, home).has("hermes")).toBe(true);
});
@@ -134,7 +134,7 @@ describe("PRD-039a a-AC-3: detectInstalledHarnesses — a present/absent MIX is
touchFile(".claude", "settings.json");
touchFile(".cursor", "hooks.json");
touchFile(".codex", "hooks.json");
- touchDir(".hermes", "honeycomb");
+ touchFile(".hermes", "honeycomb", "bundle", "capture.js");
touchDir(".pi", "honeycomb");
touchDir(".openclaw", "honeycomb");
const set = detectInstalledHarnesses(home, home);
diff --git a/tests/daemon/runtime/dashboard/harness-installed-wiring.test.ts b/tests/daemon/runtime/dashboard/harness-installed-wiring.test.ts
index 13a327b5..e003948b 100644
--- a/tests/daemon/runtime/dashboard/harness-installed-wiring.test.ts
+++ b/tests/daemon/runtime/dashboard/harness-installed-wiring.test.ts
@@ -165,7 +165,7 @@ describe("PRD-039a a-AC-3 (production wiring): live `installed` reflects real on
touchFile(".claude", "settings.json");
touchFile(".cursor", "hooks.json");
touchFile(".codex", "hooks.json");
- touchDir(".hermes", "honeycomb");
+ touchFile(".hermes", "honeycomb", "bundle", "capture.js");
touchDir(".pi", "honeycomb");
touchDir(".openclaw", "honeycomb");
const harnesses = await liveHarnesses();
diff --git a/tests/hooks/claude-code/shim.test.ts b/tests/hooks/claude-code/shim.test.ts
index e8b2fffd..b46a8d1f 100644
--- a/tests/hooks/claude-code/shim.test.ts
+++ b/tests/hooks/claude-code/shim.test.ts
@@ -16,11 +16,16 @@
*/
import { describe, expect, it } from "vitest";
-
import {
+ createClaudeCodeShim,
+ createCodexShim,
+ createCursorShim,
createFakeContextRenderer,
createFakeCredentialReader,
createFakeDaemonHookClient,
+ createHermesShim,
+ createOpenClawShim,
+ createPiShim,
type HarnessShim,
type HookCoreDeps,
type HookInput,
@@ -28,14 +33,6 @@ import {
type LogicalEvent,
type NativeEvent,
} from "../../../src/hooks/index.js";
-import {
- createClaudeCodeShim,
- createCodexShim,
- createCursorShim,
- createHermesShim,
- createOpenClawShim,
- createPiShim,
-} from "../../../src/hooks/index.js";
import { runCapture } from "../../../src/hooks/shared/capture.js";
const META: HookSessionMeta = { sessionId: "sess-1", path: "conv-1" };
@@ -78,7 +75,10 @@ const fixtures: readonly NativeFixture[] = [
shim: reference,
events: {
user_message: { name: "UserPromptSubmit", payload: { prompt: userText } },
- tool_call: { name: "PostToolUse", payload: { tool_name: "Bash", tool_input: { command: "ls" }, tool_response: "ok" } },
+ tool_call: {
+ name: "PostToolUse",
+ payload: { tool_name: "Bash", tool_input: { command: "ls" }, tool_response: "ok" },
+ },
assistant_message: { name: "Stop", payload: { text: asstText } },
},
},
@@ -86,7 +86,10 @@ const fixtures: readonly NativeFixture[] = [
shim: createCodexShim(),
events: {
user_message: { name: "UserPromptSubmit", payload: { prompt: userText } },
- tool_call: { name: "PostToolUse", payload: { tool_name: "Bash", tool_input: { command: "ls" }, tool_response: "ok" } },
+ tool_call: {
+ name: "PostToolUse",
+ payload: { tool_name: "Bash", tool_input: { command: "ls" }, tool_response: "ok" },
+ },
assistant_message: { name: "Stop", payload: { text: asstText } },
},
},
@@ -94,17 +97,22 @@ const fixtures: readonly NativeFixture[] = [
shim: createCursorShim(),
events: {
user_message: { name: "beforeSubmitPrompt", payload: { prompt: userText } },
- tool_call: { name: "postToolUse", payload: { tool_name: "Bash", tool_input: { command: "ls" }, tool_response: "ok" } },
+ tool_call: {
+ name: "postToolUse",
+ payload: { tool_name: "Bash", tool_input: { command: "ls" }, tool_response: "ok" },
+ },
assistant_message: { name: "afterAgentResponse", payload: { text: asstText } },
},
},
{
shim: createHermesShim(),
events: {
- user_message: { name: "on_user_message", payload: { message: userText } },
- // Hermes captures terminal tools only (FR-6); a terminal tool_use is equivalent
- // to the reference's tool_call for the same tool.
- tool_call: { name: "on_tool_use", payload: { tool_name: "Bash", tool_input: { command: "ls" }, tool_response: "ok" } },
+ user_message: { name: "pre_llm_call", payload: { extra: { user_message: userText } } },
+ tool_call: {
+ name: "post_tool_call",
+ payload: { tool_name: "Bash", tool_input: { command: "ls" }, extra: { result: "ok" } },
+ },
+ assistant_message: { name: "post_llm_call", payload: { extra: { assistant_response: asstText } } },
},
},
];
@@ -114,7 +122,7 @@ describe("PRD-019c c-AC-1: harness equivalence to the Claude Code reference", ()
expect(reference.mapEvent("UserPromptSubmit")).toBe("user_message");
expect(createCodexShim().mapEvent("UserPromptSubmit")).toBe("user_message");
expect(createCursorShim().mapEvent("beforeSubmitPrompt")).toBe("user_message");
- expect(createHermesShim().mapEvent("on_user_message")).toBe("user_message");
+ expect(createHermesShim().mapEvent("pre_llm_call")).toBe("user_message");
expect(createOpenClawShim().mapEvent("agent_end")).toBe("session-end");
expect(createPiShim().mapEvent("session_shutdown")).toBe("session-end");
// A non-lifecycle name maps to undefined (dropped) on every shim.
@@ -123,10 +131,7 @@ describe("PRD-019c c-AC-1: harness equivalence to the Claude Code reference", ()
});
it("c-AC-1 a user_message normalizes to the SAME daemon body across harnesses", async () => {
- const refEvent = reference.normalize(
- { name: "UserPromptSubmit", payload: { prompt: userText } },
- META,
- );
+ const refEvent = reference.normalize({ name: "UserPromptSubmit", payload: { prompt: userText } }, META);
expect(refEvent).toBeDefined();
const refBody = await captureBody(refEvent as HookInput);
// The reference body's event payload is the canonical `{ kind:"user_message", text }`.
@@ -172,15 +177,20 @@ describe("PRD-019c c-AC-1: harness equivalence to the Claude Code reference", ()
expect(daemon.calls[0].runtimePath).toBe("legacy"); // Claude Code hook script.
const { deps: d2, daemon: dm2 } = deps();
- const cur = createCursorShim().normalize({ name: "beforeSubmitPrompt", payload: { prompt: userText } }, META) as HookInput;
+ const cur = createCursorShim().normalize(
+ { name: "beforeSubmitPrompt", payload: { prompt: userText } },
+ META,
+ ) as HookInput;
await runCapture(cur, d2, {});
expect(dm2.calls[0].runtimePath).toBe("plugin"); // Cursor runtime extension.
});
it("c-AC-1 a dropped (non-lifecycle) native event normalizes to undefined", () => {
expect(reference.normalize({ name: "NotAnEvent", payload: {} }, META)).toBeUndefined();
- // Hermes drops a non-terminal tool_use (terminal-only, FR-6) → no capture.
- expect(createHermesShim().normalize({ name: "on_tool_use", payload: { tool_name: "Browser" } }, META)).toBeUndefined();
+ // Hermes does not claim unsupported pre-tool interception semantics.
+ expect(
+ createHermesShim().normalize({ name: "pre_tool_call", payload: { tool_name: "Browser" } }, META),
+ ).toBeUndefined();
// Codex drops a non-Bash PreToolUse (Bash-only, FR-3).
expect(createCodexShim().normalize({ name: "PreToolUse", payload: { tool_name: "Read" } }, META)).toBeUndefined();
});
@@ -202,9 +212,9 @@ describe("PRD-019c c-AC-1: harness equivalence to the Claude Code reference", ()
META,
);
expect(input).toBeDefined();
- expect(input!.event).toBe("pre-tool-use");
+ expect(input?.event).toBe("pre-tool-use");
// The EXACT canonical pre_tool_use shape: kind + tool + all three nested fields present.
- expect(input!.data).toEqual({
+ expect(input?.data).toEqual({
kind: "pre_tool_use",
tool: "Bash",
command: "ls -la",
@@ -221,8 +231,8 @@ describe("PRD-019c c-AC-1: harness equivalence to the Claude Code reference", ()
META,
) as HookInput;
expect(input.data).toEqual({ kind: "pre_tool_use", tool: "Bash", command: "echo hi" });
- expect(Object.prototype.hasOwnProperty.call(input.data, "path")).toBe(false);
- expect(Object.prototype.hasOwnProperty.call(input.data, "query")).toBe(false);
+ expect(Object.hasOwn(input.data, "path")).toBe(false);
+ expect(Object.hasOwn(input.data, "query")).toBe(false);
});
it("c-AC-1 the reference pre-tool-use extractor reads the `path`/`query` fallback keys", () => {
@@ -251,7 +261,7 @@ describe("PRD-019c c-AC-1: harness equivalence to the Claude Code reference", ()
META,
) as HookInput;
expect(input.data).toEqual({ kind: "pre_tool_use", tool: "Read", path: "/only/path.ts" });
- expect(Object.prototype.hasOwnProperty.call(input.data, "command")).toBe(false);
+ expect(Object.hasOwn(input.data, "command")).toBe(false);
});
it("c-AC-1 the reference session-start extractor lowers `source` (defaulting to startup)", () => {
@@ -281,13 +291,13 @@ describe("PRD-019c c-AC-1: harness equivalence to the Claude Code reference", ()
expect(withEmb.messageEmbedding).toEqual([0.1, 0.2, 0.3]);
const noEmb = reference.normalize({ name: "UserPromptSubmit", payload: { prompt: userText } }, META) as HookInput;
- expect(Object.prototype.hasOwnProperty.call(noEmb, "messageEmbedding")).toBe(false);
+ expect(Object.hasOwn(noEmb, "messageEmbedding")).toBe(false);
// A non-numeric array is rejected (the `every(typeof === number)` guard) → no key added.
const badEmb = reference.normalize(
{ name: "UserPromptSubmit", payload: { prompt: userText, messageEmbedding: [1, "x", 3] } },
META,
) as HookInput;
- expect(Object.prototype.hasOwnProperty.call(badEmb, "messageEmbedding")).toBe(false);
+ expect(Object.hasOwn(badEmb, "messageEmbedding")).toBe(false);
});
});
diff --git a/tests/hooks/harness-identity-stamp.test.ts b/tests/hooks/harness-identity-stamp.test.ts
index 92a600f3..26140b38 100644
--- a/tests/hooks/harness-identity-stamp.test.ts
+++ b/tests/hooks/harness-identity-stamp.test.ts
@@ -28,7 +28,6 @@
import { describe, expect, it } from "vitest";
import type { HarnessShim, NativeEvent } from "../../src/hooks/contracts.js";
-import type { HookInput, HookSessionMeta } from "../../src/hooks/shared/contracts.js";
import {
createClaudeCodeShim,
createCodexShim,
@@ -36,10 +35,11 @@ import {
createHermesShim,
createOpenClawShim,
createPiShim,
- openclawExpandBatch,
OPENCLAW_HARNESS,
type OpenClawMessage,
+ openclawExpandBatch,
} from "../../src/hooks/index.js";
+import type { HookInput, HookSessionMeta } from "../../src/hooks/shared/contracts.js";
/** The canonical six tokens the Harnesses page GROUPs BY — the contract the stamp must hit. */
const THE_SIX = ["claude-code", "codex", "cursor", "hermes", "pi", "openclaw"] as const;
@@ -58,7 +58,7 @@ const REPRESENTATIVE_EVENT: Readonly> = {
"claude-code": { name: "UserPromptSubmit", payload: { prompt: "find the bug" } },
codex: { name: "UserPromptSubmit", payload: { prompt: "find the bug" } },
cursor: { name: "beforeSubmitPrompt", payload: { prompt: "find the bug" } },
- hermes: { name: "on_user_message", payload: { prompt: "find the bug", text: "find the bug" } },
+ hermes: { name: "pre_llm_call", payload: { extra: { user_message: "find the bug" } } },
pi: { name: "agent_end", payload: { reason: "session_shutdown" } },
};
@@ -76,18 +76,17 @@ function hookShims(): Readonly> {
describe("harness identity → sessions.agent: every shim stamps its OWN canonical token", () => {
const shims = hookShims();
- it.each(Object.keys(REPRESENTATIVE_EVENT))(
- "%s normalizes a captured turn with meta.agent = its canonical token",
- (harness) => {
- const shim = shims[harness];
- expect(shim.harness, "the shim's declared id is the canonical token").toBe(harness);
- const input: HookInput | undefined = shim.normalize(REPRESENTATIVE_EVENT[harness], baseMeta());
- expect(input, `${harness} maps its representative event → a capture event`).toBeDefined();
- // THE FIX: the normalized capture metadata carries the harness's OWN canonical token,
- // not the empty string — so `buildCaptureBody` forwards it into `sessions.agent`.
- expect(input?.meta.agent).toBe(harness);
- },
- );
+ it.each(
+ Object.keys(REPRESENTATIVE_EVENT),
+ )("%s normalizes a captured turn with meta.agent = its canonical token", (harness) => {
+ const shim = shims[harness];
+ expect(shim.harness, "the shim's declared id is the canonical token").toBe(harness);
+ const input: HookInput | undefined = shim.normalize(REPRESENTATIVE_EVENT[harness], baseMeta());
+ expect(input, `${harness} maps its representative event → a capture event`).toBeDefined();
+ // THE FIX: the normalized capture metadata carries the harness's OWN canonical token,
+ // not the empty string — so `buildCaptureBody` forwards it into `sessions.agent`.
+ expect(input?.meta.agent).toBe(harness);
+ });
it("OpenClaw's batch path stamps agent = 'openclaw' on every expanded message", () => {
const messages: readonly OpenClawMessage[] = [
diff --git a/tests/hooks/hermes/binary-e2e.test.ts b/tests/hooks/hermes/binary-e2e.test.ts
new file mode 100644
index 00000000..d7fa4f60
--- /dev/null
+++ b/tests/hooks/hermes/binary-e2e.test.ts
@@ -0,0 +1,93 @@
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+import { describe, expect, it } from "vitest";
+
+import { type BinaryIo, runHookBinary } from "../../../src/hooks/binary.js";
+import { createHermesShim } from "../../../src/hooks/hermes/shim.js";
+import { createHookRuntime } from "../../../src/hooks/runtime.js";
+import {
+ createFakeDaemonHookClient,
+ createFakePrimeRenderer,
+ createFakeRecallRenderer,
+ createFakeRecallSessionStore,
+ createNoopSessionStartSeams,
+} from "../../../src/hooks/shared/index.js";
+
+const SESSION_ID = "hermes-binary-e2e";
+const BASE = {
+ session_id: SESSION_ID,
+ cwd: "/repo",
+ transcript_path: `/tmp/${SESSION_ID}.jsonl`,
+};
+
+function hermesEvent(hook_event_name: string, extra: Record = {}, tool_name?: string) {
+ return {
+ hook_event_name,
+ ...BASE,
+ ...(tool_name === undefined ? {} : { tool_name, tool_input: { command: "pwd" } }),
+ extra,
+ };
+}
+
+async function invoke(runtime: ReturnType, raw: unknown, recall = false): Promise {
+ let output = "";
+ const io: BinaryIo = {
+ async readStdin(): Promise {
+ return JSON.stringify(raw);
+ },
+ writeStdout(text: string): void {
+ output += text;
+ },
+ };
+ await runHookBinary({ shim: createHermesShim({ mode: recall ? "recall" : "capture" }), runtime, io });
+ return JSON.parse(output);
+}
+
+describe("Hermes installed-hook lifecycle", () => {
+ it("captures the complete lifecycle and injects daemon recall through Hermes' model-only response", async () => {
+ const daemon = createFakeDaemonHookClient();
+ const runtime = createHookRuntime({
+ daemon,
+ prime: createFakePrimeRenderer(),
+ recall: createFakeRecallRenderer([{ ref: "memory:verification", text: "HERMES_RECALL_MARKER" }]),
+ recallStore: createFakeRecallSessionStore(),
+ seams: createNoopSessionStartSeams(),
+ onboardingNotice: { hasBoundProject: () => true },
+ notifications: { drain: async () => ({ banner: null, suppressed: [] }) },
+ });
+
+ expect(await invoke(runtime, hermesEvent("on_session_start", { source: "cli" }))).toEqual({});
+ expect(await invoke(runtime, hermesEvent("pre_llm_call", { user_message: "verify lifecycle" }))).toEqual({});
+ expect(await invoke(runtime, hermesEvent("post_tool_call", { result: "ok" }, "terminal"))).toEqual({});
+ expect(await invoke(runtime, hermesEvent("post_llm_call", { assistant_response: "verified" }))).toEqual({});
+ expect(await invoke(runtime, hermesEvent("on_session_finalize", { reason: "complete" }))).toEqual({});
+
+ const recall = await invoke(runtime, hermesEvent("pre_llm_call", { user_message: "verify lifecycle" }), true);
+ expect(recall).toEqual({ context: expect.stringContaining("HERMES_RECALL_MARKER") });
+ expect(daemon.calls.map((call) => call.endpoint)).toEqual([
+ "context",
+ "capture",
+ "capture",
+ "capture",
+ "session-end",
+ ]);
+ expect(daemon.calls.map((call) => (call.body as { event?: { kind?: string } }).event?.kind)).toEqual([
+ undefined,
+ "user_message",
+ "tool_call",
+ "assistant_message",
+ undefined,
+ ]);
+ expect(daemon.calls.every((call) => call.meta.sessionId === SESSION_ID && call.runtimePath === "legacy")).toBe(
+ true,
+ );
+ });
+});
diff --git a/tests/hooks/hermes/shim.test.ts b/tests/hooks/hermes/shim.test.ts
index 31be88e0..079c4c8c 100644
--- a/tests/hooks/hermes/shim.test.ts
+++ b/tests/hooks/hermes/shim.test.ts
@@ -1,39 +1,85 @@
/**
- * PRD-019c Hermes shim suite — FR-6 (terminal-only tools + `{ context }` + MCP mention).
+ * Hermes shim suite — current Hermes shell-hook protocol + Honeycomb lifecycle mapping.
*/
import { describe, expect, it } from "vitest";
-import { createHermesShim, hermesContextOutput, HERMES_MCP_MENTION } from "../../../src/hooks/index.js";
+import {
+ createHermesShim,
+ HERMES_RECALL_HOOK_ARG,
+ hermesContextOutput,
+ hermesRenderHookResponse,
+} from "../../../src/hooks/index.js";
const META = { sessionId: "sess-h", path: "conv-h" };
-describe("PRD-019c Hermes shim", () => {
- it("FR-6 captures terminal tools only — a non-terminal tool_use is dropped", () => {
- const shim = createHermesShim();
- const terminal = shim.normalize({ name: "on_tool_use", payload: { tool_name: "Bash", tool_input: { command: "ls" }, tool_response: "ok" } }, META);
- expect(terminal).toBeDefined();
- expect((terminal!.data as { kind: string }).kind).toBe("tool_call");
- // A non-terminal tool is filtered out.
- expect(shim.normalize({ name: "on_tool_use", payload: { tool_name: "Browser" } }, META)).toBeUndefined();
+const native = (event: string, extra: Record = {}, tool?: string) => ({
+ name: event,
+ payload: {
+ hook_event_name: event,
+ tool_name: tool,
+ tool_input: tool === undefined ? null : { command: "pwd" },
+ session_id: "sess-h",
+ cwd: "/repo",
+ extra,
+ },
+});
+
+describe("Hermes shim", () => {
+ it("maps the current Hermes shell-hook lifecycle and reads event fields from extra", () => {
+ const shim = createHermesShim({ mode: "capture" });
+
+ expect(shim.mapEvent("on_session_start")).toBe("session-start");
+ expect(shim.mapEvent("pre_llm_call")).toBe("user_message");
+ expect(shim.mapEvent("pre_tool_call")).toBeUndefined();
+ expect(shim.mapEvent("post_tool_call")).toBe("tool_call");
+ expect(shim.mapEvent("post_llm_call")).toBe("assistant_message");
+ expect(shim.mapEvent("on_session_end")).toBeUndefined();
+ expect(shim.mapEvent("on_session_finalize")).toBe("session-end");
+
+ const user = shim.normalize(native("pre_llm_call", { user_message: "ship the adapter" }), META);
+ expect(user?.data).toEqual({ kind: "user_message", text: "ship the adapter" });
+
+ const assistant = shim.normalize(native("post_llm_call", { assistant_response: "adapter shipped" }), META);
+ expect(assistant?.data).toEqual({ kind: "assistant_message", text: "adapter shipped" });
});
- it("FR-6 emits a { context } output carrying the full block + MCP-tools mention", () => {
- const shim = createHermesShim();
- const env = shim.renderContext("## Goals\n- ship v2");
- expect(env.channel).toBe("user-visible");
- if (env.channel === "user-visible") {
- const out = hermesContextOutput(env.text);
- expect(out.context).toContain("ship v2");
- expect(out.context).toContain(HERMES_MCP_MENTION.trim());
- }
+ it("captures every Hermes tool and reads the post-tool result from extra", () => {
+ const shim = createHermesShim({ mode: "capture" });
+ const terminal = shim.normalize(native("post_tool_call", { result: "ok" }, "terminal"), META);
+ expect(terminal?.data).toEqual({
+ kind: "tool_call",
+ tool: "terminal",
+ input: { command: "pwd" },
+ response: "ok",
+ });
+ expect(shim.normalize(native("post_tool_call", { result: "contents" }, "read_file"), META)?.data).toEqual({
+ kind: "tool_call",
+ tool: "read_file",
+ input: { command: "pwd" },
+ response: "contents",
+ });
+ });
+
+ it("uses a dedicated recall mode so pre_llm_call injects context without double-capturing", () => {
+ const capture = createHermesShim({ mode: "capture" });
+ const recall = createHermesShim({ mode: "recall" });
+ expect(capture.mapEvent("pre_llm_call")).toBe("user_message");
+ expect(recall.mapEvent("pre_llm_call")).toBe("user_prompt_recall");
+ expect(HERMES_RECALL_HOOK_ARG).toBe("--honeycomb-recall");
});
- it("FR-6 maps its four native events and shells hermes non-interactively", () => {
+ it("emits Hermes' native { context } response only for pre_llm_call", () => {
+ const block = "## Goals\n- ship v2";
+ const out = hermesRenderHookResponse("pre_llm_call", block);
+ expect(out).toEqual(hermesContextOutput(block));
+ expect(hermesRenderHookResponse("on_session_start", block)).toEqual({});
+ });
+
+ it("shells Hermes non-interactively and keeps the legacy runtime path", () => {
const shim = createHermesShim();
- expect(shim.mapEvent("on_session_start")).toBe("session-start");
- expect(shim.mapEvent("on_session_end")).toBe("session-end");
expect(shim.runtimePath).toBe("legacy");
- expect(shim.hostCli).toEqual({ bin: "hermes", args: ["--non-interactive"] });
+ expect(shim.contextChannel).toBe("model-only");
+ expect(shim.hostCli).toEqual({ bin: "hermes", args: ["chat", "-Q", "-q"] });
});
});
diff --git a/tests/hooks/shims-channel.test.ts b/tests/hooks/shims-channel.test.ts
index 025de815..7eebafa6 100644
--- a/tests/hooks/shims-channel.test.ts
+++ b/tests/hooks/shims-channel.test.ts
@@ -19,7 +19,6 @@ import {
createHermesShim,
createOpenClawShim,
createPiShim,
- HERMES_MCP_MENTION,
} from "../../src/hooks/index.js";
const BLOCK = "## Goals\n- ship v2\n## Rules\n- prefer small PRs";
@@ -31,7 +30,12 @@ function landed(env: ContextEnvelope): string {
describe("PRD-019c c-AC-5: context channel routing", () => {
it("c-AC-5 model-only harnesses carry the VERBATIM block in additionalContext", () => {
- const modelOnly: readonly HarnessShim[] = [createClaudeCodeShim(), createCursorShim(), createOpenClawShim()];
+ const modelOnly: readonly HarnessShim[] = [
+ createClaudeCodeShim(),
+ createCursorShim(),
+ createHermesShim(),
+ createOpenClawShim(),
+ ];
for (const shim of modelOnly) {
const env = shim.renderContext(BLOCK);
expect(env.channel, shim.harness).toBe("model-only");
@@ -43,7 +47,7 @@ describe("PRD-019c c-AC-5: context channel routing", () => {
});
it("c-AC-5 user-visible harnesses carry the block as transcript text", () => {
- const userVisible: readonly HarnessShim[] = [createCodexShim(), createHermesShim(), createPiShim()];
+ const userVisible: readonly HarnessShim[] = [createCodexShim(), createPiShim()];
for (const shim of userVisible) {
const env = shim.renderContext(BLOCK);
expect(env.channel, shim.harness).toBe("user-visible");
@@ -57,11 +61,10 @@ describe("PRD-019c c-AC-5: context channel routing", () => {
expect(landed(env)).toBe("honeycomb: signed in — memory recall active");
});
- it("c-AC-5 Hermes lands the FULL block plus an MCP-tools mention (user-visible)", () => {
+ it("c-AC-5 Hermes lands the full block model-only through its { context } hook response", () => {
const env = createHermesShim().renderContext(BLOCK);
- const text = landed(env);
- expect(text).toContain(BLOCK); // the full logical block.
- expect(text).toContain(HERMES_MCP_MENTION.trim());
+ expect(env.channel).toBe("model-only");
+ expect(landed(env)).toBe(BLOCK);
});
it("c-AC-5 pi lands the block as a static AGENTS.md fenced section (user-visible)", () => {
@@ -75,10 +78,10 @@ describe("PRD-019c c-AC-5: context channel routing", () => {
it("c-AC-5 the SAME block routes to BOTH a model-only and a user-visible harness", () => {
// One logical block; correct channel for each harness — the c-AC-5 property.
const modelOnly = createClaudeCodeShim().renderContext(BLOCK);
- const userVisible = createHermesShim().renderContext(BLOCK);
+ const userVisible = createPiShim().renderContext(BLOCK);
expect(modelOnly.channel).toBe("model-only");
expect(userVisible.channel).toBe("user-visible");
- // Both carry the same logical content (Hermes appends only the MCP mention).
+ // Both carry the same logical content through their native channels.
expect(landed(modelOnly)).toBe(BLOCK);
expect(landed(userVisible)).toContain(BLOCK);
});
@@ -100,6 +103,7 @@ describe("ISS-022: renderContext extras are safe on every non-recall shim", () =
const modelOnly: readonly HarnessShim[] = [
createClaudeCodeShim({ userPromptMode: "capture" }),
createCursorShim(),
+ createHermesShim(),
createOpenClawShim(),
];
for (const shim of modelOnly) {
@@ -110,8 +114,8 @@ describe("ISS-022: renderContext extras are safe on every non-recall shim", () =
}
});
- it("user-visible shims (codex / hermes / pi) are UNCHANGED without extras and append only the suffix with them", () => {
- const userVisible: readonly HarnessShim[] = [createCodexShim(), createHermesShim(), createPiShim()];
+ it("user-visible shims (codex / pi) are unchanged without extras and append only the suffix with them", () => {
+ const userVisible: readonly HarnessShim[] = [createCodexShim(), createPiShim()];
for (const shim of userVisible) {
const bare = shim.renderContext(BLOCK);
const again = shim.renderContext(BLOCK, undefined);
diff --git a/tests/mcp/registration.test.ts b/tests/mcp/registration.test.ts
index 1795ac19..2f7a48a8 100644
--- a/tests/mcp/registration.test.ts
+++ b/tests/mcp/registration.test.ts
@@ -1,49 +1,46 @@
/**
- * PRD-021e e-AC-4 — the MCP server is registered in ONE MCP-speaking harness.
+ * PRD-021e e-AC-4 — the real Hermes connector registers Honeycomb's stdio MCP server.
*
- * The acceptance bar is a single MCP-speaking harness whose native MCP config
- * lists the Honeycomb server, so its tool list would load the unified `honeycomb_`
- * surface. Hermes is that harness (the wave plan's MCP-speaking target). This test
- * asserts the distinct registration artifact (`harnesses/hermes/.mcp.json`) exists,
- * parses, and registers a `honeycomb` stdio server pointing at the BUILT bundle
- * entry — the same `mcp/bundle/server.js` that `startMcpServer` makes answer
- * `initialize`. It does NOT touch `harnesses/hermes/src/index.ts` (021c owns that).
+ * Hermes reads `mcp_servers` from `$HERMES_HOME/config.yaml`; repository-local
+ * `.mcp.json` files are not part of Hermes' protocol. This test exercises the
+ * production connector path and validates the emitted native YAML.
*/
-import { readFileSync } from "node:fs";
-import { fileURLToPath } from "node:url";
-
import { describe, expect, it } from "vitest";
+import { parse } from "yaml";
-const REPO_ROOT = fileURLToPath(new URL("../../", import.meta.url));
-const CONFIG_PATH = `${REPO_ROOT}harnesses/hermes/.mcp.json`;
+import { createFakeFs, HermesConnector } from "../../src/connectors/index.js";
-interface McpServerEntry {
- readonly command?: string;
- readonly args?: readonly string[];
- readonly env?: Record;
-}
-interface McpConfig {
- readonly mcpServers?: Record;
-}
+const HOME = "/home/dev";
+const BUNDLE = "/repo/harnesses/hermes/bundle";
+const MCP_SOURCE = "/repo/mcp/bundle/server.js";
+const MCP_INSTALLED = `${HOME}/.hermes/honeycomb/mcp/server.mjs`;
-function readConfig(): McpConfig {
- return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as McpConfig;
+function fixture() {
+ return createFakeFs({
+ files: {
+ [`${HOME}/.hermes`]: "",
+ [`${BUNDLE}/session-start.mjs`]: "x",
+ [`${BUNDLE}/capture.mjs`]: "x",
+ [`${BUNDLE}/session-end.mjs`]: "x",
+ [MCP_SOURCE]: "mcp",
+ },
+ });
}
-describe("e-AC-4: the Honeycomb MCP server is registered in the hermes harness", () => {
- it("e-AC-4 the registration artifact lists a honeycomb server", () => {
- const config = readConfig();
- expect(config.mcpServers).toBeDefined();
- expect(config.mcpServers?.honeycomb).toBeDefined();
- });
+describe("e-AC-4: Honeycomb MCP is registered through Hermes' native config", () => {
+ it("copies the MCP bundle and writes mcp_servers.honeycomb with an absolute installed path", async () => {
+ const fs = fixture();
+ await new HermesConnector(fs, { home: HOME, bundleSource: BUNDLE, mcpServerPath: MCP_SOURCE }).install();
- it("e-AC-4 the honeycomb server launches the built mcp/bundle/server.js over stdio", () => {
- const entry = readConfig().mcpServers?.honeycomb;
- expect(entry?.command).toBe("node");
- // The args point at the BUILT MCP bundle — the stdio server startMcpServer serves.
- expect(entry?.args).toBeDefined();
- const joined = (entry?.args ?? []).join(" ");
- expect(joined).toContain("mcp/bundle/server.js");
+ const config = parse(fs.files.get(`${HOME}/.hermes/config.yaml`) as string) as {
+ mcp_servers?: Record;
+ };
+ expect(fs.files.get(MCP_INSTALLED)).toBe("mcp");
+ expect(config.mcp_servers?.honeycomb).toMatchObject({
+ command: process.execPath,
+ args: [MCP_INSTALLED],
+ enabled: true,
+ });
});
});
From 49c5e47b22600c68c724de2fe8afe77f3ce20101 Mon Sep 17 00:00:00 2001
From: Chris <16280532+chrisl10@users.noreply.github.com>
Date: Tue, 21 Jul 2026 17:55:42 -0700
Subject: [PATCH 2/5] fix: address Hermes review and CI failures
---
.github/workflows/ci.yaml | 22 +++--
harnesses/hermes/src/index.ts | 2 +-
src/connectors/contracts.ts | 10 +--
src/connectors/hermes.ts | 87 ++++++++++++-------
.../runtime/dashboard/harness-detect.ts | 33 ++++---
src/hooks/CONVENTIONS.md | 25 +++---
tests/connectors/hermes.test.ts | 13 +++
.../runtime/dashboard/harness-detect.test.ts | 26 +++++-
.../harness-installed-wiring.test.ts | 2 +-
tests/daemon/runtime/logs/log-store.test.ts | 7 +-
10 files changed, 150 insertions(+), 77 deletions(-)
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 08239666..d75cca3d 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -139,7 +139,7 @@ jobs:
- name: Pack-check (tarball secret/forbidden-file scan)
run: npm run pack:check
- - name: Packed CLI + Hermes conformance
+ - name: Packed CLI conformance
run: npm run test:packed-cli
minimum-node-packed-hermes:
@@ -150,14 +150,24 @@ jobs:
uses: actions/checkout@v4.2.2
with:
persist-credentials: false
- - name: Setup minimum supported Node
+ - name: Setup minimum supported runtime Node
uses: actions/setup-node@v6.4.0
with:
node-version: '22.5.0'
+ - name: Preserve minimum runtime path
+ run: echo "MINIMUM_NODE=$(command -v node)" >> "$GITHUB_ENV"
+ - name: Setup build Node
+ uses: actions/setup-node@v6.4.0
+ with:
+ node-version: '24.x'
cache: npm
- - name: Install
+ - name: Install build dependencies
run: npm ci
- - name: Build and execute packed Hermes artifacts
+ - name: Build and pack once
+ run: npm run build && npm run pack:prepare
+ - name: Install package with build Node and execute it with minimum Node
+ env:
+ HONEYCOMB_CONFORMANCE_NODE: ${{ env.MINIMUM_NODE }}
run: npm run test:packed-hermes
# ── Windows smoke. The dev host is Windows and the build scripts
@@ -197,7 +207,7 @@ jobs:
- name: Test
run: npm run test
- - name: Packed CLI + Hermes conformance (Windows)
+ - name: Packed CLI conformance (Windows)
run: npm run test:packed-cli
macos-service-smoke:
@@ -217,7 +227,7 @@ jobs:
run: npm ci
- name: Native launchd adapter tests
run: npm test -- --run tests/cli/daemon-service.test.ts tests/cli/daemon-lifecycle-service.test.ts tests/commands/standard-interface.test.ts
- - name: Packed CLI + Hermes conformance (macOS)
+ - name: Packed CLI conformance (macOS)
run: npm run test:packed-cli
# ── Secret gate. ───────────────────────────────────────────────────────────
diff --git a/harnesses/hermes/src/index.ts b/harnesses/hermes/src/index.ts
index ef9de0ff..5827745f 100644
--- a/harnesses/hermes/src/index.ts
+++ b/harnesses/hermes/src/index.ts
@@ -20,7 +20,7 @@ import { maybeRunHookBinaryMain, runHookBinary } from "../../../src/hooks/binary
import { createHermesShim } from "../../../src/hooks/hermes/shim.js";
import type { HookEventOutcome } from "../../../src/hooks/runtime.js";
-export async function runHermesHook(): Promise {
+export function runHermesHook(): Promise {
return runHookBinary({ shim: createHermesShim() });
}
diff --git a/src/connectors/contracts.ts b/src/connectors/contracts.ts
index 291647b9..3484e7b2 100644
--- a/src/connectors/contracts.ts
+++ b/src/connectors/contracts.ts
@@ -70,16 +70,16 @@ export interface ConnectorFs {
readFile(path: string): Promise;
/** Write a UTF-8 file, creating parent dirs as needed. */
writeFile(path: string, contents: string): Promise;
- /** Atomically replace a UTF-8 file from a same-directory temporary file. */
- writeFileAtomic(path: string, contents: string): Promise;
+ /** Atomically replace a UTF-8 file from a same-directory temporary file when supported. */
+ writeFileAtomic?(path: string, contents: string): Promise;
/** Remove a file. No-op when absent (idempotent uninstall). */
removeFile(path: string): Promise;
/** True when a path exists (file, dir, or symlink). */
exists(path: string): Promise;
/** Ensure a directory exists (mkdir -p). */
ensureDir(path: string): Promise;
- /** Remove a directory only when empty; never removes foreign contents. */
- removeEmptyDir(path: string): Promise;
+ /** Remove a directory only when empty when supported; never removes foreign contents. */
+ removeEmptyDir?(path: string): Promise;
/** Create a symlink `linkPath` → `target`, never clobbering a foreign entry (FR-4 / a-AC-6). */
symlink(target: string, linkPath: string): Promise;
/** Read a symlink's target, or `undefined` when `linkPath` is not a symlink. */
@@ -610,7 +610,7 @@ function isConfigEmpty(config: HarnessConfig): boolean {
}
/** The directory portion of a `/`-or-`\`-separated path. */
-function dirOf(path: string): string {
+export function dirOf(path: string): string {
const norm = path.replace(/\\/g, "/");
const idx = norm.lastIndexOf("/");
return idx <= 0 ? "" : norm.slice(0, idx);
diff --git a/src/connectors/hermes.ts b/src/connectors/hermes.ts
index 09eab777..0b8dbf2c 100644
--- a/src/connectors/hermes.ts
+++ b/src/connectors/hermes.ts
@@ -23,9 +23,9 @@ import { isAbsolute } from "node:path";
import { isMap, isSeq, parseDocument, YAMLMap, YAMLSeq } from "yaml";
import {
- type ConfigHookEntry,
type ConnectorFs,
type ConnectorRunResult,
+ dirOf,
HarnessConnector,
HONEYCOMB_ENTRY_KEY,
HONEYCOMB_MARKER,
@@ -230,25 +230,21 @@ export class HermesConnector extends HarnessConnector {
}
}
- async install(): Promise {
- const handlers = this.hookHandlers();
- const managedFiles = this.managedFiles();
+ private async readManagedSourceBodies(managedFiles: readonly InstallFileEntry[]): Promise