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
This commit is contained in:
ruvnet
2026-07-18 19:05:06 -04:00
parent 2a4db26721
commit b68ad4ccba
9 changed files with 461 additions and 172 deletions
+3 -1
View File
@@ -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 <pkg>@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": [
{
+22 -7
View File
@@ -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`.
+3 -1
View File
@@ -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 <pkg>@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": [
{
+9 -10
View File
@@ -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 <pkg>@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 <pkg>@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'))\""
}
]
}
+196 -112
View File
@@ -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 <hook-subcommand> [args...]
* e.g. node ruflo-hook.cjs post-edit --file "x.ts" --train-patterns
* Usage: node ruflo-hook.cjs <hook-subcommand>
* (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 <cli.js>` 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();
}
+116 -36
View File
@@ -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 <cmd>` 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 <path-to-cli-binary>
*
* 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 <<EOF\\nline1\\nline2\\nEOF"},"tool_response":{"exit_code":0}}',
[{ contains: 'cat <<EOF' }, { absent: 'Required option missing' }]);
run('Bash hook records non-zero exit (distinct from -s value)',
cmdBash,
cmdPostCommand,
'{"tool_input":{"command":"echo failing-cmd"},"tool_response":{"exit_code":1}}',
[{ contains: 'echo failing-cmd' }, { absent: 'Recording command outcome: false' }, { absent: 'Recording command outcome: true' }]);
run('Bash hook silently no-ops when no command present',
cmdBash,
cmdPostCommand,
'{"tool_input":{},"tool_response":{}}',
[]);
run('Bash hook silently no-ops on empty stdin',
cmdPostCommand,
'',
[]);
// --- PreCompact: pure guidance text, no CLI call ---
run('PreCompact (manual) prints guidance and includes custom instructions',
cmdPrecompactManual,
'{"custom_instructions":"focus on the auth module"}',
[{ contains: 'PreCompact Guidance' }, { contains: 'focus on the auth module' }]);
run('PreCompact (manual) prints guidance with no custom instructions',
cmdPrecompactManual,
'{}',
[{ contains: 'PreCompact Guidance' }, { absent: 'Custom compact instructions' }]);
run('PreCompact (auto) prints guidance',
cmdPrecompactAuto,
'',
[{ contains: 'Auto-Compact Guidance' }]);
// --- Stop: session-end ---
run('Stop hook runs session-end without error',
cmdStop,
'{}',
[{ absent: 'Required option missing' }, { absent: 'Invalid value' }]);
console.log(`\n${cases.length - failed}/${cases.length} passed`);
process.exit(failed === 0 ? 0 : 1);
+3 -4
View File
@@ -1,14 +1,13 @@
{
"_note": "Stop hook auto-runs cost-track at session end so the cost-tracking namespace stays populated without users remembering to run `/cost-track` manually. TRACK_QUIET=1 suppresses the markdown summary (avoid spamming on every Stop). `|| true` ensures a track failure NEVER blocks session end — analytics data is best-effort, not load-bearing.",
"_platform": "posix",
"_platform_note": "POSIX hooks (bash + node). Windows users continue to invoke `cost-track` manually via the slash command (the Stop hook silently no-ops on a non-bash shell).",
"_note": "Stop hook auto-runs cost-track at session end so the cost-tracking namespace stays populated without users remembering to run `/cost-track` manually. TRACK_QUIET=1 suppresses the markdown summary (avoid spamming on every Stop). A track failure NEVER blocks session end — analytics data is best-effort, not load-bearing (see scripts/ruflo-hook.cjs).",
"_platform_note": "#2721 — the command below is a `node -e` bootstrap: it resolves 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. No bash or /dev/null redirection involved.",
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/bin/bash -c 'TRACK_QUIET=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/track.mjs\" >/dev/null 2>&1 || true'"
"command": "node -e \"require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT,'scripts','ruflo-hook.cjs'))\""
}
]
}
@@ -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);
+31 -1
View File
@@ -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/<name>/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;