Skip to content

D236 — A Codex hook registration left in config.toml is drift

Decision. On-disk verification (D139) reports a Codex hook whose files are intact but whose registration is still in .codex/config.toml as unregistered. Because that finding can be healed, the next sync rewrites the hook, and registerCodexHook migrates the registration to hooks.json (D230). When the migration deletes the old # cartographer:hook:<name> block, it first moves out every table inside it that is not one of the hook's [[hooks.<Event>]] registrations, as D126 does for the MCP block.

Why. The migration used to run only when a hook was materialized, and a hook is materialized only when its content changes. After an upgrade, a KB hook whose content was unchanged kept firing twice, once from each file. doctor suggested cartographer sync as the fix, but sync never touched the hook, so the fix did nothing (#338). The block was also deleted whole, which destroyed a [notice] table that Codex's own rewrite of the file had placed inside it. Treating the stale registration as drift reuses the existing path: status counts the hook as divergent, sync --no-heal reports it, and sync repairs it. The cost is one extra read of config.toml per Codex hook on every verification.

Alternatives rejected. - Migrate unconditionally at the start of every Apply: this adds a second place that writes config.toml, and status would still call the client in-sync while the hook fires twice. - Have doctor name reconnect instead of sync: that fixes the advice, not the upgrade, and every user would still have to run a command they never needed before. - Parse the block and rewrite only our tables: D58 forbids re-serializing the user's TOML. Cutting whole table groups out verbatim, as D126 does, keeps every byte.

Consequences. Only Codex hooks are checked for a stale registration; Claude has a single settings file and so no second place a registration can linger. The bootstrap hook is not in the manifest, so the heal pass cannot rewrite it, but EnsureBootstrapHook registers it again on every run. Inside a hook block, a [[hooks.<Event>]] table is assumed to be ours, because that is all D58 ever wrote there.