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,470 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ralph_webhooks.py - Webhook Integration for Ralph Notifications (ANV-304, ANV-305, ANV-306)
4
+
5
+ Sends Ralph events to external webhook endpoints (Slack, Discord).
6
+
7
+ Usage:
8
+ python3 ralph_webhooks.py --dispatch # Dispatch pending events
9
+ python3 ralph_webhooks.py --test-slack URL # Test Slack webhook
10
+ python3 ralph_webhooks.py --test-discord URL # Test Discord webhook
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import time
16
+ import urllib.request
17
+ import urllib.error
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional
22
+
23
+
24
+ # =============================================================================
25
+ # Configuration
26
+ # =============================================================================
27
+
28
+ DEFAULT_CONFIG_PATH = os.path.expanduser("~/.anvil/notifications.yaml")
29
+ PROJECT_CONFIG_PATH = ".anvil/notifications.yaml"
30
+ DEFAULT_EVENTS_DIR = os.path.expanduser("~/.anvil/events")
31
+ WEBHOOK_STATE_FILE = os.path.expanduser("~/.anvil/events/.webhook-state.json")
32
+
33
+ # Rate limiting
34
+ MIN_REQUEST_INTERVAL = 1.0 # seconds between requests
35
+ MAX_RETRIES = 3
36
+ RETRY_DELAY = 2.0
37
+
38
+
39
+ # =============================================================================
40
+ # Configuration Data Classes
41
+ # =============================================================================
42
+
43
+ @dataclass
44
+ class SlackConfig:
45
+ enabled: bool = False
46
+ webhook_url: str = ""
47
+ events: List[str] = field(default_factory=lambda: [
48
+ "session_complete", "error_occurred"
49
+ ])
50
+ mention_on_error: bool = True
51
+ channel_override: str = ""
52
+
53
+
54
+ @dataclass
55
+ class DiscordConfig:
56
+ enabled: bool = False
57
+ webhook_url: str = ""
58
+ events: List[str] = field(default_factory=lambda: [
59
+ "session_complete", "error_occurred"
60
+ ])
61
+ mention_role: str = ""
62
+
63
+
64
+ @dataclass
65
+ class WebhookConfig:
66
+ slack: SlackConfig = field(default_factory=SlackConfig)
67
+ discord: DiscordConfig = field(default_factory=DiscordConfig)
68
+
69
+ @classmethod
70
+ def load(cls) -> "WebhookConfig":
71
+ config = cls()
72
+ for config_path in [PROJECT_CONFIG_PATH, DEFAULT_CONFIG_PATH]:
73
+ if Path(config_path).exists():
74
+ try:
75
+ import yaml
76
+ with open(config_path) as f:
77
+ data = yaml.safe_load(f)
78
+ if data and "notifications" in data:
79
+ channels = data["notifications"].get("channels", {})
80
+
81
+ if "slack" in channels:
82
+ slack = channels["slack"]
83
+ config.slack.enabled = slack.get("enabled", False)
84
+ config.slack.webhook_url = os.path.expandvars(
85
+ slack.get("webhook_url", "")
86
+ )
87
+ config.slack.events = slack.get("events", config.slack.events)
88
+ config.slack.mention_on_error = slack.get("mention_on_error", True)
89
+
90
+ if "discord" in channels:
91
+ discord = channels["discord"]
92
+ config.discord.enabled = discord.get("enabled", False)
93
+ config.discord.webhook_url = os.path.expandvars(
94
+ discord.get("webhook_url", "")
95
+ )
96
+ config.discord.events = discord.get("events", config.discord.events)
97
+ config.discord.mention_role = discord.get("mention_role", "")
98
+ break
99
+ except ImportError:
100
+ pass
101
+ except Exception:
102
+ pass
103
+ return config
104
+
105
+
106
+ # =============================================================================
107
+ # Webhook State Management
108
+ # =============================================================================
109
+
110
+ @dataclass
111
+ class WebhookState:
112
+ last_request_time: float = 0.0
113
+ dispatched_event_ids: List[str] = field(default_factory=list)
114
+
115
+ @classmethod
116
+ def load(cls) -> "WebhookState":
117
+ if not Path(WEBHOOK_STATE_FILE).exists():
118
+ return cls()
119
+ try:
120
+ with open(WEBHOOK_STATE_FILE) as f:
121
+ data = json.load(f)
122
+ return cls(
123
+ last_request_time=data.get("last_request_time", 0.0),
124
+ dispatched_event_ids=data.get("dispatched_event_ids", [])[-200:],
125
+ )
126
+ except Exception:
127
+ return cls()
128
+
129
+ def save(self) -> None:
130
+ Path(WEBHOOK_STATE_FILE).parent.mkdir(parents=True, exist_ok=True)
131
+ with open(WEBHOOK_STATE_FILE, "w") as f:
132
+ json.dump({
133
+ "last_request_time": self.last_request_time,
134
+ "dispatched_event_ids": self.dispatched_event_ids[-200:],
135
+ }, f, indent=2)
136
+
137
+ def mark_dispatched(self, event_id: str) -> None:
138
+ self.dispatched_event_ids.append(event_id)
139
+ self.last_request_time = time.time()
140
+
141
+ def is_dispatched(self, event_id: str) -> bool:
142
+ return event_id in self.dispatched_event_ids
143
+
144
+ def wait_for_rate_limit(self) -> None:
145
+ elapsed = time.time() - self.last_request_time
146
+ if elapsed < MIN_REQUEST_INTERVAL:
147
+ time.sleep(MIN_REQUEST_INTERVAL - elapsed)
148
+
149
+
150
+ # =============================================================================
151
+ # HTTP Utilities
152
+ # =============================================================================
153
+
154
+ def send_webhook(url: str, payload: Dict, retries: int = MAX_RETRIES) -> bool:
155
+ """Send a webhook request with retry logic."""
156
+ data = json.dumps(payload).encode("utf-8")
157
+ headers = {"Content-Type": "application/json"}
158
+
159
+ for attempt in range(retries):
160
+ try:
161
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
162
+ with urllib.request.urlopen(req, timeout=10) as response:
163
+ return response.status in (200, 201, 204)
164
+ except urllib.error.HTTPError as e:
165
+ if e.code == 429: # Rate limited
166
+ time.sleep(RETRY_DELAY * (attempt + 1))
167
+ continue
168
+ if attempt == retries - 1:
169
+ return False
170
+ except Exception:
171
+ if attempt == retries - 1:
172
+ return False
173
+ time.sleep(RETRY_DELAY)
174
+
175
+ return False
176
+
177
+
178
+ # =============================================================================
179
+ # Slack Formatting
180
+ # =============================================================================
181
+
182
+ def format_slack_message(event: Dict) -> Dict:
183
+ """Format event as Slack Block Kit message."""
184
+ event_type = event.get("event_type", "")
185
+ progress = event.get("progress", {})
186
+ payload = event.get("payload", {})
187
+ task_name = event.get("task_name", "Unknown")
188
+ completed = progress.get("completed", 0)
189
+ total = progress.get("total", 0)
190
+ percent = progress.get("percent", 0)
191
+
192
+ # Status emoji
193
+ emoji_map = {
194
+ "session_started": ":rocket:",
195
+ "subtask_complete": ":white_check_mark:",
196
+ "session_complete": ":tada:",
197
+ "error_occurred": ":x:",
198
+ "circuit_breaker": ":warning:",
199
+ }
200
+ emoji = emoji_map.get(event_type, ":robot_face:")
201
+
202
+ # Build blocks
203
+ blocks = []
204
+
205
+ # Header
206
+ if event_type == "session_complete":
207
+ status = payload.get("final_status", "completed")
208
+ if status == "completed":
209
+ header_text = f"{emoji} Ralph Session Complete!"
210
+ else:
211
+ header_text = f"{emoji} Ralph Session Ended: {status}"
212
+ elif event_type == "subtask_complete":
213
+ subtask_id = payload.get("subtask_identifier", "")
214
+ header_text = f"{emoji} Subtask Complete: {subtask_id}"
215
+ elif event_type == "error_occurred":
216
+ header_text = f"{emoji} Ralph Error"
217
+ elif event_type == "session_started":
218
+ header_text = f"{emoji} Ralph Session Started"
219
+ else:
220
+ header_text = f"{emoji} Ralph: {event_type}"
221
+
222
+ blocks.append({
223
+ "type": "header",
224
+ "text": {"type": "plain_text", "text": header_text, "emoji": True}
225
+ })
226
+
227
+ # Task info
228
+ blocks.append({
229
+ "type": "section",
230
+ "fields": [
231
+ {"type": "mrkdwn", "text": f"*Task:*\n{task_name}"},
232
+ {"type": "mrkdwn", "text": f"*Progress:*\n{completed}/{total} ({percent:.0f}%)"},
233
+ ]
234
+ })
235
+
236
+ # Progress bar
237
+ if total > 0:
238
+ filled = int(20 * completed / total)
239
+ bar = "█" * filled + "░" * (20 - filled)
240
+ blocks.append({
241
+ "type": "section",
242
+ "text": {"type": "mrkdwn", "text": f"`{bar}`"}
243
+ })
244
+
245
+ # Error details
246
+ if event_type == "error_occurred":
247
+ error_msg = payload.get("message", "Unknown error")
248
+ blocks.append({
249
+ "type": "section",
250
+ "text": {"type": "mrkdwn", "text": f"*Error:*\n```{error_msg}```"}
251
+ })
252
+
253
+ # Linear link if available
254
+ linear = event.get("linear", {})
255
+ if linear and linear.get("url"):
256
+ blocks.append({
257
+ "type": "context",
258
+ "elements": [
259
+ {"type": "mrkdwn", "text": f"<{linear['url']}|View in Linear>"}
260
+ ]
261
+ })
262
+
263
+ # Timestamp
264
+ blocks.append({
265
+ "type": "context",
266
+ "elements": [
267
+ {"type": "mrkdwn", "text": f"_{event.get('timestamp', '')[:19]}_"}
268
+ ]
269
+ })
270
+
271
+ return {"blocks": blocks}
272
+
273
+
274
+ # =============================================================================
275
+ # Discord Formatting
276
+ # =============================================================================
277
+
278
+ def format_discord_message(event: Dict) -> Dict:
279
+ """Format event as Discord embed message."""
280
+ event_type = event.get("event_type", "")
281
+ progress = event.get("progress", {})
282
+ payload = event.get("payload", {})
283
+ task_name = event.get("task_name", "Unknown")
284
+ completed = progress.get("completed", 0)
285
+ total = progress.get("total", 0)
286
+ percent = progress.get("percent", 0)
287
+
288
+ # Color by event type
289
+ color_map = {
290
+ "session_started": 0x3498DB, # Blue
291
+ "subtask_complete": 0x2ECC71, # Green
292
+ "session_complete": 0x9B59B6, # Purple
293
+ "error_occurred": 0xE74C3C, # Red
294
+ "circuit_breaker": 0xF39C12, # Orange
295
+ }
296
+ color = color_map.get(event_type, 0x95A5A6)
297
+
298
+ # Build embed
299
+ if event_type == "session_complete":
300
+ status = payload.get("final_status", "completed")
301
+ title = f"🏁 Ralph Session {'Complete' if status == 'completed' else 'Ended'}"
302
+ elif event_type == "subtask_complete":
303
+ subtask_id = payload.get("subtask_identifier", "")
304
+ title = f"✅ Subtask Complete: {subtask_id}"
305
+ elif event_type == "error_occurred":
306
+ title = "❌ Ralph Error"
307
+ elif event_type == "session_started":
308
+ title = "🚀 Ralph Session Started"
309
+ else:
310
+ title = f"🤖 Ralph: {event_type}"
311
+
312
+ # Progress bar
313
+ if total > 0:
314
+ filled = int(10 * completed / total)
315
+ bar = "█" * filled + "░" * (10 - filled)
316
+ progress_text = f"`{bar}` {completed}/{total} ({percent:.0f}%)"
317
+ else:
318
+ progress_text = "No progress data"
319
+
320
+ embed = {
321
+ "title": title,
322
+ "color": color,
323
+ "fields": [
324
+ {"name": "Task", "value": task_name[:100], "inline": True},
325
+ {"name": "Progress", "value": progress_text, "inline": True},
326
+ ],
327
+ "timestamp": event.get("timestamp", datetime.now(timezone.utc).isoformat()),
328
+ }
329
+
330
+ # Error details
331
+ if event_type == "error_occurred":
332
+ error_msg = payload.get("message", "Unknown error")[:500]
333
+ embed["fields"].append({
334
+ "name": "Error",
335
+ "value": f"```{error_msg}```",
336
+ "inline": False
337
+ })
338
+
339
+ # Linear link
340
+ linear = event.get("linear", {})
341
+ if linear and linear.get("url"):
342
+ embed["url"] = linear["url"]
343
+
344
+ return {"embeds": [embed]}
345
+
346
+
347
+ # =============================================================================
348
+ # Webhook Dispatcher
349
+ # =============================================================================
350
+
351
+ class WebhookDispatcher:
352
+ def __init__(self, config: Optional[WebhookConfig] = None):
353
+ self.config = config or WebhookConfig.load()
354
+ self.state = WebhookState.load()
355
+
356
+ def dispatch_event(self, event: Dict) -> Dict[str, bool]:
357
+ event_type = event.get("event_type", "")
358
+ event_id = event.get("event_id", "")
359
+
360
+ if event_id and self.state.is_dispatched(event_id):
361
+ return {}
362
+
363
+ results = {}
364
+
365
+ # Slack
366
+ if self.config.slack.enabled and self.config.slack.webhook_url:
367
+ if event_type in self.config.slack.events:
368
+ self.state.wait_for_rate_limit()
369
+ payload = format_slack_message(event)
370
+ results["slack"] = send_webhook(self.config.slack.webhook_url, payload)
371
+ self.state.last_request_time = time.time()
372
+
373
+ # Discord
374
+ if self.config.discord.enabled and self.config.discord.webhook_url:
375
+ if event_type in self.config.discord.events:
376
+ self.state.wait_for_rate_limit()
377
+ payload = format_discord_message(event)
378
+ results["discord"] = send_webhook(self.config.discord.webhook_url, payload)
379
+ self.state.last_request_time = time.time()
380
+
381
+ if event_id:
382
+ self.state.mark_dispatched(event_id)
383
+ self.state.save()
384
+
385
+ return results
386
+
387
+ def dispatch_pending(self) -> int:
388
+ events_file = Path(DEFAULT_EVENTS_DIR) / "current-session.jsonl"
389
+ if not events_file.exists():
390
+ return 0
391
+
392
+ events = []
393
+ with open(events_file) as f:
394
+ for line in f:
395
+ line = line.strip()
396
+ if line:
397
+ try:
398
+ events.append(json.loads(line))
399
+ except json.JSONDecodeError:
400
+ continue
401
+
402
+ dispatched = 0
403
+ for event in events:
404
+ event_id = event.get("event_id", "")
405
+ if not self.state.is_dispatched(event_id):
406
+ results = self.dispatch_event(event)
407
+ if results:
408
+ dispatched += 1
409
+
410
+ return dispatched
411
+
412
+
413
+ # =============================================================================
414
+ # CLI Entry Point
415
+ # =============================================================================
416
+
417
+ def main():
418
+ import argparse
419
+ parser = argparse.ArgumentParser(description="Ralph Webhook Dispatcher")
420
+ parser.add_argument("--dispatch", action="store_true", help="Dispatch pending events")
421
+ parser.add_argument("--test-slack", metavar="URL", help="Test Slack webhook")
422
+ parser.add_argument("--test-discord", metavar="URL", help="Test Discord webhook")
423
+ parser.add_argument("--config", action="store_true", help="Show configuration")
424
+ args = parser.parse_args()
425
+
426
+ if args.config:
427
+ config = WebhookConfig.load()
428
+ print(f"Slack: enabled={config.slack.enabled}, url={'set' if config.slack.webhook_url else 'not set'}")
429
+ print(f"Discord: enabled={config.discord.enabled}, url={'set' if config.discord.webhook_url else 'not set'}")
430
+ return
431
+
432
+ if args.test_slack:
433
+ test_event = {
434
+ "event_id": "test-slack",
435
+ "event_type": "session_complete",
436
+ "timestamp": datetime.now(timezone.utc).isoformat(),
437
+ "task_name": "Webhook Test",
438
+ "progress": {"completed": 5, "total": 5, "percent": 100},
439
+ "payload": {"final_status": "completed"},
440
+ }
441
+ payload = format_slack_message(test_event)
442
+ success = send_webhook(args.test_slack, payload)
443
+ print(f"Slack webhook: {'sent' if success else 'failed'}")
444
+ return
445
+
446
+ if args.test_discord:
447
+ test_event = {
448
+ "event_id": "test-discord",
449
+ "event_type": "session_complete",
450
+ "timestamp": datetime.now(timezone.utc).isoformat(),
451
+ "task_name": "Webhook Test",
452
+ "progress": {"completed": 5, "total": 5, "percent": 100},
453
+ "payload": {"final_status": "completed"},
454
+ }
455
+ payload = format_discord_message(test_event)
456
+ success = send_webhook(args.test_discord, payload)
457
+ print(f"Discord webhook: {'sent' if success else 'failed'}")
458
+ return
459
+
460
+ if args.dispatch:
461
+ dispatcher = WebhookDispatcher()
462
+ count = dispatcher.dispatch_pending()
463
+ print(f"Dispatched {count} event(s) to webhooks")
464
+ return
465
+
466
+ parser.print_help()
467
+
468
+
469
+ if __name__ == "__main__":
470
+ main()
@@ -321,6 +321,91 @@ def on_doc_coverage(
321
321
  _write_state(state)
322
322
 
323
323
 
324
+ def on_code_review(
325
+ issue_count: int,
326
+ result: str,
327
+ tool: str = "coderabbit",
328
+ ) -> None:
329
+ """Called when code review runs (from /evidence or /coderabbit-fix).
330
+
331
+ Stores review results for statusline display.
332
+
333
+ Note: This function directly manipulates state rather than using update_state()
334
+ because code review is a quality gate (not a workflow phase change) and
335
+ requires storing structured data (codeReview object) not supported by update_state().
336
+ Registry sync is intentionally skipped since review status is project-local.
337
+
338
+ Args:
339
+ issue_count: Number of issues found (0 = clean review)
340
+ result: Review result status (pass, issues, error)
341
+ tool: Review tool name (default: coderabbit)
342
+ """
343
+ state = _read_state()
344
+
345
+ state["codeReview"] = {
346
+ "issueCount": issue_count,
347
+ "lastResult": result,
348
+ "tool": tool,
349
+ "timestamp": datetime.now(timezone.utc).isoformat(),
350
+ }
351
+
352
+ _write_state(state)
353
+
354
+
355
+ def clear_code_review() -> None:
356
+ """Clear code review data from state.
357
+
358
+ Called when starting fresh work to remove stale review indicators.
359
+ """
360
+ state = _read_state()
361
+
362
+ if "codeReview" in state:
363
+ del state["codeReview"]
364
+ _write_state(state)
365
+
366
+
367
+ def on_pr_created(
368
+ url: str,
369
+ number: str,
370
+ ) -> None:
371
+ """Called when a PR is created (from PostToolUse hook or /commit-push-pr).
372
+
373
+ Stores PR info for statusline and workflow tracking.
374
+
375
+ Args:
376
+ url: Full GitHub PR URL
377
+ number: PR number
378
+ """
379
+ state = _read_state()
380
+ session = state.setdefault("session", {})
381
+
382
+ state["prCreation"] = {
383
+ "url": url,
384
+ "number": number,
385
+ "createdAt": datetime.now(timezone.utc).isoformat(),
386
+ }
387
+
388
+ # Also update session phase to "review"
389
+ session["phase"] = "review"
390
+ session["lastCommand"] = "gh pr create"
391
+ session["lastCommandAt"] = datetime.now(timezone.utc).isoformat()
392
+
393
+ _write_state(state)
394
+ _sync_to_registry(session)
395
+
396
+
397
+ def clear_pr_creation() -> None:
398
+ """Clear PR creation data from state.
399
+
400
+ Called when starting fresh work or after PR is merged.
401
+ """
402
+ state = _read_state()
403
+
404
+ if "prCreation" in state:
405
+ del state["prCreation"]
406
+ _write_state(state)
407
+
408
+
324
409
  # =============================================================================
325
410
  # CLI Interface
326
411
  # =============================================================================
@@ -343,6 +428,10 @@ if __name__ == "__main__":
343
428
  print(" evidence - Run evidence handler")
344
429
  print(" handoff - Run handoff handler")
345
430
  print(" doc-coverage <p> <t> <d> <s> - Store coverage snapshot")
431
+ print(" code-review <count> <result> - Store code review result")
432
+ print(" clear-code-review - Clear code review data")
433
+ print(" pr-created <url> <number> - Store PR creation info")
434
+ print(" clear-pr-creation - Clear PR creation data")
346
435
  sys.exit(1)
347
436
 
348
437
  cmd = sys.argv[1]
@@ -412,6 +501,38 @@ if __name__ == "__main__":
412
501
  )
413
502
  print(f"Doc coverage recorded: {sys.argv[2]}%")
414
503
 
504
+ elif cmd == "code-review":
505
+ if len(sys.argv) < 4:
506
+ print("Error: code-review requires count and result")
507
+ print("Usage: state_manager.py code-review <count> <result>")
508
+ print(" count: Number of issues found (0 = clean)")
509
+ print(" result: pass, issues, or error")
510
+ sys.exit(1)
511
+ on_code_review(
512
+ issue_count=int(sys.argv[2]),
513
+ result=sys.argv[3],
514
+ )
515
+ print(f"Code review recorded: {sys.argv[2]} issues ({sys.argv[3]})")
516
+
517
+ elif cmd == "clear-code-review":
518
+ clear_code_review()
519
+ print("Code review data cleared")
520
+
521
+ elif cmd == "pr-created":
522
+ if len(sys.argv) < 4:
523
+ print("Error: pr-created requires url and number")
524
+ print("Usage: state_manager.py pr-created <url> <number>")
525
+ sys.exit(1)
526
+ on_pr_created(
527
+ url=sys.argv[2],
528
+ number=sys.argv[3],
529
+ )
530
+ print(f"PR creation recorded: #{sys.argv[3]}")
531
+
532
+ elif cmd == "clear-pr-creation":
533
+ clear_pr_creation()
534
+ print("PR creation data cleared")
535
+
415
536
  else:
416
537
  print(f"Unknown command: {cmd}")
417
538
  sys.exit(1)