fix(#1348): canonicalize Codex hooks.json writes to the nested { hooks } shape (#1363)

* fix(#1348): canonicalize Codex hooks.json writes to the nested { hooks } shape

reconcileCodexHooksJsonEvent preserved whatever shape it read, so on an empty,
absent, or legacy top-level hooks.json it wrote top-level event keys
(`{ "SessionStart": [...] }`) that current Codex (deny_unknown_fields) rejects,
instead of the canonical `{ "hooks": { "SessionStart": [...] } }`.

- Lift any top-level event arrays (legacy, empty, or mixed nested+top-level)
  into the nested `hooks` table, merging same-named events so no user/legacy
  entry is dropped and no stray top-level event key survives. Mirrors
  reconcileCursorHooksJson.
- Collapse an empty hook table back to `{}` so removal on an absent file does
  not write a spurious `{ "hooks": {} }`.
- Read path still tolerates both shapes; dedup/removal unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1348): add changeset for Codex hooks.json canonicalization

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-17 08:51:11 -04:00
committed by GitHub
parent 07adeb50a0
commit c03f3cc6af
4 changed files with 270 additions and 13 deletions

View File

@@ -482,7 +482,24 @@ function reconcileCodexHooksJsonEvent(targetDir: string, eventName: string, opts
const usesNestedHooksObject =
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
const hookTable = usesNestedHooksObject ? (parsed['hooks'] as Record<string, unknown>) : parsed;
// #1348: canonicalize every write to the nested { hooks: { <Event>: [...] } }
// shape. Lift ANY top-level event array (legacy, empty, OR mixed nested+top-level)
// into the nested table — merging when the same event exists in both — so
// user/legacy entries are preserved under `hooks` and no stray top-level event
// key survives (Codex deny_unknown_fields rejects them). Mirrors reconcileCursorHooksJson.
const hookTable: Record<string, unknown> = usesNestedHooksObject
? (parsed['hooks'] as Record<string, unknown>)
: {};
for (const key of Object.keys(parsed)) {
if (key === 'hooks') continue;
if (Array.isArray(parsed[key])) {
const lifted = parsed[key] as unknown[];
const existing = Array.isArray(hookTable[key]) ? (hookTable[key] as unknown[]) : [];
hookTable[key] = [...lifted, ...existing];
delete parsed[key];
}
}
parsed['hooks'] = hookTable;
const eventEntries = Array.isArray(hookTable[eventName]) ? (hookTable[eventName] as unknown[]) : [];
let removedLegacy = false;
@@ -524,7 +541,11 @@ function reconcileCodexHooksJsonEvent(targetDir: string, eventName: string, opts
} else {
delete hookTable[eventName];
}
if (usesNestedHooksObject) parsed['hooks'] = hookTable;
// Avoid writing an empty `{ "hooks": {} }` artifact (e.g. removal on an absent
// file): collapse an empty hook table back to `{}` so the existing
// shouldWrite/no-write-on-empty behavior is preserved.
if (Object.keys(hookTable).length === 0) delete parsed['hooks'];
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
const changed = currentContent !== nextContent;