ralph-code 0.5.0__py3-none-any.whl
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.
- ralph/__init__.py +20 -0
- ralph/__main__.py +34 -0
- ralph/app.py +1328 -0
- ralph/claude_runner.py +22 -0
- ralph/colors.py +183 -0
- ralph/config.py +227 -0
- ralph/git_manager.py +304 -0
- ralph/harness.py +393 -0
- ralph/harness_runner.py +972 -0
- ralph/prd_manager.py +348 -0
- ralph/schemas/ralph_tasks_schema.json +95 -0
- ralph/schemas/task_schema.json +92 -0
- ralph/spinner.py +287 -0
- ralph/storage.py +77 -0
- ralph/tasks.py +298 -0
- ralph/user_stories.py +283 -0
- ralph/workflow.py +1036 -0
- ralph_code-0.5.0.dist-info/METADATA +79 -0
- ralph_code-0.5.0.dist-info/RECORD +23 -0
- ralph_code-0.5.0.dist-info/WHEEL +5 -0
- ralph_code-0.5.0.dist-info/entry_points.txt +2 -0
- ralph_code-0.5.0.dist-info/licenses/LICENSE +21 -0
- ralph_code-0.5.0.dist-info/top_level.txt +1 -0
ralph/user_stories.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""User Story management for ralph-coding.
|
|
2
|
+
|
|
3
|
+
User stories are derived from PRDs and stored in tasks.json in the project root.
|
|
4
|
+
Each story is a small, implementable unit of work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import jsonschema
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_schema() -> dict[str, Any]:
|
|
16
|
+
"""Load the ralph tasks schema."""
|
|
17
|
+
schema_path = Path(__file__).parent / "schemas" / "ralph_tasks_schema.json"
|
|
18
|
+
with open(schema_path, "r", encoding="utf-8") as f:
|
|
19
|
+
result: dict[str, Any] = json.load(f)
|
|
20
|
+
return result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_tasks_data(data: dict[str, Any]) -> None:
|
|
24
|
+
"""Validate tasks data against the JSON schema."""
|
|
25
|
+
schema = _get_schema()
|
|
26
|
+
jsonschema.validate(instance=data, schema=schema)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class UserStory:
|
|
31
|
+
"""Represents a single user story from tasks.json."""
|
|
32
|
+
id: str # US-001, US-002, etc.
|
|
33
|
+
title: str
|
|
34
|
+
description: str
|
|
35
|
+
acceptance_criteria: list[str]
|
|
36
|
+
priority: int
|
|
37
|
+
passes: bool = False
|
|
38
|
+
blocked: bool = False # True if max attempts exceeded
|
|
39
|
+
needs_intervention: bool = False # True if human intervention needed
|
|
40
|
+
notes: str = ""
|
|
41
|
+
attempts: int = 0
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, data: dict[str, Any]) -> "UserStory":
|
|
45
|
+
"""Create a UserStory from a dictionary."""
|
|
46
|
+
return cls(
|
|
47
|
+
id=data["id"],
|
|
48
|
+
title=data["title"],
|
|
49
|
+
description=data["description"],
|
|
50
|
+
acceptance_criteria=data["acceptanceCriteria"],
|
|
51
|
+
priority=data["priority"],
|
|
52
|
+
passes=data.get("passes", False),
|
|
53
|
+
blocked=data.get("blocked", False),
|
|
54
|
+
needs_intervention=data.get("needsIntervention", False),
|
|
55
|
+
notes=data.get("notes", ""),
|
|
56
|
+
attempts=data.get("attempts", 0),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict[str, Any]:
|
|
60
|
+
"""Convert to dictionary for serialization."""
|
|
61
|
+
result = {
|
|
62
|
+
"id": self.id,
|
|
63
|
+
"title": self.title,
|
|
64
|
+
"description": self.description,
|
|
65
|
+
"acceptanceCriteria": self.acceptance_criteria,
|
|
66
|
+
"priority": self.priority,
|
|
67
|
+
"passes": self.passes,
|
|
68
|
+
"notes": self.notes,
|
|
69
|
+
"attempts": self.attempts,
|
|
70
|
+
}
|
|
71
|
+
if self.blocked:
|
|
72
|
+
result["blocked"] = True
|
|
73
|
+
if self.needs_intervention:
|
|
74
|
+
result["needsIntervention"] = True
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
def mark_passing(self) -> None:
|
|
78
|
+
"""Mark this story as passing."""
|
|
79
|
+
self.passes = True
|
|
80
|
+
|
|
81
|
+
def mark_failed(self, notes: str = "") -> None:
|
|
82
|
+
"""Mark this story as failed with optional notes."""
|
|
83
|
+
self.passes = False
|
|
84
|
+
self.attempts += 1
|
|
85
|
+
if notes:
|
|
86
|
+
self.notes = f"{self.notes}\n\nAttempt {self.attempts}:\n{notes}".strip()
|
|
87
|
+
|
|
88
|
+
def get_prompt(self) -> str:
|
|
89
|
+
"""Get the implementation prompt for this story."""
|
|
90
|
+
criteria = "\n".join(f"- {c}" for c in self.acceptance_criteria)
|
|
91
|
+
prompt = f"""## User Story: {self.id} - {self.title}
|
|
92
|
+
|
|
93
|
+
{self.description}
|
|
94
|
+
|
|
95
|
+
### Acceptance Criteria:
|
|
96
|
+
{criteria}
|
|
97
|
+
"""
|
|
98
|
+
if self.notes:
|
|
99
|
+
prompt += f"""
|
|
100
|
+
### Notes from Previous Attempts:
|
|
101
|
+
{self.notes}
|
|
102
|
+
"""
|
|
103
|
+
return prompt
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class TasksFile:
|
|
108
|
+
"""Represents the complete tasks.json file."""
|
|
109
|
+
project: str
|
|
110
|
+
branch_name: str
|
|
111
|
+
description: str
|
|
112
|
+
prd_file: str
|
|
113
|
+
user_stories: list[UserStory] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_dict(cls, data: dict[str, Any]) -> "TasksFile":
|
|
117
|
+
"""Create a TasksFile from a dictionary."""
|
|
118
|
+
return cls(
|
|
119
|
+
project=data["project"],
|
|
120
|
+
branch_name=data["branchName"],
|
|
121
|
+
description=data["description"],
|
|
122
|
+
prd_file=data.get("prdFile", ""),
|
|
123
|
+
user_stories=[UserStory.from_dict(s) for s in data["userStories"]],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict[str, Any]:
|
|
127
|
+
"""Convert to dictionary for serialization."""
|
|
128
|
+
data = {
|
|
129
|
+
"project": self.project,
|
|
130
|
+
"branchName": self.branch_name,
|
|
131
|
+
"description": self.description,
|
|
132
|
+
"userStories": [s.to_dict() for s in self.user_stories],
|
|
133
|
+
}
|
|
134
|
+
if self.prd_file:
|
|
135
|
+
data["prdFile"] = self.prd_file
|
|
136
|
+
return data
|
|
137
|
+
|
|
138
|
+
def get_next_story(self, max_attempts: int = 3) -> UserStory | None:
|
|
139
|
+
"""Get the next story to implement (lowest priority that hasn't passed or blocked)."""
|
|
140
|
+
pending = [
|
|
141
|
+
s for s in self.user_stories
|
|
142
|
+
if not s.passes and not s.blocked and s.attempts < max_attempts
|
|
143
|
+
]
|
|
144
|
+
if not pending:
|
|
145
|
+
return None
|
|
146
|
+
return min(pending, key=lambda s: s.priority)
|
|
147
|
+
|
|
148
|
+
def get_progress(self) -> tuple[int, int]:
|
|
149
|
+
"""Get progress as (completed, total)."""
|
|
150
|
+
completed = sum(1 for s in self.user_stories if s.passes)
|
|
151
|
+
return completed, len(self.user_stories)
|
|
152
|
+
|
|
153
|
+
def get_progress_percent(self) -> int:
|
|
154
|
+
"""Get progress as a percentage."""
|
|
155
|
+
completed, total = self.get_progress()
|
|
156
|
+
if total == 0:
|
|
157
|
+
return 0
|
|
158
|
+
return int((completed / total) * 100)
|
|
159
|
+
|
|
160
|
+
def is_complete(self) -> bool:
|
|
161
|
+
"""Check if all stories have passed."""
|
|
162
|
+
return all(s.passes for s in self.user_stories)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class UserStoryManager:
|
|
166
|
+
"""Manages tasks.json for a project."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, project_dir: Path):
|
|
169
|
+
self.project_dir = project_dir
|
|
170
|
+
self.tasks_file_path = project_dir / "tasks.json"
|
|
171
|
+
self._tasks_file: TasksFile | None = None
|
|
172
|
+
self._load()
|
|
173
|
+
|
|
174
|
+
def _load(self) -> None:
|
|
175
|
+
"""Load tasks.json if it exists."""
|
|
176
|
+
if self.tasks_file_path.exists():
|
|
177
|
+
try:
|
|
178
|
+
with open(self.tasks_file_path, "r", encoding="utf-8") as f:
|
|
179
|
+
data = json.load(f)
|
|
180
|
+
_validate_tasks_data(data)
|
|
181
|
+
self._tasks_file = TasksFile.from_dict(data)
|
|
182
|
+
except (json.JSONDecodeError, jsonschema.ValidationError, IOError):
|
|
183
|
+
self._tasks_file = None
|
|
184
|
+
else:
|
|
185
|
+
self._tasks_file = None
|
|
186
|
+
|
|
187
|
+
def _save(self) -> None:
|
|
188
|
+
"""Save tasks.json."""
|
|
189
|
+
if self._tasks_file is None:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
data = self._tasks_file.to_dict()
|
|
193
|
+
_validate_tasks_data(data)
|
|
194
|
+
|
|
195
|
+
with open(self.tasks_file_path, "w", encoding="utf-8") as f:
|
|
196
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
197
|
+
|
|
198
|
+
def reload(self) -> None:
|
|
199
|
+
"""Reload from disk."""
|
|
200
|
+
self._load()
|
|
201
|
+
|
|
202
|
+
def has_tasks(self) -> bool:
|
|
203
|
+
"""Check if tasks.json exists and has stories."""
|
|
204
|
+
return self._tasks_file is not None and len(self._tasks_file.user_stories) > 0
|
|
205
|
+
|
|
206
|
+
def get_tasks_file(self) -> TasksFile | None:
|
|
207
|
+
"""Get the current tasks file."""
|
|
208
|
+
return self._tasks_file
|
|
209
|
+
|
|
210
|
+
def get_prd_file(self) -> str | None:
|
|
211
|
+
"""Get the source PRD file path."""
|
|
212
|
+
if self._tasks_file:
|
|
213
|
+
return self._tasks_file.prd_file
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
def create_from_json(self, tasks_json: str, prd_file: str = "") -> TasksFile:
|
|
217
|
+
"""Create tasks.json from JSON string (output from Claude)."""
|
|
218
|
+
data = json.loads(tasks_json)
|
|
219
|
+
if prd_file:
|
|
220
|
+
data["prdFile"] = prd_file
|
|
221
|
+
_validate_tasks_data(data)
|
|
222
|
+
self._tasks_file = TasksFile.from_dict(data)
|
|
223
|
+
self._save()
|
|
224
|
+
return self._tasks_file
|
|
225
|
+
|
|
226
|
+
def get_next_story(self, max_attempts: int = 3) -> UserStory | None:
|
|
227
|
+
"""Get the next story to implement."""
|
|
228
|
+
if self._tasks_file is None:
|
|
229
|
+
return None
|
|
230
|
+
return self._tasks_file.get_next_story(max_attempts)
|
|
231
|
+
|
|
232
|
+
def update_story(self, story: UserStory) -> None:
|
|
233
|
+
"""Update a story and save."""
|
|
234
|
+
if self._tasks_file is None:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
for i, s in enumerate(self._tasks_file.user_stories):
|
|
238
|
+
if s.id == story.id:
|
|
239
|
+
self._tasks_file.user_stories[i] = story
|
|
240
|
+
self._save()
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
def get_progress(self) -> tuple[int, int]:
|
|
244
|
+
"""Get progress as (completed, total)."""
|
|
245
|
+
if self._tasks_file is None:
|
|
246
|
+
return 0, 0
|
|
247
|
+
return self._tasks_file.get_progress()
|
|
248
|
+
|
|
249
|
+
def get_progress_percent(self) -> int:
|
|
250
|
+
"""Get progress as a percentage."""
|
|
251
|
+
if self._tasks_file is None:
|
|
252
|
+
return 0
|
|
253
|
+
return self._tasks_file.get_progress_percent()
|
|
254
|
+
|
|
255
|
+
def is_complete(self) -> bool:
|
|
256
|
+
"""Check if all stories have passed."""
|
|
257
|
+
if self._tasks_file is None:
|
|
258
|
+
return False
|
|
259
|
+
return self._tasks_file.is_complete()
|
|
260
|
+
|
|
261
|
+
def get_branch_name(self) -> str | None:
|
|
262
|
+
"""Get the branch name for this feature."""
|
|
263
|
+
if self._tasks_file:
|
|
264
|
+
return self._tasks_file.branch_name
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def clear(self) -> None:
|
|
268
|
+
"""Remove tasks.json."""
|
|
269
|
+
if self.tasks_file_path.exists():
|
|
270
|
+
self.tasks_file_path.unlink()
|
|
271
|
+
self._tasks_file = None
|
|
272
|
+
|
|
273
|
+
def archive(self, archive_dir: Path) -> None:
|
|
274
|
+
"""Archive the current tasks.json to a directory."""
|
|
275
|
+
if not self.tasks_file_path.exists():
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
archive_path = archive_dir / "tasks.json"
|
|
280
|
+
|
|
281
|
+
# Copy tasks.json to archive
|
|
282
|
+
import shutil
|
|
283
|
+
shutil.copy(self.tasks_file_path, archive_path)
|