diff --git a/.agents/hooks.json b/.agents/hooks.json index f7b02da4..1cc75fb6 100644 --- a/.agents/hooks.json +++ b/.agents/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PreToolUse --cli antigravity", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli antigravity", "timeout": 30, "__failproofai_hook__": true } @@ -19,7 +19,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PostToolUse --cli antigravity", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli antigravity", "timeout": 30, "__failproofai_hook__": true } @@ -29,7 +29,7 @@ "PreInvocation": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PreInvocation --cli antigravity", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreInvocation --cli antigravity", "timeout": 30, "__failproofai_hook__": true } @@ -37,7 +37,7 @@ "Stop": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook Stop --cli antigravity", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook Stop --cli antigravity", "timeout": 30, "__failproofai_hook__": true } diff --git a/.agents/plugins/failproofai/hooks/hooks.json b/.agents/plugins/failproofai/hooks/hooks.json index 3e01bf19..db51f6a1 100644 --- a/.agents/plugins/failproofai/hooks/hooks.json +++ b/.agents/plugins/failproofai/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook SessionStart --cli goose" + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionStart --cli goose" } ] } @@ -15,7 +15,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook UserPromptSubmit --cli goose" + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli goose" } ] } @@ -25,7 +25,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PreToolUse --cli goose" + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli goose" } ] } @@ -35,7 +35,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PostToolUse --cli goose" + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli goose" } ] } @@ -45,7 +45,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook SessionEnd --cli goose" + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionEnd --cli goose" } ] } diff --git a/.claude/settings.json b/.claude/settings.json index fe134c20..573c48b5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook SessionStart", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook SessionStart", "timeout": 60, "__failproofai_hook__": true } @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook SessionEnd", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook SessionEnd", "timeout": 60, "__failproofai_hook__": true } @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook UserPromptSubmit", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook UserPromptSubmit", "timeout": 60, "__failproofai_hook__": true } @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook PreToolUse", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook PreToolUse", "timeout": 60, "__failproofai_hook__": true } @@ -53,7 +53,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook PermissionRequest", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook PermissionRequest", "timeout": 60, "__failproofai_hook__": true } @@ -65,7 +65,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook PermissionDenied", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook PermissionDenied", "timeout": 60, "__failproofai_hook__": true } @@ -77,7 +77,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook PostToolUse", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook PostToolUse", "timeout": 60, "__failproofai_hook__": true } @@ -89,7 +89,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook PostToolUseFailure", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook PostToolUseFailure", "timeout": 60, "__failproofai_hook__": true } @@ -101,7 +101,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook Notification", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook Notification", "timeout": 60, "__failproofai_hook__": true } @@ -113,7 +113,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook SubagentStart", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook SubagentStart", "timeout": 60, "__failproofai_hook__": true } @@ -125,7 +125,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook SubagentStop", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook SubagentStop", "timeout": 60, "__failproofai_hook__": true } @@ -137,7 +137,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook TaskCreated", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook TaskCreated", "timeout": 60, "__failproofai_hook__": true } @@ -149,7 +149,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook TaskCompleted", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook TaskCompleted", "timeout": 60, "__failproofai_hook__": true } @@ -161,7 +161,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook Stop", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook Stop", "timeout": 60, "__failproofai_hook__": true } @@ -173,7 +173,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook StopFailure", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook StopFailure", "timeout": 60, "__failproofai_hook__": true } @@ -185,7 +185,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook TeammateIdle", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook TeammateIdle", "timeout": 60, "__failproofai_hook__": true } @@ -197,7 +197,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook InstructionsLoaded", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook InstructionsLoaded", "timeout": 60, "__failproofai_hook__": true } @@ -209,7 +209,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook ConfigChange", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook ConfigChange", "timeout": 60, "__failproofai_hook__": true } @@ -221,7 +221,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook CwdChanged", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook CwdChanged", "timeout": 60, "__failproofai_hook__": true } @@ -233,7 +233,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook FileChanged", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook FileChanged", "timeout": 60, "__failproofai_hook__": true } @@ -245,7 +245,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook WorktreeCreate", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook WorktreeCreate", "timeout": 60, "__failproofai_hook__": true } @@ -257,7 +257,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook WorktreeRemove", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook WorktreeRemove", "timeout": 60, "__failproofai_hook__": true } @@ -269,7 +269,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook PreCompact", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook PreCompact", "timeout": 60, "__failproofai_hook__": true } @@ -281,7 +281,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook PostCompact", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook PostCompact", "timeout": 60, "__failproofai_hook__": true } @@ -293,7 +293,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook Elicitation", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook Elicitation", "timeout": 60, "__failproofai_hook__": true } @@ -305,7 +305,7 @@ "hooks": [ { "type": "command", - "command": "bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook ElicitationResult", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook ElicitationResult", "timeout": 60, "__failproofai_hook__": true } diff --git a/.codex/hooks.json b/.codex/hooks.json index 20bc2fc8..9afb9e63 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook SessionStart --cli codex", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionStart --cli codex", "timeout": 60, "__failproofai_hook__": true } @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PreToolUse --cli codex", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli codex", "timeout": 60, "__failproofai_hook__": true } @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PermissionRequest --cli codex", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PermissionRequest --cli codex", "timeout": 60, "__failproofai_hook__": true } @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PostToolUse --cli codex", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli codex", "timeout": 60, "__failproofai_hook__": true } @@ -53,7 +53,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook UserPromptSubmit --cli codex", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli codex", "timeout": 60, "__failproofai_hook__": true } @@ -65,7 +65,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook Stop --cli codex", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook Stop --cli codex", "timeout": 60, "__failproofai_hook__": true } diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 395bb640..cfa821d7 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -4,42 +4,42 @@ "sessionStart": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook sessionStart --cli cursor", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook sessionStart --cli cursor", "timeout": 60 } ], "sessionEnd": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook sessionEnd --cli cursor", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook sessionEnd --cli cursor", "timeout": 60 } ], "beforeSubmitPrompt": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook beforeSubmitPrompt --cli cursor", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook beforeSubmitPrompt --cli cursor", "timeout": 60 } ], "preToolUse": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook preToolUse --cli cursor", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook preToolUse --cli cursor", "timeout": 60 } ], "postToolUse": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook postToolUse --cli cursor", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook postToolUse --cli cursor", "timeout": 60 } ], "stop": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook stop --cli cursor", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook stop --cli cursor", "timeout": 60 } ] diff --git a/.devin/config.json b/.devin/config.json index 3b311ccd..4f6a3c78 100644 --- a/.devin/config.json +++ b/.devin/config.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook SessionStart --cli devin", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionStart --cli devin", "timeout": 60, "__failproofai_hook__": true } @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook UserPromptSubmit --cli devin", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli devin", "timeout": 60, "__failproofai_hook__": true } @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PreToolUse --cli devin", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli devin", "timeout": 60, "__failproofai_hook__": true } @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PostToolUse --cli devin", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli devin", "timeout": 60, "__failproofai_hook__": true } @@ -53,7 +53,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PermissionRequest --cli devin", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PermissionRequest --cli devin", "timeout": 60, "__failproofai_hook__": true } @@ -65,7 +65,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook Stop --cli devin", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook Stop --cli devin", "timeout": 60, "__failproofai_hook__": true } @@ -77,7 +77,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook SessionEnd --cli devin", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionEnd --cli devin", "timeout": 60, "__failproofai_hook__": true } diff --git a/.factory/hooks.json b/.factory/hooks.json index b2fa3660..1677c14b 100644 --- a/.factory/hooks.json +++ b/.factory/hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook SessionStart --cli factory", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionStart --cli factory", "timeout": 30, "__failproofai_hook__": true } @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook UserPromptSubmit --cli factory", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli factory", "timeout": 30, "__failproofai_hook__": true } @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PreToolUse --cli factory", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli factory", "timeout": 30, "__failproofai_hook__": true } @@ -42,7 +42,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PostToolUse --cli factory", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli factory", "timeout": 30, "__failproofai_hook__": true } @@ -54,7 +54,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook Notification --cli factory", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook Notification --cli factory", "timeout": 30, "__failproofai_hook__": true } @@ -66,7 +66,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook Stop --cli factory", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook Stop --cli factory", "timeout": 30, "__failproofai_hook__": true } @@ -78,7 +78,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook SubagentStop --cli factory", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook SubagentStop --cli factory", "timeout": 30, "__failproofai_hook__": true } @@ -90,7 +90,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook PreCompact --cli factory", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreCompact --cli factory", "timeout": 30, "__failproofai_hook__": true } @@ -102,7 +102,7 @@ "hooks": [ { "type": "command", - "command": "bun bin/failproofai.mjs --hook SessionEnd --cli factory", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionEnd --cli factory", "timeout": 30, "__failproofai_hook__": true } diff --git a/.github/hooks/failproofai.json b/.github/hooks/failproofai.json index 918927d5..0622d16a 100644 --- a/.github/hooks/failproofai.json +++ b/.github/hooks/failproofai.json @@ -6,8 +6,8 @@ "hooks": [ { "type": "command", - "bash": "bun bin/failproofai.mjs --hook SessionStart --cli copilot", - "powershell": "bun bin/failproofai.mjs --hook SessionStart --cli copilot", + "bash": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionStart --cli copilot", + "powershell": "if (-not (Get-Command node -ErrorAction SilentlyContinue)) { [Console]::Error.WriteLine('[failproofai] node not found; install Node >=20.9'); exit 2 }; node scripts/dev-hook.mjs --hook SessionStart --cli copilot", "timeoutSec": 60, "__failproofai_hook__": true } @@ -19,8 +19,8 @@ "hooks": [ { "type": "command", - "bash": "bun bin/failproofai.mjs --hook SessionEnd --cli copilot", - "powershell": "bun bin/failproofai.mjs --hook SessionEnd --cli copilot", + "bash": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionEnd --cli copilot", + "powershell": "if (-not (Get-Command node -ErrorAction SilentlyContinue)) { [Console]::Error.WriteLine('[failproofai] node not found; install Node >=20.9'); exit 2 }; node scripts/dev-hook.mjs --hook SessionEnd --cli copilot", "timeoutSec": 60, "__failproofai_hook__": true } @@ -32,8 +32,8 @@ "hooks": [ { "type": "command", - "bash": "bun bin/failproofai.mjs --hook UserPromptSubmit --cli copilot", - "powershell": "bun bin/failproofai.mjs --hook UserPromptSubmit --cli copilot", + "bash": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli copilot", + "powershell": "if (-not (Get-Command node -ErrorAction SilentlyContinue)) { [Console]::Error.WriteLine('[failproofai] node not found; install Node >=20.9'); exit 2 }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli copilot", "timeoutSec": 60, "__failproofai_hook__": true } @@ -45,8 +45,8 @@ "hooks": [ { "type": "command", - "bash": "bun bin/failproofai.mjs --hook PreToolUse --cli copilot", - "powershell": "bun bin/failproofai.mjs --hook PreToolUse --cli copilot", + "bash": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli copilot", + "powershell": "if (-not (Get-Command node -ErrorAction SilentlyContinue)) { [Console]::Error.WriteLine('[failproofai] node not found; install Node >=20.9'); exit 2 }; node scripts/dev-hook.mjs --hook PreToolUse --cli copilot", "timeoutSec": 60, "__failproofai_hook__": true } @@ -58,8 +58,8 @@ "hooks": [ { "type": "command", - "bash": "bun bin/failproofai.mjs --hook PostToolUse --cli copilot", - "powershell": "bun bin/failproofai.mjs --hook PostToolUse --cli copilot", + "bash": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli copilot", + "powershell": "if (-not (Get-Command node -ErrorAction SilentlyContinue)) { [Console]::Error.WriteLine('[failproofai] node not found; install Node >=20.9'); exit 2 }; node scripts/dev-hook.mjs --hook PostToolUse --cli copilot", "timeoutSec": 60, "__failproofai_hook__": true } @@ -71,8 +71,8 @@ "hooks": [ { "type": "command", - "bash": "bun bin/failproofai.mjs --hook Stop --cli copilot", - "powershell": "bun bin/failproofai.mjs --hook Stop --cli copilot", + "bash": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook Stop --cli copilot", + "powershell": "if (-not (Get-Command node -ErrorAction SilentlyContinue)) { [Console]::Error.WriteLine('[failproofai] node not found; install Node >=20.9'); exit 1 }; node scripts/dev-hook.mjs --hook Stop --cli copilot", "timeoutSec": 60, "__failproofai_hook__": true } diff --git a/.opencode/plugins/failproofai.mjs b/.opencode/plugins/failproofai.mjs index 784ab192..71f663ce 100644 --- a/.opencode/plugins/failproofai.mjs +++ b/.opencode/plugins/failproofai.mjs @@ -1,11 +1,13 @@ // AUTO-GENERATED by failproofai. __failproofai_hook__ // Re-generate via: failproofai policies --install --cli opencode // -// DEV variant: this repo's contributors run failproofai from source via -// `bun bin/failproofai.mjs ...` instead of `npx -y failproofai`. This file -// is the project-scope shim with that dev path baked in. The production -// shape (npx-based) is generated by `src/hooks/integrations.ts` on install -// for end users — see `buildOpenCodePluginShim` there. +// DEV variant: this repo's contributors run failproofai from source, via +// the shared `scripts/dev-hook.mjs` resolver rather than `npx -y failproofai`. +// That resolver locates bun across PATH, $BUN_INSTALL/bin, ~/.bun/bin, Homebrew +// and every nvm version dir — a bare `spawnSync("bun")` silently fails open the +// moment bun is off the hook's PATH. The production shape (npx-based) is +// generated by `src/hooks/integrations.ts` on install for end users — see +// `buildOpenCodePluginShim` there. // // Do NOT install this repo via `failproofai policies --install --cli // opencode --scope project` — it would overwrite this dev path with the @@ -17,10 +19,7 @@ // When #337 landed, this dev shim drifted and `block-read-outside-cwd` // silently no-op'd on every opencode `read` call inside this repo. import { spawnSync } from "node:child_process"; -import { resolve } from "node:path"; - -const REPO_ROOT = resolve(import.meta.dirname, "..", ".."); -const FAILPROOFAI_DEV_BIN = resolve(REPO_ROOT, "bin", "failproofai.mjs"); +import { resolveDevSpawn } from "../../scripts/dev-hook.mjs"; const BUS_EVENT_MAP = { "session.created": "SessionStart", @@ -72,13 +71,33 @@ function canonicalizeToolInput(canonicalToolName, args) { } function runFailproofai(eventName, payload, directory) { - const r = spawnSync("bun", [FAILPROOFAI_DEV_BIN, "--hook", eventName, "--cli", "opencode"], { + // Shared with the JSON configs' launcher: locates bun across PATH, ~/.bun/bin, + // $BUN_INSTALL, Homebrew and every nvm version dir. A bare spawnSync("bun") + // dies with ENOENT the moment bun is off the hook's PATH. + const spawn = resolveDevSpawn(); + if (!spawn) { + process.stderr.write( + "[failproofai-dev] bun not found — opencode policies are NOT enforcing. " + + "Install bun: curl -fsSL https://bun.sh/install | bash\n", + ); + return { exitCode: 0, stdout: "", stderr: "" }; + } + const r = spawnSync(spawn.cmd, [...spawn.args, "--hook", eventName, "--cli", "opencode"], { input: JSON.stringify(payload), encoding: "utf8", timeout: 60_000, cwd: directory, }); - return { exitCode: r.status ?? 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; + // `r.status` is null when the spawn never ran (ENOENT) or the child was + // killed. Coercing that to 0 reports "allowed" for a policy that never got + // to run, so say so on stderr rather than failing open in silence. + if (r.status === null) { + process.stderr.write( + `[failproofai-dev] ${eventName} hook did not run (${r.error?.code ?? r.signal ?? "unknown"}) — not enforcing\n`, + ); + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; } async function applyDecision(result, ctx, eventName) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 168aef80..936bd614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Redirect the 13 AgentEye pages the upstream syncs deleted (`/agenteye/deployment`, `/agenteye/kubernetes-deployment`, `/agenteye/troubleshooting`, …) to a live page instead of hard-404ing. (#556) ### Fixes +- Stop this repo's dogfood hooks from silently no-op'ing when `bun` isn't on the hook's PATH. All 75 hook commands across the 8 project-level agent-CLI configs fired `bun bin/failproofai.mjs --hook ` directly, so whenever the agent CLI was handed a PATH without bun — `npm i -g bun` installs into a single nvm version's bin dir, so `nvm use ` drops it; a macOS GUI launch inherits a launchd PATH built without `~/.zshrc` — every event died with exit 127 and the session ran with **zero** policy enforcement, saying nothing. The configs now call a dev-only `node scripts/dev-hook.mjs` launcher that locates bun across `PATH`, `$BUN_INSTALL/bin`, `~/.bun/bin`, the node execPath sibling, Homebrew/`/usr/local`, and every `~/.nvm/versions/node/*/bin`; installs it via npm if it is genuinely absent; builds `dist/index.js` when missing so `.failproofai/policies/*.mjs` can resolve `import ... from 'failproofai'`; then re-execs the real binary with `stdio: "inherit"` (byte-exact stdout — the deny contracts are JSON on stdout) and propagates the exit code verbatim (2 still means deny; signals map to 128+signum as `sh` did). A `command -v node` pre-check fronts each command so a missing node is a loud one-liner rather than silence — exit 2 on tool events, exit 1 on stop-class events, where exit 2 would mean "retry" and loop forever. The `.opencode` dev shim shares the same resolver and no longer reports a never-run hook as `exitCode: 0` (a silent allow). Production users on `npx -y failproofai` are unaffected. A new drift-guard test reads every committed dogfood config and asserts the launcher form, guard, exit-code split, and per-file command counts — these configs are generated by nothing and read by nothing, which is how #337's opencode shim drifted and silently no-op'd `block-read-outside-cwd` repo-wide. (#564) - Delete translated pages whose English source no longer exists, and stop them coming back: `translate-docs` gained a `--prune` mode that runs by default on every translation pass (`--no-prune` opts out) and as an explicit step in the `consolidate` job — that job re-checks-out `main` and *overlays* the artifacts, so a prune done only in the per-language jobs would be undone. Translation only ever moved forward, so the 11 pages the AgentEye syncs removed upstream left 154 orphans (11 × 14 locales) that `--update-nav` dropped from the sidebar but Mintlify still served and indexed — non-English readers could land on docs for a deleted feature with no way out. A repo invariant test now fails if any translation outlives its English source. (#556) - Move the docs auto-translation daily cron from 06:00 UTC to 11:05 AM IST (05:35 UTC, encoded as `35 5 * * *` since GitHub Actions cron is always UTC). (#553) diff --git a/CLAUDE.md b/CLAUDE.md index ec5d2914..2e84b5f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,14 +10,44 @@ ## Dev hooks (this repo only) -This repo's `.claude/settings.json` uses `bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook ` -instead of the standard `npx -y failproofai` command. This is because `npx -y failproofai` +Every dogfood config in this repo routes through **`scripts/dev-hook.mjs`**, a dev-only +launcher, instead of the standard `npx -y failproofai` command — `npx -y failproofai` creates a self-referencing conflict when run inside the failproofai project itself. +`.claude/settings.json` uses: -The path **must** start with `$CLAUDE_PROJECT_DIR` (not a relative `./bin/...`). Claude -Code spawns hooks with the live session CWD, which drifts whenever the agent `cd`s into a -subdirectory — a relative path then fails with `Module not found "./bin/failproofai.mjs"`. -`$CLAUDE_PROJECT_DIR` is set once per session to the project root and never drifts. +``` +command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node "$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs" --hook +``` + +**Why node fronts a bun-only binary.** `bin/failproofai.mjs` cannot run under node — it +does a bare `import { version } from "../package.json"` (node needs `with { type: "json" }`) +and `await import("../src/hooks/handler")`, an extensionless TypeScript specifier node +cannot resolve. But naming `bun` directly in the config makes every hook exit 127 the +moment bun is off the *hook's* PATH — which happens routinely even when bun is on yours: +`npm i -g bun` installs into ONE nvm version's bin dir, so `nvm use ` drops it; a +macOS GUI launch gets a launchd PATH built without `~/.zshrc`. The session then runs with +zero enforcement, silently. The launcher locates bun across PATH, `$BUN_INSTALL/bin`, +`~/.bun/bin`, Homebrew, and every `~/.nvm/versions/node/*/bin`; installs it via npm if it +is genuinely absent; ensures `dist/index.js` exists so `.failproofai/policies/*.mjs` can +resolve `import ... from 'failproofai'`; then delegates with `stdio: "inherit"` and +propagates the child's exit code verbatim. + +**The `command -v node` prefix is a pre-check, never a `||` fallback.** Exit 2 is the deny +signal, so a reactive `launcher || fallback` chain would re-fire on every legitimate +denial. Stop-class events (`Stop`, `StopFailure`, `SubagentStop`) use `exit 1` instead of +`exit 2` — exit 2 there means "don't finish, retry", and a shell guard can't read +`stop_hook_active` off stdin to know it is already retrying, so it would loop forever. + +The `.claude` path **must** start with `$CLAUDE_PROJECT_DIR` (not a relative `./scripts/...`). +Claude Code spawns hooks with the live session CWD, which drifts whenever the agent `cd`s +into a subdirectory — a relative path then fails with `Cannot find module`. +`$CLAUDE_PROJECT_DIR` is set once per session to the project root and never drifts. (The +other CLIs use a relative path; they spawn hooks from the project root.) + +`__tests__/hooks/dogfood-configs.test.ts` is the tripwire for all of the above — it reads +every committed dogfood config and asserts the launcher form, the guard, the exit-code +split, and the per-file command counts. These configs are hand-maintained and generated by +nothing, so without it they drift silently (see #337). For all other repos, the recommended approach is `npx -y failproofai`, installed via: ```bash @@ -33,7 +63,7 @@ This repo also ships a `.codex/hooks.json` for OpenAI Codex sessions, mirroring `.claude/settings.json` setup. Codex does **not** define an equivalent of `$CLAUDE_PROJECT_DIR` — its stdin payload exposes `cwd` but the hook command string runs before stdin is read. Codex hook commands are spawned with the project root as -cwd (where `codex` was launched), so we use a relative `bun bin/failproofai.mjs` +cwd (where `codex` was launched), so we use a relative `node scripts/dev-hook.mjs` path. If Codex ever changes that behavior and the hook fails to find the binary, switch to an absolute path. @@ -57,7 +87,7 @@ milliseconds, for its timeout field). Like Codex, Copilot does not expose a `$COPILOT_PROJECT_DIR` env var, and its hooks are spawned with the project root as cwd, so we use a relative -`bun bin/failproofai.mjs --hook ... --cli copilot` path. If Copilot ever +`node scripts/dev-hook.mjs --hook ... --cli copilot` path. If Copilot ever changes that behavior and the hook fails to find the binary, switch to an absolute path. @@ -131,7 +161,7 @@ existing builtin policies fire unchanged. Like Codex and Copilot, Cursor does not expose a `$CURSOR_PROJECT_DIR` env var to the hook command line (only as a process env var inside the hook itself), and Cursor hooks are spawned with the project root as cwd, so we -use a relative `bun bin/failproofai.mjs --hook ... --cli cursor` path. If +use a relative `node scripts/dev-hook.mjs --hook ... --cli cursor` path. If Cursor ever changes that behavior and the hook fails to find the binary, switch to an absolute path. @@ -179,7 +209,7 @@ and a matching entry in `.opencode/opencode.json`'s `plugin: []` array. OpenCode's extensibility model is fundamentally different from Claude / Codex / Copilot / Cursor: it has **no external-command hook system**. Plugins are in-process JS/TS modules loaded by opencode at startup. The -shim subprocess-calls the failproofai binary (`bun bin/failproofai.mjs +shim subprocess-calls the failproofai binary (`node scripts/dev-hook.mjs --hook ... --cli opencode` for dev) and translates the binary's existing Claude-shape JSON response back into plugin semantics — `throw new Error(reason)` for deny, `client.session.prompt(...)` for instruct, @@ -199,7 +229,7 @@ failproofai policies --install --cli opencode --scope project which writes a portable `npx -y failproofai --hook ... --cli opencode` command into the shim. Same self-reference caveat applies — do **not** install the standard `npx` form from inside this repo (it would overwrite -the dev `bun bin/failproofai.mjs` path). +the dev `node scripts/dev-hook.mjs` path). ### Pi hooks (`.pi/settings.json`) @@ -449,7 +479,7 @@ failproofai policies --install --cli openclaw --scope user Factory's **droid** CLI is a **dual-pillar** integration (live hooks + audit), supporting **user + project** scope. Unlike Hermes/OpenClaw it uses a Claude/Codex-style **external shell-hook system** — the installed command is -`bun bin/failproofai.mjs --hook --cli factory` (dev) / +`node scripts/dev-hook.mjs --hook --cli factory` (dev) / `npx -y failproofai --hook --cli factory` (production project scope). The entire contract below was **verified live against droid v0.171.0**. @@ -694,7 +724,7 @@ events; Goose **auto-discovers** any dir under `~/.agents/plugins//` (user or `/.agents/plugins//` (project) at startup and **self-registers** it into `~/.config/goose/config.yaml`. So the installer just **drops the plugin dir** (no config edit — simpler than OpenCode). The installed command is -`bun bin/failproofai.mjs --hook --cli goose` (dev) / +`node scripts/dev-hook.mjs --hook --cli goose` (dev) / `npx -y failproofai --hook --cli goose` (production). **Schema: an Open Plugins `hooks.json` WITH a top-level `"hooks"` wrapper** (unlike @@ -762,7 +792,7 @@ failproofai policies --install --cli goose --scope project Like the Codex / Cursor / OpenCode / Pi setups above, this repo ships **project-scope dogfood configs** for the four newest CLIs so failproofai enforces on itself when you drive this repo with them. Each uses the dev -`bun bin/failproofai.mjs --hook --cli ` command (never the `npx` +`node scripts/dev-hook.mjs --hook --cli ` command (never the `npx` production form — same self-reference caveat as the others): | CLI | Dogfood path | Schema | @@ -776,7 +806,7 @@ These were generated from each integration's own `writeHookEntries`, so they track the live schema. See each CLI's architecture section above for the full contract. As with the other CLIs, do **not** run `failproofai policies --install --cli ` from inside this repo — it would -overwrite the dev `bun bin/failproofai.mjs` path with the production `npx` form. +overwrite the dev `node scripts/dev-hook.mjs` path with the production `npx` form. ## Workflow rules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8da1b944..217bc474 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,10 @@ Thanks for your interest in contributing! Here's how to get started. ## Prerequisites -- Bun >= 1.3.0 (or Node.js >= 20.9.0) +- Bun >= 1.3.0 — required, not optional. `bun install` runs `prepare` → `bun run build`, + which shells out to `bun build`, so there is no Node-only setup path. +- Node.js >= 20.9.0 — what the published package targets, and what runs the in-repo + dev hooks' launcher. ## Development Setup @@ -20,12 +23,19 @@ The dev server starts at `http://localhost:8020`. ### Build before the in-repo dev hooks will work This repo **dogfoods failproofai on itself**: `.claude/settings.json` (and the -sibling `.codex/`, `.cursor/`, `.github/hooks/`, … configs) register -hooks that run `bun bin/failproofai.mjs --hook `. Those hooks load the -custom policies in `.failproofai/policies/*.mjs`, which `import` the -`failproofai` package — resolved against the **compiled `dist/index.js` bundle**. - -If `dist/` is missing or stale you'll see hook errors like: +sibling `.codex/`, `.cursor/`, `.github/hooks/`, … configs) register hooks that run +`node scripts/dev-hook.mjs --hook --cli `. That launcher locates `bun` +(across `PATH`, `$BUN_INSTALL/bin`, `~/.bun/bin`, Homebrew, and every +`~/.nvm/versions/node/*/bin`), builds `dist/index.js` if it is missing, then hands off +to `bin/failproofai.mjs`. See `scripts/dev-hook.mjs` for why node fronts a bun-only +binary. Those hooks load the custom policies in `.failproofai/policies/*.mjs`, which +`import` the `failproofai` package — resolved against the **compiled `dist/index.js` +bundle**. + +If `dist/` is missing the launcher rebuilds it for you and says so on stderr. If it is +**stale** — you changed `src/index.ts` and the bundle predates it — nothing detects that, +and you'll get policies enforcing yesterday's code. When the bundle is missing *and* the +launcher can't run, the errors look like: ``` [failproofai:hook] ERROR failed to load custom hooks from @@ -33,8 +43,8 @@ If `dist/` is missing or stale you'll see hook errors like: ``` `bun install` builds `dist/` for you via its `prepare` script, so a clean clone -just works. **Build explicitly whenever the bundle is missing or you've changed -`src/`** (the hooks run the compiled bundle, not your live `src/`): +just works. **Rebuild explicitly whenever you've changed `src/`** (the hooks load the +compiled bundle, not your live `src/`): ```bash bun run build # full build (Next.js + dist/) diff --git a/__tests__/hooks/dogfood-configs.test.ts b/__tests__/hooks/dogfood-configs.test.ts new file mode 100644 index 00000000..39a344e8 --- /dev/null +++ b/__tests__/hooks/dogfood-configs.test.ts @@ -0,0 +1,186 @@ +// @vitest-environment node +/** + * Drift guard for THIS repo's dogfood hook configs. + * + * These configs are hand-maintained, are not generated by `integrations.ts`, + * and nothing else in the suite reads them — so when one drifts, it drifts + * silently and the repo simply stops enforcing its own policies. That is not + * hypothetical: when #337 landed, the opencode dev shim drifted and + * `block-read-outside-cwd` no-op'd on every opencode read in this repo, + * unnoticed. This file is the tripwire. + * + * It asserts the *dev* form. The production `npx -y failproofai` form that + * users install is covered separately by __tests__/hooks/integrations.test.ts. + */ +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const ROOT = process.cwd(); +const LAUNCHER = "scripts/dev-hook.mjs"; + +/** Keys that hold a shell command. `bash`/`powershell` are Copilot's schema. */ +const COMMAND_KEYS = new Set(["command", "bash", "powershell"]); + +/** + * The five dogfood schemas differ wildly (Claude-nested, Cursor-flat, Factory + * top-level-keys, Antigravity named-hook, Copilot bash+powershell). Rather than + * encode all five, walk the tree and pick up anything that looks like one of + * our commands — new schemas come along for free. + */ +function collectHookCommands(node: unknown, out: string[] = []): string[] { + if (Array.isArray(node)) { + for (const v of node) collectHookCommands(v, out); + return out; + } + if (node && typeof node === "object") { + for (const [k, v] of Object.entries(node)) { + if (typeof v === "string" && COMMAND_KEYS.has(k) && v.includes("failproofai")) out.push(v); + else collectHookCommands(v, out); + } + } + return out; +} + +function commandsIn(file: string): string[] { + return collectHookCommands(JSON.parse(readFileSync(resolve(ROOT, file), "utf8"))); +} + +/** + * Events where exit 2 means "don't finish, retry". A shell guard can't read + * `stop_hook_active` off stdin to know it's already retrying, so it must not + * deny here — it would loop forever. Everything else gets exit 2, which + * genuinely blocks on Claude/Factory/Goose and surfaces loudly elsewhere. + */ +const STOP_CLASS = new Set(["Stop", "StopFailure", "SubagentStop", "stop", "subagentStop"]); + +const CONFIGS = [ + { file: ".claude/settings.json", cli: null, count: 26 }, + { file: ".codex/hooks.json", cli: "codex", count: 6 }, + { file: ".github/hooks/failproofai.json", cli: "copilot", count: 12 }, // 6 events × bash+powershell + { file: ".cursor/hooks.json", cli: "cursor", count: 6 }, + { file: ".factory/hooks.json", cli: "factory", count: 9 }, + { file: ".devin/config.json", cli: "devin", count: 7 }, + { file: ".agents/hooks.json", cli: "antigravity", count: 4 }, + { file: ".agents/plugins/failproofai/hooks/hooks.json", cli: "goose", count: 5 }, +]; + +describe.each(CONFIGS)("$file", ({ file, cli, count }) => { + it(`registers exactly ${count} failproofai commands`, () => { + // Catches an event silently dropped during a hand-edit. + expect(commandsIn(file)).toHaveLength(count); + }); + + it("routes every command through the dev launcher", () => { + // .claude quotes the path so a $CLAUDE_PROJECT_DIR containing spaces still + // resolves; the others pass it bare. + for (const cmd of commandsIn(file)) expect(cmd).toMatch(/scripts\/dev-hook\.mjs"? --hook /); + }); + + it("never spawns bun directly", () => { + // The whole point: `bun ...` dies with exit 127 when bun is off the hook's + // PATH, and the session runs unenforced without saying so. + for (const cmd of commandsIn(file)) expect(cmd).not.toMatch(/(^|[|;&\s])bun\s/); + }); + + it("never uses the production npx form", () => { + // `failproofai policies --install --scope project` run inside this repo + // would rewrite these to npx, which enforces the *published* package + // against the working tree. That is the #337 class of silent wrongness. + for (const cmd of commandsIn(file)) expect(cmd).not.toContain("npx -y failproofai"); + }); + + it("guards on node before invoking the launcher, never after", () => { + // A reactive `launcher || fallback` chain would re-fire the fallback on + // every legitimate exit-2 denial. The check must precede the launcher. + for (const cmd of commandsIn(file)) { + expect(cmd).toMatch(/^(command -v node|if \(-not \(Get-Command node)/); + expect(cmd).not.toMatch(/dev-hook\.mjs[^;]*\|\|/); + } + }); + + it("exits 1 on stop-class events and 2 everywhere else", () => { + for (const cmd of commandsIn(file)) { + const event = /--hook (\S+)/.exec(cmd)?.[1]; + expect(event).toBeDefined(); + const expected = STOP_CLASS.has(event!) ? 1 : 2; + expect(`${event} → exit ${/exit (\d)/.exec(cmd)?.[1]}`).toBe(`${event} → exit ${expected}`); + } + }); + + if (cli) { + it(`passes --cli ${cli}`, () => { + for (const cmd of commandsIn(file)) expect(cmd).toContain(`--cli ${cli}`); + }); + } else { + it("omits --cli, relying on the cli=claude default", () => { + for (const cmd of commandsIn(file)) expect(cmd).not.toContain("--cli "); + }); + } +}); + +describe("copilot's dual-shell schema", () => { + const file = ".github/hooks/failproofai.json"; + + it("uses the POSIX guard on bash and the PowerShell guard on powershell", () => { + const cfg = JSON.parse(readFileSync(resolve(ROOT, file), "utf8")); + const entries: { bash?: string; powershell?: string }[] = []; + const walk = (n: unknown): void => { + if (Array.isArray(n)) return n.forEach(walk); + if (n && typeof n === "object") { + const o = n as Record; + if (typeof o.bash === "string" && typeof o.powershell === "string") { + entries.push(o as { bash: string; powershell: string }); + } + Object.values(o).forEach(walk); + } + }; + walk(cfg); + + expect(entries).toHaveLength(6); + for (const e of entries) { + expect(e.bash).toMatch(/^command -v node/); + expect(e.powershell).toMatch(/^if \(-not \(Get-Command node/); + // `command -v` is not a PowerShell builtin; shipping it there would make + // the guard itself the thing that fails. + expect(e.powershell).not.toContain("command -v"); + } + }); +}); + +describe("the surfaces that spawn bun from JS rather than a config", () => { + it("the opencode dev shim resolves bun via the shared resolver", () => { + const src = readFileSync(resolve(ROOT, ".opencode/plugins/failproofai.mjs"), "utf8"); + expect(src).toContain("resolveDevSpawn"); + // Strip comments — prose is free to mention the anti-pattern it warns about. + const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + expect(code).not.toMatch(/spawnSync\(\s*"bun"/); + }); + + /** + * Known gap, encoded deliberately. `.pi/settings.json` loads + * `pi-extension/index.ts`, whose resolveSpawn() has the same bare-"bun" + * fallback — but that file ships to users in the npm tarball, where + * dist-first is the correct order. Fixing it means changing shipped + * behaviour, so it is out of scope here. This test documents the hole so + * nobody mistakes it for coverage. + */ + it("pi's dogfood surface is knowingly NOT protected", () => { + const src = readFileSync(resolve(ROOT, "pi-extension/index.ts"), "utf8"); + expect(src).toContain('cmd: "bun"'); + }); +}); + +describe("repo-wide invariants", () => { + it("the launcher every config points at exists", () => { + expect(existsSync(resolve(ROOT, LAUNCHER))).toBe(true); + }); + + it("the hook-sync image still installs bun", () => { + // The launcher only *finds* bun; it cannot conjure it. That container runs + // headless with no bun of its own, so without this layer the + // require-*-before-stop policies driving its auto-PR never fire. + const dockerfile = readFileSync(resolve(ROOT, "docker-hook-sync/Dockerfile"), "utf8"); + expect(dockerfile).toMatch(/bun/i); + }); +}); diff --git a/__tests__/scripts/dev-hook-resolver.test.ts b/__tests__/scripts/dev-hook-resolver.test.ts new file mode 100644 index 00000000..cbab9e29 --- /dev/null +++ b/__tests__/scripts/dev-hook-resolver.test.ts @@ -0,0 +1,234 @@ +// @vitest-environment node +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// The launcher must never shell out to find bun — it runs on every hook event, +// so resolution is an existsSync walk. Mocking child_process lets the "never +// spawns" test below prove that rather than assume it. +const spawnSync = vi.fn(); +vi.mock("node:child_process", () => ({ spawnSync })); + +const LAUNCHER = "../../scripts/dev-hook.mjs"; +const HOME = "/fake/home"; + +/** Every dep injected, so no test touches the real filesystem or env. */ +function deps(overrides: Record = {}) { + return { + env: {}, + platform: "linux", + execPath: "/usr/bin/node", + home: HOME, + exists: () => false, + readdir: () => [], + ...overrides, + }; +} + +/** exists() backed by an explicit set — anything else does not exist. */ +function existsIn(...paths: string[]) { + const set = new Set(paths); + return (p: string) => set.has(p); +} + +describe("findBun — locating the bun executable", () => { + it("returns the first PATH entry that has bun", async () => { + const { findBun } = await import(LAUNCHER); + expect( + findBun(deps({ env: { PATH: "/a:/b" }, exists: existsIn("/a/bun", "/b/bun") })), + ).toBe("/a/bun"); + }); + + it("skips PATH entries that do not have bun", async () => { + const { findBun } = await import(LAUNCHER); + expect( + findBun(deps({ env: { PATH: "/empty:/also-empty:/b" }, exists: existsIn("/b/bun") })), + ).toBe("/b/bun"); + }); + + it("ignores empty PATH segments", async () => { + const { findBun } = await import(LAUNCHER); + expect( + findBun(deps({ env: { PATH: "::/b:" }, exists: existsIn("/b/bun") })), + ).toBe("/b/bun"); + }); + + it("falls back to $BUN_INSTALL/bin when PATH has no bun", async () => { + const { findBun } = await import(LAUNCHER); + expect( + findBun(deps({ + env: { PATH: "/nope", BUN_INSTALL: "/opt/bun" }, + exists: existsIn("/opt/bun/bin/bun"), + })), + ).toBe("/opt/bun/bin/bun"); + }); + + // The macOS GUI-launch case: launchd builds PATH without ~/.zshrc, so + // ~/.bun/bin is absent from the hook's PATH while bun is right there on disk. + it("falls back to ~/.bun/bin — the macOS GUI-launch case", async () => { + const { findBun } = await import(LAUNCHER); + expect( + findBun(deps({ env: { PATH: "/usr/bin" }, exists: existsIn(`${HOME}/.bun/bin/bun`) })), + ).toBe(`${HOME}/.bun/bin/bun`); + }); + + it("falls back to Homebrew before /usr/local", async () => { + const { findBun } = await import(LAUNCHER); + expect( + findBun(deps({ + env: { PATH: "/nope" }, + exists: existsIn("/opt/homebrew/bin/bun", "/usr/local/bin/bun"), + })), + ).toBe("/opt/homebrew/bin/bun"); + }); + + it("skips the POSIX prefixes on win32", async () => { + const { findBun } = await import(LAUNCHER); + expect( + findBun(deps({ + platform: "win32", + env: { PATH: "C:\\nope" }, + exists: existsIn("/opt/homebrew/bin/bun", "/usr/local/bin/bun"), + })), + ).toBeNull(); + }); + + // The load-bearing case. `npm i -g bun` puts bun in ONE nvm version's bin, + // so `nvm use ` drops it off PATH entirely even though it still works. + it("finds bun in another nvm version dir when the active node's dir has none", async () => { + const { findBun } = await import(LAUNCHER); + const nvm = `${HOME}/.nvm/versions/node`; + expect( + findBun(deps({ + env: { PATH: `${nvm}/v24.0.0/bin` }, + execPath: `${nvm}/v24.0.0/bin/node`, + readdir: (p: string) => (p === nvm ? ["v20.1.0", "v24.0.0", "v26.5.0"] : []), + exists: existsIn(`${nvm}/v26.5.0/bin/bun`), + })), + ).toBe(`${nvm}/v26.5.0/bin/bun`); + }); + + it("prefers the newest nvm version when several have bun", async () => { + const { findBun } = await import(LAUNCHER); + const nvm = `${HOME}/.nvm/versions/node`; + expect( + findBun(deps({ + env: { PATH: "/nope" }, + readdir: (p: string) => (p === nvm ? ["v20.1.0", "v26.5.0"] : []), + exists: existsIn(`${nvm}/v20.1.0/bin/bun`, `${nvm}/v26.5.0/bin/bun`), + })), + ).toBe(`${nvm}/v26.5.0/bin/bun`); + }); + + // Only the executable *name* is platform-driven here — PATH splitting and + // joining come from the host's node:path, so these run with POSIX-shaped + // dirs even while asserting win32 naming. + it("prefers bun.exe over bare bun on win32", async () => { + const { findBun } = await import(LAUNCHER); + expect( + findBun(deps({ + platform: "win32", + env: { PATH: "/tools" }, + exists: existsIn("/tools/bun.exe", "/tools/bun"), + })), + ).toBe("/tools/bun.exe"); + }); + + it("returns null when bun exists nowhere", async () => { + const { findBun } = await import(LAUNCHER); + expect(findBun(deps({ env: { PATH: "/a:/b" } }))).toBeNull(); + }); + + it("does not probe the same directory twice", async () => { + const { findBun } = await import(LAUNCHER); + const seen: string[] = []; + findBun(deps({ + // /usr/local/bin appears on PATH *and* as a hardcoded prefix; the node + // execPath sibling duplicates a PATH entry too. + env: { PATH: "/usr/local/bin:/usr/bin" }, + execPath: "/usr/bin/node", + exists: (p: string) => { + seen.push(p); + return false; + }, + })); + expect(seen).toStrictEqual([...new Set(seen)]); + }); + + it("never spawns a subprocess", async () => { + const { findBun } = await import(LAUNCHER); + spawnSync.mockClear(); + findBun(deps({ env: { PATH: "/a:/b" } })); + expect(spawnSync).not.toHaveBeenCalled(); + }); +}); + +describe("findNpm", () => { + it("finds npm on PATH", async () => { + const { findNpm } = await import(LAUNCHER); + expect(findNpm(deps({ env: { PATH: "/usr/bin" }, exists: existsIn("/usr/bin/npm") }))).toBe( + "/usr/bin/npm", + ); + }); + + it("returns null when npm is absent", async () => { + const { findNpm } = await import(LAUNCHER); + expect(findNpm(deps({ env: { PATH: "/usr/bin" } }))).toBeNull(); + }); + + it("prefers npm.cmd over bare npm on win32", async () => { + const { findNpm } = await import(LAUNCHER); + expect( + findNpm(deps({ + platform: "win32", + env: { PATH: "/tools" }, + exists: existsIn("/tools/npm.cmd", "/tools/npm"), + })), + ).toBe("/tools/npm.cmd"); + }); +}); + +describe("resolveDevSpawn", () => { + it("pairs the resolved bun with the real binary's absolute path", async () => { + const { resolveDevSpawn } = await import(LAUNCHER); + const spawn = resolveDevSpawn(deps({ env: { PATH: "/a" }, exists: existsIn("/a/bun") })); + expect(spawn.cmd).toBe("/a/bun"); + expect(spawn.args).toStrictEqual([resolve(process.cwd(), "bin", "failproofai.mjs")]); + }); + + it("returns null when bun is unfindable", async () => { + const { resolveDevSpawn } = await import(LAUNCHER); + expect(resolveDevSpawn(deps({ env: { PATH: "/a" } }))).toBeNull(); + }); +}); + +describe("bunMissingMessage", () => { + it("is a single actionable line — it can fire on all 26 events", async () => { + const { bunMissingMessage } = await import(LAUNCHER); + const msg = bunMissingMessage(); + expect(msg).not.toContain("\n"); + expect(msg).toContain("bun.sh/install"); + }); +}); + +// Every CLI except Claude and Factory reads its deny decision as JSON on the +// hook's stdout. One stray byte from the launcher corrupts that parse and turns +// a denial into a silent allow, so the launcher must never write there at all. +describe("stdout purity", () => { + /** Launcher source with comments stripped — prose may discuss stdout freely. */ + function launcherCode() { + const src = readFileSync(resolve(process.cwd(), "scripts", "dev-hook.mjs"), "utf8"); + return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + } + + it("the launcher source never writes to stdout", () => { + expect(launcherCode()).not.toMatch(/console\.log|process\.stdout/); + }); + + it("never hands a subprocess our stdout to write to", () => { + // The delegate spawn inherits all three fds on purpose — that is the whole + // point, the child owns the contract. Every *other* spawn (npm, bun build) + // must discard its stdout instead. + expect(launcherCode().match(/stdio:\s*"inherit"/g) ?? []).toHaveLength(1); + }); +}); diff --git a/docker-hook-sync/Dockerfile b/docker-hook-sync/Dockerfile index 066d6e4e..c71d3521 100644 --- a/docker-hook-sync/Dockerfile +++ b/docker-hook-sync/Dockerfile @@ -5,10 +5,14 @@ # harness we integrate with and this repo, and opens ONE auto-PR with the fix. # # Bundles: -# - Node 20 + npm -# - bun (REQUIRED: the cloned repo's .claude/settings.json fires hooks via -# `bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook `, so the -# dogfooded require-*-before-stop policies enforce the commit/push/PR) +# - Node 20 + npm (npm is a fallback bun installer for scripts/dev-hook.mjs; +# node runs the launcher itself) +# - bun (STILL REQUIRED: the cloned repo's .claude/settings.json fires hooks via +# `node "$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs" --hook `, and that +# launcher only *locates* bun — it cannot conjure one. Without this layer the +# dogfooded require-*-before-stop policies never fire and the auto-PR is +# never committed/pushed. We install to BUN_INSTALL=/home/appuser/.bun, which +# the launcher probes explicitly, so enforcement survives a broken PATH.) # - Claude Code CLI (`claude`) — run headless with --effort ultracode # - failproofai@latest on PATH (belt-and-suspenders; the dogfood hooks actually # run the clone's SOURCE via bun, not this global) diff --git a/scripts/dev-hook.mjs b/scripts/dev-hook.mjs new file mode 100644 index 00000000..2503d6bc --- /dev/null +++ b/scripts/dev-hook.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node +/** + * failproofai DEV-ONLY hook launcher — for THIS repo's dogfood configs only. + * + * End users never run this. They install `npx -y failproofai --hook ` + * (see `src/hooks/integrations.ts`), which resolves the published package. + * This file exists because this repo dogfoods itself from source, and doing so + * has two prerequisites that silently rot: + * + * 1. `bun` on the hook's PATH. `bin/failproofai.mjs` cannot run under node — + * it does a bare `import { version } from "../package.json"` (node needs + * `with { type: "json" }`) and `await import("../src/hooks/handler")`, + * an extensionless TypeScript specifier node cannot resolve. But bun is + * routinely absent from a hook's PATH even when it's on yours: `npm i -g + * bun` installs into ONE nvm version's bin dir, so `nvm use ` drops + * it; a macOS GUI launch gets a launchd PATH built without ~/.zshrc. + * When that happens every hook exits 127 and the session runs with ZERO + * policy enforcement — silently. + * + * 2. `dist/index.js`. The committed policies in `.failproofai/policies/` + * `import from 'failproofai'`, which `findDistIndex()` resolves to that + * bundle. Without it all three fail-open and only builtins enforce. + * + * So: locate bun (installing it via npm if we must), make sure the bundle + * exists, then hand off to the real binary. If we cannot enforce, say so in + * one line rather than failing silently. + * + * INVARIANT — never write to stdout. Most CLIs' deny contracts are JSON on + * stdout (Copilot `{decision:"block"}`, Cursor `{followup_message}`, Devin, + * Factory-on-Stop, Antigravity). A single stray byte corrupts the parse and + * turns a denial into an allow. Diagnostics go to stderr; subprocesses we run + * for our own purposes get their stdout discarded, never inherited. + */ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs"; +import { constants, homedir } from "node:os"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, ".."); +const SRC_BIN = resolve(REPO_ROOT, "bin", "failproofai.mjs"); +const BUNDLE = resolve(REPO_ROOT, "dist", "index.js"); +const CACHE_DIR = resolve(REPO_ROOT, "node_modules", ".cache", "failproofai-dev"); +const LOCK_DIR = join(CACHE_DIR, ".lock"); + +/** A lock older than this belonged to a process that died mid-install. */ +const LOCK_STALE_MS = 5 * 60_000; +/** 120 × 250ms = 30s, under the tightest hook timeout (Factory/Antigravity). */ +const LOCK_WAIT_TRIES = 120; +const LOCK_WAIT_MS = 250; + +/** + * @typedef {object} Deps + * @property {Record} [env] + * @property {string} [platform] + * @property {string} [execPath] + * @property {string} [home] + * @property {(p: string) => boolean} [exists] + * @property {(p: string) => string[]} [readdir] + */ + +function warn(msg) { + process.stderr.write(`[failproofai-dev] ${msg}\n`); +} + +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function readdirSafe(p) { + try { + return readdirSync(p); + } catch { + return []; + } +} + +/** Executable basenames to probe, most specific first. */ +function exeNames(base, platform) { + return platform === "win32" ? [`${base}.exe`, `${base}.cmd`, base] : [base]; +} + +/** First existing `/` across dirs × names, or null. */ +function probe(dirs, names, exists) { + for (const dir of dirs) { + for (const name of names) { + const candidate = join(dir, name); + if (exists(candidate)) return candidate; + } + } + return null; +} + +/** + * Absolute path to a bun executable, or null. NEVER spawns a subprocess — + * this runs on every hook, so it is an existsSync walk and nothing more. + * @param {Deps} deps + */ +export function findBun(deps = {}) { + const env = deps.env ?? process.env; + const platform = deps.platform ?? process.platform; + const execPath = deps.execPath ?? process.execPath; + const home = deps.home ?? homedir(); + const exists = deps.exists ?? existsSync; + const readdir = deps.readdir ?? readdirSafe; + + const dirs = []; + const push = (d) => { + if (d && !dirs.includes(d)) dirs.push(d); + }; + + // 1. PATH — the common case, and the only one that costs nothing. + for (const p of (env.PATH ?? "").split(delimiter)) push(p); + // 2. An explicit bun install root. + if (env.BUN_INSTALL) push(join(env.BUN_INSTALL, "bin")); + // 3. Where bun.sh's installer puts it. The macOS GUI-launch case: launchd's + // PATH has /usr/local/bin but never ~/.bun/bin. + push(join(home, ".bun", "bin")); + // 4. Package-manager prefixes. + if (platform !== "win32") { + push("/opt/homebrew/bin"); + push("/usr/local/bin"); + } + // 5. Every nvm-managed node version's bin dir. Load-bearing: `npm i -g bun` + // lands bun in exactly ONE version's bin, so `nvm use ` makes it + // vanish from PATH entirely while it's still perfectly usable on disk. + // Descending so the newest version wins deterministically. + const nvmRoot = join(home, ".nvm", "versions", "node"); + for (const v of readdir(nvmRoot).sort().reverse()) push(join(nvmRoot, v, "bin")); + // 6. Next to the running node. For our own `node scripts/dev-hook.mjs` form + // this is a strict subset of (1) — node was resolved from PATH — so it is + // a no-op. Kept only for a caller that spawns node by absolute path. + push(dirname(execPath)); + + // Cache from a previous npm-install (see installBun). Last, so a real bun + // that reappears on PATH always wins over our vendored copy. + push(join(CACHE_DIR, "node_modules", ".bin")); + + return probe(dirs, exeNames("bun", platform), exists); +} + +/** Absolute path to npm, or null. PATH only — npm ships beside node. */ +export function findNpm(deps = {}) { + const env = deps.env ?? process.env; + const platform = deps.platform ?? process.platform; + const exists = deps.exists ?? existsSync; + const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean); + return probe(dirs, exeNames("npm", platform), exists); +} + +/** + * `{ cmd, args }` to run the real binary, or null when bun is unfindable. + * Shared with `.opencode/plugins/failproofai.mjs`, which spawns bun itself. + * @param {Deps} deps + */ +export function resolveDevSpawn(deps = {}) { + const bun = findBun(deps); + return bun ? { cmd: bun, args: [SRC_BIN] } : null; +} + +/** One actionable line. Keep it to one — this can fire on 26 events. */ +export function bunMissingMessage() { + return ( + "bun not found and npm unavailable to install it — this repo's dogfood hooks are NOT " + + "enforcing. Install bun: curl -fsSL https://bun.sh/install | bash" + ); +} + +/** + * Run `doWork` at most once across concurrently-firing hooks. `mkdirSync` is + * atomic, so it doubles as the lock: EEXIST means another process got there. + * Losers wait for the artifact rather than duplicating the work. + */ +function ensureOnce(isDone, doWork) { + if (isDone()) return true; + mkdirSync(CACHE_DIR, { recursive: true }); + + for (let i = 0; i < LOCK_WAIT_TRIES; i++) { + if (isDone()) return true; + try { + mkdirSync(LOCK_DIR); + } catch (err) { + if (err?.code !== "EEXIST") throw err; + try { + if (Date.now() - statSync(LOCK_DIR).mtimeMs > LOCK_STALE_MS) { + rmSync(LOCK_DIR, { recursive: true, force: true }); + } else { + sleepSync(LOCK_WAIT_MS); + } + } catch { + /* lock vanished under us — retry immediately */ + } + continue; + } + try { + doWork(); + } finally { + try { + rmSync(LOCK_DIR, { recursive: true, force: true }); + } catch { + /* best effort; a leaked lock goes stale in LOCK_STALE_MS */ + } + } + return isDone(); + } + return isDone(); +} + +/** + * Vendor bun into node_modules/.cache via npm. bun ships as an npm package, so + * npm is the one bootstrap that works when bun is genuinely absent — building + * `dist/cli.mjs` to run under node can't, because `build:cli` is itself + * `bun build`. + */ +function installBun(deps = {}) { + const npm = findNpm(deps); + if (!npm) return null; + ensureOnce( + () => findBun(deps) !== null, + () => { + warn("bun not found — installing it into node_modules/.cache (one time, ~15s)"); + const r = spawnSync( + npm, + ["install", "bun", "--prefix", CACHE_DIR, "--no-save", "--no-audit", "--no-fund", "--loglevel=error"], + // NOT "inherit" on stdout: npm prints there, and our stdout is a deny + // channel. stderr passes through so a failed install is visible. + { stdio: ["ignore", "ignore", "inherit"], cwd: REPO_ROOT }, + ); + if (r.status !== 0) warn("npm install bun failed — see the error above"); + }, + ); + return findBun(deps); +} + +/** + * Build dist/index.js if absent, so `.failproofai/policies/*.mjs` can resolve + * `import ... from 'failproofai'`. Non-fatal: builtins enforce without it. + */ +export function ensureBundle(bunPath) { + return ensureOnce( + () => existsSync(BUNDLE), + () => { + warn("dist/index.js missing — building it so .failproofai/policies/ can load (one time)"); + const r = spawnSync( + bunPath, + ["build", "--target=node", "--format=cjs", "--outfile=dist/index.js", "src/index.ts"], + { stdio: ["ignore", "ignore", "inherit"], cwd: REPO_ROOT }, + ); + if (r.status !== 0) warn("build failed — policies in .failproofai/policies/ will NOT load"); + }, + ); +} + +function main() { + const bun = findBun() ?? installBun(); + if (!bun) { + warn(bunMissingMessage()); + process.exit(1); + } + ensureBundle(bun); + + // stdio:"inherit" hands the child our actual fds: the payload on stdin and + // any decision JSON on stdout flow byte-exact, unbuffered, unparsed by us. + // No cwd override — the child must see whatever cwd the agent CLI chose, + // exactly as it did when the config spawned bun directly. + const r = spawnSync(bun, [SRC_BIN, ...process.argv.slice(2)], { stdio: "inherit" }); + + // Exit codes are a contract, not a status: 2 means DENY on Claude/Factory/ + // Goose. Propagate verbatim. Never synthesize 2 (would deny) or 0 (would + // silently allow). + if (r.error) { + warn(`could not run bun (${r.error.code ?? r.error.message}) — hooks are NOT enforcing`); + process.exit(1); + } + if (r.status === null && r.signal) { + // Mirror what `sh` reports for a signalled child, which is what the agent + // CLI saw before this launcher sat in between. + process.exit(128 + (constants.signals[r.signal] ?? 15)); + } + process.exit(r.status ?? 1); +} + +// Only run when executed, not when `.opencode/plugins/failproofai.mjs` imports +// the resolver. realpath so a symlinked or relative argv[1] still matches. +let invokedDirectly = false; +try { + invokedDirectly = realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); +} catch { + invokedDirectly = false; +} +if (invokedDirectly) main();