This commit is contained in:
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`);
|
||||
}
|
||||
Reference in New Issue
Block a user