Initial commit
This commit is contained in:
230
hooks/pre_tool_use_state_sync.py
Executable file
230
hooks/pre_tool_use_state_sync.py
Executable file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""
|
||||
PreToolUse State Sync - Git-Powered File State Awareness
|
||||
|
||||
Intercepts file operations (Write/Edit/NotebookEdit) and checks if files
|
||||
changed externally since Claude last read them.
|
||||
|
||||
Uses git as source of truth for current state.
|
||||
|
||||
**Token Overhead:** ~200-500 tokens (only when files changed externally)
|
||||
**Blocking:** No (provides feedback, always continues)
|
||||
|
||||
Hook Protocol:
|
||||
- Input: JSON via stdin with tool invocation data
|
||||
- Output: JSON via stdout with feedback
|
||||
- IMPORTANT: Never blocks (always {"continue": true})
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_file_git_status(file_path: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if file has uncommitted changes or differs from last commit.
|
||||
|
||||
Returns: (has_changes, status_description)
|
||||
"""
|
||||
try:
|
||||
# Check git status for this specific file
|
||||
result = subprocess.run(
|
||||
["git", "status", "--short", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
status = result.stdout.strip()
|
||||
|
||||
if not status:
|
||||
# File unchanged
|
||||
return False, "unchanged"
|
||||
|
||||
# Parse git status codes
|
||||
# M = Modified (staged)
|
||||
# _M = Modified (unstaged)
|
||||
# ?? = Untracked
|
||||
# A = Added
|
||||
# D = Deleted
|
||||
|
||||
if status.startswith("M") or status.startswith(" M"):
|
||||
return True, "modified"
|
||||
elif status.startswith("??"):
|
||||
return True, "untracked"
|
||||
elif status.startswith("A"):
|
||||
return True, "added"
|
||||
elif status.startswith("D"):
|
||||
return True, "deleted"
|
||||
else:
|
||||
return True, status[:2]
|
||||
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Failed to check git status: {e}", file=sys.stderr)
|
||||
return False, "unknown"
|
||||
|
||||
|
||||
def get_file_diff_summary(file_path: str) -> str:
|
||||
"""
|
||||
Get summary of changes in file since last commit.
|
||||
|
||||
Returns: Diff summary (lines added/removed)
|
||||
"""
|
||||
try:
|
||||
# Get diff stat
|
||||
result = subprocess.run(
|
||||
["git", "diff", "HEAD", file_path, "--stat"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
diff_stat = result.stdout.strip()
|
||||
|
||||
if not diff_stat:
|
||||
return "No changes"
|
||||
|
||||
# Extract line changes from stat
|
||||
# Example: "file.py | 10 +++++-----"
|
||||
return diff_stat
|
||||
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Failed to get diff summary: {e}", file=sys.stderr)
|
||||
return "Unknown changes"
|
||||
|
||||
|
||||
def check_file_tracked_in_cache(file_path: str) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if Claude has read this file in current session.
|
||||
|
||||
Uses simple cache file to track reads.
|
||||
|
||||
Returns: (was_read, last_read_hash)
|
||||
"""
|
||||
try:
|
||||
cache_dir = Path.home() / ".claude" / "plugins" / "contextune" / ".cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cache_file = cache_dir / "read_files.json"
|
||||
|
||||
if not cache_file.exists():
|
||||
return False, None
|
||||
|
||||
with open(cache_file) as f:
|
||||
cache = json.load(f)
|
||||
|
||||
file_info = cache.get(file_path)
|
||||
if file_info:
|
||||
return True, file_info.get("git_hash")
|
||||
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Failed to check read cache: {e}", file=sys.stderr)
|
||||
return False, None
|
||||
|
||||
|
||||
def main():
|
||||
"""PreToolUse hook entry point."""
|
||||
try:
|
||||
# Read hook data from stdin
|
||||
hook_data = json.loads(sys.stdin.read())
|
||||
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
tool_input = hook_data.get("tool_input", {})
|
||||
|
||||
# DEBUG logging
|
||||
print(f"DEBUG: PreToolUse state sync for tool: {tool_name}", file=sys.stderr)
|
||||
|
||||
# Only intercept file operation tools
|
||||
if tool_name not in ["Write", "Edit", "NotebookEdit"]:
|
||||
# Not a file operation, continue without feedback
|
||||
response = {"continue": True}
|
||||
print(json.dumps(response))
|
||||
return
|
||||
|
||||
# Get file path from tool input
|
||||
file_path = tool_input.get("file_path")
|
||||
if not file_path:
|
||||
# No file path, continue
|
||||
response = {"continue": True}
|
||||
print(json.dumps(response))
|
||||
return
|
||||
|
||||
print(f"DEBUG: Checking state for file: {file_path}", file=sys.stderr)
|
||||
|
||||
# Check git status
|
||||
has_changes, status = get_file_git_status(file_path)
|
||||
|
||||
if not has_changes:
|
||||
# File unchanged, continue without feedback
|
||||
response = {"continue": True}
|
||||
print(json.dumps(response))
|
||||
return
|
||||
|
||||
# File has external changes!
|
||||
print(f"DEBUG: File has external changes: {status}", file=sys.stderr)
|
||||
|
||||
# Get diff summary
|
||||
diff_summary = get_file_diff_summary(file_path)
|
||||
|
||||
# Build feedback message
|
||||
feedback = f"""⚠️ File State Change Detected
|
||||
|
||||
**File:** `{file_path}`
|
||||
**Status:** {status.upper()}
|
||||
**Git Says:** File has uncommitted changes since last commit
|
||||
|
||||
**Diff Summary:**
|
||||
```
|
||||
{diff_summary}
|
||||
```
|
||||
|
||||
**Recommendation:**
|
||||
- ✅ Re-read the file to see current state
|
||||
- ✅ Use Read tool before {tool_name} to avoid conflicts
|
||||
|
||||
**Git Source of Truth:**
|
||||
The file's current state may differ from what you have in context.
|
||||
Git tracking shows external modifications.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Check current state
|
||||
git diff {file_path}
|
||||
|
||||
# See what changed
|
||||
git log -1 --oneline -- {file_path}
|
||||
```
|
||||
|
||||
**Note:** Continuing with your {tool_name} operation, but verify file state first!
|
||||
"""
|
||||
|
||||
# Log the detection
|
||||
print(f"DEBUG: Providing state sync feedback for {file_path}", file=sys.stderr)
|
||||
|
||||
# IMPORTANT: Never block, always continue with feedback
|
||||
response = {
|
||||
"continue": True,
|
||||
"feedback": feedback,
|
||||
"suppressOutput": False # Show feedback to Claude
|
||||
}
|
||||
|
||||
print(json.dumps(response))
|
||||
|
||||
except Exception as e:
|
||||
# Never fail the hook - always continue
|
||||
print(f"DEBUG: PreToolUse state sync error: {e}", file=sys.stderr)
|
||||
response = {"continue": True}
|
||||
print(json.dumps(response))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user