231 lines
6.2 KiB
Python
Executable File
231 lines
6.2 KiB
Python
Executable File
#!/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()
|