4e8b5af6c4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
4.6 KiB
Bash
Executable File
134 lines
4.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# PreToolUse hook: sugiere funciones del registry cuando un comando Bash
|
|
# inline probablemente reinventa una funcion existente (issue 0087).
|
|
#
|
|
# Llama a `./fn match "<cmd>"` con timeout 200ms. Si encaja con alta
|
|
# confianza, imprime un <system-reminder> a stderr para que Claude Code
|
|
# lo lea como recordatorio. NUNCA bloquea la tool — exit 0 siempre.
|
|
|
|
set -euo pipefail
|
|
|
|
# ---- Always exit 0, no matter what ----
|
|
trap 'exit 0' ERR
|
|
|
|
# ---- Resolve registry root (walks up from cwd) ----
|
|
resolve_root() {
|
|
local d="${PWD}"
|
|
while [ "$d" != "/" ]; do
|
|
if [ -f "$d/registry.db" ]; then
|
|
printf '%s' "$d"
|
|
return 0
|
|
fi
|
|
d=$(dirname "$d")
|
|
done
|
|
return 1
|
|
}
|
|
|
|
ROOT=$(resolve_root) || exit 0
|
|
FN_BIN="$ROOT/fn"
|
|
[ -x "$FN_BIN" ] || exit 0
|
|
|
|
# ---- Read stdin JSON ----
|
|
command -v jq >/dev/null 2>&1 || exit 0
|
|
INPUT=$(cat)
|
|
[ -z "$INPUT" ] && exit 0
|
|
|
|
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
|
|
[ "$TOOL_NAME" = "Bash" ] || exit 0
|
|
|
|
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
|
|
[ -z "$CMD" ] && exit 0
|
|
|
|
# Single-line for matching against denylist patterns
|
|
CMD_FLAT=$(printf '%s' "$CMD" | tr '\n' ' ')
|
|
|
|
# ---- Denylist (skip antes de llamar fn match para ahorrar el invoke) ----
|
|
|
|
# Comandos demasiado cortos -> trivial
|
|
CMD_LEN=${#CMD_FLAT}
|
|
[ "$CMD_LEN" -lt 20 ] && exit 0
|
|
|
|
# Trivial single-utility commands
|
|
case "$CMD_FLAT" in
|
|
"ls"|"ls "*|"cd"|"cd "*|"pwd"|"pwd "*|"cat"|"cat "*|"echo"|"echo "*)
|
|
exit 0 ;;
|
|
"grep"|"grep "*|"head"|"head "*|"tail"|"tail "*|"wc"|"wc "*)
|
|
exit 0 ;;
|
|
"mkdir"|"mkdir "*|"rm"|"rm "*|"mv"|"mv "*|"cp"|"cp "*)
|
|
exit 0 ;;
|
|
"git"|"git "*)
|
|
exit 0 ;;
|
|
"go"|"go "*)
|
|
# go build / go test corrientes — el agente ya los maneja
|
|
exit 0 ;;
|
|
esac
|
|
|
|
# Comandos que ya usan el registry: ./fn ..., fn run ..., mcp__registry__*
|
|
if printf '%s' "$CMD_FLAT" | grep -qE '(^|[[:space:]])\./fn([[:space:]]|$)'; then
|
|
exit 0
|
|
fi
|
|
if printf '%s' "$CMD_FLAT" | grep -qE '(^|[[:space:]])fn[[:space:]]+(run|search|show|code|uses|doctor|index|match|list|add|proposal|sync|ops|check)'; then
|
|
exit 0
|
|
fi
|
|
|
|
# Pure-cd (movement only, no logic)
|
|
if printf '%s' "$CMD_FLAT" | grep -qE '^[[:space:]]*cd[[:space:]]+[^&|;]+$'; then
|
|
exit 0
|
|
fi
|
|
|
|
# ---- Llamar fn match con timeout 200ms ----
|
|
command -v timeout >/dev/null 2>&1 || exit 0
|
|
|
|
# Truncar el comando a algo razonable para fn match (evitar args huge)
|
|
CMD_TRUNC=$(printf '%s' "$CMD_FLAT" | head -c 500)
|
|
|
|
MATCH_JSON=$(timeout 0.2 "$FN_BIN" match "$CMD_TRUNC" --format json --top 3 2>/dev/null) || exit 0
|
|
[ -z "$MATCH_JSON" ] && exit 0
|
|
|
|
# ---- Parsear JSON ----
|
|
HIGH_CONF=$(printf '%s' "$MATCH_JSON" | jq -r '.high_confidence // false' 2>/dev/null || echo "false")
|
|
TOP_ID=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].id // ""' 2>/dev/null || echo "")
|
|
TOP_SCORE=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].score // 0' 2>/dev/null || echo "0")
|
|
TOP_SIG=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].signature // ""' 2>/dev/null || echo "")
|
|
TOP_SNIP=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].snippet // ""' 2>/dev/null || echo "")
|
|
|
|
[ -z "$TOP_ID" ] && exit 0
|
|
|
|
# Trigger condition: (high_confidence==true OR score>=0.85) AND score>=0.6
|
|
# - high_confidence requires top1/top2 gap > 1.5 (set por fn match)
|
|
# - score>=0.85 cubre matches muy fuertes donde el gap es modesto
|
|
SCORE_HI=$(awk -v s="$TOP_SCORE" 'BEGIN{ print (s+0 >= 0.85) ? "1" : "0" }')
|
|
SCORE_MIN=$(awk -v s="$TOP_SCORE" 'BEGIN{ print (s+0 >= 0.6) ? "1" : "0" }')
|
|
|
|
[ "$SCORE_MIN" = "1" ] || exit 0
|
|
if [ "$HIGH_CONF" != "true" ] && [ "$SCORE_HI" != "1" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Truncar snippet a 100 chars y limpiar saltos de linea
|
|
SNIP_SHORT=$(printf '%s' "$TOP_SNIP" | tr '\n' ' ' | head -c 100)
|
|
|
|
# Formatear score con 2 decimales
|
|
SCORE_FMT=$(awk -v s="$TOP_SCORE" 'BEGIN{ printf "%.2f", s+0 }')
|
|
|
|
# ---- Emitir <system-reminder> a stderr ----
|
|
cat >&2 <<EOF
|
|
<system-reminder>FUZZY-MATCH (issue 0087): your Bash command may already be a function.
|
|
USE: ./fn run $TOP_ID -> $TOP_SIG
|
|
SNIPPET: $SNIP_SHORT
|
|
Confidence: $SCORE_FMT. If you proceed inline, the violation will be logged.
|
|
</system-reminder>
|
|
EOF
|
|
|
|
exit 0
|
|
|
|
# Test manual:
|
|
# echo '{"tool_name":"Bash","tool_input":{"command":"taskkill.exe /IM registry_dashboard.exe /F"},"session_id":"test"}' \
|
|
# | bash .claude/scripts/hook_fn_match.sh
|
|
#
|
|
# Casos silenciosos:
|
|
# echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"},"session_id":"test"}' \
|
|
# | bash .claude/scripts/hook_fn_match.sh
|
|
# echo '{"tool_name":"Bash","tool_input":{"command":"./fn run filter_slice_go_core 1 2 3"},"session_id":"test"}' \
|
|
# | bash .claude/scripts/hook_fn_match.sh
|