moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2026 Egzakta Group d.o.o. · waggle-os.ai
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Full license text: https://www.apache.org/licenses/LICENSE-2.0.txt

View File

@@ -0,0 +1,84 @@
# @waggle/hive-mind-hooks-hermes
Silent-capture shim that wires **Hermes** (`NousResearch/hermes-agent`)
lifecycle hooks into [hive-mind](https://github.com/marolinik/hive-mind)
frames. Every Hermes session deterministically captures session-start /
prompt / response events into your personal memory via `hive-mind-cli` — the
same every-turn pattern proven by `@waggle/hive-mind-hooks-claude-code`, built
on the shared `@waggle/hive-mind-hooks-core` foundation.
Hermes's hook config is a **YAML installer** with **create-if-missing**
semantics. It targets the **SHELL-HOOKS** system — the top-level `hooks:`
block in `~/.hermes/config.yaml`. It does **not** touch Hermes's two other
"hooks" systems (the directory-based gateway hooks under `~/.hermes/hooks/`,
or the in-process plugin hooks) — only the shell-hooks block.
> **Not hook parity with claude-code.** Hermes ships **only 3 lifecycle
> hooks** — there is **no PreCompact event** — and splits SessionStart across
> two events. See the Capture fidelity table below.
## Install
```bash
npx @waggle/hive-mind-hooks-hermes install
# Windows / production: pin the CLI path
npx @waggle/hive-mind-hooks-hermes install --cli-path "C:\\path\\to\\hive-mind-cli\\dist\\index.js"
```
The installer additively merges hive-mind shell-hook entries into
`~/.hermes/config.yaml` (creating the file if absent), preserving any existing
hooks you have, and writes a pointer + **literal byte-identical backup** so
uninstall is exact. (YAML re-serialization loses comments and key ordering, so
uninstall restores the original bytes rather than a re-serialized diff.)
> **Headless / gateway consent (IMPORTANT).** Hermes keeps a first-use consent
> allow-list keyed on the exact hook command string. Under a **headless /
> non-TTY launch** the hooks register **only** if `hooks_auto_accept: true` is
> in the config (the installer seeds this by default) **or**
> `HERMES_ACCEPT_HOOKS=1` is set — otherwise they silently never register.
> Pass `--no-auto-accept` to manage consent yourself.
```bash
npx @waggle/hive-mind-hooks-hermes verify # smoke-check
npx @waggle/hive-mind-hooks-hermes uninstall # byte-identical restore (or remove if we created it)
```
## Capture fidelity
| Lifecycle | Hermes event(s) | Status | Notes |
|---|---|---|---|
| SessionStart (recall + inject) | `on_session_start` (observer) **+** `pre_llm_call` (`is_first_turn`) | full (split) | observer side is a no-op; inject side fires on the first turn only and appends `{ "context": "..." }` to the user message (not the system prompt — preserves the prefix cache) |
| UserPromptSubmit (save temporary) | `pre_llm_call` | full | the user text arrives at `extra.user_message`; saved every turn |
| Stop (summarize + save) | `post_llm_call` | full | the assistant text arrives at `extra.assistant_response`; `on_session_finalize` is gateway-only and is **not** used |
| PreCompact (compact memory) | **— (none)** | **opt-in approximation** | Hermes ships **no compaction hook** (`VALID_HOOKS` has no compact entry) — there is genuinely nothing to bind. Set `WAGGLE_HERMES_COMPACT_ON_STOP=1` (default off) to approximate it from the per-turn Stop hook, time-gated by `WAGGLE_HERMES_COMPACT_WINDOW_MIN` (minutes, default 10) so it runs at most once per window |
**Disclosures:**
- **No PreCompact event at all.** Hermes has no compaction hook, so the
`cleanup_frames` maintenance step that claude-code runs on PreCompact does
**not** run under Hermes by default. Memory still accrues correctly; only the
periodic compaction nudge is absent. **Opt-in:** set
`WAGGLE_HERMES_COMPACT_ON_STOP=1` to approximate it from the per-turn Stop
hook, time-gated by `WAGGLE_HERMES_COMPACT_WINDOW_MIN` (minutes, default 10)
so the maintenance pass fires at most once per window.
- **SessionStart is split** across `on_session_start` (observer) and
`pre_llm_call` (inject). One compiled script is registered under both; it
gates injection on `is_first_turn` so it injects once per session, not every
turn.
- **Block/inject is via stdout JSON**, not exit codes — Hermes has no
exit-code-2 contract. Our hooks are capture-only: they emit `{}` (or the
SessionStart `{ "context": ... }`) and exit 0.
- **Headless consent** — without `hooks_auto_accept: true` (seeded by default)
or `HERMES_ACCEPT_HOOKS=1`, the hooks silently never register under a
headless launch.
## How it works
Each hook is a short-lived Node subprocess. Hermes spawns the configured
`command` (`shlex.split` + `shell=False`), pipes the event JSON to the hook's
stdin, and reads optional JSON back from stdout. The hook shells to
`hive-mind-cli` to recall or save frames, then exits 0. **Fail-open:** if
`hive-mind-cli` is unreachable or the payload is malformed, the hook logs to
stderr and exits 0 — Hermes is never blocked.
License: Apache-2.0.

View File

@@ -0,0 +1,66 @@
{
"name": "@waggle/hive-mind-hooks-hermes",
"version": "0.1.0",
"description": "Hermes silent capture shim for hive-mind. Adds on_session_start / pre_llm_call / post_llm_call shell hooks that route conversation episodes into hive-mind frames via @waggle/hive-mind-shim-core. Reversible, create-if-missing install — additive marker-tagged merge into ~/.hermes/config.yaml with literal byte-identical uninstall (YAML round-trip is lossy). No PreCompact event (Hermes ships no compaction hook). Built on @waggle/hive-mind-hooks-core.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"hermes-hooks": "dist/bin/hermes-hooks.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./hooks/session-start": "./dist/hooks/session-start.js",
"./hooks/user-prompt-submit": "./dist/hooks/user-prompt-submit.js",
"./hooks/stop": "./dist/hooks/stop.js"
},
"scripts": {
"build": "tsc --build",
"build:clean": "tsc --build --clean",
"typecheck": "tsc --build && tsc --noEmit -p tsconfig.test.json",
"test": "node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/hive-mind-hooks-hermes/tests",
"test:watch": "vitest"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@waggle/hive-mind-hooks-core": "*",
"@waggle/hive-mind-shim-core": "*",
"yaml": "^2.9.0"
},
"peerDependencies": {
"@waggle/hive-mind-cli": "*"
},
"peerDependenciesMeta": {
"@waggle/hive-mind-cli": {
"optional": true
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/marolinik/waggle-os.git",
"directory": "packages/hive-mind-hooks-hermes"
},
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-hooks-hermes#readme",
"bugs": {
"url": "https://github.com/marolinik/waggle-os/issues"
},
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
"keywords": [
"hermes",
"hive-mind",
"memory",
"ai",
"hook",
"silent-capture",
"yaml"
],
"types": "dist/index.d.ts"
}

View File

@@ -0,0 +1,106 @@
/**
* Hermes EventAdapter.
*
* Hermes (`NousResearch/hermes-agent`) exposes a SHELL-HOOKS system — a
* top-level `hooks:` block in `~/.hermes/config.yaml` whose entries spawn a
* configured `command` as an OS subprocess, pipe the event JSON to stdin,
* and read optional JSON back from stdout. That contract is structurally
* identical to shim-core's `runHook` (stdin-JSON / exit-0), so the three
* lifecycle handler bodies reuse `runHook` as-is.
*
* Hermes differs from claude-code in three source-verified ways (spec §5.4):
* - SessionStart is SPLIT across two native events — `on_session_start`
* (observer/no-op) AND `pre_llm_call` gated on `is_first_turn` (the
* inject seam). The register side wires BOTH keys (see yaml-merger.ts);
* the inject stdout shape is `{ "context": "..." }` (appended to the
* user message, NOT the system prompt, to preserve prefix cache), so
* `formatInject` returns `{ context: text }`.
* - UserPromptSubmit → `pre_llm_call`; the user text lives at
* `extra.user_message`.
* - Stop → `post_llm_call`; the assistant text lives at
* `extra.assistant_response`. (`on_session_finalize` is gateway-only —
* do NOT use it; `post_llm_call` is the dependable per-turn Stop signal.)
*
* There is NO PreCompact event — Hermes ships no compaction hook
* (`eventName['pre-compact'] = undefined`); the maintenance pass is
* approximated opt-in from Stop (`WAGGLE_HERMES_COMPACT_ON_STOP`, default
* off) — see `compact-on-stop.ts`. (`eventName['pre-compact']` stays
* `undefined`; we are NOT registering a hook.)
*
* Hermes events carry block/inject control purely via stdout JSON (no
* exit-code-2 contract). Our hooks are capture-only, so they emit `{}` or
* the SessionStart context and exit 0 — fully compatible with the shim.
*/
import {
pickStringField,
type EventAdapter,
type Lifecycle,
} from '@waggle/hive-mind-hooks-core';
/**
* Canonical lifecycle → Hermes native event key. SessionStart maps to
* `pre_llm_call` for the inject side; the observer-only `on_session_start`
* key is registered separately by the YAML merger (it cannot be expressed
* in this 1:1 map). `pre-compact` is `undefined` — Hermes has no compaction
* hook.
*/
export const HERMES_EVENT_NAME: Record<Lifecycle, string | undefined> = {
'session-start': 'pre_llm_call',
'user-prompt-submit': 'pre_llm_call',
'stop': 'post_llm_call',
'pre-compact': undefined,
};
/** Hermes observer-only SessionStart key, registered alongside `pre_llm_call`. */
export const HERMES_SESSION_START_OBSERVE_EVENT = 'on_session_start';
/**
* Read a string field nested under the Hermes `extra` envelope, falling
* back to top-level keys for forward-compat. Hermes delivers the turn
* payload under `extra.{user_message,assistant_response,...}`.
*/
function pickFromExtra(payload: unknown, ...keys: string[]): string | undefined {
if (payload && typeof payload === 'object') {
const extra = (payload as Record<string, unknown>)['extra'];
const fromExtra = pickStringField(extra, ...keys);
if (fromExtra) return fromExtra;
}
return pickStringField(payload, ...keys);
}
/** The Hermes EventAdapter consumed by the shared lifecycle handler bodies. */
export const hermesAdapter: EventAdapter = {
source: 'hermes',
eventName: HERMES_EVENT_NAME,
extractCwd(payload): string | undefined {
return pickFromExtra(payload, 'cwd', 'working_directory', 'workingDirectory');
},
extractSessionId(payload): string | undefined {
return pickFromExtra(payload, 'session_id', 'sessionId', 'conversation_id', 'conversationId');
},
extractPrompt(payload): string | undefined {
// pre_llm_call carries the user text at extra.user_message.
return pickFromExtra(payload, 'user_message', 'prompt');
},
extractResponse(payload): string | undefined {
// post_llm_call carries the assistant text at extra.assistant_response.
return pickFromExtra(payload, 'assistant_response', 'final_response', 'response');
},
extractParent(payload): string | undefined {
return pickFromExtra(payload, 'parent_frame_id', 'prompt_frame_id');
},
/**
* Hermes appends `pre_llm_call` stdout `{ "context": "..." }` to the
* user message (NOT the system prompt — preserves the prefix cache).
*/
formatInject(additionalContext: string): unknown {
return { context: additionalContext };
},
};

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* `hermes-hooks` — CLI entry for the @waggle/hive-mind-hooks-hermes shim.
*
* hermes-hooks install Patch ~/.hermes/config.yaml (additive, create-if-missing).
* hermes-hooks uninstall Restore the literal byte-identical pre-install state (or
* remove the config.yaml we created).
* hermes-hooks verify Smoke-check the install + hive-mind-cli reachability.
*/
import { install, type InstallResult } from '../install.js';
import { uninstall, type UninstallResult } from '../uninstall.js';
import { verify, type VerifyResult } from '../verify.js';
type ParsedArgs = {
command: 'install' | 'uninstall' | 'verify' | 'help';
flags: Record<string, string | boolean>;
};
function parseArgs(argv: readonly string[]): ParsedArgs {
const [first, ...rest] = argv;
const valid = ['install', 'uninstall', 'verify'] as const;
const command = first === '-h' || first === '--help' || first === undefined
? 'help'
: valid.includes(first as typeof valid[number]) ? first as typeof valid[number] : 'help';
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (!arg.startsWith('--')) continue;
const eq = arg.indexOf('=');
if (eq !== -1) {
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
} else {
const next = rest[i + 1];
if (next && !next.startsWith('--')) {
flags[arg.slice(2)] = next;
i += 1;
} else {
flags[arg.slice(2)] = true;
}
}
}
return { command: command as ParsedArgs['command'], flags };
}
function printHelp(): void {
process.stdout.write([
'Usage: hermes-hooks <command> [options]',
'',
'Commands:',
' install Patch ~/.hermes/config.yaml (additive, create-if-missing, with backup).',
' uninstall Restore the literal byte-identical pre-install config.yaml (or remove it if created).',
' verify Smoke-check the install + hive-mind-cli reachability.',
'',
'Options:',
' --help, -h Show this help.',
' --hooks-dir <PATH> Override compiled hooks directory (testing).',
' --hook-timeout <S> Override per-hook timeout in seconds (default 60, cap 300).',
' --no-auto-accept Do NOT seed hooks_auto_accept: true. WARNING: under',
' headless / non-TTY launches the hooks then silently',
' never register unless HERMES_ACCEPT_HOOKS=1 is set.',
' --cli-path <PATH> Absolute path to the hive-mind-cli binary or its',
' compiled JS entry. Required on Windows (npm bin',
' is a .cmd shim) and recommended for production',
' installs. Threaded into every hook command.',
'',
'Repo: https://github.com/marolinik/waggle-os',
'',
].join('\n'));
}
function printInstallSummary(result: InstallResult): void {
const lines: string[] = [
'hive-mind/hermes-hooks: install',
` - config.yaml: ${result.paths.configPath}`,
` - backup: ${result.backupPath ?? '(none — config.yaml created by us)'}`,
` - pointer: ${result.pointerPath}`,
` - hook scripts: ${result.installedHooks.join(', ')}`,
` - events: ${result.registeredEvents.join(', ')}`,
` - auto-accept: ${result.autoAcceptSeeded ? 'seeded (hooks_auto_accept: true)' : 'not seeded'}`,
` - cli path: ${result.cliPath ?? '(default — hive-mind-cli on PATH)'}`,
'',
'Headless / gateway consent (IMPORTANT):',
result.autoAcceptSeeded
? ' hooks_auto_accept: true was written, so the hooks register under headless launch.'
: ' hooks_auto_accept was NOT seeded. Under a headless / non-TTY launch the hooks',
result.autoAcceptSeeded
? ' (Hermes keys consent on the exact command string; editing the target script is silently trusted.)'
: ' silently never register unless you set HERMES_ACCEPT_HOOKS=1 (or add hooks_auto_accept: true).',
'',
'Capture fidelity:',
' 3 events only — SessionStart (split: on_session_start + pre_llm_call/is_first_turn),',
' UserPromptSubmit (pre_llm_call), Stop (post_llm_call). NO PreCompact event (Hermes',
' ships no compaction hook). See the README Capture fidelity table.',
' Opt-in: set WAGGLE_HERMES_COMPACT_ON_STOP=1 to approximate the PreCompact',
' maintenance pass from the Stop hook (time-gated, default off).',
'',
'Done. New Hermes sessions will silently capture to hive-mind.',
'Run "hermes-hooks verify" to inspect, "hermes-hooks uninstall" to revert.',
'',
];
process.stdout.write(lines.join('\n'));
}
function printUninstallSummary(result: UninstallResult): void {
const lines: string[] = [
'hive-mind/hermes-hooks: uninstall',
` - config.yaml: ${result.paths.configPath}`,
` - restored from: ${result.restoredFrom ?? '(none — removed file we created)'}`,
` - created removed: ${result.createdRemoved ? 'yes' : 'no'}`,
` - backup removed: ${result.backupRemoved ? 'yes' : 'no (kept on disk)'}`,
` - pointer removed: ${result.pointerRemoved ? 'yes' : 'no'}`,
'',
'config.yaml is byte-identical to pre-install state (or removed if we created it).',
'',
];
process.stdout.write(lines.join('\n'));
}
function printVerifySummary(result: VerifyResult): void {
const lines: string[] = ['hive-mind/hermes-hooks: verify'];
for (const c of result.checks) {
const tag = c.ok ? 'PASS' : 'FAIL';
const detail = c.detail ? `${c.detail}` : '';
lines.push(` [${tag}] ${c.name}${detail}`);
}
lines.push('');
lines.push(result.ok ? 'All checks passed.' : 'One or more checks failed.');
lines.push('');
process.stdout.write(lines.join('\n'));
}
async function main(): Promise<void> {
const { command, flags } = parseArgs(process.argv.slice(2));
if (command === 'help') {
printHelp();
return;
}
const hooksDir = typeof flags['hooks-dir'] === 'string' ? flags['hooks-dir'] : undefined;
const hookTimeoutRaw = flags['hook-timeout'];
const hookTimeoutSeconds = typeof hookTimeoutRaw === 'string'
? Number.parseInt(hookTimeoutRaw, 10) || undefined
: undefined;
const cliPath = typeof flags['cli-path'] === 'string' ? flags['cli-path'] : undefined;
const noAutoAccept = flags['no-auto-accept'] === true;
const baseOpts = {
moduleUrl: import.meta.url,
...(hooksDir ? { hooksDir } : {}),
};
try {
if (command === 'install') {
const installOpts = {
...baseOpts,
...(hookTimeoutSeconds !== undefined ? { hookTimeoutSeconds } : {}),
...(cliPath !== undefined ? { cliPath } : {}),
...(noAutoAccept ? { autoAccept: false } : {}),
};
const result = await install(installOpts);
printInstallSummary(result);
return;
}
if (command === 'uninstall') {
const result = await uninstall(baseOpts);
printUninstallSummary(result);
return;
}
if (command === 'verify') {
const result = await verify(baseOpts);
printVerifySummary(result);
if (!result.ok) process.exit(1);
return;
}
} catch (err) {
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
}
void main();

View File

@@ -0,0 +1,126 @@
/**
* Hermes opportunistic, time-gated compact-on-Stop (OQ-4).
*
* Hermes ships no PreCompact lifecycle event, so the `cleanup_frames`
* maintenance pass every other built hook binds to PreCompact never runs.
* This module approximates "occasional before-compaction maintenance" by
* running `bridge.cleanupFrames()` opportunistically from the per-turn Stop
* hook — OPT-IN, DEFAULT OFF (`WAGGLE_HERMES_COMPACT_ON_STOP`), so OSS
* consumers see zero behavior change unless they ask for it.
*
* Statelessness bridge: Hermes' Stop is delivered to a fresh Node subprocess
* every turn, so an "every N turns" counter is impossible without persistence.
* We compact at most once per time window, tracking the last-compact instant
* in a small file under `~/.hermes/`. Turns are sequential subprocesses
* (process N exits before N+1 starts) → no read/write race.
*
* Fail-open is sacred: `maybeCompactOnStop` NEVER throws and NEVER rejects;
* every IO path is wrapped + swallowed. The timestamp is written ON SUCCESS
* ONLY — a failed compact stays eligible to retry next turn rather than being
* locked out for a whole window.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { HookContext } from '@waggle/hive-mind-hooks-core';
import { resolvePaths } from './paths.js';
/** Default compact window: at most once per 10 minutes. */
export const DEFAULT_WINDOW_MS = 600_000;
const STATE_BASENAME = '.hive-mind-last-compact';
/**
* Absolute path to the last-compact timestamp file, under the hermes config
* root (`~/.hermes/.hive-mind-last-compact`). `home` overrides $HOME for tests.
*/
export function compactStatePath(home?: string): string {
return join(resolvePaths({ home }).hermesDir, STATE_BASENAME);
}
/**
* Whether opt-in compact-on-Stop is enabled. Parsed exactly like
* `WAGGLE_SIGNAL_EMIT` in `runStopBody`: truthy unless unset / empty / '0' /
* 'false' (case-insensitive).
*/
export function isCompactEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const flag = env['WAGGLE_HERMES_COMPACT_ON_STOP'];
return flag != null && flag !== '' && flag !== '0' && flag.toLowerCase() !== 'false';
}
/**
* Resolve the compact window in ms. A test `overrideMs` wins; else
* `WAGGLE_HERMES_COMPACT_WINDOW_MIN` minutes when finite and > 0; else the
* 10-minute default.
*/
export function resolveWindowMs(
env: NodeJS.ProcessEnv = process.env,
overrideMs?: number,
): number {
if (overrideMs !== undefined) return overrideMs;
const raw = env['WAGGLE_HERMES_COMPACT_WINDOW_MIN'];
if (raw != null && raw !== '') {
const minutes = Number.parseFloat(raw);
if (Number.isFinite(minutes) && minutes > 0) return minutes * 60_000;
}
return DEFAULT_WINDOW_MS;
}
/**
* Read the persisted last-compact timestamp. Missing file / garbage / NaN →
* undefined ("never"). Fail-open: any error resolves to undefined.
*/
export async function readLastCompactTs(path: string): Promise<number | undefined> {
try {
const raw = await readFile(path, 'utf-8');
const ts = Number.parseInt(raw.trim(), 10);
return Number.isFinite(ts) ? ts : undefined;
} catch {
return undefined;
}
}
/**
* Persist the last-compact timestamp. mkdir-recursive the parent first (a bare
* writeFile would ENOENT every turn on a host where `~/.hermes/` doesn't exist
* yet, silently degrading the throttle to every-turn; mkdir-recursive is
* idempotent + cheap, and in a real install the dir already exists). The
* caller catches + swallows errors.
*/
export async function writeLastCompactTs(path: string, ts: number): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, String(ts));
}
export interface MaybeCompactOptions {
/** Injectable clock for tests; falls back to the system clock. */
now?: () => number;
/** $HOME override for the state file (tests). */
home?: string;
/** Compact window override in ms (tests); wins over the env var. */
windowMs?: number;
}
/**
* Opportunistically run the `cleanup_frames` maintenance pass, gated by the
* opt-in flag + a persisted time window. NEVER throws, NEVER rejects.
*/
export async function maybeCompactOnStop(
ctx: HookContext,
opts: MaybeCompactOptions = {},
): Promise<void> {
try {
if (!isCompactEnabled()) return;
const now = opts.now?.() ?? Date.now();
const path = compactStatePath(opts.home);
const last = await readLastCompactTs(path);
const windowMs = resolveWindowMs(process.env, opts.windowMs);
if (last !== undefined && now - last < windowMs) return;
await ctx.bridge.cleanupFrames();
await writeLastCompactTs(path, now);
} catch (err) {
ctx.logger.warn('compact-on-stop failed open', {
error: err instanceof Error ? err.message : String(err),
});
}
}

View File

@@ -0,0 +1,90 @@
/**
* Hermes session-start hook — recalls the top-N most relevant frames from
* personal memory and injects them as additional context for the new Hermes
* session. Thin entrypoint over the shared SessionStart handler body.
*
* Hermes SPLITS SessionStart across two native events, and this ONE compiled
* script is registered under BOTH (spec §5.4):
* - `on_session_start` — observer-only (fires once per new session; its
* return value is ignored by Hermes).
* - `pre_llm_call` — the inject seam, but ONLY on the first turn.
*
* Because the same script fires on every `pre_llm_call` (not just turn 1),
* it gates on the payload's `is_first_turn` flag: when the host indicates a
* non-first turn, the script emits nothing (the per-turn save is handled by
* the separate user-prompt-submit hook). On the first turn (or when the flag
* is absent — e.g. the `on_session_start` observer payload), it recalls +
* injects via the hermes adapter's `formatInject` (`{ context: text }`).
*
* Fail-open: if hive-mind-cli is unreachable the hook logs and exits 0 with
* no output — the session starts as it would have without the shim.
*/
import {
makeSessionStartHandler,
readStdinAsString,
runHook,
safeJsonParse,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { hermesAdapter } from '../adapter.js';
/**
* True unless the host explicitly marks this as a non-first turn. Hermes
* sets `extra.is_first_turn=false` on subsequent `pre_llm_call`s; the
* `on_session_start` observer payload omits the flag, so absence ⇒ inject.
*/
function isFirstTurn(raw: unknown): boolean {
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
const extra = obj['extra'];
const flag = (extra && typeof extra === 'object'
? (extra as Record<string, unknown>)['is_first_turn']
: undefined) ?? obj['is_first_turn'];
if (flag === false) return false;
}
return true;
}
export async function runSessionStart(opts: Partial<HookRunOptions> = {}): Promise<void> {
// Read stdin ONCE, then honor Hermes's is_first_turn inject gating without
// changing the shared handler body. On a non-first turn we exit 0 with no
// output (so the host pipe drains and the session is never blocked); the
// per-turn save is owned by the separate user-prompt-submit hook.
const exit = opts.exit ?? ((c: number): void => { process.exit(c); });
let raw: string;
try {
const reader = opts.readStdin ?? readStdinAsString;
raw = await reader();
if (!isFirstTurn(safeJsonParse(raw))) {
exit(0);
return;
}
} catch {
// Fail-open: a throwing stdin read must never escape to the host
// (mirrors runHook's exit-0-always contract for the pre-gate path).
exit(0);
return;
}
return runHook(makeSessionStartHandler(hermesAdapter), {
name: 'session-start',
loggerPrefix: 'hermes-hooks',
...opts,
// Re-feed the already-read payload so runHook does not block on a
// drained stdin.
readStdin: async () => raw,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runSessionStart();
}

View File

@@ -0,0 +1,68 @@
/**
* Hermes stop hook — fired when an assistant turn completes. Summarizes the
* turn deterministically (no LLM call) and writes it as an `important` frame
* parented to the originating prompt frame when known. Thin entrypoint over
* the shared handler body.
*
* Native event: `post_llm_call` (fires once per turn after the loop
* completes, only if a final response was produced and the turn was not
* interrupted — the dependable per-turn Stop signal for a single-shot CLI
* run). The assistant text arrives at `extra.assistant_response`; the hermes
* adapter's `extractResponse` reads it. NOTE: `on_session_finalize` is the
* gateway-only path and is deliberately NOT used.
*
* Capture-only: emits no stdout (Hermes block/inject is via stdout JSON,
* not exit codes); fails open on any error and exits 0.
*/
import {
makeStopHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { hermesAdapter } from '../adapter.js';
import { maybeCompactOnStop } from '../compact-on-stop.js';
export interface HermesStopOptions extends Partial<HookRunOptions> {
/** Injectable clock for the compact gate (tests). */
now?: () => number;
/** $HOME override for the compact state file (tests). */
home?: string;
/** Compact window override in ms (tests). */
compactWindowMs?: number;
}
export async function runStop(opts: HermesStopOptions = {}): Promise<void> {
const { now, home, compactWindowMs, ...runOpts } = opts;
const base = makeStopHandler(hermesAdapter);
const handler: typeof base = {
parse: base.parse,
async run(payload, ctx) {
// Primary save first — unchanged. If this throws, the compact step is
// skipped and the throw lands in runHook's fail-open catch (exit 0).
await base.run(payload, ctx);
// Best-effort maintenance layered after the save. Never throws/rejects,
// so it cannot affect the already-completed save or the exit code.
await maybeCompactOnStop(ctx, { now, home, windowMs: compactWindowMs });
return undefined;
},
};
return runHook(handler, {
name: 'stop',
loggerPrefix: 'hermes-hooks',
...runOpts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runStop();
}

View File

@@ -0,0 +1,44 @@
/**
* Hermes user-prompt-submit hook — captures the user prompt as a temporary
* frame scoped to the current Hermes session. Thin entrypoint over the
* shared handler body.
*
* Native event: `pre_llm_call` (fires once per turn before the tool loop).
* The user text arrives at `extra.user_message`; the hermes adapter's
* `extractPrompt` reads it. This hook is registered alongside the
* session-start inject hook on the same `pre_llm_call` event — the two are
* independent: session-start injects only on the first turn, this one saves
* on every turn.
*
* Save-only: Hermes block/inject control flows through stdout JSON (no
* exit-code-2 contract), and this hook is capture-only — it emits no stdout
* and is a pure side-effect on the .mind file.
*/
import {
makeUserPromptSubmitHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { hermesAdapter } from '../adapter.js';
export async function runUserPromptSubmit(opts: Partial<HookRunOptions> = {}): Promise<void> {
return runHook(makeUserPromptSubmitHandler(hermesAdapter), {
name: 'user-prompt-submit',
loggerPrefix: 'hermes-hooks',
...opts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runUserPromptSubmit();
}

View File

@@ -0,0 +1,85 @@
/**
* @waggle/hive-mind-hooks-hermes — barrel export.
*
* Hermes silent-capture shim for hive-mind. A bespoke-YAML installer built
* on @waggle/hive-mind-hooks-core, with create-if-missing semantics
* (Hermes's `~/.hermes/config.yaml` is optional). Targets the SHELL-HOOKS
* system (the top-level `hooks:` block) — NOT the gateway dir-hooks nor the
* in-process plugin hooks.
*
* Three events only (NO PreCompact — Hermes ships no compaction hook):
* - SessionStart is SPLIT across `on_session_start` (observer) and
* `pre_llm_call` (inject, gated `is_first_turn`).
* - UserPromptSubmit → `pre_llm_call`.
* - Stop → `post_llm_call`.
*
* Because Hermes has no PreCompact event, the `cleanup_frames` maintenance
* pass is approximated OPT-IN from the per-turn Stop hook
* (`WAGGLE_HERMES_COMPACT_ON_STOP`, default off; time-gated by
* `WAGGLE_HERMES_COMPACT_WINDOW_MIN`) — see `compact-on-stop.ts`.
*
* YAML round-trip is lossy, so reversibility relies on the literal
* byte-identical backup written at install time. Programmatic install /
* uninstall / verify lifecycle; most users invoke the `hermes-hooks` bin.
*/
export type {
InstallOptions,
InstallResult,
} from './install.js';
export { install } from './install.js';
export type {
UninstallOptions,
UninstallResult,
} from './uninstall.js';
export { uninstall } from './uninstall.js';
export type {
VerifyOptions,
VerifyResult,
VerifyCheck,
} from './verify.js';
export { verify } from './verify.js';
export type {
HermesPaths,
ResolvePathsOptions,
HookBasename,
} from './paths.js';
export {
resolvePaths,
allHookBasenames,
backupPathFor,
hookCommandFor,
} from './paths.js';
export {
hermesAdapter,
HERMES_EVENT_NAME,
HERMES_SESSION_START_OBSERVE_EVENT,
} from './adapter.js';
export type { MaybeCompactOptions } from './compact-on-stop.js';
export {
maybeCompactOnStop,
compactStatePath,
isCompactEnabled,
resolveWindowMs,
DEFAULT_WINDOW_MS,
} from './compact-on-stop.js';
export type {
HermesHookEntry,
HermesRegisterEntry,
} from './yaml-merger.js';
export {
parseConfig,
serializeConfig,
yamlRegister,
yamlUnregister,
hasHiveEntries,
isHiveEntry,
HIVE_MIND_MARKER,
HOOKS_KEY,
} from './yaml-merger.js';

View File

@@ -0,0 +1,200 @@
/**
* Programmatic install entry point for the Hermes hive-mind hooks.
*
* Steps:
* 1. Read existing `~/.hermes/config.yaml` IF it exists (create-if-missing
* — config.yaml is OPTIONAL on a fresh install). We touch ONLY the
* top-level `hooks:` block (the SHELL-HOOKS system).
* 2. If it pre-existed, write a LITERAL byte-identical backup; if absent,
* skip the backup and record `created_by_us=true`. (YAML round-trip is
* lossy, so reversibility relies on the literal backup, not a
* re-serialized diff.)
* 3. Additively register the hive-mind shell hooks into the `hooks:`
* block via `yamlRegister`. SessionStart is SPLIT across two native
* events: `on_session_start` (observer) and `pre_llm_call` (inject,
* gated `is_first_turn`). UserPromptSubmit also rides `pre_llm_call`;
* Stop rides `post_llm_call`. Existing entries preserved verbatim.
* 4. Optionally seed `hooks_auto_accept: true` so the hooks register under
* headless / non-TTY launches (Hermes's first-use consent allow-list
* otherwise silently never registers them).
* 5. Write the merged YAML back over `config.yaml`.
* 6. Drop a pointer file at `~/.hermes/hive-mind-install.json` so a future
* `uninstall` knows whether to restore the backup or delete the file
* we created.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import {
backupByteIdentical,
hookCommandFor,
hookScriptPath,
normalizeCliPath,
writePointer,
type InstallPointer,
} from '@waggle/hive-mind-hooks-core';
import { resolvePaths, type HermesPaths, type ResolvePathsOptions } from './paths.js';
import {
parseConfig,
serializeConfig,
yamlRegister,
type HermesRegisterEntry,
} from './yaml-merger.js';
import { HERMES_SESSION_START_OBSERVE_EVENT } from './adapter.js';
export interface InstallResult {
paths: HermesPaths;
/** The literal byte-identical backup written when config.yaml pre-existed, else null. */
backupPath: string | null;
pointerPath: string;
/** Hook basenames whose scripts are referenced (session-start / user-prompt-submit / stop). */
installedHooks: readonly string[];
/** Native Hermes event keys we registered entries under (recorded in pointer.extra). */
registeredEvents: readonly string[];
/** True when config.yaml did NOT pre-exist and we created it. */
createdByUs: boolean;
/** True when `hooks_auto_accept: true` was seeded into the config. */
autoAcceptSeeded: boolean;
/** The cli_path embedded in hook commands (undefined = default lookup at runtime). */
cliPath?: string;
}
export interface InstallOptions extends ResolvePathsOptions {
/** Per-hook timeout, seconds. Default 60 (Hermes default), hard cap 300. */
hookTimeoutSeconds?: number;
/**
* Seed `hooks_auto_accept: true` into config.yaml so the hooks register
* under headless / non-TTY launches. Default true — without it (or
* `HERMES_ACCEPT_HOOKS=1`) the hooks silently never register. Set false
* to manage consent yourself.
*/
autoAccept?: boolean;
/** Override clock for deterministic tests. */
now?: () => Date;
/** Logger override. */
logger?: Logger;
/**
* Absolute path to the hive-mind-cli binary or its compiled JS entry.
* Required on Windows (npm bin shim is `.cmd` and can't be exec'd
* without a shell). Threaded into every hook command as
* `--cli-path "<path>"`.
*/
cliPath?: string;
}
const DEFAULT_HOOK_TIMEOUT_S = 60;
const HOOK_TIMEOUT_CAP_S = 300;
const POINTER_VERSION = '0.1.0';
async function ensureDir(p: string): Promise<void> {
if (!existsSync(p)) await mkdir(p, { recursive: true });
}
/**
* Build the hermes register entries from the resolved hook script paths.
* SessionStart is SPLIT: the session-start script registers under BOTH
* `on_session_start` (observer) and `pre_llm_call` (inject). The
* user-prompt-submit script also rides `pre_llm_call`; stop rides
* `post_llm_call`.
*/
function buildEntries(hooksDir: string, cliPath: string | undefined, timeout: number): HermesRegisterEntry[] {
const sessionStartCmd = hookCommandFor(hookScriptPath(hooksDir, 'session-start'), cliPath);
const userPromptCmd = hookCommandFor(hookScriptPath(hooksDir, 'user-prompt-submit'), cliPath);
const stopCmd = hookCommandFor(hookScriptPath(hooksDir, 'stop'), cliPath);
return [
// SessionStart observer side — fires once per new session.
{ eventKey: HERMES_SESSION_START_OBSERVE_EVENT, command: sessionStartCmd, timeout },
// SessionStart inject side — fires on pre_llm_call, gated is_first_turn.
{ eventKey: 'pre_llm_call', command: sessionStartCmd, timeout },
// UserPromptSubmit — also rides pre_llm_call (every turn).
{ eventKey: 'pre_llm_call', command: userPromptCmd, timeout },
// Stop — post_llm_call (turn end).
{ eventKey: 'post_llm_call', command: stopCmd, timeout },
];
}
export async function install(opts: InstallOptions = {}): Promise<InstallResult> {
const log = opts.logger ?? createLogger({ name: 'hermes-hooks/install' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
});
const now = opts.now ?? ((): Date => new Date());
const autoAccept = opts.autoAccept ?? true;
log.info('install starting', { config: paths.configPath, hooksDir: paths.hooksDir });
// Read the existing config if present; create-if-missing otherwise.
let existingConfig: Record<string, unknown> | undefined;
const preExisted = existsSync(paths.configPath);
if (preExisted) {
const originalContent = await readFile(paths.configPath, 'utf-8');
existingConfig = parseConfig(originalContent);
}
await ensureDir(dirname(paths.pointerPath));
// Literal byte-identical backup of the original config (no-op when absent).
// Uninstall restores THESE exact bytes — YAML re-serialization is lossy.
const { backupPath, preExisted: backedUp } = await backupByteIdentical(
paths.configPath,
now().toISOString(),
);
if (backedUp) log.info('config.yaml backed up', { backupPath });
const cliPath = normalizeCliPath(opts.cliPath);
const requested = opts.hookTimeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_S;
const timeout = Math.min(Math.max(1, Math.floor(requested)), HOOK_TIMEOUT_CAP_S);
const entries = buildEntries(paths.hooksDir, cliPath, timeout);
let merged = yamlRegister(existingConfig, entries);
let autoAcceptSeeded = false;
if (autoAccept && merged['hooks_auto_accept'] !== true) {
merged = { ...merged, hooks_auto_accept: true };
autoAcceptSeeded = true;
}
const mergedYaml = serializeConfig(merged);
await writeFile(paths.configPath, mergedYaml, 'utf-8');
const registeredEvents = [...new Set(entries.map((e) => e.eventKey))];
const basenames = ['session-start', 'user-prompt-submit', 'stop'] as const;
const createdByUs = !preExisted;
const pointer: InstallPointer = {
version: POINTER_VERSION,
installed_at: now().toISOString(),
config_path: paths.configPath,
settings_backup: backupPath,
created_by_us: createdByUs,
hooks_dir: paths.hooksDir,
installed_hooks: basenames,
cli_path: cliPath ?? null,
extra: {
registered_events: registeredEvents,
auto_accept_seeded: autoAcceptSeeded,
},
};
await writePointer(paths.pointerPath, pointer);
log.info('install complete', {
registeredEvents,
createdByUs,
autoAcceptSeeded,
cliPath: cliPath ?? '(PATH lookup)',
});
const result: InstallResult = {
paths,
backupPath,
pointerPath: paths.pointerPath,
installedHooks: basenames,
registeredEvents,
createdByUs,
autoAcceptSeeded,
};
if (cliPath !== undefined) result.cliPath = cliPath;
return result;
}

View File

@@ -0,0 +1,81 @@
/**
* Filesystem path helpers for the Hermes hive-mind hook install lifecycle.
*
* Mirrors the Codex/Cursor `paths.ts` shape, but targets Hermes's
* `~/.hermes/config.yaml` (the path `hermes_cli/config.py get_config_path`
* resolves to). We touch ONLY the top-level `hooks:` block (the SHELL-HOOKS
* system) — never the gateway dir-hooks (`~/.hermes/hooks/<name>/`) nor the
* in-process plugin hooks. Hermes ships only THREE lifecycle hooks (there is
* no PreCompact event), so the basename set omits `pre-compact`.
*
* The Windows-safe backup path + `--cli-path` quoting + hooks-dir resolution
* are reused verbatim from `@waggle/hive-mind-hooks-core` so hermes reads
* like the reference.
*/
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import {
backupPathFor,
hookCommandFor,
hooksDirFromModuleUrl,
} from '@waggle/hive-mind-hooks-core';
export interface HermesPaths {
/** Hermes config root (`~/.hermes/`). */
hermesDir: string;
/** `~/.hermes/config.yaml` — the shell-hooks config (top-level `hooks:`). */
configPath: string;
/** `~/.hermes/hive-mind-install.json` — pointer to the active backup. */
pointerPath: string;
/** Directory where compiled hook scripts live (`dist/hooks/`). */
hooksDir: string;
}
export interface ResolvePathsOptions {
/** Override $HOME for tests. */
home?: string;
/** Override the URL used to locate dist/hooks (defaults to import.meta.url at runtime). */
moduleUrl?: string;
/** Override hooks directory directly (wins over moduleUrl). */
hooksDir?: string;
}
/**
* Hermes ships THREE lifecycle hooks only — there is genuinely no
* compaction hook to bind (confirmed absent in `VALID_HOOKS`), so
* `pre-compact` is intentionally omitted.
*/
const HOOK_BASENAMES = [
'session-start',
'user-prompt-submit',
'stop',
] as const;
export type HookBasename = typeof HOOK_BASENAMES[number];
export function allHookBasenames(): readonly HookBasename[] {
return HOOK_BASENAMES;
}
export function resolvePaths(opts: ResolvePathsOptions = {}): HermesPaths {
const home = opts.home ?? homedir();
const hermesDir = join(home, '.hermes');
const configPath = join(hermesDir, 'config.yaml');
const pointerPath = join(hermesDir, 'hive-mind-install.json');
let hooksDir: string;
if (opts.hooksDir) {
hooksDir = resolve(opts.hooksDir);
} else if (opts.moduleUrl) {
hooksDir = hooksDirFromModuleUrl(opts.moduleUrl);
} else {
// Fallback for ad-hoc test use — install.ts always passes moduleUrl.
hooksDir = resolve(process.cwd(), 'dist', 'hooks');
}
return { hermesDir, configPath, pointerPath, hooksDir };
}
/** Re-export the shared Windows-safe helpers so hermes modules read like CC. */
export { backupPathFor, hookCommandFor };

View File

@@ -0,0 +1,79 @@
/**
* Programmatic uninstall entry point for the Hermes hive-mind hooks.
*
* Round-trip guarantee:
* - `created_by_us=false` (config.yaml pre-existed): restore the LITERAL
* byte-identical backup the installer wrote; refuse to delete the backup
* unless the in-place readback matches. (YAML re-serialization is lossy,
* so we restore the original BYTES — comments and key ordering are
* preserved exactly because we never touch the re-serialized merge on
* this path.)
* - `created_by_us=true` (we created config.yaml): delete the file we
* created — never orphan it, never leave a backup behind.
*
* Both branches are handled by the shared `restoreFromBackup` primitive.
*/
import { unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import {
readPointer,
restoreFromBackup,
} from '@waggle/hive-mind-hooks-core';
import { resolvePaths, type HermesPaths, type ResolvePathsOptions } from './paths.js';
export interface UninstallResult {
paths: HermesPaths;
/** Backup restored from, or null when we deleted a file we created. */
restoredFrom: string | null;
/** True when created_by_us=true and we removed the config we created. */
createdRemoved: boolean;
pointerRemoved: boolean;
backupRemoved: boolean;
}
export interface UninstallOptions extends ResolvePathsOptions {
logger?: Logger;
/** If false, leaves the backup file in place after restore. Default true. */
cleanupBackup?: boolean;
}
export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
const log = opts.logger ?? createLogger({ name: 'hermes-hooks/uninstall' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
});
const cleanup = opts.cleanupBackup ?? true;
if (!existsSync(paths.pointerPath)) {
throw new Error(
`no install pointer found at ${paths.pointerPath}. ` +
`Was @waggle/hive-mind-hooks-hermes ever installed for this user?`,
);
}
const pointer = await readPointer(paths.pointerPath);
const restore = await restoreFromBackup({
configPath: paths.configPath,
pointer,
cleanupBackup: cleanup,
});
if (restore.createdRemoved) {
log.info('hermes config.yaml removed (created by us)', { config: paths.configPath });
} else {
log.info('hermes config.yaml restored byte-identical', { config: paths.configPath });
}
await unlink(paths.pointerPath);
return {
paths,
restoredFrom: restore.restoredFrom,
createdRemoved: restore.createdRemoved,
pointerRemoved: true,
backupRemoved: restore.backupRemoved,
};
}

View File

@@ -0,0 +1,162 @@
/**
* Smoke-check the Hermes install: config.yaml exists + parses + carries
* hive-mind entries that reference live hook scripts, and hive-mind-cli
* answers a `--help` probe. Plus the hermes-specific consent advisory
* (spec §5.4 / §6.2): under headless / non-TTY launches the hooks register
* only if `hooks_auto_accept: true` (or `HERMES_ACCEPT_HOOKS=1`) is set,
* otherwise Hermes's first-use consent allow-list silently never registers
* them.
*
* Probe priority for `cli_path`:
* 1. Explicit `opts.cliPath` (caller override)
* 2. `cli_path` recorded in `~/.hermes/hive-mind-install.json`
* 3. Bare `'hive-mind-cli'` on PATH
*/
import { readFile, access } from 'node:fs/promises';
import { constants, existsSync } from 'node:fs';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import { resolvePaths, allHookBasenames, type ResolvePathsOptions } from './paths.js';
import { parseConfig, hasHiveEntries } from './yaml-merger.js';
export interface VerifyCheck {
name: string;
ok: boolean;
detail?: string;
}
export interface VerifyResult {
ok: boolean;
checks: VerifyCheck[];
}
export interface VerifyOptions extends ResolvePathsOptions {
logger?: Logger;
/** Override hive-mind-cli executable name. Default 'hive-mind-cli'. */
cliPath?: string;
/** Test hook for spawn. */
spawnImpl?: typeof spawn;
}
async function fileReadable(p: string): Promise<boolean> {
try { await access(p, constants.R_OK); return true; } catch { return false; }
}
function isJsPath(p: string): boolean {
return p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs');
}
function probeCliVersion(
cliPath: string,
spawnImpl: typeof spawn,
timeoutMs: number,
): Promise<{ ok: boolean; output: string }> {
return new Promise((resolve) => {
let settled = false;
const command = isJsPath(cliPath) ? process.execPath : cliPath;
const args = isJsPath(cliPath) ? [cliPath, '--help'] : ['--help'];
const child = spawnImpl(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { child.kill('SIGTERM'); } catch { /* gone */ }
resolve({ ok: false, output: 'timed out probing hive-mind-cli' });
}, timeoutMs);
child.stdout?.on('data', (c: Buffer) => stdout.push(c));
child.stderr?.on('data', (c: Buffer) => stderr.push(c));
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: false, output: err instanceof Error ? err.message : String(err) });
});
child.on('exit', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
const out = Buffer.concat(stdout).toString('utf-8').slice(0, 200);
const err = Buffer.concat(stderr).toString('utf-8').slice(0, 200);
resolve({ ok: code === 0, output: code === 0 ? out : err });
});
});
}
export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
const log = opts.logger ?? createLogger({ name: 'hermes-hooks/verify' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
});
const checks: VerifyCheck[] = [];
// 1. config.yaml exists and parses.
if (!existsSync(paths.configPath)) {
checks.push({ name: 'config.yaml exists', ok: false, detail: paths.configPath });
return { ok: false, checks };
}
checks.push({ name: 'config.yaml exists', ok: true, detail: paths.configPath });
let parsed: Record<string, unknown>;
try {
parsed = parseConfig(await readFile(paths.configPath, 'utf-8'));
checks.push({ name: 'config.yaml parses as YAML', ok: true });
} catch (err) {
checks.push({
name: 'config.yaml parses as YAML',
ok: false,
detail: err instanceof Error ? err.message : String(err),
});
return { ok: false, checks };
}
// 2. hive-mind entries present in the hooks: block.
checks.push({
name: 'config.yaml contains hive-mind hook entries',
ok: hasHiveEntries(parsed),
});
// 3. each hive hook script exists on disk (3 scripts — no pre-compact).
for (const basename of allHookBasenames()) {
const scriptPath = join(paths.hooksDir, `${basename}.js`);
const ok = await fileReadable(scriptPath);
checks.push({ name: `${basename}.js readable on disk`, ok, detail: scriptPath });
}
// 4. consent advisory — headless launches need auto-accept or env.
const autoAccept = parsed['hooks_auto_accept'] === true;
checks.push({
name: 'headless consent configured',
ok: autoAccept,
detail: autoAccept
? 'hooks_auto_accept: true is set — hooks register under headless launch.'
: 'set hooks_auto_accept: true (or HERMES_ACCEPT_HOOKS=1) or hooks silently never register under headless / non-TTY launch.',
});
// 5. hive-mind-cli responds to --help (prefer install-pinned --cli-path).
let cliPathFromPointer: string | undefined;
if (existsSync(paths.pointerPath)) {
try {
const pointerObj = JSON.parse(await readFile(paths.pointerPath, 'utf-8')) as Record<string, unknown>;
const pointerCliPath = pointerObj['cli_path'];
if (typeof pointerCliPath === 'string' && pointerCliPath.length > 0) {
cliPathFromPointer = pointerCliPath;
}
} catch { /* pointer unreadable — fall through */ }
}
const cliPath = opts.cliPath ?? cliPathFromPointer ?? 'hive-mind-cli';
const spawnImpl = opts.spawnImpl ?? spawn;
const probe = await probeCliVersion(cliPath, spawnImpl, 4000);
checks.push({
name: 'hive-mind-cli reachable',
ok: probe.ok,
detail: cliPathFromPointer ? `${probe.output} (pinned: ${cliPath})` : probe.output,
});
const ok = checks.every((c) => c.ok);
log.info('verify complete', { ok, total: checks.length, failed: checks.filter((c) => !c.ok).length });
return { ok, checks };
}

View File

@@ -0,0 +1,186 @@
/**
* Bespoke YAML codec + additive register/unregister for the Hermes
* shell-hooks `config.yaml`.
*
* Hermes's hook config is NOT JSON — it is a top-level `hooks:` block in
* `~/.hermes/config.yaml`, so it cannot use the shared `jsonRegister`
* helper. This module is the hermes-only equivalent: parse/serialize via
* the `yaml` dep, and additively merge marker-tagged hive entries into the
* `hooks:` block while preserving the user's existing entries verbatim.
*
* Reversibility note: YAML re-serialization is NOT byte-identical (comments
* and key ordering are lost on round-trip), so uninstall does NOT rely on
* re-serializing a diff. Instead the installer writes a LITERAL
* byte-identical backup of the original `config.yaml` and uninstall restores
* those exact bytes (see install-core `backupByteIdentical` /
* `restoreFromBackup`). This module only produces the merged YAML we WRITE
* on install; the marker just lets re-install dedup our own entries in
* place rather than duplicating them.
*
* Marker strategy: YAML comments do not survive a parse→serialize round
* trip, so the marker is a STRUCTURAL sentinel key (`_hive_mind`) stamped on
* each entry we add, backed up by a recognizable command-path prefix. We
* detect our own entries by the sentinel; dedup matches on (eventKey,
* command).
*
* Immutability: every function returns a NEW config object and never
* mutates its input (mirrors the `jsonRegister` contract).
*/
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
/** Structural marker stamped on every hook entry we add. */
export const HIVE_MIND_MARKER = '@hive-mind/hermes-hooks';
/** Top-level YAML key holding the shell-hooks map. */
export const HOOKS_KEY = 'hooks';
/**
* A single Hermes shell-hook entry. Hermes honors `command` + `timeout`
* on lifecycle events; `matcher` is honored ONLY on pre/post_tool_call and
* is stripped-with-warning elsewhere, so we never set it on our events.
*/
export interface HermesHookEntry {
command: string;
timeout?: number;
/** Structural marker so re-install/uninstall can find our own entries. */
_hive_mind?: string;
}
/** One entry to register: which native event key, plus the command + timeout. */
export interface HermesRegisterEntry {
/** Native Hermes event key, e.g. 'on_session_start' / 'pre_llm_call' / 'post_llm_call'. */
eventKey: string;
command: string;
timeout: number;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function asEntryArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? (value as Record<string, unknown>[]) : [];
}
/** True iff a parsed hook entry carries our structural marker. */
export function isHiveEntry(entry: unknown): boolean {
const e = asRecord(entry);
return !!e && e['_hive_mind'] === HIVE_MIND_MARKER;
}
function entryCommand(entry: unknown): string | undefined {
const e = asRecord(entry);
const cmd = e?.['command'];
return typeof cmd === 'string' ? cmd : undefined;
}
/**
* Parse a `config.yaml` string into a plain object. Returns `{}` for an
* empty/whitespace string (create-if-missing). Throws on malformed YAML so
* the installer fails loudly rather than silently clobbering a user config.
*/
export function parseConfig(raw: string): Record<string, unknown> {
if (!raw || raw.trim().length === 0) return {};
let parsed: unknown;
try {
parsed = parseYaml(raw);
} catch (err) {
throw new Error(
'failed to parse ~/.hermes/config.yaml as YAML: ' +
(err instanceof Error ? err.message : String(err)),
);
}
const record = asRecord(parsed);
// A top-level scalar/array YAML doc is not a valid hermes config shape;
// treat it as empty rather than crashing (the original bytes are backed
// up regardless, so nothing is lost).
return record ?? {};
}
/** Serialize a config object back to YAML text (trailing newline). */
export function serializeConfig(config: Record<string, unknown>): string {
const text = stringifyYaml(config);
return text.endsWith('\n') ? text : text + '\n';
}
/**
* Returns a NEW config object with hive-mind shell-hook entries registered
* under each supplied native event key. Existing non-hive entries are
* preserved verbatim. A hive entry for the same (eventKey, command) is
* replaced in place rather than duplicated, so re-running install upgrades
* cleanly.
*/
export function yamlRegister(
config: Record<string, unknown> | undefined,
entries: readonly HermesRegisterEntry[],
): Record<string, unknown> {
const next: Record<string, unknown> = config ? { ...config } : {};
const existingHooks = asRecord(next[HOOKS_KEY]);
const nextHooks: Record<string, unknown> = existingHooks ? { ...existingHooks } : {};
for (const entry of entries) {
const arr = asEntryArray(nextHooks[entry.eventKey]).slice();
const newEntry: HermesHookEntry = {
command: entry.command,
timeout: entry.timeout,
_hive_mind: HIVE_MIND_MARKER,
};
let replaced = false;
for (let i = 0; i < arr.length; i += 1) {
if (isHiveEntry(arr[i]) && entryCommand(arr[i]) === entry.command) {
arr[i] = newEntry as unknown as Record<string, unknown>;
replaced = true;
break;
}
}
if (!replaced) arr.push(newEntry as unknown as Record<string, unknown>);
nextHooks[entry.eventKey] = arr;
}
next[HOOKS_KEY] = nextHooks;
return next;
}
/**
* Returns a NEW config object with all marker-tagged hive entries stripped
* from every event array. Non-hive entries are preserved verbatim; empty
* event arrays are left in place (minimal-touch). Never mutates input.
*
* Note: uninstall normally restores the literal byte-identical backup, so
* this is used for verify/diagnostics and for the (rare) backup-less path.
*/
export function yamlUnregister(
config: Record<string, unknown> | undefined,
): Record<string, unknown> {
const next: Record<string, unknown> = config ? { ...config } : {};
const existingHooks = asRecord(next[HOOKS_KEY]);
if (!existingHooks) return next;
const nextHooks: Record<string, unknown> = {};
for (const [eventKey, value] of Object.entries(existingHooks)) {
const arr = asEntryArray(value);
if (arr.length === 0) {
nextHooks[eventKey] = value;
continue;
}
nextHooks[eventKey] = arr.filter((e) => !isHiveEntry(e));
}
next[HOOKS_KEY] = nextHooks;
return next;
}
/** True iff at least one event array carries a marker-tagged hive entry. */
export function hasHiveEntries(config: Record<string, unknown> | undefined): boolean {
if (!config) return false;
const hooks = asRecord(config[HOOKS_KEY]);
if (!hooks) return false;
for (const value of Object.values(hooks)) {
if (asEntryArray(value).some((e) => isHiveEntry(e))) return true;
}
return false;
}

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
HERMES_EVENT_NAME,
HERMES_SESSION_START_OBSERVE_EVENT,
hermesAdapter,
} from '../src/adapter.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, '..', 'src');
describe('HERMES_EVENT_NAME (lifecycle → native event key)', () => {
it('maps the three live lifecycles to snake_case Hermes events; pre-compact is undefined', () => {
expect(HERMES_EVENT_NAME['session-start']).toBe('pre_llm_call');
expect(HERMES_EVENT_NAME['user-prompt-submit']).toBe('pre_llm_call');
expect(HERMES_EVENT_NAME['stop']).toBe('post_llm_call');
// CONFIRMED ABSENT in source — Hermes ships no compaction hook.
expect(HERMES_EVENT_NAME['pre-compact']).toBeUndefined();
});
it('exposes the observer-only SessionStart key registered alongside pre_llm_call', () => {
expect(HERMES_SESSION_START_OBSERVE_EVENT).toBe('on_session_start');
});
});
describe('hermesAdapter field extraction (reads from the `extra` envelope)', () => {
const payload = {
extra: {
cwd: '/work/dir',
session_id: 'sess-42',
user_message: 'the user prompt',
assistant_response: 'the assistant reply',
parent_frame_id: 'frame-99',
},
};
it('source is hermes', () => {
expect(hermesAdapter.source).toBe('hermes');
});
it('extractCwd / extractSessionId / extractPrompt read from extra', () => {
expect(hermesAdapter.extractCwd(payload)).toBe('/work/dir');
expect(hermesAdapter.extractSessionId(payload)).toBe('sess-42');
expect(hermesAdapter.extractPrompt(payload)).toBe('the user prompt');
});
it('extractResponse / extractParent read from extra', async () => {
expect(await hermesAdapter.extractResponse(payload, {})).toBe('the assistant reply');
expect(hermesAdapter.extractParent(payload)).toBe('frame-99');
});
it('falls back to top-level keys when the extra envelope is absent (forward-compat)', () => {
const flat = { cwd: '/c', session_id: 's', user_message: 'p', parent_frame_id: 'pp' };
expect(hermesAdapter.extractCwd(flat)).toBe('/c');
expect(hermesAdapter.extractSessionId(flat)).toBe('s');
expect(hermesAdapter.extractPrompt(flat)).toBe('p');
expect(hermesAdapter.extractParent(flat)).toBe('pp');
});
it('formatInject produces the { context } shape (appended to the user message)', () => {
expect(hermesAdapter.formatInject?.('recalled frames here')).toEqual({ context: 'recalled frames here' });
});
it('returns undefined for missing fields rather than throwing', () => {
expect(hermesAdapter.extractCwd({})).toBeUndefined();
expect(hermesAdapter.extractSessionId({})).toBeUndefined();
expect(hermesAdapter.extractPrompt({})).toBeUndefined();
expect(hermesAdapter.extractParent({})).toBeUndefined();
});
});
describe('NO pre-compact entrypoint exists (structural invariant)', () => {
it('ships exactly the three hook entrypoints — session-start / user-prompt-submit / stop', () => {
expect(existsSync(join(SRC, 'hooks', 'session-start.ts'))).toBe(true);
expect(existsSync(join(SRC, 'hooks', 'user-prompt-submit.ts'))).toBe(true);
expect(existsSync(join(SRC, 'hooks', 'stop.ts'))).toBe(true);
// There is genuinely nothing to hook for compaction — no entrypoint.
expect(existsSync(join(SRC, 'hooks', 'pre-compact.ts'))).toBe(false);
});
});

View File

@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
compactStatePath,
isCompactEnabled,
maybeCompactOnStop,
readLastCompactTs,
resolveWindowMs,
writeLastCompactTs,
} from '../src/compact-on-stop.js';
import { makeMockBridge } from './hooks/_test-helpers.js';
import type { HookContext } from '@waggle/hive-mind-hooks-core';
import type { Logger } from '@waggle/hive-mind-shim-core';
import type { MockBridge } from './hooks/_test-helpers.js';
const FLAG = 'WAGGLE_HERMES_COMPACT_ON_STOP';
const WINDOW_ENV = 'WAGGLE_HERMES_COMPACT_WINDOW_MIN';
const MINUTE_MS = 60_000;
function makeLogger(): Logger {
return {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
} as unknown as Logger;
}
function makeCtx(bridge: MockBridge): HookContext {
return { bridge, logger: makeLogger() };
}
describe('compact-on-stop unit', () => {
let home: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'hmher-compact-'));
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(home, { recursive: true, force: true });
});
// case 1
it('flag off → cleanupFrames NOT called and no state file written', async () => {
vi.stubEnv(FLAG, '');
const bridge = makeMockBridge();
await maybeCompactOnStop(makeCtx(bridge), { now: () => 1_000_000, home });
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
const last = await readLastCompactTs(compactStatePath(home));
expect(last).toBeUndefined();
});
// case 2
it('flag on, no prior timestamp → cleanupFrames called once and state file holds now', async () => {
vi.stubEnv(FLAG, '1');
const bridge = makeMockBridge();
const now = 5_000_000;
await maybeCompactOnStop(makeCtx(bridge), { now: () => now, home });
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
const last = await readLastCompactTs(compactStatePath(home));
expect(last).toBe(now);
});
// case 3
it('flag on, last = now - 1min, window 10min → NOT called (inside window)', async () => {
vi.stubEnv(FLAG, '1');
const bridge = makeMockBridge();
const now = 10_000_000;
await writeLastCompactTs(compactStatePath(home), now - 1 * MINUTE_MS);
await maybeCompactOnStop(makeCtx(bridge), { now: () => now, home, windowMs: 10 * MINUTE_MS });
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
// timestamp untouched
const last = await readLastCompactTs(compactStatePath(home));
expect(last).toBe(now - 1 * MINUTE_MS);
});
// case 4
it('flag on, last = now - 11min, window 10min → called and timestamp updated to now', async () => {
vi.stubEnv(FLAG, '1');
const bridge = makeMockBridge();
const now = 20_000_000;
await writeLastCompactTs(compactStatePath(home), now - 11 * MINUTE_MS);
await maybeCompactOnStop(makeCtx(bridge), { now: () => now, home, windowMs: 10 * MINUTE_MS });
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
const last = await readLastCompactTs(compactStatePath(home));
expect(last).toBe(now);
});
// case 5
it('flag on, cleanupFrames rejects → resolves (no throw) and state file NOT updated', async () => {
vi.stubEnv(FLAG, '1');
const bridge = makeMockBridge();
bridge.cleanupFrames.mockRejectedValueOnce(new Error('cli unreachable'));
const now = 30_000_000;
await expect(
maybeCompactOnStop(makeCtx(bridge), { now: () => now, home }),
).resolves.toBeUndefined();
const last = await readLastCompactTs(compactStatePath(home));
expect(last).toBeUndefined();
});
// case 6
it('flag on, state-file write fails (home is a FILE) → resolves, no throw, cleanupFrames still attempted', async () => {
vi.stubEnv(FLAG, '1');
const homeFile = join(home, 'home-as-file');
await writeFile(homeFile, 'not a dir');
const bridge = makeMockBridge();
const now = 40_000_000;
await expect(
maybeCompactOnStop(makeCtx(bridge), { now: () => now, home: homeFile }),
).resolves.toBeUndefined();
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
});
// case 7
it('WAGGLE_HERMES_COMPACT_WINDOW_MIN=5 honored; compactWindowMs opt overrides env', () => {
vi.stubEnv(WINDOW_ENV, '5');
expect(resolveWindowMs(process.env)).toBe(5 * MINUTE_MS);
// opt override wins over env
expect(resolveWindowMs(process.env, 2 * MINUTE_MS)).toBe(2 * MINUTE_MS);
});
it('resolveWindowMs default is 10min when env unset/invalid', () => {
expect(resolveWindowMs({})).toBe(600_000);
expect(resolveWindowMs({ [WINDOW_ENV]: 'garbage' })).toBe(600_000);
expect(resolveWindowMs({ [WINDOW_ENV]: '0' })).toBe(600_000);
expect(resolveWindowMs({ [WINDOW_ENV]: '-3' })).toBe(600_000);
});
// case 8
it('isCompactEnabled truth table', () => {
expect(isCompactEnabled({})).toBe(false);
expect(isCompactEnabled({ [FLAG]: '' })).toBe(false);
expect(isCompactEnabled({ [FLAG]: '0' })).toBe(false);
expect(isCompactEnabled({ [FLAG]: 'false' })).toBe(false);
expect(isCompactEnabled({ [FLAG]: 'FALSE' })).toBe(false);
expect(isCompactEnabled({ [FLAG]: '1' })).toBe(true);
expect(isCompactEnabled({ [FLAG]: 'true' })).toBe(true);
expect(isCompactEnabled({ [FLAG]: 'yes' })).toBe(true);
});
it('compactStatePath lives under the hermes dir', () => {
const p = compactStatePath(home);
expect(p).toBe(join(home, '.hermes', '.hive-mind-last-compact'));
});
it('readLastCompactTs returns undefined on garbage content', async () => {
const p = compactStatePath(home);
await writeLastCompactTs(p, 123);
// overwrite with garbage
await writeFile(p, 'not-a-number');
expect(await readLastCompactTs(p)).toBeUndefined();
});
it('readLastCompactTs returns undefined when file is missing', async () => {
expect(await readLastCompactTs(join(home, 'does-not-exist'))).toBeUndefined();
});
it('writeLastCompactTs mkdirs the parent recursively then writes', async () => {
const p = compactStatePath(home); // parent .hermes does not exist yet
await writeLastCompactTs(p, 777);
const raw = await readFile(p, 'utf-8');
expect(raw).toBe('777');
});
});

View File

@@ -0,0 +1,62 @@
import { vi } from 'vitest';
import type { CliBridge, MemoryHit } from '@waggle/hive-mind-shim-core';
export interface MockBridgeOverrides {
saveMemoryResult?: { id: string; success: boolean; workspace: string };
recallMemoryHits?: MemoryHit[];
cleanupFramesResult?: { pruned: number };
saveMemoryThrows?: Error;
}
export interface MockBridge extends CliBridge {
saveMemory: ReturnType<typeof vi.fn>;
recallMemory: ReturnType<typeof vi.fn>;
cleanupFrames: ReturnType<typeof vi.fn>;
callMcpTool: ReturnType<typeof vi.fn>;
setWorkspaceById: ReturnType<typeof vi.fn>;
getActiveWorkspaceId: ReturnType<typeof vi.fn>;
}
/**
* Mirrors the cursor sibling test helper (tests/hooks/_test-helpers.ts). The
* hermes hooks drive the SAME shared `runHook` contract, so the same
* injectable CliBridge mock + stdout/exit captures apply unchanged.
*/
export function makeMockBridge(overrides: MockBridgeOverrides = {}): MockBridge {
let activeWorkspaceId: string | undefined;
const saveMemory = overrides.saveMemoryThrows
? vi.fn(async () => { throw overrides.saveMemoryThrows; })
: vi.fn(async () => overrides.saveMemoryResult ?? { id: 'frame-1', success: true, workspace: 'personal' });
const recallMemory = vi.fn(async () => overrides.recallMemoryHits ?? []);
const cleanupFrames = vi.fn(async () => overrides.cleanupFramesResult ?? { pruned: 0 });
const callMcpTool = vi.fn(async () => ({}));
const setWorkspaceById = vi.fn((id?: string) => { activeWorkspaceId = id; });
const getActiveWorkspaceId = vi.fn(() => activeWorkspaceId);
return {
saveMemory,
recallMemory,
cleanupFrames,
callMcpTool,
setWorkspaceById,
getActiveWorkspaceId,
} as unknown as MockBridge;
}
export interface CapturedHookOutput {
stdout: string[];
exits: number[];
}
export function makeHookCaptures(): CapturedHookOutput & {
writeStdout: (s: string) => void;
exit: (code: number) => void;
} {
const stdout: string[] = [];
const exits: number[] = [];
return {
stdout,
exits,
writeStdout: (s) => stdout.push(s),
exit: (c) => exits.push(c),
};
}

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import { runSessionStart } from '../../src/hooks/session-start.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
import type { MemoryHit } from '@waggle/hive-mind-shim-core';
const HIT_FIXTURE: MemoryHit = {
id: 1,
content: '[hm src:hermes event:stop] past observation',
importance: 'important',
source: 'system',
score: 0.87,
created_at: '2026-04-28T10:00:00.000Z',
from: 'personal',
};
describe('hermes session-start handler (split: on_session_start observer + pre_llm_call inject)', () => {
it('INJECT path: recalls personal-scoped frames and emits { context } (hermes rename) when is_first_turn is absent', async () => {
// The on_session_start observer payload omits is_first_turn → absence ⇒ inject.
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => JSON.stringify({ extra: { cwd: '/proj/x' }, recall_limit: 1 }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 1, scope: 'personal', workspace: null });
expect(cap.stdout).toHaveLength(1);
const parsed = JSON.parse(cap.stdout[0]) as Record<string, unknown>;
// Hermes appends pre_llm_call stdout { context } to the USER message (not the
// system prompt — preserves the prefix cache). Assert the rename + that the
// default CC hookSpecificOutput envelope is NOT used.
expect(parsed['hookSpecificOutput']).toBeUndefined();
expect(typeof parsed['context']).toBe('string');
expect(parsed['context'] as string).toContain('past observation');
expect(cap.exits).toEqual([0]);
});
it('INJECT path: explicit is_first_turn=true still injects { context }', async () => {
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => JSON.stringify({ extra: { is_first_turn: true } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.recallMemory).toHaveBeenCalledTimes(1);
const parsed = JSON.parse(cap.stdout[0]) as Record<string, unknown>;
expect(typeof parsed['context']).toBe('string');
expect(cap.exits).toEqual([0]);
});
it('GATING: is_first_turn=false → emits NO output, exits 0, never recalls', async () => {
// On a non-first pre_llm_call the per-turn save is owned by user-prompt-submit;
// session-start must do nothing (drain the pipe + exit 0).
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => JSON.stringify({ extra: { is_first_turn: false, user_message: 'turn 2' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.recallMemory).not.toHaveBeenCalled();
expect(cap.stdout).toHaveLength(0);
expect(cap.exits).toEqual([0]);
});
it('handles an empty recall result gracefully (still { context })', async () => {
const bridge = makeMockBridge({ recallMemoryHits: [] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const parsed = JSON.parse(cap.stdout[0]) as { context: string };
expect(parsed.context).toContain('no recalled frames');
expect(cap.exits).toEqual([0]);
});
it('annotates hits with their workspace origin when from != personal', async () => {
const bridge = makeMockBridge({ recallMemoryHits: [{ ...HIT_FIXTURE, from: 'workspace:team-foo' }] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const parsed = JSON.parse(cap.stdout[0]) as { context: string };
expect(parsed.context).toContain('workspace:team-foo');
});
it('FAIL-OPEN: exits 0 even when the bridge throws', async () => {
const bridge = makeMockBridge();
bridge.recallMemory.mockRejectedValueOnce(new Error('cli unreachable'));
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: malformed stdin → no recall, exits 0', async () => {
// safeJsonParse turns garbage into {} → absence of is_first_turn ⇒ inject path,
// but the recall still runs against the empty payload and must exit 0.
const bridge = makeMockBridge({ recallMemoryHits: [] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => 'not json at all {{{',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: a throwing stdin reader on the pre-gate path still exits 0', async () => {
// The is_first_turn gate reads stdin BEFORE runHook; a rejecting reader
// must not escape to the host (it would otherwise block the session).
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => { throw new Error('stdin exploded'); },
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
expect(bridge.recallMemory).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runStop } from '../../src/hooks/stop.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
import type { HookFrame } from '@waggle/hive-mind-shim-core';
describe('hermes stop handler (post_llm_call — assistant_response in extra)', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('summarizes the completed turn off extra.assistant_response and saves an important frame', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
extra: {
assistant_response: 'Here is the answer to your question about X. It depends on the config.',
cwd: '/proj/foo',
conversation_id: 'conv-9',
},
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.source).toBe('hermes');
expect(frame.scope).toBe('conv-9');
expect(['important', 'critical']).toContain(frame.importance);
expect(frame.content.length).toBeGreaterThan(0);
expect(cap.exits).toEqual([0]);
});
it('SAVE-ONLY: emits NO stdout (Hermes block/inject is stdout JSON, not exit codes)', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'done', session_id: 's' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.stdout).toHaveLength(0);
expect(cap.exits).toEqual([0]);
});
it('TOLERATE-NULL: no assistant_response → no save, NO throw, exits 0', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ extra: { session_id: 'conv-2' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('reads the top-level response fallback when extra.assistant_response is absent', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ response: 'inline assistant message', session_id: 's' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.content.length).toBeGreaterThan(0);
});
it('links the Stop frame to its parent prompt frame when known (extra.parent_frame_id)', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
extra: {
assistant_response: 'done with the task',
parent_frame_id: 'frame-prompt-1',
session_id: 's',
},
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.parent).toBe('frame-prompt-1');
});
it('does NOT emit a discovery signal by default (WAGGLE_SIGNAL_EMIT off)', async () => {
vi.stubEnv('WAGGLE_SIGNAL_EMIT', '');
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'hi there', session_id: 's' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.callMcpTool).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: exits 0 even when saveMemory rejects', async () => {
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli unreachable') });
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'some answer', session_id: 's' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: malformed stdin → no save, exits 0', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => '%%% not json %%%',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
});
describe('hermes stop handler — opt-in compact-on-stop (OQ-4)', () => {
let home: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'hmher-stop-compact-'));
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(home, { recursive: true, force: true });
});
// case 9
it('DEFAULT-OFF: flag unset → save happens AND cleanupFrames NOT called', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'an answer', session_id: 's' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
now: () => 1_000_000,
home,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
// case 10
it('flag on, eligible → save happens AND cleanupFrames called, save BEFORE cleanup', async () => {
vi.stubEnv('WAGGLE_HERMES_COMPACT_ON_STOP', '1');
const order: string[] = [];
const bridge = makeMockBridge();
bridge.saveMemory.mockImplementation(async () => {
order.push('save');
return { id: 'frame-1', success: true, workspace: 'personal' };
});
bridge.cleanupFrames.mockImplementation(async () => {
order.push('cleanup');
return { pruned: 0 };
});
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'an answer', session_id: 's' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
now: () => 2_000_000,
home,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
expect(order).toEqual(['save', 'cleanup']);
expect(cap.exits).toEqual([0]);
});
// case 11
it('flag on, eligible, cleanupFrames rejects → exits 0 and saveMemory still called once', async () => {
vi.stubEnv('WAGGLE_HERMES_COMPACT_ON_STOP', '1');
const bridge = makeMockBridge();
bridge.cleanupFrames.mockRejectedValueOnce(new Error('cli unreachable'));
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'an answer', session_id: 's' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
now: () => 3_000_000,
home,
});
expect(cap.exits).toEqual([0]);
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
});
// case 12
it('flag on, no assistant_response (no save) → cleanupFrames still gate-eligible and runs, exits 0', async () => {
vi.stubEnv('WAGGLE_HERMES_COMPACT_ON_STOP', '1');
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ extra: { session_id: 's' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
now: () => 4_000_000,
home,
});
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
expect(cap.exits).toEqual([0]);
});
// case 13 — save-before-compact ordering lock (flag ON)
it('SAVE-FIRST: flag on but saveMemory rejects → cleanupFrames NOT called, exits 0', async () => {
vi.stubEnv('WAGGLE_HERMES_COMPACT_ON_STOP', '1');
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli fail') });
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'an answer', session_id: 's' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
now: () => 5_000_000,
home,
});
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { runUserPromptSubmit } from '../../src/hooks/user-prompt-submit.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
import type { HookFrame } from '@waggle/hive-mind-shim-core';
describe('hermes user-prompt-submit handler (SAVE-ONLY — pre_llm_call)', () => {
it('saves a temporary, hermes-sourced frame containing the prompt from extra.user_message', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({
extra: {
user_message: 'How do I X?',
cwd: '/proj/foo',
conversation_id: 'conv-7',
},
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame).toMatchObject({
content: 'How do I X?',
importance: 'temporary',
scope: 'conv-7',
source: 'hermes',
});
expect(cap.exits).toEqual([0]);
});
it('SAVE-ONLY: emits NO stdout (this hook cannot inject)', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ extra: { user_message: 'hi', session_id: 's1' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
// The shared UserPromptSubmit body returns undefined → runHook writes nothing.
expect(cap.stdout).toHaveLength(0);
expect(cap.exits).toEqual([0]);
});
it('reads the top-level prompt fallback when extra.user_message is absent', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ prompt: 'top-level prompt', session_id: 's2' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.content).toBe('top-level prompt');
expect(frame.source).toBe('hermes');
});
it('skips the save when no prompt is present', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ extra: { session_id: 's3' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: exits 0 even if saveMemory rejects', async () => {
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli down') });
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ extra: { user_message: 'x' } }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: malformed stdin → no save, exits 0', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => '<<<not json>>>',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,217 @@
import { describe, expect, it, afterEach } from 'vitest';
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { parse as parseYaml } from 'yaml';
import { install } from '../src/install.js';
import { HIVE_MIND_MARKER } from '../src/yaml-merger.js';
interface TestEnv {
home: string;
hooksDir: string;
configPath: string;
pointerPath: string;
}
/** Hermes config.yaml is OPTIONAL — `initial=undefined` exercises create-if-missing. */
async function bootstrap(initialYaml: string | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmher-install-'));
const hermesDir = join(home, '.hermes');
await mkdir(hermesDir, { recursive: true });
const configPath = join(hermesDir, 'config.yaml');
if (initialYaml !== undefined) {
await writeFile(configPath, initialYaml, 'utf-8');
}
const hooksDir = resolve(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
return { home, hooksDir, configPath, pointerPath: join(hermesDir, 'hive-mind-install.json') };
}
function readPointer(p: string): Promise<Record<string, unknown>> {
return readFile(p, 'utf-8').then((s) => JSON.parse(s) as Record<string, unknown>);
}
function hooksOf(config: Record<string, unknown>): Record<string, Array<Record<string, unknown>>> {
return config['hooks'] as Record<string, Array<Record<string, unknown>>>;
}
describe('install (hermes)', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws on malformed YAML in an existing config.yaml', async () => {
env = await bootstrap('hooks:\n\t- : : :\n bad');
await expect(install({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/parse/i);
});
// ── pre-existed branch ────────────────────────────────────────────────
it('writes a LITERAL byte-identical backup before mutating a pre-existing config.yaml', async () => {
// Comments + ordering that a YAML round-trip would NOT preserve — proves
// the backup is the original bytes, not a re-serialized merge.
const initial = '# my hermes config\nmodel: opus\nhooks:\n pre_llm_call:\n - command: node /existing/x.js\n timeout: 10\n';
env = await bootstrap(initial);
const original = await readFile(env.configPath, 'utf-8');
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.backupPath).not.toBeNull();
const backupContent = await readFile(result.backupPath as string, 'utf-8');
expect(backupContent).toBe(original);
// The backup preserves the comment that YAML re-serialization drops.
expect(backupContent).toContain('# my hermes config');
});
it('records created_by_us=false + settings_backup when config.yaml pre-existed', async () => {
env = await bootstrap('model: opus\nhooks: {}\n');
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.createdByUs).toBe(false);
const pointer = await readPointer(result.pointerPath);
expect(pointer['created_by_us']).toBe(false);
expect(pointer['settings_backup']).toBe(result.backupPath);
});
it('additively merges hive entries + preserves the user hook entry + user top-level keys', async () => {
const initial = 'model: opus\nhooks:\n pre_llm_call:\n - command: node /existing/x.js\n timeout: 10\n';
env = await bootstrap(initial);
await install({ home: env.home, hooksDir: env.hooksDir });
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
const hooks = hooksOf(after);
// pre_llm_call now holds the user lint hook + 2 hive entries (session-start inject + user-prompt).
expect(hooks['pre_llm_call']).toHaveLength(3);
expect(hooks['pre_llm_call'][0]['command']).toBe('node /existing/x.js');
expect(hooks['pre_llm_call'][0]['_hive_mind']).toBeUndefined();
const hiveEntries = hooks['pre_llm_call'].filter((e) => e['_hive_mind'] === HIVE_MIND_MARKER);
expect(hiveEntries).toHaveLength(2);
// The split SessionStart observer + the Stop entry are present.
expect(hooks['on_session_start']).toHaveLength(1);
expect(hooks['post_llm_call']).toHaveLength(1);
// User top-level key preserved.
expect(after['model']).toBe('opus');
});
// ── create-if-missing branch ──────────────────────────────────────────
it('creates config.yaml with the hive hooks when it is absent', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(env.configPath)).toBe(true);
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
const hooks = hooksOf(after);
expect(Object.keys(hooks).sort()).toEqual(['on_session_start', 'post_llm_call', 'pre_llm_call']);
expect(result.createdByUs).toBe(true);
});
it('records created_by_us=true and writes NO backup when config.yaml is absent', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.backupPath).toBeNull();
const pointer = await readPointer(result.pointerPath);
expect(pointer['created_by_us']).toBe(true);
expect(pointer['settings_backup']).toBeNull();
});
// ── auto-accept consent allow-list ────────────────────────────────────
it('seeds hooks_auto_accept: true by default (headless consent allow-list)', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.autoAcceptSeeded).toBe(true);
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
expect(after['hooks_auto_accept']).toBe(true);
});
it('does NOT seed auto-accept when autoAccept=false (--no-auto-accept)', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, hooksDir: env.hooksDir, autoAccept: false });
expect(result.autoAcceptSeeded).toBe(false);
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
expect(after['hooks_auto_accept']).toBeUndefined();
});
it('does not re-seed auto-accept when the user already set it true', async () => {
env = await bootstrap('hooks_auto_accept: true\nhooks: {}\n');
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.autoAcceptSeeded).toBe(false);
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
expect(after['hooks_auto_accept']).toBe(true);
});
// ── pointer + events + cli-path ───────────────────────────────────────
it('drops a pointer file with installed_hooks (3, no pre-compact) + registered_events', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(result.pointerPath)).toBe(true);
const pointer = await readPointer(result.pointerPath);
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop']);
expect(typeof pointer['version']).toBe('string');
const extra = pointer['extra'] as Record<string, unknown>;
// Dedup'd native event keys across the four register entries.
expect((extra['registered_events'] as string[]).sort()).toEqual([
'on_session_start',
'post_llm_call',
'pre_llm_call',
]);
expect(extra['auto_accept_seeded']).toBe(true);
});
it('result.registeredEvents reports the 3 unique native keys', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect([...result.registeredEvents].sort()).toEqual([
'on_session_start',
'post_llm_call',
'pre_llm_call',
]);
});
it('clamps the per-hook timeout to the 300s hard cap', async () => {
env = await bootstrap(undefined);
await install({ home: env.home, hooksDir: env.hooksDir, hookTimeoutSeconds: 9999 });
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
const hooks = hooksOf(after);
expect(hooks['post_llm_call'][0]['timeout']).toBe(300);
});
it('respects a custom now() for a deterministic backup filename', async () => {
env = await bootstrap('hooks: {}\n');
const fixedTs = '2026-04-28T10:30:45.123Z';
const result = await install({
home: env.home,
hooksDir: env.hooksDir,
now: () => new Date(fixedTs),
});
expect(result.backupPath).toContain('hive-mind-backup.2026-04-28T10-30-45-123Z');
const stats = await stat(result.backupPath as string);
expect(stats.isFile()).toBe(true);
});
it('threads --cli-path into every generated hook command + records it in the pointer', async () => {
env = await bootstrap(undefined);
const cliPath = '/abs/path/to/dist/index.js';
const result = await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
expect(result.cliPath).toBe(cliPath);
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
const hooks = hooksOf(after);
expect(hooks['post_llm_call'][0]['command']).toContain(`--cli-path "${cliPath}"`);
expect(hooks['on_session_start'][0]['command']).toContain(`--cli-path "${cliPath}"`);
const pointer = await readPointer(result.pointerPath);
expect(pointer['cli_path']).toBe(cliPath);
});
it('rejects --cli-path values containing double-quote characters', async () => {
env = await bootstrap(undefined);
await expect(install({
home: env.home,
hooksDir: env.hooksDir,
cliPath: 'malicious" && rm -rf / "',
})).rejects.toThrow(/double-quote/);
});
});

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { join, resolve } from 'node:path';
import {
allHookBasenames,
backupPathFor,
hookCommandFor,
resolvePaths,
} from '../src/paths.js';
describe('resolvePaths (hermes)', () => {
it('places config.yaml + pointer under <home>/.hermes/', () => {
const home = resolve('/fake/home');
const paths = resolvePaths({ home, hooksDir: resolve('/some/dist/hooks') });
expect(paths.hermesDir).toBe(join(home, '.hermes'));
// Hermes targets the SHELL-HOOKS config.yaml (top-level `hooks:` block) —
// NOT the gateway dir-hooks (~/.hermes/hooks/<name>/) nor plugin hooks.
expect(paths.configPath).toBe(join(home, '.hermes', 'config.yaml'));
expect(paths.pointerPath).toBe(join(home, '.hermes', 'hive-mind-install.json'));
});
it('hooksDir override wins over moduleUrl', () => {
const explicit = resolve('/x/y/hooks');
const paths = resolvePaths({
home: resolve('/h'),
hooksDir: explicit,
moduleUrl: 'file:///irrelevant/dist/install.js',
});
expect(paths.hooksDir).toBe(explicit);
});
it('falls back to cwd/dist/hooks when neither moduleUrl nor hooksDir is given', () => {
const paths = resolvePaths({ home: resolve('/h') });
expect(paths.hooksDir).toBe(resolve(process.cwd(), 'dist', 'hooks'));
});
});
describe('hookCommandFor (hermes, 2-arg shared helper)', () => {
it('produces a quoted node invocation around the absolute script path', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'));
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
});
it('appends --cli-path when supplied', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), '/abs/cli/dist/index.js');
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
});
it('omits --cli-path when empty string is passed', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), '');
expect(cmd).not.toContain('--cli-path');
});
it('preserves Windows-style paths (with spaces) inside the quotes', () => {
const cmd = hookCommandFor('/abs/dist/hooks/stop.js', 'C:\\Program Files\\hive-mind\\dist\\index.js');
expect(cmd).toContain('--cli-path "C:\\Program Files\\hive-mind\\dist\\index.js"');
});
});
describe('backupPathFor (hermes)', () => {
it('replaces colons and dots in the timestamp for filesystem safety', () => {
const backup = backupPathFor('/h/.hermes/config.yaml', '2026-04-28T10:30:45.123Z');
expect(backup).toBe('/h/.hermes/config.yaml.hive-mind-backup.2026-04-28T10-30-45-123Z');
});
});
describe('allHookBasenames (hermes — THREE hooks only, NO pre-compact)', () => {
it('returns exactly the three canonical basenames (Hermes ships no compaction hook)', () => {
expect([...allHookBasenames()].sort()).toEqual([
'session-start',
'stop',
'user-prompt-submit',
]);
// Explicit: pre-compact is intentionally absent.
expect([...allHookBasenames()]).not.toContain('pre-compact');
});
});

View File

@@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest';
import { parse as parseYaml } from 'yaml';
import {
HIVE_MIND_MARKER,
HOOKS_KEY,
hasHiveEntries,
isHiveEntry,
parseConfig,
serializeConfig,
yamlRegister,
yamlUnregister,
type HermesRegisterEntry,
} from '../src/yaml-merger.js';
function entry(eventKey: string, command: string, timeout = 60): HermesRegisterEntry {
return { eventKey, command, timeout };
}
/** The four register entries hermes install builds (SessionStart is 2-key). */
const ALL_ENTRIES: readonly HermesRegisterEntry[] = [
entry('on_session_start', 'node "/abs/dist/hooks/session-start.js"'),
entry('pre_llm_call', 'node "/abs/dist/hooks/session-start.js"'),
entry('pre_llm_call', 'node "/abs/dist/hooks/user-prompt-submit.js"'),
entry('post_llm_call', 'node "/abs/dist/hooks/stop.js"'),
];
function eventArray(config: Record<string, unknown>, eventKey: string): Record<string, unknown>[] {
const hooks = config[HOOKS_KEY] as Record<string, unknown> | undefined;
return (hooks?.[eventKey] as Record<string, unknown>[] | undefined) ?? [];
}
describe('parseConfig (hermes YAML codec)', () => {
it('returns {} for an empty / whitespace-only config (create-if-missing)', () => {
expect(parseConfig('')).toEqual({});
expect(parseConfig(' \n ')).toEqual({});
});
it('parses a realistic config.yaml WITH comments + a pre-existing user hook', () => {
const raw = [
'# Hermes CLI config',
'model: claude-opus',
'hooks:',
' # a user-installed lint hook',
' pre_llm_call:',
' - command: node /user/own/lint.js',
' timeout: 30',
'hooks_auto_accept: false',
'',
].join('\n');
const parsed = parseConfig(raw);
expect(parsed['model']).toBe('claude-opus');
expect(parsed['hooks_auto_accept']).toBe(false);
const arr = eventArray(parsed, 'pre_llm_call');
expect(arr).toHaveLength(1);
expect(arr[0]['command']).toBe('node /user/own/lint.js');
expect(arr[0]['timeout']).toBe(30);
});
it('throws on malformed YAML so the installer fails loudly', () => {
// A block-mapping value that cannot parse (tab indentation / bad structure).
expect(() => parseConfig('hooks:\n\t- : : :\n bad')).toThrow(/parse/i);
});
it('treats a top-level scalar/array YAML doc as empty (not a crash)', () => {
expect(parseConfig('"just a string"')).toEqual({});
expect(parseConfig('- a\n- b\n')).toEqual({});
});
});
describe('serializeConfig (hermes YAML codec)', () => {
it('round-trips a merged config back to parseable YAML with a trailing newline', () => {
const merged = yamlRegister({ model: 'x' }, [entry('post_llm_call', 'node /a/stop.js')]);
const text = serializeConfig(merged);
expect(text.endsWith('\n')).toBe(true);
const reparsed = parseYaml(text) as Record<string, unknown>;
expect(reparsed['model']).toBe('x');
expect(eventArray(reparsed, 'post_llm_call')).toHaveLength(1);
});
});
describe('yamlRegister (hermes flat {command,timeout,_hive_mind} entry shape)', () => {
it('returns a NEW object — does not mutate input (immutability contract)', () => {
const original: Record<string, unknown> = { hooks: { pre_llm_call: [] } };
const merged = yamlRegister(original, [entry('pre_llm_call', 'node /a/x.js')]);
expect(merged).not.toBe(original);
// Input untouched.
expect((original['hooks'] as Record<string, unknown>)['pre_llm_call']).toEqual([]);
});
it('registers a marker-tagged entry under each supplied native event key', () => {
const merged = yamlRegister({}, ALL_ENTRIES);
expect(eventArray(merged, 'on_session_start')).toHaveLength(1);
// pre_llm_call carries BOTH the session-start inject hook and the user-prompt hook.
expect(eventArray(merged, 'pre_llm_call')).toHaveLength(2);
expect(eventArray(merged, 'post_llm_call')).toHaveLength(1);
});
it('builds the flat entry shape: {command, timeout, _hive_mind marker}', () => {
const merged = yamlRegister({}, [entry('post_llm_call', 'node /a/stop.js', 9)]);
const e = eventArray(merged, 'post_llm_call')[0];
expect(e['command']).toBe('node /a/stop.js');
expect(e['timeout']).toBe(9);
expect(e['_hive_mind']).toBe(HIVE_MIND_MARKER);
// No nested matcher/hooks wrapper — matcher is stripped-with-warning on
// lifecycle events, so we never set it.
expect(e['matcher']).toBeUndefined();
expect(e['hooks']).toBeUndefined();
});
it('preserves existing (user) hook entries verbatim — additive merge', () => {
const existing: Record<string, unknown> = {
hooks: {
pre_llm_call: [
{ command: 'node /user/own.js', timeout: 30 },
],
},
};
const merged = yamlRegister(existing, [entry('pre_llm_call', 'node /a/session-start.js')]);
const arr = eventArray(merged, 'pre_llm_call');
expect(arr).toHaveLength(2);
expect(arr[0]['command']).toBe('node /user/own.js');
expect(arr[0]['_hive_mind']).toBeUndefined();
expect(arr[1]['_hive_mind']).toBe(HIVE_MIND_MARKER);
});
it('preserves unrelated top-level (non-hooks) YAML keys', () => {
const merged = yamlRegister(
{ model: 'claude-opus', temperature: 0.2, hooks: {} },
[entry('pre_llm_call', 'node /a/x.js')],
);
expect(merged['model']).toBe('claude-opus');
expect(merged['temperature']).toBe(0.2);
});
it('replaces our own marker-tagged entry on re-install (dedup by command, in place)', () => {
const e = entry('pre_llm_call', 'node /a/session-start.js', 60);
const merged1 = yamlRegister({}, [e]);
const merged2 = yamlRegister(merged1, [{ ...e, timeout: 120 }]);
const arr = eventArray(merged2, 'pre_llm_call');
expect(arr).toHaveLength(1); // never duplicated
expect(arr[0]['timeout']).toBe(120);
expect(arr[0]['_hive_mind']).toBe(HIVE_MIND_MARKER);
});
it('re-registering the full set keeps each pre_llm_call slot at exactly 2 hive entries', () => {
const merged1 = yamlRegister({}, ALL_ENTRIES);
const merged2 = yamlRegister(merged1, ALL_ENTRIES);
expect(eventArray(merged2, 'pre_llm_call')).toHaveLength(2);
expect(eventArray(merged2, 'on_session_start')).toHaveLength(1);
expect(eventArray(merged2, 'post_llm_call')).toHaveLength(1);
});
it('does not collapse two DIFFERENT hive commands sharing one event key', () => {
// session-start inject + user-prompt both ride pre_llm_call with distinct
// commands — they must coexist, not dedup each other.
const merged = yamlRegister({}, [
entry('pre_llm_call', 'node /a/session-start.js'),
entry('pre_llm_call', 'node /a/user-prompt-submit.js'),
]);
const arr = eventArray(merged, 'pre_llm_call');
expect(arr).toHaveLength(2);
expect(arr.map((e) => e['command']).sort()).toEqual([
'node /a/session-start.js',
'node /a/user-prompt-submit.js',
]);
});
});
describe('isHiveEntry (hermes structural marker)', () => {
it('true only for entries carrying the structural _hive_mind sentinel', () => {
expect(isHiveEntry({ command: 'x', _hive_mind: HIVE_MIND_MARKER })).toBe(true);
expect(isHiveEntry({ command: 'x' })).toBe(false);
expect(isHiveEntry({ command: 'x', _hive_mind: 'someone-else' })).toBe(false);
expect(isHiveEntry(undefined)).toBe(false);
expect(isHiveEntry('not-an-object')).toBe(false);
});
});
describe('hasHiveEntries (hermes)', () => {
it('false on empty / hookless config', () => {
expect(hasHiveEntries(undefined)).toBe(false);
expect(hasHiveEntries({})).toBe(false);
expect(hasHiveEntries({ hooks: {} })).toBe(false);
});
it('true once a marker-tagged entry is present', () => {
const merged = yamlRegister({}, [entry('post_llm_call', 'node /a/stop.js')]);
expect(hasHiveEntries(merged)).toBe(true);
});
it('false for a config holding ONLY non-hive (user) entries', () => {
const userOnly: Record<string, unknown> = {
hooks: { post_llm_call: [{ command: 'node /user/own.js', timeout: 5 }] },
};
expect(hasHiveEntries(userOnly)).toBe(false);
});
});
describe('yamlUnregister (hermes — used for diagnostics / backup-less path)', () => {
it('strips exactly our marker-tagged entries, preserves user entries', () => {
const userEntry = { command: 'node /user/own.js', timeout: 5 };
const withUser: Record<string, unknown> = { hooks: { post_llm_call: [userEntry] } };
const merged = yamlRegister(withUser, [entry('post_llm_call', 'node /a/stop.js')]);
expect(eventArray(merged, 'post_llm_call')).toHaveLength(2);
const stripped = yamlUnregister(merged);
const arr = eventArray(stripped, 'post_llm_call');
expect(arr).toHaveLength(1);
expect(arr[0]['command']).toBe('node /user/own.js');
expect(hasHiveEntries(stripped)).toBe(false);
});
it('returns a NEW object and leaves the input untouched (immutability)', () => {
const merged = yamlRegister({}, [entry('post_llm_call', 'node /a/stop.js')]);
const stripped = yamlUnregister(merged);
expect(stripped).not.toBe(merged);
expect(hasHiveEntries(merged)).toBe(true); // original still has the entry
});
it('is a no-op (new object) when there is no hooks block', () => {
const stripped = yamlUnregister({ model: 'x' });
expect(stripped['model']).toBe('x');
expect(hasHiveEntries(stripped)).toBe(false);
});
});

View File

@@ -0,0 +1,130 @@
import { describe, expect, it, afterEach } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { install } from '../src/install.js';
import { uninstall } from '../src/uninstall.js';
interface TestEnv {
home: string;
hooksDir: string;
configPath: string;
pointerPath: string;
}
async function bootstrap(initialYaml: string | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmher-uninstall-'));
const hermesDir = join(home, '.hermes');
await mkdir(hermesDir, { recursive: true });
const configPath = join(hermesDir, 'config.yaml');
if (initialYaml !== undefined) {
await writeFile(configPath, initialYaml, 'utf-8');
}
const hooksDir = resolve(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
return { home, hooksDir, configPath, pointerPath: join(hermesDir, 'hive-mind-install.json') };
}
function sha256(s: string): string {
return createHash('sha256').update(s, 'utf-8').digest('hex');
}
describe('uninstall (hermes)', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws when no pointer file exists', async () => {
env = await bootstrap('hooks: {}\n');
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/pointer/);
});
it('throws when the pointer is malformed', async () => {
env = await bootstrap('hooks: {}\n');
await writeFile(env.pointerPath, '{}', 'utf-8');
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/malformed/);
});
// ── created_by_us=false: LITERAL byte-identical restore (§7.3 invariant 2) ─
// YAML round-trip is lossy (comments + ordering are dropped on re-serialize),
// so reversibility relies on restoring the ORIGINAL BYTES from the backup.
it('install + uninstall round-trip is SHA-256 identical to pre-install state (comments preserved)', async () => {
// Deliberately include comments + non-alphabetical key ordering that a
// naive YAML re-serialize would NOT reproduce.
const initial = [
'# Hermes config — hand-edited, comments matter',
'model: claude-opus # the good one',
'temperature: 0.2',
'hooks:',
' pre_llm_call:',
' - command: node /existing/ctx.js',
' timeout: 10',
' post_llm_call:',
' - command: node /existing/turn.js',
' timeout: 10',
'',
].join('\n');
env = await bootstrap(initial);
const preInstall = await readFile(env.configPath, 'utf-8');
const preHash = sha256(preInstall);
await install({ home: env.home, hooksDir: env.hooksDir });
const afterInstall = await readFile(env.configPath, 'utf-8');
expect(sha256(afterInstall)).not.toBe(preHash); // install actually mutated
// Sanity: the merged write IS lossy — the comment is gone post-install,
// which is exactly why we need the literal backup to reverse it.
expect(afterInstall).not.toContain('# Hermes config');
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.createdRemoved).toBe(false);
expect(u.restoredFrom).not.toBeNull();
const afterUninstall = await readFile(env.configPath, 'utf-8');
// Byte-for-byte identical — the comment + ordering are back.
expect(sha256(afterUninstall)).toBe(preHash);
expect(afterUninstall).toBe(preInstall);
expect(afterUninstall).toContain('# Hermes config — hand-edited, comments matter');
});
it('removes backup + pointer by default after a restore', async () => {
env = await bootstrap('model: opus\nhooks: {}\n');
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(result.backupPath as string)).toBe(true);
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.backupRemoved).toBe(true);
expect(existsSync(result.backupPath as string)).toBe(false);
expect(existsSync(result.pointerPath)).toBe(false);
});
it('keeps the backup when cleanupBackup=false', async () => {
env = await bootstrap('model: opus\nhooks: {}\n');
const result = await install({ home: env.home, hooksDir: env.hooksDir });
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir, cleanupBackup: false });
expect(u.backupRemoved).toBe(false);
expect(existsSync(result.backupPath as string)).toBe(true);
});
// ── created_by_us=true: delete-if-created, no orphan (§7.3 invariant 2) ─
it('deletes the config.yaml we created and leaves NO orphan (absent → install → uninstall)', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.createdByUs).toBe(true);
expect(existsSync(env.configPath)).toBe(true);
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.createdRemoved).toBe(true);
expect(u.restoredFrom).toBeNull();
// No orphaned config, no leftover pointer.
expect(existsSync(env.configPath)).toBe(false);
expect(existsSync(env.pointerPath)).toBe(false);
});
});

View File

@@ -0,0 +1,187 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ChildProcess } from 'node:child_process';
import { install } from '../src/install.js';
import { verify } from '../src/verify.js';
interface TestEnv {
home: string;
hooksDir: string;
hermesDir: string;
}
async function bootstrap(
initialYaml: string | undefined,
withHookFiles: boolean,
): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmher-verify-'));
const hermesDir = join(home, '.hermes');
await mkdir(hermesDir, { recursive: true });
if (initialYaml !== undefined) {
await writeFile(join(hermesDir, 'config.yaml'), initialYaml, 'utf-8');
}
const hooksDir = join(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
if (withHookFiles) {
// THREE scripts only — Hermes ships no pre-compact hook.
for (const b of ['session-start', 'user-prompt-submit', 'stop']) {
await writeFile(join(hooksDir, `${b}.js`), '/* mock hook */', 'utf-8');
}
}
return { home, hooksDir, hermesDir };
}
function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: string }): typeof import('node:child_process').spawn {
return ((_cmd: string, _args: readonly string[], _options?: unknown) => {
const emitter = new EventEmitter();
const stdout = Readable.from([Buffer.from(opts.stdout ?? 'hive-mind-cli help text\n')]);
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
const child = Object.assign(emitter, {
stdout,
stderr,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
setImmediate(() => emitter.emit('exit', opts.exitCode));
return child;
}) as unknown as typeof import('node:child_process').spawn;
}
describe('verify (hermes)', () => {
const envs: TestEnv[] = [];
afterEach(async () => {
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
});
it('reports failure when config.yaml is missing', async () => {
const env = await bootstrap(undefined, true);
envs.push(env);
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks[0].name).toBe('config.yaml exists');
expect(result.checks[0].ok).toBe(false);
});
it('reports failure when hooks are not yet installed', async () => {
const env = await bootstrap('model: opus\nhooks: {}\n', true);
envs.push(env);
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks.some((c) => !c.ok && c.name.includes('hive-mind hook entries'))).toBe(true);
});
it('passes after install with the 3 hook files on disk and CLI reachable', async () => {
const env = await bootstrap('model: opus\nhooks: {}\n', true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(true);
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
expect(cliCheck?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'config.yaml contains hive-mind hook entries')?.ok).toBe(true);
const diskChecks = result.checks.filter((c) => c.name.includes('readable on disk'));
expect(diskChecks).toHaveLength(3); // exactly 3 — no pre-compact
expect(diskChecks.every((c) => c.ok)).toBe(true);
});
it('reports CLI unreachable when the spawn exits non-zero', async () => {
const env = await bootstrap('hooks: {}\n', true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 127, stderr: 'command not found' }),
});
expect(result.ok).toBe(false);
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
expect(cliCheck?.ok).toBe(false);
});
it('flags missing hook script files even when the config entry is present', async () => {
const env = await bootstrap('hooks: {}\n', false); // no hook .js files
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
const fileCheck = result.checks.find((c) => c.name.includes('readable on disk'));
expect(fileCheck?.ok).toBe(false);
});
it('uses cli_path from the install pointer for the probe (node <path> --help)', async () => {
const env = await bootstrap('hooks: {}\n', true);
envs.push(env);
const cliPath = '/abs/from/pointer.js';
await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
const records: Array<{ command: string; args: readonly string[] }> = [];
const recordingSpawn = ((cmd: string, args: readonly string[]) => {
records.push({ command: cmd, args });
return mockSpawnImpl({ exitCode: 0 })(cmd, args);
}) as typeof import('node:child_process').spawn;
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: recordingSpawn,
});
expect(result.ok).toBe(true);
const probeRecord = records[records.length - 1];
expect(probeRecord.command).toBe(process.execPath);
expect(probeRecord.args[0]).toBe(cliPath);
expect(probeRecord.args[1]).toBe('--help');
});
// ── hermes-specific surfacing: headless consent / registration ────────
it('surfaces the headless-consent check as PASS when auto-accept is seeded (default install)', async () => {
const env = await bootstrap('hooks: {}\n', true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir }); // auto-accept seeded by default
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
const consent = result.checks.find((c) => c.name.toLowerCase().includes('consent'));
expect(consent).toBeDefined();
expect(consent?.ok).toBe(true);
expect(consent?.detail?.toLowerCase()).toContain('hooks_auto_accept');
});
it('FAILS the headless-consent check + overall ok when auto-accept was NOT seeded', async () => {
const env = await bootstrap('hooks: {}\n', true);
envs.push(env);
// --no-auto-accept: hooks silently never register under a headless launch.
await install({ home: env.home, hooksDir: env.hooksDir, autoAccept: false });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
const consent = result.checks.find((c) => c.name.toLowerCase().includes('consent'));
expect(consent?.ok).toBe(false);
expect(consent?.detail?.toLowerCase()).toContain('hermes_accept_hooks=1');
// The advisory failing drags overall ok to false.
expect(result.ok).toBe(false);
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests/**", "dist/**", "node_modules/**"],
"references": [
{ "path": "../hive-mind-hooks-core" },
{ "path": "../hive-mind-shim-core" }
]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist/**", "node_modules/**"]
}