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,398 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ralph_events.py - Ralph Event Detection and Emission (ANV-299)
4
+
5
+ Detects state changes in Ralph sessions and emits structured events
6
+ for the notification system.
7
+
8
+ Usage:
9
+ python3 ralph_events.py --state .claude/ralph-state.json --emit
10
+ python3 ralph_events.py --test subtask_complete
11
+ python3 ralph_events.py --list
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ from dataclasses import dataclass, field, asdict
18
+ from datetime import datetime, timezone
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional
22
+ import uuid
23
+ import hashlib
24
+
25
+
26
+ # =============================================================================
27
+ # Configuration
28
+ # =============================================================================
29
+
30
+ DEFAULT_STATE_FILE = ".claude/ralph-state.json"
31
+ DEFAULT_EVENTS_DIR = os.path.expanduser("~/.anvil/events")
32
+ DEBOUNCE_STATE_FILE = os.path.expanduser("~/.anvil/events/.debounce-state.json")
33
+
34
+
35
+ # =============================================================================
36
+ # Event Types
37
+ # =============================================================================
38
+
39
+ class RalphEventType(str, Enum):
40
+ SESSION_STARTED = "session_started"
41
+ ITERATION_COMPLETE = "iteration_complete"
42
+ SUBTASK_COMPLETE = "subtask_complete"
43
+ SESSION_COMPLETE = "session_complete"
44
+ ERROR_OCCURRED = "error_occurred"
45
+ CHECKPOINT_TRIGGERED = "checkpoint_triggered"
46
+ CIRCUIT_BREAKER = "circuit_breaker"
47
+ MANUAL_STOP = "manual_stop"
48
+
49
+
50
+ # =============================================================================
51
+ # Event Data Classes
52
+ # =============================================================================
53
+
54
+ @dataclass
55
+ class EventProgress:
56
+ completed: int = 0
57
+ total: int = 0
58
+ percent: float = 0.0
59
+
60
+
61
+ @dataclass
62
+ class LinearInfo:
63
+ parent_issue: Optional[str] = None
64
+ subtask: Optional[str] = None
65
+ url: Optional[str] = None
66
+
67
+
68
+ @dataclass
69
+ class RalphEvent:
70
+ event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
71
+ event_type: str = ""
72
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
73
+ session_id: str = ""
74
+ task_name: str = ""
75
+ iteration: int = 0
76
+ progress: EventProgress = field(default_factory=EventProgress)
77
+ payload: Dict[str, Any] = field(default_factory=dict)
78
+ linear: Optional[LinearInfo] = None
79
+
80
+ def to_dict(self) -> Dict[str, Any]:
81
+ result = {
82
+ "event_id": self.event_id,
83
+ "event_type": self.event_type,
84
+ "timestamp": self.timestamp,
85
+ "session_id": self.session_id,
86
+ "task_name": self.task_name,
87
+ "iteration": self.iteration,
88
+ "progress": asdict(self.progress),
89
+ "payload": self.payload,
90
+ }
91
+ if self.linear:
92
+ result["linear"] = asdict(self.linear)
93
+ return result
94
+
95
+ def to_json(self) -> str:
96
+ return json.dumps(self.to_dict())
97
+
98
+
99
+ # =============================================================================
100
+ # State Snapshot for Change Detection
101
+ # =============================================================================
102
+
103
+ @dataclass
104
+ class StateSnapshot:
105
+ iteration: int = 0
106
+ status: str = ""
107
+ completed_count: int = 0
108
+ todo_count: int = 0
109
+ subtask_statuses: Dict[str, str] = field(default_factory=dict)
110
+ checkpoint_active: bool = False
111
+ no_change_count: int = 0
112
+ state_hash: str = ""
113
+
114
+ @classmethod
115
+ def from_state_file(cls, state_path: str) -> Optional["StateSnapshot"]:
116
+ if not Path(state_path).exists():
117
+ return None
118
+ try:
119
+ with open(state_path) as f:
120
+ data = json.load(f)
121
+ subtask_statuses = {}
122
+ linear_int = data.get("linear_integration", {})
123
+ if linear_int and linear_int.get("enabled"):
124
+ for subtask in linear_int.get("subtasks", []):
125
+ subtask_statuses[subtask["identifier"]] = subtask.get("status", "todo")
126
+ state_hash = hashlib.md5(json.dumps(data, sort_keys=True).encode()).hexdigest()
127
+ return cls(
128
+ iteration=data.get("iteration", 0),
129
+ status=data.get("status", "unknown"),
130
+ completed_count=len(data.get("completed_items", [])),
131
+ todo_count=len(data.get("todo_items", [])),
132
+ subtask_statuses=subtask_statuses,
133
+ checkpoint_active=data.get("context_checkpoint", {}).get("active", False),
134
+ no_change_count=data.get("no_change_count", 0),
135
+ state_hash=state_hash,
136
+ )
137
+ except (json.JSONDecodeError, KeyError, TypeError):
138
+ return None
139
+
140
+
141
+ # =============================================================================
142
+ # Debounce State
143
+ # =============================================================================
144
+
145
+ @dataclass
146
+ class DebounceState:
147
+ last_event_time: float = 0.0
148
+ pending_events: List[Dict[str, Any]] = field(default_factory=list)
149
+ last_state_hash: str = ""
150
+ last_snapshot: Optional[Dict[str, Any]] = None
151
+
152
+ @classmethod
153
+ def load(cls) -> "DebounceState":
154
+ if not Path(DEBOUNCE_STATE_FILE).exists():
155
+ return cls()
156
+ try:
157
+ with open(DEBOUNCE_STATE_FILE) as f:
158
+ data = json.load(f)
159
+ return cls(
160
+ last_event_time=data.get("last_event_time", 0.0),
161
+ pending_events=data.get("pending_events", []),
162
+ last_state_hash=data.get("last_state_hash", ""),
163
+ last_snapshot=data.get("last_snapshot"),
164
+ )
165
+ except (json.JSONDecodeError, KeyError):
166
+ return cls()
167
+
168
+ def save(self) -> None:
169
+ Path(DEBOUNCE_STATE_FILE).parent.mkdir(parents=True, exist_ok=True)
170
+ with open(DEBOUNCE_STATE_FILE, "w") as f:
171
+ json.dump({
172
+ "last_event_time": self.last_event_time,
173
+ "pending_events": self.pending_events,
174
+ "last_state_hash": self.last_state_hash,
175
+ "last_snapshot": self.last_snapshot,
176
+ }, f, indent=2)
177
+
178
+
179
+ # =============================================================================
180
+ # Event Detection
181
+ # =============================================================================
182
+
183
+ class RalphEventDetector:
184
+ def __init__(self, state_path: str = DEFAULT_STATE_FILE):
185
+ self.state_path = state_path
186
+ self.debounce = DebounceState.load()
187
+
188
+ def detect_changes(self) -> List[RalphEvent]:
189
+ current = StateSnapshot.from_state_file(self.state_path)
190
+ if not current:
191
+ return []
192
+ previous = None
193
+ if self.debounce.last_snapshot:
194
+ previous = StateSnapshot(**self.debounce.last_snapshot)
195
+ events = []
196
+ with open(self.state_path) as f:
197
+ state_data = json.load(f)
198
+
199
+ # Session start
200
+ if previous is None:
201
+ events.append(self._create_event(RalphEventType.SESSION_STARTED, state_data, current))
202
+ # Iteration complete
203
+ elif current.iteration > previous.iteration:
204
+ events.append(self._create_event(RalphEventType.ITERATION_COMPLETE, state_data, current))
205
+ # Subtask completions
206
+ if previous:
207
+ for identifier, status in current.subtask_statuses.items():
208
+ prev_status = previous.subtask_statuses.get(identifier, "todo")
209
+ if status == "completed" and prev_status != "completed":
210
+ events.append(self._create_subtask_event(state_data, current, identifier))
211
+ # Session completion
212
+ if previous and current.status != previous.status:
213
+ if current.status in ("completed", "max_iterations", "circuit_breaker", "fatal_error", "stopped"):
214
+ events.append(self._create_session_complete_event(state_data, current))
215
+ # Checkpoint
216
+ if current.checkpoint_active and (not previous or not previous.checkpoint_active):
217
+ events.append(self._create_event(RalphEventType.CHECKPOINT_TRIGGERED, state_data, current))
218
+ # Circuit breaker
219
+ if current.no_change_count >= 3 and (not previous or previous.no_change_count < 3):
220
+ events.append(self._create_event(RalphEventType.CIRCUIT_BREAKER, state_data, current))
221
+
222
+ self.debounce.last_state_hash = current.state_hash
223
+ self.debounce.last_snapshot = asdict(current)
224
+ self.debounce.save()
225
+ return events
226
+
227
+ def _get_session_id(self, state_data: Dict) -> str:
228
+ started_at = state_data.get("started_at", "")
229
+ if started_at:
230
+ return hashlib.md5(started_at.encode()).hexdigest()[:12]
231
+ return str(uuid.uuid4())[:12]
232
+
233
+ def _get_progress(self, state_data: Dict, current: StateSnapshot) -> EventProgress:
234
+ total = current.completed_count + current.todo_count
235
+ percent = (current.completed_count / total * 100) if total > 0 else 0.0
236
+ return EventProgress(completed=current.completed_count, total=total, percent=round(percent, 1))
237
+
238
+ def _get_linear_info(self, state_data: Dict, subtask_id: Optional[str] = None) -> Optional[LinearInfo]:
239
+ linear = state_data.get("linear_integration", {})
240
+ if not linear or not linear.get("enabled"):
241
+ return None
242
+ parent = linear.get("parent_issue", "")
243
+ url = f"https://linear.app/anvil/issue/{parent}" if parent else None
244
+ return LinearInfo(parent_issue=parent, subtask=subtask_id, url=url)
245
+
246
+ def _create_event(self, event_type: RalphEventType, state_data: Dict, current: StateSnapshot) -> RalphEvent:
247
+ return RalphEvent(
248
+ event_type=event_type.value,
249
+ session_id=self._get_session_id(state_data),
250
+ task_name=state_data.get("task_name", "Unknown"),
251
+ iteration=current.iteration,
252
+ progress=self._get_progress(state_data, current),
253
+ linear=self._get_linear_info(state_data),
254
+ )
255
+
256
+ def _create_subtask_event(self, state_data: Dict, current: StateSnapshot, identifier: str) -> RalphEvent:
257
+ linear = state_data.get("linear_integration", {})
258
+ subtask_title = ""
259
+ for subtask in linear.get("subtasks", []):
260
+ if subtask["identifier"] == identifier:
261
+ subtask_title = subtask.get("title", "")
262
+ break
263
+ return RalphEvent(
264
+ event_type=RalphEventType.SUBTASK_COMPLETE.value,
265
+ session_id=self._get_session_id(state_data),
266
+ task_name=state_data.get("task_name", "Unknown"),
267
+ iteration=current.iteration,
268
+ progress=self._get_progress(state_data, current),
269
+ payload={"subtask_identifier": identifier, "subtask_title": subtask_title},
270
+ linear=self._get_linear_info(state_data, identifier),
271
+ )
272
+
273
+ def _create_session_complete_event(self, state_data: Dict, current: StateSnapshot) -> RalphEvent:
274
+ started_at = state_data.get("started_at", "")
275
+ duration = 0.0
276
+ if started_at:
277
+ try:
278
+ start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
279
+ duration = (datetime.now(timezone.utc) - start).total_seconds()
280
+ except ValueError:
281
+ pass
282
+ return RalphEvent(
283
+ event_type=RalphEventType.SESSION_COMPLETE.value,
284
+ session_id=self._get_session_id(state_data),
285
+ task_name=state_data.get("task_name", "Unknown"),
286
+ iteration=current.iteration,
287
+ progress=self._get_progress(state_data, current),
288
+ payload={
289
+ "total_iterations": current.iteration,
290
+ "total_duration_seconds": duration,
291
+ "subtasks_completed": current.completed_count,
292
+ "subtasks_total": current.completed_count + current.todo_count,
293
+ "final_status": current.status,
294
+ },
295
+ linear=self._get_linear_info(state_data),
296
+ )
297
+
298
+
299
+ # =============================================================================
300
+ # Event Emission
301
+ # =============================================================================
302
+
303
+ class RalphEventEmitter:
304
+ def __init__(self, events_dir: str = DEFAULT_EVENTS_DIR):
305
+ self.events_dir = Path(events_dir)
306
+ self.events_dir.mkdir(parents=True, exist_ok=True)
307
+ self.current_session_file = self.events_dir / "current-session.jsonl"
308
+
309
+ def emit(self, event: RalphEvent) -> None:
310
+ with open(self.current_session_file, "a") as f:
311
+ f.write(event.to_json() + "\n")
312
+
313
+ def emit_batch(self, events: List[RalphEvent]) -> None:
314
+ for event in events:
315
+ self.emit(event)
316
+
317
+ def get_recent_events(self, limit: int = 50) -> List[Dict]:
318
+ if not self.current_session_file.exists():
319
+ return []
320
+ events = []
321
+ with open(self.current_session_file) as f:
322
+ for line in f:
323
+ line = line.strip()
324
+ if line:
325
+ try:
326
+ events.append(json.loads(line))
327
+ except json.JSONDecodeError:
328
+ continue
329
+ return events[-limit:]
330
+
331
+
332
+ # =============================================================================
333
+ # Main Entry Point
334
+ # =============================================================================
335
+
336
+ def check_and_emit(state_path: str = DEFAULT_STATE_FILE, emit: bool = True) -> List[RalphEvent]:
337
+ detector = RalphEventDetector(state_path)
338
+ events = detector.detect_changes()
339
+ if emit and events:
340
+ emitter = RalphEventEmitter()
341
+ emitter.emit_batch(events)
342
+ # Trigger notification dispatch
343
+ try:
344
+ import subprocess
345
+ lib_dir = Path(__file__).parent
346
+ notifier_path = lib_dir / "ralph_notifier.py"
347
+ if notifier_path.exists():
348
+ subprocess.Popen(
349
+ ["python3", str(notifier_path), "--dispatch"],
350
+ stdout=subprocess.DEVNULL,
351
+ stderr=subprocess.DEVNULL,
352
+ start_new_session=True,
353
+ )
354
+ except Exception:
355
+ pass
356
+ return events
357
+
358
+
359
+ def main():
360
+ import argparse
361
+ parser = argparse.ArgumentParser(description="Ralph Event Detection")
362
+ parser.add_argument("--state", default=DEFAULT_STATE_FILE, help="Path to Ralph state file")
363
+ parser.add_argument("--emit", action="store_true", help="Emit detected events")
364
+ parser.add_argument("--check", action="store_true", help="Check for changes")
365
+ parser.add_argument("--test", help="Emit test event of specified type")
366
+ parser.add_argument("--list", action="store_true", help="List recent events")
367
+ args = parser.parse_args()
368
+
369
+ if args.list:
370
+ emitter = RalphEventEmitter()
371
+ for event in emitter.get_recent_events():
372
+ print(json.dumps(event))
373
+ return
374
+
375
+ if args.test:
376
+ event = RalphEvent(
377
+ event_type=args.test,
378
+ session_id="test-session",
379
+ task_name="Test Task",
380
+ iteration=1,
381
+ progress=EventProgress(completed=1, total=5, percent=20.0),
382
+ payload={"test": True},
383
+ )
384
+ RalphEventEmitter().emit(event)
385
+ print(f"Emitted test event: {event.event_type}")
386
+ return
387
+
388
+ events = check_and_emit(args.state, emit=args.emit)
389
+ if events:
390
+ print(f"Detected {len(events)} event(s):")
391
+ for event in events:
392
+ print(f" - {event.event_type}: {event.task_name}")
393
+ else:
394
+ print("No events detected")
395
+
396
+
397
+ if __name__ == "__main__":
398
+ main()