anvil-dev-framework 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -22
- package/VERSION +1 -1
- package/docs/ANV-263-hook-logging-investigation.md +116 -0
- package/docs/command-reference.md +398 -17
- package/docs/session-workflow.md +62 -9
- package/docs/system-architecture.md +584 -0
- package/global/api/__pycache__/ralph_api.cpython-314.pyc +0 -0
- package/global/api/openapi.yaml +357 -0
- package/global/api/ralph_api.py +528 -0
- package/global/commands/anvil-settings.md +47 -19
- package/global/commands/audit.md +163 -0
- package/global/commands/checklist.md +180 -0
- package/global/commands/coderabbit-fix.md +282 -0
- package/global/commands/efficiency.md +356 -0
- package/global/commands/evidence.md +117 -33
- package/global/commands/hud.md +24 -0
- package/global/commands/insights.md +101 -3
- package/global/commands/orient.md +22 -21
- package/global/commands/patterns.md +115 -0
- package/global/commands/ralph.md +47 -1
- package/global/commands/token-budget.md +214 -0
- package/global/commands/weekly-review.md +21 -1
- package/global/config/notifications.yaml.template +50 -0
- package/global/hooks/ralph_stop.sh +33 -1
- package/global/hooks/statusline.sh +67 -2
- package/global/lib/__pycache__/coderabbit_metrics.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/command_tracker.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/context_optimizer.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/git_utils.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/optimization_applier.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/ralph_webhooks.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/token_analyzer.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/token_metrics.cpython-314.pyc +0 -0
- package/global/lib/coderabbit_metrics.py +647 -0
- package/global/lib/command_tracker.py +147 -0
- package/global/lib/context_optimizer.py +323 -0
- package/global/lib/linear_provider.py +210 -16
- package/global/lib/log_rotation.py +287 -0
- package/global/lib/optimization_applier.py +582 -0
- package/global/lib/ralph_events.py +398 -0
- package/global/lib/ralph_notifier.py +366 -0
- package/global/lib/ralph_state.py +264 -24
- package/global/lib/ralph_webhooks.py +470 -0
- package/global/lib/state_manager.py +121 -0
- package/global/lib/token_analyzer.py +1383 -0
- package/global/lib/token_metrics.py +919 -0
- package/global/tests/__pycache__/test_command_tracker.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_context_optimizer.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_doc_coverage.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_issue_models.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_linear_filtering.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_linear_provider.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_local_provider.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_optimization_applier.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_token_analyzer.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_token_analyzer_phase6.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/__pycache__/test_token_metrics.cpython-314-pytest-9.0.2.pyc +0 -0
- package/global/tests/test_command_tracker.py +172 -0
- package/global/tests/test_context_optimizer.py +321 -0
- package/global/tests/test_linear_filtering.py +319 -0
- package/global/tests/test_linear_provider.py +40 -1
- package/global/tests/test_optimization_applier.py +508 -0
- package/global/tests/test_token_analyzer.py +735 -0
- package/global/tests/test_token_analyzer_phase6.py +537 -0
- package/global/tests/test_token_metrics.py +829 -0
- package/global/tools/README.md +153 -0
- package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
- package/global/tools/__pycache__/orient_linear.cpython-314.pyc +0 -0
- package/global/tools/__pycache__/ralph-watchcpython-314.pyc +0 -0
- package/global/tools/anvil-hud.py +86 -1
- package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +472 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +405 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +36 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +653 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +727 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +340 -0
- package/global/tools/anvil-memory/src/__tests__/commands.test.ts +218 -0
- package/global/tools/anvil-memory/src/commands/context.ts +322 -0
- package/global/tools/anvil-memory/src/db.ts +108 -0
- package/global/tools/anvil-memory/src/index.ts +2 -8
- package/global/tools/orient_linear.py +159 -0
- package/global/tools/ralph-watch +423 -0
- package/package.json +2 -1
- package/project/.anvil-project.yaml.template +93 -0
- package/project/CLAUDE.md.template +343 -0
- package/project/agents/README.md +119 -0
- package/project/agents/cross-layer-debugger.md +217 -0
- package/project/agents/security-code-reviewer.md +162 -0
- package/project/constitution.md.template +235 -0
- package/project/coordination.md +103 -0
- package/project/docs/background-tasks.md +258 -0
- package/project/docs/skills-frontmatter.md +243 -0
- package/project/examples/README.md +106 -0
- package/project/examples/api-route-template.ts +171 -0
- package/project/examples/component-template.tsx +110 -0
- package/project/examples/hook-template.ts +152 -0
- package/project/examples/service-template.ts +207 -0
- package/project/examples/test-template.test.tsx +249 -0
- package/project/hooks/README.md +491 -0
- package/project/hooks/__pycache__/notification.cpython-314.pyc +0 -0
- package/project/hooks/__pycache__/post_tool_use.cpython-314.pyc +0 -0
- package/project/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
- package/project/hooks/__pycache__/session_start.cpython-314.pyc +0 -0
- package/project/hooks/__pycache__/stop.cpython-314.pyc +0 -0
- package/project/hooks/notification.py +183 -0
- package/project/hooks/permission_request.py +438 -0
- package/project/hooks/post_tool_use.py +397 -0
- package/project/hooks/pre_compact.py +126 -0
- package/project/hooks/pre_tool_use.py +454 -0
- package/project/hooks/session_start.py +656 -0
- package/project/hooks/stop.py +356 -0
- package/project/hooks/subagent_start.py +223 -0
- package/project/hooks/subagent_stop.py +215 -0
- package/project/hooks/user_prompt_submit.py +110 -0
- package/project/hooks/utils/llm/anth.py +114 -0
- package/project/hooks/utils/llm/oai.py +114 -0
- package/project/hooks/utils/tts/elevenlabs_tts.py +63 -0
- package/project/hooks/utils/tts/mlx_audio_tts.py +86 -0
- package/project/hooks/utils/tts/openai_tts.py +92 -0
- package/project/hooks/utils/tts/pyttsx3_tts.py +75 -0
- package/project/linear.yaml.template +23 -0
- package/project/product.md.template +238 -0
- package/project/retros/README.md +126 -0
- package/project/rules/README.md +90 -0
- package/project/rules/debugging.md +139 -0
- package/project/rules/security-review.md +115 -0
- package/project/settings.yaml.template +185 -0
- package/project/specs/SPEC-ANV-72-hud-kanban.md +525 -0
- package/project/templates/api-python/CLAUDE.md +547 -0
- package/project/templates/generic/CLAUDE.md +260 -0
- package/project/templates/saas/CLAUDE.md +478 -0
- package/project/tests/README.md +140 -0
- package/project/tests/__pycache__/test_transcript_parser.cpython-314-pytest-9.0.2.pyc +0 -0
- package/project/tests/fixtures/sample-transcript.jsonl +21 -0
- package/project/tests/test-hooks.sh +259 -0
- package/project/tests/test-lib.sh +248 -0
- package/project/tests/test-statusline.sh +165 -0
- package/project/tests/test_transcript_parser.py +323 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "python-dotenv",
|
|
6
|
+
# ]
|
|
7
|
+
# ///
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
PreToolUse Hook - Safety checks, warnings, and TTS announcements before tools run.
|
|
11
|
+
|
|
12
|
+
Safety (blocks tool execution):
|
|
13
|
+
- Blocks dangerous rm -rf commands
|
|
14
|
+
- Blocks access to .env files
|
|
15
|
+
|
|
16
|
+
Warnings (soft warnings, does not block):
|
|
17
|
+
- Warns when editing gitignored files (suggests tracked alternative)
|
|
18
|
+
- Reminds to commit WIP every 50 tool invocations
|
|
19
|
+
|
|
20
|
+
TTS Events (with --announce flag):
|
|
21
|
+
- AskUserQuestion -> "Question for you" (before user sees the question)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import platform
|
|
32
|
+
from datetime import datetime, timedelta
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from dotenv import load_dotenv
|
|
37
|
+
load_dotenv()
|
|
38
|
+
except ImportError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_apple_silicon():
|
|
43
|
+
"""Check if running on Apple Silicon Mac."""
|
|
44
|
+
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_tts_script_path():
|
|
48
|
+
"""
|
|
49
|
+
Determine which TTS script to use.
|
|
50
|
+
Priority: MLX Audio (Apple Silicon) > ElevenLabs > OpenAI > pyttsx3
|
|
51
|
+
"""
|
|
52
|
+
script_dir = Path(__file__).parent
|
|
53
|
+
tts_dir = script_dir / "utils" / "tts"
|
|
54
|
+
|
|
55
|
+
# MLX Audio for Apple Silicon
|
|
56
|
+
if is_apple_silicon():
|
|
57
|
+
mlx_script = tts_dir / "mlx_audio_tts.py"
|
|
58
|
+
if mlx_script.exists():
|
|
59
|
+
return str(mlx_script)
|
|
60
|
+
|
|
61
|
+
# ElevenLabs if API key set
|
|
62
|
+
if os.getenv('ELEVENLABS_API_KEY'):
|
|
63
|
+
elevenlabs_script = tts_dir / "elevenlabs_tts.py"
|
|
64
|
+
if elevenlabs_script.exists():
|
|
65
|
+
return str(elevenlabs_script)
|
|
66
|
+
|
|
67
|
+
# OpenAI TTS if API key set
|
|
68
|
+
if os.getenv('OPENAI_API_KEY'):
|
|
69
|
+
openai_script = tts_dir / "openai_tts.py"
|
|
70
|
+
if openai_script.exists():
|
|
71
|
+
return str(openai_script)
|
|
72
|
+
|
|
73
|
+
# pyttsx3 as fallback
|
|
74
|
+
pyttsx3_script = tts_dir / "pyttsx3_tts.py"
|
|
75
|
+
if pyttsx3_script.exists():
|
|
76
|
+
return str(pyttsx3_script)
|
|
77
|
+
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def speak(message: str):
|
|
82
|
+
"""Speak a message using the best available TTS."""
|
|
83
|
+
try:
|
|
84
|
+
tts_script = get_tts_script_path()
|
|
85
|
+
if tts_script:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["uv", "run", tts_script, message],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
timeout=15
|
|
90
|
+
)
|
|
91
|
+
# If TTS script failed, fall back to macOS say
|
|
92
|
+
if result.returncode != 0 and platform.system() == "Darwin":
|
|
93
|
+
subprocess.run(['say', message], capture_output=True, timeout=10)
|
|
94
|
+
elif platform.system() == "Darwin":
|
|
95
|
+
subprocess.run(['say', message], capture_output=True, timeout=10)
|
|
96
|
+
except Exception:
|
|
97
|
+
# Last resort fallback
|
|
98
|
+
if platform.system() == "Darwin":
|
|
99
|
+
try:
|
|
100
|
+
subprocess.run(['say', message], capture_output=True, timeout=10)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_tts_message(tool_name: str, tool_input: dict) -> str | None:
|
|
106
|
+
"""
|
|
107
|
+
Determine if this tool invocation should trigger TTS and return the message.
|
|
108
|
+
Returns None if no TTS should be triggered.
|
|
109
|
+
"""
|
|
110
|
+
# AskUserQuestion - Question for the user (announce BEFORE tool runs)
|
|
111
|
+
if tool_name == "AskUserQuestion":
|
|
112
|
+
return "I have a question for you."
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_dangerous_rm_command(command):
|
|
118
|
+
"""
|
|
119
|
+
Comprehensive detection of dangerous rm commands.
|
|
120
|
+
Matches various forms of rm -rf and similar destructive patterns.
|
|
121
|
+
"""
|
|
122
|
+
# Normalize command by removing extra spaces and converting to lowercase
|
|
123
|
+
normalized = ' '.join(command.lower().split())
|
|
124
|
+
|
|
125
|
+
# Pattern 1: Standard rm -rf variations
|
|
126
|
+
patterns = [
|
|
127
|
+
r'\brm\s+.*-[a-z]*r[a-z]*f', # rm -rf, rm -fr, rm -Rf, etc.
|
|
128
|
+
r'\brm\s+.*-[a-z]*f[a-z]*r', # rm -fr variations
|
|
129
|
+
r'\brm\s+--recursive\s+--force', # rm --recursive --force
|
|
130
|
+
r'\brm\s+--force\s+--recursive', # rm --force --recursive
|
|
131
|
+
r'\brm\s+-r\s+.*-f', # rm -r ... -f
|
|
132
|
+
r'\brm\s+-f\s+.*-r', # rm -f ... -r
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
# Check for dangerous patterns
|
|
136
|
+
for pattern in patterns:
|
|
137
|
+
if re.search(pattern, normalized):
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
# Pattern 2: Check for rm with recursive flag targeting dangerous paths
|
|
141
|
+
dangerous_paths = [
|
|
142
|
+
r'/', # Root directory
|
|
143
|
+
r'/\*', # Root with wildcard
|
|
144
|
+
r'~', # Home directory
|
|
145
|
+
r'~/', # Home directory path
|
|
146
|
+
r'\$HOME', # Home environment variable
|
|
147
|
+
r'\.\.', # Parent directory references
|
|
148
|
+
r'\*', # Wildcards in general rm -rf context
|
|
149
|
+
r'\.', # Current directory
|
|
150
|
+
r'\.\s*$', # Current directory at end of command
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
if re.search(r'\brm\s+.*-[a-z]*r', normalized): # If rm has recursive flag
|
|
154
|
+
for path in dangerous_paths:
|
|
155
|
+
if re.search(path, normalized):
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_env_file_access(tool_name, tool_input):
|
|
162
|
+
"""
|
|
163
|
+
Check if any tool is trying to access .env files containing sensitive data.
|
|
164
|
+
"""
|
|
165
|
+
if tool_name in ['Read', 'Edit', 'MultiEdit', 'Write', 'Bash']:
|
|
166
|
+
# Check file paths for file-based tools
|
|
167
|
+
if tool_name in ['Read', 'Edit', 'MultiEdit', 'Write']:
|
|
168
|
+
file_path = tool_input.get('file_path', '')
|
|
169
|
+
if '.env' in file_path and not file_path.endswith('.env.sample'):
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
# Check bash commands for .env file access
|
|
173
|
+
elif tool_name == 'Bash':
|
|
174
|
+
command = tool_input.get('command', '')
|
|
175
|
+
# Pattern to detect .env file access (but allow .env.sample)
|
|
176
|
+
env_patterns = [
|
|
177
|
+
r'\b\.env\b(?!\.sample)', # .env but not .env.sample
|
|
178
|
+
r'cat\s+.*\.env\b(?!\.sample)', # cat .env
|
|
179
|
+
r'echo\s+.*>\s*\.env\b(?!\.sample)', # echo > .env
|
|
180
|
+
r'touch\s+.*\.env\b(?!\.sample)', # touch .env
|
|
181
|
+
r'cp\s+.*\.env\b(?!\.sample)', # cp .env
|
|
182
|
+
r'mv\s+.*\.env\b(?!\.sample)', # mv .env
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
for pattern in env_patterns:
|
|
186
|
+
if re.search(pattern, command):
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# =============================================================================
|
|
193
|
+
# Gitignore Verification (Soft Warnings)
|
|
194
|
+
# =============================================================================
|
|
195
|
+
|
|
196
|
+
def is_gitignored(file_path: str) -> bool:
|
|
197
|
+
"""Check if a file path is gitignored using git check-ignore."""
|
|
198
|
+
try:
|
|
199
|
+
result = subprocess.run(
|
|
200
|
+
['git', 'check-ignore', '-q', file_path],
|
|
201
|
+
capture_output=True,
|
|
202
|
+
timeout=5
|
|
203
|
+
)
|
|
204
|
+
return result.returncode == 0
|
|
205
|
+
except Exception:
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_tracked_alternative(file_path: str) -> str | None:
|
|
210
|
+
"""
|
|
211
|
+
For dual-location patterns, return the tracked alternative path.
|
|
212
|
+
Maps gitignored locations to their tracked counterparts.
|
|
213
|
+
"""
|
|
214
|
+
dual_locations = {
|
|
215
|
+
".claude/hooks/": "project/hooks/",
|
|
216
|
+
".claude/commands/": "project/skills/",
|
|
217
|
+
".claude/tests/": "project/tests/",
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for gitignored_prefix, tracked_prefix in dual_locations.items():
|
|
221
|
+
if gitignored_prefix in file_path:
|
|
222
|
+
return file_path.replace(gitignored_prefix, tracked_prefix)
|
|
223
|
+
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def check_gitignored_file(file_path: str) -> None:
|
|
228
|
+
"""
|
|
229
|
+
Check if file is gitignored and print warning if so.
|
|
230
|
+
Suggests tracked alternative if available (dual-location pattern).
|
|
231
|
+
"""
|
|
232
|
+
if not file_path or not is_gitignored(file_path):
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
# Get tracked alternative if this is a dual-location pattern
|
|
236
|
+
tracked_alt = get_tracked_alternative(file_path)
|
|
237
|
+
|
|
238
|
+
print(f"WARNING: Editing gitignored file: {file_path}", file=sys.stderr)
|
|
239
|
+
if tracked_alt:
|
|
240
|
+
print(f" Consider editing the tracked version instead: {tracked_alt}", file=sys.stderr)
|
|
241
|
+
print(" Changes to gitignored files won't be preserved across sessions.", file=sys.stderr)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# =============================================================================
|
|
245
|
+
# WIP Commit Reminder (Soft Warnings)
|
|
246
|
+
# =============================================================================
|
|
247
|
+
|
|
248
|
+
def get_tool_count() -> int:
|
|
249
|
+
"""Get current tool invocation count from the log file."""
|
|
250
|
+
try:
|
|
251
|
+
log_path = Path.cwd() / 'logs' / 'pre_tool_use.json'
|
|
252
|
+
if log_path.exists():
|
|
253
|
+
with open(log_path, 'r') as f:
|
|
254
|
+
log_data = json.load(f)
|
|
255
|
+
return len(log_data) if isinstance(log_data, list) else 0
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def check_uncommitted_changes() -> tuple[bool, str]:
|
|
262
|
+
"""
|
|
263
|
+
Check for uncommitted changes in the git repository.
|
|
264
|
+
Returns (has_changes, branch_name).
|
|
265
|
+
"""
|
|
266
|
+
try:
|
|
267
|
+
# Get current branch
|
|
268
|
+
branch_result = subprocess.run(
|
|
269
|
+
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
270
|
+
capture_output=True,
|
|
271
|
+
text=True,
|
|
272
|
+
timeout=5
|
|
273
|
+
)
|
|
274
|
+
branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
|
|
275
|
+
|
|
276
|
+
# Check for uncommitted changes
|
|
277
|
+
status_result = subprocess.run(
|
|
278
|
+
['git', 'status', '--porcelain'],
|
|
279
|
+
capture_output=True,
|
|
280
|
+
text=True,
|
|
281
|
+
timeout=5
|
|
282
|
+
)
|
|
283
|
+
has_changes = bool(status_result.stdout.strip()) if status_result.returncode == 0 else False
|
|
284
|
+
|
|
285
|
+
return has_changes, branch_name
|
|
286
|
+
except Exception:
|
|
287
|
+
return False, "unknown"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def maybe_remind_wip_commit(tool_count: int) -> None:
|
|
291
|
+
"""
|
|
292
|
+
Remind user to commit WIP if there are uncommitted changes.
|
|
293
|
+
Triggers every 50 tool invocations.
|
|
294
|
+
"""
|
|
295
|
+
# Only remind every 50 tools
|
|
296
|
+
if tool_count % 50 != 0 or tool_count == 0:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
has_changes, branch_name = check_uncommitted_changes()
|
|
300
|
+
if not has_changes:
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
print(f"REMINDER: {tool_count} tool invocations - consider committing your work.", file=sys.stderr)
|
|
304
|
+
print(f" Branch: {branch_name}", file=sys.stderr)
|
|
305
|
+
print(" Run: git add -A && git commit -m 'WIP: checkpoint'", file=sys.stderr)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# =============================================================================
|
|
309
|
+
# Log Rotation (ANV-231)
|
|
310
|
+
# =============================================================================
|
|
311
|
+
|
|
312
|
+
LOG_MAX_SIZE_BYTES = 500 * 1024 # 500KB
|
|
313
|
+
LOG_RETENTION_DAYS = 7
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def rotate_log_if_needed(log_path: Path) -> bool:
|
|
317
|
+
"""
|
|
318
|
+
Rotate log file if it exceeds 500KB.
|
|
319
|
+
Archives to logs/archive/YYYY-MM-DD-HH-MM-SS-{logname}.json
|
|
320
|
+
"""
|
|
321
|
+
if not log_path.exists():
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
if log_path.stat().st_size < LOG_MAX_SIZE_BYTES:
|
|
326
|
+
return False
|
|
327
|
+
except OSError:
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
archive_dir = log_path.parent / "archive"
|
|
331
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
|
|
333
|
+
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
|
334
|
+
archive_path = archive_dir / f"{timestamp}-{log_path.name}"
|
|
335
|
+
|
|
336
|
+
# Handle collision (same second rotation) with bounded counter
|
|
337
|
+
counter = 1
|
|
338
|
+
max_counter = 100
|
|
339
|
+
while archive_path.exists() and counter < max_counter:
|
|
340
|
+
archive_path = archive_dir / f"{timestamp}-{counter}-{log_path.name}"
|
|
341
|
+
counter += 1
|
|
342
|
+
|
|
343
|
+
if counter >= max_counter:
|
|
344
|
+
return False # Give up if too many collisions
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
shutil.move(str(log_path), str(archive_path))
|
|
348
|
+
return True
|
|
349
|
+
except OSError:
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def cleanup_old_archives(archive_dir: Path) -> int:
|
|
354
|
+
"""Remove archives older than retention period (7 days)."""
|
|
355
|
+
if not archive_dir.exists():
|
|
356
|
+
return 0
|
|
357
|
+
|
|
358
|
+
cutoff = datetime.now() - timedelta(days=LOG_RETENTION_DAYS)
|
|
359
|
+
deleted = 0
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
for f in archive_dir.glob("*.json"):
|
|
363
|
+
try:
|
|
364
|
+
if datetime.fromtimestamp(f.stat().st_mtime) < cutoff:
|
|
365
|
+
f.unlink()
|
|
366
|
+
deleted += 1
|
|
367
|
+
except OSError:
|
|
368
|
+
continue
|
|
369
|
+
except OSError:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
return deleted
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def main():
|
|
376
|
+
try:
|
|
377
|
+
parser = argparse.ArgumentParser()
|
|
378
|
+
parser.add_argument('--announce', action='store_true',
|
|
379
|
+
help='Enable TTS announcements for tool events')
|
|
380
|
+
args = parser.parse_args()
|
|
381
|
+
|
|
382
|
+
# Read JSON input from stdin
|
|
383
|
+
input_data = json.load(sys.stdin)
|
|
384
|
+
|
|
385
|
+
tool_name = input_data.get('tool_name', '')
|
|
386
|
+
tool_input = input_data.get('tool_input', {})
|
|
387
|
+
|
|
388
|
+
# TTS announcement (before safety checks so user hears it even if blocked)
|
|
389
|
+
if args.announce:
|
|
390
|
+
message = get_tts_message(tool_name, tool_input)
|
|
391
|
+
if message:
|
|
392
|
+
speak(message)
|
|
393
|
+
|
|
394
|
+
# Soft warning: Check for gitignored file edits (dual-location pattern)
|
|
395
|
+
if tool_name in ['Edit', 'Write', 'MultiEdit']:
|
|
396
|
+
file_path = tool_input.get('file_path', '')
|
|
397
|
+
check_gitignored_file(file_path)
|
|
398
|
+
|
|
399
|
+
# Check for .env file access (blocks access to sensitive environment files)
|
|
400
|
+
if is_env_file_access(tool_name, tool_input):
|
|
401
|
+
print("BLOCKED: Access to .env files containing sensitive data is prohibited", file=sys.stderr)
|
|
402
|
+
print("Use .env.sample for template files instead", file=sys.stderr)
|
|
403
|
+
sys.exit(2) # Exit code 2 blocks tool call and shows error to Claude
|
|
404
|
+
|
|
405
|
+
# Check for dangerous rm -rf commands
|
|
406
|
+
if tool_name == 'Bash':
|
|
407
|
+
command = tool_input.get('command', '')
|
|
408
|
+
|
|
409
|
+
# Block rm -rf commands with comprehensive pattern matching
|
|
410
|
+
if is_dangerous_rm_command(command):
|
|
411
|
+
print("BLOCKED: Dangerous rm command detected and prevented", file=sys.stderr)
|
|
412
|
+
sys.exit(2) # Exit code 2 blocks tool call and shows error to Claude
|
|
413
|
+
|
|
414
|
+
# Ensure log directory exists
|
|
415
|
+
log_dir = Path.cwd() / 'logs'
|
|
416
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
log_path = log_dir / 'pre_tool_use.json'
|
|
418
|
+
|
|
419
|
+
# Rotate log if needed and cleanup old archives (ANV-231)
|
|
420
|
+
rotate_log_if_needed(log_path)
|
|
421
|
+
cleanup_old_archives(log_dir / "archive")
|
|
422
|
+
|
|
423
|
+
# Read existing log data or initialize empty list
|
|
424
|
+
if log_path.exists():
|
|
425
|
+
with open(log_path, 'r') as f:
|
|
426
|
+
try:
|
|
427
|
+
log_data = json.load(f)
|
|
428
|
+
except (json.JSONDecodeError, ValueError):
|
|
429
|
+
log_data = []
|
|
430
|
+
else:
|
|
431
|
+
log_data = []
|
|
432
|
+
|
|
433
|
+
# Append new data
|
|
434
|
+
log_data.append(input_data)
|
|
435
|
+
|
|
436
|
+
# Write back to file with formatting
|
|
437
|
+
with open(log_path, 'w') as f:
|
|
438
|
+
json.dump(log_data, f, indent=2)
|
|
439
|
+
|
|
440
|
+
# Soft warning: Remind to commit WIP every 50 tool invocations
|
|
441
|
+
maybe_remind_wip_commit(len(log_data))
|
|
442
|
+
|
|
443
|
+
sys.exit(0)
|
|
444
|
+
|
|
445
|
+
except json.JSONDecodeError:
|
|
446
|
+
# Gracefully handle JSON decode errors
|
|
447
|
+
sys.exit(0)
|
|
448
|
+
except Exception:
|
|
449
|
+
# Handle any other errors gracefully
|
|
450
|
+
sys.exit(0)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
if __name__ == '__main__':
|
|
454
|
+
main()
|