diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 3c553fa01..8a9f38102 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -1280,15 +1280,9 @@ function looksLikeGrokEnvelope(event) { return typeof event.toolName === 'string' && event.toolInput !== undefined; } -// Grok Build 1.0.5 (captured 2026-08-24) uses snake_case event names in -// camelCase fields: hookEventName "post_tool_use" / "stop". Map onto the -// internal Claude names the rest of the hook already keys on. -const GROK_HOOK_EVENTS = { - post_tool_use: 'PostToolUse', - pre_tool_use: 'PreToolUse', - stop: 'Stop', -}; - +// 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; @@ -1386,23 +1380,19 @@ function normalizeGitHubEvent(event, projectCwd) { }; } -function grokProjectCwd(event, projectCwd) { - if (typeof event.cwd === 'string' && event.cwd) return event.cwd; - if (typeof event.workspaceRoot === 'string' && event.workspaceRoot) { - return event.workspaceRoot.replace(/\/+$/, '') || event.workspaceRoot; - } - return envProjectDir(projectCwd) || 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 = grokProjectCwd(event, projectCwd); + const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd; const sessionId = event.sessionId || event.session_id || 'unknown'; - const toolInput = event.toolInput && typeof event.toolInput === 'object' && !Array.isArray(event.toolInput) - ? { ...event.toolInput } + const rawInput = event.toolInput ?? event.tool_input; + const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) + ? { ...rawInput } : {}; - const mappedName = typeof event.hookEventName === 'string' - ? (GROK_HOOK_EVENTS[event.hookEventName] || event.hookEventName) - : undefined; const out = { ...event, cwd, @@ -1410,7 +1400,6 @@ function normalizeGrokEvent(event, projectCwd) { tool_name: event.toolName || event.tool_name || null, tool_input: toolInput, }; - if (mappedName) out.hook_event_name = mappedName; if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) { out.stop_hook_active = event.stopHookActive; } diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 907de5f6c..4604f80c6 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -2798,7 +2798,6 @@ describe('resolveHarness() / normalizeHookEvent()', () => { }, '/fallback', 'grok'); assert.equal(normalized.session_id, 'g1'); assert.equal(normalized.cwd, '/proj'); - assert.equal(normalized.hook_event_name, 'PostToolUse'); assert.equal(normalized.tool_name, 'search_replace'); assert.equal(normalized.tool_input.file_path, '/proj/src/styles.css'); assert.deepEqual(resolveTargetFiles(normalized, '/proj'), ['/proj/src/styles.css']); @@ -4128,6 +4127,36 @@ describe('runStopHook()', () => { assert.match(again.stdout, /dark-glow/, 'a finding fixed then reintroduced must fire at Stop again'); }); + it('a Stop detector failure leaves the remembered set alone', async () => { + // A throw yields an empty scan; recording that as truth would wipe the + // remembered keys and make the next successful Stop re-emit everything. + const sid = 'grok-stop-throw'; + const file = write('src/Card.tsx', 'noop'); + let fail = false; + const scan = () => { + if (fail) throw new Error('detector crashed'); + return [finding('dark-glow', 5)]; + }; + const det = { detectText: scan, detectHtml: scan }; + + await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd, detector: det }); + const first = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); + assert.match(first.stdout, /dark-glow/); + const remembered = readCache(cwd).sessions[sid].files[file].findings; + assert.equal(remembered.length, 1); + + fail = true; + const broken = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); + assert.equal(broken.stdout, ''); + assert.equal(broken.audit.skipped, 'stop-clean'); + assert.deepEqual(readCache(cwd).sessions[sid].files[file].findings, remembered); + + fail = false; + const recovered = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); + assert.equal(recovered.stdout, '', 'an unchanged finding must stay deduped after a detector failure'); + assert.equal(recovered.audit.skipped, 'stop-clean'); + }); + it('Stop remembers the live scan, not only newly emitted findings', async () => { // Per-edit already remembered dark-glow. Stop then emits the deferred // remainder. The cache must keep both keys so a second Stop stays silent