diff --git a/.claude/hooks/auto-lint.sh b/.claude/hooks/auto-lint.sh new file mode 100755 index 0000000..7521f5a --- /dev/null +++ b/.claude/hooks/auto-lint.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +# PostToolUse hook: auto-lints files after Write/Edit. +# If lint fails, blocks Claude with errors so it self-corrects. +# If lint passes, exits silently (no context waste). + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path') + +# Skip if no file path or null +[ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0 + +# Skip files outside the project +[[ "$FILE_PATH" == "$CLAUDE_PROJECT_DIR/"* ]] || exit 0 + +# Skip non-existent files (e.g. after a failed write) +[ -f "$FILE_PATH" ] || exit 0 + +EXTENSION="${FILE_PATH##*.}" +LINT_OUTPUT="" +LINT_EXIT=0 + +case "$EXTENSION" in + ts|tsx|js|jsx|json|css) + LINT_OUTPUT=$(biome check --write "$FILE_PATH" 2>&1) || LINT_EXIT=$? + ;; + py) + LINT_OUTPUT=$( + cd "$CLAUDE_PROJECT_DIR/packages/execution" \ + && uv run ruff check --fix "$FILE_PATH" 2>&1 \ + && uv run ruff format "$FILE_PATH" 2>&1 + ) || LINT_EXIT=$? + ;; + *) + exit 0 + ;; +esac + +if [ $LINT_EXIT -ne 0 ]; then + # Strip ANSI codes and send errors back to Claude + CLEAN_OUTPUT=$(echo "$LINT_OUTPUT" | sed 's/\x1b\[[0-9;]*m//g') + jq -n --arg reason "$CLEAN_OUTPUT" '{"decision": "block", "reason": $reason}' + exit 0 +fi + +# Lint passed — exit silently +exit 0 diff --git a/.claude/hooks/command-interceptor.sh b/.claude/hooks/command-interceptor.sh new file mode 100755 index 0000000..b520815 --- /dev/null +++ b/.claude/hooks/command-interceptor.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +# PreToolUse hook: intercepts test/lint/build Bash commands, +# wraps them in run_silent.sh, and injects fail-fast flags. +# Non-matching commands pass through untouched. + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command') +TIMEOUT=$(echo "$INPUT" | jq -r '.tool_input.timeout // 120000') +DESC=$(echo "$INPUT" | jq -r '.tool_input.description // ""') +RUN_SILENT="$CLAUDE_PROJECT_DIR/scripts/run_silent.sh" + +# --- Skip: compound commands and shell metacharacters --- +if [[ "$COMMAND" == *"&&"* ]] || [[ "$COMMAND" == *"||"* ]] || [[ "$COMMAND" == *";"* ]] \ + || [[ "$COMMAND" == *'$('* ]] || [[ "$COMMAND" == *'`'* ]] || [[ "$COMMAND" == *"|"* ]] \ + || [[ "$COMMAND" == *">("* ]] || [[ "$COMMAND" == *"<("* ]] \ + || [[ "$COMMAND" == *">"* ]] || [[ "$COMMAND" == *"<"* ]]; then + exit 0 +fi + +# --- Skip: interactive, dev, install, git, docker --- +case "$COMMAND" in + git\ *|docker\ *|docker-compose\ *) exit 0 ;; + pnpm\ dev*|pnpm\ run\ dev*|pnpm\ add*) exit 0 ;; + pnpm\ install*|pnpm\ i\ *|pnpm\ i) exit 0 ;; + uv\ add*|uv\ sync*|uv\ pip*) exit 0 ;; + cd\ *|ls*|cat\ *|echo\ *|which\ *|pwd*) exit 0 ;; + mkdir\ *|rm\ *|cp\ *|mv\ *|chmod\ *) exit 0 ;; + gh\ *|curl\ *|wget\ *) exit 0 ;; +esac + +# Skip anything with --watch or --interactive +if [[ "$COMMAND" == *"--watch"* ]] || [[ "$COMMAND" == *"--interactive"* ]]; then + exit 0 +fi + +# --- Match: wrap in run_silent + inject fail-fast --- +WRAPPED="" + +case "$COMMAND" in + # vitest with fail-fast (bare `vitest` excluded — it launches watch mode) + vitest\ run*) + if [[ "$COMMAND" != *"--bail"* ]]; then + WRAPPED="$RUN_SILENT $COMMAND --bail 1" + else + WRAPPED="$RUN_SILENT $COMMAND" + fi ;; + + # pytest with fail-fast + pytest\ *|pytest|uv\ run\ pytest\ *|uv\ run\ pytest) + if [[ "$COMMAND" != *"-x"* ]]; then + WRAPPED="$RUN_SILENT $COMMAND -x" + else + WRAPPED="$RUN_SILENT $COMMAND" + fi ;; + + # pnpm scripts + pnpm\ test*|pnpm\ run\ test*) WRAPPED="$RUN_SILENT $COMMAND" ;; + pnpm\ typecheck*|pnpm\ run\ typecheck*) WRAPPED="$RUN_SILENT $COMMAND" ;; + pnpm\ lint*|pnpm\ run\ lint*) WRAPPED="$RUN_SILENT $COMMAND" ;; + pnpm\ build*|pnpm\ run\ build*) WRAPPED="$RUN_SILENT $COMMAND" ;; + pnpm\ verify*|pnpm\ run\ verify*) WRAPPED="$RUN_SILENT $COMMAND" ;; + pnpm\ check*|pnpm\ run\ check*) WRAPPED="$RUN_SILENT $COMMAND" ;; + pnpm\ format*|pnpm\ run\ format*) WRAPPED="$RUN_SILENT $COMMAND" ;; + + # Direct tool invocations + turbo\ run\ *) WRAPPED="$RUN_SILENT $COMMAND" ;; + tsc\ *) WRAPPED="$RUN_SILENT $COMMAND" ;; + biome\ check*) WRAPPED="$RUN_SILENT $COMMAND" ;; + ruff\ check*) WRAPPED="$RUN_SILENT $COMMAND" ;; + ruff\ format*) WRAPPED="$RUN_SILENT $COMMAND" ;; +esac + +# No match — pass through silently +if [ -z "$WRAPPED" ]; then + exit 0 +fi + +# Emit rewrite JSON: wrap command + auto-approve +jq -n \ + --arg cmd "$WRAPPED" \ + --argjson timeout "$TIMEOUT" \ + --arg desc "$DESC" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "Context backpressure: wrapping in run_silent", + "updatedInput": { + "command": $cmd, + "timeout": $timeout, + "description": $desc + } + } + }' diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..136ec14 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,28 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/command-interceptor.sh", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-lint.sh", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 6c00576..0c5bc2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,22 @@ Biome handles formatting and linting. No ESLint. --- +## Context backpressure + +Three hooks manage output automatically: + +1. **PreToolUse** wraps test/lint/build commands in `scripts/run_silent.sh`: + success → `✓ command (Xs)`, failure → filtered output only. +2. **PostToolUse** auto-lints after every Write/Edit via biome (TS) or + ruff (Python). Lint failures block until fixed — do not suppress them. +3. **Fail-fast** flags (`pytest -x`, `vitest --bail 1`) are injected + automatically. + +Do not pipe to `/dev/null` or truncate with `head`/`tail`. The hooks +handle it. For full debug output, prefix with `env VERBOSE=1`. + +--- + ## Skills (load when relevant) ``` diff --git a/scripts/run_silent.sh b/scripts/run_silent.sh new file mode 100755 index 0000000..df97786 --- /dev/null +++ b/scripts/run_silent.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Context-efficient backpressure wrapper. +# Success: prints a single summary line with test counts (if applicable). +# Failure: prints filtered output (no ANSI, no noise). +# Bypass: VERBOSE=1 ./scripts/run_silent.sh + +if [ "${VERBOSE:-}" = "1" ]; then + exec "$@" +fi + +LABEL="$*" +TMPFILE=$(mktemp) +trap 'rm -f "$TMPFILE"' EXIT INT TERM +SECONDS=0 + +set +e +"$@" > "$TMPFILE" 2>&1 +EXIT_CODE=$? +set -e + +ELAPSED="${SECONDS}s" + +if [ $EXIT_CODE -eq 0 ]; then + # Extract framework-specific test counts for a compact summary + SUMMARY="" + + # vitest: "Test Files 3 passed (3)" + if grep -qE "Test Files.*passed" "$TMPFILE" 2>/dev/null; then + COUNT=$(grep -oE "[0-9]+ passed" "$TMPFILE" | head -1 | awk '{print $1}') + [ -n "$COUNT" ] && SUMMARY="${COUNT} test files, " + + # pytest: "8 passed in 1.23s" + elif grep -qE "[0-9]+ passed" "$TMPFILE" 2>/dev/null; then + COUNT=$(grep -oE "[0-9]+ passed" "$TMPFILE" | tail -1 | awk '{print $1}') + [ -n "$COUNT" ] && SUMMARY="${COUNT} tests, " + fi + + printf "✓ %s (%s%s)\n" "$LABEL" "$SUMMARY" "$ELAPSED" +else + printf "✗ %s (exit %d, %s)\n" "$LABEL" "$EXIT_CODE" "$ELAPSED" + echo "---" + # Filter noise: ANSI codes, blank lines, node_modules frames, timing/cache lines + sed 's/\x1b\[[0-9;]*m//g' "$TMPFILE" \ + | grep -v '^\s*$' \ + | grep -v '^\s*at .*/node_modules/' \ + | grep -v '^ Duration' \ + | grep -v '^ Tasks:' \ + | grep -v '^ Cache:' \ + || true + echo "---" +fi + +exit $EXIT_CODE