fix(hooks): Codex rejects the hook output we ship — v4.7.3 - #210
Merged
Conversation
… Codex
Reported from a live Codex session against installed 4.7.1, and confirmed
present in the v4.7.2 tag before touching anything:
PostToolUse hook returned unsupported suppressOutput
hook returned invalid post-tool-use JSON output
hook returned invalid stop hook JSON output
`post-commit.js` (PostToolUse/Bash) and `session-summary.js` (Stop) each
ended with `console.log(JSON.stringify({ suppressOutput: true }))`. That is
valid Claude Code hook output. Codex validates hook output per event against
its own schema and rejects the field, so a Codex user got an error after every
Bash command and at the end of every turn — with the capture already
succeeded. The memory was written and the error appeared anyway.
Both now print nothing. Not a compromise: the field was never doing any work.
Neither hook writes anything else to stdout, so there was no output to
suppress; it announced an intent that silence already expresses. Empty stdout
with exit 0 is the "no opinion" signal in both contracts, and is what this
repo's own validateHookOutput already classifies as kind 'empty'.
I had told KT this class of error was not memesh's. That was wrong, and the
way it was wrong is worth recording: I proved session-summary emits only
well-formed JSON, which is true and is the wrong question. JSON a host can
parse is not JSON a host accepts. I answered "is the output valid" and
reported it as "is the output compatible".
Two things went wrong writing the regression test, both caught before landing:
1. The source-scan assertion tripped on the explanatory comments I had just
added to the hooks. Comments are now stripped before scanning, because a
check that cannot tell an explanation from an emission would forbid
documenting the decision.
2. The first runtime test still went green with the bug reinstated. Its
payload was synthetic, and this hook turns such a payload away at one of
six gates long before the line that used to print, so every early return
was already silent and the assertion covered nothing. It now builds a real
git repository, makes a real commit, and feeds the hook the hash git
actually produced, with a guard asserting git's output still matches the
hook's own regex. It also asserts the database was written, so silence
cannot quietly become "did nothing".
tests/hooks/post-commit.test.ts had a scenario named "Hook output includes
suppressOutput flag" pinning the old contract. Renamed and rewritten rather
than deleted, since the name was the clearest statement of what changed.
[Verified-By: node scripts/run-tests-isolated.mjs -> exit=0, "Test Files 189 passed (189)", "Tests 2595 passed (2595)"]
[Verified-By: node scripts/check-version-coherence.mjs -> exit=0, "All version sources agree." with 8 anchors at 4.7.3]
[Verified-By: npm run build -> exit=0]
[Verified-By: break-test with the bug reinstated in post-commit.js -> exit=1, "Tests 2 failed | 1 passed (3)" covering the real-commit runtime case AND the source scan; file restored byte-identical]
…d nothing The verification audit's C1 detector caught this before CI did, and it was right rather than noisy. The new source scan ends in `expect(offenders) .toEqual([])`, which passes identically whether the scan read every hook and none offended, or read NO hooks at all because the path was wrong. Those are opposite outcomes and the assertion cannot tell them apart. The file count is now pinned first. Break-tested by pointing the scan at a misspelled directory: it fails, where before the mutation it would have reported a clean scan of nothing. [Verified-By: node scripts/run-tests-isolated.mjs tests/hooks/cross-host-output-contract.test.ts -> exit=0, "Tests 3 passed (3)"] [Verified-By: node scripts/audit/verification-audit.mjs -> exit=0, "C1: denominator=111 hits=25 new=0", "Every hit is triaged; every detector saw a non-empty candidate set."] [Verified-By: break-test with the scan path misspelled -> exit=1, FAIL on the source-scan case with ENOENT; file restored byte-identical]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported from a live Codex session against installed 4.7.1, and confirmed present in the v4.7.2 tag before touching anything.
What happens
post-commit.js(PostToolUse/Bash) andsession-summary.js(Stop) each ended with:That is valid Claude Code hook output. Codex validates hook output per event against its own schema and rejects the field — so a Codex user gets an error after every Bash command and at the end of every turn, with the capture having already succeeded. The memory is written and the error appears anyway.
The fix
Both hooks now print nothing.
Not a compromise — the field was never doing any work. Neither hook writes anything else to stdout, so there was no output to suppress; it announced an intent that silence already expresses. Empty stdout with exit 0 is the "no opinion" signal in both contracts, and is exactly what this repo's own
validateHookOutputclassifies askind: 'empty'.Claude Code behaviour is unchanged: a hook that prints nothing and one that asks for its output to be hidden look identical to the user.
A conclusion of mine this overturns
I had told KT that this class of error was not memesh's, on the strength of a structural proof that
session-summary.jsemits only well-formed JSON. That proof is correct and answers the wrong question. JSON a host can parse is not JSON a host accepts. I checked validity and reported compatibility.Two bad tests, caught before landing
The source scan tripped on my own comments. It searched for the string
suppressOutput, and the hooks now carry a comment explaining why the field is absent. Comments are stripped before scanning — a check that cannot tell an explanation from an emission would forbid documenting the decision.The first runtime test passed with the bug reinstated. Its payload was synthetic, and this hook turns such a payload away at one of six gates long before the line that used to print — every early return was already silent, so the assertion covered nothing. It now builds a real git repository, makes a real commit, and feeds the hook the hash git actually produced, with a guard asserting git's output still matches the hook's own regex. It also asserts the database was written, so silence cannot quietly become "did nothing".
And the audit caught a third. C1 flagged
expect(offenders).toEqual([])as an emptiness assertion with no size pin — correctly: it passes identically whether the scan read every hook and none offended, or read no hooks because the path was wrong. The file count is pinned first now.Evidence
Break-tests, each restored byte-identical:
post-commit.jsexit=1— 2 failed: the real-commit runtime case and the source scanexit=1— ENOENT on the source-scan caseRelease
Bumped to 4.7.3 in this PR. 4.7.1 and 4.7.2 are both affected for anyone running MeMesh as a Codex plugin, so this wants shipping rather than waiting.