Compare commits

...
Author SHA1 Message Date
Abdul WahabandCursor 6bea544a0a Fix: drain context stdout before process.exit (#573)
process.exit after a queued write truncated boot output on a backpressured pipe. Await the write callback, then close the fetch dispatcher.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 07:51:47 +05:00
Abdul WahabandCursor 2ef8e43d1e Fix: close fetch sockets before context helper exit (#573)
On Windows/Node 24, a live undici keep-alive from the update-check fetch aborted libuv during teardown after valid stdout. Destroy the dispatcher first, matching concept-seed.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 07:31:18 +05:00
dependabot[bot]andGitHub 78b50aa416 Build(deps): update the Bun dependency group (#650)
Update the grouped Bun dependencies while holding ai at 7.0.69 to avoid the automatic tool-execution behavior regression introduced in 7.0.70.

Prepared and validated with AI assistance.
2026-08-24 15:15:01 -04:00
Abdul WahabandGitHub c3a30086bc Merge pull request #649 from pbakaus/codex/link-hook-trust-guide
Docs: Link harness trust guidance
2026-08-24 08:26:26 +05:00
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
37 changed files with 697 additions and 263 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:
+23 -19
View File
@@ -17,7 +17,7 @@
"@ai-sdk/google": "^4.0.8",
"@ai-sdk/openai": "^4.0.7",
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
"@anthropic-ai/sdk": "^0.115.0",
"@anthropic-ai/sdk": "^0.120.0",
"@babel/parser": "^8.0.4",
"ai": "^7.0.14",
"archiver": "^8.0.0",
@@ -31,37 +31,37 @@
},
},
"packages": {
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.39", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JAMGtYeEuaBzqbsPO4fkho6vQyNoVhsHASM4o59wmJRU6Vh7prjOp490Kmc7YQTY+ioU1/xYzXvWOtxZBup0Xw=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.28" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cFlCZspCUC1LGDipARsKx3+4A8c9qI+vFuG0/04Phs0deKwifNsl8wDmcU2HO31aiNR9AJgEBcvg5S00zUS70g=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.52", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.55", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7WP/nlDz2BkXFlZzwF3w5JgCvktyHC++LP4PZ4mQpbvxrq+M7OdNslevlkuddHhJ+62BZu4oiL/afXlsWOSJZQ=="],
"@ai-sdk/google": ["@ai-sdk/google@4.0.44", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bmRTDg06jQD+eX8nf214pET9+Oe8O1+lUIRGbWsGXj9IN2UJkpl1O1x7cvtiboyTtKSLvSRdVtItUfSl8sQ2GA=="],
"@ai-sdk/google": ["@ai-sdk/google@4.0.49", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.28" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZJMehjQNVyvLytV5cUQcTMNEG79NS+p8d2AHu+bH/Mg2M2IQ8Pueh/xQga2v85TX8BGT55m9Q01taY1fZjS4ag=="],
"@ai-sdk/openai": ["@ai-sdk/openai@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZxDca6jJalYuXrIGVrw6dnkpz1Io9AWy+/b/wVWIbjigHCbd+zWLpPi8NnK0OFU+U3YCpP+KWfUvEnG5pFhltA=="],
"@ai-sdk/openai": ["@ai-sdk/openai@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.28" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-F/zHXTfCaHO0Q5egf8HRdc645Mp/ofLE9NSjl5RwaXfqrqLfiLROE9xScgKf4iziElsjuQYbesPEcW37mmZy3w=="],
"@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.28", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TnHUyd/rCYQqHg5RuiOaz/hUql6U+kbUaBW0Rp+0N5UhnAInA9CzzV0HXvuAAPwppDsK6fAx9Rd+tRawpJ/3pg=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.233", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.233", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.233", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.233", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.233", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.233", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.233", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.233", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.233" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-Dy+YqhggwtbezDy3Ap2pb1sK3bOqnI+sLNnsVjB3AUWvR0QlGnjjrjORXY03Y50I+B1eFRNEcYPAZKRYlCkSLQ=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.238", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.238", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.238", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.238", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.238", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.238", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.238", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.238", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.238" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-ppRfbAflZuV7HPqr8BkHPEk8c7gih3PK+GHnZq6zP0Uo7bJHXAJwc4Za8LJRm4hXMixBNMjK1cU/VWfQ9fE2sg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.233", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4WDiBZgcrmvTDJjS8RNZwoxGgMz/0EpOM+sYa6EtyjwHTd6It1H/+k5zBckCmBajbgS5/ASCJqdwZzi7dwBl0Q=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.238", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7KctNItTzHRiDg+jFxTM46o+Y/YS52V6HSopaWPggO6BbR+3MV/rKMmq+zP93If7eVwadW9Yg9vHLjXDKIw9OQ=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.233", "", { "os": "darwin", "cpu": "x64" }, "sha512-RaaEfNrbqSh77H5NdVF9cJQ0xhAUO92aOv71LSKSdAYModMeUvJN0k22Q7gvmx0TlmqJ+aVyCG8J8gVfgSL9mg=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.238", "", { "os": "darwin", "cpu": "x64" }, "sha512-aDt8LXjWISwLzqeCBHxJC5AR1iVYlY5UYIm2VLztjI1U0PCb1BiH/IfyyjjizqRhhEwyQp9YAJeZZq8n/2vQEA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.233", "", { "os": "linux", "cpu": "arm64" }, "sha512-Az9HjQthYQqRjJCacBtDIAHX3TRGK9WlACNb/UOGAK3JndNzZMprM2mK/t6YmP2cRLJsGyorxL7HZmR9R9HYaw=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.238", "", { "os": "linux", "cpu": "arm64" }, "sha512-P7V9TFokcNRdIJPUzDQ0GLBfMuoewWEH4rh6U3e7RaEAxzdeUcdS9P0N4arXqK2YAOtK1o93QSEtiGUF1fTfWw=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.233", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z3uZdzt6xgJ3f4NIgO6lzBYSELULKSq6AL4OsNLBzuaEpVW0iYs1kUCaD9rcMlMrf3cV+Dk/GA/lTCGMgbucjQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.238", "", { "os": "linux", "cpu": "arm64" }, "sha512-ankSEMAMTVulKYg0NT8fZ7a5+q+aIzfiqhcrLe9zYPeuhE+lpNeq0JPMcHyOtUAENxVI0/2J2q5OXGl9O0qWcg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.233", "", { "os": "linux", "cpu": "x64" }, "sha512-jpbhV+n9PnxLiyheQ/HjtHIg/E5/jVsk2Vdu132BSoL/3bsObSmMqKgsqoMutzwRZvtpqRs2RPVcjsC8G4A9Zw=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.238", "", { "os": "linux", "cpu": "x64" }, "sha512-/arAOSqtIAWDu7Z4Uf2Un3n+/5Zg3NpxZKzMQEDnYNkbJRhr44sM59TFaEOiWq1FqlUJKi6wHjgm+TW5B8DWWg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.233", "", { "os": "linux", "cpu": "x64" }, "sha512-kYBIAQCu2f1YITcGbpUN2jfrkAzs59TVAragAhE2z+GrkIcxcpZwmaRY6heMBtaSY8SuyrwgqbCW9hJALYFnEg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.238", "", { "os": "linux", "cpu": "x64" }, "sha512-buo3IBSd7EmYcQsH+OKARgNNbSF1+l/+z1hDXSXGKzwD1LFioJZ/k9FWWWC166SoQ93jPmN+G/ksTzEUB3zRGQ=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.233", "", { "os": "win32", "cpu": "arm64" }, "sha512-aO2MaNdmQofyPLKszE4s+Ope/sLJPeI/ZlGdCcjYp7qhji2hgZ4bRWWsOrx5eKjz0gFK5CFFltILkFcNcxCsVg=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.238", "", { "os": "win32", "cpu": "arm64" }, "sha512-yW8lhV7QgYiNu3NatjgmkhUspgJsG2N2N6lmm/7B99sWFobKTbVLMLsXxj6A46R6qE2C4Zzm6PjxgGaMYOMn0g=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.233", "", { "os": "win32", "cpu": "x64" }, "sha512-TcAYyWPXS5mREZGUksuCZsLIRQjbo/Vriur2PqIhAmgZ1oiqBZO27a90sX60EUczD7yV8wpwOVhVLhUxO0kAEg=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.238", "", { "os": "win32", "cpu": "x64" }, "sha512-6Fb2JRrBci282fBfhCGB0Tpq7N8V5HMMqsO3YGFkc9hhbn/y9G7glNKSNgRtxD5Ujjxj8rhiaYstXW2DJzanzw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.115.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.120.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-ZlvmNFT/iIF6JD13rxbbMWD8nvGR0RaUp6yMQnoc+4Af0YjVVe/bIdW1XSQQsoxXAtg1NaT6Vak0LKFlJ4d37Q=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="],
@@ -109,7 +109,7 @@
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"ai": ["ai@7.0.66", "", { "dependencies": { "@ai-sdk/gateway": "4.0.52", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg=="],
"ai": ["ai@7.0.69", "", { "dependencies": { "@ai-sdk/gateway": "4.0.55", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gudEqQYt/FuRpQkckLHPYKOh5M5v5ayPmzQAk5xyZOaWkUM0ArNGdXQiBGEuZA7GCwYOjzmWxB44ZW0CyRhHfQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
@@ -327,7 +327,7 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@18.0.9", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w=="],
"marked": ["marked@18.0.10", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-FJeH4bRpYoXiggcgriCGItKCSv3xkngJc4QCZ/rkQCogU3VYaLxYJoZl8Nw/b4+x7iij/pd+09mZ6A1dXzpL0A=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -435,7 +435,7 @@
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"svelte": ["svelte@5.56.9", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA=="],
"svelte": ["svelte@5.56.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g=="],
"tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="],
@@ -483,10 +483,14 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
"@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"eventsource/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
+1 -1
View File
@@ -89,7 +89,7 @@
"@ai-sdk/google": "^4.0.8",
"@ai-sdk/openai": "^4.0.7",
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
"@anthropic-ai/sdk": "^0.115.0",
"@anthropic-ai/sdk": "^0.120.0",
"@babel/parser": "^8.0.4",
"ai": "^7.0.14",
"archiver": "^8.0.0",
+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.
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
+56
View File
@@ -1060,6 +1060,27 @@ describe('context.mjs CLI', () => {
assert.match(res.stdout, /detect\.mjs --json <changed targets>/);
});
it('drains stdout before exit when the parent pipe is paused', async () => {
const MARKER = 'END_MARKER_573';
write('PRODUCT.md', `# Acme\n\n${'x'.repeat(256 * 1024)}\n\n${MARKER}\n`);
const child = spawn(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
let stdout = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stdout.pause();
const resume = setTimeout(() => child.stdout.resume(), 100);
const status = await new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', resolve);
});
clearTimeout(resume);
assert.equal(status, 0);
assert.match(stdout, /END_MARKER_573/);
assert.match(stdout, /RESOLVED_CONTEXT:/);
});
// The build-path preference rides the unified config beside hook and
// detector settings. The local file wins because whether a machine can
// generate images is a property of that machine, not of the committed
@@ -1588,4 +1609,39 @@ describe('context.mjs update check', () => {
assert.equal(typeof cache.lastCheck, 'number'); // stamped so we don't re-poll every boot
assert.equal(cache.latestVersion, undefined); // nothing learned
});
// Targeted live-fetch boot: the Windows abort in issue #573 fired after
// stdout was already complete, so the contract is exit 0 with the full
// context still on stdout.
it('exits 0 after a targeted live-fetch boot writes full context', async () => {
const { srv, host } = await startStub({ skills: '2.0.0' });
try {
const { skillScript, project, env } = setup({}, { host });
fs.writeFileSync(
path.join(project, 'package.json'),
JSON.stringify({ private: true, workspaces: ['packages/*'] }),
);
const jervPi = path.join(project, 'packages', 'jerv-pi');
fs.mkdirSync(jervPi, { recursive: true });
fs.writeFileSync(path.join(jervPi, 'PRODUCT.md'), '# Jerv Pi product\n');
const result = await new Promise((resolveRun, rejectRun) => {
const child = spawn(process.execPath, [skillScript, '--target', 'packages/jerv-pi'], {
cwd: project,
env,
});
let stdout = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.on('error', rejectRun);
child.on('close', (status) => resolveRun({ status, stdout }));
});
assert.equal(result.status, 0);
assert.match(result.stdout, /RESOLVED_CONTEXT:/);
assert.match(result.stdout, /# Jerv Pi product/);
assert.match(result.stdout, /UPDATE_AVAILABLE/);
} finally {
srv.close();
}
});
});