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.
Files changed (143) hide show
  1. package/README.md +71 -22
  2. package/VERSION +1 -1
  3. package/docs/ANV-263-hook-logging-investigation.md +116 -0
  4. package/docs/command-reference.md +398 -17
  5. package/docs/session-workflow.md +62 -9
  6. package/docs/system-architecture.md +584 -0
  7. package/global/api/__pycache__/ralph_api.cpython-314.pyc +0 -0
  8. package/global/api/openapi.yaml +357 -0
  9. package/global/api/ralph_api.py +528 -0
  10. package/global/commands/anvil-settings.md +47 -19
  11. package/global/commands/audit.md +163 -0
  12. package/global/commands/checklist.md +180 -0
  13. package/global/commands/coderabbit-fix.md +282 -0
  14. package/global/commands/efficiency.md +356 -0
  15. package/global/commands/evidence.md +117 -33
  16. package/global/commands/hud.md +24 -0
  17. package/global/commands/insights.md +101 -3
  18. package/global/commands/orient.md +22 -21
  19. package/global/commands/patterns.md +115 -0
  20. package/global/commands/ralph.md +47 -1
  21. package/global/commands/token-budget.md +214 -0
  22. package/global/commands/weekly-review.md +21 -1
  23. package/global/config/notifications.yaml.template +50 -0
  24. package/global/hooks/ralph_stop.sh +33 -1
  25. package/global/hooks/statusline.sh +67 -2
  26. package/global/lib/__pycache__/coderabbit_metrics.cpython-314.pyc +0 -0
  27. package/global/lib/__pycache__/command_tracker.cpython-314.pyc +0 -0
  28. package/global/lib/__pycache__/context_optimizer.cpython-314.pyc +0 -0
  29. package/global/lib/__pycache__/git_utils.cpython-314.pyc +0 -0
  30. package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
  31. package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
  32. package/global/lib/__pycache__/optimization_applier.cpython-314.pyc +0 -0
  33. package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
  34. package/global/lib/__pycache__/ralph_webhooks.cpython-314.pyc +0 -0
  35. package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
  36. package/global/lib/__pycache__/token_analyzer.cpython-314.pyc +0 -0
  37. package/global/lib/__pycache__/token_metrics.cpython-314.pyc +0 -0
  38. package/global/lib/coderabbit_metrics.py +647 -0
  39. package/global/lib/command_tracker.py +147 -0
  40. package/global/lib/context_optimizer.py +323 -0
  41. package/global/lib/linear_provider.py +210 -16
  42. package/global/lib/log_rotation.py +287 -0
  43. package/global/lib/optimization_applier.py +582 -0
  44. package/global/lib/ralph_events.py +398 -0
  45. package/global/lib/ralph_notifier.py +366 -0
  46. package/global/lib/ralph_state.py +264 -24
  47. package/global/lib/ralph_webhooks.py +470 -0
  48. package/global/lib/state_manager.py +121 -0
  49. package/global/lib/token_analyzer.py +1383 -0
  50. package/global/lib/token_metrics.py +919 -0
  51. package/global/tests/__pycache__/test_command_tracker.cpython-314-pytest-9.0.2.pyc +0 -0
  52. package/global/tests/__pycache__/test_context_optimizer.cpython-314-pytest-9.0.2.pyc +0 -0
  53. package/global/tests/__pycache__/test_doc_coverage.cpython-314-pytest-9.0.2.pyc +0 -0
  54. package/global/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  55. package/global/tests/__pycache__/test_issue_models.cpython-314-pytest-9.0.2.pyc +0 -0
  56. package/global/tests/__pycache__/test_linear_filtering.cpython-314-pytest-9.0.2.pyc +0 -0
  57. package/global/tests/__pycache__/test_linear_provider.cpython-314-pytest-9.0.2.pyc +0 -0
  58. package/global/tests/__pycache__/test_local_provider.cpython-314-pytest-9.0.2.pyc +0 -0
  59. package/global/tests/__pycache__/test_optimization_applier.cpython-314-pytest-9.0.2.pyc +0 -0
  60. package/global/tests/__pycache__/test_token_analyzer.cpython-314-pytest-9.0.2.pyc +0 -0
  61. package/global/tests/__pycache__/test_token_analyzer_phase6.cpython-314-pytest-9.0.2.pyc +0 -0
  62. package/global/tests/__pycache__/test_token_metrics.cpython-314-pytest-9.0.2.pyc +0 -0
  63. package/global/tests/test_command_tracker.py +172 -0
  64. package/global/tests/test_context_optimizer.py +321 -0
  65. package/global/tests/test_linear_filtering.py +319 -0
  66. package/global/tests/test_linear_provider.py +40 -1
  67. package/global/tests/test_optimization_applier.py +508 -0
  68. package/global/tests/test_token_analyzer.py +735 -0
  69. package/global/tests/test_token_analyzer_phase6.py +537 -0
  70. package/global/tests/test_token_metrics.py +829 -0
  71. package/global/tools/README.md +153 -0
  72. package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
  73. package/global/tools/__pycache__/orient_linear.cpython-314.pyc +0 -0
  74. package/global/tools/__pycache__/ralph-watchcpython-314.pyc +0 -0
  75. package/global/tools/anvil-hud.py +86 -1
  76. package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +472 -0
  77. package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +405 -0
  78. package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +36 -0
  79. package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +653 -0
  80. package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +727 -0
  81. package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +340 -0
  82. package/global/tools/anvil-memory/src/__tests__/commands.test.ts +218 -0
  83. package/global/tools/anvil-memory/src/commands/context.ts +322 -0
  84. package/global/tools/anvil-memory/src/db.ts +108 -0
  85. package/global/tools/anvil-memory/src/index.ts +2 -8
  86. package/global/tools/orient_linear.py +159 -0
  87. package/global/tools/ralph-watch +423 -0
  88. package/package.json +2 -1
  89. package/project/.anvil-project.yaml.template +93 -0
  90. package/project/CLAUDE.md.template +343 -0
  91. package/project/agents/README.md +119 -0
  92. package/project/agents/cross-layer-debugger.md +217 -0
  93. package/project/agents/security-code-reviewer.md +162 -0
  94. package/project/constitution.md.template +235 -0
  95. package/project/coordination.md +103 -0
  96. package/project/docs/background-tasks.md +258 -0
  97. package/project/docs/skills-frontmatter.md +243 -0
  98. package/project/examples/README.md +106 -0
  99. package/project/examples/api-route-template.ts +171 -0
  100. package/project/examples/component-template.tsx +110 -0
  101. package/project/examples/hook-template.ts +152 -0
  102. package/project/examples/service-template.ts +207 -0
  103. package/project/examples/test-template.test.tsx +249 -0
  104. package/project/hooks/README.md +491 -0
  105. package/project/hooks/__pycache__/notification.cpython-314.pyc +0 -0
  106. package/project/hooks/__pycache__/post_tool_use.cpython-314.pyc +0 -0
  107. package/project/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  108. package/project/hooks/__pycache__/session_start.cpython-314.pyc +0 -0
  109. package/project/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  110. package/project/hooks/notification.py +183 -0
  111. package/project/hooks/permission_request.py +438 -0
  112. package/project/hooks/post_tool_use.py +397 -0
  113. package/project/hooks/pre_compact.py +126 -0
  114. package/project/hooks/pre_tool_use.py +454 -0
  115. package/project/hooks/session_start.py +656 -0
  116. package/project/hooks/stop.py +356 -0
  117. package/project/hooks/subagent_start.py +223 -0
  118. package/project/hooks/subagent_stop.py +215 -0
  119. package/project/hooks/user_prompt_submit.py +110 -0
  120. package/project/hooks/utils/llm/anth.py +114 -0
  121. package/project/hooks/utils/llm/oai.py +114 -0
  122. package/project/hooks/utils/tts/elevenlabs_tts.py +63 -0
  123. package/project/hooks/utils/tts/mlx_audio_tts.py +86 -0
  124. package/project/hooks/utils/tts/openai_tts.py +92 -0
  125. package/project/hooks/utils/tts/pyttsx3_tts.py +75 -0
  126. package/project/linear.yaml.template +23 -0
  127. package/project/product.md.template +238 -0
  128. package/project/retros/README.md +126 -0
  129. package/project/rules/README.md +90 -0
  130. package/project/rules/debugging.md +139 -0
  131. package/project/rules/security-review.md +115 -0
  132. package/project/settings.yaml.template +185 -0
  133. package/project/specs/SPEC-ANV-72-hud-kanban.md +525 -0
  134. package/project/templates/api-python/CLAUDE.md +547 -0
  135. package/project/templates/generic/CLAUDE.md +260 -0
  136. package/project/templates/saas/CLAUDE.md +478 -0
  137. package/project/tests/README.md +140 -0
  138. package/project/tests/__pycache__/test_transcript_parser.cpython-314-pytest-9.0.2.pyc +0 -0
  139. package/project/tests/fixtures/sample-transcript.jsonl +21 -0
  140. package/project/tests/test-hooks.sh +259 -0
  141. package/project/tests/test-lib.sh +248 -0
  142. package/project/tests/test-statusline.sh +165 -0
  143. 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()