diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.agents/skills/impeccable/scripts/hook.mjs b/.agents/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.agents/skills/impeccable/scripts/hook.mjs +++ b/.agents/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.claude/skills/impeccable/scripts/hook.mjs b/.claude/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.claude/skills/impeccable/scripts/hook.mjs +++ b/.claude/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.cursor/skills/impeccable/scripts/hook.mjs b/.cursor/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.cursor/skills/impeccable/scripts/hook.mjs +++ b/.cursor/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.gemini/skills/impeccable/scripts/hook.mjs b/.gemini/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.gemini/skills/impeccable/scripts/hook.mjs +++ b/.gemini/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.github/skills/impeccable/scripts/hook.mjs b/.github/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.github/skills/impeccable/scripts/hook.mjs +++ b/.github/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.grok/skills/impeccable/scripts/hook-lib.mjs b/.grok/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.grok/skills/impeccable/scripts/hook-lib.mjs +++ b/.grok/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.grok/skills/impeccable/scripts/hook.mjs b/.grok/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.grok/skills/impeccable/scripts/hook.mjs +++ b/.grok/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.hermes/skills/impeccable/scripts/hook-lib.mjs b/.hermes/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.hermes/skills/impeccable/scripts/hook-lib.mjs +++ b/.hermes/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.hermes/skills/impeccable/scripts/hook.mjs b/.hermes/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.hermes/skills/impeccable/scripts/hook.mjs +++ b/.hermes/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.kiro/skills/impeccable/scripts/hook.mjs b/.kiro/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.kiro/skills/impeccable/scripts/hook.mjs +++ b/.kiro/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.opencode/skills/impeccable/scripts/hook.mjs b/.opencode/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.opencode/skills/impeccable/scripts/hook.mjs +++ b/.opencode/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.pi/skills/impeccable/scripts/hook.mjs b/.pi/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.pi/skills/impeccable/scripts/hook.mjs +++ b/.pi/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.qoder/skills/impeccable/scripts/hook.mjs b/.qoder/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.qoder/skills/impeccable/scripts/hook.mjs +++ b/.qoder/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.rovodev/skills/impeccable/scripts/hook.mjs b/.rovodev/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.rovodev/skills/impeccable/scripts/hook.mjs +++ b/.rovodev/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.trae-cn/skills/impeccable/scripts/hook.mjs b/.trae-cn/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.trae-cn/skills/impeccable/scripts/hook.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.trae/skills/impeccable/scripts/hook.mjs b/.trae/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.trae/skills/impeccable/scripts/hook.mjs +++ b/.trae/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/.vibe/skills/impeccable/scripts/hook-lib.mjs b/.vibe/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/.vibe/skills/impeccable/scripts/hook-lib.mjs +++ b/.vibe/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.vibe/skills/impeccable/scripts/hook.mjs b/.vibe/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/.vibe/skills/impeccable/scripts/hook.mjs +++ b/.vibe/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 8a9f38102..1e709b11a 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) { if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; if (explicit === 'grok') return 'grok'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. // Check Grok first: the old GitHub heuristic (`toolName` and no @@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } @@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no audit.harness = harness; event = normalizeHookEvent(event, cwd, harness); - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Claude sends `stop_hook_active`; Grok sends - // `stopHookActive`, copied onto the snake_case field above. The strict - // `=== true` is a no-op when the field is absent. This guard makes the - // loop impossible regardless of the finding cache key's line-number - // sensitivity (out of scope here; see findingCacheKey). + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } @@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/plugin/skills/impeccable/scripts/hook.mjs b/plugin/skills/impeccable/scripts/hook.mjs index 771e0cc75..a190ad697 100644 --- a/plugin/skills/impeccable/scripts/hook.mjs +++ b/plugin/skills/impeccable/scripts/hook.mjs @@ -12,7 +12,7 @@ * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent.