* 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user