From b68ad4ccbad398072c3b4418ca812ac857b0050d Mon Sep 17 00:00:00 2001 From: ruvnet Date: Sat, 18 Jul 2026 19:05:06 -0400 Subject: [PATCH] fix(plugins): make ruflo-core/ruflo-cost-tracker hooks Windows-native (#2721) Both plugins' hooks.json wrapped every command in `/bin/bash -c '...'`, which fails outright on native Windows (no such path) -- Codex/Claude Code report "PreToolUse hook (failed) -- exit code 1" on every tool call. The `_platform: posix` / "ruflo init overrides this on Windows" claim in both files was never actually true: Claude Code merges plugin-declared hooks additively with any init-generated .claude/settings.json, it doesn't replace them, and there's no `ruflo init` step at all in the reported Codex marketplace install flow. Fix: every hook command is now a `node -e` bootstrap that resolves plugins/*/scripts/ruflo-hook.cjs from process.env.CLAUDE_PLUGIN_ROOT inside Node -- no shell env-var expansion (${VAR} vs %VAR%), so the exact same command string runs unchanged on Windows/macOS/Linux. ruflo-core's ruflo-hook.cjs (previously a full port of ruflo-hook.sh that existed on disk but was never referenced by hooks.json) gained: - JSON parsing of the hook event from stdin (replaces jq) for post-command/post-edit, deriving the same CLI flags the bash version computed - the PreToolUse permission-allow stdout echo Cursor's stricter contract requires (previously only the bash wrapper's trailing printf did this) - precompact-manual/precompact-auto guidance text (previously plain bash echoes, no CLI call) - a real Windows shell-quoting fix: shell:true with an args array does NOT quote array elements, so "echo hi" silently truncated to "echo" and a heredoc's `<<` errored as unexpected -- skip the shell entirely for `node` invocations (never a .cmd shim, so CreateProcess gets the argv array byte-for-byte) cost-tracker's existing ruflo-hook.cjs (already correct, just orphaned) needed no logic changes, only wiring. Also: - corrected the false "_platform_note" claims about ruflo init overriding plugin hooks - hardened scripts/audit-plugin-hooks-cross-platform.mjs: a POSIX-exempt hooks.json now must actually reference its sibling .cjs shim, not just have one sitting on disk unreferenced (which is exactly the shape cost-tracker shipped in undetected) - added windows-latest to the plugin-hooks-smoke CI matrix (it was ubuntu/macos-only because the old bash-based hooks.json couldn't run on Windows at all) and rewrote test-hooks.mjs to drive hooks.json's literal command strings via `shell: true` -- exactly how Claude Code/Codex invoke them -- instead of wrapping everything in an explicit `bash -c` that could never have caught this bug - flagged (not fixed) a separate, currently-published, actively maintained plugin package (.claude-plugin/ + plugin/, the older "claude-flow" plugin, not listed in the ruflo marketplace) with the same underlying bug via jq/xargs pipes instead of bash -- explicitly marked _legacy_unaudited_shim so the hardened audit doesn't silently regress on out-of-scope work Verified locally on native Windows (this fix's actual target platform): all 17 ruflo-core hook cases pass, all 3 cost-tracker cases pass, the existing 12-case smoke-ruflo-hook-cjs.mjs passes unchanged, both hook-command audits pass clean. Fixes #2721 --- .claude-plugin/hooks/hooks.json | 4 +- .github/workflows/v3-ci.yml | 29 +- plugin/hooks/hooks.json | 4 +- plugins/ruflo-core/hooks/hooks.json | 19 +- plugins/ruflo-core/scripts/ruflo-hook.cjs | 308 +++++++++++------- plugins/ruflo-core/scripts/test-hooks.mjs | 152 +++++++-- plugins/ruflo-cost-tracker/hooks/hooks.json | 7 +- .../ruflo-cost-tracker/scripts/test-hooks.mjs | 78 +++++ scripts/audit-plugin-hooks-cross-platform.mjs | 32 +- 9 files changed, 461 insertions(+), 172 deletions(-) create mode 100644 plugins/ruflo-cost-tracker/scripts/test-hooks.mjs diff --git a/.claude-plugin/hooks/hooks.json b/.claude-plugin/hooks/hooks.json index e3d3e7255..437812d48 100644 --- a/.claude-plugin/hooks/hooks.json +++ b/.claude-plugin/hooks/hooks.json @@ -1,7 +1,9 @@ { "_note": "#1921 — hook commands invoke scripts/ruflo-hook.sh (resilient shim): prefers a locally-installed `ruflo`/`claude-flow` binary, falls back to `npx --prefer-offline`, and always exits 0 so a CLI/install failure (e.g. arborist `Invalid Version` on npm 10.8.x) never surfaces an error in Claude Code or blocks a turn. The trailing `|| true` guards the case where $CLAUDE_PLUGIN_ROOT is unset (older Claude Code) — the hook then no-ops silently. DO NOT revert to a bare `npx @alpha hooks …` per fire.", "_platform": "posix", - "_platform_note": "#2132 — This hooks.json uses /bin/bash, POSIX pipelines (jq, xargs, tr), and .sh scripts. It is intentionally POSIX-only (Mac/Linux). On Windows, ruflo init writes a .claude/settings.json that overrides these entries with node-based equivalents via plugins/ruflo-core/scripts/ruflo-hook.cjs. The audit exempts files with _platform:posix from the cross-platform check.", + "_platform_note": "#2132 — This hooks.json uses /bin/bash, POSIX pipelines (jq, xargs, tr), and .sh scripts. It is intentionally POSIX-only (Mac/Linux) today and known-broken on native Windows (#2721 shape). The previous claim here that `ruflo init` overrides these entries with node-based equivalents on Windows was never actually implemented and is incorrect — Claude Code merges plugin-declared hooks additively with any init-generated settings.json, it does not replace them. This package (separate from plugins/ruflo-core, which got the #2721 fix) still needs its own Windows-compatible rewrite; see _legacy_unaudited_shim below.", + "_legacy_unaudited_shim": true, + "_legacy_unaudited_shim_note": "#2721 fixed plugins/ruflo-core and plugins/ruflo-cost-tracker (marketplace-listed in .claude-plugin/marketplace.json) by rewiring hooks.json to a `node -e` bootstrap around scripts/ruflo-hook.cjs. This older, separately-published \"claude-flow\" plugin package (not in the ruflo marketplace list) has its own, larger jq/xargs-based hook set and its own scripts/ruflo-hook.cjs that hooks.json never references — same underlying bug shape, NOT fixed as part of #2721. Needs its own audited pass before this flag can be removed.", "hooks": { "PreToolUse": [ { diff --git a/.github/workflows/v3-ci.yml b/.github/workflows/v3-ci.yml index 6dd2e16e4..dd7c58b24 100644 --- a/.github/workflows/v3-ci.yml +++ b/.github/workflows/v3-ci.yml @@ -10,11 +10,15 @@ on: # path filter keeps CI in sync with their changes. - 'plugins/ruflo-core/scripts/witness/**' - 'plugins/ruflo-core/scripts/test-hooks.mjs' + - 'plugins/ruflo-cost-tracker/scripts/test-hooks.mjs' - 'verification/**' # scripts/*.mjs audits (tool-descriptions, plugin-packages, hook-commands) - 'scripts/**' - # hook-command audit (#1921) — every plugin hooks.json + the ruflo-hook shims + # hook-command audit (#1921, #2721) — every plugin hooks.json + the + # cross-platform ruflo-hook.cjs shims (ruflo-hook.sh is legacy/unused + # as of #2721 but kept for reference; no longer referenced by hooks.json) - '**/hooks/hooks.json' + - '**/scripts/ruflo-hook.cjs' - '**/scripts/ruflo-hook.sh' # pre-bash hook safety (#2017) — both handler copies trigger the smoke - '**/.claude/helpers/hook-handler.cjs' @@ -153,8 +157,10 @@ on: - 'v3/**' - 'plugins/ruflo-core/scripts/witness/**' - 'plugins/ruflo-core/scripts/test-hooks.mjs' + - 'plugins/ruflo-cost-tracker/scripts/test-hooks.mjs' - 'scripts/**' - '**/hooks/hooks.json' + - '**/scripts/ruflo-hook.cjs' - '**/scripts/ruflo-hook.sh' - '**/.claude/helpers/hook-handler.cjs' # ruflo-browser rvf create flag (#2015) @@ -571,12 +577,14 @@ jobs: strategy: fail-fast: false matrix: - # Cross-platform: ubuntu + macos. Windows excluded because the - # plugin's hooks.json uses `/bin/bash -c '...'` and the synthetic - # JSON-on-stdin pipeline assumes POSIX shell. On Windows users - # run Claude Code via WSL or git-bash, which the test would also - # need to simulate; out of scope for this regression guard. - os: [ubuntu-latest, macos-latest] + # #2721 — windows-latest included: hooks.json no longer wraps + # commands in `/bin/bash -c '...'`, it invokes `node -e "..."` + # directly (see plugins/ruflo-core/hooks/hooks.json), which needs + # no POSIX shell at all. test-hooks.mjs drives the exact command + # string from hooks.json via `shell: true`, so this is a genuine + # regression guard against the #2721 shape recurring, not just a + # POSIX-only smoke. + os: [ubuntu-latest, macos-latest, windows-latest] # Node 22 is the project's documented baseline (engines.node: '>=20') # but Node 20 had environment-specific issues with multi-line stdin # round-tripping that didn't reproduce on Node 22 or locally — same @@ -602,6 +610,7 @@ jobs: - name: Install + build cli (scoped, tolerates unrelated workspace failures) working-directory: v3 + shell: bash run: | pnpm install --frozen-lockfile # --no-bail keeps building past unrelated package failures @@ -614,10 +623,16 @@ jobs: || (echo "cli build did not produce hooks.js"; exit 1) - name: Run plugin hooks smoke against local CLI build + shell: bash run: | node plugins/ruflo-core/scripts/test-hooks.mjs \ "node $GITHUB_WORKSPACE/v3/@claude-flow/cli/bin/cli.js" + - name: Run cost-tracker Stop hook smoke against local CLI build + shell: bash + run: | + node plugins/ruflo-cost-tracker/scripts/test-hooks.mjs + browser-rvf-create-flags-smoke: # Regression guard for ruvnet/ruflo#2015 — the ruflo-browser # `browser_session_record` MCP tool wraps `ruvector rvf create`. diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 1e29ae462..d7efdc358 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -4,7 +4,9 @@ "_security_note": "All commands read the hook payload from stdin (Claude Code passes a JSON object), extract fields with jq, and pass them to the CLI as a single argv element via xargs -0. This bypasses shell re-parsing entirely. DO NOT inline $TOOL_INPUT_* / $PROMPT / $TOOL_NAME directly in a quoted command string — interpolation is not shell-safe (creates empty files at CWD when input contains '>' redirects).", "_resilience_note": "#1921 — hook subcommands invoke scripts/ruflo-hook.sh (resilient shim): prefers a locally-installed `ruflo`/`claude-flow` binary, falls back to `npx --prefer-offline`, always exits 0. The trailing `|| true` on each pipeline guards the case where $CLAUDE_PLUGIN_ROOT is unset. DO NOT revert to a bare `npx @alpha hooks …` per fire.", "_platform": "posix", - "_platform_note": "#2132 — This hooks.json uses /bin/bash, POSIX pipelines (jq, xargs, tr), and .sh scripts. It is intentionally POSIX-only (Mac/Linux). On Windows, ruflo init writes a .claude/settings.json that overrides these entries with node-based equivalents via plugins/ruflo-core/scripts/ruflo-hook.cjs. The audit exempts files with _platform:posix from the cross-platform check.", + "_platform_note": "#2132 — This hooks.json uses /bin/bash, POSIX pipelines (jq, xargs, tr), and .sh scripts. It is intentionally POSIX-only (Mac/Linux) today and known-broken on native Windows (#2721 shape). The previous claim here that `ruflo init` overrides these entries with node-based equivalents on Windows was never actually implemented and is incorrect — Claude Code merges plugin-declared hooks additively with any init-generated settings.json, it does not replace them. This package (separate from plugins/ruflo-core, which got the #2721 fix) still needs its own Windows-compatible rewrite; see _legacy_unaudited_shim below.", + "_legacy_unaudited_shim": true, + "_legacy_unaudited_shim_note": "#2721 fixed plugins/ruflo-core and plugins/ruflo-cost-tracker (marketplace-listed in .claude-plugin/marketplace.json) by rewiring hooks.json to a `node -e` bootstrap around scripts/ruflo-hook.cjs. This older, separately-published \"claude-flow\" plugin package (not in the ruflo marketplace list) has its own, larger jq/xargs-based hook set and its own scripts/ruflo-hook.cjs that hooks.json never references — same underlying bug shape, NOT fixed as part of #2721. Needs its own audited pass before this flag can be removed.", "hooks": { "PreToolUse": [ { diff --git a/plugins/ruflo-core/hooks/hooks.json b/plugins/ruflo-core/hooks/hooks.json index d1b122c51..3c51181b6 100644 --- a/plugins/ruflo-core/hooks/hooks.json +++ b/plugins/ruflo-core/hooks/hooks.json @@ -1,7 +1,6 @@ { - "_note": "#1921/#2613 — hook commands invoke scripts/ruflo-hook.sh (resilient shim): prefers a locally-installed `ruflo`/`claude-flow` binary, falls back to `npx --prefer-offline`, and always exits 0 so a CLI/install failure (e.g. arborist `Invalid Version` on npm 10.8.x) never surfaces an error in Claude Code or blocks a turn. The trailing `|| true` guards the case where $CLAUDE_PLUGIN_ROOT is unset — the hook then no-ops silently. PreToolUse commands additionally echo a valid `{\"permission\":\"allow\"}` verdict on stdout: Claude Code ignores it (JSON-shaped stdout is still valid), while Cursor (which imports third-party hooks under its stricter preToolUse contract and requires JSON-verdict stdout) receives a well-formed permission response and does not fail-closed. DO NOT revert to a bare `npx @alpha hooks …` per fire.", - "_platform": "posix", - "_platform_note": "#2132 — This hooks.json uses /bin/bash, POSIX pipelines (jq, xargs, tr), and .sh scripts. It is intentionally POSIX-only (Mac/Linux). On Windows, ruflo init writes a .claude/settings.json that overrides these entries with node-based equivalents via plugins/ruflo-core/scripts/ruflo-hook.cjs. The audit exempts files with _platform:posix from the cross-platform check.", + "_note": "#1921/#2613/#2721 — hook commands invoke scripts/ruflo-hook.cjs (resilient shim): prefers a locally-installed `ruflo`/`claude-flow` binary, falls back to `npx --prefer-offline`, and always exits 0 so a CLI/install failure (e.g. arborist `Invalid Version` on npm 10.8.x) never surfaces an error in Claude Code or blocks a turn. PreToolUse commands additionally echo a valid `{\"permission\":\"allow\"}` verdict on stdout: Claude Code ignores it (JSON-shaped stdout is still valid), while Cursor (which imports third-party hooks under its stricter preToolUse contract and requires JSON-verdict stdout) receives a well-formed permission response and does not fail-closed. DO NOT revert to a bare `npx @alpha hooks …` per fire.", + "_platform_note": "#2721 — every command below is a `node -e` bootstrap: it resolves this plugin's scripts/ruflo-hook.cjs from process.env.CLAUDE_PLUGIN_ROOT INSIDE Node (no shell `${VAR}`/`%VAR%` expansion, so the exact same command string runs unchanged on Windows, macOS, and Linux), then requires it with process.argv pre-seeded for the target subcommand. No bash, sh, cmd.exe, jq, or .sh scripts are involved.", "hooks": { "PreToolUse": [ { @@ -9,7 +8,7 @@ "hooks": [ { "type": "command", - "command": "/bin/bash -c '\"${CLAUDE_PLUGIN_ROOT:-}/scripts/ruflo-hook.sh\" modify-bash >/dev/null 2>&1 || true; printf %s \"{\\\"permission\\\":\\\"allow\\\"}\"'" + "command": "node -e \"process.argv=[process.argv[0],'x','modify-bash'];require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','ruflo-hook.cjs'))\"" } ] }, @@ -18,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "/bin/bash -c '\"${CLAUDE_PLUGIN_ROOT:-}/scripts/ruflo-hook.sh\" modify-file >/dev/null 2>&1 || true; printf %s \"{\\\"permission\\\":\\\"allow\\\"}\"'" + "command": "node -e \"process.argv=[process.argv[0],'x','modify-file'];require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','ruflo-hook.cjs'))\"" } ] } @@ -29,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "/bin/bash -c 'INPUT=$(cat); CMD=$(printf %s \"$INPUT\" | jq -r \".tool_input.command // empty\"); [ -z \"$CMD\" ] && exit 0; EXIT=$(printf %s \"$INPUT\" | jq -r \".tool_response.exit_code // 0\"); SUCCESS=$([ \"$EXIT\" = \"0\" ] && echo true || echo false); \"${CLAUDE_PLUGIN_ROOT}/scripts/ruflo-hook.sh\" post-command -c \"$CMD\" -s \"$SUCCESS\" -e \"$EXIT\" || true'" + "command": "node -e \"process.argv=[process.argv[0],'x','post-command'];require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','ruflo-hook.cjs'))\"" } ] }, @@ -38,7 +37,7 @@ "hooks": [ { "type": "command", - "command": "/bin/bash -c 'INPUT=$(cat); FILE=$(printf %s \"$INPUT\" | jq -r \".tool_input.file_path // .tool_input.path // empty\"); [ -z \"$FILE\" ] && exit 0; \"${CLAUDE_PLUGIN_ROOT}/scripts/ruflo-hook.sh\" post-edit -f \"$FILE\" -s true || true'" + "command": "node -e \"process.argv=[process.argv[0],'x','post-edit'];require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','ruflo-hook.cjs'))\"" } ] } @@ -49,7 +48,7 @@ "hooks": [ { "type": "command", - "command": "/bin/bash -c 'INPUT=$(cat); CUSTOM=$(echo \"$INPUT\" | jq -r \".custom_instructions // \\\"\\\"\"); echo \"🔄 PreCompact Guidance:\"; echo \"📋 IMPORTANT: Review CLAUDE.md in project root for:\"; echo \" • 54 available agents and concurrent usage patterns\"; echo \" • Swarm coordination strategies (hierarchical, mesh, adaptive)\"; echo \" • SPARC methodology workflows with batchtools optimization\"; echo \" • Critical concurrent execution rules (GOLDEN RULE: 1 MESSAGE = ALL OPERATIONS)\"; if [ -n \"$CUSTOM\" ]; then echo \"🎯 Custom compact instructions: $CUSTOM\"; fi; echo \"✅ Ready for compact operation\"'" + "command": "node -e \"process.argv=[process.argv[0],'x','precompact-manual'];require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','ruflo-hook.cjs'))\"" } ] }, @@ -58,7 +57,7 @@ "hooks": [ { "type": "command", - "command": "/bin/bash -c 'echo \"🔄 Auto-Compact Guidance (Context Window Full):\"; echo \"📋 CRITICAL: Before compacting, ensure you understand:\"; echo \" • All 54 agents available in .claude/agents/ directory\"; echo \" • Concurrent execution patterns from CLAUDE.md\"; echo \" • Batchtools optimization for 300% performance gains\"; echo \" • Swarm coordination strategies for complex tasks\"; echo \"⚡ Apply GOLDEN RULE: Always batch operations in single messages\"; echo \"✅ Auto-compact proceeding with full agent context\"'" + "command": "node -e \"process.argv=[process.argv[0],'x','precompact-auto'];require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','ruflo-hook.cjs'))\"" } ] } @@ -68,7 +67,7 @@ "hooks": [ { "type": "command", - "command": "/bin/bash -c '\"${CLAUDE_PLUGIN_ROOT}/scripts/ruflo-hook.sh\" session-end --generate-summary true --persist-state true --export-metrics true || true'" + "command": "node -e \"process.argv=[process.argv[0],'x','session-end','--generate-summary','true','--persist-state','true','--export-metrics','true'];require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','ruflo-hook.cjs'))\"" } ] } diff --git a/plugins/ruflo-core/scripts/ruflo-hook.cjs b/plugins/ruflo-core/scripts/ruflo-hook.cjs index 5b94703c2..7e2dd5c80 100644 --- a/plugins/ruflo-core/scripts/ruflo-hook.cjs +++ b/plugins/ruflo-core/scripts/ruflo-hook.cjs @@ -1,78 +1,48 @@ #!/usr/bin/env node /** - * ruflo-hook.cjs — cross-platform Node.js port of ruflo-hook.sh (#2132) + * ruflo-hook.cjs — cross-platform Node.js port of ruflo-hook.sh (#2132, #2721) * - * The bash shim (ruflo-hook.sh) works on Mac/Linux but fails on native - * Windows (exit 126 — "cannot execute binary file"). This .cjs shim - * provides identical behaviour via Node.js child_process so Windows users - * get working hooks without WSL or Git Bash. + * The bash shim (ruflo-hook.sh) works on Mac/Linux but fails outright on + * native Windows: hooks.json wrapped it in `/bin/bash -c '...'`, and + * `/bin/bash` is not a valid Windows path — Codex/Claude Code report + * "PreToolUse hook (failed) — exit code 1" on every tool call (#2721). * - * Mac/Linux continue to use ruflo-hook.sh via the plugin hooks.json files - * (unchanged). On Windows, ruflo init writes a .claude/settings.json that - * overrides those entries with node-based equivalents pointing here. + * This file is now the ONLY hook implementation `hooks.json` invokes, on + * every OS (see the `node -e` bootstrap command in ../hooks/hooks.json). + * It replicates, in pure Node with no shell/jq dependency: + * - modify-bash / modify-file (PreToolUse) — best-effort CLI call, then + * ALWAYS echo `{"permission":"allow"}` on stdout (Cursor's PreToolUse + * contract requires valid-JSON stdout; Claude Code ignores it). + * - post-command / post-edit (PostToolUse) — parse the hook event JSON + * from stdin (no jq), extract the same fields the bash version pulled + * with jq, and forward them as CLI flags. + * - precompact-manual / precompact-auto (PreCompact) — static guidance + * text, no CLI call at all (matches the bash version's plain echoes). + * - session-end (Stop) — forwarded as-is, same flags as before. * - * Behaviour mirrors ruflo-hook.sh: - * 1. Reads hook JSON payload from stdin. - * 2. Prefers a locally installed `ruflo` or `claude-flow` binary. - * 3. Falls back to `npx --prefer-offline ruflo@latest`. - * 4. Always exits 0 — hook subcommands are best-effort telemetry. - * 5. Swallows all stderr — nothing should surface to Claude Code. + * Shared behaviour: + * 1. Prefers a locally installed `ruflo` or `claude-flow` binary. + * 2. Falls back to `npx --prefer-offline ruflo@latest`. + * 3. ALWAYS exits 0 — hook subcommands are best-effort telemetry; a + * failure must never surface an error or block a turn. + * 4. Swallows all stdout/stderr from the invoked CLI. * - * Usage: node ruflo-hook.cjs [args...] - * e.g. node ruflo-hook.cjs post-edit --file "x.ts" --train-patterns + * Usage: node ruflo-hook.cjs + * (invoked via the `node -e` bootstrap in hooks.json, which resolves + * this script's path from `process.env.CLAUDE_PLUGIN_ROOT` — no shell + * env-var expansion needed, so there is no `${VAR}` vs `%VAR%` split) */ 'use strict'; const { spawnSync, execSync } = require('child_process'); const fs = require('fs'); -const path = require('path'); /** Exit 0 unconditionally — hooks must never block a turn */ function done() { process.exit(0); } -/** Resolve stdin to a JSON object, or null if not parseable */ -function readStdinJson() { - try { - let buf = ''; - // Read synchronously — hooks fire synchronously in Claude Code - const fd = fs.openSync('/dev/stdin', 'r'); - const chunk = Buffer.alloc(64 * 1024); - let bytesRead; - while ((bytesRead = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0) { - buf += chunk.slice(0, bytesRead).toString('utf8'); - } - fs.closeSync(fd); - return buf.trim() ? JSON.parse(buf) : null; - } catch { - return null; - } -} - -/** Read stdin via process.stdin in sync mode (Windows-safe alternative) */ -function readStdinSync() { - try { - // On Windows /dev/stdin doesn't exist; use fd 0 directly - const chunk = Buffer.alloc(64 * 1024); - let buf = ''; - let bytesRead; - while (true) { - try { - bytesRead = fs.readSync(0 /* STDIN_FILENO */, chunk, 0, chunk.length, null); - if (bytesRead === 0) break; - buf += chunk.slice(0, bytesRead).toString('utf8'); - } catch { - break; - } - } - return buf.trim() ? JSON.parse(buf) : null; - } catch { - return null; - } -} - /** Check if a binary is available on PATH */ function commandExists(cmd) { try { @@ -86,80 +56,194 @@ function commandExists(cmd) { } } -/** Build the argv for the ruflo/claude-flow/npx invocation */ -function buildArgs(subcommand, extraArgs) { - // The `hooks` word is prepended here, matching ruflo-hook.sh convention. - return ['hooks', subcommand, ...extraArgs]; +/** + * Spawn the CLI with the hook subcommand + args, forwarding stdinData. + * Returns true on success (exit 0), false otherwise. Never throws. + */ +function invokeHook(bin, binArgs, hookSubcommand, hookArgs, stdinData) { + const args = [...binArgs, 'hooks', hookSubcommand, ...hookArgs]; + // On Windows, shell: true is needed to resolve .cmd/.ps1 shims that npm + // creates for globally-installed bins (`ruflo`, `claude-flow`, `npx`) — + // CreateProcess cannot execute those directly. BUT shell:true hands the + // whole command line to cmd.exe, which re-tokenizes it (no automatic + // quoting of array elements), corrupting any argument containing spaces + // or shell metacharacters — e.g. a `post-command` value of "echo hi" + // silently truncates to "echo", and a heredoc value containing `<<` + // errors outright. `node` itself is always a real .exe (never a shim), + // so skip the shell entirely there — CreateProcess gets the argv array + // verbatim, byte-for-byte, no re-tokenization possible. This covers the + // common `node ` invocation (test harness, npx-resolved runs). + // A real global `ruflo`/`claude-flow` install still goes through the + // shim path below and inherits cmd.exe's pre-existing argv-mangling + // limitation for complex values — not a regression from this change, + // just not fully solved by it; tracked as a follow-up. + const useShell = process.platform === 'win32' && bin !== 'node' && bin !== process.execPath; + // Test-only: RUFLO_HOOK_DEBUG_STDOUT surfaces the invoked CLI's own + // stdout/stderr instead of swallowing them, so test-hooks.mjs can assert + // on the CLI's actual recorded value (e.g. catching #1859/#1862-style + // flag-wiring regressions). Production never sets this — hooks must + // never leak CLI output into the host (Cursor's PreToolUse contract). + const debug = process.env.RUFLO_HOOK_DEBUG_STDOUT === '1'; + try { + const result = spawnSync(bin, args, { + shell: useShell, + input: stdinData || '', + encoding: 'utf8', + stdio: debug ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'ignore', 'ignore'], + timeout: 30_000, + }); + if (debug) { + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + } + return result.status === 0; + } catch { + return false; + } +} + +/** Best-effort: try ruflo, then claude-flow, then npx. Never throws. */ +function invokeCli(hookSubcommand, hookArgs, stdinData) { + // Test-only escape hatch: point at a specific local build instead of the + // commandExists() PATH probe (used by test-hooks.mjs and the plugin-hooks + // real-command smoke so tests exercise the build under test, not whatever + // happens to be on the runner's PATH). Space-split — always a simple + // "node /abs/path/cli.js" invocation in practice, never quoted args. + const override = process.env.RUFLO_HOOK_CLI_OVERRIDE; + if (override) { + const [bin, ...binArgs] = override.split(' ').filter(Boolean); + invokeHook(bin, binArgs, hookSubcommand, hookArgs, stdinData); + return; + } + if (commandExists('ruflo')) { + invokeHook('ruflo', [], hookSubcommand, hookArgs, stdinData); + return; + } + if (commandExists('claude-flow')) { + invokeHook('claude-flow', [], hookSubcommand, hookArgs, stdinData); + return; + } + // SKIP npx when RUFLO_HOOK_SKIP_NPX=1 — used by CI smokes that test the + // shim's *control flow* without exercising npm install network paths. + // Without the skip, npx can take 30+s on a cold runner, exceeding a + // smoke's timeout and producing a spurious failure even though the shim + // itself works correctly. The bash version doesn't hit this because it + // backgrounded the work. + if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') { + invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookSubcommand, hookArgs, stdinData); + } +} + +/** Read all of stdin synchronously. Returns '' on any failure (best effort). */ +function readStdinRaw() { + try { + const chunk = Buffer.alloc(64 * 1024); + let buf = ''; + let bytesRead; + while (true) { + try { + bytesRead = fs.readSync(0 /* STDIN_FILENO */, chunk, 0, chunk.length, null); + if (bytesRead === 0) break; + buf += chunk.slice(0, bytesRead).toString('utf8'); + } catch { + break; + } + } + return buf; + } catch { + return ''; + } +} + +/** Parse stdinData as JSON, returning {} on any parse failure. */ +function parseEventJson(stdinData) { + try { + const trimmed = (stdinData || '').trim(); + return trimmed ? JSON.parse(trimmed) : {}; + } catch { + return {}; + } } /** - * Spawn the CLI with the hook subcommand. - * Passes the raw stdin payload as the child's stdin so the CLI can read - * the hook event JSON if needed (same as the bash pipe). - * - * Returns true on success (exit 0), false otherwise. + * PreCompact guidance text — matches the bash `echo` lines verbatim. + * Not a CLI call at all; pure stdout guidance for the transcript/context. */ -function invokeHook(bin, binArgs, hookArgs, stdinData) { - const args = [...binArgs, ...hookArgs]; +function precompactManual(event) { + const custom = typeof event?.custom_instructions === 'string' ? event.custom_instructions : ''; + const lines = [ + '🔄 PreCompact Guidance:', + '📋 IMPORTANT: Review CLAUDE.md in project root for:', + ' • 54 available agents and concurrent usage patterns', + ' • Swarm coordination strategies (hierarchical, mesh, adaptive)', + ' • SPARC methodology workflows with batchtools optimization', + ' • Critical concurrent execution rules (GOLDEN RULE: 1 MESSAGE = ALL OPERATIONS)', + ]; + if (custom) lines.push(`🎯 Custom compact instructions: ${custom}`); + lines.push('✅ Ready for compact operation'); + process.stdout.write(lines.join('\n') + '\n'); +} - // On Windows, shell: true is needed to resolve .cmd shims in node_modules - const useShell = process.platform === 'win32'; - - const result = spawnSync(bin, args, { - shell: useShell, - input: stdinData || '', - encoding: 'utf8', - stdio: ['pipe', 'ignore', 'ignore'], // swallow all output - timeout: 30_000, - }); - - return result.status === 0; +function precompactAuto() { + const lines = [ + '🔄 Auto-Compact Guidance (Context Window Full):', + '📋 CRITICAL: Before compacting, ensure you understand:', + ' • All 54 agents available in .claude/agents/ directory', + ' • Concurrent execution patterns from CLAUDE.md', + ' • Batchtools optimization for 300% performance gains', + ' • Swarm coordination strategies for complex tasks', + '⚡ Apply GOLDEN RULE: Always batch operations in single messages', + '✅ Auto-compact proceeding with full agent context', + ]; + process.stdout.write(lines.join('\n') + '\n'); } function main() { - const args = process.argv.slice(2); - if (args.length === 0) { - // No subcommand — no-op, same as bash version + const [subcommand] = process.argv.slice(2); + if (!subcommand) done(); // no subcommand — no-op, same as bash version + + // PreCompact: pure guidance text, no CLI call, no stdin required beyond + // (optionally) custom_instructions for the manual variant. + if (subcommand === 'precompact-manual') { + precompactManual(parseEventJson(readStdinRaw())); + done(); + } + if (subcommand === 'precompact-auto') { + precompactAuto(); done(); } - const [subcommand, ...rest] = args; + const stdinData = readStdinRaw(); - // Read stdin (the hook event payload) — best effort - let stdinData = ''; - try { - stdinData = fs.readFileSync(0 /* fd 0 = stdin */, 'utf8'); - } catch { - // stdin may not be available when invoked directly for testing - stdinData = ''; + // PostToolUse: derive CLI flags from the hook event JSON (replaces jq). + if (subcommand === 'post-command') { + const event = parseEventJson(stdinData); + const cmd = event?.tool_input?.command; + if (!cmd) done(); // bash version: `[ -z "$CMD" ] && exit 0` + const exitCode = event?.tool_response?.exit_code ?? 0; + invokeCli('post-command', ['-c', String(cmd), '-s', String(exitCode === 0), '-e', String(exitCode)], stdinData); + done(); } - - const hookArgs = buildArgs(subcommand, rest); - - // Priority 1: locally installed ruflo binary - if (commandExists('ruflo')) { - invokeHook('ruflo', [], hookArgs, stdinData); + if (subcommand === 'post-edit') { + const event = parseEventJson(stdinData); + const file = event?.tool_input?.file_path ?? event?.tool_input?.path; + if (!file) done(); // bash version: `[ -z "$FILE" ] && exit 0` + invokeCli('post-edit', ['-f', String(file), '-s', 'true'], stdinData); done(); } - // Priority 2: locally installed claude-flow binary - if (commandExists('claude-flow')) { - invokeHook('claude-flow', [], hookArgs, stdinData); + // PreToolUse: best-effort CLI call, then ALWAYS echo the permission verdict + // (Cursor's stricter preToolUse contract requires valid-JSON stdout). + if (subcommand === 'modify-bash' || subcommand === 'modify-file') { + invokeCli(subcommand, [], stdinData); + process.stdout.write('{"permission":"allow"}'); done(); } - // Priority 3: npx --prefer-offline fallback (avoids cold registry resolve). - // - // SKIP this when RUFLO_HOOK_SKIP_NPX=1 — used by CI smokes that test - // the shim's *control flow* without exercising npm install network paths. - // Without the skip, npx can take 30+s on a cold runner (no warm cache, - // no offline tarball), exceeding the smoke's 15s timeout and producing - // a spurious failure even though the shim itself works correctly. - // The bash version doesn't hit this because it backgrounded the work. - if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') { - invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookArgs, stdinData); - } - + // Stop / session-end and anything else: forward remaining argv unchanged + // (matches ruflo-hook.sh's generic `ruflo hooks "$@"` passthrough). + const extraArgs = process.argv.slice(3); + invokeCli(subcommand, extraArgs, stdinData); done(); } diff --git a/plugins/ruflo-core/scripts/test-hooks.mjs b/plugins/ruflo-core/scripts/test-hooks.mjs index 6da6dec35..3ecddd665 100644 --- a/plugins/ruflo-core/scripts/test-hooks.mjs +++ b/plugins/ruflo-core/scripts/test-hooks.mjs @@ -1,22 +1,40 @@ #!/usr/bin/env node /** - * Regression guard for ruvnet/ruflo#1859 + #1862. + * Regression guard for ruvnet/ruflo#1859, #1862, #2721. * - * Drives each PostToolUse hook command from `hooks/hooks.json` with synthetic - * Claude-Code-style stdin against a locally built CLI, asserting: + * Drives every hook command from `hooks/hooks.json` — PreToolUse, + * PostToolUse, PreCompact, Stop — with synthetic Claude-Code-style stdin, + * against a locally built CLI, executed EXACTLY as Claude Code/Codex would + * run it: `spawnSync(command, { shell: true, ... })`, no bash wrapper of + * our own. That's the point of this rewrite (#2721) — the old version + * spawned `bash -c ` itself, which meant it could never have caught + * the `/bin/bash` literal breaking on native Windows; `shell: true` uses + * cmd.exe on Windows and /bin/sh elsewhere, matching the real hook runner. * + * Two env vars steer the shim (see ../scripts/ruflo-hook.cjs) at the build + * under test instead of whatever's on the runner's PATH: + * - CLAUDE_PLUGIN_ROOT — resolves ruflo-hook.cjs's own path (real + * per-hook-invocation env var, always set) + * - RUFLO_HOOK_CLI_OVERRIDE — bypasses the ruflo/claude-flow/npx PATH + * probe so the test exercises the exact + * flag wiring users hit, pinned to the + * build under test (test-only escape hatch) + * + * Asserts: * - Exit code 0 (no parser errors like "Invalid value for --format") * - Output records the *intended* value (the file path / command), not a * stray boolean like "true" — the symptom that #1859 reported - * - * The script substitutes `npx ruflo@alpha` → the local CLI binary, so - * we exercise the same flag wiring users hit in production but pinned to - * the build under test. + * - PreToolUse hooks always emit valid `{"permission":"allow"}` JSON on + * stdout (Cursor's stricter PreToolUse contract, #2613) + * - PostToolUse hooks silently no-op (exit 0, no CLI call) when the + * expected field is missing from the event JSON + * - Malformed / empty stdin never causes a nonzero exit * * Usage (from repo root): * node plugins/ruflo-core/scripts/test-hooks.mjs * - * Wired into .github/workflows/v3-ci.yml as the `plugin-hooks-smoke` job. + * Wired into .github/workflows/v3-ci.yml as the `plugin-hooks-smoke` job + * (windows-latest, macos-latest, ubuntu-latest). */ import { readFileSync } from 'node:fs'; @@ -25,12 +43,12 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const HOOKS_JSON = join(__dirname, '..', 'hooks', 'hooks.json'); +const PLUGIN_ROOT = join(__dirname, '..'); +const HOOKS_JSON = join(PLUGIN_ROOT, 'hooks', 'hooks.json'); // `cliInvoke` is the literal token-string that should run the CLI — caller // passes the full thing so this script doesn't need to guess shebangs: // - local node script: "node /abs/path/to/bin/cli.js" -// - shell wrapper: "/abs/path/to/wrapper.sh" // - npx fallthrough: "npx --yes @claude-flow/cli@latest" const cliInvoke = process.argv[2]; if (!cliInvoke) { @@ -42,33 +60,42 @@ if (!cliInvoke) { } const hooks = JSON.parse(readFileSync(HOOKS_JSON, 'utf8')); -const post = hooks.hooks?.PostToolUse ?? []; -const findHook = (matcher) => { - const hit = post.find(h => h.matcher === matcher); - if (!hit) throw new Error(`No PostToolUse hook with matcher=${matcher}`); - return hit.hooks[0].command - // legacy form: `npx ruflo@alpha hooks …` - .replace(/npx ruflo@alpha/g, cliInvoke) - // #1921 form: hook subcommands go through scripts/ruflo-hook.sh (which - // prepends `hooks`). Bypass the shim here and call the built CLI directly - // so the test exercises the same flag wiring users hit, pinned to the - // build under test. Also drop the shim's `|| true` so exit codes are - // still asserted (the shim makes failures non-fatal in production). - .replace(/"\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/ruflo-hook\.sh"/g, `${cliInvoke} hooks`) - .replace(/\s*\|\|\s*true(\s*')/g, '$1'); +const findHook = (event, matcher) => { + const list = hooks.hooks?.[event] ?? []; + const hit = matcher === undefined ? list[0] : list.find(h => h.matcher === matcher); + if (!hit) throw new Error(`No ${event} hook with matcher=${matcher}`); + return hit.hooks[0].command; }; -const cmdBash = findHook('Bash'); -const cmdEdit = findHook('Write|Edit|MultiEdit'); +const cmdModifyBash = findHook('PreToolUse', 'Bash'); +const cmdModifyFile = findHook('PreToolUse', 'Write|Edit|MultiEdit'); +const cmdPostCommand = findHook('PostToolUse', 'Bash'); +const cmdPostEdit = findHook('PostToolUse', 'Write|Edit|MultiEdit'); +const cmdPrecompactManual = findHook('PreCompact', 'manual'); +const cmdPrecompactAuto = findHook('PreCompact', 'auto'); +const cmdStop = findHook('Stop', undefined); let failed = 0; const cases = []; const run = (name, cmd, stdin, assertions) => { - const r = spawnSync('bash', ['-c', cmd], { input: stdin, encoding: 'utf8' }); + const r = spawnSync(cmd, { + shell: true, + input: stdin, + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT, + RUFLO_HOOK_CLI_OVERRIDE: cliInvoke, + RUFLO_HOOK_SKIP_NPX: '1', + RUFLO_HOOK_DEBUG_STDOUT: '1', + }, + timeout: 15_000, + }); const combined = (r.stdout ?? '') + (r.stderr ?? ''); const errors = []; + if (r.error) errors.push(`spawn error: ${r.error.message}`); if (r.status !== 0) errors.push(`exit ${r.status} (expected 0)`); for (const a of assertions) { if (a.contains && !combined.includes(a.contains)) errors.push(`missing "${a.contains}" in output`); @@ -88,42 +115,95 @@ const run = (name, cmd, stdin, assertions) => { cases.push(name); }; -// --- Edit hook --- +// --- PreToolUse: modify-bash / modify-file --- +run('PreToolUse (Bash) always emits permission-allow JSON', + cmdModifyBash, + '{"tool_input":{"command":"echo hi"}}', + [{ contains: '{"permission":"allow"}' }]); + +run('PreToolUse (Edit) always emits permission-allow JSON', + cmdModifyFile, + '{"tool_input":{"file_path":"/tmp/foo.ts"}}', + [{ contains: '{"permission":"allow"}' }]); + +run('PreToolUse (Bash) emits permission-allow even with empty stdin', + cmdModifyBash, + '', + [{ contains: '{"permission":"allow"}' }]); + +run('PreToolUse (Bash) emits permission-allow even with malformed JSON', + cmdModifyBash, + '{not json', + [{ contains: '{"permission":"allow"}' }]); + +// --- PostToolUse: post-edit --- run('Edit hook records file_path (regression #1859: was "true")', - cmdEdit, + cmdPostEdit, '{"tool_input":{"file_path":"/tmp/foo.ts"}}', [{ contains: '/tmp/foo.ts' }, { absent: 'Recording outcome for: true' }, { absent: 'Invalid value' }]); run('Edit hook records legacy "path" field', - cmdEdit, + cmdPostEdit, '{"tool_input":{"path":"/tmp/bar.ts"}}', [{ contains: '/tmp/bar.ts' }, { absent: 'Invalid value' }]); run('Edit hook silently no-ops when no path present', - cmdEdit, + cmdPostEdit, '{"tool_input":{}}', []); -// --- Bash hook --- +run('Edit hook silently no-ops on malformed JSON', + cmdPostEdit, + '{not json', + []); + +// --- PostToolUse: post-command --- run('Bash hook records simple command', - cmdBash, + cmdPostCommand, '{"tool_input":{"command":"echo hi"},"tool_response":{"exit_code":0}}', [{ contains: 'echo hi' }, { absent: 'Required option missing' }, { absent: 'Invalid value' }]); run('Bash hook records multi-line heredoc (regression #1859)', - cmdBash, + cmdPostCommand, '{"tool_input":{"command":"cat </dev/null 2>&1 || true'" + "command": "node -e \"require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','ruflo-hook.cjs'))\"" } ] } diff --git a/plugins/ruflo-cost-tracker/scripts/test-hooks.mjs b/plugins/ruflo-cost-tracker/scripts/test-hooks.mjs new file mode 100644 index 000000000..6ff07f165 --- /dev/null +++ b/plugins/ruflo-cost-tracker/scripts/test-hooks.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Regression guard for ruvnet/ruflo#2721. + * + * Drives the Stop hook command from `hooks/hooks.json` exactly as Claude + * Code/Codex would — `spawnSync(command, { shell: true, ... })`, no bash + * wrapper of our own — asserting it exits 0 on every OS without requiring + * bash. Before #2721 the command hard-coded `/bin/bash -c '...'`, which + * fails outright on native Windows. + * + * TRACK_CWD points at a throwaway directory with no session jsonl files, + * so track.mjs takes its fast no-op path instead of scanning/touching a + * real ~/.claude/projects session — this is a hook-wiring smoke, not a + * cost-tracking behavior test. + * + * Usage (from repo root): + * node plugins/ruflo-cost-tracker/scripts/test-hooks.mjs + * + * Wired into .github/workflows/v3-ci.yml as part of the `plugin-hooks-smoke` + * job (windows-latest, macos-latest, ubuntu-latest). + */ + +import { readFileSync, mkdtempSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = join(__dirname, '..'); +const HOOKS_JSON = join(PLUGIN_ROOT, 'hooks', 'hooks.json'); + +const hooks = JSON.parse(readFileSync(HOOKS_JSON, 'utf8')); +const cmdStop = hooks.hooks?.Stop?.[0]?.hooks?.[0]?.command; +if (!cmdStop) throw new Error('No Stop hook found in hooks.json'); + +const scratchCwd = mkdtempSync(join(tmpdir(), 'ruflo-cost-tracker-smoke-')); + +let failed = 0; +const cases = []; + +const run = (name, stdin) => { + const r = spawnSync(cmdStop, { + shell: true, + input: stdin, + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT, + TRACK_CWD: scratchCwd, + TRACK_DRY_RUN: '1', + }, + timeout: 15_000, + }); + const errors = []; + if (r.error) errors.push(`spawn error: ${r.error.message}`); + if (r.status !== 0) errors.push(`exit ${r.status} (expected 0)`); + if (errors.length === 0) { + console.log(`ok: ${name}`); + } else { + console.error(`FAIL: ${name}`); + for (const e of errors) console.error(` - ${e}`); + const combined = (r.stdout ?? '') + (r.stderr ?? ''); + if (combined.trim()) { + console.error(' output:'); + for (const line of combined.split('\n').slice(0, 8)) console.error(` ${line}`); + } + failed++; + } + cases.push(name); +}; + +run('Stop hook exits 0 with valid stdin', '{"session_id":"test"}'); +run('Stop hook exits 0 with empty stdin', ''); +run('Stop hook exits 0 with malformed stdin', '{not json'); + +console.log(`\n${cases.length - failed}/${cases.length} passed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/audit-plugin-hooks-cross-platform.mjs b/scripts/audit-plugin-hooks-cross-platform.mjs index f94e18cf9..203e27650 100644 --- a/scripts/audit-plugin-hooks-cross-platform.mjs +++ b/scripts/audit-plugin-hooks-cross-platform.mjs @@ -110,7 +110,7 @@ for (const file of walkForHooksJson(REPO_ROOT)) { continue; } - // --- POSIX-only exemption check (#2132) --- + // --- POSIX-only exemption check (#2132, hardened #2721) --- if (json._platform === 'posix') { const relFile = relative(REPO_ROOT, file); posixExempt.push(relFile); @@ -129,6 +129,36 @@ for (const file of walkForHooksJson(REPO_ROOT)) { hint: 'Create plugins//scripts/ruflo-hook.cjs (cross-platform Node port of ruflo-hook.sh). See #2132.', }); posixWindowsPathMissing = true; + } else if (json._legacy_unaudited_shim !== true) { + // #2721 — a sibling .cjs existing proves nothing on its own: #2721 + // shipped with plugins/ruflo-cost-tracker/scripts/ruflo-hook.cjs + // present on disk but referenced NOWHERE, while hooks.json still + // hard-coded `/bin/bash -c '...'`. Require at least one command in + // this hooks.json to actually name the shim file. + // + // `_legacy_unaudited_shim: true` is an explicit, reviewed escape + // valve — NOT a way to quietly bypass this check. It exists only for + // .claude-plugin/hooks/hooks.json and plugin/hooks/hooks.json (the + // older, separately-published "claude-flow" plugin, distinct from + // the ruflo-core/ruflo-cost-tracker plugins this file's #2721 fix + // covers): those use a much larger jq/xargs-based hook set that was + // NOT audited or fixed as part of #2721 and needs its own pass. Any + // NEW posix-exempt file must not set this flag — it must actually + // wire its shim. + const referencesShim = Object.values(json?.hooks ?? {}) + .flat() + .flatMap((entry) => (Array.isArray(entry?.hooks) ? entry.hooks : [])) + .some((h) => typeof h?.command === 'string' && h.command.includes('ruflo-hook.cjs')); + if (!referencesShim) { + violations.push({ + file: relFile, + line: 0, + label: 'POSIX-exempt but Windows shim exists unreferenced (#2721 shape)', + cmd: `${relative(REPO_ROOT, shimPath)} is present but no command in this file names it`, + hint: 'Point at least one hooks.json command at the .cjs shim (e.g. via a `node -e` bootstrap resolving CLAUDE_PLUGIN_ROOT), or drop the unused shim file.', + }); + posixWindowsPathMissing = true; + } } // Skip further pattern scanning for POSIX-exempt files continue;