TradingAgents/.claude/hooks/unified_post_tool.py

261 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Unified Post Tool Hook - Dispatcher for PostToolUse Lifecycle
Consolidates PostToolUse hooks:
- post_tool_use_error_capture.py (tool error logging)
Hook: PostToolUse (runs after any tool execution)
Environment Variables (opt-in/opt-out):
CAPTURE_TOOL_ERRORS=true/false (default: true)
Exit codes:
0: Always (non-blocking hook for informational logging)
Usage:
# As PostToolUse hook (automatic)
echo '{"tool_name": "Bash", "tool_result": {"exit_code": 1}}' | python unified_post_tool.py
# Manual run
echo '{"tool_name": "Bash", "tool_result": {"exit_code": 0}}' | python unified_post_tool.py
"""
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, Optional
# ============================================================================
# Dynamic Library Discovery
# ============================================================================
def find_lib_dir() -> Optional[Path]:
"""
Find the lib directory dynamically.
Searches:
1. Relative to this file: ../lib
2. In project root: plugins/autonomous-dev/lib
3. In global install: ~/.autonomous-dev/lib
Returns:
Path to lib directory or None if not found
"""
candidates = [
Path(__file__).parent.parent / "lib", # Relative to hooks/
Path.cwd() / "plugins" / "autonomous-dev" / "lib", # Project root
Path.home() / ".autonomous-dev" / "lib", # Global install
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
# Add lib to path
LIB_DIR = find_lib_dir()
if LIB_DIR:
sys.path.insert(0, str(LIB_DIR))
# Optional imports with graceful fallback
try:
from error_analyzer import write_error_to_registry
HAS_ERROR_ANALYZER = True
except ImportError:
HAS_ERROR_ANALYZER = False
# ============================================================================
# Configuration
# ============================================================================
# Check configuration from environment
CAPTURE_TOOL_ERRORS = os.environ.get("CAPTURE_TOOL_ERRORS", "true").lower() == "true"
# Error patterns to detect in stderr
ERROR_PATTERNS = [
r"error:",
r"Error:",
r"ERROR:",
r"failed",
r"Failed",
r"FAILED",
r"exception",
r"Exception",
r"EXCEPTION",
r"traceback",
r"Traceback",
]
# ============================================================================
# Tool Error Capture
# ============================================================================
def is_tool_failure(tool_result: Dict) -> bool:
"""
Determine if a tool result represents a failure.
Args:
tool_result: Tool result dictionary
Returns:
True if failure detected, False otherwise
Example:
>>> is_tool_failure({"exit_code": 1})
True
>>> is_tool_failure({"exit_code": 0})
False
>>> is_tool_failure({"stderr": "Error: file not found"})
True
"""
# Check exit code
exit_code = tool_result.get("exit_code")
if exit_code is not None and exit_code != 0:
return True
# Check stderr for error patterns
stderr = tool_result.get("stderr", "")
if stderr:
for pattern in ERROR_PATTERNS:
if re.search(pattern, stderr, re.IGNORECASE):
return True
# Check for error field in result
if tool_result.get("error"):
return True
return False
def extract_error_message(tool_result: Dict) -> str:
"""
Extract error message from tool result.
Args:
tool_result: Tool result dictionary
Returns:
Error message string (truncated to 1000 chars max)
Example:
>>> extract_error_message({"error": "File not found"})
'File not found'
>>> extract_error_message({"stderr": "Error: " + "x" * 2000})[:10]
'Error: xxx'
"""
# Priority: error field > stderr > stdout truncated
if tool_result.get("error"):
return str(tool_result["error"])
stderr = tool_result.get("stderr", "")
if stderr:
return stderr[:1000] # Cap at 1000 chars
stdout = tool_result.get("stdout", "")
if stdout:
return stdout[:500] # Less for stdout
return "Unknown error (no details in tool result)"
def capture_error(tool_name: str, tool_input: Dict, tool_result: Dict) -> bool:
"""
Capture error to registry.
Args:
tool_name: Name of the tool that failed
tool_input: Tool input parameters
tool_result: Tool result with error
Returns:
True if captured successfully, False otherwise
"""
if not CAPTURE_TOOL_ERRORS or not HAS_ERROR_ANALYZER:
return False
try:
error_message = extract_error_message(tool_result)
exit_code = tool_result.get("exit_code")
# Build context (sanitized)
context = {
"tool_input_keys": list(tool_input.keys()) if tool_input else [],
}
# Add command for Bash (sanitized - no secrets)
if tool_name == "Bash" and "command" in tool_input:
cmd = str(tool_input["command"])
# Only capture first 100 chars of command
context["command_preview"] = cmd[:100] + "..." if len(cmd) > 100 else cmd
return write_error_to_registry(
tool_name=tool_name,
exit_code=exit_code,
error_message=error_message,
context=context,
)
except Exception:
# Graceful degradation
return False
# ============================================================================
# Main Hook Entry Point
# ============================================================================
def main() -> int:
"""
Main hook entry point.
Reads stdin for hook input, captures errors if detected.
Returns:
Always 0 (non-blocking hook)
"""
# Read input from stdin
try:
input_data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
# Invalid input - allow tool to proceed
output = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse"
}
}
print(json.dumps(output))
return 0
# Extract tool info
tool_name = input_data.get("tool_name", "unknown")
tool_input = input_data.get("tool_input", {})
tool_result = input_data.get("tool_result", {})
# Check if this is a failure
if is_tool_failure(tool_result):
# Non-blocking capture - failures here don't interrupt workflow
try:
capture_error(tool_name, tool_input, tool_result)
except Exception:
pass # Graceful degradation
# Always allow tool to proceed (PostToolUse is informational)
output = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse"
}
}
print(json.dumps(output))
return 0
if __name__ == "__main__":
sys.exit(main())