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

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

View File

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

View File

@@ -0,0 +1,58 @@
{
"name": "@waggle/hive-mind-hooks-core",
"version": "0.1.0",
"description": "Tool-agnostic foundation for the hive-mind lifecycle-hook port portfolio. Generalizes the reversible-install primitives, the EventAdapter-parameterized lifecycle handler bodies, and the jsonRegister merge helper proven by @waggle/hive-mind-hooks-claude-code so the codex/codex-desktop/cursor/hermes/openclaw packages stay thin. Codec-agnostic — YAML/JSON5 parsers live in the consumer packages.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.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-core/tests",
"test:watch": "vitest"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@waggle/hive-mind-shim-core": "*"
},
"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-core"
},
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-hooks-core#readme",
"bugs": {
"url": "https://github.com/marolinik/waggle-os/issues"
},
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
"keywords": [
"hive-mind",
"memory",
"ai",
"mcp",
"hook",
"silent-capture",
"foundation"
],
"types": "dist/index.d.ts"
}

View File

@@ -0,0 +1,57 @@
/**
* EventAdapter — the per-tool seam that lets the four tool-agnostic
* lifecycle handler bodies (handlers-core.ts) read fields out of an
* opaque host payload without hardcoding key names.
*
* This generalizes what the frozen Wave 1 claude-code handlers do inline
* via `pickStringFromObject` multi-key fallbacks (e.g. stop.ts reads
* `response | assistant_message | transcript`). A tool package supplies
* one `EventAdapter`; the shared handler factories consume it.
*/
import type { ShimSource } from '@waggle/hive-mind-shim-core';
/** Canonical lifecycle actions, tool-agnostic. */
export type Lifecycle = 'session-start' | 'user-prompt-submit' | 'stop' | 'pre-compact';
/**
* Context handed to async field extractors. Cursor delivers the completed
* turn via a `transcript_path` to read off disk rather than inline, so
* `extractResponse` may need a file reader.
*/
export interface ExtractContext {
readFile?: (path: string) => Promise<string>;
}
export interface EventAdapter {
/** Tool id used for HookEvent.source + logger names. */
readonly source: ShimSource;
/**
* Map each canonical lifecycle name to the tool's native event key.
* `undefined` ⇒ the tool has no native event for that lifecycle
* (degraded; documented per spec §6.1).
*/
readonly eventName: Record<Lifecycle, string | undefined>;
/** Extract the working directory from the opaque payload. */
extractCwd(payload: unknown): string | undefined;
/** Extract the session/conversation id from the opaque payload. */
extractSessionId(payload: unknown): string | undefined;
/** Extract the user prompt text (UserPromptSubmit). */
extractPrompt(payload: unknown): string | undefined;
/**
* Extract the completed assistant turn (Stop). Async because some tools
* (cursor) deliver it via a `transcript_path` read off disk rather than
* inline. Must fail open — return undefined rather than throw.
*/
extractResponse(
payload: unknown,
ctx: ExtractContext,
): Promise<string | undefined> | string | undefined;
/** Extract a parent frame id to link a Stop frame to its prompt, if any. */
extractParent(payload: unknown): string | undefined;
/**
* Per-tool shape of the SessionStart inject response. `undefined` ⇒ the
* tool cannot inject context (save-only); the handler then emits nothing.
*/
formatInject?(additionalContext: string): unknown;
}

View File

@@ -0,0 +1,526 @@
/**
* Tool-agnostic lifecycle handler bodies + `HookHandler` factories.
*
* The four canonical lifecycle actions (recall+inject, save-temporary,
* summarize+save-important, compact_memory) mirror the frozen Wave 1
* claude-code hook bodies (`hooks/session-start.ts`, `user-prompt-submit.ts`,
* `stop.ts`, `pre-compact.ts`) but read fields through a per-tool
* `EventAdapter` instead of hardcoded key lists.
*
* Two drive paths share ONE set of bodies:
* 1. The stdin-JSON / exit-0 tools (codex, codex-desktop, cursor, hermes)
* use the `make*Handler` factories, which return `HookHandler` objects
* consumed by `runHook` (hook-shared.ts).
* 2. OpenClaw's in-process TypeScript handlers can't use `runHook`
* (in-process, not subprocess); `makeOpenclawHandler` wraps the SAME
* bodies for the in-process path.
*
* The bodies therefore take an ALREADY-EXTRACTED payload (not the raw host
* event) so both paths can drive them.
*
* Immutability: bodies build fresh HookFrame / event objects; they never
* mutate the bridge, adapter, or incoming payload.
*/
import {
classifyImportance,
encodeFrame,
maybeEmitDiscovery,
summarizeTurn,
type HookEvent,
type MemoryHit,
} from '@waggle/hive-mind-shim-core';
import type { EventAdapter, ExtractContext, Lifecycle } from './event-adapter.js';
import { recallPersonalAndWorkspace } from '@waggle/hive-mind-shim-core';
import type { HookContext, HookHandler } from './hook-shared.js';
// ── Extracted payloads (the shared bodies operate on these) ────────────
export interface SessionStartExtracted {
cwd: string;
sessionId: string | undefined;
recallLimit: number;
}
export interface UserPromptExtracted {
prompt: string;
cwd: string;
sessionId: string;
}
export interface StopExtracted {
cwd: string;
sessionId: string;
response: string;
parent: string | undefined;
}
export interface PreCompactExtracted {
scope: string | undefined;
}
// ── Defaults (mirror the CC bodies) ────────────────────────────────────
const DEFAULT_RECALL_LIMIT = 20;
const PER_HIT_CONTENT_BUDGET = 240;
const DEFAULT_SUMMARY_BUDGET_CHARS = 400;
export interface SessionStartOpts {
recallLimit?: number;
}
export interface StopOpts {
summaryBudgetChars?: number;
}
function formatHitsForContext(hits: readonly MemoryHit[]): string {
if (hits.length === 0) {
return 'hive-mind: no recalled frames for this workspace yet.';
}
const lines: string[] = [`hive-mind: top ${hits.length} recalled frames`];
for (const h of hits) {
const from = h.from && h.from !== 'personal' ? ` [${h.from}]` : '';
const content = h.content.length > PER_HIT_CONTENT_BUDGET
? h.content.slice(0, PER_HIT_CONTENT_BUDGET) + '…'
: h.content;
lines.push(`- (${h.importance})${from} ${h.created_at}: ${content}`);
}
return lines.join('\n');
}
/** Default SessionStart inject shape — the CC hookSpecificOutput convention. */
function defaultFormatInject(source: string, additionalContext: string): unknown {
return {
hookSpecificOutput: {
hookEventName: 'SessionStart',
source,
additionalContext,
},
};
}
// ── Shared handler bodies (drive-path agnostic) ────────────────────────
/**
* SessionStart body — recall top-N frames from personal memory and format
* them for injection. Returns the tool-shaped inject object, or undefined
* when the adapter declares no inject seam (`formatInject` absent).
*/
export async function runSessionStartBody(
a: EventAdapter,
payload: SessionStartExtracted,
ctx: HookContext,
): Promise<unknown> {
ctx.logger.debug('recall starting', { limit: payload.recallLimit });
const hits = await recallPersonalAndWorkspace(ctx.bridge, '', {
limit: payload.recallLimit,
});
ctx.logger.debug('recall complete', { hits: hits.length });
const text = formatHitsForContext(hits);
if (a.formatInject) return a.formatInject(text);
return defaultFormatInject(a.source, text);
}
/**
* UserPromptSubmit body — persist the prompt as a temporary frame. No
* stdout output; pure side-effect on the .mind file.
*/
export async function runUserPromptBody(
a: EventAdapter,
payload: UserPromptExtracted,
ctx: HookContext,
): Promise<undefined> {
if (!payload.prompt) {
ctx.logger.debug('no prompt in payload, skipping save');
return undefined;
}
const event: HookEvent = {
eventType: 'user-prompt-submit',
source: a.source,
cwd: payload.cwd,
timestamp_iso: new Date().toISOString(),
payload: {
content: payload.prompt,
session_id: payload.sessionId,
},
};
const frame = encodeFrame(event, { importance: 'temporary' });
const result = await ctx.bridge.saveMemory(frame);
ctx.logger.debug('prompt frame saved', { id: result.id, scope: frame.scope });
return undefined;
}
/**
* Stop body — summarize the completed turn deterministically, classify
* importance, save an important/critical frame, and (opt-in via
* WAGGLE_SIGNAL_EMIT) emit a discovery signal. Mirrors CC stop.ts:46-108.
*/
export async function runStopBody(
a: EventAdapter,
payload: StopExtracted,
ctx: HookContext,
opts: StopOpts = {},
): Promise<undefined> {
if (!payload.response) {
ctx.logger.debug('no response in payload, skipping save');
return undefined;
}
const summary = summarizeTurn(payload.response, {
maxChars: opts.summaryBudgetChars ?? DEFAULT_SUMMARY_BUDGET_CHARS,
});
const rawImportance = classifyImportance(summary, { eventType: 'stop' });
const importance = rawImportance === 'critical' ? 'critical' : 'important';
const event: HookEvent = {
eventType: 'stop',
source: a.source,
cwd: payload.cwd,
timestamp_iso: new Date().toISOString(),
payload: {
content: summary,
session_id: payload.sessionId,
},
};
const encodeOpts: { importance: typeof importance; parent?: string } = { importance };
if (payload.parent !== undefined) encodeOpts.parent = payload.parent;
const frame = encodeFrame(event, encodeOpts);
const result = await ctx.bridge.saveMemory(frame);
ctx.logger.debug('stop frame saved', {
id: result.id,
importance: frame.importance,
bytes: summary.length,
});
// Opt-in v2 signal emission. Off by default so OSS consumers see no
// behavior change; flip WAGGLE_SIGNAL_EMIT to broadcast high/critical
// stops to the local Waggle sidecar. Fails open (never throws).
const emitFlag = process.env.WAGGLE_SIGNAL_EMIT;
if (emitFlag && emitFlag !== '0' && emitFlag.toLowerCase() !== 'false') {
const emitImportance =
rawImportance === 'critical' ? 'critical'
: rawImportance === 'important' ? 'high'
: rawImportance === 'normal' ? 'normal'
: 'low';
const emitted = await maybeEmitDiscovery(
'stop',
emitImportance,
{
tool: a.source,
sessionId: payload.sessionId,
topic: summary.slice(0, 160),
summary,
frameId: result.id,
memoryWorkspace: result.workspace,
cwd: payload.cwd,
},
{ senderId: `${a.source}-hook` },
);
if (emitted) ctx.logger.debug('stop signal emitted', { id: emitted.id });
}
return undefined;
}
/**
* PreCompact body — trigger upstream frame compaction maintenance before
* the host truncates context. Mirrors CC pre-compact.ts.
*/
export async function runPreCompactBody(
_a: EventAdapter,
payload: PreCompactExtracted,
ctx: HookContext,
): Promise<undefined> {
const result = await ctx.bridge.cleanupFrames();
ctx.logger.debug('cleanup_frames done', { pruned: result.pruned, scope: payload.scope });
return undefined;
}
// ── Extraction (raw host payload → extracted payload) ──────────────────
function extractSessionStart(a: EventAdapter, raw: unknown, recallLimit: number): SessionStartExtracted {
return {
cwd: a.extractCwd(raw) ?? process.cwd(),
sessionId: a.extractSessionId(raw),
recallLimit,
};
}
function resolveRecallLimit(raw: unknown, fallback: number): number {
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
const val = obj['recall_limit'] ?? obj['recallLimit'];
if (typeof val === 'number' && val > 0) return Math.floor(val);
}
return fallback;
}
function extractUserPrompt(a: EventAdapter, raw: unknown): UserPromptExtracted {
return {
prompt: a.extractPrompt(raw) ?? '',
cwd: a.extractCwd(raw) ?? process.cwd(),
sessionId: a.extractSessionId(raw) ?? 'default',
};
}
async function extractStop(a: EventAdapter, raw: unknown, ctx: ExtractContext): Promise<StopExtracted> {
const response = (await a.extractResponse(raw, ctx)) ?? '';
return {
cwd: a.extractCwd(raw) ?? process.cwd(),
sessionId: a.extractSessionId(raw) ?? 'default',
response,
parent: a.extractParent(raw),
};
}
function extractPreCompact(a: EventAdapter, raw: unknown): PreCompactExtracted {
return { scope: a.extractSessionId(raw) };
}
// ── HookHandler factories (stdin-JSON / exit-0 drive path) ─────────────
/**
* SessionStart `HookHandler` for the `runHook` subprocess path.
* `recallLimit` resolves from the payload (`recall_limit`/`recallLimit`)
* else `opts.recallLimit` else 20.
*/
export function makeSessionStartHandler(
a: EventAdapter,
opts: SessionStartOpts = {},
): HookHandler<SessionStartExtracted, unknown> {
const fallbackLimit = opts.recallLimit ?? DEFAULT_RECALL_LIMIT;
return {
parse(raw): SessionStartExtracted {
const limit = resolveRecallLimit(raw, fallbackLimit);
return extractSessionStart(a, raw, limit);
},
run(payload, ctx): Promise<unknown> {
return runSessionStartBody(a, payload, ctx) as Promise<unknown>;
},
};
}
export function makeUserPromptSubmitHandler(
a: EventAdapter,
): HookHandler<UserPromptExtracted, undefined> {
return {
parse(raw): UserPromptExtracted {
return extractUserPrompt(a, raw);
},
run(payload, ctx): Promise<undefined> {
return runUserPromptBody(a, payload, ctx);
},
};
}
/**
* Stop's `HookHandler.parse` is synchronous, but Stop extraction can be
* async (cursor reads the completed turn off `transcript_path`). So the
* parsed value carries the raw payload, and `run` performs the async
* response extraction. A typed wrapper avoids any intersection abuse.
*/
export interface StopParsed {
raw: unknown;
}
export function makeStopHandler(
a: EventAdapter,
opts: StopOpts = {},
): HookHandler<StopParsed, undefined> {
return {
parse(raw): StopParsed {
return { raw };
},
async run(payload, ctx): Promise<undefined> {
const resolved = await extractStop(a, payload.raw, {});
return runStopBody(a, resolved, ctx, opts);
},
};
}
export function makePreCompactHandler(
a: EventAdapter,
): HookHandler<PreCompactExtracted, undefined> {
return {
parse(raw): PreCompactExtracted {
return extractPreCompact(a, raw);
},
run(payload, ctx): Promise<undefined> {
return runPreCompactBody(a, payload, ctx);
},
};
}
// ── OpenClaw in-process handler factory (spec §5.5) ────────────────────
/**
* Minimal shape of OpenClaw's `InternalHookEvent` the in-process handler
* needs. The gateway delivers `(type, action)` plus an opaque `context`
* carrying cwd / prompt / response / session id. Matching is on the
* `(type, action)` pair, NOT the joined string (e.g. PreCompact fires on
* action `'compact:before'`, not `'session:compact:before'`).
*/
export interface InternalHookEventLike {
type: string;
action: string;
sessionKey?: string;
context?: unknown;
timestamp?: unknown;
messages?: unknown;
}
/**
* Map an OpenClaw `(type, action)` pair to a canonical lifecycle, using
* the adapter's eventName map (each value is the OpenClaw `type:action`
* native key, e.g. 'agent:bootstrap', and PreCompact is matched specially
* on action 'compact:before').
*/
function lifecycleForOpenclawEvent(
a: EventAdapter,
ev: InternalHookEventLike,
): Lifecycle | undefined {
const joined = `${ev.type}:${ev.action}`;
for (const lc of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact'] as Lifecycle[]) {
const native = a.eventName[lc];
if (native === undefined) continue;
if (native === joined) return lc;
// PreCompact ONLY: the runtime action is 'compact:before' while the
// HOOK.md events[] entry is 'session:compact:before'. Accept a match on
// the action suffix exclusively for pre-compact — applying it to every
// lifecycle would mis-map an unrelated type whose action suffix collides
// (e.g. type='gateway' action='sent' must NOT map to stop's 'message:sent').
if (lc === 'pre-compact' && native.endsWith(`:${ev.action}`) && ev.type !== '') return lc;
}
return undefined;
}
export interface OpenclawHandlerOptions {
/** Stop debounce window (ms) for the 0..N `message:sent` per turn. */
stopDebounceMs?: number;
/** Stop summary budget. */
summaryBudgetChars?: number;
/** SessionStart recall limit. */
recallLimit?: number;
}
export interface OpenclawHandler {
/**
* In-process entrypoint. Receives an already-extracted payload object
* (the OpenClaw `handler.ts` adapter extracts `event.context` into the
* shared extracted-payload shapes before calling) plus the lifecycle.
* Shells to hive-mind-cli via the supplied bridge. FAIL-OPEN: wraps the
* body in try/catch and NEVER throws — the returned promise always
* resolves.
*/
handle(input: OpenclawHandlerInput, ctx: HookContext): Promise<void>;
}
/**
* The in-process driver input: an OpenClaw event-shaped object plus the
* already-extracted lifecycle payload. The shared bodies run on the
* extracted payload; the event is carried for lifecycle resolution +
* debounce keying.
*/
export interface OpenclawHandlerInput {
event: InternalHookEventLike;
/** Already-extracted payload, shaped per the resolved lifecycle. */
extracted:
| SessionStartExtracted
| UserPromptExtracted
| StopExtracted
| PreCompactExtracted;
}
/**
* Build an OpenClaw in-process handler that drives the SAME shared bodies
* the subprocess path uses. The host (`triggerInternalHook`) already wraps
* each handler in try/catch, but per spec §7.3 invariant 1 the openclaw
* fail-open contract is "the default-exported handler must not throw" — so
* we wrap the body here too and never rely on the host's catch as the only
* safety net.
*/
export function makeOpenclawHandler(
a: EventAdapter,
opts: OpenclawHandlerOptions = {},
): OpenclawHandler {
// Debounce state for message:sent (0..N per turn): keep only the last
// outbound payload of a turn by deferring the save behind a short timer.
const stopTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; resolve: () => void }>();
const debounceMs = opts.stopDebounceMs ?? 0;
async function dispatch(input: OpenclawHandlerInput, ctx: HookContext): Promise<void> {
const lifecycle = lifecycleForOpenclawEvent(a, input.event);
if (lifecycle === undefined) {
ctx.logger.debug('openclaw event ignored (no lifecycle match)', {
type: input.event.type,
action: input.event.action,
});
return;
}
switch (lifecycle) {
case 'session-start':
// Recall+inject: the OpenClaw adapter mutates bootstrapFiles with
// the returned text; the body returns it.
await runSessionStartBody(a, input.extracted as SessionStartExtracted, ctx);
return;
case 'user-prompt-submit':
await runUserPromptBody(a, input.extracted as UserPromptExtracted, ctx);
return;
case 'stop': {
const stopPayload = input.extracted as StopExtracted;
if (debounceMs <= 0) {
await runStopBody(a, stopPayload, ctx, { summaryBudgetChars: opts.summaryBudgetChars });
return;
}
// Debounce: keep only the last message:sent of a turn.
const key = input.event.sessionKey ?? stopPayload.sessionId;
const existing = stopTimers.get(key);
if (existing) {
// Supersede the prior message:sent of this turn (last-writer-wins).
// Cancel its pending save AND resolve its awaiting handle() so the
// host's sequential await of the superseded dispatch never hangs
// (fail-open invariant §7.3.1 — a hook must never block the host).
clearTimeout(existing.timer);
existing.resolve();
}
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
stopTimers.delete(key);
// FAIL-OPEN §7.3.1: this save is DETACHED (fires after dispatch's
// try/catch has returned), so a rejection here would escape as an
// unhandled rejection. Catch it explicitly before resolving.
void runStopBody(a, stopPayload, ctx, {
summaryBudgetChars: opts.summaryBudgetChars,
})
.catch((err) => {
ctx.logger.warn('openclaw debounced stop save failed open', {
error: err instanceof Error ? err.message : String(err),
});
})
.finally(() => resolve());
}, debounceMs);
stopTimers.set(key, { timer, resolve });
});
return;
}
case 'pre-compact':
await runPreCompactBody(a, input.extracted as PreCompactExtracted, ctx);
return;
}
}
return {
async handle(input, ctx): Promise<void> {
try {
await dispatch(input, ctx);
} catch (err) {
// FAIL-OPEN: swallow — never throw to the host event loop.
ctx.logger.warn('openclaw hook failed open', {
type: input.event.type,
action: input.event.action,
error: err instanceof Error ? err.message : String(err),
});
}
},
};
}

View File

@@ -0,0 +1,171 @@
/**
* Shared subprocess plumbing for the stdin-JSON / exit-0 lifecycle hook
* scripts (codex, codex-desktop, cursor, hermes).
*
* Re-authored from the frozen Wave 1 `hooks/_shared.ts` so the five new
* packages import `runHook`/`parseHookArgs` from `hooks-core` and never
* reach across the frozen claude-code package boundary. The logger name
* prefix is generic (derived from `opts.name`) rather than hardcoded to
* `claude-code-hooks`.
*
* Hook scripts run as short-lived Node subprocesses spawned by the host
* tool. The contract:
* - stdin : the host writes a JSON event payload (may be empty on
* some events).
* - stdout : structured JSON for hooks that influence host behaviour
* (SessionStart context injection, etc.).
* - stderr : structured logger output (never blocks the host).
* - exit code : 0 always — silent capture must not break the host.
*
* On any internal error, hooks log the error and exit 0. The `runHook`
* helper wraps the supplied hook body with this fail-open contract.
*/
import {
createCliBridge,
createLogger,
type CliBridge,
type CliBridgeOptions,
type Logger,
} from '@waggle/hive-mind-shim-core';
export interface HookContext {
bridge: CliBridge;
logger: Logger;
}
export interface HookRunOptions {
/** Component name for the logger (e.g. 'session-start'). */
name: string;
/**
* Logger name prefix; defaults to 'hive-mind-hooks'. Consumers can set
* their own tool-scoped prefix (e.g. 'codex-hooks') so log lines read
* like the reference package.
*/
loggerPrefix?: string;
/** Stdin reader override for tests. */
readStdin?: () => Promise<string>;
/** Stdout writer override for tests. */
writeStdout?: (s: string) => void;
/** Override exit; tests provide a no-op so they don't terminate vitest. */
exit?: (code: number) => void;
/** Logger override. */
logger?: Logger;
/** Bridge override (tests inject a mock with a fake spawnImpl). */
bridge?: CliBridge;
/** Override argv for tests; defaults to `process.argv.slice(2)`. */
argv?: readonly string[];
}
const DEFAULT_LOGGER_PREFIX = 'hive-mind-hooks';
/**
* Parse `--cli-path <value>` from argv. Used by hook scripts to thread
* the install-time-pinned CLI binary path into createCliBridge so a
* single hook script works on POSIX (where `hive-mind-cli` is on PATH)
* and on Windows (where the npm `.cmd` shim cannot be exec'd directly).
*/
export function parseHookArgs(argv: readonly string[]): { cliPath?: string } {
const idx = argv.indexOf('--cli-path');
if (idx >= 0 && idx + 1 < argv.length) {
const value = argv[idx + 1];
if (typeof value === 'string' && value.length > 0) return { cliPath: value };
}
return {};
}
export interface HookHandler<TPayload = unknown, TStdoutPayload = unknown> {
parse(raw: unknown): TPayload;
run(payload: TPayload, ctx: HookContext): Promise<TStdoutPayload | undefined>;
}
const STDIN_READ_TIMEOUT_MS = 2000;
export async function readStdinAsString(
timeoutMs: number = STDIN_READ_TIMEOUT_MS,
): Promise<string> {
if (process.stdin.isTTY) return '';
return new Promise<string>((resolve) => {
const chunks: Buffer[] = [];
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks).toString('utf-8'));
}, timeoutMs);
process.stdin.on('data', (c: Buffer) => chunks.push(c));
process.stdin.on('end', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString('utf-8'));
});
process.stdin.on('error', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString('utf-8'));
});
});
}
export function safeJsonParse(raw: string): unknown {
if (!raw || raw.trim().length === 0) return {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
export async function runHook<TPayload, TStdoutPayload>(
handler: HookHandler<TPayload, TStdoutPayload>,
opts: HookRunOptions,
): Promise<void> {
const prefix = opts.loggerPrefix ?? DEFAULT_LOGGER_PREFIX;
const logger = opts.logger ?? createLogger({ name: `${prefix}/${opts.name}` });
const writeStdout = opts.writeStdout ?? ((s: string) => process.stdout.write(s));
const exit = opts.exit ?? ((c: number): void => { process.exit(c); });
const reader = opts.readStdin ?? readStdinAsString;
const argv = opts.argv ?? process.argv.slice(2);
const argvFlags = parseHookArgs(argv);
const bridgeOpts: CliBridgeOptions = { logger };
if (argvFlags.cliPath !== undefined) bridgeOpts.cli_path = argvFlags.cliPath;
const bridge = opts.bridge ?? createCliBridge(bridgeOpts);
try {
const raw = await reader();
const parsed = safeJsonParse(raw);
const payload = handler.parse(parsed);
const out = await handler.run(payload, { bridge, logger });
if (out !== undefined) {
writeStdout(JSON.stringify(out) + '\n');
}
exit(0);
} catch (err) {
logger.warn('hook failed open', {
hook: opts.name,
error: err instanceof Error ? err.message : String(err),
});
exit(0);
}
}
/**
* Best-effort accessor for nested string fields on opaque payloads.
* Returns undefined when the key path doesn't resolve to a non-empty string.
*/
export function pickStringField(payload: unknown, ...keys: string[]): string | undefined {
if (!payload || typeof payload !== 'object') return undefined;
const obj = payload as Record<string, unknown>;
for (const key of keys) {
const value = obj[key];
if (typeof value === 'string' && value.length > 0) return value;
}
return undefined;
}
export function pickStringFromObject(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

View File

@@ -0,0 +1,29 @@
/**
* @waggle/hive-mind-hooks-core — public barrel.
*
* Tool-agnostic foundation for the hive-mind lifecycle-hook ports
* (codex / codex-desktop / cursor / hermes / openclaw). Generalizes the
* reversible-install primitives, the EventAdapter-parameterized lifecycle
* handler bodies, and the jsonRegister merge helper proven by the Wave-1
* @waggle/hive-mind-hooks-claude-code reference. Codec-agnostic: YAML/JSON5
* parsers live in the consumer packages, not here.
*/
// Per-tool event mapping + field extraction contract.
export * from './event-adapter.js';
// Filesystem path + command-string helpers (backup paths, cli-path quoting).
export * from './paths-core.js';
// Reversible-install primitives (byte-identical backup, pointer round-trip,
// create-if-missing + verified restore/uninstall).
export * from './install-core.js';
// Additive, marker-tagged, immutable JSON config merge for JSON-config tools.
export * from './json-register.js';
// Shared lifecycle handler bodies + factories (stdin/exit-0 + openclaw in-process).
export * from './handlers-core.js';
// Fail-open hook runner + stdin/argv helpers (re-authored from the CC _shared.ts).
export * from './hook-shared.js';

View File

@@ -0,0 +1,190 @@
/**
* Tool-agnostic reversible-install primitives.
*
* Generalizes the frozen Wave 1 claude-code install/uninstall logic with
* a CREATE-IF-MISSING mode the CC reference lacks: CC assumes the config
* file must pre-exist and throws if absent (install.ts:85-90). Codex /
* cursor / hermes / openclaw config files are optional and may not exist
* on a fresh machine, so install must be able to create a minimal
* skeleton and uninstall must DELETE-IF-WE-CREATED-IT vs
* RESTORE-BACKUP-IF-IT-EXISTED.
*
* Immutability contract: helpers here never mutate their inputs; the
* pointer object passed to `writePointer` is serialized as-is, and
* `restoreFromBackup` reads the pointer and acts on it without mutation.
*/
import { readFile, writeFile, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { backupPathFor } from './paths-core.js';
/**
* Pointer file recording what an install did, so uninstall is exact.
* Superset of the CC pointer: adds `created_by_us` (true when the config
* file did not pre-exist) so uninstall can delete vs restore.
*/
export interface InstallPointer {
version: string;
installed_at: string;
config_path: string;
/** null ⇔ created_by_us=true (no backup written — nothing to restore). */
settings_backup: string | null;
/** true if the config file did NOT pre-exist and we created it. */
created_by_us: boolean;
/** null for in-process tools (openclaw) that ship no hook-script dir. */
hooks_dir: string | null;
installed_hooks: readonly string[];
cli_path: string | null;
/** per-tool: e.g. openclaw hook dir names, hermes registered event keys. */
extra?: Record<string, unknown>;
}
export interface BackupResult {
/** Path the byte-identical backup was written to, or null if absent. */
backupPath: string | null;
/** Whether the config file existed at backup time. */
preExisted: boolean;
}
/**
* Write a byte-identical timestamped backup of an existing config file.
*
* preExisted=false → no backup written; caller records
* created_by_us=true in the pointer.
* preExisted=true → backup written with the EXACT original bytes
* (CC install.ts:104-106 idiom), so uninstall can
* restore the true pre-install state byte-for-byte
* even when the config codec round-trip is lossy
* (hermes YAML, openclaw JSON5).
*/
export async function backupByteIdentical(
configPath: string,
isoTimestamp: string,
): Promise<BackupResult> {
if (!existsSync(configPath)) {
return { backupPath: null, preExisted: false };
}
const originalBytes = await readFile(configPath);
const backupPath = backupPathFor(configPath, isoTimestamp);
await writeFile(backupPath, originalBytes);
return { backupPath, preExisted: true };
}
export async function writePointer(pointerPath: string, pointer: InstallPointer): Promise<void> {
await writeFile(pointerPath, JSON.stringify(pointer, null, 2) + '\n', 'utf-8');
}
function isInstallPointer(value: unknown): value is InstallPointer {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return (
typeof v['config_path'] === 'string' &&
typeof v['created_by_us'] === 'boolean' &&
(v['settings_backup'] === null || typeof v['settings_backup'] === 'string') &&
Array.isArray(v['installed_hooks'])
);
}
/** Read + validate an install pointer. Throws if absent or malformed. */
export async function readPointer(pointerPath: string): Promise<InstallPointer> {
if (!existsSync(pointerPath)) {
throw new Error(`no install pointer found at ${pointerPath}.`);
}
const raw = await readFile(pointerPath, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(
`install pointer at ${pointerPath} is malformed: ` +
(err instanceof Error ? err.message : String(err)),
);
}
if (!isInstallPointer(parsed)) {
throw new Error(`install pointer at ${pointerPath} is malformed`);
}
return parsed;
}
export interface RestoreResult {
/** The backup we 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 file we created. */
createdRemoved: boolean;
/** True when the backup file was cleaned up after a successful restore. */
backupRemoved: boolean;
}
/**
* Round-trip-verified restore. Mirrors CC uninstall.ts:71-89.
*
* created_by_us=false → restore the backup byte-identically: write the
* backup bytes over configPath, re-read, assert
* byte equality, and REFUSE to delete the backup
* unless the readback matches.
* created_by_us=true → DELETE the config file we created (never orphan
* it); restoredFrom=null.
*
* Never mutates the pointer.
*/
export async function restoreFromBackup(args: {
configPath: string;
pointer: InstallPointer;
/** Clean up the backup file after a verified restore. Default true. */
cleanupBackup?: boolean;
/**
* Test seam: override fs read/write to exercise the round-trip
* verification-failure branch (a write→read mismatch is otherwise
* impossible to provoke on a real filesystem). Defaults to node:fs/promises.
*/
io?: {
readFile?: (p: string) => Promise<Buffer>;
writeFile?: (p: string, data: Buffer) => Promise<void>;
};
}): Promise<RestoreResult> {
const { configPath, pointer } = args;
const cleanup = args.cleanupBackup ?? true;
const rf = args.io?.readFile ?? ((p: string): Promise<Buffer> => readFile(p));
const wf = args.io?.writeFile ?? ((p: string, data: Buffer): Promise<void> => writeFile(p, data));
// Branch A: we created the config file — delete it, never orphan it.
if (pointer.created_by_us) {
if (existsSync(configPath)) {
await unlink(configPath);
}
return { restoredFrom: null, createdRemoved: true, backupRemoved: false };
}
// Branch B: config pre-existed — restore the byte-identical backup.
if (pointer.settings_backup === null) {
throw new Error(
`pointer for ${configPath} has created_by_us=false but no settings_backup; ` +
`cannot restore.`,
);
}
if (!existsSync(pointer.settings_backup)) {
throw new Error(
`backup file referenced by the pointer is missing: ${pointer.settings_backup}`,
);
}
const backupBytes = await rf(pointer.settings_backup);
await wf(configPath, backupBytes);
// Round-trip verification: read what we just wrote and compare bytes.
const verifyBytes = await rf(configPath);
if (!backupBytes.equals(verifyBytes)) {
throw new Error(
`uninstall verification failed: ${configPath} content differs from backup ` +
`${pointer.settings_backup}. Backup was NOT removed; restore manually if needed.`,
);
}
let backupRemoved = false;
if (cleanup) {
await unlink(pointer.settings_backup);
backupRemoved = true;
}
return { restoredFrom: pointer.settings_backup, createdRemoved: false, backupRemoved };
}

View File

@@ -0,0 +1,142 @@
/**
* Generalized additive JSON-config register/unregister helper.
*
* Generalizes the frozen Wave 1 claude-code `mergeHiveHooks`
* (settings-merger.ts:72-101) for any JSON-config tool whose event keys
* map to ARRAYS of hook groups (codex `{matcher,hooks:[]}`, cursor
* `{command,type,timeout}`). Additive merge + marker tag + dedup /
* replace-in-place.
*
* Immutability contract: every function returns a NEW object and never
* mutates its input (mirrors the CC settings-merger contract). Used by
* codex, codex-desktop (via codex), and cursor. Hermes (YAML) and
* OpenClaw (JSON5 + dirs) have bespoke codecs and do NOT use this.
*/
import type { Lifecycle } from './event-adapter.js';
/** Base marker; per-tool suffix appended, e.g. '@hive-mind/codex-hooks'. */
export const HIVE_MIND_MARKER_BASE = '@hive-mind';
export interface JsonRegisterEntry {
lifecycle: Lifecycle;
command: string;
timeout: number;
}
export interface JsonRegisterSpec {
/** Top-level object key holding the per-event map (e.g. 'hooks'). */
hooksKey: string;
/** Canonical lifecycle → tool event-key map (from EventAdapter.eventName). */
eventName: Record<Lifecycle, string | undefined>;
/**
* Build the tool-shaped group object for one hook entry. Must stamp the
* marker so `isHiveGroup` can later detect our own entries for
* replace/remove (codex uses {matcher,hooks:[...]}; cursor uses a flat
* {command,type,timeout}).
*/
buildGroup(lifecycle: Lifecycle, command: string, timeout: number): Record<string, unknown>;
/** Reads the marker off a group to detect our own entries. */
isHiveGroup(group: unknown): boolean;
/**
* Reads the command string out of a group so dedup can match by
* (eventKey, command). Returns undefined when the group has no command.
*/
groupCommand(group: unknown): string | undefined;
/** Optional skeleton seed (e.g. cursor needs {version:1}). */
ensureSkeleton?(root: Record<string, unknown>): Record<string, unknown>;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function asGroupArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? (value as Record<string, unknown>[]) : [];
}
/**
* Returns a NEW config object with hive-mind hook entries registered
* under each tool event key. Existing non-hive entries are preserved
* verbatim. If a hive entry for the same (eventKey, command) already
* exists it is replaced in place rather than duplicated — supports
* re-running install for upgrades.
*/
export function jsonRegister(
config: Record<string, unknown> | undefined,
entries: readonly JsonRegisterEntry[],
spec: JsonRegisterSpec,
): Record<string, unknown> {
// Shallow-copy the root, then apply the optional skeleton seed.
let next: Record<string, unknown> = config ? { ...config } : {};
if (spec.ensureSkeleton) next = spec.ensureSkeleton(next);
const existingHooks = asRecord(next[spec.hooksKey]);
const nextHooks: Record<string, unknown> = existingHooks ? { ...existingHooks } : {};
for (const entry of entries) {
const eventKey = spec.eventName[entry.lifecycle];
if (eventKey === undefined) continue; // tool has no native event for this lifecycle
const existingArr = asGroupArray(nextHooks[eventKey]).slice();
const newGroup = spec.buildGroup(entry.lifecycle, entry.command, entry.timeout);
let replaced = false;
for (let i = 0; i < existingArr.length; i += 1) {
const g = existingArr[i];
if (spec.isHiveGroup(g) && spec.groupCommand(g) === entry.command) {
existingArr[i] = newGroup;
replaced = true;
break;
}
}
if (!replaced) existingArr.push(newGroup);
nextHooks[eventKey] = existingArr;
}
next[spec.hooksKey] = nextHooks;
return next;
}
/**
* Returns a NEW config object with all marker-tagged hive groups stripped
* from every event array. Non-hive entries are preserved verbatim. Empty
* event arrays are left in place (minimal-touch). Never mutates input.
*/
export function jsonUnregister(
config: Record<string, unknown> | undefined,
spec: JsonRegisterSpec,
): Record<string, unknown> {
const next: Record<string, unknown> = config ? { ...config } : {};
const existingHooks = asRecord(next[spec.hooksKey]);
if (!existingHooks) return next;
const nextHooks: Record<string, unknown> = {};
for (const [eventKey, value] of Object.entries(existingHooks)) {
const arr = asGroupArray(value);
if (arr.length === 0) {
nextHooks[eventKey] = value;
continue;
}
nextHooks[eventKey] = arr.filter((g) => !spec.isHiveGroup(g));
}
next[spec.hooksKey] = nextHooks;
return next;
}
/** True iff at least one event array carries a marker-tagged hive group. */
export function hasHiveEntries(
config: Record<string, unknown> | undefined,
spec: JsonRegisterSpec,
): boolean {
if (!config) return false;
const hooks = asRecord(config[spec.hooksKey]);
if (!hooks) return false;
for (const value of Object.values(hooks)) {
if (asGroupArray(value).some((g) => spec.isHiveGroup(g))) return true;
}
return false;
}

View File

@@ -0,0 +1,81 @@
/**
* Tool-agnostic filesystem path helpers shared by every hook port.
*
* Mirrors the frozen Wave 1 claude-code `paths.ts` idioms (Windows-safe
* backup paths + `--cli-path` quoting) but parameterized so each tool
* package can plug in its own config root, pointer name, and hook script
* basenames.
*/
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
/**
* Filesystem-safe backup path for a config file at `configPath`.
* Mirrors claude-code paths.ts:86 — replaces `:` and `.` in the ISO
* timestamp with `-` so the filename is valid on Windows.
*/
export function backupPathFor(configPath: string, isoTimestamp: string): string {
const stamp = isoTimestamp.replace(/[:.]/g, '-');
return `${configPath}.hive-mind-backup.${stamp}`;
}
/**
* Build the host-tool hook command string for a compiled hook script.
* Mirrors claude-code paths.ts:74-84 — quotes paths so spaces in the
* user's home dir (Windows: "C:\Users\Marko Markovic\") don't fragment
* the command, and routes through `node` so `.js` scripts run
* cross-platform without a shell launching an npm `.cmd` bin shim.
*
* @param scriptPath absolute path to the compiled `dist/hooks/<event>.js`.
* @param cliPath optional install-time-pinned hive-mind-cli path,
* threaded as `--cli-path "<value>"`.
*/
export function hookCommandFor(scriptPath: string, cliPath?: string): string {
const cliFlag = cliPath && cliPath.length > 0 ? ` --cli-path "${cliPath}"` : '';
return `${hookNodeCommand()} "${scriptPath}"${cliFlag}`;
}
function hookNodeCommand(): string {
const configured = process.env.WAGGLE_HOOK_NODE_PATH?.trim();
if (!configured) return 'node';
if (configured.includes('"')) throw new Error('WAGGLE_HOOK_NODE_PATH cannot contain double quotes');
return `"${configured}"`;
}
/**
* Reject `--cli-path` values containing embedded double-quotes — they
* would break the `--cli-path "<value>"` quoting in the generated hook
* command. Lifted verbatim from claude-code install.ts:142-154.
*
* Returns undefined for undefined / empty / whitespace-only input.
* Throws on a double-quote so the caller fails loudly at install time.
*/
export function normalizeCliPath(input: string | undefined): string | undefined {
if (input === undefined) return undefined;
const trimmed = input.trim();
if (trimmed.length === 0) return undefined;
if (trimmed.includes('"')) {
throw new Error(
`--cli-path value must not contain double-quote characters; got: ${trimmed.slice(0, 80)}`,
);
}
return trimmed;
}
/**
* Resolve a package's compiled `dist/hooks/` directory from the caller's
* `import.meta.url`. The compiled install module lives at
* `<pkg>/dist/<file>.js`; dirname gives `<pkg>/dist/`, join gives
* `<pkg>/dist/hooks/`. Resolved once more so the path is absolute and
* platform-normalized.
*/
export function hooksDirFromModuleUrl(moduleUrl: string): string {
const dir = dirname(fileURLToPath(moduleUrl));
return resolve(dir, 'hooks');
}
/** Absolute path to a compiled hook script `<hooksDir>/<basename>.js`. */
export function hookScriptPath(hooksDir: string, basename: string): string {
return join(hooksDir, `${basename}.js`);
}

View File

@@ -0,0 +1,154 @@
import { vi } from 'vitest';
import type { CliBridge, Logger, MemoryHit, ShimSource } from '@waggle/hive-mind-shim-core';
import type { EventAdapter, Lifecycle } from '../src/event-adapter.js';
import type { HookContext } from '../src/hook-shared.js';
// ── Mock CliBridge ──────────────────────────────────────────────────────
// Mirrors the CC `_test-helpers.ts` mock bridge so handler tests assert on
// the exact recallMemory / saveMemory / cleanupFrames calls without ever
// spawning hive-mind-cli.
export interface MockBridgeOverrides {
saveMemoryResult?: { id: string; success: boolean; workspace: string };
recallMemoryHits?: MemoryHit[];
cleanupFramesResult?: { pruned: number };
saveMemoryThrows?: Error;
recallMemoryThrows?: Error;
cleanupFramesThrows?: 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>;
}
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 = overrides.recallMemoryThrows
? vi.fn(async () => { throw overrides.recallMemoryThrows; })
: vi.fn(async () => overrides.recallMemoryHits ?? []);
const cleanupFrames = overrides.cleanupFramesThrows
? vi.fn(async () => { throw overrides.cleanupFramesThrows; })
: 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;
}
// ── Mock Logger ─────────────────────────────────────────────────────────
// The shared handler bodies log via ctx.logger.{debug,warn}. A no-op logger
// keeps test output clean while still satisfying the Logger interface.
export function makeMockLogger(): Logger & {
debug: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
} {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
export function makeCtx(bridge: CliBridge): HookContext {
return { bridge, logger: makeMockLogger() };
}
// ── Mock EventAdapter ───────────────────────────────────────────────────
// A minimal, configurable adapter that reads snake_case keys off a flat
// payload object — enough to drive every shared handler body.
export interface MockAdapterOverrides {
source?: ShimSource;
eventName?: Partial<Record<Lifecycle, string | undefined>>;
/** Provide a custom formatInject; pass `null` to leave it undefined. */
formatInject?: ((text: string) => unknown) | null;
/** Override extractResponse (e.g. to simulate an async transcript read). */
extractResponse?: EventAdapter['extractResponse'];
}
const DEFAULT_EVENT_NAME: Record<Lifecycle, string | undefined> = {
'session-start': 'SessionStart',
'user-prompt-submit': 'UserPromptSubmit',
stop: 'Stop',
'pre-compact': 'PreCompact',
};
function pick(payload: unknown, ...keys: string[]): string | undefined {
if (!payload || typeof payload !== 'object') return undefined;
const obj = payload as Record<string, unknown>;
for (const k of keys) {
const v = obj[k];
if (typeof v === 'string' && v.length > 0) return v;
}
return undefined;
}
export function makeMockAdapter(overrides: MockAdapterOverrides = {}): EventAdapter {
const eventName: Record<Lifecycle, string | undefined> = {
...DEFAULT_EVENT_NAME,
...overrides.eventName,
};
const adapter: EventAdapter = {
source: overrides.source ?? 'cursor',
eventName,
extractCwd: (p) => pick(p, 'cwd'),
extractSessionId: (p) => pick(p, 'session_id', 'sessionId'),
extractPrompt: (p) => pick(p, 'prompt'),
extractResponse:
overrides.extractResponse ??
((p) => pick(p, 'response', 'assistant_message')),
extractParent: (p) => pick(p, 'parent', 'parent_frame_id'),
};
if (overrides.formatInject === undefined) {
// Default: provide a renamed inject shape so we can assert the seam fires.
adapter.formatInject = (text: string): unknown => ({ additional_context: text });
} else if (overrides.formatInject !== null) {
adapter.formatInject = overrides.formatInject;
}
// formatInject === null → leave undefined (no inject seam).
return adapter;
}
export const HIT_FIXTURE: MemoryHit = {
id: 1,
content: '[hm src:cursor event:stop] past observation',
importance: 'important',
source: 'system',
score: 0.87,
created_at: '2026-05-28T10:00:00.000Z',
from: 'personal',
};
/** Run `fn` with `process.env[key]` temporarily set, then restore. */
export function withEnv<T>(
key: string,
value: string | undefined,
fn: () => Promise<T>,
): Promise<T> {
const prev = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
return fn().finally(() => {
if (prev === undefined) delete process.env[key];
else process.env[key] = prev;
});
}

View File

@@ -0,0 +1,373 @@
import { describe, expect, it } from 'vitest';
import {
makeOpenclawHandler,
makePreCompactHandler,
makeSessionStartHandler,
makeStopHandler,
makeUserPromptSubmitHandler,
type OpenclawHandlerInput,
type PreCompactExtracted,
type SessionStartExtracted,
type StopExtracted,
type UserPromptExtracted,
} from '../src/handlers-core.js';
import type { EventAdapter, Lifecycle } from '../src/event-adapter.js';
import { HIT_FIXTURE, makeCtx, makeMockAdapter, makeMockBridge, withEnv } from './_helpers.js';
// ── make*Handler factories (stdin-JSON / exit-0 drive path) ─────────────
describe('makeSessionStartHandler', () => {
it('recalls personal-scoped frames and returns the adapter inject shape', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
const h = makeSessionStartHandler(a);
const payload = h.parse({ cwd: '/proj', recall_limit: 7 });
expect(payload.recallLimit).toBe(7);
const out = await h.run(payload, makeCtx(bridge));
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 7, scope: 'personal', workspace: null });
// Mock adapter renames the inject seam to { additional_context }.
expect(out).toMatchObject({ additional_context: expect.stringContaining('past observation') });
});
it('defaults recallLimit to opts.recallLimit then 20', () => {
const a = makeMockAdapter();
expect(makeSessionStartHandler(a).parse({}).recallLimit).toBe(20);
expect(makeSessionStartHandler(a, { recallLimit: 5 }).parse({}).recallLimit).toBe(5);
// payload value wins over opts.
expect(makeSessionStartHandler(a, { recallLimit: 5 }).parse({ recall_limit: 9 }).recallLimit).toBe(9);
});
it('falls back to the CC hookSpecificOutput shape when the adapter has no formatInject', async () => {
const a = makeMockAdapter({ formatInject: null, source: 'codex' });
const bridge = makeMockBridge({ recallMemoryHits: [] });
const out = (await makeSessionStartHandler(a).run({ cwd: '/p', sessionId: undefined, recallLimit: 20 }, makeCtx(bridge))) as {
hookSpecificOutput: { hookEventName: string; source: string; additionalContext: string };
};
expect(out.hookSpecificOutput.hookEventName).toBe('SessionStart');
expect(out.hookSpecificOutput.source).toBe('codex');
expect(out.hookSpecificOutput.additionalContext).toContain('no recalled frames');
});
});
describe('makeUserPromptSubmitHandler', () => {
it('saves a temporary frame carrying the prompt', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeUserPromptSubmitHandler(a);
const payload = h.parse({ prompt: 'do the thing', cwd: '/proj', session_id: 's1' });
await h.run(payload, makeCtx(bridge));
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0];
expect(frame.importance).toBe('temporary');
expect(frame.content).toBe('do the thing');
expect(frame.source).toBe(a.source);
});
it('skips the save when the prompt is empty', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeUserPromptSubmitHandler(a);
await h.run(h.parse({ cwd: '/proj' }), makeCtx(bridge));
expect(bridge.saveMemory).not.toHaveBeenCalled();
});
});
describe('makeStopHandler', () => {
it('summarizes the turn and saves an important frame', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeStopHandler(a);
const longResp = 'First sentence. ' + 'X'.repeat(2000) + '.';
await h.run(h.parse({ response: longResp, cwd: '/proj', session_id: 's2' }), makeCtx(bridge));
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0];
expect(['important', 'critical']).toContain(frame.importance);
expect(frame.content.length).toBeLessThanOrEqual(401); // budget + ellipsis
});
it('promotes to critical on a "never" directive and attaches the parent frame id', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeStopHandler(a);
await h.run(
h.parse({ response: 'never commit secrets to the public repo.', cwd: '/proj', parent_frame_id: 'frame-99' }),
makeCtx(bridge),
);
const frame = bridge.saveMemory.mock.calls[0][0];
expect(frame.importance).toBe('critical');
expect(frame.parent).toBe('frame-99');
});
it('skips the save when the response is empty', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeStopHandler(a);
await h.run(h.parse({ cwd: '/proj' }), makeCtx(bridge));
expect(bridge.saveMemory).not.toHaveBeenCalled();
});
});
describe('makePreCompactHandler', () => {
it('calls cleanupFrames', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge({ cleanupFramesResult: { pruned: 3 } });
const h = makePreCompactHandler(a);
await h.run(h.parse({ session_id: 's3' }), makeCtx(bridge));
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
});
});
// ── makeOpenclawHandler (in-process drive path) ─────────────────────────
// OpenClaw adapter: eventName values are the native `type:action` keys.
function openclawAdapter(): EventAdapter {
const eventName: Record<Lifecycle, string | undefined> = {
'session-start': 'agent:bootstrap',
'user-prompt-submit': 'message:received',
stop: 'message:sent',
// HOOK.md uses the session-prefixed key; runtime action is 'compact:before'.
'pre-compact': 'session:compact:before',
};
return makeMockAdapter({ source: 'openclaw', eventName, formatInject: null });
}
describe('makeOpenclawHandler — lifecycle dispatch', () => {
it('SessionStart (agent:bootstrap) recalls memory; consumer mutates bootstrapFiles', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
const handler = makeOpenclawHandler(a);
// The body returns the recalled/formatted text; the openclaw adapter is
// responsible for pushing it onto event.context.bootstrapFiles. We model
// that by capturing the recall and asserting the consumer can mutate.
const bootstrapFiles: string[] = [];
const input: OpenclawHandlerInput = {
event: { type: 'agent', action: 'bootstrap', context: { bootstrapFiles } },
extracted: { cwd: '/proj', sessionId: 's1', recallLimit: 20 } as SessionStartExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 20, scope: 'personal', workspace: null });
// Simulate the adapter's documented mutation seam working end-to-end.
bootstrapFiles.push('recalled');
expect((input.event.context as { bootstrapFiles: string[] }).bootstrapFiles).toEqual(['recalled']);
});
it('UserPromptSubmit (message:received) saves a temporary frame', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'message', action: 'received' },
extracted: { prompt: 'hi there', cwd: '/proj', sessionId: 's1' } as UserPromptExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(bridge.saveMemory.mock.calls[0][0].importance).toBe('temporary');
});
it('Stop (message:sent) saves an important/critical frame', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'message', action: 'sent' },
extracted: { cwd: '/proj', sessionId: 's1', response: 'we decided to ship it.', parent: undefined } as StopExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(['important', 'critical']).toContain(bridge.saveMemory.mock.calls[0][0].importance);
});
it('PreCompact matches on action "compact:before" (NOT the joined string) and cleans frames', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'session', action: 'compact:before' },
extracted: { scope: 's1' } as PreCompactExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
});
it('ignores an event with no lifecycle match (no bridge calls)', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'gateway', action: 'pre-restart' },
extracted: { scope: undefined } as PreCompactExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(bridge.recallMemory).not.toHaveBeenCalled();
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
});
it('does NOT map an unrelated type whose action suffix collides (suffix-match is pre-compact-only)', async () => {
// 'message:sent' is Stop's native key; a 'gateway:sent' event shares the
// ':sent' suffix but must NOT route to Stop — the action-suffix fallback is
// reserved for pre-compact's compact:before alias.
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
await handler.handle(
{ event: { type: 'gateway', action: 'sent' }, extracted: { cwd: '/p', sessionId: 's1', response: 'x' } as StopExtracted },
makeCtx(bridge),
);
expect(bridge.saveMemory).not.toHaveBeenCalled();
});
it('debounces message:sent — every dispatch resolves, only the last saves', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a, { stopDebounceMs: 5 });
const mk = (response: string): OpenclawHandlerInput => ({
event: { type: 'message', action: 'sent', sessionKey: 'turn-1' },
extracted: { cwd: '/proj', sessionId: 's1', response, parent: undefined } as StopExtracted,
});
// Fire three message:sent for the same turn and AWAIT ALL of them. Each
// superseded dispatch MUST resolve (not hang) — the host awaits handlers
// sequentially, so a leaked promise would block the host event loop
// forever (fail-open invariant §7.3(1)). With the old code the first two
// promises never settled and this Promise.all would time out.
await Promise.all([
handler.handle(mk('first decided.'), makeCtx(bridge)),
handler.handle(mk('second decided.'), makeCtx(bridge)),
handler.handle(mk('third decided.'), makeCtx(bridge)),
]);
// Last-writer-wins: exactly one save, carrying the final payload.
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(bridge.saveMemory.mock.calls[0][0].content).toContain('third decided');
});
});
describe('makeOpenclawHandler — FAIL-OPEN (invariant §7.3(1))', () => {
it('swallows a body error, resolves the promise, and never throws', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli unreachable') });
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'message', action: 'received' },
extracted: { prompt: 'will explode on save', cwd: '/proj', sessionId: 's1' } as UserPromptExtracted,
};
// Must resolve (not reject) despite the bridge throwing.
await expect(handler.handle(input, makeCtx(bridge))).resolves.toBeUndefined();
});
it('fails open when recallMemory rejects on SessionStart', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge({ recallMemoryThrows: new Error('boom') });
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'agent', action: 'bootstrap', context: {} },
extracted: { cwd: '/proj', sessionId: 's1', recallLimit: 20 } as SessionStartExtracted,
};
await expect(handler.handle(input, makeCtx(bridge))).resolves.toBeUndefined();
});
it('swallows a rejecting save in the DEBOUNCE branch (no unhandled rejection)', async () => {
// The debounced Stop save fires in a detached timer AFTER dispatch's
// try/catch returned, so a rejection there would escape as an unhandled
// rejection unless explicitly caught. Regression guard for the openclaw
// stopDebounceMs path (the only caller that sets it).
const a = openclawAdapter();
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli down') });
const handler = makeOpenclawHandler(a, { stopDebounceMs: 5 });
const rejections: unknown[] = [];
const onRej = (e: unknown): void => { rejections.push(e); };
process.on('unhandledRejection', onRej);
try {
await handler.handle(
{ event: { type: 'message', action: 'sent', sessionKey: 't1' }, extracted: { cwd: '/p', sessionId: 's1', response: 'final', parent: undefined } as StopExtracted },
makeCtx(bridge),
);
// Flush any detached microtask carrying an unhandled rejection.
await new Promise((r) => setTimeout(r, 20));
} finally {
process.off('unhandledRejection', onRej);
}
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(rejections).toEqual([]);
});
});
// ── Stop signal-emit opt-in (mirrors CC stop.ts Phase 1E) ───────────────
describe('makeStopHandler — WAGGLE_SIGNAL_EMIT opt-in', () => {
function withCapturedFetch<T>(fetchImpl: typeof globalThis.fetch, fn: () => Promise<T>): Promise<T> {
const original = globalThis.fetch;
globalThis.fetch = fetchImpl;
return fn().finally(() => { globalThis.fetch = original; });
}
function makeOkFetch(): typeof globalThis.fetch & { calls: Array<{ url: string; body: unknown }> } {
const calls: Array<{ url: string; body: unknown }> = [];
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
const body = init?.body ? JSON.parse(String(init.body)) : null;
calls.push({ url: String(url), body });
return new Response(JSON.stringify({ dispatched: true, message: { id: 'srv-1' } }), {
status: 201,
headers: { 'content-type': 'application/json' },
});
}) as typeof globalThis.fetch & { calls: typeof calls };
impl.calls = calls;
return impl;
}
it('does not emit when WAGGLE_SIGNAL_EMIT is unset', async () => {
const a = makeMockAdapter({ source: 'cursor' });
const bridge = makeMockBridge();
const f = makeOkFetch();
await withEnv('WAGGLE_SIGNAL_EMIT', undefined, () =>
withCapturedFetch(f, () =>
makeStopHandler(a).run(
makeStopHandler(a).parse({ response: 'never do that.', cwd: '/p' }),
makeCtx(bridge),
),
),
);
expect(f.calls).toHaveLength(0);
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
});
it('emits a discovery signal on a critical turn when WAGGLE_SIGNAL_EMIT=1', async () => {
const a = makeMockAdapter({ source: 'cursor' });
const bridge = makeMockBridge();
const f = makeOkFetch();
const h = makeStopHandler(a);
await withEnv('WAGGLE_SIGNAL_EMIT', '1', () =>
withCapturedFetch(f, () =>
h.run(h.parse({ response: 'never commit secrets.', cwd: '/p', session_id: 'sc' }), makeCtx(bridge)),
),
);
expect(f.calls).toHaveLength(1);
const body = f.calls[0].body as { content: Record<string, unknown>; senderId: string };
expect(body.senderId).toBe('cursor-hook');
expect(body.content.tool).toBe('cursor');
expect(body.content.importance).toBe('critical');
expect(body.content.frameId).toBe('frame-1');
expect(body.content.memoryWorkspace).toBe('personal');
expect(body.content.summary).toContain('never commit secrets');
});
it('still saves the frame even when the signal endpoint is unreachable (fail-open)', async () => {
const a = makeMockAdapter({ source: 'cursor' });
const bridge = makeMockBridge();
const unreachable = (async () => { throw new Error('ECONNREFUSED'); }) as typeof globalThis.fetch;
const h = makeStopHandler(a);
await withEnv('WAGGLE_SIGNAL_EMIT', 'true', () =>
withCapturedFetch(unreachable, () =>
h.run(h.parse({ response: 'never commit secrets.', cwd: '/p' }), makeCtx(bridge)),
),
);
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest';
import {
parseHookArgs,
pickStringField,
pickStringFromObject,
runHook,
safeJsonParse,
type HookContext,
type HookHandler,
} from '../src/hook-shared.js';
import { makeMockBridge, makeMockLogger } from './_helpers.js';
// A capture rig for stdout/exit (mirrors the CC _test-helpers shape).
function makeCaptures(): {
stdout: string[];
exits: number[];
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) };
}
describe('safeJsonParse', () => {
it('returns {} for empty / whitespace input', () => {
expect(safeJsonParse('')).toEqual({});
expect(safeJsonParse(' ')).toEqual({});
});
it('returns parsed JSON when valid', () => {
expect(safeJsonParse('{"a":1}')).toEqual({ a: 1 });
});
it('returns {} when JSON is malformed (garbage)', () => {
expect(safeJsonParse('not json')).toEqual({});
expect(safeJsonParse('{')).toEqual({});
});
});
describe('parseHookArgs', () => {
it('extracts --cli-path value when present', () => {
expect(parseHookArgs(['--cli-path', '/abs/cli.js'])).toEqual({ cliPath: '/abs/cli.js' });
});
it('returns {} when --cli-path is absent', () => {
expect(parseHookArgs([])).toEqual({});
expect(parseHookArgs(['--other-flag', 'value'])).toEqual({});
});
it('returns {} when --cli-path has no following value', () => {
expect(parseHookArgs(['--cli-path'])).toEqual({});
});
it('rejects empty-string value as missing', () => {
expect(parseHookArgs(['--cli-path', ''])).toEqual({});
});
it('handles the flag in the middle of argv', () => {
expect(parseHookArgs(['--foo', 'bar', '--cli-path', '/x.js', '--baz'])).toEqual({ cliPath: '/x.js' });
});
});
describe('pickStringField / pickStringFromObject', () => {
it('returns the first non-empty string match', () => {
expect(pickStringField({ a: 'x', b: 'y' }, 'a', 'b')).toBe('x');
expect(pickStringField({ a: '', b: 'y' }, 'a', 'b')).toBe('y');
});
it('returns undefined when no key resolves', () => {
expect(pickStringField({}, 'a')).toBeUndefined();
expect(pickStringField(null, 'a')).toBeUndefined();
expect(pickStringField(undefined, 'a')).toBeUndefined();
});
it('pickStringFromObject only treats non-empty strings as hits', () => {
expect(pickStringFromObject({ a: 1 } as Record<string, unknown>, 'a')).toBeUndefined();
expect(pickStringFromObject({ a: 'ok' }, 'a')).toBe('ok');
expect(pickStringFromObject({ a: '' }, 'a')).toBeUndefined();
});
});
describe('runHook — happy path', () => {
it('parses stdin, runs the handler, writes stdout JSON, and exits 0', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<{ value: string }, { echoed: string }> = {
parse(raw): { value: string } {
return { value: (raw as Record<string, unknown>).value as string };
},
async run(payload): Promise<{ echoed: string }> {
return { echoed: payload.value };
},
};
await runHook(handler, {
name: 'demo',
readStdin: async () => JSON.stringify({ value: 'hi' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
expect(cap.exits).toEqual([0]);
expect(JSON.parse(cap.stdout[0])).toEqual({ echoed: 'hi' });
});
it('threads --cli-path through to the handler context via the bridge override path', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
let sawCtx: HookContext | undefined;
const handler: HookHandler<unknown, undefined> = {
parse: (raw) => raw,
async run(_payload, ctx): Promise<undefined> {
sawCtx = ctx;
return undefined;
},
};
await runHook(handler, {
name: 'demo',
argv: ['--cli-path', '/abs/cli/dist/index.js'],
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
// No stdout emitted when run returns undefined.
expect(cap.stdout).toHaveLength(0);
expect(cap.exits).toEqual([0]);
expect(sawCtx?.bridge).toBe(bridge);
});
});
describe('runHook — FAIL-OPEN (invariant §7.3(1))', () => {
it('exits 0 when the handler body throws (injected bridge error)', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<unknown, undefined> = {
parse: (raw) => raw,
async run(): Promise<undefined> {
throw new Error('cli unreachable');
},
};
await runHook(handler, {
name: 'demo',
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
expect(cap.exits).toEqual([0]);
});
it('exits 0 when parse throws on malformed stdin (safeJsonParse yields {}, parse explodes)', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<unknown, undefined> = {
parse(): unknown {
throw new Error('bad shape');
},
async run(): Promise<undefined> {
return undefined;
},
};
await runHook(handler, {
name: 'demo',
readStdin: async () => 'not json at all',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
expect(cap.exits).toEqual([0]);
});
it('exits 0 when the stdin reader itself rejects', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<unknown, undefined> = {
parse: (raw) => raw,
async run(): Promise<undefined> {
return undefined;
},
};
await runHook(handler, {
name: 'demo',
readStdin: async () => { throw new Error('stdin broke'); },
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
expect(cap.exits).toEqual([0]);
});
it('never throws to the caller even on a thrown body (promise resolves)', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<unknown, undefined> = {
parse: (raw) => raw,
async run(): Promise<undefined> { throw new Error('boom'); },
};
await expect(
runHook(handler, {
name: 'demo',
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
}),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,233 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
backupByteIdentical,
readPointer,
restoreFromBackup,
writePointer,
type InstallPointer,
} from '../src/install-core.js';
import { backupPathFor } from '../src/paths-core.js';
const ISO = '2026-06-01T10:30:45.123Z';
function sha256(buf: Buffer | string): string {
return createHash('sha256').update(buf).digest('hex');
}
let dir: string;
async function tmp(): Promise<string> {
dir = await mkdtemp(join(tmpdir(), 'hmhc-install-'));
return dir;
}
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
function basePointer(over: Partial<InstallPointer> = {}): InstallPointer {
return {
version: '0.1.0',
installed_at: ISO,
config_path: '/x/cfg.json',
settings_backup: null,
created_by_us: false,
hooks_dir: null,
installed_hooks: ['stop'],
cli_path: null,
...over,
};
}
describe('backupByteIdentical', () => {
it('writes a byte-identical backup when the config pre-existed', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
// Include comments-as-content / odd bytes so we prove EXACT byte fidelity.
const original = '{\n "hooks": {},\n "user": "kept verbatim ✓"\n}\n';
await writeFile(configPath, original, 'utf-8');
const result = await backupByteIdentical(configPath, ISO);
expect(result.preExisted).toBe(true);
expect(result.backupPath).toBe(backupPathFor(configPath, ISO));
const backupBytes = await readFile(result.backupPath as string);
expect(sha256(backupBytes)).toBe(sha256(Buffer.from(original, 'utf-8')));
});
it('writes NO backup and reports preExisted=false when the config is absent', async () => {
const home = await tmp();
const configPath = join(home, 'does-not-exist.json');
const result = await backupByteIdentical(configPath, ISO);
expect(result.preExisted).toBe(false);
expect(result.backupPath).toBeNull();
expect(existsSync(backupPathFor(configPath, ISO))).toBe(false);
});
});
describe('writePointer / readPointer round-trip', () => {
it('round-trips a pointer through disk', async () => {
const home = await tmp();
const pointerPath = join(home, 'hive-mind-install.json');
const pointer = basePointer({
config_path: join(home, 'hooks.json'),
settings_backup: '/x/hooks.json.hive-mind-backup.stamp',
installed_hooks: ['session-start', 'stop'],
cli_path: '/abs/cli/dist/index.js',
extra: { dir: 'hive-mind' },
});
await writePointer(pointerPath, pointer);
const read = await readPointer(pointerPath);
expect(read).toEqual(pointer);
});
it('throws when the pointer file is absent', async () => {
const home = await tmp();
await expect(readPointer(join(home, 'missing.json'))).rejects.toThrow(/no install pointer/);
});
it('throws when the pointer JSON is malformed (not parseable)', async () => {
const home = await tmp();
const pointerPath = join(home, 'bad.json');
await writeFile(pointerPath, '{ not valid json', 'utf-8');
await expect(readPointer(pointerPath)).rejects.toThrow(/malformed/);
});
it('throws when the pointer JSON parses but fails the shape check', async () => {
const home = await tmp();
const pointerPath = join(home, 'shape.json');
await writeFile(pointerPath, JSON.stringify({ hello: 'world' }), 'utf-8');
await expect(readPointer(pointerPath)).rejects.toThrow(/malformed/);
});
});
describe('restoreFromBackup — created_by_us=false (byte-identical restore)', () => {
it('restores the pre-install bytes exactly and removes the backup by default', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
const original = '{\n "hooks": { "Stop": [] },\n "keep": "me"\n}\n';
await writeFile(configPath, original, 'utf-8');
const preHash = sha256(await readFile(configPath));
// Simulate install: byte-identical backup, then mutate the live config.
const { backupPath } = await backupByteIdentical(configPath, ISO);
await writeFile(configPath, '{ "hooks": { "Stop": ["MUTATED"] } }', 'utf-8');
expect(sha256(await readFile(configPath))).not.toBe(preHash);
const pointer = basePointer({
config_path: configPath,
settings_backup: backupPath,
created_by_us: false,
});
const result = await restoreFromBackup({ configPath, pointer });
expect(result.createdRemoved).toBe(false);
expect(result.restoredFrom).toBe(backupPath);
expect(result.backupRemoved).toBe(true);
// Invariant §7.3(2): config is byte-identical to pre-install state.
expect(sha256(await readFile(configPath))).toBe(preHash);
expect(await readFile(configPath, 'utf-8')).toBe(original);
expect(existsSync(backupPath as string)).toBe(false);
});
it('keeps the backup when cleanupBackup=false', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
await writeFile(configPath, 'ORIGINAL', 'utf-8');
const { backupPath } = await backupByteIdentical(configPath, ISO);
await writeFile(configPath, 'MUTATED', 'utf-8');
const pointer = basePointer({ config_path: configPath, settings_backup: backupPath });
const result = await restoreFromBackup({ configPath, pointer, cleanupBackup: false });
expect(result.backupRemoved).toBe(false);
expect(existsSync(backupPath as string)).toBe(true);
expect(await readFile(configPath, 'utf-8')).toBe('ORIGINAL');
});
it('refuses to delete the backup and throws when the readback does not match', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
await writeFile(configPath, 'ORIGINAL', 'utf-8');
const { backupPath } = await backupByteIdentical(configPath, ISO);
const pointer = basePointer({ config_path: configPath, settings_backup: backupPath });
// Force a round-trip mismatch via the io seam: the verify read returns
// bytes that differ from the backup, so the guard MUST throw and MUST NOT
// delete the backup (a failed verification leaves recovery possible).
await expect(
restoreFromBackup({
configPath,
pointer,
io: {
readFile: async (p) =>
p === backupPath ? Buffer.from('ORIGINAL') : Buffer.from('CORRUPTED'),
writeFile: async () => {},
},
}),
).rejects.toThrow(/verification failed/);
// The backup survives a failed verification — never deleted on mismatch.
expect(existsSync(backupPath as string)).toBe(true);
});
it('throws when created_by_us=false but settings_backup is null', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
await writeFile(configPath, 'x', 'utf-8');
const pointer = basePointer({ config_path: configPath, settings_backup: null, created_by_us: false });
await expect(restoreFromBackup({ configPath, pointer })).rejects.toThrow(/cannot restore/);
});
it('throws when the referenced backup file is missing', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
await writeFile(configPath, 'x', 'utf-8');
const pointer = basePointer({
config_path: configPath,
settings_backup: join(home, 'ghost-backup'),
created_by_us: false,
});
await expect(restoreFromBackup({ configPath, pointer })).rejects.toThrow(/backup file referenced/);
});
});
describe('restoreFromBackup — created_by_us=true (delete the file we created)', () => {
it('deletes the config file we created and leaves no orphan, no backup', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
// Install created this file fresh (no backup ever written).
await writeFile(configPath, '{ "hooks": {} }', 'utf-8');
const pointer = basePointer({
config_path: configPath,
settings_backup: null,
created_by_us: true,
});
const result = await restoreFromBackup({ configPath, pointer });
expect(result.createdRemoved).toBe(true);
expect(result.restoredFrom).toBeNull();
expect(result.backupRemoved).toBe(false);
// Invariant §7.3(2): the file we created is removed, no orphan.
expect(existsSync(configPath)).toBe(false);
expect(existsSync(backupPathFor(configPath, ISO))).toBe(false);
});
it('is a no-op-safe delete when the created file was already removed', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
const pointer = basePointer({ config_path: configPath, created_by_us: true });
const result = await restoreFromBackup({ configPath, pointer });
expect(result.createdRemoved).toBe(true);
expect(existsSync(configPath)).toBe(false);
});
});

View File

@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest';
import {
HIVE_MIND_MARKER_BASE,
hasHiveEntries,
jsonRegister,
jsonUnregister,
type JsonRegisterEntry,
type JsonRegisterSpec,
} from '../src/json-register.js';
import type { Lifecycle } from '../src/event-adapter.js';
const MARKER = `${HIVE_MIND_MARKER_BASE}/codex-hooks`;
// A codex-style spec: each event key holds an array of
// { matcher, hooks:[{type,command,timeout}], _hiveMindShim } groups.
const codexSpec: JsonRegisterSpec = {
hooksKey: 'hooks',
eventName: {
'session-start': 'SessionStart',
'user-prompt-submit': 'UserPromptSubmit',
stop: 'Stop',
'pre-compact': 'PreCompact',
},
buildGroup(_lifecycle: Lifecycle, command: string, timeout: number): Record<string, unknown> {
return {
hooks: [{ type: 'command', command, timeout }],
_hiveMindShim: MARKER,
};
},
isHiveGroup(group: unknown): boolean {
return (
!!group &&
typeof group === 'object' &&
(group as Record<string, unknown>)['_hiveMindShim'] === MARKER
);
},
groupCommand(group: unknown): string | undefined {
if (!group || typeof group !== 'object') return undefined;
const hooks = (group as Record<string, unknown>)['hooks'];
if (!Array.isArray(hooks) || hooks.length === 0) return undefined;
const first = hooks[0] as Record<string, unknown>;
return typeof first['command'] === 'string' ? (first['command'] as string) : undefined;
},
};
const ENTRIES: readonly JsonRegisterEntry[] = [
{ lifecycle: 'session-start', command: 'node /dist/hooks/session-start.js', timeout: 30 },
{ lifecycle: 'stop', command: 'node /dist/hooks/stop.js', timeout: 60 },
];
describe('jsonRegister — additive', () => {
it('preserves existing user (non-hive) entries verbatim', () => {
const userGroup = { hooks: [{ type: 'command', command: 'node /user/own.js' }] };
const config = { hooks: { Stop: [userGroup] }, somethingElse: { kept: true } };
const next = jsonRegister(config, ENTRIES, codexSpec);
const stopArr = (next.hooks as Record<string, unknown[]>).Stop;
// user group still present, byte-equal, and FIRST.
expect(stopArr[0]).toEqual(userGroup);
// top-level non-hooks keys untouched.
expect(next.somethingElse).toEqual({ kept: true });
});
it('appends a hive group per entry under the mapped event key', () => {
const next = jsonRegister({}, ENTRIES, codexSpec);
const hooks = next.hooks as Record<string, unknown[]>;
expect(hooks.SessionStart).toHaveLength(1);
expect(hooks.Stop).toHaveLength(1);
expect(codexSpec.isHiveGroup(hooks.SessionStart[0])).toBe(true);
expect(codexSpec.groupCommand(hooks.Stop[0])).toBe('node /dist/hooks/stop.js');
});
it('skips lifecycles the tool has no native event for', () => {
const spec: JsonRegisterSpec = {
...codexSpec,
eventName: { ...codexSpec.eventName, 'pre-compact': undefined },
};
const next = jsonRegister(
{},
[{ lifecycle: 'pre-compact', command: 'node /dist/hooks/pre-compact.js', timeout: 30 }],
spec,
);
// No event key created (the only entry maps to undefined).
expect(Object.keys(next.hooks as Record<string, unknown>)).toHaveLength(0);
});
it('applies the optional skeleton seed (e.g. cursor version:1)', () => {
const spec: JsonRegisterSpec = {
...codexSpec,
ensureSkeleton: (root) => ({ version: 1, ...root }),
};
const next = jsonRegister(undefined, ENTRIES, spec);
expect(next.version).toBe(1);
});
});
describe('jsonRegister — dedup / replace-in-place on re-run', () => {
it('replaces the same (eventKey, command) hive group in place rather than duplicating', () => {
const once = jsonRegister({}, ENTRIES, codexSpec);
const twice = jsonRegister(once, ENTRIES, codexSpec);
const stopArr = (twice.hooks as Record<string, unknown[]>).Stop;
expect(stopArr).toHaveLength(1); // not 2
const ssArr = (twice.hooks as Record<string, unknown[]>).SessionStart;
expect(ssArr).toHaveLength(1);
});
it('upgrades the timeout of an existing hive entry in place', () => {
const once = jsonRegister({}, [{ lifecycle: 'stop', command: 'node /dist/hooks/stop.js', timeout: 60 }], codexSpec);
const upgraded = jsonRegister(
once,
[{ lifecycle: 'stop', command: 'node /dist/hooks/stop.js', timeout: 120 }],
codexSpec,
);
const stopArr = upgraded.hooks as Record<string, unknown[]>;
expect(stopArr.Stop).toHaveLength(1);
const group = stopArr.Stop[0] as { hooks: Array<{ timeout: number }> };
expect(group.hooks[0].timeout).toBe(120);
});
it('keeps a different-command hive entry alongside (no false dedup)', () => {
const a = jsonRegister({}, [{ lifecycle: 'stop', command: 'node /a.js', timeout: 30 }], codexSpec);
const b = jsonRegister(a, [{ lifecycle: 'stop', command: 'node /b.js', timeout: 30 }], codexSpec);
expect((b.hooks as Record<string, unknown[]>).Stop).toHaveLength(2);
});
});
describe('jsonRegister — immutability (input never mutated)', () => {
it('does not mutate the input config object (deep-equal to a frozen snapshot)', () => {
const config = {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'node /user/own.js' }] }] },
version: 1,
};
const snapshot = structuredClone(config);
Object.freeze(config); // any in-place mutation would throw
const next = jsonRegister(config, ENTRIES, codexSpec);
// Input unchanged…
expect(config).toEqual(snapshot);
// …and a NEW object was returned.
expect(next).not.toBe(config);
expect(next.hooks).not.toBe(config.hooks);
});
it('does not mutate the input on re-run dedup either', () => {
const once = jsonRegister({}, ENTRIES, codexSpec);
const snapshot = structuredClone(once);
// Deep-freeze the nested arrays so replace-in-place can't touch the input.
Object.freeze(once);
Object.freeze(once.hooks);
for (const v of Object.values(once.hooks as Record<string, unknown>)) Object.freeze(v);
const twice = jsonRegister(once, ENTRIES, codexSpec);
expect(once).toEqual(snapshot);
expect(twice).not.toBe(once);
});
});
describe('jsonUnregister', () => {
it('strips only marker-tagged hive groups, preserving user entries', () => {
const userGroup = { hooks: [{ type: 'command', command: 'node /user/own.js' }] };
const registered = jsonRegister({ hooks: { Stop: [userGroup] } }, ENTRIES, codexSpec);
const stripped = jsonUnregister(registered, codexSpec);
const stopArr = (stripped.hooks as Record<string, unknown[]>).Stop;
expect(stopArr).toEqual([userGroup]); // hive Stop group gone, user group kept
// SessionStart array now empty (only our group was there).
expect((stripped.hooks as Record<string, unknown[]>).SessionStart).toEqual([]);
});
it('does not mutate the input config', () => {
const registered = jsonRegister({}, ENTRIES, codexSpec);
const snapshot = structuredClone(registered);
Object.freeze(registered);
const stripped = jsonUnregister(registered, codexSpec);
expect(registered).toEqual(snapshot);
expect(stripped).not.toBe(registered);
});
it('returns the config unchanged shape when there is no hooks key', () => {
const stripped = jsonUnregister({ other: true }, codexSpec);
expect(stripped).toEqual({ other: true });
});
});
describe('hasHiveEntries', () => {
it('is false for empty / undefined / no-hive configs', () => {
expect(hasHiveEntries(undefined, codexSpec)).toBe(false);
expect(hasHiveEntries({}, codexSpec)).toBe(false);
expect(
hasHiveEntries({ hooks: { Stop: [{ hooks: [{ command: 'node /user.js' }] }] } }, codexSpec),
).toBe(false);
});
it('is true once a marker-tagged hive group is registered', () => {
const registered = jsonRegister({}, ENTRIES, codexSpec);
expect(hasHiveEntries(registered, codexSpec)).toBe(true);
});
it('is false again after unregister', () => {
const registered = jsonRegister({}, ENTRIES, codexSpec);
expect(hasHiveEntries(jsonUnregister(registered, codexSpec), codexSpec)).toBe(false);
});
});

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { pathToFileURL } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import {
backupPathFor,
hookCommandFor,
hooksDirFromModuleUrl,
hookScriptPath,
normalizeCliPath,
} from '../src/paths-core.js';
describe('backupPathFor', () => {
it('replaces colons and dots in the ISO timestamp for Windows safety', () => {
const backup = backupPathFor('/h/.codex/hooks.json', '2026-06-01T10:30:45.123Z');
expect(backup).toBe('/h/.codex/hooks.json.hive-mind-backup.2026-06-01T10-30-45-123Z');
});
it('contains no `:` or `.` after the configPath suffix', () => {
const backup = backupPathFor('/h/cfg.json', '2026-06-01T10:30:45.123Z');
const stampPart = backup.slice('/h/cfg.json.hive-mind-backup.'.length);
expect(stampPart).not.toContain(':');
expect(stampPart).not.toContain('.');
});
it('keeps the config path verbatim as the prefix', () => {
const backup = backupPathFor('C:\\Users\\Marko Markovic\\.codex\\hooks.json', '2026-06-01T00:00:00.000Z');
expect(backup.startsWith('C:\\Users\\Marko Markovic\\.codex\\hooks.json.hive-mind-backup.')).toBe(true);
});
});
describe('hookCommandFor', () => {
it('produces a quoted node invocation with no --cli-path when omitted', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'));
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
expect(cmd).not.toContain('--cli-path');
});
it('appends a quoted --cli-path when supplied', () => {
const cmd = hookCommandFor('/abs/dist/hooks/stop.js', '/abs/cli/dist/index.js');
expect(cmd).toBe('node "/abs/dist/hooks/stop.js" --cli-path "/abs/cli/dist/index.js"');
});
it('omits --cli-path when an empty string is passed', () => {
const cmd = hookCommandFor('/abs/dist/hooks/stop.js', '');
expect(cmd).not.toContain('--cli-path');
});
it('keeps spaces in the script path inside the quotes', () => {
const scriptPath = 'C:\\Program Files\\hive-mind\\dist\\hooks\\stop.js';
const cmd = hookCommandFor(scriptPath);
expect(cmd).toBe('node "C:\\Program Files\\hive-mind\\dist\\hooks\\stop.js"');
});
it('keeps spaces in the cli-path 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('normalizeCliPath', () => {
it('returns undefined for undefined / empty / whitespace-only input', () => {
expect(normalizeCliPath(undefined)).toBeUndefined();
expect(normalizeCliPath('')).toBeUndefined();
expect(normalizeCliPath(' ')).toBeUndefined();
});
it('trims surrounding whitespace', () => {
expect(normalizeCliPath(' /abs/cli/dist/index.js ')).toBe('/abs/cli/dist/index.js');
});
it('throws when the value contains an embedded double-quote', () => {
expect(() => normalizeCliPath('/abs/cli/has"quote/index.js')).toThrow(/double-quote/);
});
it('passes through a clean path with spaces (no double-quote)', () => {
expect(normalizeCliPath('C:\\Program Files\\hive-mind\\index.js')).toBe(
'C:\\Program Files\\hive-mind\\index.js',
);
});
});
describe('hooksDirFromModuleUrl', () => {
it('resolves <pkg>/dist/hooks from a dist/<file>.js module url', () => {
const moduleUrl = pathToFileURL(resolve('/pkg/dist/install.js')).href;
const hooksDir = hooksDirFromModuleUrl(moduleUrl);
expect(hooksDir).toBe(resolve('/pkg/dist/hooks'));
});
it('returns an absolute, platform-normalized path whose parent is the module dir', () => {
const moduleUrl = pathToFileURL(resolve('/some/where/dist/index.js')).href;
const hooksDir = hooksDirFromModuleUrl(moduleUrl);
expect(dirname(hooksDir)).toBe(resolve('/some/where/dist'));
});
});
describe('hookScriptPath', () => {
it('joins <hooksDir>/<basename>.js', () => {
const hooksDir = resolve('/pkg/dist/hooks');
expect(hookScriptPath(hooksDir, 'session-start')).toBe(join(hooksDir, 'session-start.js'));
expect(hookScriptPath(hooksDir, 'stop')).toBe(join(hooksDir, 'stop.js'));
});
});

View File

@@ -0,0 +1,14 @@
{
"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-shim-core" }
]
}

View File

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