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:
parent
89cc44eaf6
commit
bd4207c806
7 changed files with 237 additions and 48 deletions
83
claude/code/docs/hook-output-policy.md
Normal file
83
claude/code/docs/hook-output-policy.md
Normal 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"
|
||||||
|
```
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
100
claude/code/scripts/output-policy.sh
Executable file
100
claude/code/scripts/output-policy.sh
Executable 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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue