Trim the Grok normalizer to the fields the hook reads

The hook_event_name mapping (and its pre_tool_use entry) had no reader:
hook.mjs routes on the raw stdin via isStopEvent, and nothing downstream
keys on the normalized event name. The trailing-slash strip duplicated
the path.resolve every consumer already does. Pin the one commit-2
behavior that had no test: a Stop detector failure must leave the
remembered set alone.

Prepared with AI assistance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abdul Wahab
2026-08-24 06:52:52 +05:00
co-authored by Claude Fable 5
parent 3c442af7ad
commit bfe634e254
2 changed files with 43 additions and 25 deletions
+13 -24
View File
@@ -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;
}
+30 -1
View File
@@ -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