diff --git a/claude/code/docs/hook-output-policy.md b/claude/code/docs/hook-output-policy.md new file mode 100644 index 0000000..0953502 --- /dev/null +++ b/claude/code/docs/hook-output-policy.md @@ -0,0 +1,83 @@ +# Hook Output Policy + +Consistent policy for what hook output to expose to Claude vs hide. + +## Principles + +### Always Expose + +| Category | Example | Reason | +|----------|---------|--------| +| Test failures | `FAIL: TestFoo` | Must be fixed | +| Build errors | `cannot find package` | Blocks progress | +| Lint errors | `undefined: foo` | Code quality | +| Security alerts | `HIGH vulnerability` | Critical | +| Type errors | `type mismatch` | Must be fixed | +| Debug statements | `dd() found` | Must be removed | +| Uncommitted work | `3 files unstaged` | Might get lost | +| Coverage drops | `84% → 79%` | Quality regression | + +### Always Hide + +| Category | Example | Reason | +|----------|---------|--------| +| Pass confirmations | `PASS: TestFoo` | No action needed | +| Format success | `Formatted 3 files` | No action needed | +| Coverage stable | `84% (unchanged)` | No action needed | +| Timing info | `(12.3s)` | Noise | +| Progress bars | `[=====> ]` | Noise | + +### Conditional + +| Category | Show When | Hide When | +|----------|-----------|-----------| +| Warnings | First occurrence | Repeated | +| Suggestions | Actionable | Informational | +| Diffs | Small (<10 lines) | Large | +| Stack traces | Unique error | Repeated | + +## Implementation + +Use `output-policy.sh` helper functions: + +```bash +source "$SCRIPT_DIR/output-policy.sh" + +# Expose failures +expose_error "Build failed" "$error_details" +expose_warning "Debug statements found" "$locations" + +# Hide success +hide_success + +# Pass through unchanged +pass_through "$input" +``` + +## Hook-Specific Policies + +| Hook | Expose | Hide | +|------|--------|------| +| `check-debug.sh` | Debug statements found | Clean file | +| `post-commit-check.sh` | Uncommitted work | Clean working tree | +| `check-coverage.sh` | Coverage dropped | Coverage stable/improved | +| `go-format.sh` | (never) | Always silent | +| `php-format.sh` | (never) | Always silent | + +## Aggregation + +When multiple issues, aggregate intelligently: + +``` +Instead of: +- FAIL: TestA +- FAIL: TestB +- FAIL: TestC +- (47 more) + +Show: +"50 tests failed. Top failures: +- TestA: nil pointer +- TestB: timeout +- TestC: assertion failed" +``` diff --git a/claude/code/scripts/check-coverage.sh b/claude/code/scripts/check-coverage.sh index 63bedf2..817dd08 100755 --- a/claude/code/scripts/check-coverage.sh +++ b/claude/code/scripts/check-coverage.sh @@ -1,22 +1,23 @@ #!/bin/bash # Check for a drop in test coverage. +# Policy: EXPOSE warning when coverage drops, HIDE when stable/improved -set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/output-policy.sh" # Source the main coverage script to use its functions -source claude/code/commands/coverage.sh +source claude/code/commands/coverage.sh 2>/dev/null || true -# Read the input from the hook system read -r input -# Get current and previous coverage -CURRENT_COVERAGE=$(get_current_coverage) -PREVIOUS_COVERAGE=$(get_previous_coverage) +# Get current and previous coverage (with fallbacks) +CURRENT_COVERAGE=$(get_current_coverage 2>/dev/null || echo "0") +PREVIOUS_COVERAGE=$(get_previous_coverage 2>/dev/null || echo "0") -# Compare coverage and print warning to stderr +# Compare coverage if awk -v current="$CURRENT_COVERAGE" -v previous="$PREVIOUS_COVERAGE" 'BEGIN {exit !(current < previous)}'; then - echo "⚠️ Test coverage dropped from $PREVIOUS_COVERAGE% to $CURRENT_COVERAGE%" >&2 + DROP=$(awk -v c="$CURRENT_COVERAGE" -v p="$PREVIOUS_COVERAGE" 'BEGIN {printf "%.1f", p - c}') + expose_warning "Test coverage dropped by ${DROP}%" "Previous: ${PREVIOUS_COVERAGE}% → Current: ${CURRENT_COVERAGE}%" +else + pass_through "$input" fi - -# Pass the original input through to the next hook -echo "$input" diff --git a/claude/code/scripts/check-debug.sh b/claude/code/scripts/check-debug.sh index 079cc0e..f426b48 100755 --- a/claude/code/scripts/check-debug.sh +++ b/claude/code/scripts/check-debug.sh @@ -1,27 +1,28 @@ #!/bin/bash # Warn about debug statements left in code after edits +# Policy: EXPOSE warning when found, HIDE when clean + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/output-policy.sh" read -r input FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') +FOUND="" + if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then case "$FILE_PATH" in *.go) - # Check for fmt.Println, log.Println debug statements - if grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then - echo "[Hook] WARNING: Debug prints found in $FILE_PATH" >&2 - grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 >&2 - fi + FOUND=$(grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3) ;; *.php) - # Check for dd(), dump(), var_dump(), print_r() - if grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then - echo "[Hook] WARNING: Debug statements found in $FILE_PATH" >&2 - grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 >&2 - fi + FOUND=$(grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3) ;; esac fi -# Pass through the input -echo "$input" +if [[ -n "$FOUND" ]]; then + expose_warning "Debug statements in \`$FILE_PATH\`" "\`\`\`\n$FOUND\n\`\`\`" +else + pass_through "$input" +fi diff --git a/claude/code/scripts/go-format.sh b/claude/code/scripts/go-format.sh index 8f9d322..3255802 100755 --- a/claude/code/scripts/go-format.sh +++ b/claude/code/scripts/go-format.sh @@ -1,5 +1,9 @@ #!/bin/bash # Auto-format Go files after edits using core go fmt +# Policy: HIDE success (formatting is silent background operation) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/output-policy.sh" read -r input FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') @@ -15,5 +19,5 @@ if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then fi fi -# Pass through the input -echo "$input" +# Silent success - no output needed +hide_success diff --git a/claude/code/scripts/output-policy.sh b/claude/code/scripts/output-policy.sh new file mode 100755 index 0000000..8f873d0 --- /dev/null +++ b/claude/code/scripts/output-policy.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Hook Output Policy - Expose vs Hide +# +# EXPOSE (additionalContext): +# - Errors that need fixing +# - Failures that block progress +# - Security warnings +# - Breaking changes +# +# HIDE (suppressOutput): +# - Success confirmations +# - Verbose progress output +# - Repetitive status messages +# - Debug information +# +# Usage: +# source output-policy.sh +# expose_error "Test failed: $error" +# expose_warning "Debug statements found" +# hide_success +# pass_through "$input" + +# Expose an error to Claude (always visible) +expose_error() { + local message="$1" + local context="$2" + + cat << EOF +{ + "hookSpecificOutput": { + "additionalContext": "## ❌ Error\n\n$message${context:+\n\n$context}" + } +} +EOF +} + +# Expose a warning to Claude (visible, but not blocking) +expose_warning() { + local message="$1" + local context="$2" + + cat << EOF +{ + "hookSpecificOutput": { + "additionalContext": "## ⚠️ Warning\n\n$message${context:+\n\n$context}" + } +} +EOF +} + +# Expose informational context (visible when relevant) +expose_info() { + local message="$1" + + cat << EOF +{ + "hookSpecificOutput": { + "additionalContext": "$message" + } +} +EOF +} + +# Hide output (success, no action needed) +hide_success() { + echo '{"suppressOutput": true}' +} + +# Pass through without modification (neutral) +pass_through() { + echo "$1" +} + +# Aggregate multiple issues into a summary +aggregate_issues() { + local issues=("$@") + local count=${#issues[@]} + + if [[ $count -eq 0 ]]; then + hide_success + return + fi + + local summary="" + local shown=0 + local max_shown=5 + + for issue in "${issues[@]}"; do + if [[ $shown -lt $max_shown ]]; then + summary+="- $issue\n" + ((shown++)) + fi + done + + if [[ $count -gt $max_shown ]]; then + summary+="\n... and $((count - max_shown)) more" + fi + + expose_warning "$count issues found:" "$summary" +} diff --git a/claude/code/scripts/php-format.sh b/claude/code/scripts/php-format.sh index e0e7ec1..b17bdb1 100755 --- a/claude/code/scripts/php-format.sh +++ b/claude/code/scripts/php-format.sh @@ -1,5 +1,9 @@ #!/bin/bash # Auto-format PHP files after edits using core php fmt +# Policy: HIDE success (formatting is silent background operation) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/output-policy.sh" read -r input FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') @@ -13,5 +17,5 @@ if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then fi fi -# Pass through the input -echo "$input" +# Silent success - no output needed +hide_success diff --git a/claude/code/scripts/post-commit-check.sh b/claude/code/scripts/post-commit-check.sh index 42418b6..a13d4ee 100755 --- a/claude/code/scripts/post-commit-check.sh +++ b/claude/code/scripts/post-commit-check.sh @@ -1,15 +1,16 @@ #!/bin/bash # Post-commit hook: Check for uncommitted work that might get lost -# -# After committing task-specific files, check if there's other work -# in the repo that should be committed or stashed +# Policy: EXPOSE warning when uncommitted work exists, HIDE when clean + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/output-policy.sh" read -r input COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty') # Only run after git commit if ! echo "$COMMAND" | grep -qE '^git commit'; then - echo "$input" + pass_through "$input" exit 0 fi @@ -21,31 +22,26 @@ UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d TOTAL=$((UNSTAGED + STAGED + UNTRACKED)) if [[ $TOTAL -gt 0 ]]; then - echo "" >&2 - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 - echo "[PostCommit] WARNING: Uncommitted work remains" >&2 - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 + DETAILS="" if [[ $UNSTAGED -gt 0 ]]; then - echo " Modified (unstaged): $UNSTAGED files" >&2 - git diff --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2 - [[ $UNSTAGED -gt 5 ]] && echo " ... and $((UNSTAGED - 5)) more" >&2 + FILES=$(git diff --name-only 2>/dev/null | head -5 | sed 's/^/ - /') + DETAILS+="**Modified (unstaged):** $UNSTAGED files\n$FILES\n" + [[ $UNSTAGED -gt 5 ]] && DETAILS+=" ... and $((UNSTAGED - 5)) more\n" fi if [[ $STAGED -gt 0 ]]; then - echo " Staged (not committed): $STAGED files" >&2 - git diff --cached --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2 + FILES=$(git diff --cached --name-only 2>/dev/null | head -5 | sed 's/^/ - /') + DETAILS+="**Staged (not committed):** $STAGED files\n$FILES\n" fi if [[ $UNTRACKED -gt 0 ]]; then - echo " Untracked: $UNTRACKED files" >&2 - git ls-files --others --exclude-standard 2>/dev/null | head -5 | sed 's/^/ /' >&2 - [[ $UNTRACKED -gt 5 ]] && echo " ... and $((UNTRACKED - 5)) more" >&2 + FILES=$(git ls-files --others --exclude-standard 2>/dev/null | head -5 | sed 's/^/ - /') + DETAILS+="**Untracked:** $UNTRACKED files\n$FILES\n" + [[ $UNTRACKED -gt 5 ]] && DETAILS+=" ... and $((UNTRACKED - 5)) more\n" fi - echo "" >&2 - echo "Consider: commit these, stash them, or confirm they're intentionally left" >&2 - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 + expose_warning "Uncommitted work remains ($TOTAL files)" "$DETAILS" +else + pass_through "$input" fi - -echo "$input"