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