feat(hooks): implement expose/hide output policy (#17)

Add consistent policy for what hook output to show vs suppress:
- EXPOSE: errors, warnings, debug statements, uncommitted work
- HIDE: format success, coverage stable, pass confirmations

New files:
- output-policy.sh: helper functions (expose_error, expose_warning, hide_success)
- hook-output-policy.md: documentation

Updated hooks to use proper Claude Code JSON output format:
- check-debug.sh: expose warnings via additionalContext
- post-commit-check.sh: expose uncommitted work warnings
- check-coverage.sh: expose coverage drops
- go-format.sh: suppress output on success
- php-format.sh: suppress output on success

Closes #17

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-02 15:08:07 +00:00
parent 89cc44eaf6
commit bd4207c806
7 changed files with 237 additions and 48 deletions

View file

@ -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"
```

View file

@ -1,22 +1,23 @@
#!/bin/bash #!/bin/bash
# Check for a drop in test coverage. # 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 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 read -r input
# Get current and previous coverage # Get current and previous coverage (with fallbacks)
CURRENT_COVERAGE=$(get_current_coverage) CURRENT_COVERAGE=$(get_current_coverage 2>/dev/null || echo "0")
PREVIOUS_COVERAGE=$(get_previous_coverage) 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 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 fi
# Pass the original input through to the next hook
echo "$input"

View file

@ -1,27 +1,28 @@
#!/bin/bash #!/bin/bash
# Warn about debug statements left in code after edits # 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 read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
FOUND=""
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
case "$FILE_PATH" in case "$FILE_PATH" in
*.go) *.go)
# Check for fmt.Println, log.Println debug statements FOUND=$(grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3)
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
;; ;;
*.php) *.php)
# Check for dd(), dump(), var_dump(), print_r() FOUND=$(grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3)
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
;; ;;
esac esac
fi fi
# Pass through the input if [[ -n "$FOUND" ]]; then
echo "$input" expose_warning "Debug statements in \`$FILE_PATH\`" "\`\`\`\n$FOUND\n\`\`\`"
else
pass_through "$input"
fi

View file

@ -1,5 +1,9 @@
#!/bin/bash #!/bin/bash
# Auto-format Go files after edits using core go fmt # 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 read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') 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
fi fi
# Pass through the input # Silent success - no output needed
echo "$input" hide_success

View file

@ -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"
}

View file

@ -1,5 +1,9 @@
#!/bin/bash #!/bin/bash
# Auto-format PHP files after edits using core php fmt # 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 read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') 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
fi fi
# Pass through the input # Silent success - no output needed
echo "$input" hide_success

View file

@ -1,15 +1,16 @@
#!/bin/bash #!/bin/bash
# Post-commit hook: Check for uncommitted work that might get lost # Post-commit hook: Check for uncommitted work that might get lost
# # Policy: EXPOSE warning when uncommitted work exists, HIDE when clean
# After committing task-specific files, check if there's other work
# in the repo that should be committed or stashed SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/output-policy.sh"
read -r input read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty') COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
# Only run after git commit # Only run after git commit
if ! echo "$COMMAND" | grep -qE '^git commit'; then if ! echo "$COMMAND" | grep -qE '^git commit'; then
echo "$input" pass_through "$input"
exit 0 exit 0
fi fi
@ -21,31 +22,26 @@ UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d
TOTAL=$((UNSTAGED + STAGED + UNTRACKED)) TOTAL=$((UNSTAGED + STAGED + UNTRACKED))
if [[ $TOTAL -gt 0 ]]; then if [[ $TOTAL -gt 0 ]]; then
echo "" >&2 DETAILS=""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
echo "[PostCommit] WARNING: Uncommitted work remains" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
if [[ $UNSTAGED -gt 0 ]]; then if [[ $UNSTAGED -gt 0 ]]; then
echo " Modified (unstaged): $UNSTAGED files" >&2 FILES=$(git diff --name-only 2>/dev/null | head -5 | sed 's/^/ - /')
git diff --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2 DETAILS+="**Modified (unstaged):** $UNSTAGED files\n$FILES\n"
[[ $UNSTAGED -gt 5 ]] && echo " ... and $((UNSTAGED - 5)) more" >&2 [[ $UNSTAGED -gt 5 ]] && DETAILS+=" ... and $((UNSTAGED - 5)) more\n"
fi fi
if [[ $STAGED -gt 0 ]]; then if [[ $STAGED -gt 0 ]]; then
echo " Staged (not committed): $STAGED files" >&2 FILES=$(git diff --cached --name-only 2>/dev/null | head -5 | sed 's/^/ - /')
git diff --cached --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2 DETAILS+="**Staged (not committed):** $STAGED files\n$FILES\n"
fi fi
if [[ $UNTRACKED -gt 0 ]]; then if [[ $UNTRACKED -gt 0 ]]; then
echo " Untracked: $UNTRACKED files" >&2 FILES=$(git ls-files --others --exclude-standard 2>/dev/null | head -5 | sed 's/^/ - /')
git ls-files --others --exclude-standard 2>/dev/null | head -5 | sed 's/^/ /' >&2 DETAILS+="**Untracked:** $UNTRACKED files\n$FILES\n"
[[ $UNTRACKED -gt 5 ]] && echo " ... and $((UNTRACKED - 5)) more" >&2 [[ $UNTRACKED -gt 5 ]] && DETAILS+=" ... and $((UNTRACKED - 5)) more\n"
fi fi
echo "" >&2 expose_warning "Uncommitted work remains ($TOTAL files)" "$DETAILS"
echo "Consider: commit these, stash them, or confirm they're intentionally left" >&2 else
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 pass_through "$input"
fi fi
echo "$input"