261 lines
6.9 KiB
Python
Executable File
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())
|