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