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

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

View File

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

View File

@@ -0,0 +1,84 @@
# @hive-mind/claude-code-hooks
> Silent memory capture for [Anthropic Claude Code](https://claude.com/claude-code) — every session writes to your local `~/.hive-mind/global.mind`, no cloud, no opt-in per session.
**Status**: Wave 1 MVP, pre-1.0.
**License**: Apache-2.0.
**Peer dependency**: `@hive-mind/cli` >= 0.1.0 (must be on PATH).
## What it does
Patches `~/.claude/settings.json` with four hook entries that route Claude Code lifecycle events into hive-mind via the [shim-core CLI bridge](../shim-core):
| Hook | What we do |
|---|---|
| `SessionStart` | Resolve workspace, switch to it, recall the top-N frames, inject as additional context. |
| `UserPromptSubmit` | Save the user prompt as a `temporary` frame scoped to the current session. |
| `Stop` | Deterministically summarize the just-completed turn and save it as an `important` frame, parented to the prompt frame. |
| `PreCompact` | Run `compact_memory` so superseded P/B frames merge before Claude's own context truncation. |
All hooks **fail open**: if `hive-mind-cli` is unreachable or any step throws, the hook logs a warning to stderr and exits 0. Claude Code never sees a hook failure.
## Quickstart
```bash
# One-time: install hive-mind-cli globally and init the .mind file
npm install -g @hive-mind/cli
hive-mind-cli init
# Per-machine: install the Claude Code shim
npx @hive-mind/claude-code-hooks install
```
### Windows / pinned CLI path
On Windows, `hive-mind-cli` is published as an `npm` `.cmd` shim that cannot be exec'd directly from a hook. Pin the path at install time:
```bash
# Find the upstream CLI's compiled JS entry point
npx @hive-mind/claude-code-hooks install \
--cli-path "C:\\Users\\<you>\\AppData\\Roaming\\npm\\node_modules\\@hive-mind\\cli\\dist\\index.js"
```
The path is recorded in `~/.claude/hive-mind-install.json` and threaded into every generated hook command as `--cli-path "..."`. Production users on POSIX may omit the flag if `hive-mind-cli` is reliably on `$PATH` at hook invocation time.
Output:
```
hive-mind/claude-code-hooks: install
- settings: /Users/you/.claude/settings.json
- backup: /Users/you/.claude/settings.json.hive-mind-backup.2026-04-28T...
- pointer: /Users/you/.claude/hive-mind-install.json
- added hooks: session-start, user-prompt-submit, stop, pre-compact
- cli path: (default — hive-mind-cli on PATH)
Done. New Claude Code sessions will silently capture to hive-mind.
Run "claude-code-hooks verify" to inspect, "claude-code-hooks uninstall" to revert.
```
## Reversibility
Install writes a timestamped, byte-identical backup of `~/.claude/settings.json` *before* any modification, plus a small pointer file `~/.claude/hive-mind-install.json`. Uninstall reads the pointer, copies the backup back over `settings.json`, and refuses to remove the backup until the round-trip content matches. The result is a SHA-256-identical pre-install state.
```bash
npx @hive-mind/claude-code-hooks uninstall
```
## Coexistence with `MEMORY.md`
This shim handles the **episodic** layer (actual conversation turns, captured automatically as frames). It does **not** touch your existing `MEMORY.md` — that file is the **semantic** layer (distilled rules and preferences) and stays under your manual control. The two layers complement each other; nothing is moved or rewritten.
## Verify
```bash
npx @hive-mind/claude-code-hooks verify
```
Checks:
- `~/.claude/settings.json` exists and is valid JSON.
- All four hive-mind hook entries are present and reference compiled `dist/hooks/*.js` files that actually exist.
- `hive-mind-cli` is on PATH and answers `--version`.
## License
Apache-2.0 — see [the repo root](https://github.com/marolinik/hive-mind-clients/blob/main/LICENSE).

View File

@@ -0,0 +1,66 @@
{
"name": "@waggle/hive-mind-hooks-claude-code",
"version": "0.1.0",
"description": "Anthropic Claude Code silent capture shim for hive-mind. Adds SessionStart / UserPromptSubmit / Stop / PreCompact hooks that route conversation episodes into hive-mind frames via @waggle/hive-mind-shim-core. Reversible install — additive merge into ~/.claude/settings.json with byte-identical uninstall.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"claude-code-hooks": "dist/bin/claude-code-hooks-cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./hooks/session-start": "./dist/hooks/session-start.js",
"./hooks/user-prompt-submit": "./dist/hooks/user-prompt-submit.js",
"./hooks/stop": "./dist/hooks/stop.js",
"./hooks/pre-compact": "./dist/hooks/pre-compact.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-claude-code/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-claude-code"
},
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-hooks-claude-code#readme",
"bugs": {
"url": "https://github.com/marolinik/waggle-os/issues"
},
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
"keywords": [
"claude-code",
"anthropic",
"hive-mind",
"memory",
"ai",
"mcp",
"hook",
"silent-capture"
],
"types": "dist/index.d.ts"
}

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env node
/**
* `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 verify Smoke-check the install + hive-mind-cli reachability.
*/
import { install, type InstallResult } from '../install.js';
import { uninstall, type UninstallResult } from '../uninstall.js';
import { verify, type VerifyResult } from '../verify.js';
type ParsedArgs = {
command: 'install' | 'uninstall' | 'verify' | 'help';
flags: Record<string, string | boolean>;
};
function parseArgs(argv: readonly string[]): ParsedArgs {
const [first, ...rest] = argv;
const valid = ['install', 'uninstall', 'verify'] as const;
const command = first === '-h' || first === '--help' || first === undefined
? 'help'
: valid.includes(first as typeof valid[number]) ? first as typeof valid[number] : 'help';
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (!arg.startsWith('--')) continue;
const eq = arg.indexOf('=');
if (eq !== -1) {
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
} else {
const next = rest[i + 1];
if (next && !next.startsWith('--')) {
flags[arg.slice(2)] = next;
i += 1;
} else {
flags[arg.slice(2)] = true;
}
}
}
return { command: command as ParsedArgs['command'], flags };
}
function printHelp(): void {
process.stdout.write([
'Usage: claude-code-hooks <command> [options]',
'',
'Commands:',
' install Patch ~/.claude/settings.json (additive, with backup).',
' uninstall Restore the byte-identical pre-install settings.json.',
' 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).',
' --cli-path <PATH> Absolute path to the hive-mind-cli binary or its',
' compiled JS entry. Required on Windows (npm bin',
' is a .cmd shim) and recommended for production',
' installs. Threaded into every hook command.',
'',
'Repo: https://github.com/marolinik/hive-mind-clients',
'',
].join('\n'));
}
function printInstallSummary(result: InstallResult): void {
const lines: string[] = [
'hive-mind/claude-code-hooks: install',
` - settings: ${result.paths.settingsPath}`,
` - backup: ${result.backupPath}`,
` - pointer: ${result.pointerPath}`,
` - added hooks: ${result.installedHooks.join(', ')}`,
` - cli path: ${result.cliPath ?? '(default — hive-mind-cli on PATH)'}`,
'',
'Done. New Claude Code sessions will silently capture to hive-mind.',
'Run "claude-code-hooks verify" to inspect, "claude-code-hooks uninstall" to revert.',
'',
];
process.stdout.write(lines.join('\n'));
}
function printUninstallSummary(result: UninstallResult): void {
const lines: string[] = [
'hive-mind/claude-code-hooks: uninstall',
` - settings: ${result.paths.settingsPath}`,
` - 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.',
'',
];
process.stdout.write(lines.join('\n'));
}
function printVerifySummary(result: VerifyResult): void {
const lines: string[] = ['hive-mind/claude-code-hooks: verify'];
for (const c of result.checks) {
const tag = c.ok ? 'PASS' : 'FAIL';
const detail = c.detail ? `${c.detail}` : '';
lines.push(` [${tag}] ${c.name}${detail}`);
}
lines.push('');
lines.push(result.ok ? 'All checks passed.' : 'One or more checks failed.');
lines.push('');
process.stdout.write(lines.join('\n'));
}
async function main(): Promise<void> {
const { command, flags } = parseArgs(process.argv.slice(2));
if (command === 'help') {
printHelp();
return;
}
const hooksDir = typeof flags['hooks-dir'] === 'string' ? flags['hooks-dir'] : undefined;
const hookTimeoutRaw = flags['hook-timeout'];
const hookTimeoutSeconds = typeof hookTimeoutRaw === 'string'
? Number.parseInt(hookTimeoutRaw, 10) || undefined
: undefined;
const cliPath = typeof flags['cli-path'] === 'string' ? flags['cli-path'] : undefined;
const baseOpts = {
moduleUrl: import.meta.url,
...(hooksDir ? { hooksDir } : {}),
};
try {
if (command === 'install') {
const installOpts = {
...baseOpts,
...(hookTimeoutSeconds !== undefined ? { hookTimeoutSeconds } : {}),
...(cliPath !== undefined ? { cliPath } : {}),
};
const result = await install(installOpts);
printInstallSummary(result);
return;
}
if (command === 'uninstall') {
const result = await uninstall(baseOpts);
printUninstallSummary(result);
return;
}
if (command === 'verify') {
const result = await verify(baseOpts);
printVerifySummary(result);
if (!result.ok) process.exit(1);
return;
}
} catch (err) {
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
}
void main();

View File

@@ -0,0 +1,148 @@
/**
* Shared helpers for the four hook scripts.
*
* Hook scripts run as short-lived Node subprocesses spawned by Claude
* Code. The contract:
* - stdin : Claude Code writes a JSON event payload (may be empty
* on some hook events).
* - stdout : structured JSON for hooks that influence Claude Code
* behaviour (SessionStart context injection, etc.).
* - stderr : structured logger output (never blocks the host).
* - exit code : 0 always — silent capture must not break the IDE.
*
* On any internal error, hooks log the error and exit 0. The
* `runHook` helper wraps the user-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. */
name: 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[];
}
/**
* 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 logger = opts.logger ?? createLogger({ name: `claude-code-hooks/${opts.name}` });
const writeStdout = opts.writeStdout ?? ((s: string) => process.stdout.write(s));
const exit = opts.exit ?? ((c: number): void => { process.exit(c); });
const reader = opts.readStdin ?? readStdinAsString;
const argv = opts.argv ?? process.argv.slice(2);
const argvFlags = parseHookArgs(argv);
const bridgeOpts: CliBridgeOptions = { logger };
if (argvFlags.cliPath !== undefined) bridgeOpts.cli_path = argvFlags.cliPath;
const bridge = opts.bridge ?? createCliBridge(bridgeOpts);
try {
const raw = await reader();
const parsed = safeJsonParse(raw);
const payload = handler.parse(parsed);
const out = await handler.run(payload, { bridge, logger });
if (out !== undefined) {
writeStdout(JSON.stringify(out) + '\n');
}
exit(0);
} catch (err) {
logger.warn('hook failed open', {
hook: opts.name,
error: err instanceof Error ? err.message : String(err),
});
exit(0);
}
}
/**
* Best-effort accessor for nested string fields on opaque payloads.
* Returns undefined when the key path doesn't resolve to a non-empty string.
*/
export function pickStringField(payload: unknown, ...keys: string[]): string | undefined {
if (!payload || typeof payload !== 'object') return undefined;
const obj = payload as Record<string, unknown>;
for (const key of keys) {
const value = obj[key];
if (typeof value === 'string' && value.length > 0) return value;
}
return undefined;
}
export function pickStringFromObject(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

View File

@@ -0,0 +1,52 @@
/**
* PreCompact hook — fired just before Claude Code truncates context.
* Triggers `compact_memory` so superseded P/B frames merge before the
* native compaction step.
*/
import {
pickStringFromObject,
runHook,
type HookHandler,
type HookRunOptions,
} from './_shared.js';
interface PreCompactPayload {
scope: string | undefined;
}
export const preCompactHandler: HookHandler<PreCompactPayload, undefined> = {
parse(raw): PreCompactPayload {
const obj = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
const scope = pickStringFromObject(obj, 'session_id')
?? pickStringFromObject(obj, 'sessionId')
?? pickStringFromObject(obj, 'scope');
return { scope };
},
async run(payload, { bridge, logger }): Promise<undefined> {
// Commit 1.4: scope param is unused at the upstream level (cleanup_frames
// operates on the active workspace). We retain payload.scope on the
// parsed payload for future use (workspace-id selection in Wave 2).
const result = await bridge.cleanupFrames();
logger.debug('cleanup_frames done', { pruned: result.pruned, scope: payload.scope });
return undefined;
},
};
export async function runPreCompact(opts: Partial<HookRunOptions> = {}): Promise<void> {
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) {
void runPreCompact();
}

View File

@@ -0,0 +1,102 @@
/**
* SessionStart hook — recalls the top-N most relevant frames from
* personal memory and injects them as additional context for the new
* Claude Code session.
*
* IMPORTANT (Commit 1.4): there is no `switch_workspace` MCP tool,
* so this hook no longer attempts to switch the active workspace.
* Workspace targeting will arrive in Wave 2 via per-call `workspace`
* parameters once project-level workspace discovery is wired up.
*
* Output format follows Claude Code's hookSpecificOutput convention:
* { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: <text> } }
*
* If hive-mind-cli is unreachable, the hook logs and exits 0 with no
* output — the session starts as it would have without the shim.
*/
import { recallPersonalAndWorkspace, type MemoryHit } from '@waggle/hive-mind-shim-core';
import {
pickStringFromObject,
runHook,
type HookHandler,
type HookRunOptions,
} from './_shared.js';
interface SessionStartPayload {
cwd: string;
sessionId?: string;
recallLimit: number;
}
interface SessionStartOutput {
hookSpecificOutput: {
hookEventName: 'SessionStart';
additionalContext: string;
};
}
const DEFAULT_RECALL_LIMIT = 20;
const PER_HIT_CONTENT_BUDGET = 240;
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 ts = h.created_at;
const importance = h.importance;
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(`- (${importance})${from} ${ts}: ${content}`);
}
return lines.join('\n');
}
export const sessionStartHandler: HookHandler<SessionStartPayload, SessionStartOutput> = {
parse(raw): SessionStartPayload {
const obj = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
const cwd = pickStringFromObject(obj, 'cwd') ?? process.cwd();
const sessionId = pickStringFromObject(obj, 'session_id')
?? pickStringFromObject(obj, 'sessionId');
const limitVal = obj['recall_limit'] ?? obj['recallLimit'];
const recallLimit = typeof limitVal === 'number' && limitVal > 0
? Math.floor(limitVal)
: DEFAULT_RECALL_LIMIT;
const result: SessionStartPayload = { cwd, recallLimit };
if (sessionId !== undefined) result.sessionId = sessionId;
return result;
},
async run(payload, { bridge, logger }): Promise<SessionStartOutput | undefined> {
logger.debug('recall starting', { limit: payload.recallLimit });
const hits = await recallPersonalAndWorkspace(bridge, '', { limit: payload.recallLimit });
logger.debug('recall complete', { hits: hits.length });
return {
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: formatHitsForContext(hits),
},
};
},
};
export async function runSessionStart(opts: Partial<HookRunOptions> = {}): Promise<void> {
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) {
void runSessionStart();
}

View File

@@ -0,0 +1,129 @@
/**
* Stop hook — fired when an assistant turn completes. Summarizes the
* turn deterministically (no LLM call) and writes it as an `important`
* frame parented to the originating prompt frame when known.
*/
import {
classifyImportance,
encodeFrame,
maybeEmitDiscovery,
summarizeTurn,
type HookEvent,
} from '@waggle/hive-mind-shim-core';
import {
pickStringFromObject,
runHook,
type HookHandler,
type HookRunOptions,
} from './_shared.js';
interface StopPayload {
cwd: string;
sessionId: string;
response: string;
parent: string | undefined;
}
const SUMMARY_BUDGET_CHARS = 400;
export const stopHandler: HookHandler<StopPayload, undefined> = {
parse(raw): StopPayload {
const obj = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
const cwd = pickStringFromObject(obj, 'cwd') ?? process.cwd();
const sessionId = pickStringFromObject(obj, 'session_id')
?? pickStringFromObject(obj, 'sessionId')
?? 'default';
const response = pickStringFromObject(obj, 'response')
?? pickStringFromObject(obj, 'assistant_message')
?? pickStringFromObject(obj, 'transcript')
?? '';
const parent = pickStringFromObject(obj, 'parent_frame_id')
?? pickStringFromObject(obj, 'prompt_frame_id');
return { cwd, sessionId, response, parent };
},
async run(payload, { bridge, logger }): Promise<undefined> {
if (!payload.response) {
logger.debug('no response in payload, skipping save');
return undefined;
}
const summary = summarizeTurn(payload.response, { maxChars: SUMMARY_BUDGET_CHARS });
const rawImportance = classifyImportance(summary, { eventType: 'stop' });
const importance = rawImportance === 'critical' ? 'critical' : 'important';
const event: HookEvent = {
eventType: 'stop',
source: 'claude-code',
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 bridge.saveMemory(frame);
logger.debug('stop frame saved', {
id: result.id,
importance: frame.importance,
bytes: summary.length,
});
// AI-OS Phase 1E — opt-in v2 signal emission. Off by default so
// OSS consumers see no behavior change; flip WAGGLE_SIGNAL_EMIT=1
// (or any truthy value) to broadcast high/critical-importance
// stops to the local Waggle sidecar. Fails open (sidecar offline
// → null returned + stderr warning); never throws.
const emitFlag = process.env.WAGGLE_SIGNAL_EMIT;
if (emitFlag && emitFlag !== '0' && emitFlag.toLowerCase() !== 'false') {
// Map shim-core's Importance to maybeEmitDiscovery's emission
// scale. 'important' is the shim-core label for the
// emission-worthy threshold; the policy helper only fires on
// 'high' / 'critical'. 'temporary' / 'normal' do not emit.
const emitImportance =
rawImportance === 'critical' ? 'critical'
: rawImportance === 'important' ? 'high'
: rawImportance === 'normal' ? 'normal'
: 'low';
const emitted = await maybeEmitDiscovery(
'stop',
emitImportance,
{
tool: 'claude-code',
sessionId: payload.sessionId,
topic: summary.slice(0, 160),
summary,
frameId: result.id,
memoryWorkspace: result.workspace,
cwd: payload.cwd,
},
{ senderId: 'claude-code-hook' },
);
if (emitted) {
logger.debug('stop signal emitted', { id: emitted.id });
}
}
return undefined;
},
};
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) {
void runStop();
}

View File

@@ -0,0 +1,72 @@
/**
* UserPromptSubmit hook — captures the user prompt as a temporary
* frame scoped to the current Claude Code session.
*
* 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 {
pickStringFromObject,
runHook,
type HookHandler,
type HookRunOptions,
} from './_shared.js';
interface UserPromptSubmitPayload {
prompt: string;
cwd: string;
sessionId: string;
}
export const userPromptSubmitHandler: HookHandler<UserPromptSubmitPayload, undefined> = {
parse(raw): UserPromptSubmitPayload {
const obj = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
const prompt = pickStringFromObject(obj, 'prompt')
?? pickStringFromObject(obj, 'user_message')
?? '';
const cwd = pickStringFromObject(obj, 'cwd') ?? process.cwd();
const sessionId = pickStringFromObject(obj, 'session_id')
?? pickStringFromObject(obj, 'sessionId')
?? 'default';
return { prompt, cwd, sessionId };
},
async run(payload, { bridge, logger }): Promise<undefined> {
if (!payload.prompt) {
logger.debug('no prompt in payload, skipping save');
return undefined;
}
const event: HookEvent = {
eventType: 'user-prompt-submit',
source: 'claude-code',
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 bridge.saveMemory(frame);
logger.debug('prompt frame saved', { id: result.id, scope: frame.scope });
return undefined;
},
};
export async function runUserPromptSubmit(opts: Partial<HookRunOptions> = {}): Promise<void> {
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) {
void runUserPromptSubmit();
}

View File

@@ -0,0 +1,51 @@
/**
* @waggle/hive-mind-hooks-claude-code — barrel export (was @hive-mind/claude-code-hooks pre-monorepo migration).
*
* Programmatic API for the install / uninstall / verify lifecycle.
* Most users invoke the CLI bin (`npx @hive-mind/claude-code-hooks ...`)
* but the same functions are exposed for embedding in other tooling.
*/
export type {
InstallOptions,
InstallResult,
} from './install.js';
export { install } from './install.js';
export type {
UninstallOptions,
UninstallResult,
} from './uninstall.js';
export { uninstall } from './uninstall.js';
export type {
VerifyOptions,
VerifyResult,
VerifyCheck,
} from './verify.js';
export { verify } from './verify.js';
export type {
ShimPaths,
ResolvePathsOptions,
HookBasename,
} from './paths.js';
export {
resolvePaths,
hookCommandFor,
backupPathFor,
allHookBasenames,
} from './paths.js';
export type {
ClaudeCodeSettings,
HookGroup,
HookEntrySpec,
} from './settings-merger.js';
export {
HIVE_MIND_MARKER,
HOOK_EVENT_BY_BASENAME,
defaultHookEntries,
hasHiveHooks,
mergeHiveHooks,
} from './settings-merger.js';

View File

@@ -0,0 +1,154 @@
/**
* Programmatic install entry point.
*
* Steps:
* 1. Read existing `~/.claude/settings.json` (must exist + be valid JSON).
* 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
* to compiled `dist/hooks/*.js`).
* 4. Additively merge the four hook groups into the settings object
* (existing entries preserved verbatim).
* 5. Write the merged JSON back over `settings.json`.
* 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.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import {
defaultHookEntries,
mergeHiveHooks,
type ClaudeCodeSettings,
} from './settings-merger.js';
import {
backupPathFor,
hookCommandFor,
resolvePaths,
type ResolvePathsOptions,
type ShimPaths,
} from './paths.js';
export interface InstallResult {
paths: ShimPaths;
backupPath: string;
pointerPath: string;
installedHooks: readonly string[];
alreadyInstalled: boolean;
/** The cli_path embedded in hook commands (undefined = default lookup at runtime). */
cliPath?: string;
}
export interface InstallOptions extends ResolvePathsOptions {
/** Per-hook timeout, seconds. Default 5. */
hookTimeoutSeconds?: number;
/** Override clock for deterministic tests. */
now?: () => Date;
/** Logger override. */
logger?: Logger;
/**
* Absolute path to the hive-mind-cli binary or its compiled JS entry.
* Required on Windows (npm bin shim is `.cmd` and can't be exec'd
* without a shell). Recommended for any production install where
* `hive-mind-cli` may not be on PATH at hook invocation time.
* Threaded into every hook command as `--cli-path "<path>"`.
*/
cliPath?: string;
}
const DEFAULT_HOOK_TIMEOUT_S = 5;
async function ensureDir(p: string): Promise<void> {
if (!existsSync(p)) await mkdir(p, { recursive: true });
}
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
// (e.g. the bin script in `src/bin/`) don't accidentally point us at
// `dist/bin/hooks/` instead of `dist/hooks/`. opts.hooksDir always
// wins when explicitly supplied.
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
});
const now = opts.now ?? ((): Date => new Date());
log.info('install starting', { settings: paths.settingsPath, hooksDir: paths.hooksDir });
if (!existsSync(paths.settingsPath)) {
throw new Error(
`expected Claude Code settings at ${paths.settingsPath}, file not found. ` +
`Run Claude Code at least once before installing this shim.`,
);
}
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)),
);
}
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);
const entries = defaultHookEntries(
paths.hooksDir,
opts.hookTimeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_S,
hookCommandFor,
cliPath,
);
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(),
settings_backup: backupPath,
hooks_dir: paths.hooksDir,
installed_hooks: entries.map((e) => e.basename),
cli_path: cliPath ?? null,
};
await writeFile(paths.pointerPath, JSON.stringify(pointer, null, 2) + '\n', 'utf-8');
log.info('install complete', { added: entries.length, cliPath: cliPath ?? '(PATH lookup)' });
const result: InstallResult = {
paths,
backupPath,
pointerPath: paths.pointerPath,
installedHooks: entries.map((e) => e.basename),
alreadyInstalled: false,
};
if (cliPath !== undefined) result.cliPath = cliPath;
return result;
}
function normalizeCliPath(input: string | undefined): string | undefined {
if (input === undefined) return undefined;
const trimmed = input.trim();
if (trimmed.length === 0) return undefined;
// Reject embedded double-quotes — they would break the
// `--cli-path "<value>"` quoting in the generated hook command.
if (trimmed.includes('"')) {
throw new Error(
`--cli-path value must not contain double-quote characters; got: ${trimmed.slice(0, 80)}`,
);
}
return trimmed;
}

View File

@@ -0,0 +1,93 @@
/**
* Filesystem path helpers for Claude Code shim install/uninstall.
*
* Centralized so tests can mock the home directory and dist directory
* without poking process.env or import.meta.url internals.
*/
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
export interface ShimPaths {
/** Claude Code config root (`~/.claude/`). */
claudeDir: string;
/** `~/.claude/settings.json`. */
settingsPath: string;
/** `~/.claude/hive-mind-install.json` — pointer to the active backup. */
pointerPath: string;
/** Directory where compiled hook scripts live (dist/hooks/). */
hooksDir: string;
}
export interface ResolvePathsOptions {
/** Override $HOME for tests. */
home?: string;
/** Override the URL used to locate dist/hooks (defaults to import.meta.url at runtime). */
moduleUrl?: string;
/** Override hooks directory directly (wins over moduleUrl). */
hooksDir?: string;
}
const HOOK_BASENAMES = [
'session-start',
'user-prompt-submit',
'stop',
'pre-compact',
] as const;
export type HookBasename = typeof HOOK_BASENAMES[number];
export function allHookBasenames(): readonly HookBasename[] {
return HOOK_BASENAMES;
}
function defaultHooksDirFromUrl(moduleUrl: string): string {
// Compiled location: <pkg>/dist/<somefile>.js — caller passes its own
// import.meta.url, dirname gives <pkg>/dist/, join gives <pkg>/dist/hooks.
// We resolve once more so the path is absolute and platform-normalized.
const dir = dirname(fileURLToPath(moduleUrl));
return resolve(dir, 'hooks');
}
export function resolvePaths(opts: ResolvePathsOptions = {}): ShimPaths {
const home = opts.home ?? homedir();
const claudeDir = join(home, '.claude');
const settingsPath = join(claudeDir, 'settings.json');
const pointerPath = join(claudeDir, 'hive-mind-install.json');
let hooksDir: string;
if (opts.hooksDir) {
hooksDir = resolve(opts.hooksDir);
} else if (opts.moduleUrl) {
hooksDir = defaultHooksDirFromUrl(opts.moduleUrl);
} else {
// Fallback: the consumer didn't pass moduleUrl — best we can do is
// assume CWD/dist/hooks. install.ts always passes moduleUrl so this
// only kicks in for ad-hoc test use.
hooksDir = resolve(process.cwd(), 'dist', 'hooks');
}
return { claudeDir, settingsPath, pointerPath, hooksDir };
}
export function hookCommandFor(
hooksDir: string,
basename: HookBasename,
cliPath?: string,
): string {
const scriptPath = join(hooksDir, `${basename}.js`);
// Quote paths so spaces in user home dir (Windows: "C:\Users\Marko Markovic\")
// don't fragment the command. Claude Code parses this string with shell rules.
const cliFlag = cliPath && cliPath.length > 0 ? ` --cli-path "${cliPath}"` : '';
const configuredNode = process.env.WAGGLE_HOOK_NODE_PATH?.trim();
if (configuredNode?.includes('"')) throw new Error('WAGGLE_HOOK_NODE_PATH cannot contain double quotes');
const nodeCommand = configuredNode ? `"${configuredNode}"` : 'node';
return `${nodeCommand} "${scriptPath}"${cliFlag}`;
}
export function backupPathFor(settingsPath: string, isoTimestamp: string): string {
// Replace ":" and "." with "-" so timestamp is filesystem-safe on Windows.
const stamp = isoTimestamp.replace(/[:.]/g, '-');
return `${settingsPath}.hive-mind-backup.${stamp}`;
}

View File

@@ -0,0 +1,130 @@
/**
* Pure-functional helpers for additively merging hive-mind hook entries
* into a Claude Code `settings.json` object.
*
* Design contract:
* - `mergeHiveHooks(settings, entries)` returns a new object; the
* caller-supplied settings are NEVER mutated in place.
* - Existing hook entries are preserved verbatim (preserves Marko's
* gsd-context-monitor.js etc.).
* - hive-mind entries are tagged with the marker key `_hiveMindShim:
* "@hive-mind/claude-code-hooks"` on the group so a future install
* can detect duplicates / upgrade in place.
*/
import { allHookBasenames, type HookBasename } from './paths.js';
export const HIVE_MIND_MARKER = '@hive-mind/claude-code-hooks';
export type HookGroup = {
hooks: Array<{
type: 'command';
command: string;
timeout?: number;
}>;
matcher?: string;
/** Marker our installer drops on every group it adds, used by uninstall. */
_hiveMindShim?: string;
};
export type ClaudeCodeSettings = Record<string, unknown> & {
hooks?: Record<string, HookGroup[]>;
};
/** Maps our four canonical hook basenames to the Claude Code `hooks.<event>` key. */
export const HOOK_EVENT_BY_BASENAME: Record<HookBasename, string> = {
'session-start': 'SessionStart',
'user-prompt-submit': 'UserPromptSubmit',
'stop': 'Stop',
'pre-compact': 'PreCompact',
};
export interface HookEntrySpec {
basename: HookBasename;
command: string;
timeout?: number;
}
function buildGroup(spec: HookEntrySpec): HookGroup {
const group: HookGroup = {
hooks: [{
type: 'command',
command: spec.command,
...(spec.timeout !== undefined ? { timeout: spec.timeout } : {}),
}],
_hiveMindShim: HIVE_MIND_MARKER,
};
return group;
}
function isHiveGroup(group: HookGroup | undefined): boolean {
return !!group && group._hiveMindShim === HIVE_MIND_MARKER;
}
/**
* 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
* duplicated — supports re-running install for upgrades.
*/
export function mergeHiveHooks(
settings: ClaudeCodeSettings | undefined,
entries: readonly HookEntrySpec[],
): ClaudeCodeSettings {
// Deep-copy starting structure (settings + settings.hooks + each event array).
const next: ClaudeCodeSettings = settings ? { ...settings } : {};
const nextHooks: Record<string, HookGroup[]> = next.hooks ? { ...next.hooks } : {};
for (const spec of entries) {
const eventKey = HOOK_EVENT_BY_BASENAME[spec.basename];
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;
}
}
if (!replaced) existingArr.push(newGroup);
nextHooks[eventKey] = existingArr;
}
next.hooks = nextHooks;
return next;
}
/**
* Detect whether a settings object already has hive-mind hooks installed.
* True iff at least one event array has a group bearing the marker.
*/
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;
}
return false;
}
/**
* Build the canonical 4-entry spec list for the four hooks we install,
* given the hooksDir and a shared timeout.
*/
export function defaultHookEntries(
hooksDir: string,
timeoutSeconds: number,
cmdBuilder: (hooksDir: string, basename: HookBasename, cliPath?: string) => string,
cliPath?: string,
): HookEntrySpec[] {
return allHookBasenames().map((basename) => ({
basename,
command: cmdBuilder(hooksDir, basename, cliPath),
timeout: timeoutSeconds,
}));
}

View File

@@ -0,0 +1,98 @@
/**
* 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.
*/
import { readFile, writeFile, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import { resolvePaths, type ResolvePathsOptions, type ShimPaths } from './paths.js';
export interface UninstallResult {
paths: ShimPaths;
restoredFrom: string;
pointerRemoved: boolean;
backupRemoved: boolean;
}
export interface UninstallOptions extends ResolvePathsOptions {
logger?: Logger;
/** If false, leaves the backup file in place after restore. Default true. */
cleanupBackup?: boolean;
}
interface InstallPointer {
version: string;
installed_at: string;
settings_backup: string;
hooks_dir: string;
installed_hooks: readonly string[];
}
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';
}
export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
const log = opts.logger ?? createLogger({ name: 'claude-code-hooks/uninstall' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
});
const cleanup = opts.cleanupBackup ?? true;
if (!existsSync(paths.pointerPath)) {
throw new Error(
`no install pointer found at ${paths.pointerPath}. ` +
`Was @hive-mind/claude-code-hooks ever installed for this user?`,
);
}
const pointerRaw = await readFile(paths.pointerPath, 'utf-8');
const pointerJson: unknown = JSON.parse(pointerRaw);
if (!isPointer(pointerJson)) {
throw new Error(`install pointer at ${paths.pointerPath} is malformed`);
}
const pointer = pointerJson;
if (!existsSync(pointer.settings_backup)) {
throw new Error(
`backup file referenced by ${paths.pointerPath} is missing: ${pointer.settings_backup}`,
);
}
const backupContent = await readFile(pointer.settings_backup, 'utf-8');
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.`,
);
}
log.info('settings restored byte-identical', { settings: paths.settingsPath });
let backupRemoved = false;
if (cleanup) {
await unlink(pointer.settings_backup);
backupRemoved = true;
}
await unlink(paths.pointerPath);
return {
paths,
restoredFrom: pointer.settings_backup,
pointerRemoved: true,
backupRemoved,
};
}

View File

@@ -0,0 +1,175 @@
/**
* Smoke-check the install: settings exist + parse + reference live
* hook scripts, and hive-mind-cli answers a `--help` probe.
*
* Probe priority for `cli_path`:
* 1. Explicit `opts.cliPath` (caller override)
* 2. `cli_path` recorded in `~/.claude/hive-mind-install.json` (set
* at install time via `--cli-path`)
* 3. Bare `'hive-mind-cli'` on PATH
*/
import { readFile, access } from 'node:fs/promises';
import { constants, existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import { resolvePaths, allHookBasenames, type ResolvePathsOptions } from './paths.js';
import {
HIVE_MIND_MARKER,
HOOK_EVENT_BY_BASENAME,
type ClaudeCodeSettings,
type HookGroup,
} from './settings-merger.js';
export interface VerifyCheck {
name: string;
ok: boolean;
detail?: string;
}
export interface VerifyResult {
ok: boolean;
checks: VerifyCheck[];
}
export interface VerifyOptions extends ResolvePathsOptions {
logger?: Logger;
/** Override hive-mind-cli executable name. Default 'hive-mind-cli'. */
cliPath?: string;
/** Test hook for spawn. */
spawnImpl?: typeof spawn;
}
async function fileReadable(p: string): Promise<boolean> {
try { await access(p, constants.R_OK); return true; } catch { return false; }
}
function 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 probeCliVersion(
cliPath: string,
spawnImpl: typeof spawn,
timeoutMs: number,
): Promise<{ ok: boolean; output: string }> {
return new Promise((resolve) => {
let settled = false;
// If cliPath is a JS file, run via Node directly so .js paths work
// cross-platform without needing a shell to launch npm bin shims.
const command = isJsPath(cliPath) ? process.execPath : cliPath;
const args = isJsPath(cliPath) ? [cliPath, '--help'] : ['--help'];
const child = spawnImpl(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { child.kill('SIGTERM'); } catch { /* gone */ }
resolve({ ok: false, output: 'timed out probing hive-mind-cli' });
}, timeoutMs);
child.stdout?.on('data', (c: Buffer) => stdout.push(c));
child.stderr?.on('data', (c: Buffer) => stderr.push(c));
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: false, output: err instanceof Error ? err.message : String(err) });
});
child.on('exit', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
const out = Buffer.concat(stdout).toString('utf-8').slice(0, 200);
const err = Buffer.concat(stderr).toString('utf-8').slice(0, 200);
resolve({ ok: code === 0, output: code === 0 ? out : err });
});
});
}
export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
const log = opts.logger ?? createLogger({ name: 'claude-code-hooks/verify' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
});
const checks: VerifyCheck[] = [];
// 1. settings.json exists and parses
if (!existsSync(paths.settingsPath)) {
checks.push({ name: 'settings.json exists', ok: false, detail: paths.settingsPath });
return { ok: false, checks };
}
checks.push({ name: 'settings.json exists', ok: true, detail: paths.settingsPath });
let parsed: ClaudeCodeSettings;
try {
parsed = JSON.parse(await readFile(paths.settingsPath, 'utf-8')) as ClaudeCodeSettings;
checks.push({ name: 'settings.json parses as JSON', ok: true });
} catch (err) {
checks.push({
name: 'settings.json parses as JSON',
ok: false,
detail: err instanceof Error ? err.message : String(err),
});
return { ok: false, checks };
}
// 2. each hive hook entry is present and points to an existing dist file
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;
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;
}
// 3. hive-mind-cli responds to --help.
// Prefer a path recorded by install (--cli-path flag) if present.
let cliPathFromPointer: string | undefined;
if (existsSync(paths.pointerPath)) {
try {
const pointerObj = JSON.parse(await readFile(paths.pointerPath, 'utf-8')) as Record<string, unknown>;
const pointerCliPath = pointerObj['cli_path'];
if (typeof pointerCliPath === 'string' && pointerCliPath.length > 0) {
cliPathFromPointer = pointerCliPath;
}
} catch { /* pointer unreadable — fall through */ }
}
const cliPath = opts.cliPath ?? cliPathFromPointer ?? 'hive-mind-cli';
const spawnImpl = opts.spawnImpl ?? spawn;
const probe = await probeCliVersion(cliPath, spawnImpl, 4000);
checks.push({
name: 'hive-mind-cli reachable',
ok: probe.ok,
detail: cliPathFromPointer ? `${probe.output} (pinned: ${cliPath})` : probe.output,
});
const ok = checks.every((c) => c.ok);
log.info('verify complete', { ok, total: checks.length, failed: checks.filter((c) => !c.ok).length });
return { ok, checks };
}

View File

@@ -0,0 +1,57 @@
import { vi } from 'vitest';
import type { CliBridge, MemoryHit } from '@waggle/hive-mind-shim-core';
export interface MockBridgeOverrides {
saveMemoryResult?: { id: string; success: boolean; workspace: string };
recallMemoryHits?: MemoryHit[];
cleanupFramesResult?: { pruned: number };
saveMemoryThrows?: Error;
}
export interface MockBridge extends CliBridge {
saveMemory: ReturnType<typeof vi.fn>;
recallMemory: ReturnType<typeof vi.fn>;
cleanupFrames: ReturnType<typeof vi.fn>;
callMcpTool: ReturnType<typeof vi.fn>;
setWorkspaceById: ReturnType<typeof vi.fn>;
getActiveWorkspaceId: ReturnType<typeof vi.fn>;
}
export function makeMockBridge(overrides: MockBridgeOverrides = {}): MockBridge {
let activeWorkspaceId: string | undefined;
const saveMemory = overrides.saveMemoryThrows
? vi.fn(async () => { throw overrides.saveMemoryThrows; })
: vi.fn(async () => overrides.saveMemoryResult ?? { id: 'frame-1', success: true, workspace: 'personal' });
const recallMemory = vi.fn(async () => overrides.recallMemoryHits ?? []);
const cleanupFrames = vi.fn(async () => overrides.cleanupFramesResult ?? { pruned: 0 });
const callMcpTool = vi.fn(async () => ({}));
const setWorkspaceById = vi.fn((id?: string) => { activeWorkspaceId = id; });
const getActiveWorkspaceId = vi.fn(() => activeWorkspaceId);
return {
saveMemory,
recallMemory,
cleanupFrames,
callMcpTool,
setWorkspaceById,
getActiveWorkspaceId,
} as unknown as MockBridge;
}
export interface CapturedHookOutput {
stdout: string[];
exits: number[];
}
export function makeHookCaptures(): CapturedHookOutput & {
writeStdout: (s: string) => void;
exit: (code: number) => void;
} {
const stdout: string[] = [];
const exits: number[] = [];
return {
stdout,
exits,
writeStdout: (s) => stdout.push(s),
exit: (c) => exits.push(c),
};
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { preCompactHandler, runPreCompact } from '../../src/hooks/pre-compact.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
describe('pre-compact handler', () => {
it('extracts scope from session_id, sessionId, or scope', () => {
expect(preCompactHandler.parse({ session_id: 'a' }).scope).toBe('a');
expect(preCompactHandler.parse({ sessionId: 'b' }).scope).toBe('b');
expect(preCompactHandler.parse({ scope: 'c' }).scope).toBe('c');
expect(preCompactHandler.parse({}).scope).toBeUndefined();
});
it('calls cleanupFrames (Commit 1.4 renamed from compactMemory)', async () => {
const bridge = makeMockBridge({ cleanupFramesResult: { pruned: 4 } });
const cap = makeHookCaptures();
await runPreCompact({
readStdin: async () => JSON.stringify({ session_id: 'sess-3' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
expect(cap.exits).toEqual([0]);
});
it('still calls cleanupFrames even when no scope present', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runPreCompact({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.cleanupFrames).toHaveBeenCalled();
});
it('exits 0 when cleanupFrames rejects', async () => {
const bridge = makeMockBridge();
bridge.cleanupFrames.mockRejectedValueOnce(new Error('cli unreachable'));
const cap = makeHookCaptures();
await runPreCompact({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { sessionStartHandler, runSessionStart } from '../../src/hooks/session-start.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
import type { MemoryHit } from '@waggle/hive-mind-shim-core';
const HIT_FIXTURE: MemoryHit = {
id: 1,
content: '[hm src:claude-code event:stop] past observation',
importance: 'important',
source: 'system',
score: 0.87,
created_at: '2026-04-28T10:00:00.000Z',
from: 'personal',
};
describe('session-start handler', () => {
it('parses cwd from payload and falls back to process.cwd()', () => {
expect(sessionStartHandler.parse({ cwd: '/proj/x' }).cwd).toBe('/proj/x');
expect(sessionStartHandler.parse({}).cwd).toBe(process.cwd());
});
it('parses recallLimit number with default 20', () => {
expect(sessionStartHandler.parse({}).recallLimit).toBe(20);
expect(sessionStartHandler.parse({ recall_limit: 5 }).recallLimit).toBe(5);
expect(sessionStartHandler.parse({ recallLimit: 'not-a-number' }).recallLimit).toBe(20);
});
it('calls recallMemory with personal scope and formats hits into context', async () => {
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => JSON.stringify({ cwd: '/proj/x', recall_limit: 1 }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
// Commit 1.4: switchWorkspace removed — only recallMemory should fire.
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 1, scope: 'personal', workspace: null });
expect(cap.stdout).toHaveLength(1);
const parsed = JSON.parse(cap.stdout[0]) as { hookSpecificOutput: { additionalContext: string } };
expect(parsed.hookSpecificOutput.additionalContext).toContain('past observation');
expect(cap.exits).toEqual([0]);
});
it('handles empty recall result gracefully', async () => {
const bridge = makeMockBridge({ recallMemoryHits: [] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const parsed = JSON.parse(cap.stdout[0]) as { hookSpecificOutput: { additionalContext: string } };
expect(parsed.hookSpecificOutput.additionalContext).toContain('no recalled frames');
expect(cap.exits).toEqual([0]);
});
it('exits 0 even when bridge throws (fail-open)', async () => {
const bridge = makeMockBridge();
bridge.recallMemory.mockRejectedValueOnce(new Error('cli unreachable'));
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
it('annotates hits with their workspace origin when from != personal', async () => {
const bridge = makeMockBridge({ recallMemoryHits: [{ ...HIT_FIXTURE, from: 'workspace:team-foo' }] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const parsed = JSON.parse(cap.stdout[0]) as { hookSpecificOutput: { additionalContext: string } };
expect(parsed.hookSpecificOutput.additionalContext).toContain('workspace:team-foo');
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
parseHookArgs,
pickStringField,
pickStringFromObject,
safeJsonParse,
} from '../../src/hooks/_shared.js';
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', () => {
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 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 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();
});
});

View File

@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest';
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', () => {
expect(stopHandler.parse({ response: 'r' }).response).toBe('r');
expect(stopHandler.parse({ assistant_message: 'a' }).response).toBe('a');
expect(stopHandler.parse({}).response).toBe('');
});
it('summarizes long responses and saves an important frame', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
const longResp = 'First sentence. ' + 'X'.repeat(2000) + '.';
await runStop({
readStdin: async () => JSON.stringify({
response: longResp,
cwd: '/proj',
session_id: 'sess-2',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const arg = bridge.saveMemory.mock.calls[0][0];
expect(['important', 'critical']).toContain(arg.importance);
expect(typeof arg.content).toBe('string');
expect(arg.content.length).toBeLessThanOrEqual(401); // budget + ellipsis
expect(cap.exits).toEqual([0]);
});
it('promotes to critical when response contains a "never" directive', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
response: 'never commit secrets to the public repo.',
cwd: '/proj',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const arg = bridge.saveMemory.mock.calls[0][0];
expect(arg.importance).toBe('critical');
});
it('attaches parent frame id when supplied', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
response: 'something happened.',
parent_frame_id: 'frame-99',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const arg = bridge.saveMemory.mock.calls[0][0];
expect(arg.parent).toBe('frame-99');
});
it('skips save when response is empty', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
});
// ── AI-OS Phase 1E — opt-in v2 signal emission ─────────────────────
describe('stop handler — WAGGLE_SIGNAL_EMIT (Phase 1E)', () => {
// Capture the global fetch so we can assert on the emitter call.
// maybeEmitDiscovery uses globalThis.fetch when no fetchImpl is
// passed — the production hook does not pass one.
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',
teamId: 'personal::claude-code-hook',
senderId: 'claude-code-hook',
type: 'broadcast',
subtype: 'discovery',
content: body?.content ?? {},
referenceId: null,
routing: null,
createdAt: new Date().toISOString(),
},
}),
{ status: 201, headers: { 'content-type': 'application/json' } },
);
}) as typeof globalThis.fetch & { calls: typeof calls };
impl.calls = calls;
return impl;
}
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;
});
}
it('does not emit when WAGGLE_SIGNAL_EMIT is unset', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
const f = makeOkFetch();
await withEnv('WAGGLE_SIGNAL_EMIT', undefined, () =>
withCapturedFetch(f, () =>
runStop({
readStdin: async () => JSON.stringify({
response: 'never commit secrets to the public repo.',
cwd: '/proj',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
}),
),
);
expect(f.calls).toHaveLength(0);
});
it('does not emit when WAGGLE_SIGNAL_EMIT=0', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
const f = makeOkFetch();
await withEnv('WAGGLE_SIGNAL_EMIT', '0', () =>
withCapturedFetch(f, () =>
runStop({
readStdin: async () => JSON.stringify({
response: 'never commit secrets to the public repo.',
cwd: '/proj',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
}),
),
);
expect(f.calls).toHaveLength(0);
});
it('emits on critical importance when WAGGLE_SIGNAL_EMIT=1', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
const f = makeOkFetch();
await withEnv('WAGGLE_SIGNAL_EMIT', '1', () =>
withCapturedFetch(f, () =>
runStop({
readStdin: async () => JSON.stringify({
response: 'never commit secrets to the public repo.',
cwd: '/proj',
session_id: 'sess-cc',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
}),
),
);
expect(f.calls).toHaveLength(1);
expect(f.calls[0].url).toContain('/api/waggle-dance/signal');
const body = f.calls[0].body as Record<string, unknown>;
expect(body.type).toBe('broadcast');
expect(body.subtype).toBe('discovery');
expect(body.senderId).toBe('claude-code-hook');
const content = body.content as Record<string, unknown>;
expect(content.tool).toBe('claude-code');
expect(content.eventType).toBe('stop');
// Critical "never" sentence → critical importance → high-or-critical
// emission per the Importance→emit mapping (critical → critical).
expect(content.importance).toBe('critical');
expect(content.sessionId).toBe('sess-cc');
expect(content.frameId).toBe('frame-1');
expect(content.memoryWorkspace).toBe('personal');
expect(content.summary).toContain('never commit secrets');
});
it('does not emit on a normal-importance turn (emission policy floor)', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
const f = makeOkFetch();
// A short benign response → classifyImportance returns 'normal',
// which our mapping bumps to 'normal' (not high/critical) → the
// maybeEmitDiscovery policy skips emission.
await withEnv('WAGGLE_SIGNAL_EMIT', '1', () =>
withCapturedFetch(f, () =>
runStop({
readStdin: async () => JSON.stringify({
response: 'Hello.',
cwd: '/proj',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
}),
),
);
expect(f.calls).toHaveLength(0);
});
it('saves frame even when the signal endpoint is unreachable (fail-open)', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
const unreachable = (async () => {
throw new Error('ECONNREFUSED');
}) as typeof globalThis.fetch;
await withEnv('WAGGLE_SIGNAL_EMIT', 'true', () =>
withCapturedFetch(unreachable, () =>
runStop({
readStdin: async () => JSON.stringify({
response: 'never commit secrets to the public repo.',
cwd: '/proj',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
}),
),
);
// Frame save still happened — emitter failure does not block.
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { runUserPromptSubmit, userPromptSubmitHandler } from '../../src/hooks/user-prompt-submit.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
describe('user-prompt-submit handler', () => {
it('extracts prompt from payload.prompt or payload.user_message', () => {
expect(userPromptSubmitHandler.parse({ prompt: 'hi' }).prompt).toBe('hi');
expect(userPromptSubmitHandler.parse({ user_message: 'hello' }).prompt).toBe('hello');
expect(userPromptSubmitHandler.parse({}).prompt).toBe('');
});
it('saves a temporary frame containing the prompt', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({
prompt: 'How do I X?',
cwd: '/proj/foo',
session_id: 'sess-7',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const arg = bridge.saveMemory.mock.calls[0][0];
expect(arg).toMatchObject({
content: 'How do I X?',
importance: 'temporary',
scope: 'sess-7',
source: 'claude-code',
});
expect(cap.exits).toEqual([0]);
});
it('skips save when no prompt is present', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('exits 0 even if saveMemory rejects', async () => {
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli down') });
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ prompt: 'x' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,150 @@
import { describe, expect, it, afterEach } from 'vitest';
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { install } from '../src/install.js';
import { HIVE_MIND_MARKER, type ClaudeCodeSettings } from '../src/settings-merger.js';
interface TestEnv {
home: string;
hooksDir: string;
settingsPath: string;
pointerPath: string;
}
async function bootstrap(initial: ClaudeCodeSettings): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmc-install-'));
const claudeDir = join(home, '.claude');
await mkdir(claudeDir, { recursive: true });
const settingsPath = join(claudeDir, 'settings.json');
await writeFile(settingsPath, JSON.stringify(initial, null, 2), 'utf-8');
const hooksDir = resolve(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
return { home, hooksDir, settingsPath, pointerPath: join(claudeDir, 'hive-mind-install.json') };
}
async function cleanup(env: TestEnv): Promise<void> {
await rm(env.home, { recursive: true, force: true });
}
describe('install', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await cleanup(env);
});
it('throws when settings.json is missing', async () => {
const home = await mkdtemp(join(tmpdir(), 'hmc-install-no-settings-'));
try {
await expect(install({
home,
hooksDir: join(home, 'dist', 'hooks'),
})).rejects.toThrow(/settings/);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('throws on malformed JSON in settings.json', async () => {
env = await bootstrap({} as ClaudeCodeSettings);
await writeFile(env.settingsPath, '{ not valid json', 'utf-8');
await expect(install({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/parse/);
});
it('writes a byte-identical backup before mutating', async () => {
env = await bootstrap({ env: { FOO: '1' } } as ClaudeCodeSettings);
const original = await readFile(env.settingsPath, 'utf-8');
const result = await install({ home: env.home, hooksDir: env.hooksDir });
const backupContent = await readFile(result.backupPath, 'utf-8');
expect(backupContent).toBe(original);
});
it('appends 4 hive entries and preserves existing structure', async () => {
const initial: ClaudeCodeSettings = {
hooks: {
SessionStart: [
{ hooks: [{ type: 'command', command: 'node /existing/x.js' }] },
],
},
};
env = await bootstrap(initial);
await install({ home: env.home, hooksDir: env.hooksDir });
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
expect(after.hooks?.SessionStart).toHaveLength(2);
expect(after.hooks?.SessionStart?.[0].hooks[0].command).toBe('node /existing/x.js');
expect(after.hooks?.SessionStart?.[1]._hiveMindShim).toBe(HIVE_MIND_MARKER);
expect(after.hooks?.UserPromptSubmit).toHaveLength(1);
expect(after.hooks?.Stop).toHaveLength(1);
expect(after.hooks?.PreCompact).toHaveLength(1);
});
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['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
expect(typeof pointer['version']).toBe('string');
});
it('respects a custom now() for deterministic backup filename', async () => {
env = await bootstrap({});
const fixedTs = '2026-04-28T10:30:45.123Z';
const result = await install({
home: env.home,
hooksDir: env.hooksDir,
now: () => new Date(fixedTs),
});
expect(result.backupPath).toContain('hive-mind-backup.2026-04-28T10-30-45-123Z');
const stats = await stat(result.backupPath);
expect(stats.isFile()).toBe(true);
});
it('threads --cli-path into every generated hook command', async () => {
env = await bootstrap({});
const cliPath = '/abs/path/to/dist/index.js';
const result = await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
expect(result.cliPath).toBe(cliPath);
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
const sessionStart = after.hooks?.SessionStart?.[0];
expect(sessionStart?.hooks[0].command).toContain(`--cli-path "${cliPath}"`);
const stop = after.hooks?.Stop?.[0];
expect(stop?.hooks[0].command).toContain(`--cli-path "${cliPath}"`);
});
it('records cli_path in the install pointer for verify to pick up', async () => {
env = await bootstrap({});
const cliPath = '/abs/cli.js';
const result = await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['cli_path']).toBe(cliPath);
});
it('records cli_path: null when --cli-path is omitted', async () => {
env = await bootstrap({});
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.cliPath).toBeUndefined();
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['cli_path']).toBeNull();
});
it('rejects --cli-path values that contain double-quote characters', async () => {
env = await bootstrap({});
await expect(install({
home: env.home,
hooksDir: env.hooksDir,
cliPath: 'malicious" && rm -rf / "',
})).rejects.toThrow(/double-quote/);
});
it('treats whitespace-only --cli-path as unset', async () => {
env = await bootstrap({});
const result = await install({ home: env.home, hooksDir: env.hooksDir, cliPath: ' ' });
expect(result.cliPath).toBeUndefined();
});
});

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { join, resolve } from 'node:path';
import {
allHookBasenames,
backupPathFor,
hookCommandFor,
resolvePaths,
} from '../src/paths.js';
describe('resolvePaths', () => {
it('places settings.json + pointer under <home>/.claude/', () => {
const home = resolve('/fake/home');
const paths = resolvePaths({ home, hooksDir: resolve('/some/dist/hooks') });
expect(paths.claudeDir).toBe(join(home, '.claude'));
expect(paths.settingsPath).toBe(join(home, '.claude', 'settings.json'));
expect(paths.pointerPath).toBe(join(home, '.claude', 'hive-mind-install.json'));
});
it('hooksDir override wins over moduleUrl', () => {
const explicit = resolve('/x/y/hooks');
const paths = resolvePaths({
home: resolve('/h'),
hooksDir: explicit,
moduleUrl: 'file:///irrelevant/dist/install.js',
});
expect(paths.hooksDir).toBe(explicit);
});
it('falls back to cwd/dist/hooks when neither moduleUrl nor hooksDir is given', () => {
const paths = resolvePaths({ home: resolve('/h') });
expect(paths.hooksDir).toBe(resolve(process.cwd(), 'dist', 'hooks'));
});
});
describe('hookCommandFor', () => {
it('produces a quoted node invocation with absolute path', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks'), 'session-start');
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
});
it('appends --cli-path when supplied', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks'), 'session-start', '/abs/cli/dist/index.js');
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
});
it('omits --cli-path when empty string is passed', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks'), 'session-start', '');
expect(cmd).not.toContain('--cli-path');
});
it('preserves Windows-style paths inside quotes', () => {
const cmd = hookCommandFor('/abs/dist/hooks', 'stop', 'C:\\Program Files\\hive-mind\\dist\\index.js');
expect(cmd).toContain('--cli-path "C:\\Program Files\\hive-mind\\dist\\index.js"');
});
});
describe('backupPathFor', () => {
it('replaces colons and dots in the timestamp for filesystem safety', () => {
const backup = backupPathFor('/h/.claude/settings.json', '2026-04-28T10:30:45.123Z');
expect(backup).toBe('/h/.claude/settings.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
});
});
describe('allHookBasenames', () => {
it('returns the four canonical basenames', () => {
expect([...allHookBasenames()].sort()).toEqual([
'pre-compact',
'session-start',
'stop',
'user-prompt-submit',
]);
});
});

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import {
HIVE_MIND_MARKER,
HOOK_EVENT_BY_BASENAME,
defaultHookEntries,
hasHiveHooks,
mergeHiveHooks,
type ClaudeCodeSettings,
type HookEntrySpec,
} from '../src/settings-merger.js';
import { hookCommandFor } from '../src/paths.js';
const HOOKS_DIR = '/abs/dist/hooks';
function makeEntry(basename: 'session-start' | 'user-prompt-submit' | 'stop' | 'pre-compact'): HookEntrySpec {
return {
basename,
command: hookCommandFor(HOOKS_DIR, basename),
timeout: 5,
};
}
describe('mergeHiveHooks', () => {
it('returns a new object — does not mutate input', () => {
const original: ClaudeCodeSettings = { hooks: { SessionStart: [] } };
const merged = mergeHiveHooks(original, [makeEntry('session-start')]);
expect(merged).not.toBe(original);
expect(original.hooks?.SessionStart).toEqual([]);
});
it('appends a hive group to each requested event array', () => {
const merged = mergeHiveHooks({}, [
makeEntry('session-start'),
makeEntry('user-prompt-submit'),
makeEntry('stop'),
makeEntry('pre-compact'),
]);
expect(merged.hooks?.SessionStart).toHaveLength(1);
expect(merged.hooks?.UserPromptSubmit).toHaveLength(1);
expect(merged.hooks?.Stop).toHaveLength(1);
expect(merged.hooks?.PreCompact).toHaveLength(1);
});
it('preserves existing hook entries verbatim', () => {
const existing: ClaudeCodeSettings = {
hooks: {
SessionStart: [
{ hooks: [{ type: 'command', command: 'node /existing/gsd-context.js', timeout: 10 }] },
],
},
};
const merged = mergeHiveHooks(existing, [makeEntry('session-start')]);
const arr = merged.hooks?.SessionStart;
expect(arr).toHaveLength(2);
expect(arr?.[0].hooks[0].command).toBe('node /existing/gsd-context.js');
expect(arr?.[1]._hiveMindShim).toBe(HIVE_MIND_MARKER);
});
it('replaces an existing hive entry when the same command is re-installed (idempotent)', () => {
const cmd = hookCommandFor(HOOKS_DIR, 'session-start');
const merged1 = mergeHiveHooks({}, [{ basename: 'session-start', command: cmd, timeout: 5 }]);
const merged2 = mergeHiveHooks(merged1, [{ basename: 'session-start', command: cmd, timeout: 7 }]);
expect(merged2.hooks?.SessionStart).toHaveLength(1);
expect(merged2.hooks?.SessionStart?.[0].hooks[0].timeout).toBe(7);
});
it('preserves unrelated top-level fields', () => {
const merged = mergeHiveHooks(
{ env: { SOMETHING: '1' }, statusLine: { type: 'command', command: 'foo' }, hooks: {} } as ClaudeCodeSettings,
[makeEntry('session-start')],
);
expect(merged['env']).toEqual({ SOMETHING: '1' });
expect(merged['statusLine']).toEqual({ type: 'command', command: 'foo' });
});
});
describe('hasHiveHooks', () => {
it('returns false on empty settings', () => {
expect(hasHiveHooks(undefined)).toBe(false);
expect(hasHiveHooks({})).toBe(false);
expect(hasHiveHooks({ hooks: {} })).toBe(false);
});
it('returns true when at least one event array has the marker', () => {
const merged = mergeHiveHooks({}, [makeEntry('stop')]);
expect(hasHiveHooks(merged)).toBe(true);
});
});
describe('defaultHookEntries', () => {
it('builds 4 entries — one per canonical hook', () => {
const entries = defaultHookEntries(HOOKS_DIR, 5, hookCommandFor);
expect(entries).toHaveLength(4);
const events = entries.map((e) => HOOK_EVENT_BY_BASENAME[e.basename]);
expect([...events].sort()).toEqual(['PreCompact', 'SessionStart', 'Stop', 'UserPromptSubmit']);
});
it('every entry carries the requested timeout', () => {
const entries = defaultHookEntries(HOOKS_DIR, 9, hookCommandFor);
expect(entries.every((e) => e.timeout === 9)).toBe(true);
});
it('threads cliPath into every generated hook command', () => {
const cliPath = '/abs/cli.js';
const entries = defaultHookEntries(HOOKS_DIR, 5, hookCommandFor, cliPath);
expect(entries.every((e) => e.command.includes(`--cli-path "${cliPath}"`))).toBe(true);
});
it('omits --cli-path entirely when none supplied', () => {
const entries = defaultHookEntries(HOOKS_DIR, 5, hookCommandFor);
expect(entries.every((e) => !e.command.includes('--cli-path'))).toBe(true);
});
});

View File

@@ -0,0 +1,100 @@
import { describe, expect, it, afterEach } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { install } from '../src/install.js';
import { uninstall } from '../src/uninstall.js';
import type { ClaudeCodeSettings } from '../src/settings-merger.js';
interface TestEnv {
home: string;
hooksDir: string;
settingsPath: string;
pointerPath: string;
}
async function bootstrap(initial: ClaudeCodeSettings): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmc-uninstall-'));
const claudeDir = join(home, '.claude');
await mkdir(claudeDir, { recursive: true });
const settingsPath = join(claudeDir, 'settings.json');
await writeFile(settingsPath, JSON.stringify(initial, null, 2), 'utf-8');
const hooksDir = resolve(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
return { home, hooksDir, settingsPath, pointerPath: join(claudeDir, 'hive-mind-install.json') };
}
function sha256(s: string): string {
return createHash('sha256').update(s, 'utf-8').digest('hex');
}
describe('uninstall', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws when no pointer file exists', async () => {
env = await bootstrap({});
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/install pointer/);
});
it('throws when pointer is malformed', async () => {
env = await bootstrap({});
await writeFile(env.pointerPath, '{}', 'utf-8');
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/malformed/);
});
it('install + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
const initialSettings: ClaudeCodeSettings = {
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' },
hooks: {
SessionStart: [
{ hooks: [{ type: 'command', command: 'node /existing/gsd-context.js' }] },
],
PreCompact: [
{ hooks: [{ type: 'command', command: 'node /existing/pre-compact.js', timeout: 10 }] },
],
},
};
env = await bootstrap(initialSettings);
const preInstall = await readFile(env.settingsPath, 'utf-8');
const preHash = sha256(preInstall);
await install({ home: env.home, hooksDir: env.hooksDir });
const afterInstall = await readFile(env.settingsPath, 'utf-8');
expect(sha256(afterInstall)).not.toBe(preHash);
await uninstall({ home: env.home, hooksDir: env.hooksDir });
const afterUninstall = await readFile(env.settingsPath, 'utf-8');
expect(sha256(afterUninstall)).toBe(preHash);
expect(afterUninstall).toBe(preInstall);
});
it('removes the backup file by default after restore', async () => {
env = await bootstrap({});
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(result.backupPath)).toBe(true);
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.backupRemoved).toBe(true);
expect(existsSync(result.backupPath)).toBe(false);
expect(existsSync(result.pointerPath)).toBe(false);
});
it('keeps the backup when cleanupBackup=false', async () => {
env = await bootstrap({});
const result = await install({ home: env.home, hooksDir: env.hooksDir });
const u = await uninstall({
home: env.home,
hooksDir: env.hooksDir,
cleanupBackup: false,
});
expect(u.backupRemoved).toBe(false);
expect(existsSync(result.backupPath)).toBe(true);
});
});

View File

@@ -0,0 +1,145 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ChildProcess } from 'node:child_process';
import { install } from '../src/install.js';
import { verify } from '../src/verify.js';
import type { ClaudeCodeSettings } from '../src/settings-merger.js';
interface TestEnv {
home: string;
hooksDir: string;
}
async function bootstrap(initial: ClaudeCodeSettings, withHookFiles: boolean): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmc-verify-'));
const claudeDir = join(home, '.claude');
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');
await mkdir(hooksDir, { recursive: true });
if (withHookFiles) {
for (const b of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
await writeFile(join(hooksDir, `${b}.js`), '/* mock hook */', 'utf-8');
}
}
return { home, hooksDir };
}
function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: string }): typeof import('node:child_process').spawn {
return ((_cmd: string, _args: readonly string[], _options?: unknown) => {
const emitter = new EventEmitter();
const stdout = Readable.from([Buffer.from(opts.stdout ?? 'hive-mind-cli help text\n')]);
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
const child = Object.assign(emitter, {
stdout,
stderr,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
setImmediate(() => emitter.emit('exit', opts.exitCode));
return child;
}) as unknown as typeof import('node:child_process').spawn;
}
describe('verify', () => {
const envs: TestEnv[] = [];
afterEach(async () => {
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
});
it('reports failure when settings.json is missing', async () => {
const home = await mkdtemp(join(tmpdir(), 'hmc-verify-missing-'));
envs.push({ home, hooksDir: '' });
const result = await verify({
home,
hooksDir: join(home, 'fake-dist', 'hooks'),
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks[0].name).toBe('settings.json exists');
expect(result.checks[0].ok).toBe(false);
});
it('reports failure when hooks are not yet installed', async () => {
const env = await bootstrap({ hooks: {} }, true);
envs.push(env);
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks.some((c) => !c.ok && c.name.includes('contains hive-mind entry'))).toBe(true);
});
it('passes after a successful install with hook files on disk and CLI reachable', async () => {
const env = await bootstrap({}, true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(true);
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
expect(cliCheck?.ok).toBe(true);
});
it('reports CLI unreachable when the spawn exits non-zero', async () => {
const env = await bootstrap({}, true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 127, stderr: 'command not found' }),
});
expect(result.ok).toBe(false);
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
expect(cliCheck?.ok).toBe(false);
});
it('uses cli_path from the install pointer for the probe', async () => {
const env = await bootstrap({}, true);
envs.push(env);
const cliPath = '/abs/from/pointer.js';
await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
const records: Array<{ command: string; args: readonly string[] }> = [];
const recordingSpawn = ((cmd: string, args: readonly string[]) => {
records.push({ command: cmd, args });
return mockSpawnImpl({ exitCode: 0 })(cmd, args);
}) as typeof import('node:child_process').spawn;
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: recordingSpawn,
});
expect(result.ok).toBe(true);
// For a .js cli_path, verify should spawn `node <path> --help`.
const probeRecord = records[records.length - 1];
expect(probeRecord.command).toBe(process.execPath);
expect(probeRecord.args[0]).toBe(cliPath);
expect(probeRecord.args[1]).toBe('--help');
});
it('flags missing hook script files even when settings entry is present', async () => {
const env = await bootstrap({}, false); // no hook .js files
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
const fileCheck = result.checks.find((c) => c.name.includes('readable on disk'));
expect(fileCheck?.ok).toBe(false);
});
});

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests/**", "dist/**", "node_modules/**"],
"references": [
{ "path": "../hive-mind-shim-core" }
]
}

View File

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

View File

@@ -0,0 +1,55 @@
From cf6e6c5d430bd5040088a6a65f4b4e10795a9bd1 Mon Sep 17 00:00:00 2001
From: Marko Markovic <marko.markovic@egzakta.com>
Date: Wed, 29 Apr 2026 13:28:18 +0200
Subject: [PATCH] fix: resolve Windows .cmd shims in MCP health-check
probeCommandServer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On Windows, child_process.spawn cannot find npm-installed CLI shims
(.cmd / .bat) without shell: true. This caused the PreToolUse hook to
fail with `spawn <name> ENOENT` for healthy MCP servers (hive-mind-cli,
npx-launched chrome-devtools), then quarantine them for the entire
backoff window — blocking working servers from any tool calls.
Fix: enable shell: true and windowsHide: true on win32 only.
POSIX behavior preserved (Linux/macOS handle shebangs natively).
---
scripts/hooks/mcp-health-check.js | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js
index 80a535e..17f081e 100644
--- a/scripts/hooks/mcp-health-check.js
+++ b/scripts/hooks/mcp-health-check.js
@@ -312,13 +312,21 @@ function probeCommandServer(serverName, config) {
resolve(result);
}
+ const spawnOptions = {
+ env: mergedEnv,
+ cwd: process.cwd(),
+ stdio: ['pipe', 'ignore', 'pipe']
+ };
+ if (process.platform === 'win32') {
+ // Required so .cmd/.bat shims (e.g., npm-installed CLIs like hive-mind-cli)
+ // resolve via cmd.exe instead of failing with spawn ENOENT.
+ spawnOptions.shell = true;
+ spawnOptions.windowsHide = true;
+ }
+
let child;
try {
- child = spawn(command, args, {
- env: mergedEnv,
- cwd: process.cwd(),
- stdio: ['pipe', 'ignore', 'pipe']
- });
+ child = spawn(command, args, spawnOptions);
} catch (error) {
finish({
ok: false,
--
2.51.0.windows.1

View File

@@ -0,0 +1,76 @@
# Upstream PR materials — `everything-claude-code` mcp-health-check.js Windows .cmd shim fix
This directory bundles the patch + context for the upstream PR against
[`everything-claude-code`](https://github.com/Affaan-Mustafa/everything-claude-code) (the
Claude Code plugin marketplace). The fix is currently shipped as a postinstall override
by `@waggle/hive-mind-cli` (see `packages/hive-mind-cli/postinstall.js`); once upstream
merges the PR, the override can be retired.
Per `feedback_memory_install_dead_simple` binding rule (mirror at
`D:/Projects/PM-Waggle-OS/memory-mirror/feedback_memory_install_dead_simple.md`), the
override is the **PRIMARY** path — Solo $19/mo Waggle launch does NOT wait on upstream
merge timing. The upstream PR proceeds in parallel as good-citizen contribution.
## The bug
`scripts/hooks/mcp-health-check.js::probeCommandServer` calls `child_process.spawn(command, args, { ... })`
without `shell: true`. On Windows, this means npm-installed CLI shims (`.cmd` / `.bat`
wrappers, e.g., `hive-mind-cli`) fail with `spawn <name> ENOENT` because Windows requires
`cmd.exe` to resolve them.
The hook then marks the MCP server unhealthy, quarantines it for the entire backoff
window (30s → 10min, doubling each retry), and blocks every subsequent MCP tool call
until the quarantine expires.
This is a zero-engagement failure mode for any Claude Code user with an npm-installed
MCP server CLI on Windows — `hive-mind-cli`, `npx`-launched chrome-devtools, etc.
## The fix
Set `shell: true` and `windowsHide: true` on `spawnOptions` when `process.platform === 'win32'`.
POSIX behavior is preserved (Linux + macOS handle shebangs natively without `shell: true`).
## Files in this directory
- `0001-fix-resolve-windows-cmd-shims.patch``git format-patch` output of commit
`cf6e6c5d` on the everything-claude-code marketplace clone. Apply upstream with
`git am 0001-fix-resolve-windows-cmd-shims.patch`.
## How to submit
CC does NOT push the upstream PR — Marko handles the GitHub interaction directly. To
submit:
1. Fork [`everything-claude-code`](https://github.com/Affaan-Mustafa/everything-claude-code)
on GitHub if not already forked.
2. Clone the fork locally: `git clone https://github.com/<your-fork>/everything-claude-code.git`
3. Apply the patch: `git am 0001-fix-resolve-windows-cmd-shims.patch`
4. Push: `git push origin main` (or a feature branch).
5. Open PR against `Affaan-Mustafa/everything-claude-code` with title:
```
fix(mcp-health-check): resolve Windows .cmd shims in probeCommandServer
```
6. Reference issue search before opening — check upstream issue tracker for `ENOENT` /
`Windows` / `spawn` reports and link them in the PR body.
## Verification (already done locally on Marko's machine 2026-04-29)
- `mcp__hive-mind__get_identity` returned clean RPC, no ENOENT
- `mcp__hive-mind__save_memory` → frame ID 23 persisted at 2026-04-29 11:27:42
- `mcp__hive-mind__recall_memory` → returned frame 23 with the probe content intact
- `chrome-devtools` MCP server (npx-launched) also recovered (same bug class)
## License
The patch is MIT (matches upstream `everything-claude-code` LICENSE — original by
Affaan Mustafa, modified by Marko Markovic at Egzakta Group d.o.o. per commit
`cf6e6c5d` on the marketplace clone). MIT and Apache 2.0 are bidirectionally compatible
for distribution; the @waggle/hive-mind-cli postinstall bundles the patched hook with
the upstream MIT notice preserved.
## Related
- `packages/hive-mind-cli/postinstall.js` — postinstall script that drops the override
- `packages/hive-mind-cli/assets/mcp-health-check-fixed.js` — bundled patched hook (MIT)
- `packages/hive-mind-cli/docs/WINDOWS-QUIRKS.md` — user-facing Windows install notes
- Wave 1 cleanup brief: `D:/Projects/PM-Waggle-OS/briefs/2026-04-29-wave1-hooks-cleanup-brief.md`