This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -3,7 +3,7 @@
* `claude-code-hooks``claude-code-hooks` — CLI entry for the @waggle/hive-mind-hooks-claude-code shim (was @hive-mind/claude-code-hooks pre-monorepo migration).
*
* claude-code-hooks install Patch ~/.claude/settings.json (additive merge).
* claude-code-hooks uninstall Restore the byte-identical pre-install state.
* claude-code-hooks uninstall Restore the exact pre-install settings state.
* claude-code-hooks verify Smoke-check the install + hive-mind-cli reachability.
*/
@@ -49,13 +49,13 @@ function printHelp(): void {
'',
'Commands:',
' install Patch ~/.claude/settings.json (additive, with backup).',
' uninstall Restore the byte-identical pre-install settings.json.',
' uninstall Restore pre-existing settings or remove installer-created settings.',
' verify Smoke-check the install + hive-mind-cli reachability.',
'',
'Options:',
' --help, -h Show this help.',
' --hooks-dir <PATH> Override compiled hooks directory (testing).',
' --hook-timeout <S> Override per-hook timeout in seconds (default 5).',
' --hook-timeout <S> Override all hook timeouts (defaults: SessionStart 15s, others 12s).',
' --cli-path <PATH> Absolute path to the hive-mind-cli binary or its',
' compiled JS entry. Required on Windows (npm bin',
' is a .cmd shim) and recommended for production',
@@ -86,11 +86,13 @@ function printUninstallSummary(result: UninstallResult): void {
const lines: string[] = [
'hive-mind/claude-code-hooks: uninstall',
` - settings: ${result.paths.settingsPath}`,
` - restored from: ${result.restoredFrom}`,
result.settingsRemoved
? ' - settings state: removed (file was installer-created)'
: ` - restored from: ${result.restoredFrom}`,
` - backup removed: ${result.backupRemoved ? 'yes' : 'no (kept on disk)'}`,
` - pointer removed: ${result.pointerRemoved ? 'yes' : 'no'}`,
'',
'Done. settings.json is byte-identical to pre-install state.',
'Done. The pre-install settings state has been restored.',
'',
];
process.stdout.write(lines.join('\n'));

View File

@@ -60,6 +60,19 @@ export interface HookHandler<TPayload = unknown, TStdoutPayload = unknown> {
}
const STDIN_READ_TIMEOUT_MS = 2000;
const HOOK_CLI_TIMEOUT_MS = 10_000;
export function buildHookBridgeOptions(
logger: Logger,
cliPath?: string,
): CliBridgeOptions {
return {
logger,
timeout_ms: HOOK_CLI_TIMEOUT_MS,
max_retries: 0,
...(cliPath !== undefined ? { cli_path: cliPath } : {}),
};
}
export async function readStdinAsString(timeoutMs: number = STDIN_READ_TIMEOUT_MS): Promise<string> {
if (process.stdin.isTTY) return '';
@@ -106,8 +119,7 @@ export async function runHook<TPayload, TStdoutPayload>(
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 bridgeOpts = buildHookBridgeOptions(logger, argvFlags.cliPath);
const bridge = opts.bridge ?? createCliBridge(bridgeOpts);
try {

View File

@@ -4,6 +4,8 @@
* native compaction step.
*/
import { isDirectExecution } from '@waggle/hive-mind-shim-core';
import {
pickStringFromObject,
runHook,
@@ -38,15 +40,6 @@ export async function runPreCompact(opts: Partial<HookRunOptions> = {}): Promise
return runHook(preCompactHandler, { name: 'pre-compact', ...opts });
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
if (isDirectExecution(import.meta.url)) {
void runPreCompact();
}

View File

@@ -15,7 +15,11 @@
* output — the session starts as it would have without the shim.
*/
import { recallPersonalAndWorkspace, type MemoryHit } from '@waggle/hive-mind-shim-core';
import {
isDirectExecution,
recallPersonalAndWorkspace,
type MemoryHit,
} from '@waggle/hive-mind-shim-core';
import {
pickStringFromObject,
runHook,
@@ -88,15 +92,6 @@ export async function runSessionStart(opts: Partial<HookRunOptions> = {}): Promi
return runHook(sessionStartHandler, { name: 'session-start', ...opts });
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
if (isDirectExecution(import.meta.url)) {
void runSessionStart();
}

View File

@@ -7,6 +7,7 @@
import {
classifyImportance,
encodeFrame,
isDirectExecution,
maybeEmitDiscovery,
summarizeTurn,
type HookEvent,
@@ -34,7 +35,8 @@ export const stopHandler: HookHandler<StopPayload, undefined> = {
const sessionId = pickStringFromObject(obj, 'session_id')
?? pickStringFromObject(obj, 'sessionId')
?? 'default';
const response = pickStringFromObject(obj, 'response')
const response = pickStringFromObject(obj, 'last_assistant_message')
?? pickStringFromObject(obj, 'response')
?? pickStringFromObject(obj, 'assistant_message')
?? pickStringFromObject(obj, 'transcript')
?? '';
@@ -115,15 +117,6 @@ export async function runStop(opts: Partial<HookRunOptions> = {}): Promise<void>
return runHook(stopHandler, { name: 'stop', ...opts });
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
if (isDirectExecution(import.meta.url)) {
void runStop();
}

View File

@@ -5,7 +5,7 @@
* No stdout output: this hook is purely a side-effect on the .mind file.
*/
import { encodeFrame, type HookEvent } from '@waggle/hive-mind-shim-core';
import { encodeFrame, isDirectExecution, type HookEvent } from '@waggle/hive-mind-shim-core';
import {
pickStringFromObject,
runHook,
@@ -58,15 +58,6 @@ export async function runUserPromptSubmit(opts: Partial<HookRunOptions> = {}): P
return runHook(userPromptSubmitHandler, { name: 'user-prompt-submit', ...opts });
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
if (isDirectExecution(import.meta.url)) {
void runUserPromptSubmit();
}

View File

@@ -2,7 +2,8 @@
* Programmatic install entry point.
*
* Steps:
* 1. Read existing `~/.claude/settings.json` (must exist + be valid JSON).
* 1. Read existing `~/.claude/settings.json`, or create a minimal one when
* Claude Code has not written it yet.
* 2. Write a byte-identical backup at
* `~/.claude/settings.json.hive-mind-backup.<timestamp>`.
* 3. Compute the four hive-mind hook command strings (absolute paths
@@ -13,9 +14,10 @@
* 6. Drop a pointer file at `~/.claude/hive-mind-install.json` so a
* future `uninstall` knows which backup to restore.
*
* Round-trip guarantee: the pre-install settings.json content equals
* the byte-identical content written to the backup. `uninstall` simply
* copies the backup over.
* Round-trip guarantee: pre-existing settings content equals the
* byte-identical content written to the backup. The pointer records whether
* settings were pre-existing or installer-created so uninstall can restore
* the former and remove the latter.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
@@ -46,7 +48,7 @@ export interface InstallResult {
}
export interface InstallOptions extends ResolvePathsOptions {
/** Per-hook timeout, seconds. Default 5. */
/** Per-hook timeout override, seconds. Defaults: SessionStart 15; others 12. */
hookTimeoutSeconds?: number;
/** Override clock for deterministic tests. */
now?: () => Date;
@@ -62,12 +64,50 @@ export interface InstallOptions extends ResolvePathsOptions {
cliPath?: string;
}
const DEFAULT_HOOK_TIMEOUT_S = 5;
const DEFAULT_HOOK_TIMEOUT_S = 12;
const DEFAULT_SESSION_START_TIMEOUT_S = 15;
interface ActiveInstallPointer {
createdByUs: boolean;
installedAt?: string;
settingsBackup: string;
}
async function ensureDir(p: string): Promise<void> {
if (!existsSync(p)) await mkdir(p, { recursive: true });
}
async function readActiveInstallPointer(pointerPath: string): Promise<ActiveInstallPointer | null> {
if (!existsSync(pointerPath)) return null;
let value: unknown;
try {
value = JSON.parse(await readFile(pointerPath, 'utf-8')) as unknown;
} catch (err) {
throw new Error(
`existing install pointer at ${pointerPath} is unreadable: `
+ (err instanceof Error ? err.message : String(err)),
);
}
if (!value || typeof value !== 'object') {
throw new Error(`existing install pointer at ${pointerPath} is malformed`);
}
const pointer = value as Record<string, unknown>;
const settingsBackup = pointer['settings_backup'];
const createdByUs = pointer['created_by_us'];
if (typeof settingsBackup !== 'string' || !existsSync(settingsBackup)) {
throw new Error(`existing install pointer at ${pointerPath} has no readable settings backup`);
}
if (createdByUs !== undefined && typeof createdByUs !== 'boolean') {
throw new Error(`existing install pointer at ${pointerPath} has invalid settings ownership`);
}
const installedAt = pointer['installed_at'];
return {
createdByUs: createdByUs ?? false,
settingsBackup,
...(typeof installedAt === 'string' ? { installedAt } : {}),
};
}
export async function install(opts: InstallOptions = {}): Promise<InstallResult> {
const log = opts.logger ?? createLogger({ name: 'claude-code-hooks/install' });
// Use install.ts's own URL for hooks-dir derivation so that callers
@@ -82,44 +122,65 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
log.info('install starting', { settings: paths.settingsPath, hooksDir: paths.hooksDir });
if (!existsSync(paths.settingsPath)) {
const activeInstall = await readActiveInstallPointer(paths.pointerPath);
const settingsExisted = existsSync(paths.settingsPath);
if (activeInstall && !activeInstall.createdByUs && !settingsExisted) {
throw new Error(
`expected Claude Code settings at ${paths.settingsPath}, file not found. ` +
`Run Claude Code at least once before installing this shim.`,
`existing install pointer at ${paths.pointerPath} owns a pre-existing settings file, `
+ `but ${paths.settingsPath} is missing; uninstall to restore it before reinstalling`,
);
}
const originalContent = await readFile(paths.settingsPath, 'utf-8');
let parsed: ClaudeCodeSettings;
try {
parsed = JSON.parse(originalContent) as ClaudeCodeSettings;
} catch (err) {
throw new Error(
`failed to parse existing ${paths.settingsPath} as JSON: ` +
(err instanceof Error ? err.message : String(err)),
);
const createdByUs = activeInstall?.createdByUs ?? !settingsExisted;
let originalContent = '{}\n';
let parsed: ClaudeCodeSettings = {};
if (settingsExisted) {
originalContent = await readFile(paths.settingsPath, 'utf-8');
try {
parsed = JSON.parse(originalContent) as ClaudeCodeSettings;
} catch (err) {
throw new Error(
`failed to parse existing ${paths.settingsPath} as JSON: ` +
(err instanceof Error ? err.message : String(err)),
);
}
}
await ensureDir(dirname(paths.pointerPath));
const backupPath = backupPathFor(paths.settingsPath, now().toISOString());
await writeFile(backupPath, originalContent, 'utf-8');
log.info('settings backed up', { backupPath });
const cliPath = normalizeCliPath(opts.cliPath);
await ensureDir(dirname(paths.pointerPath));
if (!settingsExisted) {
await writeFile(paths.settingsPath, originalContent, 'utf-8');
log.info('minimal settings created', { settings: paths.settingsPath });
}
const backupPath = activeInstall?.settingsBackup
?? backupPathFor(paths.settingsPath, now().toISOString());
if (!activeInstall) {
await writeFile(backupPath, originalContent, 'utf-8');
log.info('settings backed up', { backupPath });
} else {
log.info('existing rollback state preserved', { backupPath, createdByUs });
}
const requestedTimeout = opts.hookTimeoutSeconds;
const entries = defaultHookEntries(
paths.hooksDir,
opts.hookTimeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_S,
requestedTimeout ?? DEFAULT_HOOK_TIMEOUT_S,
hookCommandFor,
cliPath,
);
).map((entry) => (
requestedTimeout === undefined && entry.basename === 'session-start'
? { ...entry, timeout: DEFAULT_SESSION_START_TIMEOUT_S }
: entry
));
const merged = mergeHiveHooks(parsed, entries);
const mergedJson = JSON.stringify(merged, null, 2) + '\n';
await writeFile(paths.settingsPath, mergedJson, 'utf-8');
const pointer: Record<string, unknown> = {
version: '0.1.0',
installed_at: now().toISOString(),
installed_at: activeInstall?.installedAt ?? now().toISOString(),
config_path: paths.settingsPath,
settings_backup: backupPath,
created_by_us: createdByUs,
hooks_dir: paths.hooksDir,
installed_hooks: entries.map((e) => e.basename),
cli_path: cliPath ?? null,
@@ -133,7 +194,7 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
backupPath,
pointerPath: paths.pointerPath,
installedHooks: entries.map((e) => e.basename),
alreadyInstalled: false,
alreadyInstalled: activeInstall !== null,
};
if (cliPath !== undefined) result.cliPath = cliPath;
return result;

View File

@@ -57,16 +57,65 @@ function buildGroup(spec: HookEntrySpec): HookGroup {
return group;
}
function isHiveGroup(group: HookGroup | undefined): boolean {
return !!group && group._hiveMindShim === HIVE_MIND_MARKER;
function firstCommand(group: HookGroup | undefined): string | undefined {
if (!group || !Array.isArray(group.hooks)) return undefined;
const command = group.hooks[0]?.command;
return typeof command === 'string' ? command : undefined;
}
export function isHiveHookCommand(command: string | undefined, basename?: HookBasename): boolean {
return hiveHookScriptPath(command, basename) !== undefined;
}
export function generatedHookScriptPath(
command: string | undefined,
basename?: HookBasename,
): string | undefined {
if (!command) return undefined;
const pathNode = /^node\s+"([^"]+)"(?:\s|$)/i.exec(command);
const pinnedNode = /^"([^"]+)"\s+"([^"]+)"(?:\s|$)/.exec(command);
let scriptPath = pathNode?.[1];
if (!scriptPath && pinnedNode?.[1] && pinnedNode[2]) {
const executable = pinnedNode[1].replace(/\\/g, '/').split('/').at(-1)?.toLowerCase();
if (executable === 'node' || executable === 'node.exe') scriptPath = pinnedNode[2];
}
if (!scriptPath) return undefined;
const normalized = scriptPath.replace(/\\/g, '/').toLowerCase();
const basenames = basename ? [basename] : allHookBasenames();
const generated = basenames.some((candidate) => normalized.endsWith(`/${candidate}.js`));
return generated ? scriptPath : undefined;
}
export function hiveHookScriptPath(
command: string | undefined,
basename?: HookBasename,
): string | undefined {
const scriptPath = generatedHookScriptPath(command, basename);
if (!scriptPath) return undefined;
const normalized = scriptPath.replace(/\\/g, '/').toLowerCase();
const basenames = basename ? [basename] : allHookBasenames();
const owned = basenames.some((candidate) => normalized.endsWith(
`/hive-mind-hooks-claude-code/dist/hooks/${candidate}.js`,
));
return owned ? scriptPath : undefined;
}
export function isOwnedHiveGroup(group: HookGroup | undefined, basename?: HookBasename): boolean {
return !!group && (
group._hiveMindShim === HIVE_MIND_MARKER
|| isHiveHookCommand(firstCommand(group), basename)
);
}
/**
* Returns a NEW settings object with hive-mind hook entries appended to
* each Claude Code event array. Existing entries are preserved.
*
* If a hive-mind entry for a given event is already present (matching
* marker AND command path), it is replaced in place rather than
* If a hive-mind entry for a given event is already present, its marker is
* the ownership boundary, so it is replaced in place rather than
* duplicated — supports re-running install for upgrades.
*/
export function mergeHiveHooks(
@@ -82,16 +131,15 @@ export function mergeHiveHooks(
const existingArr = nextHooks[eventKey] ? [...nextHooks[eventKey]] : [];
const newGroup = buildGroup(spec);
let replaced = false;
for (let i = 0; i < existingArr.length; i += 1) {
const g = existingArr[i];
if (isHiveGroup(g) && g.hooks[0]?.command === spec.command) {
existingArr[i] = newGroup;
replaced = true;
break;
const ownedIndex = existingArr.findIndex((group) => isOwnedHiveGroup(group, spec.basename));
if (ownedIndex === -1) {
existingArr.push(newGroup);
} else {
existingArr[ownedIndex] = newGroup;
for (let i = existingArr.length - 1; i > ownedIndex; i -= 1) {
if (isOwnedHiveGroup(existingArr[i], spec.basename)) existingArr.splice(i, 1);
}
}
if (!replaced) existingArr.push(newGroup);
nextHooks[eventKey] = existingArr;
}
@@ -107,7 +155,7 @@ export function mergeHiveHooks(
export function hasHiveHooks(settings: ClaudeCodeSettings | undefined): boolean {
if (!settings || !settings.hooks) return false;
for (const groups of Object.values(settings.hooks)) {
if (Array.isArray(groups) && groups.some(isHiveGroup)) return true;
if (Array.isArray(groups) && groups.some((group) => isOwnedHiveGroup(group))) return true;
}
return false;
}

View File

@@ -1,11 +1,9 @@
/**
* Programmatic uninstall entry point.
*
* Round-trip guarantee: after uninstall, `~/.claude/settings.json` is
* byte-identical to the pre-install state. We achieve this by reading
* the backup the installer wrote and copying it back. Uninstall refuses
* to delete the backup unless the in-place readback matches the backup
* content.
* Round-trip guarantee: after uninstall, pre-existing settings are
* byte-identical to their pre-install state, while a settings file created by
* this installer is removed. Ownership is recorded in the install pointer.
*/
import { readFile, writeFile, unlink } from 'node:fs/promises';
@@ -16,6 +14,7 @@ import { resolvePaths, type ResolvePathsOptions, type ShimPaths } from './paths.
export interface UninstallResult {
paths: ShimPaths;
restoredFrom: string;
settingsRemoved: boolean;
pointerRemoved: boolean;
backupRemoved: boolean;
}
@@ -29,7 +28,9 @@ export interface UninstallOptions extends ResolvePathsOptions {
interface InstallPointer {
version: string;
installed_at: string;
config_path?: string;
settings_backup: string;
created_by_us?: boolean;
hooks_dir: string;
installed_hooks: readonly string[];
}
@@ -37,7 +38,9 @@ interface InstallPointer {
function isPointer(value: unknown): value is InstallPointer {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return typeof v['settings_backup'] === 'string';
const createdByUs = v['created_by_us'];
return typeof v['settings_backup'] === 'string'
&& (createdByUs === undefined || typeof createdByUs === 'boolean');
}
export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
@@ -69,18 +72,30 @@ export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallR
}
const backupContent = await readFile(pointer.settings_backup, 'utf-8');
await writeFile(paths.settingsPath, backupContent, 'utf-8');
const settingsRemoved = pointer.created_by_us === true;
if (settingsRemoved) {
if (existsSync(paths.settingsPath)) await unlink(paths.settingsPath);
if (existsSync(paths.settingsPath)) {
throw new Error(
`uninstall verification failed: installer-created ${paths.settingsPath} still exists. ` +
`Backup was NOT removed; clean up manually if needed.`,
);
}
log.info('installer-created settings removed', { settings: paths.settingsPath });
} else {
await writeFile(paths.settingsPath, backupContent, 'utf-8');
// Round-trip verification: read what we just wrote and compare bytes.
const verify = await readFile(paths.settingsPath, 'utf-8');
if (verify !== backupContent) {
throw new Error(
`uninstall verification failed: ${paths.settingsPath} content differs ` +
`from backup ${pointer.settings_backup}. Backup was NOT removed; ` +
`restore manually if needed.`,
);
// Round-trip verification: read what we just wrote and compare bytes.
const verify = await readFile(paths.settingsPath, 'utf-8');
if (verify !== backupContent) {
throw new Error(
`uninstall verification failed: ${paths.settingsPath} content differs ` +
`from backup ${pointer.settings_backup}. Backup was NOT removed; ` +
`restore manually if needed.`,
);
}
log.info('settings restored byte-identical', { settings: paths.settingsPath });
}
log.info('settings restored byte-identical', { settings: paths.settingsPath });
let backupRemoved = false;
if (cleanup) {
@@ -92,6 +107,7 @@ export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallR
return {
paths,
restoredFrom: pointer.settings_backup,
settingsRemoved,
pointerRemoved: true,
backupRemoved,
};

View File

@@ -12,13 +12,14 @@
import { readFile, access } from 'node:fs/promises';
import { constants, existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { join, resolve } from 'node:path';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import { resolvePaths, allHookBasenames, type ResolvePathsOptions } from './paths.js';
import {
HIVE_MIND_MARKER,
HOOK_EVENT_BY_BASENAME,
generatedHookScriptPath,
isOwnedHiveGroup,
type ClaudeCodeSettings,
type HookGroup,
} from './settings-merger.js';
export interface VerifyCheck {
@@ -44,15 +45,16 @@ async function fileReadable(p: string): Promise<boolean> {
try { await access(p, constants.R_OK); return true; } catch { return false; }
}
function findHiveGroup(groups: HookGroup[] | undefined, command: string): HookGroup | undefined {
if (!Array.isArray(groups)) return undefined;
return groups.find((g) => g._hiveMindShim === HIVE_MIND_MARKER && g.hooks[0]?.command === command);
}
function isJsPath(p: string): boolean {
return p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs');
}
function sameResolvedPath(left: string, right: string): boolean {
const a = resolve(left).replace(/\\/g, '/');
const b = resolve(right).replace(/\\/g, '/');
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
}
function probeCliVersion(
cliPath: string,
spawnImpl: typeof spawn,
@@ -124,28 +126,25 @@ export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
for (const basename of allHookBasenames()) {
const eventKey = HOOK_EVENT_BY_BASENAME[basename];
const groups = parsed.hooks?.[eventKey];
const expectedCmdSuffix = `${basename}.js`;
const found = Array.isArray(groups)
? groups.find((g) => g._hiveMindShim === HIVE_MIND_MARKER && g.hooks[0]?.command.includes(expectedCmdSuffix))
: undefined;
const expectedScriptPath = join(paths.hooksDir, `${basename}.js`);
const found = Array.isArray(groups) ? groups.find((group) => {
if (!isOwnedHiveGroup(group, basename)) return false;
const scriptPath = generatedHookScriptPath(group.hooks[0]?.command, basename);
return scriptPath !== undefined && sameResolvedPath(scriptPath, expectedScriptPath);
}) : undefined;
if (!found) {
checks.push({ name: `hooks.${eventKey} contains hive-mind entry`, ok: false });
continue;
}
checks.push({ name: `hooks.${eventKey} contains hive-mind entry`, ok: true });
// Best-effort: extract path from `node "<path>"` and check file exists.
const m = found.hooks[0]?.command.match(/node "([^"]+)"/);
if (m && m[1]) {
const ok = await fileReadable(m[1]);
checks.push({
name: `${basename}.js readable on disk`,
ok,
detail: m[1],
});
}
// Also confirm we located the entry under the right top-level group key
void findHiveGroup;
const scriptPath = generatedHookScriptPath(found.hooks[0]?.command, basename);
const ok = scriptPath !== undefined && await fileReadable(scriptPath);
checks.push({
name: `${basename}.js readable on disk`,
ok,
...(scriptPath !== undefined ? { detail: scriptPath } : {}),
});
}
// 3. hive-mind-cli responds to --help.

View File

@@ -1,11 +1,32 @@
import { describe, expect, it } from 'vitest';
import { createLogger } from '@waggle/hive-mind-shim-core';
import {
buildHookBridgeOptions,
parseHookArgs,
pickStringField,
pickStringFromObject,
safeJsonParse,
} from '../../src/hooks/_shared.js';
describe('buildHookBridgeOptions', () => {
it('uses one bounded attempt for every Claude hook', () => {
const logger = createLogger({ name: 'claude-hook-test' });
const sessionStart = buildHookBridgeOptions(
logger,
'C:\\waggle\\hive-mind-cli.js',
);
expect(sessionStart).toMatchObject({
logger,
cli_path: 'C:\\waggle\\hive-mind-cli.js',
timeout_ms: 10_000,
max_retries: 0,
});
const stop = buildHookBridgeOptions(logger, 'C:\\waggle\\hive-mind-cli.js');
expect(stop.timeout_ms).toBe(10_000);
expect(stop.max_retries).toBe(0);
});
});
describe('safeJsonParse', () => {
it('returns {} for empty / whitespace input', () => {
expect(safeJsonParse('')).toEqual({});

View File

@@ -3,12 +3,39 @@ import { runStop, stopHandler } from '../../src/hooks/stop.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
describe('stop handler', () => {
it('extracts response from payload.response or payload.assistant_message', () => {
it('extracts response from current and legacy Claude Stop payload fields', () => {
expect(stopHandler.parse({ response: 'r' }).response).toBe('r');
expect(stopHandler.parse({ assistant_message: 'a' }).response).toBe('a');
expect(stopHandler.parse({ last_assistant_message: 'latest' }).response).toBe('latest');
expect(stopHandler.parse({
last_assistant_message: 'current',
response: 'legacy',
}).response).toBe('current');
expect(stopHandler.parse({}).response).toBe('');
});
it('saves the assistant turn from a Claude Code 2.1 host-shaped payload', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
session_id: 'host-session',
transcript_path: 'C:\\tmp\\transcript.jsonl',
cwd: 'C:\\project',
hook_event_name: 'Stop',
stop_hook_active: false,
last_assistant_message: 'CLAUDE_HOST_CANARY_OK',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(bridge.saveMemory.mock.calls[0][0].content).toContain('CLAUDE_HOST_CANARY_OK');
expect(cap.exits).toEqual([0]);
});
it('summarizes long responses and saves an important frame', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();

View File

@@ -35,13 +35,20 @@ describe('install', () => {
if (env) await cleanup(env);
});
it('throws when settings.json is missing', async () => {
it('creates minimal settings and records ownership when settings.json is missing', async () => {
const home = await mkdtemp(join(tmpdir(), 'hmc-install-no-settings-'));
try {
await expect(install({
const result = await install({
home,
hooksDir: join(home, 'dist', 'hooks'),
})).rejects.toThrow(/settings/);
});
const settings = JSON.parse(await readFile(result.paths.settingsPath, 'utf-8')) as ClaudeCodeSettings;
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(Object.keys(settings)).toEqual(['hooks']);
expect(settings.hooks?.SessionStart).toHaveLength(1);
expect(pointer['created_by_us']).toBe(true);
expect(pointer['config_path']).toBe(result.paths.settingsPath);
expect(await readFile(result.backupPath, 'utf-8')).toBe('{}\n');
} finally {
await rm(home, { recursive: true, force: true });
}
@@ -81,12 +88,33 @@ describe('install', () => {
expect(after.hooks?.PreCompact).toHaveLength(1);
});
it('gives cold SessionStart more time than write hooks by default', async () => {
env = await bootstrap({});
await install({ home: env.home, hooksDir: env.hooksDir });
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
const timeouts = Object.values(after.hooks ?? {}).flatMap((groups) => (
groups.map((group) => group.hooks[0]?.timeout)
));
expect(timeouts).toEqual([15, 12, 12, 12]);
});
it('uses an explicit timeout override for every hook', async () => {
env = await bootstrap({});
await install({ home: env.home, hooksDir: env.hooksDir, hookTimeoutSeconds: 9 });
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
const timeouts = Object.values(after.hooks ?? {}).flatMap((groups) => (
groups.map((group) => group.hooks[0]?.timeout)
));
expect(timeouts).toEqual([9, 9, 9, 9]);
});
it('drops a pointer file with the backup path + version', async () => {
env = await bootstrap({});
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(result.pointerPath)).toBe(true);
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['settings_backup']).toBe(result.backupPath);
expect(pointer['created_by_us']).toBe(false);
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
expect(typeof pointer['version']).toBe('string');
});

View File

@@ -64,6 +64,59 @@ describe('mergeHiveHooks', () => {
expect(merged2.hooks?.SessionStart?.[0].hooks[0].timeout).toBe(7);
});
it('replaces stale marked entries when the install path changes', () => {
const oldCommand = hookCommandFor('/old/dist/hooks', 'session-start', '/old/cli.js');
const newCommand = hookCommandFor('/new/dist/hooks', 'session-start', '/new/cli.js');
const merged1 = mergeHiveHooks({}, [{ basename: 'session-start', command: oldCommand, timeout: 5 }]);
const merged2 = mergeHiveHooks(merged1, [{ basename: 'session-start', command: newCommand, timeout: 7 }]);
const markedGroups = merged2.hooks?.SessionStart?.filter(
(group) => group._hiveMindShim === HIVE_MIND_MARKER,
);
expect(markedGroups).toHaveLength(1);
expect(markedGroups?.[0].hooks[0]).toEqual({
type: 'command',
command: newCommand,
timeout: 7,
});
});
it('recognizes and replaces a hook after Claude strips the ownership marker', () => {
const oldCommand = hookCommandFor('/old/hive-mind-hooks-claude-code/dist/hooks', 'session-start', '/old/cli.js');
const newCommand = hookCommandFor('/new/hive-mind-hooks-claude-code/dist/hooks', 'session-start', '/new/cli.js');
const installed = mergeHiveHooks({}, [{ basename: 'session-start', command: oldCommand, timeout: 5 }]);
const normalized = structuredClone(installed);
delete normalized.hooks?.SessionStart?.[0]._hiveMindShim;
expect(hasHiveHooks(normalized)).toBe(true);
const upgraded = mergeHiveHooks(normalized, [{ basename: 'session-start', command: newCommand, timeout: 7 }]);
expect(upgraded.hooks?.SessionStart).toHaveLength(1);
expect(upgraded.hooks?.SessionStart?.[0].hooks[0].command).toBe(newCommand);
});
it('does not claim an unrelated command that only mentions a Waggle hook path', () => {
const unrelated: ClaudeCodeSettings = {
hooks: {
SessionStart: [{
hooks: [{
type: 'command',
command: 'echo "C:\\archive\\hive-mind-hooks-claude-code\\dist\\hooks\\session-start.js"',
}],
}],
},
};
expect(hasHiveHooks(unrelated)).toBe(false);
const command = hookCommandFor(
'/new/hive-mind-hooks-claude-code/dist/hooks',
'session-start',
'/new/cli.js',
);
const merged = mergeHiveHooks(unrelated, [{ basename: 'session-start', command, timeout: 5 }]);
expect(merged.hooks?.SessionStart).toHaveLength(2);
expect(merged.hooks?.SessionStart?.[0]).toEqual(unrelated.hooks?.SessionStart?.[0]);
});
it('preserves unrelated top-level fields', () => {
const merged = mergeHiveHooks(
{ env: { SOMETHING: '1' }, statusLine: { type: 'command', command: 'foo' }, hooks: {} } as ClaudeCodeSettings,

View File

@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { install } from '../src/install.js';
import { uninstall } from '../src/uninstall.js';
import { verify } from '../src/verify.js';
import type { ClaudeCodeSettings } from '../src/settings-merger.js';
interface TestEnv {
@@ -50,7 +51,7 @@ describe('uninstall', () => {
.rejects.toThrow(/malformed/);
});
it('install + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
it('reinstall + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
const initialSettings: ClaudeCodeSettings = {
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' },
hooks: {
@@ -69,6 +70,7 @@ describe('uninstall', () => {
await install({ home: env.home, hooksDir: env.hooksDir });
const afterInstall = await readFile(env.settingsPath, 'utf-8');
expect(sha256(afterInstall)).not.toBe(preHash);
await install({ home: env.home, hooksDir: env.hooksDir });
await uninstall({ home: env.home, hooksDir: env.hooksDir });
const afterUninstall = await readFile(env.settingsPath, 'utf-8');
@@ -76,6 +78,36 @@ describe('uninstall', () => {
expect(afterUninstall).toBe(preInstall);
});
it('install + verify + uninstall round-trips an absent settings file', async () => {
const home = await mkdtemp(join(tmpdir(), 'hmc-uninstall-fresh-'));
const hooksDir = resolve(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
for (const basename of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
await writeFile(join(hooksDir, `${basename}.js`), '/* mock hook */', 'utf-8');
}
const cliPath = join(home, 'fake-cli.mjs');
await writeFile(cliPath, 'process.stdout.write("ok\\n");', 'utf-8');
env = {
home,
hooksDir,
settingsPath: join(home, '.claude', 'settings.json'),
pointerPath: join(home, '.claude', 'hive-mind-install.json'),
};
expect(existsSync(env.settingsPath)).toBe(false);
const installed = await install({ home, hooksDir, cliPath });
const reinstalled = await install({ home, hooksDir, cliPath });
expect(reinstalled.alreadyInstalled).toBe(true);
expect(reinstalled.backupPath).toBe(installed.backupPath);
await expect(verify({ home, hooksDir })).resolves.toMatchObject({ ok: true });
const removed = await uninstall({ home, hooksDir });
expect(removed.settingsRemoved).toBe(true);
expect(existsSync(env.settingsPath)).toBe(false);
expect(existsSync(installed.pointerPath)).toBe(false);
expect(existsSync(installed.backupPath)).toBe(false);
});
it('removes the backup file by default after restore', async () => {
env = await bootstrap({});
const result = await install({ home: env.home, hooksDir: env.hooksDir });

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ChildProcess } from 'node:child_process';
@@ -20,7 +20,7 @@ async function bootstrap(initial: ClaudeCodeSettings, withHookFiles: boolean): P
await mkdir(claudeDir, { recursive: true });
const settingsPath = join(claudeDir, 'settings.json');
await writeFile(settingsPath, JSON.stringify(initial, null, 2), 'utf-8');
const hooksDir = join(home, 'fake-dist', 'hooks');
const hooksDir = join(home, 'hive-mind-hooks-claude-code', 'dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
if (withHookFiles) {
for (const b of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
@@ -48,6 +48,7 @@ function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: strin
describe('verify', () => {
const envs: TestEnv[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
});
@@ -90,6 +91,25 @@ describe('verify', () => {
expect(cliCheck?.ok).toBe(true);
});
it('passes after Claude normalizes away marker keys but preserves hook commands', async () => {
const env = await bootstrap({}, true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const settingsPath = join(env.home, '.claude', 'settings.json');
const settings = JSON.parse(await readFile(settingsPath, 'utf-8')) as ClaudeCodeSettings;
for (const groups of Object.values(settings.hooks ?? {})) {
for (const group of groups) delete group._hiveMindShim;
}
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(true);
});
it('reports CLI unreachable when the spawn exits non-zero', async () => {
const env = await bootstrap({}, true);
envs.push(env);
@@ -142,4 +162,44 @@ describe('verify', () => {
const fileCheck = result.checks.find((c) => c.name.includes('readable on disk'));
expect(fileCheck?.ok).toBe(false);
});
it('flags missing hook scripts when the installed command pins a quoted Windows Node path', async () => {
vi.stubEnv('WAGGLE_HOOK_NODE_PATH', 'C:\\Program Files\\nodejs\\node.exe');
const env = await bootstrap({}, false);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
const fileChecks = result.checks.filter((c) => c.name.includes('readable on disk'));
expect(fileChecks).toHaveLength(4);
expect(fileChecks.every((check) => check.ok === false)).toBe(true);
});
it('rejects a readable hook command from a stale install directory', async () => {
const env = await bootstrap({}, true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const currentHooksDir = join(env.home, 'current', 'hive-mind-hooks-claude-code', 'dist', 'hooks');
await mkdir(currentHooksDir, { recursive: true });
for (const basename of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
await writeFile(join(currentHooksDir, `${basename}.js`), '/* current hook */', 'utf-8');
}
const result = await verify({
home: env.home,
hooksDir: currentHooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks.filter((check) => (
!check.ok && check.name.includes('contains hive-mind entry')
))).toHaveLength(4);
});
});