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