Compare commits

...
Author SHA1 Message Date
Abdul Wahab 21510c3632 Docs: Link harness trust guidance
Point installer readers to the harness-specific approval and verification steps on impeccable.style.\n\nAI-assisted: Codex prepared and verified this documentation update under direct maintainer instruction.
2026-08-24 08:00:38 +05:00
github-actions[bot] 5d00e30405 Sync generated provider output 2026-08-24 02:52:15 +00:00
Abdul WahabandGitHub f01a808890 Merge pull request #647 from pbakaus/fix/603-codex-stop-payload
Fix: emit Codex Stop hook as decision/block (#603)
2026-08-24 07:51:42 +05:00
33 changed files with 594 additions and 240 deletions
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.
+2
View File
@@ -113,6 +113,8 @@ npx impeccable update
Codex users should open `/hooks` after install or update and approve the project hook when prompted. Codex tracks trust by hook definition, so updates that change `.codex/hooks.json` can require approval again. Grok Build users need project folder trust (`/hooks-trust` or launch with `--trust`) before `.grok/hooks/` scripts run.
See [Allow the hook in your harness](https://impeccable.style/docs/hooks#allow-the-hook-in-your-harness) for harness-specific trust and verification steps.
### Option 2: Git Submodule
For teams that want to keep Impeccable vendored and updated through Git, add this repo as a submodule and link the compiled provider build into your harness folders:
+36 -14
View File
@@ -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 },
});
+1 -1
View File
@@ -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.