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/prd_manager.py ADDED
@@ -0,0 +1,348 @@
1
+ """PRD (Product Requirements Document) management for ralph-coding.
2
+
3
+ PRDs are stored as files in the PRD folder:
4
+ - .txt files = unspecced tasks (simple descriptions)
5
+ - .md files = specced PRDs (full specifications)
6
+ """
7
+
8
+ import json
9
+ import re
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ PRDStatus = Literal["unspecced", "pending", "questions", "in_progress", "completed", "errored"]
17
+
18
+
19
+ @dataclass
20
+ class PRDQuestion:
21
+ """Represents an open question in a PRD."""
22
+ question: str
23
+ options: list[str] | None = None # Multi-choice options if any
24
+ answer: str | None = None
25
+
26
+
27
+ def slugify(text: str) -> str:
28
+ """Convert text to kebab-case slug for filenames."""
29
+ # Lowercase and replace spaces/underscores with hyphens
30
+ slug = text.lower().strip()
31
+ slug = re.sub(r'[_\s]+', '-', slug)
32
+ # Remove non-alphanumeric characters except hyphens
33
+ slug = re.sub(r'[^a-z0-9-]', '', slug)
34
+ # Remove multiple consecutive hyphens
35
+ slug = re.sub(r'-+', '-', slug)
36
+ # Remove leading/trailing hyphens
37
+ slug = slug.strip('-')
38
+ return slug[:50] # Limit length
39
+
40
+
41
+ @dataclass
42
+ class PRD:
43
+ """Represents a Product Requirements Document."""
44
+ id: str
45
+ name: str
46
+ file_path: Path
47
+ is_specced: bool
48
+ status: PRDStatus = "pending"
49
+ description: str = ""
50
+ created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
51
+ started_at: str | None = None
52
+ completed_at: str | None = None
53
+ questions: list[PRDQuestion] = field(default_factory=list)
54
+
55
+ @classmethod
56
+ def from_txt_file(cls, file_path: Path) -> "PRD":
57
+ """Create an unspecced PRD from a .txt file."""
58
+ content = file_path.read_text().strip()
59
+ name = file_path.stem # filename without extension
60
+ return cls(
61
+ id=str(uuid.uuid4()),
62
+ name=name,
63
+ file_path=file_path,
64
+ is_specced=False,
65
+ status="unspecced",
66
+ description=content,
67
+ )
68
+
69
+ @classmethod
70
+ def from_md_file(cls, file_path: Path) -> "PRD":
71
+ """Create a specced PRD from a .md file."""
72
+ content = file_path.read_text()
73
+ name = file_path.stem
74
+
75
+ # Try to extract title from first heading
76
+ title_match = re.search(r'^#\s+(?:PRD:\s*)?(.+)$', content, re.MULTILINE)
77
+ if title_match:
78
+ name = title_match.group(1).strip()
79
+
80
+ # Parse the State section
81
+ status: PRDStatus = "pending"
82
+ questions: list[PRDQuestion] = []
83
+
84
+ # Look for ## State section
85
+ state_match = re.search(r'^## State\s*\n(.*?)(?=^## |\Z)', content, re.MULTILINE | re.DOTALL)
86
+ if state_match:
87
+ state_section = state_match.group(1).strip()
88
+
89
+ # Check state value
90
+ if "ready to implement" in state_section.lower():
91
+ status = "pending"
92
+ elif "open questions" in state_section.lower():
93
+ status = "questions"
94
+ # Parse questions (lines after the state indicator)
95
+ lines = state_section.split('\n')
96
+ for line in lines[1:]: # Skip the "- Open Questions" line
97
+ line = line.strip()
98
+ if not line or line.startswith('-'):
99
+ continue
100
+
101
+ # Check for JSON options at end of line
102
+ json_match = re.search(r'\[.*\]\s*$', line)
103
+ if json_match:
104
+ try:
105
+ options = json.loads(json_match.group())
106
+ question_text = line[:json_match.start()].strip()
107
+ questions.append(PRDQuestion(question=question_text, options=options))
108
+ except json.JSONDecodeError:
109
+ questions.append(PRDQuestion(question=line))
110
+ else:
111
+ questions.append(PRDQuestion(question=line))
112
+
113
+ # Check for status markers (legacy/override)
114
+ if "<!-- STATUS: completed -->" in content:
115
+ status = "completed"
116
+ elif "<!-- STATUS: in_progress -->" in content:
117
+ status = "in_progress"
118
+ elif "<!-- STATUS: errored -->" in content:
119
+ status = "errored"
120
+
121
+ return cls(
122
+ id=str(uuid.uuid4()),
123
+ name=name,
124
+ file_path=file_path,
125
+ is_specced=True,
126
+ status=status,
127
+ description=content[:500], # First 500 chars as description
128
+ questions=questions,
129
+ )
130
+
131
+ def start(self) -> None:
132
+ """Mark the PRD as in progress."""
133
+ self.status = "in_progress"
134
+ self.started_at = datetime.utcnow().isoformat()
135
+
136
+ def complete(self) -> None:
137
+ """Mark the PRD as completed."""
138
+ self.status = "completed"
139
+ self.completed_at = datetime.utcnow().isoformat()
140
+
141
+ def error(self, reason: str = "") -> None:
142
+ """Mark the PRD as errored."""
143
+ self.status = "errored"
144
+
145
+ @property
146
+ def content(self) -> str:
147
+ """Get the full content of the PRD file."""
148
+ if self.file_path.exists():
149
+ return self.file_path.read_text()
150
+ return self.description
151
+
152
+
153
+ class PRDManager:
154
+ """Manages PRD files in the PRD folder."""
155
+
156
+ def __init__(self, project_dir: Path):
157
+ self.project_dir = project_dir
158
+ self.prd_dir = project_dir / "PRD"
159
+ self._ensure_prd_dir()
160
+ self._prds: list[PRD] = []
161
+ self._load()
162
+
163
+ def _ensure_prd_dir(self) -> None:
164
+ """Ensure PRD directory exists."""
165
+ self.prd_dir.mkdir(parents=True, exist_ok=True)
166
+
167
+ def _load(self) -> None:
168
+ """Load all PRDs from the PRD folder."""
169
+ self._prds = []
170
+
171
+ # Load unspecced tasks (.txt files)
172
+ for txt_file in self.prd_dir.glob("*.txt"):
173
+ self._prds.append(PRD.from_txt_file(txt_file))
174
+
175
+ # Load specced PRDs (.md files)
176
+ for md_file in self.prd_dir.glob("*.md"):
177
+ self._prds.append(PRD.from_md_file(md_file))
178
+
179
+ def reload(self) -> None:
180
+ """Reload PRDs from disk."""
181
+ self._load()
182
+
183
+ def get_all_prds(self) -> list[PRD]:
184
+ """Get all PRDs."""
185
+ return list(self._prds)
186
+
187
+ def get_unspecced_prds(self) -> list[PRD]:
188
+ """Get all unspecced PRDs (.txt files)."""
189
+ return [p for p in self._prds if not p.is_specced]
190
+
191
+ def get_specced_prds(self) -> list[PRD]:
192
+ """Get all specced PRDs (.md files)."""
193
+ return [p for p in self._prds if p.is_specced]
194
+
195
+ def get_pending_prds(self) -> list[PRD]:
196
+ """Get specced PRDs that are pending implementation."""
197
+ return [p for p in self._prds if p.is_specced and p.status == "pending"]
198
+
199
+ def get_in_progress_prd(self) -> PRD | None:
200
+ """Get the currently in-progress PRD."""
201
+ for prd in self._prds:
202
+ if prd.status == "in_progress":
203
+ return prd
204
+ return None
205
+
206
+ def get_completed_prds(self) -> list[PRD]:
207
+ """Get all completed PRDs."""
208
+ return [p for p in self._prds if p.status == "completed"]
209
+
210
+ def get_errored_prds(self) -> list[PRD]:
211
+ """Get all errored PRDs."""
212
+ return [p for p in self._prds if p.status == "errored"]
213
+
214
+ def get_questions_prds(self) -> list[PRD]:
215
+ """Get PRDs that have open questions."""
216
+ return [p for p in self._prds if p.status == "questions"]
217
+
218
+ def get_prd_by_id(self, prd_id: str) -> PRD | None:
219
+ """Get a PRD by its ID."""
220
+ for prd in self._prds:
221
+ if prd.id == prd_id:
222
+ return prd
223
+ return None
224
+
225
+ def spec_prd(self, prd: PRD, prd_content: str, new_name: str | None = None) -> PRD:
226
+ """
227
+ Convert an unspecced PRD (.txt) to a specced PRD (.md).
228
+
229
+ Args:
230
+ prd: The unspecced PRD to convert
231
+ prd_content: The full PRD content in markdown
232
+ new_name: Optional new name for the PRD file (will be slugified)
233
+
234
+ Returns:
235
+ The new specced PRD
236
+ """
237
+ if prd.is_specced:
238
+ raise ValueError("PRD is already specced")
239
+
240
+ # Determine new filename
241
+ if new_name:
242
+ slug = slugify(new_name)
243
+ else:
244
+ # Try to extract name from PRD content
245
+ title_match = re.search(r'^#\s+(?:PRD:\s*)?(.+)$', prd_content, re.MULTILINE)
246
+ if title_match:
247
+ slug = slugify(title_match.group(1))
248
+ else:
249
+ slug = slugify(prd.name)
250
+
251
+ new_file = self.prd_dir / f"{slug}.md"
252
+
253
+ # Handle name collision
254
+ counter = 1
255
+ while new_file.exists():
256
+ new_file = self.prd_dir / f"{slug}-{counter}.md"
257
+ counter += 1
258
+
259
+ # Write the new .md file
260
+ new_file.write_text(prd_content)
261
+
262
+ # Delete the old .txt file
263
+ if prd.file_path.exists() and prd.file_path.suffix == ".txt":
264
+ prd.file_path.unlink()
265
+
266
+ # Reload to pick up changes
267
+ self._load()
268
+
269
+ # Return the new PRD
270
+ for p in self._prds:
271
+ if p.file_path == new_file:
272
+ return p
273
+
274
+ raise RuntimeError("Failed to find newly created PRD")
275
+
276
+ def update_prd_status(self, prd: PRD, status: PRDStatus) -> None:
277
+ """Update a PRD's status by modifying its file."""
278
+ if not prd.is_specced:
279
+ return # Can't update status of unspecced PRDs
280
+
281
+ content = prd.file_path.read_text()
282
+
283
+ # Remove any existing status line
284
+ content = re.sub(r'\n*<!-- STATUS: \w+ -->\n*', '\n', content)
285
+
286
+ # Add status at the end
287
+ content = content.rstrip() + f"\n\n<!-- STATUS: {status} -->\n"
288
+
289
+ prd.file_path.write_text(content)
290
+ prd.status = status
291
+
292
+ def answer_prd_questions(self, prd: PRD, answers: list[str]) -> list[tuple[str, str]]:
293
+ """
294
+ Update a PRD with answers to its questions.
295
+
296
+ Args:
297
+ prd: The PRD with questions
298
+ answers: List of answers corresponding to each question
299
+
300
+ Returns:
301
+ List of (question, answer) tuples that might be useful as learnings
302
+ """
303
+ if not prd.is_specced or prd.status != "questions":
304
+ return []
305
+
306
+ content = prd.file_path.read_text()
307
+
308
+ # Build the answered questions section
309
+ answered_lines = []
310
+ learnings = []
311
+
312
+ for i, question in enumerate(prd.questions):
313
+ answer = answers[i] if i < len(answers) else ""
314
+ question.answer = answer
315
+ answered_lines.append(f"Q: {question.question}")
316
+ answered_lines.append(f"A: {answer}")
317
+ answered_lines.append("")
318
+ learnings.append((question.question, answer))
319
+
320
+ # Replace the State section with "Ready to Implement" and answered questions
321
+ new_state = "## State\n- Ready to Implement\n\n### Answered Questions\n" + "\n".join(answered_lines)
322
+
323
+ # Find and replace the State section
324
+ state_pattern = r'^## State\s*\n(.*?)(?=^## |\Z)'
325
+ if re.search(state_pattern, content, re.MULTILINE | re.DOTALL):
326
+ content = re.sub(state_pattern, new_state + "\n", content, flags=re.MULTILINE | re.DOTALL)
327
+ else:
328
+ # Insert State section after first heading
329
+ first_heading_end = content.find('\n', content.find('#'))
330
+ if first_heading_end != -1:
331
+ content = content[:first_heading_end + 1] + "\n" + new_state + "\n" + content[first_heading_end + 1:]
332
+
333
+ prd.file_path.write_text(content)
334
+ prd.status = "pending"
335
+ prd.questions = []
336
+
337
+ return learnings
338
+
339
+ def get_stats(self) -> dict[str, int]:
340
+ """Get PRD statistics."""
341
+ return {
342
+ "unspecced": len(self.get_unspecced_prds()),
343
+ "pending": len(self.get_pending_prds()),
344
+ "questions": len(self.get_questions_prds()),
345
+ "in_progress": 1 if self.get_in_progress_prd() else 0,
346
+ "completed": len(self.get_completed_prds()),
347
+ "errored": len(self.get_errored_prds()),
348
+ }
@@ -0,0 +1,95 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Ralph Tasks Schema",
4
+ "description": "Schema for ralph tasks.json - user stories derived from PRDs",
5
+ "type": "object",
6
+ "required": ["project", "branchName", "description", "userStories"],
7
+ "properties": {
8
+ "project": {
9
+ "type": "string",
10
+ "minLength": 1,
11
+ "description": "Project name"
12
+ },
13
+ "branchName": {
14
+ "type": "string",
15
+ "pattern": "^ralph/[a-z0-9-]+$",
16
+ "description": "Git branch name for this feature (ralph/feature-name)"
17
+ },
18
+ "description": {
19
+ "type": "string",
20
+ "description": "Feature description from PRD"
21
+ },
22
+ "prdFile": {
23
+ "type": "string",
24
+ "description": "Path to the source PRD file"
25
+ },
26
+ "userStories": {
27
+ "type": "array",
28
+ "items": {
29
+ "$ref": "#/definitions/userStory"
30
+ },
31
+ "description": "List of user stories to implement"
32
+ }
33
+ },
34
+ "definitions": {
35
+ "userStory": {
36
+ "type": "object",
37
+ "required": ["id", "title", "description", "acceptanceCriteria", "priority", "passes"],
38
+ "properties": {
39
+ "id": {
40
+ "type": "string",
41
+ "pattern": "^US-[0-9]{3}$",
42
+ "description": "Story ID in format US-XXX"
43
+ },
44
+ "title": {
45
+ "type": "string",
46
+ "minLength": 1,
47
+ "description": "Short descriptive title"
48
+ },
49
+ "description": {
50
+ "type": "string",
51
+ "description": "User story in format: As a [user], I want [feature] so that [benefit]"
52
+ },
53
+ "acceptanceCriteria": {
54
+ "type": "array",
55
+ "items": {
56
+ "type": "string"
57
+ },
58
+ "minItems": 1,
59
+ "description": "Verifiable acceptance criteria"
60
+ },
61
+ "priority": {
62
+ "type": "integer",
63
+ "minimum": 1,
64
+ "description": "Execution priority (1 = first)"
65
+ },
66
+ "passes": {
67
+ "type": "boolean",
68
+ "description": "Whether this story has passed acceptance criteria"
69
+ },
70
+ "notes": {
71
+ "type": "string",
72
+ "default": "",
73
+ "description": "Notes from implementation attempts"
74
+ },
75
+ "attempts": {
76
+ "type": "integer",
77
+ "default": 0,
78
+ "description": "Number of implementation attempts"
79
+ },
80
+ "blocked": {
81
+ "type": "boolean",
82
+ "default": false,
83
+ "description": "Whether this story is blocked due to exceeding max attempts"
84
+ },
85
+ "needsIntervention": {
86
+ "type": "boolean",
87
+ "default": false,
88
+ "description": "Whether this story needs human intervention to resolve"
89
+ }
90
+ },
91
+ "additionalProperties": false
92
+ }
93
+ },
94
+ "additionalProperties": false
95
+ }
@@ -0,0 +1,92 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Ralph Task Schema",
4
+ "description": "Schema for validating ralph task specifications",
5
+ "definitions": {
6
+ "thin_task": {
7
+ "type": "string",
8
+ "minLength": 1,
9
+ "description": "A simple task description string"
10
+ },
11
+ "full_task": {
12
+ "type": "object",
13
+ "required": ["id", "name", "status", "created_at"],
14
+ "properties": {
15
+ "id": {
16
+ "type": "string",
17
+ "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
18
+ "description": "UUID identifier for the task"
19
+ },
20
+ "name": {
21
+ "type": "string",
22
+ "minLength": 1,
23
+ "description": "Short name/title for the task"
24
+ },
25
+ "description": {
26
+ "type": "string",
27
+ "description": "Detailed description of what the task involves"
28
+ },
29
+ "status": {
30
+ "type": "string",
31
+ "enum": ["pending", "in_progress", "completed", "errored"],
32
+ "description": "Current status of the task"
33
+ },
34
+ "prerequisites": {
35
+ "type": "array",
36
+ "items": {
37
+ "type": "string"
38
+ },
39
+ "description": "List of task IDs that must be completed before this task"
40
+ },
41
+ "acceptance_criteria": {
42
+ "type": "array",
43
+ "items": {
44
+ "type": "string"
45
+ },
46
+ "description": "List of criteria that must be met for the task to be considered complete"
47
+ },
48
+ "files_to_modify": {
49
+ "type": "array",
50
+ "items": {
51
+ "type": "string"
52
+ },
53
+ "description": "List of files that will need to be created or modified"
54
+ },
55
+ "notes": {
56
+ "type": "string",
57
+ "description": "Additional notes or context about the task"
58
+ },
59
+ "created_at": {
60
+ "type": "string",
61
+ "format": "date-time",
62
+ "description": "ISO datetime when the task was created"
63
+ },
64
+ "started_at": {
65
+ "type": ["string", "null"],
66
+ "format": "date-time",
67
+ "description": "ISO datetime when work on the task began"
68
+ },
69
+ "completed_at": {
70
+ "type": ["string", "null"],
71
+ "format": "date-time",
72
+ "description": "ISO datetime when the task was completed"
73
+ }
74
+ },
75
+ "additionalProperties": false
76
+ }
77
+ },
78
+ "type": "object",
79
+ "required": ["tasks"],
80
+ "properties": {
81
+ "tasks": {
82
+ "type": "array",
83
+ "items": {
84
+ "oneOf": [
85
+ { "$ref": "#/definitions/thin_task" },
86
+ { "$ref": "#/definitions/full_task" }
87
+ ]
88
+ },
89
+ "description": "List of tasks (can be thin tasks or full specs)"
90
+ }
91
+ }
92
+ }