Initial commit
This commit is contained in:
119
hooks/post-tool-use/01-track-edits.sh
Executable file
119
hooks/post-tool-use/01-track-edits.sh
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
CONTEXT_DIR="$(dirname "$0")/../context"
|
||||
LOG_FILE="$CONTEXT_DIR/edit-log.txt"
|
||||
LOCK_FILE="$CONTEXT_DIR/.edit-log.lock"
|
||||
MAX_LOG_LINES=1000
|
||||
LOCK_TIMEOUT=5
|
||||
|
||||
# Create context dir and log if doesn't exist
|
||||
mkdir -p "$CONTEXT_DIR"
|
||||
touch "$LOG_FILE"
|
||||
|
||||
# Acquire lock with timeout
|
||||
acquire_lock() {
|
||||
local count=0
|
||||
while [ $count -lt $LOCK_TIMEOUT ]; do
|
||||
if mkdir "$LOCK_FILE" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
count=$((count + 1))
|
||||
done
|
||||
# Log but don't fail - non-blocking requirement
|
||||
echo "Warning: Could not acquire lock" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Release lock
|
||||
release_lock() {
|
||||
rmdir "$LOCK_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Clean up lock on exit
|
||||
trap release_lock EXIT
|
||||
|
||||
# Function to log edit
|
||||
log_edit() {
|
||||
local file_path="$1"
|
||||
local tool_name="$2"
|
||||
local timestamp=$(date +"%Y-%m-%d %H:%M:%S")
|
||||
local repo=$(find_repo "$file_path")
|
||||
|
||||
if acquire_lock; then
|
||||
echo "$timestamp | $repo | $tool_name | $file_path" >> "$LOG_FILE"
|
||||
release_lock
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to find repo root
|
||||
find_repo() {
|
||||
local file_path="$1"
|
||||
if [ -z "$file_path" ] || [ "$file_path" = "null" ]; then
|
||||
echo "unknown"
|
||||
return
|
||||
fi
|
||||
|
||||
local dir
|
||||
dir=$(dirname "$file_path" 2>/dev/null || echo "/")
|
||||
while [ "$dir" != "/" ] && [ -n "$dir" ]; do
|
||||
if [ -d "$dir/.git" ]; then
|
||||
basename "$dir"
|
||||
return
|
||||
fi
|
||||
dir=$(dirname "$dir" 2>/dev/null || echo "/")
|
||||
done
|
||||
echo "unknown"
|
||||
}
|
||||
|
||||
# Read tool use event from stdin (with timeout to prevent hanging)
|
||||
if ! read -t 2 -r tool_use_json; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate JSON to prevent injection
|
||||
if ! echo "$tool_use_json" | jq empty 2>/dev/null; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract tool name and file path from tool use
|
||||
tool_name=$(echo "$tool_use_json" | jq -r '.tool.name // .tool_name // "unknown"' 2>/dev/null || echo "unknown")
|
||||
file_path=""
|
||||
|
||||
case "$tool_name" in
|
||||
"Edit"|"Write")
|
||||
file_path=$(echo "$tool_use_json" | jq -r '.tool.input.file_path // .tool_input.file_path // "null"' 2>/dev/null || echo "null")
|
||||
;;
|
||||
"MultiEdit")
|
||||
# MultiEdit has multiple files - log each
|
||||
echo "$tool_use_json" | jq -r '.tool.input.edits[]?.file_path // .tool_input.edits[]?.file_path // empty' 2>/dev/null | while read -r path; do
|
||||
if [ -n "$path" ] && [ "$path" != "null" ]; then
|
||||
log_edit "$path" "$tool_name"
|
||||
fi
|
||||
done
|
||||
echo '{}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Log single edit
|
||||
if [ -n "$file_path" ] && [ "$file_path" != "null" ]; then
|
||||
log_edit "$file_path" "$tool_name"
|
||||
fi
|
||||
|
||||
# Rotate log if too large (with lock)
|
||||
if acquire_lock; then
|
||||
line_count=$(wc -l < "$LOG_FILE" 2>/dev/null || echo "0")
|
||||
if [ "$line_count" -gt "$MAX_LOG_LINES" ]; then
|
||||
tail -n "$MAX_LOG_LINES" "$LOG_FILE" > "$LOG_FILE.tmp"
|
||||
mv "$LOG_FILE.tmp" "$LOG_FILE"
|
||||
fi
|
||||
release_lock
|
||||
fi
|
||||
|
||||
# Return success (non-blocking)
|
||||
echo '{}'
|
||||
102
hooks/post-tool-use/02-block-bd-truncation.py
Executable file
102
hooks/post-tool-use/02-block-bd-truncation.py
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PostToolUse hook to block bd create/update commands with truncation markers.
|
||||
|
||||
Prevents incomplete task specifications from being saved to bd, which causes
|
||||
confusion and incomplete implementation later.
|
||||
|
||||
Truncation markers include:
|
||||
- [Remaining step groups truncated for length]
|
||||
- [truncated]
|
||||
- [... (more)]
|
||||
- [etc.]
|
||||
- [Omitted for brevity]
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
|
||||
# Truncation markers to detect
|
||||
TRUNCATION_PATTERNS = [
|
||||
r'\[Remaining.*?truncated',
|
||||
r'\[truncated',
|
||||
r'\[\.\.\..*?\]',
|
||||
r'\[etc\.?\]',
|
||||
r'\[Omitted.*?\]',
|
||||
r'\[More.*?omitted\]',
|
||||
r'\[Full.*?not shown\]',
|
||||
r'\[Additional.*?omitted\]',
|
||||
r'\.\.\..*?\[', # ... [something]
|
||||
r'\(truncated\)',
|
||||
r'\(abbreviated\)',
|
||||
]
|
||||
|
||||
def check_for_truncation(text):
|
||||
"""Check if text contains any truncation markers."""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
for pattern in TRUNCATION_PATTERNS:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
return None
|
||||
|
||||
def main():
|
||||
# Read tool use event from stdin
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
# If we can't parse JSON, allow the operation
|
||||
sys.exit(0)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
|
||||
# Only check Bash tool calls
|
||||
if tool_name != "Bash":
|
||||
sys.exit(0)
|
||||
|
||||
command = tool_input.get("command", "")
|
||||
|
||||
# Check if this is a bd create or bd update command
|
||||
if not command or not re.search(r'\bbd\s+(create|update)\b', command):
|
||||
sys.exit(0)
|
||||
|
||||
# Check for truncation markers
|
||||
truncation_marker = check_for_truncation(command)
|
||||
|
||||
if truncation_marker:
|
||||
# Block the command and provide helpful feedback
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PostToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": (
|
||||
f"⚠️ BD TRUNCATION DETECTED\n\n"
|
||||
f"Found truncation marker: {truncation_marker}\n\n"
|
||||
f"This bd task specification appears incomplete or truncated. "
|
||||
f"Saving incomplete specifications leads to confusion and incomplete implementations.\n\n"
|
||||
f"Please:\n"
|
||||
f"1. Expand the full implementation details\n"
|
||||
f"2. Include ALL step groups and tasks\n"
|
||||
f"3. Do not use truncation markers like '[Remaining steps truncated]'\n"
|
||||
f"4. Ensure every step has complete, actionable instructions\n\n"
|
||||
f"If the specification is too long:\n"
|
||||
f"- Break into smaller epics\n"
|
||||
f"- Use bd dependencies to link related tasks\n"
|
||||
f"- Focus on making each task independently complete\n\n"
|
||||
f"DO NOT truncate task specifications."
|
||||
)
|
||||
}
|
||||
}
|
||||
print(json.dumps(output))
|
||||
sys.exit(0)
|
||||
|
||||
# Allow command if no truncation detected
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
113
hooks/post-tool-use/03-block-pre-commit-bash.py
Executable file
113
hooks/post-tool-use/03-block-pre-commit-bash.py
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PostToolUse hook to block Bash commands that modify .git/hooks/pre-commit
|
||||
|
||||
Catches sneaky modifications through sed, redirection, chmod, mv, cp, etc.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
|
||||
# Patterns that indicate pre-commit hook modification
|
||||
PRECOMMIT_MODIFICATION_PATTERNS = [
|
||||
# File paths
|
||||
r'\.git/hooks/pre-commit',
|
||||
r'\.git\\hooks\\pre-commit',
|
||||
|
||||
# Redirection to pre-commit
|
||||
r'>.*pre-commit',
|
||||
r'>>.*pre-commit',
|
||||
|
||||
# sed/awk/perl modifying pre-commit
|
||||
r'(sed|awk|perl).*-i.*pre-commit',
|
||||
r'(sed|awk|perl).*pre-commit.*>',
|
||||
|
||||
# Moving/copying to pre-commit
|
||||
r'(mv|cp).*\s+.*\.git/hooks/pre-commit',
|
||||
r'(mv|cp).*\s+.*pre-commit',
|
||||
|
||||
# chmod on pre-commit (might be preparing to modify)
|
||||
r'chmod.*\.git/hooks/pre-commit',
|
||||
|
||||
# echo/cat piped to pre-commit
|
||||
r'(echo|cat).*>.*\.git/hooks/pre-commit',
|
||||
r'(echo|cat).*>>.*\.git/hooks/pre-commit',
|
||||
|
||||
# tee to pre-commit
|
||||
r'tee.*\.git/hooks/pre-commit',
|
||||
|
||||
# Creating pre-commit hook
|
||||
r'cat\s*>\s*\.git/hooks/pre-commit',
|
||||
r'cat\s*<<.*\.git/hooks/pre-commit',
|
||||
]
|
||||
|
||||
def check_precommit_modification(command):
|
||||
"""Check if command modifies pre-commit hook."""
|
||||
if not command:
|
||||
return None
|
||||
|
||||
for pattern in PRECOMMIT_MODIFICATION_PATTERNS:
|
||||
match = re.search(pattern, command, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
return None
|
||||
|
||||
def main():
|
||||
# Read tool use event from stdin
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
# If we can't parse JSON, allow the operation
|
||||
sys.exit(0)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
|
||||
# Only check Bash tool calls
|
||||
if tool_name != "Bash":
|
||||
sys.exit(0)
|
||||
|
||||
command = tool_input.get("command", "")
|
||||
|
||||
# Check for pre-commit modification
|
||||
modification_pattern = check_precommit_modification(command)
|
||||
|
||||
if modification_pattern:
|
||||
# Block the command and provide helpful feedback
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PostToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": (
|
||||
f"🚫 PRE-COMMIT HOOK MODIFICATION BLOCKED\n\n"
|
||||
f"Detected modification attempt via: {modification_pattern}\n"
|
||||
f"Command: {command[:200]}{'...' if len(command) > 200 else ''}\n\n"
|
||||
"Git hooks should not be modified directly by Claude.\n\n"
|
||||
"Why this is blocked:\n"
|
||||
"- Pre-commit hooks enforce critical quality standards\n"
|
||||
"- Direct modifications bypass code review\n"
|
||||
"- Changes can break CI/CD pipelines\n"
|
||||
"- Hook modifications should be version controlled\n\n"
|
||||
"If you need to modify hooks:\n"
|
||||
"1. Edit the source hook template in version control\n"
|
||||
"2. Use proper tooling (husky, pre-commit framework, etc.)\n"
|
||||
"3. Document changes and get them reviewed\n"
|
||||
"4. Never bypass hooks with --no-verify\n\n"
|
||||
"If the hook is causing issues:\n"
|
||||
"- Fix the underlying problem the hook detected\n"
|
||||
"- Ask the user for permission to modify hooks\n"
|
||||
"- Use the test-runner agent to handle verbose hook output\n\n"
|
||||
"Common mistake: Trying to disable hooks instead of fixing issues."
|
||||
)
|
||||
}
|
||||
}
|
||||
print(json.dumps(output))
|
||||
sys.exit(0)
|
||||
|
||||
# Allow command if no pre-commit modification detected
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
113
hooks/post-tool-use/04-block-pre-existing-checks.py
Executable file
113
hooks/post-tool-use/04-block-pre-existing-checks.py
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PostToolUse hook to block git checkout when checking for pre-existing errors.
|
||||
|
||||
When projects use pre-commit hooks that enforce passing tests, checking if
|
||||
errors are "pre-existing" is unnecessary and wastes time. All test failures
|
||||
and lint errors must be from current changes because pre-commit hooks prevent
|
||||
commits with failures.
|
||||
|
||||
Blocked patterns:
|
||||
- git checkout <sha> (or git stash && git checkout)
|
||||
- Combined with test/lint commands (ruff, pytest, mypy, cargo test, npm test, etc.)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
|
||||
# Test and lint command patterns that might be run on previous commits
|
||||
VERIFICATION_COMMANDS = [
|
||||
r'\bruff\b',
|
||||
r'\bpytest\b',
|
||||
r'\bmypy\b',
|
||||
r'\bflake8\b',
|
||||
r'\bblack\b',
|
||||
r'\bisort\b',
|
||||
r'\bcargo\s+test\b',
|
||||
r'\bcargo\s+clippy\b',
|
||||
r'\bnpm\s+test\b',
|
||||
r'\bnpm\s+run\s+test\b',
|
||||
r'\byarn\s+test\b',
|
||||
r'\bgo\s+test\b',
|
||||
r'\bmvn\s+test\b',
|
||||
r'\bgradle\s+test\b',
|
||||
r'\bpylint\b',
|
||||
r'\beslint\b',
|
||||
r'\btsc\b', # TypeScript compiler
|
||||
r'\bpre-commit\s+run\b',
|
||||
]
|
||||
|
||||
def is_checking_previous_commit(command):
|
||||
"""
|
||||
Detect if command is checking out previous commits to run tests/lints.
|
||||
|
||||
Patterns:
|
||||
- git checkout <sha>
|
||||
- git stash && git checkout
|
||||
- git diff <sha>..<sha>
|
||||
"""
|
||||
# Check for git checkout patterns
|
||||
if re.search(r'git\s+checkout\s+[a-f0-9]{6,40}', command):
|
||||
return True
|
||||
|
||||
if re.search(r'git\s+stash.*?&&.*?git\s+checkout', command):
|
||||
return True
|
||||
|
||||
# Check if command contains verification commands
|
||||
# (only flag if combined with git checkout)
|
||||
has_verification = any(re.search(pattern, command) for pattern in VERIFICATION_COMMANDS)
|
||||
has_git_checkout = re.search(r'git\s+checkout', command)
|
||||
|
||||
return has_verification and has_git_checkout
|
||||
|
||||
def main():
|
||||
# Read tool use event from stdin
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
# If we can't parse JSON, allow the operation
|
||||
sys.exit(0)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
|
||||
# Only check Bash tool calls
|
||||
if tool_name != "Bash":
|
||||
sys.exit(0)
|
||||
|
||||
command = tool_input.get("command", "")
|
||||
|
||||
if not command:
|
||||
sys.exit(0)
|
||||
|
||||
# Check if this looks like checking previous commits for errors
|
||||
if is_checking_previous_commit(command):
|
||||
# Block the command and provide helpful feedback
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PostToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": (
|
||||
"⚠️ CHECKING FOR PRE-EXISTING ERRORS IS UNNECESSARY\n\n"
|
||||
"Your project uses pre-commit hooks that enforce all tests pass before commits.\n"
|
||||
"Therefore, ALL test failures and errors are from your current changes.\n\n"
|
||||
"Do not check if errors were pre-existing. Pre-commit hooks guarantee they weren't.\n\n"
|
||||
"What you should do instead:\n"
|
||||
"1. Read the error messages from the current test run\n"
|
||||
"2. Fix the errors directly\n"
|
||||
"3. Run tests again to verify the fix\n\n"
|
||||
"Checking git history for errors is wasting time when pre-commit hooks enforce quality.\n\n"
|
||||
"Blocked command:\n"
|
||||
f"{command[:200]}" # Show first 200 chars of command
|
||||
)
|
||||
}
|
||||
}
|
||||
print(json.dumps(output))
|
||||
sys.exit(0)
|
||||
|
||||
# Allow command if not checking for pre-existing errors
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
114
hooks/post-tool-use/test-hook.sh
Executable file
114
hooks/post-tool-use/test-hook.sh
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Testing PostToolUse Hook (Edit Tracker) ==="
|
||||
echo ""
|
||||
|
||||
# Clean up log before testing
|
||||
> hooks/context/edit-log.txt
|
||||
|
||||
# Test 1: Edit tool event
|
||||
echo "Test 1: Edit tool event"
|
||||
result=$(echo '{"tool":{"name":"Edit","input":{"file_path":"/Users/ryan/src/hyper/test.txt"}}}' | bash hooks/post-tool-use/01-track-edits.sh)
|
||||
if echo "$result" | jq -e 'has("decision") | not' > /dev/null; then
|
||||
echo "✓ Returns valid response without decision field"
|
||||
else
|
||||
echo "✗ FAIL: Should not have decision field"
|
||||
fi
|
||||
|
||||
if grep -q "test.txt" hooks/context/edit-log.txt; then
|
||||
echo "✓ Logged edit to test.txt"
|
||||
else
|
||||
echo "✗ FAIL: Did not log edit"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 2: Write tool event
|
||||
echo "Test 2: Write tool event"
|
||||
result=$(echo '{"tool":{"name":"Write","input":{"file_path":"/Users/ryan/src/hyper/newfile.txt"}}}' | bash hooks/post-tool-use/01-track-edits.sh)
|
||||
if echo "$result" | jq -e 'has("decision") | not' > /dev/null; then
|
||||
echo "✓ Returns valid response without decision field"
|
||||
else
|
||||
echo "✗ FAIL: Should not have decision field"
|
||||
fi
|
||||
|
||||
if grep -q "newfile.txt" hooks/context/edit-log.txt; then
|
||||
echo "✓ Logged write to newfile.txt"
|
||||
else
|
||||
echo "✗ FAIL: Did not log write"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 3: Malformed JSON
|
||||
echo "Test 3: Malformed JSON"
|
||||
result=$(echo 'invalid json' | bash hooks/post-tool-use/01-track-edits.sh)
|
||||
if echo "$result" | jq -e 'has("decision") | not' > /dev/null; then
|
||||
echo "✓ Gracefully handles malformed JSON"
|
||||
else
|
||||
echo "✗ FAIL: Did not handle malformed JSON"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 4: Empty input
|
||||
echo "Test 4: Empty input"
|
||||
result=$(echo '' | bash hooks/post-tool-use/01-track-edits.sh)
|
||||
if echo "$result" | jq -e 'has("decision") | not' > /dev/null; then
|
||||
echo "✓ Gracefully handles empty input"
|
||||
else
|
||||
echo "✗ FAIL: Did not handle empty input"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 5: Check log format
|
||||
echo "Test 5: Check log format"
|
||||
cat hooks/context/edit-log.txt
|
||||
line_count=$(wc -l < hooks/context/edit-log.txt | tr -d ' ')
|
||||
if [ "$line_count" -eq 2 ]; then
|
||||
echo "✓ Correct number of log entries (2)"
|
||||
else
|
||||
echo "✗ FAIL: Expected 2 log entries, got $line_count"
|
||||
fi
|
||||
|
||||
if grep -q "| hyper |" hooks/context/edit-log.txt; then
|
||||
echo "✓ Repo name detected correctly"
|
||||
else
|
||||
echo "✗ FAIL: Repo name not detected"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 6: Context query utilities
|
||||
echo "Test 6: Context query utilities"
|
||||
source hooks/utils/context-query.sh
|
||||
|
||||
recent=$(get_recent_edits)
|
||||
if [ -n "$recent" ]; then
|
||||
echo "✓ get_recent_edits works"
|
||||
else
|
||||
echo "✗ FAIL: get_recent_edits returned empty"
|
||||
fi
|
||||
|
||||
session_files=$(get_session_files)
|
||||
if echo "$session_files" | grep -q "test.txt"; then
|
||||
echo "✓ get_session_files works"
|
||||
else
|
||||
echo "✗ FAIL: get_session_files did not find test.txt"
|
||||
fi
|
||||
|
||||
if was_file_edited "/Users/ryan/src/hyper/test.txt"; then
|
||||
echo "✓ was_file_edited works"
|
||||
else
|
||||
echo "✗ FAIL: was_file_edited did not detect edit"
|
||||
fi
|
||||
|
||||
stats=$(get_repo_stats)
|
||||
if echo "$stats" | grep -q "hyper"; then
|
||||
echo "✓ get_repo_stats works"
|
||||
else
|
||||
echo "✗ FAIL: get_repo_stats did not find hyper repo"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Clean up
|
||||
> hooks/context/edit-log.txt
|
||||
|
||||
echo "=== All Tests Complete ==="
|
||||
Reference in New Issue
Block a user