Repository navigation
feat(hooks): normalise Codex hook events into what the lifecycle hooks read (#608) - #608
Conversation
…s read (#608) hypermnesia-mcp-hook (#605) ran a hook module unchanged against whatever stdin carried, so a Codex apply_patch or exec_command/shell_command event reached decision_gate, preemptive_context, pipeline_impact_bump and the rest in a shape none of them read tool_name or tool_input for. mcp_server/hooks/host_event.py normalises one host event into the Claude-shaped event(s) each module already reads before entry.py dispatches: apply_patch expands into one Edit/Write event per file operation via the new fail-closed mcp_server/hooks/host_patch.py parser, ported from the same owner's zetetic-team-subagents hooks/lib/host_events.py (commit 4c2bc9b, PR #139, MIT licensed); exec_command/shell_command become a Bash event with workdir resolved against cwd; SubagentStart maps agent_type to agent_name. A Claude Code event passes through byte-identical. entry.py reads stdin once, derives the event list, sets CLAUDE_PROJECT_ROOT from the event's cwd only when unset, and runs the hook module once per derived event, replacing sys.stdin for each run. A patch that cannot be translated without losing edit information is refused the same way decision_gate/no_deps_gate already fail closed: exit 2 on a PreToolUse event, exit 1 with a stderr line otherwise. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
d2f1632 to
e527fce
Compare
…s read (#608) hypermnesia-mcp-hook (#605) ran a hook module unchanged against whatever stdin carried, so a Codex apply_patch or exec_command/shell_command event reached decision_gate, preemptive_context, pipeline_impact_bump and the rest in a shape none of them read tool_name or tool_input for. mcp_server/hooks/host_event.py normalises one host event into the Claude-shaped event(s) each module already reads before entry.py dispatches: apply_patch expands into one Edit/Write event per file operation via the new fail-closed mcp_server/hooks/host_patch.py parser, ported from the same owner's zetetic-team-subagents hooks/lib/host_events.py (commit 4c2bc9b, PR #139, MIT licensed); exec_command/shell_command become a Bash event with workdir resolved against cwd; SubagentStart maps agent_type to agent_name. A Claude Code event passes through byte-identical. entry.py reads stdin once, derives the event list, sets CLAUDE_PROJECT_ROOT from the event's cwd only when unset, and runs the hook module once per derived event, replacing sys.stdin for each run. A patch that cannot be translated without losing edit information is refused the same way decision_gate/no_deps_gate already fail closed: exit 2 on a PreToolUse event, exit 1 with a stderr line otherwise. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
…py under the craftsmanship cap (#608) CI's craftsmanship gate on e527fce flagged two new violations neither of which existed at review time locally, because both were introduced by edits made after my last local run of check_craftsmanship.py: the ruff PLR2004 fix that named _MIN_ENVELOPE_LINES had no # source: comment, and ruff format's reflow of test_entry.py (plus the apply_patch end-to-end tests appended after that point) pushed the file to 302 lines, over the 300-line cap. Gives _MIN_ENVELOPE_LINES its source (zetetic-team-subagents hooks/lib/host_events.py, commit 4c2bc9b, PR #139, the same three-line envelope floor). Splits test_entry.py along its seam: PR 1's launcher parity and environment-resolution tests stay in test_entry.py (159 lines); the new apply_patch end-to-end tests move to tests_py/hooks/test_entry_apply_patch.py (165 lines), importing REPO_ROOT/_isolated_env from test_entry.py rather than duplicating them. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
e527fce to
0f7ea00
Compare
|
ZETETIC-REVIEW: REQUEST_CHANGES Reviewed 0f7ea00 (rebased on origin/main 4bd7e7f, merge-base equals main) in a read-only worktree at The parser is the strong part of this change set. The parity and fail-closed properties I could think of all hold under measurement. What is not carried by any test is the dispatch loop in Required changesThe PreToolUse stop-on-first-block and the multi-event loop are uncovered
Identical to the clean run on this tip. The mutant lives through the whole suite. A safety claim that no test would notice being deleted is not a safety claim yet. Please add a test that dispatches a two-operation CLAUDE_PROJECT_ROOT precedence is uncovered in both arms
I verified both arms behave correctly today by driving So the code is right. It is the suite that does not hold it. One divergence from the port source, to confirm or restore
If Codex ever puts the directory under What I measured and what heldPort against its source. Function by function, Fail-closed properties, run rather than read: A path escaping Pass-through pins. Fifteen Claude event shapes covering all eleven wired modules (SessionStart, UserPromptSubmit, PreToolUse Write and Edit, PostToolUse Bash/Read/Write, Stop, SessionEnd, PreCompact, PostCompact, SubagentStart, SubagentStop, Notification): End to end through A second check on the gate side: a two-operation patch whose first file is clean and whose second adds nine comment lines exits 2 and names the second file, so the loop really submits each derived event to the gate rather than only the first. Edge cases from the diff. For a Gates on 0f7ea00, run locally:
Full suite on the previous head e527fce: 8839 passed, 8 skipped, 459 subtests in 658.57s. On this tip the two mutation runs above both reported 8852 passed, 8 skipped, matching the executor's clean number for the tip. CI on 0f7ea00: 24 pass, 4 skipping, 0 fail (Craftsmanship Gate pass 16s, Type Check pass 1m33s, Test 3.10/3.11/3.12/3.13, SQLite and Windows SQLite all pass). The two test additions are about thirty lines against |
…fallback (#608) Zetetic review on 0f7ea00 proved test-adequacy gaps by mutation: deleting entry.py's stop-on-first-block line, and inverting the CLAUDE_PROJECT_ROOT precedence check, both left the full suite at 8852 passed. Neither behaviour had a test that exercised more than one derived event. mcp_server/hooks/host_dispatch.py gains two things extracted out of entry.main() (which went over the 40-line craftsmanship cap once the fix was inlined): apply_project_root(environ, cwd), unit-tested for both the unset-fills and preset-wins arms, and run_all(module, payloads, hook_event_name), which now takes the worst (max) exit code across a PostToolUse batch instead of the last one, so an earlier failing operation in a multi-event apply_patch is never masked by a later one that succeeds -- the reviewer's advisory item. New tests drive a two-operation apply_patch through decision_gate and count "[decision-gate] BLOCKED" occurrences in stderr to verify how many times the module actually ran: once for PreToolUse (stop-on-first-block), twice for PostToolUse, and that a blocked-then-benign PostToolUse pair still exits 2. host_event.py's shell-command translation gets back the tin.get("cwd") fallback zetetic's host_events.py has when "workdir" is absent -- dropped without evidence Codex never sends that key, restored per the reviewer's non-blocking note. Both original mutations were re-applied and re-reverted against the final code to confirm the new tests still catch them; see the PR conversation for the raw failing/passing output. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
…s read (#608) hypermnesia-mcp-hook (#605) ran a hook module unchanged against whatever stdin carried, so a Codex apply_patch or exec_command/shell_command event reached decision_gate, preemptive_context, pipeline_impact_bump and the rest in a shape none of them read tool_name or tool_input for. mcp_server/hooks/host_event.py normalises one host event into the Claude-shaped event(s) each module already reads before entry.py dispatches: apply_patch expands into one Edit/Write event per file operation via the new fail-closed mcp_server/hooks/host_patch.py parser, ported from the same owner's zetetic-team-subagents hooks/lib/host_events.py (commit 4c2bc9b, PR #139, MIT licensed); exec_command/shell_command become a Bash event with workdir resolved against cwd; SubagentStart maps agent_type to agent_name. A Claude Code event passes through byte-identical. entry.py reads stdin once, derives the event list, sets CLAUDE_PROJECT_ROOT from the event's cwd only when unset, and runs the hook module once per derived event, replacing sys.stdin for each run. A patch that cannot be translated without losing edit information is refused the same way decision_gate/no_deps_gate already fail closed: exit 2 on a PreToolUse event, exit 1 with a stderr line otherwise. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
…py under the craftsmanship cap (#608) CI's craftsmanship gate on e527fce flagged two new violations neither of which existed at review time locally, because both were introduced by edits made after my last local run of check_craftsmanship.py: the ruff PLR2004 fix that named _MIN_ENVELOPE_LINES had no # source: comment, and ruff format's reflow of test_entry.py (plus the apply_patch end-to-end tests appended after that point) pushed the file to 302 lines, over the 300-line cap. Gives _MIN_ENVELOPE_LINES its source (zetetic-team-subagents hooks/lib/host_events.py, commit 4c2bc9b, PR #139, the same three-line envelope floor). Splits test_entry.py along its seam: PR 1's launcher parity and environment-resolution tests stay in test_entry.py (159 lines); the new apply_patch end-to-end tests move to tests_py/hooks/test_entry_apply_patch.py (165 lines), importing REPO_ROOT/_isolated_env from test_entry.py rather than duplicating them. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
…fallback (#608) Zetetic review on 0f7ea00 proved test-adequacy gaps by mutation: deleting entry.py's stop-on-first-block line, and inverting the CLAUDE_PROJECT_ROOT precedence check, both left the full suite at 8852 passed. Neither behaviour had a test that exercised more than one derived event. mcp_server/hooks/host_dispatch.py gains two things extracted out of entry.main() (which went over the 40-line craftsmanship cap once the fix was inlined): apply_project_root(environ, cwd), unit-tested for both the unset-fills and preset-wins arms, and run_all(module, payloads, hook_event_name), which now takes the worst (max) exit code across a PostToolUse batch instead of the last one, so an earlier failing operation in a multi-event apply_patch is never masked by a later one that succeeds -- the reviewer's advisory item. New tests drive a two-operation apply_patch through decision_gate and count "[decision-gate] BLOCKED" occurrences in stderr to verify how many times the module actually ran: once for PreToolUse (stop-on-first-block), twice for PostToolUse, and that a blocked-then-benign PostToolUse pair still exits 2. host_event.py's shell-command translation gets back the tin.get("cwd") fallback zetetic's host_events.py has when "workdir" is absent -- dropped without evidence Codex never sends that key, restored per the reviewer's non-blocking note. Both original mutations were re-applied and re-reverted against the final code to confirm the new tests still catch them; see the PR conversation for the raw failing/passing output. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
2e099f2 to
7e24acf
Compare
|
ZETETIC-REVIEW: APPROVE Round 2 on 7e24acf (rebased onto origin/main 19b78df, merge-base equals main). The two blocking items are closed, measured by re-applying the mutationsI re-applied each mutation and ran Removing the Weakening I also mutated the two changes you made on the non-blocking items, since untested fixes to a review comment are the same defect in a new place. Replacing Dropping the restored Four mutants, four killed, each by a test whose name says what it protects. The subprocess tests are the part I want to single out. The third line is the The shell fallback matches the upstream order
Regressions I re-checked rather than assumedThe extraction moved real code out of
Gates on 7e24acf, run locally: 8861 matches your number exactly, and it is 9 above the 8852 of the previous head, which is the nine tests added here. Sizes stay under the caps: CI on 7e24acf: 24 pass, 4 skipping, 0 fail. One thing left standing from round 1, still not worth a change: |
Promotes the Unreleased section to 4.23.0 and moves the seventeen version surfaces (pyproject, manifest, plugin and marketplace manifests, Codex and Gemini manifests, package.json and lock, server.json, badge, uv.lock). Since 4.22.0: session hooks inject only the session's project and global memories (#604), one saved backend for Claude Code and Codex (#600), prediction records and calibration (#597), the wiki draft tools over MCP (#579), procedural skills on SQLite (#596) with a transcript-fed tool sequence (#591), the console entry point and Codex event adapter for the lifecycle hooks (#605, #608), the doctor worktree check (#602), and the launcher fixes (#573, #575, #582). The marketplace pin for zetetic-team-subagents moves to its released 2.42.0. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
* release: 4.23.0 Promotes the Unreleased section to 4.23.0 and moves the seventeen version surfaces (pyproject, manifest, plugin and marketplace manifests, Codex and Gemini manifests, package.json and lock, server.json, badge, uv.lock). Since 4.22.0: session hooks inject only the session's project and global memories (#604), one saved backend for Claude Code and Codex (#600), prediction records and calibration (#597), the wiki draft tools over MCP (#579), procedural skills on SQLite (#596) with a transcript-fed tool sequence (#591), the console entry point and Codex event adapter for the lifecycle hooks (#605, #608), the doctor worktree check (#602), and the launcher fixes (#573, #575, #582). The marketplace pin for zetetic-team-subagents moves to its released 2.42.0. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * release: the zetetic-team-subagents pin fetches the 2.42.0 tree it advertises The review of this cut found version 2.42.0 beside ref v2.41.0 and the 2.41.0 sha; installs would have checked out 77b4f785 under a 2.42.0 label. ref and sha now name the v2.42.0 tag commit 96454db1, as the earlier deliveries (#553, #554) moved all three fields together. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * docs(changelog): name the consolidation launcher that shipped The ADR-1082 entry cited a consolidate_launch module the craftsmanship gate refused; the shipped design relaunches session_lifecycle with --consolidate <mode>, as ADR-1082 and the code say. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH --------- Co-authored-by: Claude Fable 5.1 <[email protected]>
…odex (#620) * feat(codex): full profile and the same eleven lifecycle hooks under Codex Cortex shipped two plugin packages with deliberately different exposure: the Claude Code plugin with all hooks and the full tool profile, and a reduced, MCP-only Codex package pinned to `--profile lean`. This reverses that. Codex gets the same product. - `.mcp.json` drops `--profile lean`, so the default full profile is served, exactly like the Claude manifest's server args, which carry no `--profile` flag at all. - New `hooks/hooks.json`, referenced from `.codex-plugin/plugin.json` by path the way `mcpServers` already is, wires all eleven `HOOK_MODULES` names. Each runs `uvx --from "hypermnesia-mcp[postgresql,sqlite]" hypermnesia-mcp-hook <module>`, the console script from #605, so the package still ships only its own directory, never this repository and never `scripts/launcher.py`, which is Claude-only. - Event names are Codex's own (learn.chatgpt.com/docs/hooks, read 2026-09-22). Codex has no `Notification` event, so `compaction_checkpoint` is wired on `PreCompact`, which is where a checkpoint taken before compaction belongs. `SessionEnd` takes Codex's documented 3-second maximum rather than the Claude manifest's 30; `session_lifecycle` survives that ceiling because it spawns its consolidation detached (#610). No other hook sets `timeout`: Codex's own 600s default applies, and this repository has no measurement of the `uvx` hook path that would justify a tighter number. - Matchers carry Codex's native tool names alongside Claude's, so the translations in `host_event.py` (#608) receive the events they translate: `apply_patch` on every Edit/Write-triggered hook, `exec_command` and `shell_command` on the Bash-triggered one. - Each command guards only what can actually fail at that command line: `uvx` on PATH. There is no `CLAUDE_PLUGIN_ROOT` to check (nothing resolves against the plugin root) and no interpreter to locate. The contract test now imports `HOOK_MODULES` instead of restating the eleven names, so a module added to or dropped from the allowlist cannot silently leave the Codex package behind the Claude one. CI's clean-cache host-contract run moves from `--profiles lean` to `--profiles full`, and the stale "exactly ten lean tools / 103.84s" claim is replaced by a re-measurement of the shipped full-profile command: 59 tools in 120.17s from a clean uv cache, 4.24s warm, macOS 26.6.2 arm64, uv 0.11.3, 2026-09-22. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * test(codex): split the hook-manifest contracts into their own file The craftsmanship gate failed in CI: test_codex_plugin_contract.py had grown to 323 lines, over the 300-line cap. I missed it locally because the gate scopes itself to `git diff --name-only --diff-filter=ACMR <base>...HEAD`, which only sees committed work. I ran it while the change was still in the working tree, so the target-file list was empty and the gate had nothing to scan. Running it after committing reproduces the CI failure exactly. This is not a difference between the local and CI comparison points; both resolve the same base ref. The five hook-manifest contracts move to a sibling test_codex_plugin_hooks_contract.py, which asserts hooks/hooks.json, while the original keeps the package identity, the marketplace entries and the MCP server command. No assertion was trimmed to fit the cap. The event-name test splits into one for the event vocabulary and one for the matchers, and gains an assertion that PostCompact stays unwired, so the count goes from 13 to 14. Shared traversal moves into `_every_hook` and `_dispatched` helpers rather than being repeated in each test. 214 and 153 lines, both under the cap. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * fix(ci): never bind a released artifact's surface to this checkout's bounds The mcp-host-config job failed with `ModuleNotFoundError: No module named 'mcp'`. Moving its codex-cli case to `--profiles full` in d39a92b made it call `full_tool_bounds()`, which reads the registries and pulls the MCP SDK that job deliberately never installs (ADR-1077). Installing the SDK there would be the wrong fix, because the call was wrong for a second reason that has nothing to do with dependencies. The bounds come from THIS checkout's registries, while that job's command is `uvx --from "hypermnesia-mcp[postgresql,sqlite]" ...`, which resolves the published release. A PR that adds a tool moves this checkout's bounds while the published wheel still advertises the old count, so the job would have failed on a correct change. That is the same defect ADR-1077 was written about, pointed the other way: a second source of truth waiting to disagree with the first. `--published-surface` marks a case whose command resolves a released artifact. It keeps every other full-profile check (the 'full' profile marker in the initialize instructions, a non-empty tools/list, memory_stats present and callable, the empty-resources and session_recall shims) and drops only the count bound, the one assertion that cannot be true of a foreign artifact. Keeping the call unreachable is also what preserves ADR-1077's SDK-less invariant for that job, so no dependency is added anywhere. Tool-count coverage for codex-cli at the full profile is unaffected. It lives in the two `python scripts/verify_mcp_hosts.py -- hypermnesia-mcp` calls in the test and release-deps jobs (ci.yml:271 and :553), which pass no --clients or --profiles and so exercise all three clients across both profiles against this checkout's installed entry point, in jobs that do install the SDK. ADR-1077's guard was too weak: it checked only that the script stays IMPORTABLE without the SDK, which never protected the call. The new `test_the_sdk_less_job_never_binds_a_release_to_this_checkouts_bounds` reads the job out of ci.yml and fails if a full-profile case there omits the flag. Verified to fail when the flag is removed. ADR-1077 carries a dated revision recording all of this, so the "verifies lean profiles only" claim is no longer left contradicting the code; the generated mirror is regenerated from the canonical wiki page. Reproduced both directions locally against a stub stdio server with `mcp` blocked by the same meta_path blocker tests_py/test_tool_surface.py uses: without the flag, `ImportError: mcp`, exit 1; with it, `PASS codex-cli/full: initialize + discovery + memory_stats (59 tools)`, exit 0. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * docs(codex): the shipped prose describes the plugin that ships An independent review found three docs still describing the reduced Codex design this PR removed. All three verified at the PR head before fixing. plugins/hypermnesia-mcp-codex/SECURITY.md was the serious one: it told a reviewer the package runs "the read-only or idempotent-write `lean` tool profile" and "installs no hooks", both false. It now discloses what the package can actually do: the full profile advertises and accepts `forget` and the wiki_purge/wiki_migrate class, and eleven hooks run, of which decision_gate reads patched files' contents and can block a tool call, post_tool_capture persists tool output by default, session_lifecycle spawns a detached subprocess, and post_commit_reindex may reindex the repository. It also says how to opt back down: --profile lean, or drop the hooks reference. README.md said "a 10-tool lean surface" 37 lines after the table in the same file already said "all 57 tools" for the same plugin. docs/shared-host-memory.md said the bundled plugin keeps a ten-tool lean profile and cannot author an ADR. It now separates the bundled plugin (full, can author) from a --profile lean process (cannot), which is what the test it cites actually exercises. The neighbouring claim that the hosts lack equivalent lifecycle hooks was stale the same way and is corrected. Two new guards, both mutation-checked by reintroducing each original sentence and confirming a failure: - test_codex_prose_does_not_contradict_the_shipped_manifests reads the manifests and fails if the prose claims no hooks while plugin.json declares them, or a ten-tool surface while .mcp.json passes no --profile. Conditioned on the manifests, so reverting the design relaxes the guard rather than stranding it. It bans the drifted wordings, not the words, so the docs can still explain --profile lean as an opt-in. - test_codex_security_doc_discloses_what_the_package_can_do fails if the security doc stops naming the destructive tools or the two hooks with effects beyond writing to the memory store. scripts/check_doc_claims.py could not have caught any of this, measured rather than assumed. Every one of its five patterns matches a NUMBER beside a keyword (TOOL_CLAIM wants "N memory|standalone|MCP tools"), and none matched any of the three sentences, README.md included, which that gate already scans. "installs no hooks" has no number at all; "10-tool" and "ten-tool" never take the keyword form. Separately, four of the five relevant files are not in SCANNED_FILES. Widening that tuple alone would have caught nothing and bought false confidence, so the gate is left alone and the gap is reported. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * fix(codex): restore the checks the last two rounds removed or never set Five findings from an independent review of the final state. All five verified against the PR head first; all five fixes mutation-checked. 1. --published-surface took the only tool-surface check with it. Dropping the checkout-derived bounds was right; leaving nothing but a memory_stats presence test was not, since a packaging regression that dropped whole registries from the published wheel would still have passed. A released full profile is a superset of that release's own lean profile, so LEAN_TOOL_NAMES is a floor that holds across versions. It needs no MCP SDK, so the ADR-1077 invariant is untouched. Confirmed against the real published wheel: PASS codex-cli/full (59 tools, 12.14s). 2. The denylist lost in the file split is back. The package's own SECURITY.md promises no skills, apps or agents and no installer, which is testable: test_codex_plugin_ships_an_mcp_server_and_hooks_and_nothing_else asserts it, with `hooks` moved from the denied set to the required one. 3. Ten of eleven hooks had no timeout and took Codex's 600-second default, against Claude's 5 to 30 on the same hooks. A latency budget is part of behaviour, so this PR's parity claim did not hold. Codex documents a special default and maximum only for SessionEnd and Interrupt, so every other budget is settable and is now the Claude manifest's own number. auto_recall on UserPromptSubmit is the sharp case: 5 seconds, where 600 would have let a stalled uvx resolve hold up every prompt for ten minutes. decision_gate and no_deps_gate still declare none, because the Claude manifest declares none for them either and both hosts default a command hook to 600 seconds; declaring nothing on both sides is the parity case. A cold cache will exceed these budgets, which costs one skipped enrichment: both hosts cancel a timed-out hook and discard its output, and Claude Code's reference is explicit that a timed-out PreToolUse hook "doesn't block the tool call". The test now derives every expected value from the Claude manifest instead of restating literals. 4. The ci.yml regression guard checked for --published-surface anywhere in the mcp-host-config job body, so a second --profiles full case added later would have been covered by the first one's flag. It now scopes to each invocation. Mutation-checked with exactly that shape: a second unflagged full-profile case, which the old check passed and the new one catches. 5. --published-surface was a blanket flag threaded into every case of a run. Rather than document the currently-safe scope, mcp_host_cli.check_selection refuses it outside a single client and single profile, following the --command-includes-profile precedent in the same file. It cannot widen silently now. scripts/verify_mcp_hosts.py had one line of headroom under the 300-line cap, so the CLI surface moves to scripts/mcp_host_cli.py: defining flags is a separate concern from driving the contract, nothing outside imported those four helpers, and full_tool_bounds() stays put so ADR-1077 and tests_py/test_tool_surface.py need no churn. 235 and 108 lines. Mutation results, each reverted after: CAUGHT 1 published full surface missing the lean names CAUGHT 2 plugin.json regrows an `agents` key CAUGHT 3 auto_recall loses its 5s budget CAUGHT 3b SessionEnd set to 30s (over the Codex maximum) CAUGHT 4 second --profiles full case, unflagged CAUGHT 5 --published-surface across two clients Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * fix(codex): a guard that matched nothing, and a hook count in three docs Round-4 review findings. Both confirmed ones reproduced before fixing. 1. The prose-drift guard's own regex was broken. `\b` after "tool" never matches the plural, because the `s` in "tools" is a word character, so LEAN_SURFACE_CLAIM matched only the singular forms: MISSED 'The lean profile is exactly these ten tools:' MISSED '10 tools' MISSED 'ten tools' MATCH 'a 10-tool lean surface' MATCH 'its ten-tool `lean` profile' The missed line is the exact historical wording the guard was written to catch. `tools?\b` now covers both, and a new test asserts every wording on both patterns, plus three benign lines this package really ships so the guard cannot become a tripwire on its own documentation. 2. README claimed "9 lifecycle hooks" in the banner and "nine" in prose while this PR put "eleven" in the same file, and docs/ROADMAP.md said 9 too. HOOK_MODULES has had 11 entries since #605, so this predates the PR but the PR made it a self-contradiction. Fixed in all three places, written as digits so the doc-claim gate can see them, and the gate gains a `lifecycle hooks` family counted from the allowlist rather than declared. That is the general fix: it covers both files and any future one, instead of pinning the banner line alone. 3. `--published-surface` paired with `--profiles lean` validated, and would have compared a released artifact against this checkout's LEAN_TOOL_NAMES: the same checkout-versus-release coupling ADR-1077's revision removed for full. check_selection now refuses the pairing. 5. Disclosed the per-edit cost, which neither docs/codex-plugin.md nor the package's SECURITY.md mentioned. One edit fires up to five `uvx` hook processes (two before the call, three after); a patch touching several files still costs five, since host_dispatch runs the module once per derived event inside one process. Measured 2026-09-22 (macOS 26.6.2 arm64, uv 0.11.3, warm cache, published 4.23.1): 0.18s to 0.21s per process in steady state, first invocation of a module 1.3s to 5.1s while its caches fill, roughly 0.4s of the total in the blocking path. 6, 7, 8. Shared helpers: tests_py/scripts/_codex_plugin_support.py now holds the paths, the JSON reader and one `hook_index` scan, following the `_craftsmanship_support.py` precedent in the same package. That removes the three independent module-name scans (`_claude_timeouts`, `_dispatched` and an inline `_matcher_for`) and the duplicated `_json`. The duplicated `PLUGIN_ROOT / "scripts"` assertion is now made once, where it belongs. test_cross_host_manifests.py keeps its own `_json`: different signature, different module, and folding it in would widen this change for nothing. The new pattern test pushed test_codex_plugin_contract.py to 318 lines, so the prose guards move to test_codex_plugin_docs_contract.py. They assert the docs; what stays asserts the manifests. 225 and 115 lines. 4 was already reported as a disclosed trade-off and is unchanged: the published floor proves a superset of the lean names, not an upper bound. I found no way to tighten it that does not reintroduce the coupling finding 3 just closed. While verifying, the "57 tools" drift I had been carrying since round 1 turned out not to be drift: `standalone_tool_names()` is 57 and UPSTREAM_TOOL_NAMES adds 3, so the live 59 I measured sits inside the 57..60 bounds. The docs are right and the item is withdrawn. Mutation results, each reverted after: CAUGHT 1 SECURITY.md regains 'these ten tools:' (plural) CAUGHT 2 README banner back to '9 lifecycle hooks' CAUGHT 2b ROADMAP back to '9 lifecycle hooks' CAUGHT 3 --published-surface with --profiles lean Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * fix(ci): read the hook allowlist as text, never import it The Lint job died on `ModuleNotFoundError: No module named 'mcp_server'` and gated out the whole pipeline. My fault, from the previous commit. I verified the diagnosis against the code rather than accepting it. The Lint job's only setup is `pip install --no-deps --require-hashes -r requirements/lint.txt`, and that file resolves to ruff alone: no editable install, no PYTHONPATH. `python scripts/check_doc_claims.py` puts `scripts/` on sys.path rather than the repository root, so `mcp_server` is not importable there at all. The ADR-1077 precedent I cited does not transfer, and that was the error. `full_tool_bounds()` is reached only from one case in `mcp-host-config`, which the previous commit made sure never calls it without the SDK. `canonical_hook_count()` is called unconditionally from `collect_failures`, so a late import buys nothing: the call always happens. Every sibling in doc_claim_sources.py takes `read_fn` and parses text; `canonical_hook_count` was the only one importing a module. It now reads `mcp_server/hooks/entry.py` as source and evaluates the HOOK_MODULES frozenset literal with `ast` (stdlib, no dependency). The source of truth is still the allowlist, and there is still no second copy of the number. Reproduced the job's constraint rather than trusting the dev environment: a fresh venv with only requirements/lint.txt installed, run with no PYTHONPATH from the repository root. That environment confirms `mcp_server` is unimportable as the gate sees it, and all three Lint steps now pass in it: ruff format --check . -> 3404 files already formatted, exit 0 ruff check . -> All checks passed!, exit 0 python scripts/check_doc_claims.py -> doc claims OK, exit 0 The repro is faithful, not merely green: putting the importing version back into that same venv reproduces the CI failure exactly, `ModuleNotFoundError: No module named 'mcp_server'`, exit 1. New LintJobEnvironmentTests pins it. One test blocks `mcp_server` with the same meta_path blocker tests_py/test_tool_surface.py uses for the SDK and runs `collect_failures` with `scripts/` on sys.path; the other checks the text parse still equals `len(HOOK_MODULES)`, so reading text did not trade correctness for portability. Mutation-checked: restoring the importing version fails the first test. The unit fixture gains its own `mcp_server/hooks/entry.py` stand-in with a four-name allowlist, the way it already carries CATALOGUE, PINNED_TEST and BIBLIOGRAPHY for the other families. Four rather than the live eleven so that adding a hook to Cortex does not edit this file. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * fix(codex): guard the claims that were only asserted in prose Round-6 review findings, all verified before fixing. A tautological assertion is replaced. `HOOKS_PATH.is_relative_to(PLUGIN_ROOT)` could never fail: the support module defines HOOKS_PATH AS `PLUGIN_ROOT / "hooks/hooks.json"`, so it tested a constant, not the ignore file it sat beside. The test now walks every file the Codex package ships and asserts each is covered by a directory entry in .mcpbignore, and separately that no `!` line re-includes one. Mutation-checked with a re-include, which the three neighbouring membership assertions all pass and only this catches. The doc-claim gate now scans the Codex package's own README and SECURITY.md and the two repository docs that describe it. Probed first: no claim pattern matches anything in those four files today, so this adds coverage without changing a verdict. The hook-count family only ever saw digits, and most claims were spelled out. Every claim family in this gate is digit-only by convention, so the seven remaining "eleven" occurrences become "11" rather than teaching one family to read number words. The gate went from seeing 2 hook-count claims to 10, across 6 files. `session_lifecycle` does NOT reliably survive Codex's 3-second SessionEnd ceiling, and the document said it did while also saying a cold cache could lose the run. Measured, warm uv cache, the manifest's own command against a SessionEnd event: 3.46s, 1.03s, 0.97s. The first run exceeds the maximum. Detaching consolidation (#610) makes the remaining work small enough to attempt, not small enough to guarantee. Recorded as a named limitation with the numbers rather than reconciled in prose: it costs the session record of the first session after an install or upgrade, and prewarming does not help because the measurement is already warm. It needs a decision, not an edit, and the report to the owner says so. decision_gate and no_deps_gate keep no timeout, and the reasoning is now written down instead of implied. A timeout on a gate fails OPEN: Claude Code's reference says a timed-out PreToolUse hook "doesn't block the tool call ... so don't count on a stalled hook to act as a gate." A tight ceiling would trade a rare long wait for silently ungated edits. Warm, these cost 0.16s to 0.26s; the long wait is only the first cold resolve. The same exposure exists on Claude Code today, so it belongs to both manifests, and narrowing it on one host would break the parity a test now enforces. preemptive_context's `Read` matcher has no Codex equivalent, and that is a host limitation rather than a wiring gap. Codex's tool table has no read tool: shell commands and unified exec both match as `Bash`. Reading a file there is a shell command, and preemptive_context keys on `tool_input.file_path`, which a shell event does not carry, so matching `Bash` would spawn a uvx process per shell command for a guaranteed no-op. Documented as a one-sided gap; the edit-triggered half still fires through apply_patch. The duplicated uvx guard string across all 11 entries is left as acknowledged debt with a note naming the test that keeps the copies honest. Generating both manifests from one template is the real fix and is out of scope here. Mutation results, each reverted after: CAUGHT .mcpbignore re-includes the hooks directory CAUGHT .mcpbignore loses the plugin directory entry CAUGHT codex README gains '9 lifecycle hooks' CAUGHT docs/codex-plugin.md gains '9 lifecycle hooks' Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH * docs: qualify Codex hook behavior claims (#620) Signed-off-by: cdeust <[email protected]> * docs: clarify remaining Codex parity claims (#620) Signed-off-by: cdeust <[email protected]> --------- Signed-off-by: cdeust <[email protected]> Co-authored-by: Claude Sonnet 5 <[email protected]>
Summary
PR 2 of the "Cortex behaves the same under Codex as under Claude Code"
wave. PR 1 (#605) added
mcp_server/hooks/entry.py, a console entry pointthat runs one of the eleven lifecycle hook modules from the wheel, but
dispatched whatever stdin carried unchanged. This PR normalises a Codex
hook event into the Claude-shaped event the modules already read, then
runs the module once per derived event.
mcp_server/hooks/host_patch.py: a fail-closedapply_patchparser,ported from the same owner's zetetic-team-subagents
hooks/lib/host_events.py(commit 4c2bc9b, PR #139, MIT licensed).Produces an Edit/Write-shaped event per file operation; refuses
ambiguous or missing preimage context rather than approving content
different from the prospective edit.
mcp_server/hooks/host_event.py:normalize_event(event)dispatchesClaude events through unchanged, expands
apply_patchviahost_patch, turnsexec_command/shell_commandintoBashwithworkdirresolved againstcwd, and mapsSubagentStart'sagent_typetoagent_name.mcp_server/hooks/entry.py: reads stdin once, normalises it, setsCLAUDE_PROJECT_ROOTfrom the event'scwdonly when unset, and runsthe hook module once per derived event. For a
PreToolUseevent thefirst non-zero exit wins and stops the loop.
Test plan
tests_py/hooks/test_host_patch.py(ported zetetic fixtures: AddFile, Update with Move to, Delete File, End of File, non-exact and
ambiguous preimage refusal)
tests_py/hooks/test_host_event.py(Claude pass-through pins,shell workdir resolution, SubagentStart mapping,
PreCompact/PostCompact pass-through, apply_patch dispatch)
tests_py/hooks/test_entry.pyadditions:apply_patchend to endthrough
preemptive_context/pipeline_impact_bumpmatching theequivalent Claude Edit event's stdout;
decision_gateblocking anapply_patchAdd File the same way it blocks the equivalent ClaudeWrite; malformed patch exits 2 on PreToolUse, 1 with a stderr line
on PostToolUse
uvx [email protected] check/format --check: cleanpython scripts/check_craftsmanship.py --base origin/main: OKpython scripts/check_doc_claims.py: OKpytest -q: 8839 passed, 8 skipped, 459 subtests passed in 387.17s🤖 Generated with Claude Code
https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH