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,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)
|