End-to-end testing of the anti-hall hooks¶
This suite exercises every anti-hall hook as a real child process — the same way
Claude Code runs them — and asserts on the exit code and stdout/stderr contract.
It uses only Node's built-in node:test
runner and node:assert, so it stays zero-dependency, matching the
deps-free plugin it tests.
Hooks reference: the official Claude Code hooks contract is documented at https://code.claude.com/docs/en/hooks.
Approach¶
- Black-box, process-level. Each test spawns the hook with
spawnSync, pipes a JSON payload to stdin, and inspects what comes back. No internals are imported except the one pure module that is genuinely unit-testable (skip-guard.js'sisSkipped). - Zero deps.
node --testdiscovers and runs**/*.test.js; assertions usenode:assert. Nothing is installed. - Deterministic env isolation (see the gotcha below) so coordinator-vs- subagent detection is reproducible regardless of where the suite runs.
Hook I/O contract (per event)¶
The hooks implement three distinct Claude Code event contracts. The tests assert against exactly these:
| Event | Block signal | Allow signal | Context injection |
|---|---|---|---|
PreToolUse |
exit code 2 (reason on stderr or in a {decision:"block"} stdout object) |
exit 0 | — |
Stop |
stdout JSON {"decision":"block","reason":…} and exit 0 |
exit 0, no decision |
— |
UserPromptSubmit |
— (never blocks) | exit 0 | stdout JSON hookSpecificOutput.additionalContext |
SessionStart |
— (never blocks) | exit 0 | stdout JSON hookSpecificOutput.additionalContext |
Mapping to hooks:
| Hook | Event | What the test asserts |
|---|---|---|
git-guard |
PreToolUse (Bash) | force-push / AI-credit-trailer forms exit 2; safe forms exit 0 |
command-guard |
PreToolUse (Bash) | heavy commands exit 2 in coordinator; allowed in subagent / for light commands. Under DevSwarm-active env: hivecontrol workspace monitor exit 2 unconditionally, read-messages exit 2 only with durable-inbox evidence, quoted DATA mentions of either allowed — all contexts, own skip devswarm-read-guard. git-stash-guard sub-branch: a mutating git stash (push/pop/drop/clear/apply/save, bare git stash, or a flag-only push form) exit 2 in either context, but ONLY once ARMED (.anti-hall/protected-stashes at the git toplevel, or ANTIHALL_STASH_GUARD=1) — unarmed always allows; git stash list always allows; a quoted mention (a grep pattern, a commit message) never matches — own skip git-stash-guard (in skip-guard.js's DESTRUCTIVE set) |
edit-guard |
PreToolUse (Edit-family) | Edit/Write/MultiEdit/NotebookEdit exit 2 in coordinator; allowed in subagent (payload agent_id/agent_type) and for allowlisted paths |
skip-guard |
(module) | isSkipped TTL + granularity (all ≠ destructive git-guard); plus an e2e bypass through command-guard |
speculation-guard |
Stop | hedge-without-acknowledgment blocks; acknowledgment / no-hedge allows; MAX_BLOCKS cap; skip hatch |
speculation-judge |
Stop | opt-in: unless jev.semanticJudge is true or ANTIHALL_SEMANTIC_JUDGE=1 it exits 0 regardless of transcript (live API path untested) |
task-guard |
Stop | open tasks block; all-complete / none allows; skip hatch |
task-tracker |
UserPromptSubmit | first turn FULL directive, then SHORT; future/garbage timestamp self-heals to FULL |
verify-first |
UserPromptSubmit | additionalContext starts VERIFY-FIRST:; deterministic for a given envelope |
verify-first-full |
SessionStart | protocolLevel=full: full protocol contains the IRON LAW, the scannability rule, and USER OVERRIDE; default compact: the compact core keeps the load-bearing clauses and points at PROTOCOL.md (tests/hooks/verify-first-compact.test.js) |
orch-on-spawn |
PreToolUse (Agent) | one ORCH_FULL per epoch (O_EXCL claim), silent for subagents and for a missing/none marker; fail-open on bad stdin |
swarm-guard |
PreToolUse (Task) | a normal spawn is allowed; fail-open on bad stdin |
Every hook additionally has fail-open tests: empty stdin ('') and malformed
JSON ('{bad') must never block — PreToolUse hooks exit 0, Stop hooks emit no
decision:block.
The spawn-and-assert pattern¶
tests/helpers/spawn-hook.js exports:
testHook(hookFile, payloadObj, opts)— spawns the hook, pipesJSON.stringify(payloadObj)to stdin, returns{ status, stdout, stderr, json }(jsonisJSON.parse(stdout)ornull).testHookRaw(hookFile, rawString, opts)— same, but pipes a raw string (for the empty-stdin and malformed-JSON fail-open tests).bashPayload(command, { agentId })— builds aPreToolUse/Bashpayload; whenagentIdis given it lands in the payload (the subagent discriminator).
Hook paths resolve absolutely from the test file via path.join(__dirname, …,
'plugins/anti-hall/hooks', …), so the suite is location-independent.
Env-isolation gotcha (read this)¶
The test process may itself be running inside an agent harness, so process.env
can already contain CLAUDE_CODE_ENTRYPOINT or agent markers. If a hook inherited
that environment, coordinator-vs-subagent detection would be non-deterministic.
The spawn helper therefore passes a controlled environment, never a blind inherit:
So a test sets up the exact context it means to test:
- Coordinator tests set
CLAUDE_CODE_ENTRYPOINT='cli'and put noagent_idin the payload. - Subagent tests put
"agent_id":"x"in the payload — this is the primary, cmux-reliable signal the hooks read first (the process-env entrypoint is only a fallback). Control the payload first.
Fixtures¶
tests/helpers/fixtures.js exports makeHome(), which creates a disposable temp
HOME (mkdtempSync) with a ~/.anti-hall state dir, and returns helpers:
writeSkip(obj)— write~/.anti-hall/skip.json(the escape-hatch marker).writeTranscript(messages)— write a JSONL transcript (one JSON object per line) and return its path; this is what the Stop hooks parse.writeState(filename, obj)— pre-seed a hook's state file under~/.anti-hall.cleanup()—rmSyncthe temp home (recursive,force).
Each test gets its own HOME, so hook state (skip markers, loop-state, throttle
timestamps) never leaks between tests or touches the real machine.
How to run¶
From the repository root:
Bare node --test is preferred for portability: it discovers test files itself,
so no shell glob is expanded and it behaves identically across bash, zsh, and
PowerShell. (node --test 'tests/**/*.test.js' relies on the shell — PowerShell
will not expand it; and node --test tests/ is treated as a module path on newer
Node, not a discovery root.)
CI matrix¶
.github/workflows/test.yml runs the suite on pull requests, on pushes to main
(which arrive only by merging a pull request from dev) and on rc-v* candidate
tags; pushes to dev and v* release tags do not trigger it, so run node --test
locally before pushing dev. An rc-v* tag on a dev commit runs the full matrix;
main and pull requests use a reduced one:
- OS:
ubuntu-latest,macos-latest(Windows is not supported) - Node:
22.x,24.x(Node 22 is the minimum) - rc-v* tags: ubuntu x Node 22/24 and macOS x Node 22/24, each cell split with
node --test --test-shard=i/n(3 shards per ubuntu cell, 2 per macOS cell). - main and pull requests (the
dev→mainpull request is the merge gate): ubuntu x Node 22/24 plus macOS x Node 24 only, sharded the same way.
with fail-fast: false so one shard's failure does not mask the others. Each shard
checks out, sets up Node, and runs its slice of node --test.