Skip to content

fix(hooks): Codex rejects the hook output we ship — v4.7.3 - #210

Merged
kevintseng merged 2 commits into
mainfrom
fix/codex-posttooluse-contract
Aug 24, 2026
Merged

fix(hooks): Codex rejects the hook output we ship — v4.7.3#210
kevintseng merged 2 commits into
mainfrom
fix/codex-posttooluse-contract

Conversation

@kevintseng

Copy link
Copy Markdown
Contributor

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

What happens

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 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 validateHookOutput classifies as kind: '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.js emits 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

node scripts/run-tests-isolated.mjs     exit=0   189 files, 2595 tests
npm run verify:release                  exit=0   all detectors new=0
npm run build                           exit=0
check-version-coherence                 exit=0   8 anchors at 4.7.3

Break-tests, each restored byte-identical:

Mutation Result
bug reinstated in post-commit.js exit=1 — 2 failed: the real-commit runtime case and the source scan
scan path misspelled exit=1 — ENOENT on the source-scan case

Release

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.

… 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]
@kevintseng
kevintseng merged commit 17b0a3c into main Aug 24, 2026
13 checks passed
@kevintseng
kevintseng deleted the fix/codex-posttooluse-contract branch August 24, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant