This commit is contained in:
19
packages/hive-mind-hooks-codex/LICENSE
Normal file
19
packages/hive-mind-hooks-codex/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
|
||||
59
packages/hive-mind-hooks-codex/README.md
Normal file
59
packages/hive-mind-hooks-codex/README.md
Normal 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.
|
||||
66
packages/hive-mind-hooks-codex/package.json
Normal file
66
packages/hive-mind-hooks-codex/package.json
Normal 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"
|
||||
}
|
||||
132
packages/hive-mind-hooks-codex/src/adapter.ts
Normal file
132
packages/hive-mind-hooks-codex/src/adapter.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
164
packages/hive-mind-hooks-codex/src/bin/codex-hooks.ts
Normal file
164
packages/hive-mind-hooks-codex/src/bin/codex-hooks.ts
Normal 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();
|
||||
34
packages/hive-mind-hooks-codex/src/hooks/pre-compact.ts
Normal file
34
packages/hive-mind-hooks-codex/src/hooks/pre-compact.ts
Normal 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();
|
||||
}
|
||||
37
packages/hive-mind-hooks-codex/src/hooks/session-start.ts
Normal file
37
packages/hive-mind-hooks-codex/src/hooks/session-start.ts
Normal 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();
|
||||
}
|
||||
35
packages/hive-mind-hooks-codex/src/hooks/stop.ts
Normal file
35
packages/hive-mind-hooks-codex/src/hooks/stop.ts
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
49
packages/hive-mind-hooks-codex/src/index.ts
Normal file
49
packages/hive-mind-hooks-codex/src/index.ts
Normal 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';
|
||||
161
packages/hive-mind-hooks-codex/src/install.ts
Normal file
161
packages/hive-mind-hooks-codex/src/install.ts
Normal 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;
|
||||
}
|
||||
73
packages/hive-mind-hooks-codex/src/paths.ts
Normal file
73
packages/hive-mind-hooks-codex/src/paths.ts
Normal 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 };
|
||||
76
packages/hive-mind-hooks-codex/src/uninstall.ts
Normal file
76
packages/hive-mind-hooks-codex/src/uninstall.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
181
packages/hive-mind-hooks-codex/src/verify.ts
Normal file
181
packages/hive-mind-hooks-codex/src/verify.ts
Normal 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 };
|
||||
}
|
||||
62
packages/hive-mind-hooks-codex/tests/hooks/_test-helpers.ts
Normal file
62
packages/hive-mind-hooks-codex/tests/hooks/_test-helpers.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
103
packages/hive-mind-hooks-codex/tests/hooks/stop.test.ts
Normal file
103
packages/hive-mind-hooks-codex/tests/hooks/stop.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
184
packages/hive-mind-hooks-codex/tests/install.test.ts
Normal file
184
packages/hive-mind-hooks-codex/tests/install.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
74
packages/hive-mind-hooks-codex/tests/paths.test.ts
Normal file
74
packages/hive-mind-hooks-codex/tests/paths.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
150
packages/hive-mind-hooks-codex/tests/register.test.ts
Normal file
150
packages/hive-mind-hooks-codex/tests/register.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
118
packages/hive-mind-hooks-codex/tests/uninstall.test.ts
Normal file
118
packages/hive-mind-hooks-codex/tests/uninstall.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
206
packages/hive-mind-hooks-codex/tests/verify.test.ts
Normal file
206
packages/hive-mind-hooks-codex/tests/verify.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
packages/hive-mind-hooks-codex/tsconfig.json
Normal file
15
packages/hive-mind-hooks-codex/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
12
packages/hive-mind-hooks-codex/tsconfig.test.json
Normal file
12
packages/hive-mind-hooks-codex/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/**"]
|
||||
}
|
||||
Reference in New Issue
Block a user