mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
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:
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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'))\""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user