This commit is contained in:
19
packages/hive-mind-hooks-hermes/LICENSE
Normal file
19
packages/hive-mind-hooks-hermes/LICENSE
Normal 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
|
||||
84
packages/hive-mind-hooks-hermes/README.md
Normal file
84
packages/hive-mind-hooks-hermes/README.md
Normal 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.
|
||||
66
packages/hive-mind-hooks-hermes/package.json
Normal file
66
packages/hive-mind-hooks-hermes/package.json
Normal 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"
|
||||
}
|
||||
106
packages/hive-mind-hooks-hermes/src/adapter.ts
Normal file
106
packages/hive-mind-hooks-hermes/src/adapter.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
183
packages/hive-mind-hooks-hermes/src/bin/hermes-hooks.ts
Normal file
183
packages/hive-mind-hooks-hermes/src/bin/hermes-hooks.ts
Normal 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();
|
||||
126
packages/hive-mind-hooks-hermes/src/compact-on-stop.ts
Normal file
126
packages/hive-mind-hooks-hermes/src/compact-on-stop.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
90
packages/hive-mind-hooks-hermes/src/hooks/session-start.ts
Normal file
90
packages/hive-mind-hooks-hermes/src/hooks/session-start.ts
Normal 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();
|
||||
}
|
||||
68
packages/hive-mind-hooks-hermes/src/hooks/stop.ts
Normal file
68
packages/hive-mind-hooks-hermes/src/hooks/stop.ts
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
85
packages/hive-mind-hooks-hermes/src/index.ts
Normal file
85
packages/hive-mind-hooks-hermes/src/index.ts
Normal 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';
|
||||
200
packages/hive-mind-hooks-hermes/src/install.ts
Normal file
200
packages/hive-mind-hooks-hermes/src/install.ts
Normal 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;
|
||||
}
|
||||
81
packages/hive-mind-hooks-hermes/src/paths.ts
Normal file
81
packages/hive-mind-hooks-hermes/src/paths.ts
Normal 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 };
|
||||
79
packages/hive-mind-hooks-hermes/src/uninstall.ts
Normal file
79
packages/hive-mind-hooks-hermes/src/uninstall.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
162
packages/hive-mind-hooks-hermes/src/verify.ts
Normal file
162
packages/hive-mind-hooks-hermes/src/verify.ts
Normal 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 };
|
||||
}
|
||||
186
packages/hive-mind-hooks-hermes/src/yaml-merger.ts
Normal file
186
packages/hive-mind-hooks-hermes/src/yaml-merger.ts
Normal 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;
|
||||
}
|
||||
82
packages/hive-mind-hooks-hermes/tests/adapter.test.ts
Normal file
82
packages/hive-mind-hooks-hermes/tests/adapter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
169
packages/hive-mind-hooks-hermes/tests/compact-on-stop.test.ts
Normal file
169
packages/hive-mind-hooks-hermes/tests/compact-on-stop.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
62
packages/hive-mind-hooks-hermes/tests/hooks/_test-helpers.ts
Normal file
62
packages/hive-mind-hooks-hermes/tests/hooks/_test-helpers.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
246
packages/hive-mind-hooks-hermes/tests/hooks/stop.test.ts
Normal file
246
packages/hive-mind-hooks-hermes/tests/hooks/stop.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
217
packages/hive-mind-hooks-hermes/tests/install.test.ts
Normal file
217
packages/hive-mind-hooks-hermes/tests/install.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
76
packages/hive-mind-hooks-hermes/tests/paths.test.ts
Normal file
76
packages/hive-mind-hooks-hermes/tests/paths.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
225
packages/hive-mind-hooks-hermes/tests/register.test.ts
Normal file
225
packages/hive-mind-hooks-hermes/tests/register.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
130
packages/hive-mind-hooks-hermes/tests/uninstall.test.ts
Normal file
130
packages/hive-mind-hooks-hermes/tests/uninstall.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
187
packages/hive-mind-hooks-hermes/tests/verify.test.ts
Normal file
187
packages/hive-mind-hooks-hermes/tests/verify.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
packages/hive-mind-hooks-hermes/tsconfig.json
Normal file
15
packages/hive-mind-hooks-hermes/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
12
packages/hive-mind-hooks-hermes/tsconfig.test.json
Normal file
12
packages/hive-mind-hooks-hermes/tsconfig.test.json
Normal 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/**"]
|
||||
}
|
||||
Reference in New Issue
Block a user