Skip to content

feat(hooks): a console entry point runs any lifecycle hook from the wheel - #605

Merged
cdeust merged 2 commits into
mainfrom
feat/codex-hook-entry
Sep 17, 2026
Merged

cdeust merged 2 commits into
mainfrom
feat/codex-hook-entry

Conversation

@cdeust

@cdeust cdeust commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

A Codex plugin ships only its own directory, so it cannot reach this
repository's scripts/launcher.py the way the Claude Code plugin does.
mcp_server/hooks/entry.py, backed by the new hypermnesia-mcp-hook console
script, validates its module argument against the eleven hook names
.claude-plugin/plugin.json wires, resolves the storage backend the same
way mcp_server/main.py does for the server, and dispatches to the
named hook's own main unchanged. A Codex plugin can then call
uvx --from "hypermnesia-mcp[postgresql,sqlite]" hypermnesia-mcp-hook
directly. This changes nothing for the Claude Code plugin, which
keeps using scripts/launcher.py.

Test plan

  • tests_py/hooks/test_entry.py: parity between mcp_server.hooks.entry
    and scripts/launcher.py for every allowlisted module on a benign event
    (both exit code and stdout compared)
  • tests_py/hooks/test_entry.py: refusal of a module outside the
    allowlist exits 2 with empty stdout
  • tests_py/hooks/test_entry.py: prepare_environment backend
    resolution, with and without the persisted marker
  • tests_py/hooks/test_entry.py: pyproject.toml declares the script
  • ruff check && ruff format --check
  • pyright mcp_server/hooks/entry.py: 0 errors
  • scripts/check_craftsmanship.py --base origin/main: OK
  • scripts/check_doc_claims.py: OK
  • pytest tests_py/hooks tests_py/test_main.py: 495 passed, tool count
    unchanged

🤖 Generated with Claude Code

https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH

cdeust added a commit that referenced this pull request Sep 17, 2026
…heel (#605)

A Codex plugin ships only its own directory, so it cannot reach this
repository's scripts/launcher.py the way the Claude Code plugin does.
mcp_server/hooks/entry.py, backed by the new hypermnesia-mcp-hook console
script, validates its module argument against the ten hook names
.claude-plugin/plugin.json wires, resolves the storage backend the same
way mcp_server/__main__.py does for the server, and dispatches to the
named hook's own __main__ unchanged, so its headless guard and
composition-root wiring keep running exactly as under scripts/launcher.py.
A Codex plugin can then call uvx --from "hypermnesia-mcp[postgresql,sqlite]"
hypermnesia-mcp-hook <module> directly.

Parity tests run every allowlisted module against both entry points on a
benign event and assert equal exit codes and stdout.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
@cdeust
cdeust force-pushed the feat/codex-hook-entry branch from 701af82 to e193482 Compare September 17, 2026 09:08
…heel (#605)

A Codex plugin ships only its own directory, so it cannot reach this
repository's scripts/launcher.py the way the Claude Code plugin does.
mcp_server/hooks/entry.py, backed by the new hypermnesia-mcp-hook console
script, validates its module argument against the ten hook names
.claude-plugin/plugin.json wires, resolves the storage backend the same
way mcp_server/__main__.py does for the server, and dispatches to the
named hook's own __main__ unchanged, so its headless guard and
composition-root wiring keep running exactly as under scripts/launcher.py.
A Codex plugin can then call uvx --from "hypermnesia-mcp[postgresql,sqlite]"
hypermnesia-mcp-hook <module> directly.

Parity tests run every allowlisted module against both entry points on a
benign event and assert equal exit codes and stdout.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
@cdeust
cdeust force-pushed the feat/codex-hook-entry branch from e193482 to 1a7e334 Compare September 17, 2026 09:09
@cdeust

cdeust commented Sep 17, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: REQUEST_CHANGES

Verified against the PR 1 specification (console entry point running any lifecycle hook from the wheel), read-only, in a worktree at .claude/worktrees/advisor-605 on head 1a7e334, with the repository .venv. Rules version 1.0.0, plugin build 2.41.0 (plugin-version-check.sh --rules-version / --version).

CI is red on five of six test jobs

$ gh pr checks 605 --repo cdeust/Cortex --watch
Test (Python 3.10)             fail   1m56s
Test (Python 3.11)             fail   9m40s
Test (Python 3.12)             fail   9m48s
Test (Python 3.13)             fail   6m3s
Test (SQLite backend)          fail   6m22s
Test (Windows, SQLite backend) pass   5m44s

Two separate causes.

Python 3.10 dies at collection because tomllib only exists from 3.11 and requires-python = ">=3.10":

________________ ERROR collecting tests_py/hooks/test_entry.py _________________
tests_py/hooks/test_entry.py:22: in <module>
    import tomllib
E   ModuleNotFoundError: No module named 'tomllib'
1 error in 15.83s

The other four fail one existing guard, tests_py/scripts/test_composition_root_wiring_coverage.py, which scans every mcp_server/hooks/*.py with a __main__ block for a wire_composition_root() call:

FAILED tests_py/scripts/test_composition_root_wiring_coverage.py::test_every_hook_with_a_main_block_wires_the_composition_root
E   AssertionError: hook entry points missing wire_composition_root(): [PosixPath('/home/runner/work/Cortex/Cortex/mcp_server/hooks/entry.py')]
1 failed, 8786 passed, 20 skipped, 2 warnings, 459 subtests passed in 271.38s   (3.13)
1 failed, 8496 passed, 141 skipped, 3 warnings, 459 subtests passed in 301.49s  (SQLite)

This is not only a guard to satisfy. scripts/launcher.py:151-155 calls wire_composition_root() before run_module for every target, citing issue #560, and the module docstring of entry.py claims parity with that launcher. entry.py:main skips the step. Today it is masked because all eleven allowlisted hooks re-wire inside their own __main__ block (grep -n wire_composition_root mcp_server/hooks/*.py), and wiring.py:6 documents the call as idempotent, so the fix is one call in entry.py:main between prepare_environment and runpy.run_module, mirroring the launcher order. Exempting entry.py in the test would be the wrong remedy.

Required changes

  1. mcp_server/hooks/entry.py:main: call wire_composition_root() after prepare_environment(os.environ) and before runpy.run_module(...), with the # source: issue #560 marker the other hooks carry.
  2. tests_py/hooks/test_entry.py:22: drop the bare import tomllib. Either fall back to tomli below 3.11 (already resolved in uv.lock for python_full_version < '3.11'), or assert on the raw pyproject.toml text, which needs no parser at all.
  3. CHANGELOG.md (the new entry), entry.py:29 and the test_entry.py docstring all say "ten hook names" / "ten hook modules". The allowlist, .claude-plugin/plugin.json and the parametrized test all have eleven. Correct the three doc claims to eleven; the number is the claim the allowlist is verified against.
  4. Run the full pytest before re-requesting, not tests_py/hooks alone. The wiring guard lives in tests_py/scripts/ and would have caught 1 locally.

What passed, with the numbers

$ .venv/bin/python -m pytest tests_py/hooks/test_entry.py -q
15 passed in 192.95s
$ .venv/bin/python -m pytest tests_py/hooks tests_py/test_main.py -q
495 passed, 2 warnings, 13 subtests passed in 57.09s
$ uvx [email protected] check / format --check  (entry.py, test_entry.py)
All checks passed!  /  2 files already formatted
$ .venv/bin/pyright mcp_server/hooks/entry.py tests_py/hooks/test_entry.py
0 errors, 0 warnings, 0 informations
$ .venv/bin/python scripts/check_craftsmanship.py --base origin/main
Craftsmanship gate: OK
longest run of consecutive comment lines: entry.py 2, test_entry.py 0 (gate is 8)

Allowlist versus manifest: the eleven names in HOOK_MODULES equal the eleven distinct mcp_server.hooks.<name> targets in .claude-plugin/plugin.json, set-equal, checked by script.

Parity on non-benign events, sqlite backend, isolated HOME and CORTEX_CLAUDE_DIR, CLAUDE_PLUGIN_ROOT at the worktree, a fresh git repository as cwd, both hooks through python -m mcp_server.hooks.entry <m> and python scripts/launcher.py mcp_server.hooks.<m>:

Bash {"command": "git commit -m x"}   post_commit_reindex  entry rc=0 stdout 0B | launcher rc=0 stdout 0B   IDENTICAL
Bash {"command": "git commit -m x"}   post_tool_capture    entry rc=0 stdout 0B | launcher rc=0 stdout 0B   IDENTICAL (stderr identical too)
Read <real file>                      post_commit_reindex  entry rc=0 stdout 0B | launcher rc=0 stdout 0B   IDENTICAL
Read <real file>                      post_tool_capture    entry rc=0 stdout 0B | launcher rc=0 stdout 0B   IDENTICAL

The single stderr difference is a timing figure (capture skipped after 0.001s versus 0.005s, both OSError: AF_UNIX path too long from the scratchpad path length), not behaviour.

The wheel contract, installed with uv pip install "hypermnesia-mcp[sqlite] @ file://<worktree>" into a throwaway venv (no psycopg on purpose), run through the generated bin/hypermnesia-mcp-hook, env -i, sqlite backend, a SessionStart event on stdin:

$ hypermnesia-mcp-hook session_start < event.json ; echo rc=$?
## Cortex — First Run

Cortex is set up and ready. No previous sessions found.

Start working normally — Cortex will automatically remember important decisions, fixes, and patterns as you go.
rc=0
stderr: [session-start-hook] PostgreSQL connect failed: No module named 'psycopg'
        [session-start-hook] background consolidate spawned → .../claude/methodology/consolidate.log
        [session-start-hook] groomer coordinator: started
        [session-start-hook] Empty SQLite store, 0 session files found

$ hypermnesia-mcp-hook nope < event.json ; echo rc=$?
Usage: hypermnesia-mcp-hook <module>
Allowed modules: agent_briefing, auto_recall, compaction_checkpoint, decision_gate, no_deps_gate, pipeline_impact_bump, post_commit_reindex, post_tool_capture, preemptive_context, session_lifecycle, session_start
rc=2

$ hypermnesia-mcp-hook < /dev/null ; echo rc=$?
(same usage text)
rc=2

The PostgreSQL connect failed line with the backend set to sqlite comes from session_start itself, not from the entry point, and is outside this PR.

prepare_environment against launcher.py:135-139: same call to apply_backend_resolution, same "DATABASE_URL" not in environ and backend != "sqlite" predicate, same postgresql://localhost:5432/cortex default, nothing else set. The launcher wraps the resolution in a try/except; backend_marker.py:53-62 already returns None on OSError, JSONDecodeError and UnicodeDecodeError, so the unguarded call in entry.py is equivalent for the failures that exist.

Diff read: runpy.run_module(..., alter_sys=True) after sys.argv = [module] gives the hook argv[0] == "mcp_server.hooks.<name>", the same as the launcher; the launcher forwards argv[2:], entry.py drops it, and none of the eleven allowlisted hooks read sys.argv (only consolidate_background and ingest_codebase_background do, and they are not in the allowlist). entry.py never touches stdin before dispatch, so each hook reads its event exactly once; the parity runs above confirm it. Exception mapping is the launcher's: SystemExit re-raised, any other exception reported to stderr and exit 1.

Once the four changes land and every Test job is green, the measured behaviour above already supports approval; the fix to entry.py is one line and the launcher already shows where it goes.

…605)

CI on PR #605 found two gaps the parity-only test subset masked. First,
tests_py/scripts/test_composition_root_wiring_coverage.py::
test_every_hook_with_a_main_block_wires_the_composition_root failed:
scripts/launcher.py calls wire_composition_root() before run_module
(issue number 560), and entry.py skipped that call, relying only on
each hook's own __main__ block to re-wire, which the static guard does
not accept as equivalent. entry.py:main now wires the composition root
itself between prepare_environment and runpy.run_module, the same seam
scripts/launcher.py uses.

Second, tests_py/hooks/test_entry.py imported tomllib unconditionally,
which does not exist on Python 3.10 (requires-python is >=3.10); the
test now falls back to the tomli backport on 3.10, the same package
build/hatchling already pull in for that Python version per uv.lock.

Also corrected "ten hook modules" to "eleven" in entry.py, the test
docstring and the CHANGELOG entry; the allowlist has eleven names.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
@cdeust

cdeust commented Sep 17, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: APPROVE

Round 2 at head 86eeaf8e. This supersedes my round-1 REQUEST_CHANGES (#605 (comment)); the two findings raised there are fixed and measured below. Review was read-only from a detached worktree at origin/feat/codex-hook-entry.

What round 1 asked for, and what 86eeaf8 does

git diff 1a7e3349 86eeaf8e touches four files and nothing else.

  1. CI red on 3.11+/SQLite because entry.py skipped wire_composition_root(). The call now sits exactly where scripts/launcher.py:153-155 puts it, after prepare_environment(os.environ) and before runpy.run_module:
     prepare_environment(os.environ)

+    from mcp_server.hooks.wiring import wire_composition_root  # noqa: PLC0415 - issue number 560, same seam as scripts/launcher.py:153
+
+    wire_composition_root()
+
     module = f"mcp_server.hooks.{name}"

The ordering matters and is right: the backend is resolved into os.environ before the seams are wired, so a wired factory reads a settled CORTEX_MEMORY_STORE_BACKEND. The line citations in the docstring are exact, verified against scripts/launcher.py lines 135-139, 150-155 and 159-167.

  1. CI red on 3.10 because the test did a bare import tomllib. It is now version-gated with a tomli fallback. That fallback is not a new dependency: uv.lock has pytest requiring tomli under marker = "python_full_version < '3.11'", so it is present in any environment that can run the test at all.

The miscount is also gone: "ten" reads "eleven" in CHANGELOG.md, in entry.py's module docstring and allowlist comment, and in the test docstring.

Measurements

Tests the round-1 failure named, run with the repo venv:

$ .venv/bin/python -m pytest tests_py/scripts/test_composition_root_wiring_coverage.py tests_py/hooks/test_entry.py -q -p no:cacheprovider
21 passed in 203.63s (0:03:23)

The wire call is pinned, not merely present. test_every_hook_with_a_main_block_wires_the_composition_root globs mcp_server/hooks/*.py, keeps every file with a real if __name__ == "__main__": block, and asserts a genuine wire_composition_root() call (its regex rejects a mention inside a comment or docstring). entry.py has that block, so deleting the three added lines fails that test. The round-2 fix would not survive being quietly reverted.

Allowlist against the manifest, parsed rather than eyeballed, HOOK_MODULES read from the AST of entry.py and the module names read from .claude-plugin/plugin.json:

11 ['agent_briefing', 'auto_recall', 'compaction_checkpoint', 'decision_gate', 'no_deps_gate', 'pipeline_impact_bump', 'post_commit_reindex', 'post_tool_capture', 'preemptive_context', 'session_lifecycle', 'session_start']
11 ['agent_briefing', 'auto_recall', 'compaction_checkpoint', 'decision_gate', 'no_deps_gate', 'pipeline_impact_bump', 'post_commit_reindex', 'post_tool_capture', 'preemptive_context', 'session_lifecycle', 'session_start']
SET-EQUAL: True
only in plugin.json: set()
only in entry.py: set()

The wheel contract, which is the whole point of the PR, was exercised from a throwaway venv under /private/tmp, with the package installed from the worktree, not from the repository checkout:

$ uv pip install --python <throwaway venv> "hypermnesia-mcp[sqlite] @ file://<worktree>"
$ ls <venv>/bin | grep hypermnesia
hypermnesia-mcp
hypermnesia-mcp-hook

With env -i, an isolated HOME and CORTEX_CLAUDE_DIR, CORTEX_MEMORY_STORE_BACKEND=sqlite, and a SessionStart event on stdin:

$ hypermnesia-mcp-hook session_start < event.json
session_start exit=0
## Cortex — First Run

Cortex is set up and ready. No previous sessions found.

Start working normally — Cortex will automatically remember important decisions, fixes, and patterns as you go.
(stdout: 196 bytes)

stderr carried PostgreSQL connect failed: No module named 'psycopg' from session_start.py:138. That is the pre-existing optional-dependency probe inside session_start, unchanged by this PR and present on origin/main, and prepare_environment correctly left DATABASE_URL unset on the SQLite backend. It is stderr only and the stdout contract is clean.

Refusal paths from the same installed script:

$ hypermnesia-mcp-hook nope
nope exit=2  stdout_bytes=0
Usage: hypermnesia-mcp-hook <module>
Allowed modules: agent_briefing, auto_recall, compaction_checkpoint, decision_gate, no_deps_gate, pipeline_impact_bump, post_commit_reindex, post_tool_capture, preemptive_context, session_lifecycle, session_start
$ hypermnesia-mcp-hook
noarg exit=2  stdout_bytes=0

Both refusals are fail-closed with an empty stdout, and the module string is built only after allowlist membership, so no argument can steer runpy at a module the manifest does not wire.

Parity on a non-benign event, post_commit_reindex fed a Bash git commit -m x PostToolUse event, once through python -m mcp_server.hooks.entry post_commit_reindex and once through python scripts/launcher.py mcp_server.hooks.post_commit_reindex, with CLAUDE_PLUGIN_ROOT at the worktree and a fresh isolated state per run:

entry exit=0
launcher exit=0
STDOUT IDENTICAL (0 bytes)

Repeated across four configurations (a fresh git init repo as cwd, the worktree as cwd, and a state directory pre-warmed by a session_start run), the exit codes matched and stdout was byte-identical every time. The parametrized parity test in tests_py/hooks/test_entry.py covers all eleven modules on a benign event and compares both exit code and stdout, which is the right assertion pair for a hook whose stdout is its protocol.

Gates:

$ .venv/bin/python scripts/check_craftsmanship.py --base origin/main
Craftsmanship gate: OK
$ uvx [email protected] check mcp_server/hooks/entry.py tests_py/hooks/test_entry.py
All checks passed!
$ uvx [email protected] format --check mcp_server/hooks/entry.py tests_py/hooks/test_entry.py
2 files already formatted
$ .venv/bin/python -m pyright mcp_server/hooks/entry.py tests_py/hooks/test_entry.py
0 errors, 0 warnings, 0 informations
$ .venv/bin/python scripts/check_doc_claims.py
doc claims OK (1 declared not-a-claim exemption(s))

Decision gate: the longest run of consecutive comment lines is 3 in entry.py (119 lines) and 3 in tests_py/hooks/test_entry.py (159 lines), well under the limit of 8.

gh pr checks 605 reports 24 passing and 4 skipping. Passing includes Test on Python 3.10 (9m10s), 3.11 (7m4s), 3.12 (8m46s), 3.13 (8m20s), Test (SQLite backend) (6m29s), Test (Windows, SQLite backend) (5m57s), Craftsmanship Gate, Lint, Type Check, Build Package, Docker Smoke and CI Green. The four skipping are the two Docker image builds, the scheduled fuzz batch, and the release tag job. The two jobs that were red at 1a7e3349, Test (Python 3.10) and Test (SQLite backend), are both green.

Layers and design

entry.py lives in mcp_server/hooks/, imports mcp_server.infrastructure.backend_marker at module scope and mcp_server.hooks.wiring at the dispatch point, and touches no core/ symbol itself. The craftsmanship gate's layer whitelist passes against origin/main. prepare_environment takes the environment mapping and an optional marker path as parameters rather than reading globals, which is why two of the four unit tests can assert the SQLite and PostgreSQL branches without a subprocess. The except SystemExit: raise before the broad catch is the right order and mirrors the launcher exactly, so a hook's own sys.exit(0) is not rewritten into an exit 1.

Non-blocking, for later

The eleven names now exist in three places: entry.py's HOOK_MODULES, the tuple at tests_py/hooks/test_entry.py:38, and .claude-plugin/plugin.json. They agree today, measured above, and nothing asserts they keep agreeing. A twelfth hook added to the manifest would leave the Codex path refusing it with exit 2 and a usage message, which is loud rather than silent, but the failure would land on a user rather than on CI. The check is four lines against the manifest parse the wiring-coverage test already performs, so folding it into tests_py/hooks/test_entry.py would close the drift that produced the "ten" in round 1.

The except Exception arm that reports a dispatch failure and exits 1 has no test. It mirrors scripts/launcher.py:165-167 and is unreachable from any hook that handles its own errors, so this is a note, not a request.

The PR description still says "the ten hook names" in its Summary. The shipped artifacts (CHANGELOG.md, the module docstring, the test docstring) all say eleven and are what a reader will see; merges here carry per-commit messages rather than the PR body, so nothing false reaches git history. Editing the Summary costs no commit and no CI run, and is worth doing before merge so the PR page stops contradicting the file it describes.

Approved at 86eeaf8e: 21 tests green locally in 203.63s, the allowlist set-equal to the manifest at 11 names, the wheel-installed hypermnesia-mcp-hook session_start exiting 0 with 196 bytes of first-run text on stdout, and exit 2 with an empty stdout for both an unknown module and a missing argument.

@cdeust
cdeust merged commit 84c0a3b into main Sep 17, 2026
28 checks passed
@cdeust
cdeust deleted the feat/codex-hook-entry branch September 17, 2026 11:47
cdeust added a commit that referenced this pull request Sep 17, 2026
…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
cdeust added a commit that referenced this pull request Sep 17, 2026
…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
cdeust added a commit that referenced this pull request Sep 17, 2026
…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
cdeust added a commit that referenced this pull request Sep 17, 2026
…s read (#608) (#608)

* feat(hooks): normalise Codex hook events into what the lifecycle hooks 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

* fix(hooks): source the envelope-length constant and split test_entry.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

* fix(hooks): kill two dispatch-loop mutants and restore the shell cwd 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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
@cdeust cdeust mentioned this pull request Sep 17, 2026
cdeust added a commit that referenced this pull request Sep 17, 2026
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
cdeust added a commit that referenced this pull request Sep 17, 2026
* 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]>
cdeust added a commit that referenced this pull request Sep 22, 2026
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
cdeust added a commit that referenced this pull request Sep 22, 2026
…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]>
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