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
|
||||
# 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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue