Skip to content

D230 — Codex hooks register in hooks.json

Decision. A hook materialized for Codex is registered in <base>/.codex/hooks.json — the user layer and a trusted project's .codex/ alike — patched in place by its .codex/hooks/<name>/ ownership marker, with every foreign entry preserved. It replaces the hook part of D58, which wrote a marker-delimited [[hooks.<Event>]] block into config.toml. A registration an older client left in config.toml (the block and any marker-less copy of D99) is migrated out on the next provisioning run, and removed on prune.

Why. Codex reads hooks from both files, but a layer holding both is merged with a warning at every start (#327). hooks.json is where third-party integrations register — Herdr's Codex integration writes its SessionStart hook there, and recreates the file on every reinstall — so staying in config.toml meant the warning for anyone with another integration. It is also the better file to own a slice of: it holds hooks and nothing else, while config.toml is rewritten by Codex itself ([projects.*], [notice], [hooks.state]), which is what the whole marker/orphan/eviction machinery of D99, D126 and D127 existed to survive. Its shape is Claude Code's settings.json hooks, which the client already patches by ownership marker.

Alternatives rejected. Keeping config.toml and moving other tools' hooks into it: they recreate hooks.json on update, so the warning always comes back. Writing both files: that is the double representation Codex warns about, and a hook registered twice fires twice. Leaving old registrations for the user to clean up: a stale block fires alongside the new entry.

Consequences. Codex keys hook trust ([hooks.state] in config.toml) by <file>:<event>:<i>:<j>, so a migrated hook is prompted for trust once; the migration says so in its warning. The trust tables themselves are left alone — Codex's bookkeeping, inert once the registration they hash is gone. An inline command gains the same trailing # cartographer-hook: marker comment Claude's does, so it stays idempotent and prunable. A hooks.json left as an empty object after prune is removed rather than kept as {}; one holding anything else stays. doctor counts a registration still in config.toml as a stray. The config.toml MCP block is unchanged.