The Stop hook that read a payload it never got
skill/cross-instance-test-validator, #3804 · every unit test green, zero production verdicts · 2026-08-30
What was wired vs what arrived
hooks.json registered it here:
"Stop": [ ...
{ "command": "node",
"async": true, "asyncRewake": true,
"args": [..., "skill/cross-instance-test-validator", "--rewake"] }
]
the handler started with:
const filePath = input.tool_input?.file_path || ''; const content = input.tool_input?.content || ''; if (!filePath || !content) return outputSilentSuccess();
A Stop payload is {session_id, cwd, stop_hook_active, ...}.
It carries no tool_input, so the first guard returned silent success on every
invocation, hook-timing recorded ok:true, and the block path ("Missing test coverage
for new code") had never executed in production. The verdict probe measured it:
trip=silent, carried as the suite's one xfail.
Why every test was green
// the old unit test built its own input
function createFileInput(filePath, content) {
return { tool_name: 'Write', session_id: 'test-session-123',
tool_input: { file_path: filePath, content } }; // a PostToolUse shape
}
- The test measured the mock. It handed the hook exactly the field the registered event does not deliver, then asserted on the result. Same class as #3801 (a grep that never ran) and #959 (registration and handler disagree).
- The fixture the probe used was honest: a Stop payload plus
src/svc.tswith two exports and no test file. That is the only payload shape production ever sends.
What it reads now: the session's own edit history
.claude/state/edit-history.jsonl (written by posttool/write/edit-history-tracker)
{"t":1756540000000,"f":"/repo/src/svc.ts","tool":"Write","sid":"<this session>"}
{"t":1756540001200,"f":"/repo/src/svc.ts","tool":"Edit", "sid":"<this session>"}
{"t":1756540002000,"f":"/repo/docs/x.md", "tool":"Write","sid":"<other session>"} <- ignored (#2919)
Stop -> entries with sid == session_id
-> dedupe, drop test files / non-code / deleted files
-> read each file from DISK, extract exports
-> find its test file (sibling, sibling __tests__/, or MIRRORED tree)
-> no test file : BLOCK (exit 2 under --rewake, Claude is woken with the list)
test file, gaps : WARN (systemMessage; Stop-legal, unlike a PostToolUse-stamped context)
covered / nothing : SILENT
-> remember reported files in .claude/state/test-coverage-reported-<sid>.json
- Once per session. A file the operator chose to leave untested blocks once, not on every later turn.
stop_hook_activeshort-circuits. CC sets it when a Stop hook already blocked in this cycle; blocking again is the 2.1.78 infinite-loop shape.- Mirrored trees count. This repo keeps
src/__tests__/skill/x.test.tsforsrc/skill/x.ts. Without that rule the fixed hook would have blocked every ork hook author at their first Stop.
Try the verdict
Same rules as the handler, in the same order. Toggle the state of one file this session wrote and read the verdict.
Verdict probes, before and after this change
| probe | before | after | what changed |
|---|---|---|---|
| stop-cross-instance-missing-tests-block | xfail (silent) | trip=block control=silent | trip seeds edit-history with the runner's session id; control adds src/svc.test.ts |
| egress-staged-download-run-ask | fail on this host | trip=ask control=silent | pinned ORK_SANDBOX_ENABLED=unknown: since #3808 the guard reads the machine's sandbox posture and stands its ASK tier down when the sandbox is on. Same pin added to bypass-mode-ask-gate.test.ts, which failed on main for the same reason. |
The second row is the same lesson from the other side. A test that reads the machine it runs on passes in CI and fails on a developer's laptop, or the reverse. Pin the input the test is not about.
Numbers
- src/hooks vitest: 7642 passed, 0 failed, 2 skipped. The old 20-case file mocked
node:fs; the new 22-case file uses a temp project dir, a seeded history and a Stop payload. - hook count: unchanged at 175. Registration untouched: still Stop, async,
asyncRewake. - runner:
--rewakemapscontinue:falseto exit 2 with the reason on stderr, so the block now reaches Claude the moment the verifier finishes.