mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 17:16:46 +03:00
Merge origin/main into fix/603-codex-stop-payload
Keep Codex as its own Stop harness (decision/block) while taking main's Grok envelope detection and Stop cache sync. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+102
-21
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,10 +1251,15 @@ export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
// 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
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
@@ -1269,6 +1274,27 @@ export function resolveHarness(env = {}, event = null) {
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1360,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1965,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2170,8 +2231,8 @@ export const STOP_MAX_FILES = 20;
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code reads hookSpecificOutput.additionalContext, Codex takes a
|
||||
* decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* 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.
|
||||
*/
|
||||
@@ -2200,6 +2261,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// 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
|
||||
@@ -2209,18 +2274,23 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
// 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), so
|
||||
// this guard is also what caps the Codex deep pass at one continuation
|
||||
// per turn. 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).
|
||||
// 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 });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2255,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2275,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2314,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
|
||||
Reference in New Issue
Block a user