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

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

View File

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

View File

@@ -0,0 +1,59 @@
# @waggle/hive-mind-hooks-codex
Silent-capture shim that wires **OpenAI Codex** lifecycle hooks into
[hive-mind](https://github.com/marolinik/hive-mind) frames. Every Codex
session deterministically captures SessionStart / UserPromptSubmit / Stop /
PreCompact events into your personal memory via `hive-mind-cli` — the same
every-turn pattern proven by `@waggle/hive-mind-hooks-claude-code`, built on
the shared `@waggle/hive-mind-hooks-core` foundation.
This is the **second reference shape** for the hook portfolio: a CC-clone JSON
installer with **create-if-missing** semantics (Codex's `~/.codex/hooks.json`
is optional, unlike CC's `settings.json`).
## Install
```bash
npx @waggle/hive-mind-hooks-codex install
# Windows / production: pin the CLI path
npx @waggle/hive-mind-hooks-codex install --cli-path "C:\\path\\to\\hive-mind-cli\\dist\\index.js"
```
The installer additively merges four hook groups into `~/.codex/hooks.json`
(creating the file if absent), preserving any existing hooks you have, and
writes a pointer + byte-identical backup so uninstall is exact.
> **One-time trust step.** Non-managed Codex hooks require a one-time `/hooks`
> trust before they execute. After installing, run `/hooks` in Codex once to
> trust the hive-mind hooks.
```bash
npx @waggle/hive-mind-hooks-codex verify # smoke-check + surface admin lockdown
npx @waggle/hive-mind-hooks-codex uninstall # byte-identical restore (or remove if we created it)
```
`verify` also surfaces the admin lockdown `allow_managed_hooks_only = true`
(in `~/.codex/requirements.toml`), which suppresses user hooks so install would
silently no-op.
## Capture fidelity
| Lifecycle | Codex event | Status | Notes |
|---|---|---|---|
| SessionStart (recall + inject) | `SessionStart` | full | matcher `startup\|resume\|clear\|compact`; injects recalled frames as additional context |
| UserPromptSubmit (save temporary) | `UserPromptSubmit` | full | persists the prompt as a temporary frame |
| Stop (summarize + save) | `Stop` | full | reads `last_assistant_message` in the response fallback; saves an important/critical frame |
| PreCompact (compact memory) | `PreCompact` | full | carries `trigger` (`manual\|auto`); runs `cleanup_frames` before truncation |
**Full parity** with claude-code — no degraded or absent events. The only
non-silent step is the one-time `/hooks` trust step above (an install-UX step,
not a degraded event).
## How it works
Each hook is a short-lived Node subprocess. Codex pipes the event JSON to the
hook's stdin; the hook shells to `hive-mind-cli` to recall or save frames, then
exits 0. **Fail-open:** if `hive-mind-cli` is unreachable or the payload is
malformed, the hook logs to stderr and exits 0 — Codex is never blocked.
License: Apache-2.0.

View File

@@ -0,0 +1,66 @@
{
"name": "@waggle/hive-mind-hooks-codex",
"version": "0.1.0",
"description": "OpenAI Codex 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, create-if-missing install — additive merge into ~/.codex/hooks.json with byte-identical uninstall. Second reference shape (CC-clone) built on @waggle/hive-mind-hooks-core.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"codex-hooks": "dist/bin/codex-hooks.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-codex/tests",
"test:watch": "vitest"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@waggle/hive-mind-hooks-core": "*",
"@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-codex"
},
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-hooks-codex#readme",
"bugs": {
"url": "https://github.com/marolinik/waggle-os/issues"
},
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
"keywords": [
"codex",
"openai",
"hive-mind",
"memory",
"ai",
"hook",
"silent-capture"
],
"types": "dist/index.d.ts"
}

View File

@@ -0,0 +1,132 @@
/**
* Codex EventAdapter + JsonRegisterSpec.
*
* Codex's hook event surface is effectively a clone of Claude Code's
* (`SessionStart` / `UserPromptSubmit` / `Stop` / `PreCompact`), so the
* adapter reuses CC's snake_case field keys (`prompt`, `session_id`,
* `cwd`, `response`, …) and adds codex-specific fallbacks:
* - Stop reads `last_assistant_message` in the response fallback list.
* - PreCompact carries `trigger` (`manual|auto`) — surfaced as the scope.
*
* Field-name casing is confirmed from docs, not a live payload, so codex
* additions are FALLBACKS layered after the CC keys rather than replacing
* them (spec §5.1 blocker note).
*
* The register shape uses codex's `{ matcher, hooks: [...] }` group with a
* `'startup|resume|clear|compact'` matcher for SessionStart; the marker is
* carried for byte-identical reversible uninstall, not for correctness.
*/
import {
pickStringField,
HIVE_MIND_MARKER_BASE,
type EventAdapter,
type JsonRegisterSpec,
type Lifecycle,
} from '@waggle/hive-mind-hooks-core';
/** Per-tool marker stamped on every group we add — used by uninstall/verify. */
export const HIVE_MIND_MARKER = `${HIVE_MIND_MARKER_BASE}/codex-hooks`;
/** SessionStart matcher — fires on Codex session lifecycle transitions. */
export const SESSION_START_MATCHER = 'startup|resume|clear|compact';
/** Canonical lifecycle → Codex native event key (CC clone). */
export const CODEX_EVENT_NAME: Record<Lifecycle, string | undefined> = {
'session-start': 'SessionStart',
'user-prompt-submit': 'UserPromptSubmit',
'stop': 'Stop',
'pre-compact': 'PreCompact',
};
function asObject(payload: unknown): Record<string, unknown> | undefined {
return payload && typeof payload === 'object'
? (payload as Record<string, unknown>)
: undefined;
}
/** The Codex EventAdapter consumed by the shared lifecycle handler bodies. */
export const codexAdapter: EventAdapter = {
source: 'codex',
eventName: CODEX_EVENT_NAME,
extractCwd(payload): string | undefined {
return pickStringField(payload, 'cwd');
},
extractSessionId(payload): string | undefined {
return pickStringField(payload, 'session_id', 'sessionId');
},
extractPrompt(payload): string | undefined {
return pickStringField(payload, 'prompt', 'user_message');
},
extractResponse(payload): string | undefined {
// CC fallback list + codex addition `last_assistant_message`.
return pickStringField(
payload,
'response',
'assistant_message',
'last_assistant_message',
'transcript',
);
},
extractParent(payload): string | undefined {
return pickStringField(payload, 'parent_frame_id', 'prompt_frame_id');
},
// formatInject omitted ⇒ the shared SessionStart body uses CC's default
// hookSpecificOutput shape (codex honors the CC inject convention).
};
/**
* PreCompact `trigger` (`manual|auto`) accessor — codex surfaces the
* compaction trigger here. Exposed so verify/diagnostics can read it; the
* shared PreCompact body keys off the session scope, not the trigger.
*/
export function extractTrigger(payload: unknown): string | undefined {
return pickStringField(payload, 'trigger');
}
// ── JsonRegisterSpec (codex `{ matcher, hooks: [...] }` group shape) ────
interface CodexGroup {
matcher?: string;
hooks: Array<{ type: 'command'; command: string; timeout?: number }>;
_hiveMindShim?: string;
}
/**
* The Codex register spec for `jsonRegister`/`jsonUnregister`. Groups live
* under the top-level `hooks` key; SessionStart carries the lifecycle
* matcher, the other events omit it.
*/
export const codexRegisterSpec: JsonRegisterSpec = {
hooksKey: 'hooks',
eventName: CODEX_EVENT_NAME,
buildGroup(lifecycle: Lifecycle, command: string, timeout: number): Record<string, unknown> {
const group: CodexGroup = {
hooks: [{ type: 'command', command, timeout }],
_hiveMindShim: HIVE_MIND_MARKER,
};
if (lifecycle === 'session-start') group.matcher = SESSION_START_MATCHER;
return group as unknown as Record<string, unknown>;
},
isHiveGroup(group: unknown): boolean {
const g = asObject(group);
return !!g && g['_hiveMindShim'] === HIVE_MIND_MARKER;
},
groupCommand(group: unknown): string | undefined {
const g = asObject(group);
if (!g) return undefined;
const hooks = g['hooks'];
if (!Array.isArray(hooks) || hooks.length === 0) return undefined;
const first = asObject(hooks[0]);
const cmd = first?.['command'];
return typeof cmd === 'string' ? cmd : undefined;
},
};

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* `codex-hooks` — CLI entry for the @waggle/hive-mind-hooks-codex shim.
*
* codex-hooks install Patch ~/.codex/hooks.json (additive, create-if-missing).
* codex-hooks uninstall Restore the byte-identical pre-install state (or remove
* the hooks.json we created).
* codex-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: codex-hooks <command> [options]',
'',
'Commands:',
' install Patch ~/.codex/hooks.json (additive, create-if-missing, with backup).',
' uninstall Restore the byte-identical pre-install hooks.json (or remove it if created).',
' 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/waggle-os',
'',
].join('\n'));
}
function printInstallSummary(result: InstallResult): void {
const lines: string[] = [
'hive-mind/codex-hooks: install',
` - hooks.json: ${result.paths.configPath}`,
` - backup: ${result.backupPath ?? '(none — hooks.json created by us)'}`,
` - pointer: ${result.pointerPath}`,
` - added hooks: ${result.installedHooks.join(', ')}`,
` - cli path: ${result.cliPath ?? '(default — hive-mind-cli on PATH)'}`,
'',
'One-time trust step required:',
' Run `/hooks` in Codex once to trust the hive-mind hooks before they execute.',
'',
'Done. New Codex sessions will silently capture to hive-mind.',
'Run "codex-hooks verify" to inspect, "codex-hooks uninstall" to revert.',
'',
];
process.stdout.write(lines.join('\n'));
}
function printUninstallSummary(result: UninstallResult): void {
const lines: string[] = [
'hive-mind/codex-hooks: uninstall',
` - hooks.json: ${result.paths.configPath}`,
` - restored from: ${result.restoredFrom ?? '(none — removed file we created)'}`,
` - created removed: ${result.createdRemoved ? 'yes' : 'no'}`,
` - backup removed: ${result.backupRemoved ? 'yes' : 'no (kept on disk)'}`,
` - pointer removed: ${result.pointerRemoved ? 'yes' : 'no'}`,
'',
'Done. hooks.json is byte-identical to pre-install state (or removed if we created it).',
'',
];
process.stdout.write(lines.join('\n'));
}
function printVerifySummary(result: VerifyResult): void {
const lines: string[] = ['hive-mind/codex-hooks: verify'];
for (const c of result.checks) {
const tag = c.ok ? 'PASS' : 'FAIL';
const detail = c.detail ? `${c.detail}` : '';
lines.push(` [${tag}] ${c.name}${detail}`);
}
lines.push('');
lines.push(result.ok ? 'All checks passed.' : 'One or more checks failed.');
lines.push('');
process.stdout.write(lines.join('\n'));
}
async function main(): Promise<void> {
const { command, flags } = parseArgs(process.argv.slice(2));
if (command === 'help') {
printHelp();
return;
}
const hooksDir = typeof flags['hooks-dir'] === 'string' ? flags['hooks-dir'] : undefined;
const hookTimeoutRaw = flags['hook-timeout'];
const hookTimeoutSeconds = typeof hookTimeoutRaw === 'string'
? Number.parseInt(hookTimeoutRaw, 10) || undefined
: undefined;
const cliPath = typeof flags['cli-path'] === 'string' ? flags['cli-path'] : undefined;
const baseOpts = {
moduleUrl: import.meta.url,
...(hooksDir ? { hooksDir } : {}),
};
try {
if (command === 'install') {
const installOpts = {
...baseOpts,
...(hookTimeoutSeconds !== undefined ? { hookTimeoutSeconds } : {}),
...(cliPath !== undefined ? { cliPath } : {}),
};
const result = await install(installOpts);
printInstallSummary(result);
return;
}
if (command === 'uninstall') {
const result = await uninstall(baseOpts);
printUninstallSummary(result);
return;
}
if (command === 'verify') {
const result = await verify(baseOpts);
printVerifySummary(result);
if (!result.ok) process.exit(1);
return;
}
} catch (err) {
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
}
void main();

View File

@@ -0,0 +1,34 @@
/**
* Codex PreCompact hook — fired just before Codex truncates context.
* Triggers `cleanup_frames` so superseded P/B frames merge before the
* native compaction step. Thin entrypoint over the shared handler body.
* Codex carries the compaction `trigger` (`manual|auto`) on the payload.
*/
import {
makePreCompactHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { codexAdapter } from '../adapter.js';
export async function runPreCompact(opts: Partial<HookRunOptions> = {}): Promise<void> {
return runHook(makePreCompactHandler(codexAdapter), {
name: 'pre-compact',
loggerPrefix: 'codex-hooks',
...opts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runPreCompact();
}

View File

@@ -0,0 +1,37 @@
/**
* Codex SessionStart hook — recalls the top-N most relevant frames from
* personal memory and injects them as additional context for the new
* Codex session. Thin entrypoint over the shared handler body.
*
* If hive-mind-cli is unreachable, the hook logs and exits 0 with no
* output (fail-open) — the session starts as it would have without the
* shim.
*/
import {
makeSessionStartHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { codexAdapter } from '../adapter.js';
export async function runSessionStart(opts: Partial<HookRunOptions> = {}): Promise<void> {
return runHook(makeSessionStartHandler(codexAdapter), {
name: 'session-start',
loggerPrefix: 'codex-hooks',
...opts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runSessionStart();
}

View File

@@ -0,0 +1,35 @@
/**
* Codex 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. Thin
* entrypoint over the shared handler body; the adapter reads codex's
* `last_assistant_message` in the response fallback list.
*/
import {
makeStopHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { codexAdapter } from '../adapter.js';
export async function runStop(opts: Partial<HookRunOptions> = {}): Promise<void> {
return runHook(makeStopHandler(codexAdapter), {
name: 'stop',
loggerPrefix: 'codex-hooks',
...opts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runStop();
}

View File

@@ -0,0 +1,33 @@
/**
* Codex UserPromptSubmit hook — captures the user prompt as a temporary
* frame scoped to the current Codex session. Thin entrypoint over the
* shared handler body. No stdout output: pure side-effect on the .mind file.
*/
import {
makeUserPromptSubmitHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { codexAdapter } from '../adapter.js';
export async function runUserPromptSubmit(opts: Partial<HookRunOptions> = {}): Promise<void> {
return runHook(makeUserPromptSubmitHandler(codexAdapter), {
name: 'user-prompt-submit',
loggerPrefix: 'codex-hooks',
...opts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runUserPromptSubmit();
}

View File

@@ -0,0 +1,49 @@
/**
* @waggle/hive-mind-hooks-codex — barrel export.
*
* OpenAI Codex silent-capture shim for hive-mind. The second reference
* shape: a CC-clone JSON installer built on @waggle/hive-mind-hooks-core,
* with create-if-missing semantics (codex's `~/.codex/hooks.json` is
* optional). Programmatic install / uninstall / verify lifecycle; most
* users invoke the `codex-hooks` bin.
*/
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 {
CodexPaths,
ResolvePathsOptions,
HookBasename,
} from './paths.js';
export {
resolvePaths,
allHookBasenames,
backupPathFor,
hookCommandFor,
} from './paths.js';
export {
codexAdapter,
codexRegisterSpec,
extractTrigger,
CODEX_EVENT_NAME,
HIVE_MIND_MARKER,
SESSION_START_MATCHER,
} from './adapter.js';

View File

@@ -0,0 +1,161 @@
/**
* Programmatic install entry point for the Codex hive-mind hooks.
*
* Steps:
* 1. Read existing `~/.codex/hooks.json` IF it exists (create-if-missing
* — codex's hooks.json is OPTIONAL, unlike CC's settings.json which
* must pre-exist).
* 2. If it pre-existed, write a byte-identical backup; if absent, skip
* the backup and record `created_by_us=true`.
* 3. Additively merge the four hive-mind hook groups into the `hooks`
* object (existing entries preserved verbatim) via `jsonRegister`.
* 4. Write the merged JSON back over `hooks.json`.
* 5. Drop a pointer file at `~/.codex/hive-mind-install.json` so a future
* `uninstall` knows whether to restore the backup or delete the file
* we created.
*
* Round-trip guarantee (pre-existed case): the pre-install hooks.json
* content equals the byte-identical backup; `uninstall` restores it.
* Created case: `uninstall` deletes the file we created (no orphan).
*/
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 {
backupByteIdentical,
hookCommandFor,
hookScriptPath,
jsonRegister,
normalizeCliPath,
writePointer,
type InstallPointer,
type JsonRegisterEntry,
type Lifecycle,
} from '@waggle/hive-mind-hooks-core';
import { resolvePaths, allHookBasenames, type CodexPaths, type ResolvePathsOptions } from './paths.js';
import { codexRegisterSpec } from './adapter.js';
export interface InstallResult {
paths: CodexPaths;
/** The byte-identical backup written when hooks.json pre-existed, else null. */
backupPath: string | null;
pointerPath: string;
installedHooks: readonly string[];
/** True when hooks.json did NOT pre-exist and we created it. */
createdByUs: 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). Threaded into every hook command as
* `--cli-path "<path>"`.
*/
cliPath?: string;
}
const DEFAULT_HOOK_TIMEOUT_S = 5;
const POINTER_VERSION = '0.1.0';
/** Canonical lifecycle for each hook basename (basenames mirror lifecycle ids). */
const LIFECYCLE_BY_BASENAME: Record<string, Lifecycle> = {
'session-start': 'session-start',
'user-prompt-submit': 'user-prompt-submit',
'stop': 'stop',
'pre-compact': 'pre-compact',
};
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: 'codex-hooks/install' });
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', { config: paths.configPath, hooksDir: paths.hooksDir });
// Read the existing config if present; create-if-missing otherwise.
let existingConfig: Record<string, unknown> | undefined;
const preExisted = existsSync(paths.configPath);
if (preExisted) {
const originalContent = await readFile(paths.configPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(originalContent);
existingConfig = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch (err) {
throw new Error(
`failed to parse existing ${paths.configPath} as JSON: ` +
(err instanceof Error ? err.message : String(err)),
);
}
}
await ensureDir(dirname(paths.pointerPath));
// Byte-identical backup of the original config (no-op when absent).
const { backupPath, preExisted: backedUp } = await backupByteIdentical(
paths.configPath,
now().toISOString(),
);
if (backedUp) log.info('hooks.json backed up', { backupPath });
const cliPath = normalizeCliPath(opts.cliPath);
const timeout = opts.hookTimeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_S;
const basenames = allHookBasenames();
const entries: JsonRegisterEntry[] = basenames.map((basename) => ({
lifecycle: LIFECYCLE_BY_BASENAME[basename],
command: hookCommandFor(hookScriptPath(paths.hooksDir, basename), cliPath),
timeout,
}));
const merged = jsonRegister(existingConfig, entries, codexRegisterSpec);
const mergedJson = JSON.stringify(merged, null, 2) + '\n';
await writeFile(paths.configPath, mergedJson, 'utf-8');
const createdByUs = !preExisted;
const pointer: InstallPointer = {
version: POINTER_VERSION,
installed_at: now().toISOString(),
config_path: paths.configPath,
settings_backup: backupPath,
created_by_us: createdByUs,
hooks_dir: paths.hooksDir,
installed_hooks: basenames,
cli_path: cliPath ?? null,
};
await writePointer(paths.pointerPath, pointer);
log.info('install complete', {
added: entries.length,
createdByUs,
cliPath: cliPath ?? '(PATH lookup)',
});
const result: InstallResult = {
paths,
backupPath,
pointerPath: paths.pointerPath,
installedHooks: basenames,
createdByUs,
};
if (cliPath !== undefined) result.cliPath = cliPath;
return result;
}

View File

@@ -0,0 +1,73 @@
/**
* Filesystem path helpers for the Codex hive-mind hook install lifecycle.
*
* Mirrors the frozen Wave 1 claude-code `paths.ts` shape, but targets
* Codex's standalone `~/.codex/hooks.json` (NOT `~/.codex/config.toml` —
* we stay out of the user's TOML and away from protected
* `notify`/`profile`/`model_providers` keys). The Windows-safe backup
* path + `--cli-path` quoting + hooks-dir resolution are reused verbatim
* from `@waggle/hive-mind-hooks-core` so codex reads like the reference.
*/
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import {
backupPathFor,
hookCommandFor,
hooksDirFromModuleUrl,
} from '@waggle/hive-mind-hooks-core';
export interface CodexPaths {
/** Codex config root (`~/.codex/`). */
codexDir: string;
/** `~/.codex/hooks.json` — the standalone hooks config (NOT config.toml). */
configPath: string;
/** `~/.codex/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;
}
export function resolvePaths(opts: ResolvePathsOptions = {}): CodexPaths {
const home = opts.home ?? homedir();
const codexDir = join(home, '.codex');
const configPath = join(codexDir, 'hooks.json');
const pointerPath = join(codexDir, 'hive-mind-install.json');
let hooksDir: string;
if (opts.hooksDir) {
hooksDir = resolve(opts.hooksDir);
} else if (opts.moduleUrl) {
hooksDir = hooksDirFromModuleUrl(opts.moduleUrl);
} else {
// Fallback for ad-hoc test use — install.ts always passes moduleUrl.
hooksDir = resolve(process.cwd(), 'dist', 'hooks');
}
return { codexDir, configPath, pointerPath, hooksDir };
}
/** Re-export the shared Windows-safe helpers so codex modules read like CC. */
export { backupPathFor, hookCommandFor };

View File

@@ -0,0 +1,76 @@
/**
* Programmatic uninstall entry point for the Codex hive-mind hooks.
*
* Round-trip guarantee:
* - `created_by_us=false` (hooks.json pre-existed): restore the
* byte-identical backup the installer wrote; refuse to delete the
* backup unless the in-place readback matches.
* - `created_by_us=true` (we created hooks.json): delete the file we
* created — never orphan it, never leave a backup behind.
*
* Both branches are handled by the shared `restoreFromBackup` primitive.
*/
import { unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import {
readPointer,
restoreFromBackup,
} from '@waggle/hive-mind-hooks-core';
import { resolvePaths, type CodexPaths, type ResolvePathsOptions } from './paths.js';
export interface UninstallResult {
paths: CodexPaths;
/** Backup restored from, or null when we deleted a file we created. */
restoredFrom: string | null;
/** True when created_by_us=true and we removed the config we created. */
createdRemoved: boolean;
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;
}
export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
const log = opts.logger ?? createLogger({ name: 'codex-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 @waggle/hive-mind-hooks-codex ever installed for this user?`,
);
}
const pointer = await readPointer(paths.pointerPath);
const restore = await restoreFromBackup({
configPath: paths.configPath,
pointer,
cleanupBackup: cleanup,
});
if (restore.createdRemoved) {
log.info('codex hooks.json removed (created by us)', { config: paths.configPath });
} else {
log.info('codex hooks.json restored byte-identical', { config: paths.configPath });
}
await unlink(paths.pointerPath);
return {
paths,
restoredFrom: restore.restoredFrom,
createdRemoved: restore.createdRemoved,
pointerRemoved: true,
backupRemoved: restore.backupRemoved,
};
}

View File

@@ -0,0 +1,181 @@
/**
* Smoke-check the Codex install: hooks.json exists + parses + references
* live hook scripts, and hive-mind-cli answers a `--help` probe. Plus two
* codex-specific surfacings (spec §5.1 / §6.2):
* - the one-time `/hooks` trust step is required for non-managed Codex
* hooks to execute (surfaced as an advisory check, not a hard fail);
* - admin lockdown `allow_managed_hooks_only = true` in
* `~/.codex/requirements.toml` SUPPRESSES user hooks, so install would
* silently no-op — surfaced as a failing check.
*
* Probe priority for `cli_path`:
* 1. Explicit `opts.cliPath` (caller override)
* 2. `cli_path` recorded in `~/.codex/hive-mind-install.json`
* 3. Bare `'hive-mind-cli'` on PATH
*/
import { readFile, access } from 'node:fs/promises';
import { constants, existsSync } from 'node:fs';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import { hasHiveEntries } from '@waggle/hive-mind-hooks-core';
import { resolvePaths, allHookBasenames, type ResolvePathsOptions } from './paths.js';
import { codexRegisterSpec } from './adapter.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 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;
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: 'codex-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. hooks.json exists and parses.
if (!existsSync(paths.configPath)) {
checks.push({ name: 'hooks.json exists', ok: false, detail: paths.configPath });
return { ok: false, checks };
}
checks.push({ name: 'hooks.json exists', ok: true, detail: paths.configPath });
let parsed: Record<string, unknown>;
try {
const raw: unknown = JSON.parse(await readFile(paths.configPath, 'utf-8'));
parsed = raw && typeof raw === 'object' && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
checks.push({ name: 'hooks.json parses as JSON', ok: true });
} catch (err) {
checks.push({
name: 'hooks.json parses as JSON',
ok: false,
detail: err instanceof Error ? err.message : String(err),
});
return { ok: false, checks };
}
// 2. hive-mind entries present.
checks.push({
name: 'hooks.json contains hive-mind entries',
ok: hasHiveEntries(parsed, codexRegisterSpec),
});
// 3. each hive hook entry points at an existing dist file.
for (const basename of allHookBasenames()) {
const scriptPath = join(paths.hooksDir, `${basename}.js`);
const ok = await fileReadable(scriptPath);
checks.push({ name: `${basename}.js readable on disk`, ok, detail: scriptPath });
}
// 4. admin lockdown: allow_managed_hooks_only suppresses user hooks.
const requirementsPath = join(paths.codexDir, 'requirements.toml');
if (existsSync(requirementsPath)) {
try {
const toml = await readFile(requirementsPath, 'utf-8');
const locked = /allow_managed_hooks_only\s*=\s*true/.test(toml);
checks.push({
name: 'allow_managed_hooks_only lockdown',
ok: !locked,
detail: locked
? 'requirements.toml sets allow_managed_hooks_only = true — user hooks are suppressed; install will silently no-op.'
: 'not locked',
});
} catch { /* unreadable — skip */ }
}
// 5. /hooks trust step advisory (informational — non-managed Codex hooks
// require a one-time `/hooks` trust before they execute).
checks.push({
name: '/hooks trust step (run once in Codex)',
ok: true,
detail: 'Run `/hooks` in Codex once to trust the hive-mind hooks.',
});
// 6. hive-mind-cli responds to --help (prefer install-pinned --cli-path).
let cliPathFromPointer: string | undefined;
if (existsSync(paths.pointerPath)) {
try {
const pointerObj = JSON.parse(await readFile(paths.pointerPath, 'utf-8')) as Record<string, unknown>;
const pointerCliPath = pointerObj['cli_path'];
if (typeof pointerCliPath === 'string' && pointerCliPath.length > 0) {
cliPathFromPointer = pointerCliPath;
}
} catch { /* pointer unreadable — fall through */ }
}
const cliPath = opts.cliPath ?? cliPathFromPointer ?? 'hive-mind-cli';
const spawnImpl = opts.spawnImpl ?? spawn;
const probe = await probeCliVersion(cliPath, spawnImpl, 4000);
checks.push({
name: 'hive-mind-cli reachable',
ok: probe.ok,
detail: cliPathFromPointer ? `${probe.output} (pinned: ${cliPath})` : probe.output,
});
const ok = checks.every((c) => c.ok);
log.info('verify complete', { ok, total: checks.length, failed: checks.filter((c) => !c.ok).length });
return { ok, checks };
}

View File

@@ -0,0 +1,62 @@
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>;
}
/**
* Mirrors the frozen CC reference test helper (tests/hooks/_test-helpers.ts).
* The codex hooks drive the SAME shared `runHook` contract, so the same
* injectable CliBridge mock + stdout/exit captures apply unchanged.
*/
export function makeMockBridge(overrides: MockBridgeOverrides = {}): MockBridge {
let activeWorkspaceId: string | undefined;
const saveMemory = overrides.saveMemoryThrows
? vi.fn(async () => { throw overrides.saveMemoryThrows; })
: vi.fn(async () => overrides.saveMemoryResult ?? { id: 'frame-1', success: true, workspace: 'personal' });
const recallMemory = vi.fn(async () => overrides.recallMemoryHits ?? []);
const cleanupFrames = vi.fn(async () => overrides.cleanupFramesResult ?? { pruned: 0 });
const callMcpTool = vi.fn(async () => ({}));
const setWorkspaceById = vi.fn((id?: string) => { activeWorkspaceId = id; });
const getActiveWorkspaceId = vi.fn(() => activeWorkspaceId);
return {
saveMemory,
recallMemory,
cleanupFrames,
callMcpTool,
setWorkspaceById,
getActiveWorkspaceId,
} as unknown as MockBridge;
}
export interface CapturedHookOutput {
stdout: string[];
exits: number[];
}
export function makeHookCaptures(): CapturedHookOutput & {
writeStdout: (s: string) => void;
exit: (code: number) => void;
} {
const stdout: string[] = [];
const exits: number[] = [];
return {
stdout,
exits,
writeStdout: (s) => stdout.push(s),
exit: (c) => exits.push(c),
};
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { runPreCompact } from '../../src/hooks/pre-compact.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
describe('codex pre-compact handler', () => {
it('calls cleanupFrames to merge superseded frames before host compaction', async () => {
const bridge = makeMockBridge({ cleanupFramesResult: { pruned: 4 } });
const cap = makeHookCaptures();
await runPreCompact({
readStdin: async () => JSON.stringify({ session_id: 'sess-3', trigger: 'auto' }),
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/session present', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runPreCompact({
readStdin: async () => JSON.stringify({ trigger: 'manual' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.cleanupFrames).toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: exits 0 when cleanupFrames rejects', async () => {
const bridge = makeMockBridge();
bridge.cleanupFrames.mockRejectedValueOnce(new Error('cli unreachable'));
const cap = makeHookCaptures();
await runPreCompact({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { 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:codex event:stop] past observation',
importance: 'important',
source: 'system',
score: 0.87,
created_at: '2026-04-28T10:00:00.000Z',
from: 'personal',
};
describe('codex session-start handler', () => {
it('recalls personal-scoped frames and injects them as additionalContext', 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,
});
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 1, scope: 'personal', workspace: null });
expect(cap.stdout).toHaveLength(1);
const parsed = JSON.parse(cap.stdout[0]) as {
hookSpecificOutput: { source: string; additionalContext: string };
};
// Codex has no custom formatInject ⇒ the default CC hookSpecificOutput shape,
// stamped with source 'codex'.
expect(parsed.hookSpecificOutput.source).toBe('codex');
expect(parsed.hookSpecificOutput.additionalContext).toContain('past observation');
expect(cap.exits).toEqual([0]);
});
it('handles an 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('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');
});
it('FAIL-OPEN: exits 0 even when the bridge throws', 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]);
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it, afterEach, vi } from 'vitest';
import { runStop } from '../../src/hooks/stop.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
import type { HookFrame } from '@waggle/hive-mind-shim-core';
describe('codex stop handler', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('reads the codex last_assistant_message and saves an important frame', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
last_assistant_message: 'Here is the answer to your question about X.',
cwd: '/proj/foo',
session_id: 'sess-9',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.source).toBe('codex');
expect(frame.scope).toBe('sess-9');
expect(['important', 'critical']).toContain(frame.importance);
expect(frame.content.length).toBeGreaterThan(0);
expect(cap.exits).toEqual([0]);
});
it('links the Stop frame to its parent prompt frame when known', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
last_assistant_message: 'done',
parent_frame_id: 'frame-prompt-1',
session_id: 's',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.parent).toBe('frame-prompt-1');
});
it('still honours the CC response fallback keys (response / assistant_message)', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ response: 'classic CC key', session_id: 's' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.content.length).toBeGreaterThan(0);
});
it('skips the save when no response is present', 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]);
});
it('does NOT emit a discovery signal by default (WAGGLE_SIGNAL_EMIT off)', async () => {
vi.stubEnv('WAGGLE_SIGNAL_EMIT', '');
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ last_assistant_message: 'hi', session_id: 's' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
// callMcpTool is how the bridge would reach the sidecar; no emit by default.
expect(bridge.callMcpTool).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: exits 0 even when saveMemory rejects', async () => {
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli unreachable') });
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ last_assistant_message: 'x', session_id: 's' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { runUserPromptSubmit } from '../../src/hooks/user-prompt-submit.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
import type { HookFrame } from '@waggle/hive-mind-shim-core';
describe('codex user-prompt-submit handler', () => {
it('saves a temporary, codex-sourced 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 frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame).toMatchObject({
content: 'How do I X?',
importance: 'temporary',
scope: 'sess-7',
source: 'codex',
});
expect(cap.exits).toEqual([0]);
});
it('reads the codex user_message fallback when prompt is absent', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ user_message: 'hello from codex', session_id: 's1' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.content).toBe('hello from codex');
expect(frame.source).toBe('codex');
});
it('skips the 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('FAIL-OPEN: exits 0 even if saveMemory rejects', async () => {
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli down') });
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ prompt: 'x' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,184 @@
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 { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { install } from '../src/install.js';
import { HIVE_MIND_MARKER } from '../src/adapter.js';
const execFileAsync = promisify(execFile);
// This file's last test spawns the COMPILED CLI (dist/bin/codex-hooks.js).
// dist/ is gitignored and a clean CI checkout runs no build step, so the
// artifact is absent there — skip (don't fail) when it's missing. The other
// tests import from ../src and need no build.
const BIN_PATH = resolve(
fileURLToPath(new URL('../dist/bin/codex-hooks.js', import.meta.url)),
);
const BIN_BUILT = existsSync(BIN_PATH);
interface TestEnv {
home: string;
hooksDir: string;
configPath: string;
pointerPath: string;
}
/** Codex hooks.json is OPTIONAL — `withConfig=false` exercises create-if-missing. */
async function bootstrap(
initial: Record<string, unknown> | undefined,
): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmcdx-install-'));
const codexDir = join(home, '.codex');
await mkdir(codexDir, { recursive: true });
const configPath = join(codexDir, 'hooks.json');
if (initial !== undefined) {
await writeFile(configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
}
const hooksDir = resolve(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
return { home, hooksDir, configPath, pointerPath: join(codexDir, 'hive-mind-install.json') };
}
describe('install (codex)', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws on malformed JSON in an existing hooks.json', async () => {
env = await bootstrap({});
await writeFile(env.configPath, '{ not valid json', 'utf-8');
await expect(install({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/parse/);
});
// ── pre-existed branch ────────────────────────────────────────────────
it('writes a byte-identical backup before mutating a pre-existing hooks.json', async () => {
env = await bootstrap({ hooks: {} });
const original = await readFile(env.configPath, 'utf-8');
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.backupPath).not.toBeNull();
const backupContent = await readFile(result.backupPath as string, 'utf-8');
expect(backupContent).toBe(original);
});
it('records created_by_us=false when hooks.json pre-existed', async () => {
env = await bootstrap({ hooks: {} });
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.createdByUs).toBe(false);
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['created_by_us']).toBe(false);
expect(pointer['settings_backup']).toBe(result.backupPath);
});
it('appends 4 hive groups and preserves the existing structure', async () => {
const initial = {
hooks: {
SessionStart: [
{ matcher: 'startup', 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.configPath, 'utf-8')) as {
hooks: Record<string, Array<{ _hiveMindShim?: string; hooks: Array<{ command: string }> }>>;
};
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);
});
// ── create-if-missing branch ──────────────────────────────────────────
it('creates a skeleton {hooks:{...}} when hooks.json is absent', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(env.configPath)).toBe(true);
const after = JSON.parse(await readFile(env.configPath, 'utf-8')) as { hooks: Record<string, unknown> };
expect(after.hooks).toBeDefined();
expect(Object.keys(after.hooks).sort()).toEqual(['PreCompact', 'SessionStart', 'Stop', 'UserPromptSubmit']);
expect(result.createdByUs).toBe(true);
});
it('records created_by_us=true and writes NO backup when hooks.json is absent', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.backupPath).toBeNull();
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['created_by_us']).toBe(true);
expect(pointer['settings_backup']).toBeNull();
});
// ── pointer + cli-path ───────────────────────────────────────────────
it('drops a pointer file with installed_hooks + version', async () => {
env = await bootstrap(undefined);
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['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
expect(typeof pointer['version']).toBe('string');
});
it('respects a custom now() for a deterministic backup filename', async () => {
env = await bootstrap({ hooks: {} });
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 as string);
expect(stats.isFile()).toBe(true);
});
it('threads --cli-path into every generated hook command + records it in the pointer', async () => {
env = await bootstrap(undefined);
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.configPath, 'utf-8')) as {
hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>>;
};
expect(after.hooks.SessionStart[0].hooks[0].command).toContain(`--cli-path "${cliPath}"`);
expect(after.hooks.Stop[0].hooks[0].command).toContain(`--cli-path "${cliPath}"`);
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['cli_path']).toBe(cliPath);
});
it('rejects --cli-path values containing double-quote characters', async () => {
env = await bootstrap(undefined);
await expect(install({
home: env.home,
hooksDir: env.hooksDir,
cliPath: 'malicious" && rm -rf / "',
})).rejects.toThrow(/double-quote/);
});
// ── install UX: the /hooks trust step must be surfaced ────────────────
it.skipIf(!BIN_BUILT)('install output mentions the one-time /hooks trust step', async () => {
env = await bootstrap(undefined);
const { stdout } = await execFileAsync(
process.execPath,
[BIN_PATH, 'install', '--hooks-dir', env.hooksDir],
{ env: { ...process.env, HOME: env.home, USERPROFILE: env.home } },
);
expect(stdout).toContain('/hooks');
expect(stdout.toLowerCase()).toContain('trust');
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { join, resolve } from 'node:path';
import {
allHookBasenames,
backupPathFor,
hookCommandFor,
resolvePaths,
} from '../src/paths.js';
describe('resolvePaths (codex)', () => {
it('places hooks.json + pointer under <home>/.codex/', () => {
const home = resolve('/fake/home');
const paths = resolvePaths({ home, hooksDir: resolve('/some/dist/hooks') });
expect(paths.codexDir).toBe(join(home, '.codex'));
// Codex targets a STANDALONE hooks.json — NOT config.toml.
expect(paths.configPath).toBe(join(home, '.codex', 'hooks.json'));
expect(paths.pointerPath).toBe(join(home, '.codex', '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 (codex, 2-arg shared helper)', () => {
it('produces a quoted node invocation around the absolute script path', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'));
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
});
it('appends --cli-path when supplied', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), '/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.js'), '');
expect(cmd).not.toContain('--cli-path');
});
it('preserves Windows-style paths (with spaces) inside the quotes', () => {
const cmd = hookCommandFor('/abs/dist/hooks/stop.js', 'C:\\Program Files\\hive-mind\\dist\\index.js');
expect(cmd).toContain('--cli-path "C:\\Program Files\\hive-mind\\dist\\index.js"');
});
});
describe('backupPathFor (codex)', () => {
it('replaces colons and dots in the timestamp for filesystem safety', () => {
const backup = backupPathFor('/h/.codex/hooks.json', '2026-04-28T10:30:45.123Z');
expect(backup).toBe('/h/.codex/hooks.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
});
});
describe('allHookBasenames (codex)', () => {
it('returns the four canonical basenames (CC clone)', () => {
expect([...allHookBasenames()].sort()).toEqual([
'pre-compact',
'session-start',
'stop',
'user-prompt-submit',
]);
});
});

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import {
jsonRegister,
jsonUnregister,
hasHiveEntries,
type JsonRegisterEntry,
} from '@waggle/hive-mind-hooks-core';
import {
codexRegisterSpec,
HIVE_MIND_MARKER,
SESSION_START_MATCHER,
} from '../src/adapter.js';
import { hookCommandFor } from '../src/paths.js';
const HOOKS_DIR = '/abs/dist/hooks';
function entry(
lifecycle: JsonRegisterEntry['lifecycle'],
basename: string,
timeout = 5,
): JsonRegisterEntry {
return {
lifecycle,
command: hookCommandFor(`${HOOKS_DIR}/${basename}.js`),
timeout,
};
}
const ALL_ENTRIES: readonly JsonRegisterEntry[] = [
entry('session-start', 'session-start'),
entry('user-prompt-submit', 'user-prompt-submit'),
entry('stop', 'stop'),
entry('pre-compact', 'pre-compact'),
];
interface CodexGroup {
matcher?: string;
hooks: Array<{ type: string; command: string; timeout?: number }>;
_hiveMindShim?: string;
}
function groupsAt(config: Record<string, unknown>, eventKey: string): CodexGroup[] {
const hooks = config['hooks'] as Record<string, unknown> | undefined;
return (hooks?.[eventKey] as CodexGroup[] | undefined) ?? [];
}
describe('jsonRegister (codex {matcher,hooks:[...]} group shape)', () => {
it('returns a NEW object — does not mutate input', () => {
const original: Record<string, unknown> = { hooks: { SessionStart: [] } };
const merged = jsonRegister(original, [entry('session-start', 'session-start')], codexRegisterSpec);
expect(merged).not.toBe(original);
// Input untouched (immutability contract).
expect((original['hooks'] as Record<string, unknown>)['SessionStart']).toEqual([]);
});
it('registers a group under each codex native event key', () => {
const merged = jsonRegister({}, ALL_ENTRIES, codexRegisterSpec);
expect(groupsAt(merged, 'SessionStart')).toHaveLength(1);
expect(groupsAt(merged, 'UserPromptSubmit')).toHaveLength(1);
expect(groupsAt(merged, 'Stop')).toHaveLength(1);
expect(groupsAt(merged, 'PreCompact')).toHaveLength(1);
});
it('builds the codex group shape: {hooks:[{type:command,command,timeout}], marker}', () => {
const merged = jsonRegister({}, [entry('stop', 'stop', 9)], codexRegisterSpec);
const g = groupsAt(merged, 'Stop')[0];
expect(g._hiveMindShim).toBe(HIVE_MIND_MARKER);
expect(g.hooks).toHaveLength(1);
expect(g.hooks[0].type).toBe('command');
expect(g.hooks[0].command).toContain('stop.js');
expect(g.hooks[0].timeout).toBe(9);
});
it('carries the lifecycle matcher ONLY on SessionStart', () => {
const merged = jsonRegister({}, ALL_ENTRIES, codexRegisterSpec);
expect(groupsAt(merged, 'SessionStart')[0].matcher).toBe(SESSION_START_MATCHER);
expect(groupsAt(merged, 'UserPromptSubmit')[0].matcher).toBeUndefined();
expect(groupsAt(merged, 'Stop')[0].matcher).toBeUndefined();
expect(groupsAt(merged, 'PreCompact')[0].matcher).toBeUndefined();
});
it('preserves existing (user) hook groups verbatim — additive merge', () => {
const existing: Record<string, unknown> = {
hooks: {
SessionStart: [
{ matcher: 'startup', hooks: [{ type: 'command', command: 'node /existing/x.js' }] },
],
},
};
const merged = jsonRegister(existing, [entry('session-start', 'session-start')], codexRegisterSpec);
const arr = groupsAt(merged, 'SessionStart');
expect(arr).toHaveLength(2);
expect(arr[0].hooks[0].command).toBe('node /existing/x.js');
expect(arr[0]._hiveMindShim).toBeUndefined();
expect(arr[1]._hiveMindShim).toBe(HIVE_MIND_MARKER);
});
it('preserves unrelated top-level keys (does not touch the user TOML-adjacent config)', () => {
const merged = jsonRegister(
{ schemaVersion: 2, hooks: {} },
[entry('session-start', 'session-start')],
codexRegisterSpec,
);
expect(merged['schemaVersion']).toBe(2);
});
it('replaces our own marker-tagged group on re-install (idempotent dedup by command)', () => {
const e = entry('session-start', 'session-start', 5);
const merged1 = jsonRegister({}, [e], codexRegisterSpec);
const merged2 = jsonRegister(merged1, [{ ...e, timeout: 11 }], codexRegisterSpec);
const arr = groupsAt(merged2, 'SessionStart');
expect(arr).toHaveLength(1); // never duplicated
expect(arr[0].hooks[0].timeout).toBe(11);
});
});
describe('hasHiveEntries (codex)', () => {
it('false on empty / hookless config', () => {
expect(hasHiveEntries(undefined, codexRegisterSpec)).toBe(false);
expect(hasHiveEntries({}, codexRegisterSpec)).toBe(false);
expect(hasHiveEntries({ hooks: {} }, codexRegisterSpec)).toBe(false);
});
it('true once a marker-tagged group is present', () => {
const merged = jsonRegister({}, [entry('stop', 'stop')], codexRegisterSpec);
expect(hasHiveEntries(merged, codexRegisterSpec)).toBe(true);
});
it('false for a config holding ONLY non-hive (user) groups', () => {
const userOnly: Record<string, unknown> = {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'node /user/own.js' }] }] },
};
expect(hasHiveEntries(userOnly, codexRegisterSpec)).toBe(false);
});
});
describe('jsonUnregister (codex)', () => {
it('strips exactly our marker-tagged groups, preserves user groups', () => {
const userGroup = { hooks: [{ type: 'command', command: 'node /user/own.js' }] };
const withUser: Record<string, unknown> = { hooks: { Stop: [userGroup] } };
const merged = jsonRegister(withUser, [entry('stop', 'stop')], codexRegisterSpec);
expect(groupsAt(merged, 'Stop')).toHaveLength(2);
const stripped = jsonUnregister(merged, codexRegisterSpec);
const arr = groupsAt(stripped, 'Stop');
expect(arr).toHaveLength(1);
expect(arr[0].hooks[0].command).toBe('node /user/own.js');
expect(hasHiveEntries(stripped, codexRegisterSpec)).toBe(false);
});
});

View File

@@ -0,0 +1,118 @@
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';
interface TestEnv {
home: string;
hooksDir: string;
configPath: string;
pointerPath: string;
}
async function bootstrap(initial: Record<string, unknown> | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmcdx-uninstall-'));
const codexDir = join(home, '.codex');
await mkdir(codexDir, { recursive: true });
const configPath = join(codexDir, 'hooks.json');
if (initial !== undefined) {
await writeFile(configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
}
const hooksDir = resolve(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
return { home, hooksDir, configPath, pointerPath: join(codexDir, 'hive-mind-install.json') };
}
function sha256(s: string): string {
return createHash('sha256').update(s, 'utf-8').digest('hex');
}
describe('uninstall (codex)', () => {
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({ hooks: {} });
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/pointer/);
});
it('throws when the pointer is malformed', async () => {
env = await bootstrap({ hooks: {} });
await writeFile(env.pointerPath, '{}', 'utf-8');
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/malformed/);
});
// ── created_by_us=false: byte-identical restore (§7.3 invariant 2) ─────
it('install + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
const initial = {
schemaVersion: 1,
hooks: {
SessionStart: [
{ matcher: 'startup', hooks: [{ type: 'command', command: 'node /existing/ctx.js' }] },
],
PreCompact: [
{ hooks: [{ type: 'command', command: 'node /existing/pre-compact.js', timeout: 10 }] },
],
},
};
env = await bootstrap(initial);
const preInstall = await readFile(env.configPath, 'utf-8');
const preHash = sha256(preInstall);
await install({ home: env.home, hooksDir: env.hooksDir });
const afterInstall = await readFile(env.configPath, 'utf-8');
expect(sha256(afterInstall)).not.toBe(preHash); // install actually mutated
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.createdRemoved).toBe(false);
const afterUninstall = await readFile(env.configPath, 'utf-8');
expect(sha256(afterUninstall)).toBe(preHash);
expect(afterUninstall).toBe(preInstall);
});
it('removes backup + pointer by default after a restore', async () => {
env = await bootstrap({ hooks: {} });
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(result.backupPath as string)).toBe(true);
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.backupRemoved).toBe(true);
expect(existsSync(result.backupPath as string)).toBe(false);
expect(existsSync(result.pointerPath)).toBe(false);
});
it('keeps the backup when cleanupBackup=false', async () => {
env = await bootstrap({ hooks: {} });
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 as string)).toBe(true);
});
// ── created_by_us=true: delete-if-created, no orphan (§7.3 invariant 2) ─
it('deletes the hooks.json we created and leaves NO orphan (absent → install → uninstall)', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.createdByUs).toBe(true);
expect(existsSync(env.configPath)).toBe(true);
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.createdRemoved).toBe(true);
expect(u.restoredFrom).toBeNull();
// No orphaned config, no leftover backup, no leftover pointer.
expect(existsSync(env.configPath)).toBe(false);
expect(existsSync(env.pointerPath)).toBe(false);
});
});

View File

@@ -0,0 +1,206 @@
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';
interface TestEnv {
home: string;
hooksDir: string;
codexDir: string;
}
async function bootstrap(
initial: Record<string, unknown> | undefined,
withHookFiles: boolean,
): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmcdx-verify-'));
const codexDir = join(home, '.codex');
await mkdir(codexDir, { recursive: true });
if (initial !== undefined) {
await writeFile(join(codexDir, 'hooks.json'), JSON.stringify(initial, null, 2) + '\n', '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, codexDir };
}
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 (codex)', () => {
const envs: TestEnv[] = [];
afterEach(async () => {
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
});
it('reports failure when hooks.json is missing', async () => {
const env = await bootstrap(undefined, 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[0].name).toBe('hooks.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('hive-mind entries'))).toBe(true);
});
it('passes after install with hook files on disk and CLI reachable', async () => {
const env = await bootstrap({ hooks: {} }, 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);
// The entry-presence check + each hook-script-readable check passed.
expect(result.checks.find((c) => c.name === 'hooks.json contains hive-mind entries')?.ok).toBe(true);
expect(result.checks.filter((c) => c.name.includes('readable on disk')).every((c) => c.ok)).toBe(true);
});
it('reports CLI unreachable when the spawn exits non-zero', async () => {
const env = await bootstrap({ hooks: {} }, 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('flags missing hook script files even when the settings entry is present', async () => {
const env = await bootstrap({ hooks: {} }, 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);
});
it('uses cli_path from the install pointer for the probe (node <path> --help)', async () => {
const env = await bootstrap({ hooks: {} }, 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);
const probeRecord = records[records.length - 1];
expect(probeRecord.command).toBe(process.execPath);
expect(probeRecord.args[0]).toBe(cliPath);
expect(probeRecord.args[1]).toBe('--help');
});
// ── codex-specific surfacings ─────────────────────────────────────────
it('always surfaces the one-time /hooks trust step as an advisory check', async () => {
const env = await bootstrap({ hooks: {} }, 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 }),
});
const trust = result.checks.find((c) => c.name.includes('/hooks trust step'));
expect(trust).toBeDefined();
expect(trust?.detail).toContain('/hooks');
});
it('surfaces allow_managed_hooks_only lockdown as a FAILING check', async () => {
const env = await bootstrap({ hooks: {} }, true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
// Admin lockdown suppresses user hooks — install would silently no-op.
await writeFile(
join(env.codexDir, 'requirements.toml'),
'allow_managed_hooks_only = true\n',
'utf-8',
);
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
const lockdown = result.checks.find((c) => c.name.includes('allow_managed_hooks_only'));
expect(lockdown).toBeDefined();
expect(lockdown?.ok).toBe(false);
expect(result.ok).toBe(false);
});
it('does not flag lockdown when requirements.toml does not set it', async () => {
const env = await bootstrap({ hooks: {} }, true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
await writeFile(
join(env.codexDir, 'requirements.toml'),
'allow_managed_hooks_only = false\n',
'utf-8',
);
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
const lockdown = result.checks.find((c) => c.name.includes('allow_managed_hooks_only'));
expect(lockdown?.ok).toBe(true);
});
});

View File

@@ -0,0 +1,15 @@
{
"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-hooks-core" },
{ "path": "../hive-mind-shim-core" }
]
}

View File

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