From 35ae07339b63a0dc4eaf7dc59450866effef46e6 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Mon, 24 Aug 2026 05:28:21 +0500 Subject: [PATCH] Fix: parse Grok Build camelCase hook stdin (#646) Grok was classified as GitHub Copilot, so the design hook skipped every edit with no-file-path and never ran Stop. Normalize toolInput/sessionId and treat Stop additionalContext as the Grok product. Prepared with AI assistance. Co-authored-by: Cursor --- README.md | 3 +- docs/HARNESSES.md | 4 +- scripts/lib/transformers/hooks.js | 4 +- skill/reference/hooks.md | 6 +- skill/scripts/hook-lib.mjs | 106 ++++++++++++++++-- skill/scripts/hook.mjs | 17 +-- tests/hook.test.mjs | 176 ++++++++++++++++++++++++++++++ 7 files changed, 289 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 3a71f5981..5ca1a8f31 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ If an ephemeral file (a screenshot, `config.local.json`) was committed before yo ## Design hook -On Claude Code, GitHub Copilot, Codex, Cursor, and Grok Build, `npx impeccable install` and `npx impeccable update` install a provider-native hook manifest along with the skill payload. The hook runs the Impeccable design detector on direct UI file edits and surfaces findings back into the agent flow. Claude Code, GitHub Copilot, Codex, and Grok Build surface findings after the edit (and run a deeper pass on Stop where supported). Cursor blocks bad proposed writes before they land. +On Claude Code, GitHub Copilot, Codex, Cursor, and Grok Build, `npx impeccable install` and `npx impeccable update` install a provider-native hook manifest along with the skill payload. The hook runs the Impeccable design detector on direct UI file edits and surfaces findings back into the agent flow. Claude Code, GitHub Copilot, and Codex surface findings after the edit (and run a deeper pass on Stop where supported). Grok Build scans after the edit to warm Stop, then surfaces on Stop; PostToolUse stdout never reaches the model. Cursor blocks bad proposed writes before they land. Installed hook surfaces: @@ -354,6 +354,7 @@ Installed hook surfaces: - GitHub Copilot: `.github/hooks/impeccable.json` (committed, shared by the Copilot CLI and the cloud agent) runs `.github/skills/impeccable/scripts/hook.mjs`. The Copilot CLI activates it once the file is on the repository's default branch and the folder is trusted. - Cursor: `.cursor/hooks.json` runs `.cursor/skills/impeccable/scripts/hook-before-edit.mjs`. - Codex: `.codex/hooks.json` runs `.agents/skills/impeccable/scripts/hook.mjs`. +- Grok Build: `.grok/hooks/impeccable.json` runs `.grok/skills/impeccable/scripts/hook.mjs`. Requires `/hooks-trust` or `--trust`. Findings reach the model on Stop, not after each edit. The installer preserves unrelated hook entries and settings. If a hook manifest is malformed, install/update aborts by default; rerun with `--force` to back up the malformed file as `.bak` and replace it. diff --git a/docs/HARNESSES.md b/docs/HARNESSES.md index 54c3b8a04..9371215f2 100644 --- a/docs/HARNESSES.md +++ b/docs/HARNESSES.md @@ -3,7 +3,7 @@ Source of truth for what each AI coding harness supports in terms of agent skills. Used to inform provider configs in `scripts/lib/transformers/providers.js`. -Last verified: 2026-04-28 (subagent landscape spot-checked 2026-06-28; Mistral Vibe row verified 2026-07-16; Grok Build row verified 2026-07-21) +Last verified: 2026-04-28 (subagent landscape spot-checked 2026-06-28; Mistral Vibe row verified 2026-07-16; Grok Build skills row verified 2026-07-21; Grok Build hook stdin captured 2026-08-24) > This file is point-in-time. Capabilities move fast; verify live before relying > on any "only X supports Y" claim. Notably, the subagent table below lists @@ -73,7 +73,7 @@ Notes: | Claude Code | Yes (`PostToolUse`) | No | `.claude/settings.json` | Project-local settings entry installed by `npx impeccable skills install/update`. Runs `.claude/skills/impeccable/scripts/hook.mjs`. | | Codex CLI | Yes (`PostToolUse`) | No | `.codex/hooks.json` | Project-local manifest installed with the `.agents/skills/impeccable` payload. Runs `.agents/skills/impeccable/scripts/hook.mjs` from the git root. Requires normal `/hooks` trust approval. | | Cursor | Yes (`preToolUse`) | No | `.cursor/hooks.json` | Project-level manifest installed with `.cursor/skills/impeccable`. Runs `hook-before-edit.mjs` to block bad proposed writes before they land. Reloads on save; restart Cursor if hooks do not pick up. | -| Grok Build | Yes (`PostToolUse`) | No | `.grok/hooks/impeccable.json` | Project-local manifest installed with `.grok/skills/impeccable`. Claude-compatible matchers (`Edit\|Write\|MultiEdit`) alias to Grok tools. Also runs a Stop deep pass. Requires `/hooks-trust` or `--trust`. Plugin installs use `plugin/hooks/hooks.json` with `${CLAUDE_PLUGIN_ROOT}` (aliased to `GROK_PLUGIN_ROOT`). | +| Grok Build | Yes (`PostToolUse`) | No | `.grok/hooks/impeccable.json` | Project-local manifest installed with `.grok/skills/impeccable`. Claude-compatible matchers (`Edit\|Write\|MultiEdit`) alias to Grok `search_replace`. PostToolUse runs the scan and warms the session cache; Grok ignores that stdout. Stop `additionalContext` is the user-visible pass. Ignore Grok's observe-only Stop with `reason: "shutdown"`. Requires `/hooks-trust` or `--trust`. Plugin installs use `plugin/hooks/hooks.json` with `${CLAUDE_PLUGIN_ROOT}` (aliased to `GROK_PLUGIN_ROOT`). | | All other harnesses | No | No | n/a | No documented hook surface today. Skill and commands still ship. | ## Skill Directory Structure diff --git a/scripts/lib/transformers/hooks.js b/scripts/lib/transformers/hooks.js index 51828a56b..3f0dda32a 100644 --- a/scripts/lib/transformers/hooks.js +++ b/scripts/lib/transformers/hooks.js @@ -71,7 +71,9 @@ const NODE_MAJOR_FLOOR = 22; // Claude Code / Codex: `systemMessage` on stdout is shown to the user -> notice // Cursor: preToolUse output is permission-shaped and its `user_message` // renders only on DENY, so warning would block the edit -> probe only -// Grok Build: PostToolUse/Stop stdout is ignored outright -> probe only +// Grok Build: PostToolUse stdout is ignored; Stop additionalContext +// reaches the model, but the node-version notice has no systemMessage +// channel on this harness -> probe only // Copilot: output contract unconfirmed; do not guess a shape -> probe only // // The clamp avoids `<` and `>` deliberately: Volta's Windows shims run through diff --git a/skill/reference/hooks.md b/skill/reference/hooks.md index e990b1641..739a7386e 100644 --- a/skill/reference/hooks.md +++ b/skill/reference/hooks.md @@ -2,9 +2,9 @@ Manage the **design detector hook** for the current project. -The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout. -The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`. Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end. @@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores. -Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch. +Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch. On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 794ac59a1..cea0265b3 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -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,9 +1251,14 @@ 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' || explicit === 'codex') return 'claude'; - // 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) { @@ -1263,6 +1268,33 @@ 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; +} + +// 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', +}; + +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. @@ -1354,9 +1386,41 @@ 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; +} + +function normalizeGrokEvent(event, projectCwd) { + const cwd = grokProjectCwd(event, projectCwd); + const sessionId = event.sessionId || event.session_id || 'unknown'; + const toolInput = event.toolInput && typeof event.toolInput === 'object' && !Array.isArray(event.toolInput) + ? { ...event.toolInput } + : {}; + const mappedName = typeof event.hookEventName === 'string' + ? (GROK_HOOK_EVENTS[event.hookEventName] || event.hookEventName) + : undefined; + const out = { + ...event, + cwd, + session_id: sessionId, + 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; + } + 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 @@ -1959,7 +2023,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) { @@ -2191,22 +2263,32 @@ 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); + // 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. Only Claude Code sends this field; other - // harnesses omit it, so the strict `=== true` is a no-op for them. This - // guard makes the loop impossible regardless of the finding cache key's - // line-number sensitivity (out of scope here; see findingCacheKey). + // 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). 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 diff --git a/skill/scripts/hook.mjs b/skill/scripts/hook.mjs index 5813ea4f2..771e0cc75 100644 --- a/skill/scripts/hook.mjs +++ b/skill/scripts/hook.mjs @@ -2,12 +2,14 @@ /** * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by - * `hook_event_name`: + * Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin + * and routes by Stop vs everything else. Claude uses `hook_event_name: + * "Stop"`; Grok uses `hookEventName: "stop"`. * * - PostToolUse: runs the immediate-tier detector rules against the touched * file and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * `hookSpecificOutput.additionalContext` when findings exist. Grok + * 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. @@ -19,7 +21,7 @@ * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -28,10 +30,9 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } -function isStopEvent(stdinJson) { +function stdinIsStop(stdinJson) { try { - const event = JSON.parse(stdinJson); - return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + return isStopEvent(JSON.parse(stdinJson)); } catch { // Malformed stdin falls through to runHook, which audits the skip. return false; @@ -48,7 +49,7 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const run = stdinIsStop(stdinJson) ? runStopHook : runHook; const result = await run({ stdinJson, env: inheritedEnv, diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index cfcbf26dc..91e37370c 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -45,6 +45,7 @@ import { resolveTargetFiles, resolveHarness, normalizeHookEvent, + isStopEvent, expandScanTargets, parseStaticStyleImports, coLocatedStylesheets, @@ -1493,6 +1494,32 @@ rounded: assert.equal(out.hookSpecificOutput, undefined); }); + it('handles a Grok Build search_replace event and does not classify it as github (#646)', async () => { + const file = writeFixture('src/Card.tsx', 'noop'); + const det = fakeDetector([finding('gradient-text', 1, { name: 'Gradient text' })]); + const grokEvent = { + hookEventName: 'post_tool_use', + sessionId: 'grok-1', + cwd, + workspaceRoot: `${cwd}/`, + toolName: 'search_replace', + toolInput: { file_path: file, old_string: 'a', new_string: 'b' }, + toolResult: { type: 'SearchReplace' }, + }; + + const r = await runHook({ stdinJson: JSON.stringify(grokEvent), env: {}, cwd, detector: det }); + assert.equal(r.exitCode, 0); + assert.equal(r.audit.harness, 'grok'); + assert.notEqual(r.audit.harness, 'github'); + assert.equal(r.audit.emitted, true); + assert.equal(r.audit.skipped, undefined); + const out = JSON.parse(r.stdout); + assert.match(out.hookSpecificOutput.additionalContext, /gradient-text/); + const cache = readCache(cwd); + assert.ok(cache.sessions['grok-1'].files[file], 'PostToolUse must mark the file for Stop'); + assert.deepEqual(cache.sessions['grok-1'].files[file].findings || [], []); + }); + it('handles a GitHub Copilot apply_patch event end-to-end (interactive/cloud path)', async () => { // The real bug the live test caught: interactive Copilot edits via // apply_patch (raw patch string in toolArgs), which the matcher and runtime @@ -2736,6 +2763,47 @@ describe('resolveHarness() / normalizeHookEvent()', () => { assert.equal(resolveHarness({}, { tool_name: 'Edit', tool_input: { file_path: 'a.tsx' } }), 'claude'); }); + it('routes a Grok Build envelope (toolName/toolInput, no toolArgs) to grok, not github (#646)', () => { + const post = { + hookEventName: 'post_tool_use', + sessionId: 's1', + cwd: '/proj', + toolName: 'search_replace', + toolInput: { file_path: '/proj/src/styles.css' }, + }; + const stop = { + hookEventName: 'stop', + sessionId: 's1', + cwd: '/proj', + reason: 'end_turn', + stopHookActive: false, + }; + assert.equal(resolveHarness({}, post), 'grok'); + assert.equal(resolveHarness({}, stop), 'grok'); + assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'grok' }), 'grok'); + assert.equal(isStopEvent(stop), true); + assert.equal(isStopEvent({ hook_event_name: 'Stop' }), true); + assert.equal(isStopEvent(post), false); + }); + + it('normalizes a Grok search_replace event onto tool_input.file_path + session_id', () => { + const normalized = normalizeHookEvent({ + hookEventName: 'post_tool_use', + sessionId: 'g1', + cwd: '/proj', + workspaceRoot: '/proj/', + toolName: 'search_replace', + toolInput: { file_path: '/proj/src/styles.css', old_string: 'a', new_string: 'b' }, + toolResult: { type: 'SearchReplace' }, + }, '/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']); + }); + it('normalizes a GitHub edit event: JSON-string toolArgs.path -> tool_input.file_path', () => { const normalized = normalizeHookEvent({ sessionId: 's1', @@ -3679,6 +3747,7 @@ describe('runHook() — per-edit tiering', () => { assert.equal(perEditTieringActive({ perEditRules: 'all' }, 'claude'), false); assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'github'), false); assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'cursor'), false); + assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'grok'), true); assert.equal(perEditTieringActive({}, 'claude'), true); }); @@ -3980,4 +4049,111 @@ describe('runStopHook()', () => { assert.equal(reentrant.audit.reentrant, true); assert.equal(reentrant.stdout, ''); }); + + function grokEditEvent(file, sessionId) { + return { + hookEventName: 'post_tool_use', + sessionId, + cwd, + workspaceRoot: `${cwd}/`, + toolName: 'search_replace', + toolInput: { file_path: file, old_string: 'a', new_string: 'b' }, + toolResult: { type: 'SearchReplace' }, + }; + } + + function grokStopEvent(sessionId, reason = 'end_turn') { + return { + hookEventName: 'stop', + sessionId, + cwd, + workspaceRoot: `${cwd}/`, + reason, + stopHookActive: false, + }; + } + + it('Grok Stop end_turn runs the deep pass over files warmed by camelCase PostToolUse (#646)', async () => { + const sid = 'grok-stop-sid'; + const file = write('src/Card.tsx', 'noop'); + const det = fakeDetector([ + finding('dark-glow', 5), + finding('marketing-buzzword', 3), + ]); + + const edit = await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd, detector: det }); + assert.equal(edit.audit.harness, 'grok'); + assert.match(edit.stdout, /dark-glow/); + assert.doesNotMatch(edit.stdout, /marketing-buzzword/); + + const stop = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); + assert.equal(stop.exitCode, 0); + assert.equal(stop.audit.harness, 'grok'); + assert.equal(stop.audit.session, sid); + assert.equal(stop.audit.emitted, true); + const out = JSON.parse(stop.stdout); + assert.equal(out.hookSpecificOutput.hookEventName, 'Stop'); + // Grok discarded the per-edit stdout, so Stop must still carry the + // immediate-tier finding as well as the deferred remainder. + assert.match(out.hookSpecificOutput.additionalContext, /dark-glow/); + assert.match(out.hookSpecificOutput.additionalContext, /marketing-buzzword/); + }); + + it('Grok Stop shutdown is observe-only and does not emit a second deep pass (#646)', async () => { + const sid = 'grok-shutdown'; + const file = write('src/Card.tsx', 'noop'); + const det = fakeDetector([finding('dark-glow', 5)]); + await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd, detector: det }); + + const stop = await runStopHook({ + stdinJson: JSON.stringify(grokStopEvent(sid, 'shutdown')), + env: {}, cwd, detector: det, + }); + assert.equal(stop.exitCode, 0); + assert.equal(stop.stdout, ''); + assert.equal(stop.audit.skipped, 'stop-reason'); + assert.equal(stop.audit.reason, 'shutdown'); + }); + + it('Grok stopHookActive:true exits silent after camelCase normalize (#646)', async () => { + const sid = 'grok-active'; + const file = write('src/Card.tsx', 'noop'); + const det = fakeDetector([finding('marketing-buzzword', 3)]); + await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd, detector: det }); + + const active = { ...grokStopEvent(sid), stopHookActive: true }; + const stop = await runStopHook({ stdinJson: JSON.stringify(active), env: {}, cwd, detector: det }); + assert.equal(stop.exitCode, 0); + assert.equal(stop.stdout, ''); + assert.equal(stop.audit.skipped, 'stop-hook-active'); + }); + + it('hook.mjs routes Grok camelCase stop stdin into runStopHook (#646)', async () => { + const sid = 'grok-script-stop'; + const file = write('src/hero.css', [ + '.hero {', + ' background: linear-gradient(#f00, #00f);', + ' -webkit-background-clip: text;', + ' color: transparent;', + '}', + '', + ].join('\n')); + + const edit = await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd }); + assert.equal(edit.audit.harness, 'grok'); + assert.equal(edit.audit.emitted, true); + + const env = { ...process.env }; + delete env.IMPECCABLE_HOOK_DEPTH; + delete env.CLAUDE_HOOK_DEPTH; + const out = execFileSync(process.execPath, [path.resolve('skill/scripts/hook.mjs')], { + cwd, + input: JSON.stringify(grokStopEvent(sid)), + env, + encoding: 'utf-8', + }); + const payload = JSON.parse(out); + assert.equal(payload.hookSpecificOutput.hookEventName, 'Stop'); + assert.match(payload.hookSpecificOutput.additionalContext, /gradient-text/); + }); });