This commit is contained in:
19
packages/hive-mind-hooks-core/LICENSE
Normal file
19
packages/hive-mind-hooks-core/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
|
||||
58
packages/hive-mind-hooks-core/package.json
Normal file
58
packages/hive-mind-hooks-core/package.json
Normal 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"
|
||||
}
|
||||
57
packages/hive-mind-hooks-core/src/event-adapter.ts
Normal file
57
packages/hive-mind-hooks-core/src/event-adapter.ts
Normal 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;
|
||||
}
|
||||
526
packages/hive-mind-hooks-core/src/handlers-core.ts
Normal file
526
packages/hive-mind-hooks-core/src/handlers-core.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
171
packages/hive-mind-hooks-core/src/hook-shared.ts
Normal file
171
packages/hive-mind-hooks-core/src/hook-shared.ts
Normal 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;
|
||||
}
|
||||
29
packages/hive-mind-hooks-core/src/index.ts
Normal file
29
packages/hive-mind-hooks-core/src/index.ts
Normal 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';
|
||||
190
packages/hive-mind-hooks-core/src/install-core.ts
Normal file
190
packages/hive-mind-hooks-core/src/install-core.ts
Normal 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 };
|
||||
}
|
||||
142
packages/hive-mind-hooks-core/src/json-register.ts
Normal file
142
packages/hive-mind-hooks-core/src/json-register.ts
Normal 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;
|
||||
}
|
||||
81
packages/hive-mind-hooks-core/src/paths-core.ts
Normal file
81
packages/hive-mind-hooks-core/src/paths-core.ts
Normal 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`);
|
||||
}
|
||||
154
packages/hive-mind-hooks-core/tests/_helpers.ts
Normal file
154
packages/hive-mind-hooks-core/tests/_helpers.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
373
packages/hive-mind-hooks-core/tests/handlers-core.test.ts
Normal file
373
packages/hive-mind-hooks-core/tests/handlers-core.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
215
packages/hive-mind-hooks-core/tests/hook-shared.test.ts
Normal file
215
packages/hive-mind-hooks-core/tests/hook-shared.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
233
packages/hive-mind-hooks-core/tests/install-core.test.ts
Normal file
233
packages/hive-mind-hooks-core/tests/install-core.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
207
packages/hive-mind-hooks-core/tests/json-register.test.ts
Normal file
207
packages/hive-mind-hooks-core/tests/json-register.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
102
packages/hive-mind-hooks-core/tests/paths-core.test.ts
Normal file
102
packages/hive-mind-hooks-core/tests/paths-core.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
14
packages/hive-mind-hooks-core/tsconfig.json
Normal file
14
packages/hive-mind-hooks-core/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
12
packages/hive-mind-hooks-core/tsconfig.test.json
Normal file
12
packages/hive-mind-hooks-core/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