Skip to content

feat(transcripts): read Codex rollouts, write the session log before consolidating - #610

Merged
cdeust merged 1 commit into
mainfrom
feat/codex-rollout-reader
Sep 17, 2026
Merged

cdeust merged 1 commit into
mainfrom
feat/codex-rollout-reader

Conversation

@cdeust

@cdeust cdeust commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Summary

PR 3 of the "Cortex behaves the same under Codex as under Claude Code" wave. Two independent fixes.

(A) mcp_server/infrastructure/transcript_activity.py read only Claude Code's {"type": "assistant", ...} transcript shape, so a Codex SessionEnd fed procedural mining nothing: a Codex rollout's tool calls are response_item records of payload type function_call or custom_tool_call, not assistant message content. transcript_activity now peeks a transcript's first record and dispatches to a new mcp_server/infrastructure/transcript_rollout.rollout_activity when it is a session_meta record; every other transcript, including one whose first line cannot be read or parsed, is read unchanged by the existing Claude reader (pinned against every existing Claude fixture). ADR-1081.

(B) mcp_server/hooks/session_lifecycle.py ran consolidation in-process after the session-log write, which cost nothing extra under Claude Code (SessionEnd has no enforced timeout there) but risked the hook under Codex, where SessionEnd has a 1 s soft / 3 s hard timeout well under a consolidation cycle's cost. Consolidation now runs in a subprocess this module re-invokes with --consolidate <mode>, spawned detached (Popen, start_new_session=True, not waited on) after the log write, at the same light/standard/full depth the turn count already gated it to. Deliberately not routed through the existing consolidate_background launcher: that module's cycle and TTL stamp are SessionStart's periodic sweep, a different concern whose stamp would get tangled with a per-session cycle's. ADR-1082.

Test plan

  • Gate proof (A): tests_py/infrastructure/test_transcript_rollout.py::test_a_rollout_yields_its_tool_sequence_and_turn_count fails against unmodified transcript_activity.py (tool_sequence == []), passes after the fix.
  • Gate proof (B): tests_py/hooks/test_session_lifecycle.py::TestSpawnConsolidation::test_the_session_log_row_exists_even_when_the_launcher_fails fails to collect against unmodified session_lifecycle.py (_consolidation_mode does not exist there), passes after the fix.
  • uvx [email protected] check . / format --check . — clean.
  • pyright on the changed files (repo .venv, pyright 1.1.411) — 0 errors, 0 warnings.
  • python scripts/check_craftsmanship.py --base origin/main — OK, net −2 violations vs origin/main (baseline regenerated, no new debt).
  • python scripts/check_doc_claims.py — OK.
  • python scripts/check_project_wiki.py — OK.
  • Full pytest -q: 8893 passed, 8 skipped, 459 subtests passed in 495.71s (0:08:15).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH

…consolidating (#610)

Two independent fixes for the "Cortex behaves the same under Codex as
under Claude Code" wave.

(A) infrastructure/transcript_activity.py read only Claude Code's
{"type": "assistant", ...} transcript shape, so a Codex SessionEnd fed
procedural mining nothing: a Codex rollout's tool calls are
response_item records of payload type function_call or
custom_tool_call, not assistant message content. transcript_activity
now peeks a transcript's first record and dispatches to a new
infrastructure/transcript_rollout.rollout_activity when it is a
session_meta record; every other transcript, including one whose
first line cannot be read or parsed, is read unchanged by the
existing Claude reader (pinned against every existing Claude fixture).
ADR-1081.

(B) hooks/session_lifecycle.py ran consolidation in-process after the
session-log write, which cost nothing extra under Claude Code
(SessionEnd has no enforced timeout there) but risked the hook under
Codex, where SessionEnd has a 1 s soft / 3 s hard timeout well under a
consolidation cycle's cost. Consolidation now runs in a subprocess
this module re-invokes with --consolidate <mode>, spawned detached
(Popen, start_new_session=True, not waited on) after the log write, at
the same light/standard/full depth the turn count already gated it
to. Deliberately not routed through the existing consolidate_background
launcher: that module's cycle and TTL stamp are SessionStart's
periodic sweep, a different concern whose stamp would get tangled with
a per-session cycle's. ADR-1082.

Full pytest: 8893 passed, 8 skipped, 459 subtests passed in 495.71s.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
@cdeust
cdeust force-pushed the feat/codex-rollout-reader branch from 8926f1e to 4c8e6c0 Compare September 17, 2026 14:07
@cdeust

cdeust commented Sep 17, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: APPROVE

Move 0, ledger reconciliation and seen-defect check

Every branch and degraded path in the diff is accounted for: the session_meta dispatch, the Claude-reader fallback (missing file, malformed first line, JSON-array-not-JSONL), the rollout reader's per-record skip cases, the spawn-failure non-fatal path, and the unrecognized-mode default to light. No rationalization phrase such as "pre-existing" or "unrelated" or "out of scope" appears in the PR body or the diff's own comments. Not refused.

Stakes

High. Touches a SessionEnd hook that runs after every session on both hosts, and the shared craftsmanship-baseline ratchet for the whole repo.

Baseline (.craftsmanship-baseline.json)

git diff origin/main -- .craftsmanship-baseline.json shows 2 entries removed, 0 added, only the generated date changed.

  • mcp_server/hooks/session_lifecycle.py, method-size, _run_consolidation: real fix. origin/main's _run_consolidation was one function around 42 lines, doing turn-gating, mode selection, asyncio.run, and logging all inline. It is gone from the diff entirely, replaced by three functions each under 40 lines: _consolidation_mode (13 lines), _spawn_consolidation (around 37 lines, builds argv and calls Popen), _run_consolidation_cycle (around 30 lines, the body that used to run inline). Confirmed no function in the new file exceeds 40 lines.
  • mcp_server/hooks/session_lifecycle.py, unsourced-constant, _LONG_SESSION_TURNS: real fix. origin/main has one # source: ADR-0497 comment, oddly doubled, directly above _SHORT_SESSION_TURNS only; _LONG_SESSION_TURNS sits below with no source comment of its own. The diff gives each constant its own # source: ADR-0497 line.

Zero additions confirmed. The baseline shrink is honest, not a regeneration that absorbs new debt.

Rollout reader against real files

Ran transcript_activity(path) read-only against the three most recent ~/.codex/sessions/2026/09/*/rollout-*.jsonl files.

File 1: tool_sequence length 246 (starting exec, exec, exec, exec, spawn_agent, ...), turn_count 39, matching an independently computed count of response_item records whose payload.type == "message" and payload.role == "assistant" (39) exactly. File 2: tool_sequence length 280 (exec, wait, ...), turn_count 58, independent count 58, match. File 3: tool_sequence length 0, turn_count 8, independent count 8, match.

Robustness: a copy of file 1 with its last line truncated mid-JSON plus a trailing blank line does not raise, and returns the same 246/39. An empty file and a missing path both return {"tool_sequence": [], "turn_count": 0} without raising.

Fixture versus real records, field by field: dumped the real rollout's first-seen record of every (type, payload.type, payload.role) shape and diffed against codex_rollout_excerpt.jsonl. session_meta, event_msg, response_item/message/developer, response_item/message/user, response_item/message/assistant, world_state, turn_context, response_item/custom_tool_call (name exec), response_item/custom_tool_call_output, response_item/reasoning, response_item/function_call (name e.g. send_message or spawn_agent), response_item/function_call_output, token_usage_record, inter_agent_communication_metadata, response_item/agent_message, compacted. Every shape the fixture models and every shape the docstring names as read-past is present in real data with the same field names.

Claude path unchanged

git diff origin/main -- mcp_server/infrastructure/transcript_activity.py shows the original reader's body (open, iterate, substring-check "assistant", json.loads, isinstance and type guards, tool-name extraction, cap, except OSError) is byte-identical, only relocated into _claude_transcript_activity under a new signature and docstring. tests_py/infrastructure/test_transcript_activity.py passes (21 of 21 combined with the new rollout tests). A whole-file-is-a-JSON-array transcript, where the parsed record is a list rather than a dict, still returns the empty result without raising: the isinstance(record, dict) guard flagged in the PR #592 review is present and unchanged.

SessionEnd end to end

Isolated HOME, CORTEX_CLAUDE_DIR, and, after one wrong turn, CORTEX_MEMORY_STORE_BACKEND=sqlite plus CORTEX_MEMORY_DB_PATH. The first attempt set a nonexistent CORTEX_DB_BACKEND=sqlite; the real knob is CORTEX_MEMORY_STORE_BACKEND under the CORTEX_MEMORY_ prefix, and the default is STORE_BACKEND="auto" with DATABASE_URL="postgresql://127.0.0.1:5432/cortex". That first run's spawned --consolidate light child connected to a live local Postgres over TCP, confirmed via sample's native call stack showing psycopg/libpq frames, and ran over 100 seconds before I killed it with kill -9 once the cause was identified. Decay is the routine, idempotent-per-session operation this hook already runs on every real session end, so I judge the exposure low, but it is a testing mistake on my part worth flagging rather than omitting. The corrected run, with sqlite isolation, showed:

Piping a SessionEnd JSON event into python -m mcp_server.hooks.session_lifecycle exits 0 immediately; session-log.json gets the row with sessionId, turnCount, and toolsUsed filled from the transcript; stderr logs consolidation spawned (mode=light) -> .../consolidate.log. ps -ef during the run showed the spawned argv directly: .../Python -m mcp_server.hooks.session_lifecycle --consolidate light, with ppid=1, confirming start_new_session=True detachment, running separately from the parent hook process which had already exited. The child completes on its own: sqlite memory.db gets created and consolidate.log gets Dream (light): 0 decayed, 0 compressed, confirming wire_composition_root() ran in the child (the store import and DB creation only happen after wiring) before _run_consolidation_cycle.

Hang test: the parent's exit was already observed happening before the child, which ran over 100 seconds against Postgres in the mistaken first run, had done anything. That is direct proof the parent never waits on the child, independent of the unit test test_the_session_log_row_exists_even_when_the_launcher_fails, which pins the same order by making the launcher raise.

Non-blocking and unrelated to this diff: the child's consolidate.log logs four wiki_*_pass failed (non-fatal): 'SqliteMemoryStore' object has no attribute 'batch_pool' lines before the decay line. That is a pre-existing sqlite versus postgres parity gap in consolidate_handler's wiki passes, not touched by this PR.

Child interpreter

python_executable() returns sys.executable, the exact interpreter already running the hook. Since that interpreter is already executing mcp_server.hooks.session_lifecycle as -m at the moment it decides to spawn, the same -m mcp_server.hooks.session_lifecycle --consolidate <mode> is guaranteed importable in the same environment whether wheel-installed or not; no scripts/ dependency exists.

Concurrency

Ran two SessionEnds for different session_ids concurrently against the same isolated sqlite store, WAL mode, no explicit timeout= passed to sqlite3.connect so the sqlite3 module's default 5 second busy-timeout applies. Both completed. All three session-log rows from this test sequence were present, so the read-modify-write JSON log lost no update, and both consolidation cycles logged Dream (light): 0 decayed, 0 compressed with no "database is locked" error. Neither consolidate_handler nor the stores hold an explicit lock or advisory flag; concurrent writers rely on sqlite's own WAL single-writer serialization plus the connection's busy-timeout. On a real locked collision the failure mode is graceful: _run_consolidation_cycle's except Exception catches sqlite3.OperationalError the same as any other handler failure and logs it non-fatal, with no corruption. This concurrency exposure predates this PR: two overlapping SessionEnd hook invocations already ran as two independent OS processes before this change, each calling consolidate_handler in-process. What changed is that the cycle now runs detached from its own hook process rather than blocking it, which if anything shortens the collision window per event rather than lengthening it. Not a finding against this PR.

Mutation

Removing start_new_session=True from the Popen call fails TestSpawnConsolidation::test_the_launcher_gets_the_same_mode_the_turn_count_earns with KeyError: 'start_new_session', 1 failed and 34 passed in tests_py/hooks/test_session_lifecycle.py.

Removing the session_meta dispatch, so transcript_activity always calls _claude_transcript_activity, fails test_transcript_rollout.py::test_a_rollout_yields_its_tool_sequence_and_turn_count: tool_sequence comes back [] instead of ['exec', 'exec', 'send_message', 'exec'], 1 failed and 55 passed across the combined infra and hooks suites. Both files reverted cleanly afterward, git diff --stat empty.

Static guards and gates

pytest tests_py/scripts -q: 1006 passed, 359 subtests passed. python scripts/check_craftsmanship.py --base origin/main: OK. uvx [email protected] check and ruff format --check on all six changed Python files: clean. pyright on the three changed mcp_server/ files: 0 errors, 0 warnings. Decision gate: ran decision_gate._line_runs directly against each changed file's real comment-line scan, which only counts #-prefixed lines via the language lexer and ignores docstrings; no file has a run of 8 or more consecutive comment lines, compliant with PROSE_RUN_LIMIT. ADR and wiki mirror parity: docs/adr/ADR-1081-...md and docs/adr/ADR-1082-...md diff byte-for-byte identical to wiki/adr/cortex/1081-...md and 1082-...md, modulo the wiki generator's read-only-mirror banner line; wiki/manifest.json gained both ADR-1081 and ADR-1082 entries pointing at the right mirror filenames. Full pytest -q: 8893 passed, 8 skipped, 2 warnings, 459 subtests passed in 466.76s, matching the PR's claimed count exactly. gh pr checks 610: every non-skipped check passes, covering Analyze for actions/javascript-typescript/python, Build Package, CI Green, Changed paths, CodeQL, Craftsmanship Gate, Docker Smoke, Fuzz for address and undefined, HOL plugin scan, Lint, Release dependency set, Test across Python 3.10 through 3.13, Test on Windows with the SQLite backend, Test with the SQLite backend, Validate MCP host configurations, the upstream identity contract check, Type Check, and plugin-scanner. The three "skipping" jobs, Docker Build for devcontainer and runtime images and the scheduled Fuzz batch and Tag a release, are conditional jobs not triggered on this PR, not failures.

Doc-claims finding

CHANGELOG.md's Fixed entry for ADR-1082 says consolidation now runs in a detached consolidate_launch subprocess. No file, module, or symbol named consolidate_launch exists anywhere in the tree; grepping the whole repository for that string returns only that one CHANGELOG line. This is the PR's own report of a first design, a standalone consolidate_launch.py importing mcp_server.handlers.consolidate, that the craftsmanship gate refused as a new layer violation. The shipped design is the module re-invoking itself, _spawn_consolidation inside session_lifecycle.py, confirmed end to end above. The CHANGELOG line was left describing the rejected design rather than the one that shipped. ADR-1082 itself, its wiki mirror, and the code all agree with each other and correctly describe the self-relaunch; only the CHANGELOG prose drifted. Since the ADR, the authoritative decision record, is correct, and only the CHANGELOG needs a wording fix from "a detached consolidate_launch subprocess" to something like "a detached subprocess that re-invokes this module with --consolidate <mode>", this does not warrant REQUEST_CHANGES on its own. It still needs fixing before or immediately after merge: a changelog entry naming a module that was never written will mislead the next engineer who greps for it.

Verdict

APPROVE, conditioned on fixing the CHANGELOG.md consolidate_launch line to describe the shipped self-relaunch design before or immediately after merge. Everything else measured clean: the baseline shrink is real and complete, the rollout reader is verified byte-for-byte against live Codex files with independently computed turn counts, the Claude path is provably untouched, the SessionEnd and consolidation split is verified end to end including the non-blocking spawn and detachment, both required mutants are killed by name, and every static gate plus the full suite and full CI are green.

@cdeust
cdeust merged commit 70343b6 into main Sep 17, 2026
28 checks passed
@cdeust
cdeust deleted the feat/codex-rollout-reader branch September 17, 2026 17:04
cdeust added a commit that referenced this pull request Sep 22, 2026
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
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