This commit is contained in:
19
packages/hive-mind-hooks-claude-code/LICENSE
Normal file
19
packages/hive-mind-hooks-claude-code/LICENSE
Normal file
@@ -0,0 +1,19 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright 2026 Egzakta Group d.o.o. · waggle-os.ai
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Full license text: https://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
84
packages/hive-mind-hooks-claude-code/README.md
Normal file
84
packages/hive-mind-hooks-claude-code/README.md
Normal 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).
|
||||
66
packages/hive-mind-hooks-claude-code/package.json
Normal file
66
packages/hive-mind-hooks-claude-code/package.json
Normal 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"
|
||||
}
|
||||
@@ -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();
|
||||
148
packages/hive-mind-hooks-claude-code/src/hooks/_shared.ts
Normal file
148
packages/hive-mind-hooks-claude-code/src/hooks/_shared.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
102
packages/hive-mind-hooks-claude-code/src/hooks/session-start.ts
Normal file
102
packages/hive-mind-hooks-claude-code/src/hooks/session-start.ts
Normal 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();
|
||||
}
|
||||
129
packages/hive-mind-hooks-claude-code/src/hooks/stop.ts
Normal file
129
packages/hive-mind-hooks-claude-code/src/hooks/stop.ts
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
51
packages/hive-mind-hooks-claude-code/src/index.ts
Normal file
51
packages/hive-mind-hooks-claude-code/src/index.ts
Normal 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';
|
||||
154
packages/hive-mind-hooks-claude-code/src/install.ts
Normal file
154
packages/hive-mind-hooks-claude-code/src/install.ts
Normal 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;
|
||||
}
|
||||
93
packages/hive-mind-hooks-claude-code/src/paths.ts
Normal file
93
packages/hive-mind-hooks-claude-code/src/paths.ts
Normal 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}`;
|
||||
}
|
||||
130
packages/hive-mind-hooks-claude-code/src/settings-merger.ts
Normal file
130
packages/hive-mind-hooks-claude-code/src/settings-merger.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
98
packages/hive-mind-hooks-claude-code/src/uninstall.ts
Normal file
98
packages/hive-mind-hooks-claude-code/src/uninstall.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
175
packages/hive-mind-hooks-claude-code/src/verify.ts
Normal file
175
packages/hive-mind-hooks-claude-code/src/verify.ts
Normal 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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
262
packages/hive-mind-hooks-claude-code/tests/hooks/stop.test.ts
Normal file
262
packages/hive-mind-hooks-claude-code/tests/hooks/stop.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
150
packages/hive-mind-hooks-claude-code/tests/install.test.ts
Normal file
150
packages/hive-mind-hooks-claude-code/tests/install.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
73
packages/hive-mind-hooks-claude-code/tests/paths.test.ts
Normal file
73
packages/hive-mind-hooks-claude-code/tests/paths.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
100
packages/hive-mind-hooks-claude-code/tests/uninstall.test.ts
Normal file
100
packages/hive-mind-hooks-claude-code/tests/uninstall.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
145
packages/hive-mind-hooks-claude-code/tests/verify.test.ts
Normal file
145
packages/hive-mind-hooks-claude-code/tests/verify.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
packages/hive-mind-hooks-claude-code/tsconfig.json
Normal file
14
packages/hive-mind-hooks-claude-code/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests/**", "dist/**", "node_modules/**"],
|
||||
"references": [
|
||||
{ "path": "../hive-mind-shim-core" }
|
||||
]
|
||||
}
|
||||
12
packages/hive-mind-hooks-claude-code/tsconfig.test.json
Normal file
12
packages/hive-mind-hooks-claude-code/tsconfig.test.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist/**", "node_modules/**"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
76
packages/hive-mind-hooks-claude-code/upstream-pr/README.md
Normal file
76
packages/hive-mind-hooks-claude-code/upstream-pr/README.md
Normal 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`
|
||||
Reference in New Issue
Block a user