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/workflow.py ADDED
@@ -0,0 +1,1036 @@
1
+ """Workflow engine (state machine) for ralph-coding application."""
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Callable
10
+
11
+ from .harness_runner import HarnessRunner
12
+ from .config import get_config
13
+ from .git_manager import GitManager, GitError
14
+ from .prd_manager import PRD, PRDManager
15
+ from .spinner import SpinnerManager
16
+ from .user_stories import UserStory, UserStoryManager
17
+ from .storage import (
18
+ get_project_state_path,
19
+ get_progress_md_path,
20
+ get_learnings_md_path,
21
+ get_summarised_notes_path,
22
+ )
23
+
24
+
25
+ class WorkflowState(Enum):
26
+ """States in the workflow state machine."""
27
+ IDLE = "idle"
28
+ SPECCING = "speccing"
29
+ QUESTIONS = "questions" # PRD has open questions needing user input
30
+ CONVERTING = "converting" # Converting PRD to tasks.json
31
+ PICKING = "picking"
32
+ IMPLEMENTING = "implementing"
33
+ REVIEWING = "reviewing"
34
+ TESTING = "testing"
35
+ COMMITTING = "committing"
36
+ ARCHIVING = "archiving"
37
+ PAUSED = "paused"
38
+ ERROR = "error"
39
+
40
+
41
+ class VerificationStatus(Enum):
42
+ """Result of story verification."""
43
+ PASSES = "passes" # All acceptance criteria verifiably met
44
+ FAILS = "fails" # Implementation is wrong/incomplete
45
+ BLOCKED = "blocked" # Criteria cannot be verified due to external factors
46
+
47
+
48
+ # States that should display a spinner during execution
49
+ SPINNER_STATES: set[WorkflowState] = {
50
+ WorkflowState.SPECCING,
51
+ WorkflowState.CONVERTING,
52
+ WorkflowState.IMPLEMENTING,
53
+ WorkflowState.TESTING,
54
+ WorkflowState.COMMITTING,
55
+ WorkflowState.ARCHIVING,
56
+ }
57
+
58
+ # Default spinner messages for each state
59
+ SPINNER_MESSAGES: dict[WorkflowState, str] = {
60
+ WorkflowState.SPECCING: "Creating PRD specification...",
61
+ WorkflowState.CONVERTING: "Converting PRD to tasks...",
62
+ WorkflowState.IMPLEMENTING: "Implementing user story...",
63
+ WorkflowState.TESTING: "Running tests...",
64
+ WorkflowState.COMMITTING: "Committing changes...",
65
+ WorkflowState.ARCHIVING: "Archiving PRD...",
66
+ }
67
+
68
+
69
+ class WorkflowEngine:
70
+ """
71
+ Main workflow engine that orchestrates task execution.
72
+
73
+ Workflow:
74
+ 1. SPECCING: Convert .txt files to .md PRDs
75
+ 2. QUESTIONS: Pause if PRDs have open questions
76
+ 3. CONVERTING: Convert picked PRD to tasks.json
77
+ 4. IMPLEMENTING: Pick and implement user stories one at a time
78
+ 5. REVIEWING: Verify story implementation
79
+ 6. COMMITTING: Commit passing stories
80
+ 7. ARCHIVING: Archive completed PRDs
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ project_dir: Path,
86
+ debug: bool = False,
87
+ on_state_change: Callable[[WorkflowState, str], None] | None = None,
88
+ on_output: Callable[[str], None] | None = None,
89
+ use_spinner: bool = True,
90
+ ):
91
+ self.project_dir = project_dir
92
+ self.debug = debug
93
+ self.on_state_change = on_state_change
94
+ self.on_output = on_output
95
+ self._use_spinner = use_spinner
96
+
97
+ self._config = get_config()
98
+ self._prd_manager = PRDManager(project_dir)
99
+ self._story_manager = UserStoryManager(project_dir)
100
+ self._git_manager = GitManager(project_dir)
101
+ self._harness_runner = HarnessRunner(project_dir, debug=debug, on_output=on_output)
102
+
103
+ self._state = WorkflowState.IDLE
104
+ self._current_prd: PRD | None = None
105
+ self._current_story: UserStory | None = None
106
+ self._iteration = 0
107
+ self._paused = False
108
+ self._error_message = ""
109
+ self._completed_this_session = 0
110
+ self._spinner: SpinnerManager | None = None
111
+
112
+ self._load_state()
113
+
114
+ def _load_state(self) -> None:
115
+ """Load workflow state from disk."""
116
+ state_path = get_project_state_path(self.project_dir)
117
+ if state_path.exists():
118
+ try:
119
+ with open(state_path, "r", encoding="utf-8") as f:
120
+ data = json.load(f)
121
+ self._state = WorkflowState(data.get("state", "idle"))
122
+ self._iteration = data.get("iteration", 0)
123
+ self._paused = data.get("paused", False)
124
+ self._error_message = data.get("error_message", "")
125
+ if prd_id := data.get("current_prd_id"):
126
+ self._current_prd = self._prd_manager.get_prd_by_id(prd_id)
127
+ # Ensure paused flag is consistent with state
128
+ if self._state == WorkflowState.PAUSED:
129
+ self._paused = True
130
+ except (json.JSONDecodeError, IOError, ValueError):
131
+ pass
132
+
133
+ def _save_state(self) -> None:
134
+ """Save workflow state to disk."""
135
+ state_path = get_project_state_path(self.project_dir)
136
+ data = {
137
+ "state": self._state.value,
138
+ "iteration": self._iteration,
139
+ "current_prd_id": self._current_prd.id if self._current_prd else None,
140
+ "paused": self._paused,
141
+ "error_message": self._error_message,
142
+ "timestamp": datetime.utcnow().isoformat(),
143
+ }
144
+ with open(state_path, "w", encoding="utf-8") as f:
145
+ json.dump(data, f, indent=2, ensure_ascii=False)
146
+
147
+ def _set_state(self, new_state: WorkflowState, message: str = "") -> None:
148
+ """Update the workflow state and manage spinner display."""
149
+ old_state = self._state
150
+ self._state = new_state
151
+ self._save_state()
152
+
153
+ # Handle spinner for loading states
154
+ self._update_spinner(old_state, new_state, message)
155
+
156
+ if self.on_state_change:
157
+ self.on_state_change(new_state, message)
158
+
159
+ def _update_spinner(
160
+ self, old_state: WorkflowState, new_state: WorkflowState, message: str
161
+ ) -> None:
162
+ """Start or stop spinner based on state transition.
163
+
164
+ Args:
165
+ old_state: The previous workflow state.
166
+ new_state: The new workflow state.
167
+ message: Optional custom message for the spinner.
168
+ """
169
+ if not self._use_spinner:
170
+ return
171
+
172
+ should_show_spinner = (not self._paused) and (new_state in SPINNER_STATES)
173
+
174
+ if should_show_spinner:
175
+ # Determine spinner message
176
+ spinner_message = message if message else SPINNER_MESSAGES.get(
177
+ new_state, f"{new_state.value.capitalize()}..."
178
+ )
179
+
180
+ if self._spinner is None:
181
+ # Create and start new spinner
182
+ self._spinner = SpinnerManager(message=spinner_message)
183
+ self._spinner.start()
184
+ else:
185
+ # Update existing spinner message
186
+ self._spinner.update_message(spinner_message)
187
+ elif self._spinner is not None:
188
+ # Transitioning out of spinner state or paused - stop spinner
189
+ self._stop_spinner()
190
+
191
+ def _stop_spinner(self) -> None:
192
+ """Stop the spinner if it's running."""
193
+ if self._spinner is not None:
194
+ self._spinner.stop()
195
+ self._spinner = None
196
+
197
+ def _output(self, message: str) -> None:
198
+ """Send output to the callback."""
199
+ if self.on_output:
200
+ self.on_output(message)
201
+
202
+ @property
203
+ def state(self) -> WorkflowState:
204
+ """Get the current workflow state."""
205
+ return self._state
206
+
207
+ @property
208
+ def current_task(self) -> PRD | None:
209
+ """Get the current PRD being worked on."""
210
+ return self._current_prd
211
+
212
+ @property
213
+ def current_prd(self) -> PRD | None:
214
+ """Get the current PRD being worked on."""
215
+ return self._current_prd
216
+
217
+ @property
218
+ def current_story(self) -> UserStory | None:
219
+ """Get the current user story being worked on."""
220
+ return self._current_story
221
+
222
+ @property
223
+ def prd_manager(self) -> PRDManager:
224
+ """Get the PRD manager."""
225
+ return self._prd_manager
226
+
227
+ @property
228
+ def story_manager(self) -> UserStoryManager:
229
+ """Get the user story manager."""
230
+ return self._story_manager
231
+
232
+ @property
233
+ def completed_this_session(self) -> int:
234
+ """Get the number of PRDs completed this session."""
235
+ return self._completed_this_session
236
+
237
+ @property
238
+ def is_paused(self) -> bool:
239
+ """Check if the workflow is paused."""
240
+ return self._paused
241
+
242
+ @property
243
+ def error_message(self) -> str:
244
+ """Get the current error message, if any."""
245
+ return self._error_message
246
+
247
+ def pause(self) -> None:
248
+ """Pause the workflow."""
249
+ self._paused = True
250
+ self._set_state(WorkflowState.PAUSED, "Paused by user")
251
+
252
+ def resume(self) -> None:
253
+ """Resume the workflow."""
254
+ self._paused = False
255
+ self._error_message = ""
256
+ self._iteration = 0 # Reset iteration counter on resume
257
+ if self._state in (WorkflowState.PAUSED, WorkflowState.ERROR):
258
+ self._set_state(WorkflowState.IDLE, "Resumed")
259
+
260
+ def _ensure_git_branch(self) -> None:
261
+ """Ensure we're on the correct git branch."""
262
+ # Use branch from tasks.json
263
+ branch_name = self._story_manager.get_branch_name()
264
+ if branch_name:
265
+ self._git_manager.ensure_on_branch(branch_name)
266
+
267
+ def _get_learnings(self) -> str:
268
+ """Read learnings.md content if it exists."""
269
+ learnings_path = get_learnings_md_path(self.project_dir)
270
+ if learnings_path.exists():
271
+ return learnings_path.read_text()
272
+ return ""
273
+
274
+ def _append_learnings(self, learnings: list[tuple[str, str]]) -> None:
275
+ """Append new learnings to learnings.md."""
276
+ if not learnings:
277
+ return
278
+
279
+ learnings_path = get_learnings_md_path(self.project_dir)
280
+ timestamp = datetime.now().strftime("%Y-%m-%d")
281
+
282
+ content = ""
283
+ if learnings_path.exists():
284
+ content = learnings_path.read_text()
285
+ else:
286
+ content = "# Project Learnings\n\n"
287
+
288
+ content += f"\n## {timestamp}\n\n"
289
+ for question, answer in learnings:
290
+ content += f"**{question}**\n{answer}\n\n"
291
+
292
+ learnings_path.write_text(content)
293
+
294
+ def _filter_prd_learnings(self, prd: PRD) -> None:
295
+ """Filter out PRD-scoped learnings after a PRD is completed."""
296
+ learnings_path = get_learnings_md_path(self.project_dir)
297
+ if not learnings_path.exists():
298
+ return
299
+
300
+ current_learnings = learnings_path.read_text()
301
+ if not current_learnings.strip() or current_learnings.strip() == "# Project Learnings":
302
+ return
303
+
304
+ self._output("Filtering PRD-scoped learnings...")
305
+ response = self._harness_runner.filter_learnings_after_prd(prd.name, current_learnings)
306
+
307
+ if response.success and response.output.strip():
308
+ filtered = response.output.strip()
309
+ # Remove markdown code fence if present
310
+ if filtered.startswith("```"):
311
+ filtered = re.sub(r'^```\w*\n?', '', filtered)
312
+ filtered = re.sub(r'\n?```$', '', filtered)
313
+ learnings_path.write_text(filtered)
314
+
315
+ def _update_progress(self, story: UserStory, status: str, notes: str = "") -> None:
316
+ """Update progress.md with story progress.
317
+
318
+ For BLOCKED or FAILED status, stores full structured notes to inform
319
+ future iterations about what passed, what couldn't be verified, and
320
+ what actions are needed to complete.
321
+ """
322
+ progress_path = get_progress_md_path(self.project_dir)
323
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
324
+
325
+ entry = f"- [{timestamp}] {story.id}: {status}"
326
+ if notes:
327
+ # Store full notes for BLOCKED/FAILED to inform next iteration
328
+ # Use newlines for readability in multi-line structured notes
329
+ if status in ("BLOCKED", "FAILED") and "\n" in notes:
330
+ entry += f" - {notes}"
331
+ else:
332
+ entry += f" - {notes}"
333
+ entry += "\n"
334
+
335
+ if progress_path.exists():
336
+ content = progress_path.read_text()
337
+ else:
338
+ content = "# Progress\n\n"
339
+
340
+ content += entry
341
+ progress_path.write_text(content)
342
+
343
+ def _handle_first_blocked(self, story: UserStory, max_attempts: int) -> None:
344
+ """Handle a story that has just reached max attempts for the first time.
345
+
346
+ Uses a mid-tier model to analyze the failure and determine if:
347
+ 1. The story can be retried with adjustments (gets one more attempt)
348
+ 2. The story needs human intervention
349
+
350
+ Args:
351
+ story: The user story that reached max attempts.
352
+ max_attempts: The configured max attempts value.
353
+ """
354
+ self._output(f"Story {story.id} reached max attempts. Analyzing...")
355
+
356
+ # Archive full notes if debug mode is enabled
357
+ full_notes = story.notes
358
+ if self.debug:
359
+ self._archive_full_notes(story, full_notes, "") # Summary added later
360
+
361
+ # Get project context
362
+ project_context = self._harness_runner.detect_project_context()
363
+
364
+ # Get git diff
365
+ git_diff = self._git_manager.get_diff()
366
+
367
+ # Analyze the blocked story
368
+ response = self._harness_runner.analyze_blocked_story(
369
+ story_prompt=story.get_prompt(),
370
+ full_notes=full_notes,
371
+ git_diff=git_diff,
372
+ project_context=project_context,
373
+ )
374
+
375
+ if not response.success:
376
+ # Analysis failed - fall back to simple blocking
377
+ self._output(f"Analysis failed: {response.error}. Marking as blocked.")
378
+ story.blocked = True
379
+ self._story_manager.update_story(story)
380
+ self._update_progress(story, "BLOCKED", f"Analysis failed: {response.error}")
381
+ return
382
+
383
+ # Parse the response
384
+ output = response.output
385
+ action = self._extract_action_from_analysis(output)
386
+ summary = self._extract_summary_from_analysis(output)
387
+ recommendations = self._extract_recommendations_from_analysis(output)
388
+
389
+ # Update archived notes with summary (debug mode only)
390
+ if self.debug and summary:
391
+ self._archive_full_notes(story, full_notes, summary)
392
+
393
+ if action == "RETRY":
394
+ # Give the story one more attempt
395
+ story.attempts = max_attempts - 1 # Decrement to allow one more retry
396
+ # Replace notes with summary + recommendations
397
+ story.notes = f"ANALYSIS SUMMARY:\n{summary}\n\nRECOMMENDATIONS:\n{recommendations}"
398
+ self._output(f"Story {story.id} will retry with analysis recommendations")
399
+ self._story_manager.update_story(story)
400
+ self._update_progress(story, "RETRY_WITH_ANALYSIS", summary)
401
+ else:
402
+ # Needs human intervention
403
+ story.blocked = True
404
+ story.needs_intervention = True
405
+ # Replace notes with summary
406
+ story.notes = f"ANALYSIS SUMMARY:\n{summary}\n\nINTERVENTION NEEDED:\n{self._extract_intervention_reason(output)}"
407
+ self._output(f"Story {story.id} needs human intervention")
408
+ self._story_manager.update_story(story)
409
+ self._update_progress(story, "NEEDS_INTERVENTION", summary)
410
+
411
+ def _archive_full_notes(self, story: UserStory, full_notes: str, summary: str) -> None:
412
+ """Archive full notes to summarised_notes.txt (debug mode only).
413
+
414
+ Args:
415
+ story: The user story being archived.
416
+ full_notes: The complete notes from all attempts.
417
+ summary: The analysis summary (may be empty on first call).
418
+ """
419
+ notes_path = get_summarised_notes_path(self.project_dir)
420
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
421
+
422
+ entry = f"""
423
+ {'='*60}
424
+ Story: {story.id} - {story.title}
425
+ Timestamp: {timestamp}
426
+ {'='*60}
427
+
428
+ FULL NOTES:
429
+ {full_notes}
430
+
431
+ """
432
+ if summary:
433
+ entry += f"""ANALYSIS SUMMARY:
434
+ {summary}
435
+
436
+ """
437
+
438
+ # Append to file (create if doesn't exist)
439
+ with open(notes_path, "a", encoding="utf-8") as f:
440
+ f.write(entry)
441
+
442
+ def _extract_action_from_analysis(self, output: str) -> str:
443
+ """Extract the ACTION from analysis output.
444
+
445
+ Args:
446
+ output: The full analysis output from the harness.
447
+
448
+ Returns:
449
+ 'RETRY' or 'NEEDS_INTERVENTION'
450
+ """
451
+ output_upper = output.upper()
452
+ if "ACTION: RETRY" in output_upper:
453
+ return "RETRY"
454
+ elif "ACTION: NEEDS_INTERVENTION" in output_upper:
455
+ return "NEEDS_INTERVENTION"
456
+ # Default to intervention if unclear
457
+ return "NEEDS_INTERVENTION"
458
+
459
+ def _extract_summary_from_analysis(self, output: str) -> str:
460
+ """Extract the SUMMARY section from analysis output.
461
+
462
+ Args:
463
+ output: The full analysis output from the harness.
464
+
465
+ Returns:
466
+ The summary text, or empty string if not found.
467
+ """
468
+ if "SUMMARY:" not in output:
469
+ return ""
470
+
471
+ # Find the start of SUMMARY section
472
+ start = output.find("SUMMARY:") + len("SUMMARY:")
473
+
474
+ # Find the end (next section marker or end of string)
475
+ end = len(output)
476
+ for marker in ["ANALYSIS:", "RECOMMENDATIONS:", "INTERVENTION_REASON:"]:
477
+ pos = output.find(marker, start)
478
+ if pos != -1 and pos < end:
479
+ end = pos
480
+
481
+ return output[start:end].strip()
482
+
483
+ def _extract_recommendations_from_analysis(self, output: str) -> str:
484
+ """Extract the RECOMMENDATIONS section from analysis output.
485
+
486
+ Args:
487
+ output: The full analysis output from the harness.
488
+
489
+ Returns:
490
+ The recommendations text, or empty string if not found.
491
+ """
492
+ if "RECOMMENDATIONS:" not in output:
493
+ return ""
494
+
495
+ # Find the start of RECOMMENDATIONS section
496
+ start = output.find("RECOMMENDATIONS:") + len("RECOMMENDATIONS:")
497
+
498
+ # Find the end (next section marker or end of string)
499
+ end = len(output)
500
+ for marker in ["INTERVENTION_REASON:"]:
501
+ pos = output.find(marker, start)
502
+ if pos != -1 and pos < end:
503
+ end = pos
504
+
505
+ return output[start:end].strip()
506
+
507
+ def _extract_intervention_reason(self, output: str) -> str:
508
+ """Extract the INTERVENTION_REASON section from analysis output.
509
+
510
+ Args:
511
+ output: The full analysis output from the harness.
512
+
513
+ Returns:
514
+ The intervention reason text, or default message if not found.
515
+ """
516
+ if "INTERVENTION_REASON:" not in output:
517
+ return "Human intervention required to resolve this issue."
518
+
519
+ # Find the start of INTERVENTION_REASON section
520
+ start = output.find("INTERVENTION_REASON:") + len("INTERVENTION_REASON:")
521
+
522
+ return output[start:].strip()
523
+
524
+ def _clear_progress(self) -> None:
525
+ """Clear progress.md for new PRD (keep only header and learnings notes)."""
526
+ progress_path = get_progress_md_path(self.project_dir)
527
+ progress_path.write_text("# Progress\n\n")
528
+
529
+ def _spec_prds(self) -> bool:
530
+ """Convert unspecced PRDs (.txt) to specced PRDs (.md). Returns True if any were specced."""
531
+ unspecced = self._prd_manager.get_unspecced_prds()
532
+ if not unspecced:
533
+ return False
534
+
535
+ self._set_state(WorkflowState.SPECCING, f"Creating PRDs for {len(unspecced)} tasks")
536
+
537
+ # Get learnings to inform PRD creation
538
+ learnings = self._get_learnings()
539
+
540
+ for prd in unspecced:
541
+ self._output(f"Creating PRD: {prd.name}")
542
+
543
+ # Get the task description from the .txt file
544
+ task_description = prd.content
545
+
546
+ # Use Claude to create a full PRD
547
+ response = self._harness_runner.create_prd(task_description, learnings=learnings)
548
+
549
+ if not response.success:
550
+ self._output(f"Failed to create PRD: {response.error}")
551
+ continue
552
+
553
+ # The response should be markdown PRD content
554
+ prd_content = response.output.strip()
555
+
556
+ # Remove any markdown code fence if present
557
+ if prd_content.startswith("```"):
558
+ prd_content = re.sub(r'^```\w*\n?', '', prd_content)
559
+ prd_content = re.sub(r'\n?```$', '', prd_content)
560
+
561
+ try:
562
+ # Convert .txt to .md
563
+ new_prd = self._prd_manager.spec_prd(prd, prd_content)
564
+ self._output(f"Created PRD: {new_prd.file_path.name}")
565
+
566
+ # Check if it has open questions
567
+ if new_prd.status == "questions":
568
+ self._output(f"PRD has {len(new_prd.questions)} open questions")
569
+ except Exception as e:
570
+ self._output(f"Failed to save PRD: {e}")
571
+
572
+ # Reload to pick up changes
573
+ self._prd_manager.reload()
574
+ return True
575
+
576
+ def _pick_next_prd(self) -> PRD | None:
577
+ """Pick the next PRD to implement."""
578
+ # First check for in-progress PRD
579
+ in_progress = self._prd_manager.get_in_progress_prd()
580
+ if in_progress:
581
+ return in_progress
582
+
583
+ # Get pending PRDs (specced but not started)
584
+ pending = self._prd_manager.get_pending_prds()
585
+ if not pending:
586
+ return None
587
+
588
+ # If only one PRD, pick it
589
+ if len(pending) == 1:
590
+ return pending[0]
591
+
592
+ self._set_state(WorkflowState.PICKING, "Picking next PRD")
593
+
594
+ # Ask Claude to pick
595
+ prds_summary = "\n".join([
596
+ f"- ID: {p.id}\n Name: {p.name}\n File: {p.file_path.name}"
597
+ for p in pending
598
+ ])
599
+
600
+ response = self._harness_runner.pick_next_task(prds_summary)
601
+
602
+ if response.success:
603
+ # Parse the response for PRD ID
604
+ match = re.search(r'TASK_ID:\s*([a-f0-9-]+)', response.output)
605
+ if match:
606
+ prd_id = match.group(1)
607
+ prd = self._prd_manager.get_prd_by_id(prd_id)
608
+ if prd:
609
+ return prd
610
+
611
+ # Fallback: return first pending PRD
612
+ return pending[0] if pending else None
613
+
614
+ def _convert_prd_to_tasks(self, prd: PRD) -> bool:
615
+ """Convert a PRD to tasks.json. Returns True on success."""
616
+ self._set_state(WorkflowState.CONVERTING, f"Converting PRD to tasks: {prd.name}")
617
+
618
+ # Get project name from directory
619
+ project_name = self.project_dir.name
620
+
621
+ response = self._harness_runner.convert_prd_to_tasks(
622
+ prd.content, project_name, branch_prefix=self._config.branch_prefix
623
+ )
624
+
625
+ if not response.success:
626
+ self._output(f"Failed to convert PRD: {response.error}")
627
+ return False
628
+
629
+ # Extract JSON from response
630
+ tasks_json = response.output.strip()
631
+
632
+ # Remove markdown code fence if present
633
+ if tasks_json.startswith("```"):
634
+ tasks_json = re.sub(r'^```\w*\n?', '', tasks_json)
635
+ tasks_json = re.sub(r'\n?```$', '', tasks_json)
636
+
637
+ try:
638
+ # Create tasks.json
639
+ tasks_file = self._story_manager.create_from_json(tasks_json, str(prd.file_path))
640
+ self._output(f"Created tasks.json with {len(tasks_file.user_stories)} stories")
641
+
642
+ # Clear progress for new PRD
643
+ self._clear_progress()
644
+
645
+ return True
646
+ except Exception as e:
647
+ self._output(f"Failed to create tasks.json: {e}")
648
+ return False
649
+
650
+ def _implement_story(self, story: UserStory) -> bool:
651
+ """Implement a single user story. Returns True on success."""
652
+ self._current_story = story
653
+ self._set_state(
654
+ WorkflowState.IMPLEMENTING,
655
+ f"Implementing: {story.id} - {story.title}"
656
+ )
657
+
658
+ # Build context from progress and learnings
659
+ context = ""
660
+ progress_path = get_progress_md_path(self.project_dir)
661
+ if progress_path.exists():
662
+ context += f"Progress so far:\n{progress_path.read_text()}\n\n"
663
+
664
+ learnings = self._get_learnings()
665
+ if learnings:
666
+ context += f"Project learnings:\n{learnings}\n\n"
667
+
668
+ # Get the story prompt
669
+ story_prompt = story.get_prompt()
670
+
671
+ # Implement the story
672
+ response = self._harness_runner.implement_story(story_prompt, context)
673
+
674
+ if not response.success:
675
+ self._update_progress(story, "FAILED", response.error)
676
+ story.mark_failed(response.error)
677
+ self._story_manager.update_story(story)
678
+ self._output(f"Implementation failed: {response.error}")
679
+ return False
680
+
681
+ return True
682
+
683
+ def _verify_story(self, story: UserStory) -> tuple[VerificationStatus, str]:
684
+ """Verify a story implementation. Returns (status, notes)."""
685
+ self._set_state(WorkflowState.REVIEWING, f"Reviewing: {story.id}")
686
+
687
+ # Get git diff for review
688
+ git_diff = self._git_manager.get_diff()
689
+
690
+ response = self._harness_runner.verify_story(story.get_prompt(), git_diff)
691
+
692
+ if not response.success:
693
+ return VerificationStatus.FAILS, f"Verification error: {response.error}"
694
+
695
+ # Parse response
696
+ output = response.output.upper()
697
+
698
+ # Determine status (check BLOCKED before FAILS since BLOCKED is more specific)
699
+ if "STATUS: PASSES" in output:
700
+ status = VerificationStatus.PASSES
701
+ elif "STATUS: BLOCKED" in output:
702
+ status = VerificationStatus.BLOCKED
703
+ else:
704
+ status = VerificationStatus.FAILS
705
+
706
+ # Extract notes (everything after NOTES: or FEEDBACK: for backwards compat)
707
+ notes = ""
708
+ raw_output = response.output
709
+ if "NOTES:" in raw_output:
710
+ notes = raw_output.split("NOTES:", 1)[1].strip()
711
+ elif "FEEDBACK:" in raw_output:
712
+ notes = raw_output.split("FEEDBACK:", 1)[1].strip()
713
+
714
+ return status, notes
715
+
716
+ def _commit_story(self, story: UserStory) -> bool:
717
+ """Commit changes for a passing story. Returns True on success."""
718
+ self._set_state(WorkflowState.COMMITTING, f"Committing: {story.id}")
719
+
720
+ # Check if there are already staged changes or unstaged changes
721
+ has_staged = self._git_manager.has_staged_changes()
722
+ unstaged_files = self._git_manager.get_unstaged_files()
723
+
724
+ if not has_staged and not unstaged_files:
725
+ return True
726
+
727
+ try:
728
+ # Ask LLM to identify which unstaged files should be committed
729
+ if unstaged_files:
730
+ self._output(f"Reviewing {len(unstaged_files)} changed files...")
731
+ response = self._harness_runner.stage_story_changes(
732
+ story.id, story.title, unstaged_files
733
+ )
734
+
735
+ if response.success:
736
+ files_to_stage = self._parse_files_to_stage(response.output)
737
+ if files_to_stage:
738
+ self._output(f"Staging {len(files_to_stage)} files...")
739
+ self._git_manager.stage_files(files_to_stage)
740
+
741
+ # Check if we have anything to commit now
742
+ if not self._git_manager.has_staged_changes():
743
+ self._output("No files to commit")
744
+ return True
745
+
746
+ # Generate commit message
747
+ status = self._git_manager.get_status()
748
+ response = self._harness_runner.generate_commit_message(
749
+ f"Story: {story.id} - {story.title}\n\nChanges:\n{status}"
750
+ )
751
+
752
+ if response.success and response.output.strip():
753
+ message = response.output.strip()
754
+ else:
755
+ message = f"{story.id}: {story.title}"
756
+
757
+ self._git_manager.commit_staged(message)
758
+ return True
759
+
760
+ except GitError as e:
761
+ self._output(f"Git error: {e}")
762
+ return False
763
+
764
+ def _parse_files_to_stage(self, output: str) -> list[str]:
765
+ """Parse the FILES_TO_STAGE section from LLM output.
766
+
767
+ Args:
768
+ output: The full output from stage_story_changes().
769
+
770
+ Returns:
771
+ List of file paths to stage, empty if none or NONE specified.
772
+ """
773
+ if "FILES_TO_STAGE:" not in output:
774
+ return []
775
+
776
+ # Extract content after FILES_TO_STAGE:
777
+ content = output.split("FILES_TO_STAGE:", 1)[1].strip()
778
+
779
+ # Check for NONE response
780
+ if content.upper().startswith("NONE"):
781
+ return []
782
+
783
+ # Parse file paths (one per line)
784
+ files = []
785
+ for line in content.split("\n"):
786
+ line = line.strip()
787
+ # Skip empty lines and common artifacts
788
+ if line and not line.startswith("#") and not line.startswith("-"):
789
+ # Handle markdown list format if LLM uses it
790
+ if line.startswith("- "):
791
+ line = line[2:]
792
+ if line:
793
+ files.append(line)
794
+
795
+ return files
796
+
797
+ def _archive_prd(self, prd: PRD) -> None:
798
+ """Archive a completed PRD."""
799
+ self._set_state(WorkflowState.ARCHIVING, f"Archiving: {prd.name}")
800
+
801
+ # Create archive directory
802
+ timestamp = datetime.now().strftime("%Y-%m-%d")
803
+ archive_dir = self.project_dir / "ARCHIVED_PRDs" / f"{timestamp}-{prd.file_path.stem}"
804
+ archive_dir.mkdir(parents=True, exist_ok=True)
805
+
806
+ # Archive tasks.json
807
+ self._story_manager.archive(archive_dir)
808
+
809
+ # Archive progress.md
810
+ progress_path = get_progress_md_path(self.project_dir)
811
+ if progress_path.exists():
812
+ shutil.copy(progress_path, archive_dir / "progress.md")
813
+
814
+ # Move PRD to archive
815
+ if prd.file_path.exists():
816
+ shutil.copy(prd.file_path, archive_dir / prd.file_path.name)
817
+ prd.file_path.unlink()
818
+
819
+ # Filter learnings - remove PRD-scoped items, keep project-wide ones
820
+ self._filter_prd_learnings(prd)
821
+
822
+ # Clear tasks.json
823
+ self._story_manager.clear()
824
+
825
+ # Clear progress
826
+ self._clear_progress()
827
+
828
+ self._output(f"Archived PRD to {archive_dir}")
829
+
830
+ def _handle_error(self, error_message: str, prd: PRD | None = None) -> bool:
831
+ """Handle an error based on configuration."""
832
+ self._error_message = error_message
833
+ error_mode = self._config.on_error
834
+
835
+ if error_mode in ("block", "skip") and prd:
836
+ prd.error(error_message)
837
+ self._prd_manager.update_prd_status(prd, "errored")
838
+ self._output(f"PRD errored: {error_message}")
839
+ return False
840
+
841
+ elif error_mode == "pause":
842
+ self._paused = True
843
+ self._set_state(WorkflowState.ERROR, error_message)
844
+ return False
845
+
846
+ # retry mode is handled in the main step() method
847
+ return False
848
+
849
+ def get_prds_with_questions(self) -> list[PRD]:
850
+ """Get all PRDs that have open questions needing answers."""
851
+ return self._prd_manager.get_questions_prds()
852
+
853
+ def answer_questions(self, prd: PRD, answers: list[str]) -> None:
854
+ """Answer open questions for a PRD and save any learnings."""
855
+ learnings = self._prd_manager.answer_prd_questions(prd, answers)
856
+ if learnings:
857
+ self._append_learnings(learnings)
858
+ self._output(f"Saved {len(learnings)} learnings from PRD questions")
859
+ self._prd_manager.reload()
860
+
861
+ # If no more PRDs have questions, reset workflow state
862
+ if not self._prd_manager.get_questions_prds():
863
+ self._paused = False
864
+ if self._state == WorkflowState.QUESTIONS:
865
+ self._set_state(WorkflowState.IDLE, "Questions answered")
866
+
867
+ def get_story_progress(self) -> tuple[int, int, int]:
868
+ """Get story progress as (completed, total, percent)."""
869
+ completed, total = self._story_manager.get_progress()
870
+ percent = self._story_manager.get_progress_percent()
871
+ return completed, total, percent
872
+
873
+ def step(self) -> bool:
874
+ """
875
+ Execute one step of the workflow.
876
+
877
+ Returns True if there's more work to do, False if idle or paused.
878
+ """
879
+ if self._paused:
880
+ return False
881
+
882
+ # Check iteration limit
883
+ if self._iteration >= self._config.max_iterations:
884
+ self._output("Max iterations reached")
885
+ if self._config.pause_on_completion:
886
+ self.pause()
887
+ return False
888
+
889
+ self._iteration += 1
890
+
891
+ try:
892
+ # Reload to pick up any changes
893
+ self._prd_manager.reload()
894
+ self._story_manager.reload()
895
+
896
+ # Step 1: Create PRDs from unspecced .txt files
897
+ if self._prd_manager.get_unspecced_prds():
898
+ self._spec_prds()
899
+ return True
900
+
901
+ # Step 2: Check for PRDs with open questions - pause for user input
902
+ questions_prds = self._prd_manager.get_questions_prds()
903
+ if questions_prds:
904
+ self._set_state(WorkflowState.QUESTIONS, f"{len(questions_prds)} PRD(s) have open questions")
905
+ self._paused = True
906
+ return False
907
+
908
+ # Step 3: Check if we have tasks.json with incomplete stories
909
+ if self._story_manager.has_tasks() and not self._story_manager.is_complete():
910
+ # Ensure we're on the right branch
911
+ self._ensure_git_branch()
912
+
913
+ # Pick next story (respecting max attempts)
914
+ max_attempts = self._config.max_story_attempts
915
+ story = self._story_manager.get_next_story(max_attempts)
916
+
917
+ if story is None:
918
+ # No actionable stories - all are either passed or blocked
919
+ # Pause and let user decide what to do
920
+ self._output("All remaining stories are blocked (max attempts exceeded)")
921
+ self._paused = True
922
+ self._set_state(WorkflowState.PAUSED, "Stories blocked - manual intervention needed")
923
+ return False
924
+
925
+ # Implement the story
926
+ if not self._implement_story(story):
927
+ # Failed - story already marked, try next
928
+ return True
929
+
930
+ # Verify the story
931
+ status, notes = self._verify_story(story)
932
+
933
+ if status == VerificationStatus.PASSES:
934
+ story.mark_passing()
935
+ self._story_manager.update_story(story)
936
+ self._update_progress(story, "PASSED", notes)
937
+ self._output(f"Story {story.id} passed!")
938
+
939
+ # Commit the changes
940
+ self._commit_story(story)
941
+
942
+ elif status == VerificationStatus.BLOCKED:
943
+ # BLOCKED means implementation looks correct but verification
944
+ # couldn't complete (e.g., can't run tests, pre-existing errors)
945
+ # Record as BLOCKED with full notes for next iteration to address
946
+ story.mark_failed(notes)
947
+ self._story_manager.update_story(story)
948
+ self._update_progress(story, "BLOCKED", notes)
949
+
950
+ # Extract a brief summary for output
951
+ brief = notes.split("\n")[0][:100] if notes else "Verification blocked"
952
+ self._output(f"Story {story.id} blocked: {brief}...")
953
+ self._output("See progress.md for full blocker details and next steps")
954
+
955
+ else: # VerificationStatus.FAILS
956
+ story.mark_failed(notes)
957
+ # Check if this story is now blocked due to max attempts
958
+ if story.attempts >= max_attempts:
959
+ self._handle_first_blocked(story, max_attempts)
960
+ else:
961
+ brief = notes.split("\n")[0][:100] if notes else "Implementation failed"
962
+ self._output(f"Story {story.id} failed (attempt {story.attempts}/{max_attempts}): {brief}...")
963
+ self._story_manager.update_story(story)
964
+ self._update_progress(story, "FAILED", notes)
965
+
966
+ self._current_story = None
967
+ return True
968
+
969
+ # Step 4: Check if tasks.json is complete - archive the PRD and move to next
970
+ if self._story_manager.has_tasks() and self._story_manager.is_complete():
971
+ prd_file = self._story_manager.get_prd_file()
972
+ if prd_file:
973
+ # Find the PRD
974
+ for prd in self._prd_manager.get_all_prds():
975
+ if str(prd.file_path) == prd_file:
976
+ prd.complete()
977
+ self._prd_manager.update_prd_status(prd, "completed")
978
+ self._archive_prd(prd)
979
+ self._completed_this_session += 1
980
+ self._output(f"Completed PRD: {prd.name}")
981
+ break
982
+ else:
983
+ # No PRD file recorded, just clear tasks
984
+ self._story_manager.clear()
985
+
986
+ # Clear current PRD so we pick the next one
987
+ self._current_prd = None
988
+ self._prd_manager.reload()
989
+
990
+ return True # Continue to pick next PRD
991
+
992
+ # Step 5: Pick next PRD if no current one and no tasks
993
+ if not self._story_manager.has_tasks():
994
+ next_prd = self._pick_next_prd()
995
+ if not next_prd:
996
+ self._set_state(WorkflowState.IDLE, "No PRDs available")
997
+ if self._config.pause_on_completion:
998
+ self.pause()
999
+ return False
1000
+
1001
+ # Mark PRD as in progress
1002
+ if next_prd.status != "in_progress":
1003
+ next_prd.start()
1004
+ self._prd_manager.update_prd_status(next_prd, "in_progress")
1005
+
1006
+ self._current_prd = next_prd
1007
+
1008
+ # Convert PRD to tasks.json
1009
+ if not self._convert_prd_to_tasks(next_prd):
1010
+ self._handle_error("Failed to convert PRD to tasks", next_prd)
1011
+ self._current_prd = None
1012
+ return True
1013
+
1014
+ # Ensure we're on the right branch
1015
+ self._ensure_git_branch()
1016
+
1017
+ return True
1018
+
1019
+ return False
1020
+
1021
+ except GitError as e:
1022
+ # Git errors always pause - user needs to fix something
1023
+ self._error_message = str(e)
1024
+ self._paused = True
1025
+ self._set_state(WorkflowState.ERROR, str(e))
1026
+ return False
1027
+
1028
+ except Exception as e:
1029
+ self._handle_error(str(e), self._current_prd)
1030
+ # Only continue if on_error is "retry", otherwise stop
1031
+ return self._config.on_error == "retry"
1032
+
1033
+ def run(self) -> None:
1034
+ """Run the workflow until completion or pause."""
1035
+ while self.step():
1036
+ pass