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/app.py
ADDED
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
"""Main application class with Rich UI for ralph-coding."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
from questionary import Style
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from .colors import (
|
|
15
|
+
# Polar Night (backgrounds)
|
|
16
|
+
NORD1,
|
|
17
|
+
NORD3,
|
|
18
|
+
# Snow Storm (text)
|
|
19
|
+
NORD4,
|
|
20
|
+
NORD5,
|
|
21
|
+
NORD6,
|
|
22
|
+
# Frost (accents)
|
|
23
|
+
NORD8,
|
|
24
|
+
NORD10,
|
|
25
|
+
# Aurora (states)
|
|
26
|
+
NORD11,
|
|
27
|
+
NORD12,
|
|
28
|
+
NORD13,
|
|
29
|
+
NORD14,
|
|
30
|
+
NORD15,
|
|
31
|
+
)
|
|
32
|
+
from .config import get_config
|
|
33
|
+
from .harness import Harness, HarnessDetector
|
|
34
|
+
from .spinner import RichSpinner, SpinnerStyle
|
|
35
|
+
from .user_stories import UserStory
|
|
36
|
+
from .workflow import SPINNER_MESSAGES, SPINNER_STATES, WorkflowEngine, WorkflowState
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Ralph Wiggum ASCII art - Using Nord Theme colors
|
|
40
|
+
# NORD15 (Aurora Purple) for the face, NORD8 (Frost Cyan) for eyes and tagline
|
|
41
|
+
RALPH_ART = rf"""
|
|
42
|
+
[bold {NORD15}] _______________
|
|
43
|
+
/ \
|
|
44
|
+
| [/bold {NORD15}][bold {NORD8}]●[/bold {NORD8}][bold {NORD15}] [/bold {NORD15}][bold {NORD8}]●[/bold {NORD8}][bold {NORD15}] |
|
|
45
|
+
| [/bold {NORD15}][bold {NORD11}]>[/bold {NORD11}][bold {NORD15}] |
|
|
46
|
+
| \______/ |
|
|
47
|
+
\_______________/
|
|
48
|
+
| |
|
|
49
|
+
/| |\
|
|
50
|
+
/ | | \
|
|
51
|
+
[/bold {NORD15}][bold {NORD8}] "I'm helping!"[/bold {NORD8}]
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
RALPH_ART_SMALL = f"""[bold {NORD15}] ___
|
|
55
|
+
(o o) [/bold {NORD15}][bold {NORD8}]"I'm helping!"[/bold {NORD8}]
|
|
56
|
+
[bold {NORD15}] \\\\[/bold {NORD15}][bold {NORD11}]>[/bold {NORD11}][bold {NORD15}]/[/bold {NORD15}]"""
|
|
57
|
+
|
|
58
|
+
# Custom style for questionary using Nord Theme colors
|
|
59
|
+
MENU_STYLE = Style([
|
|
60
|
+
('qmark', f'fg:{NORD15} bold'), # Aurora purple for question marks
|
|
61
|
+
('question', f'fg:{NORD6} bold'), # Snow storm (brightest) for question text
|
|
62
|
+
('answer', f'fg:{NORD8} bold'), # Frost cyan for answers
|
|
63
|
+
('pointer', f'fg:{NORD14} bold'), # Aurora green for pointer
|
|
64
|
+
('highlighted', f'fg:{NORD8} bold'), # Frost cyan for highlighted items
|
|
65
|
+
('selected', f'fg:{NORD10}'), # Frost blue for selected items
|
|
66
|
+
('separator', f'fg:{NORD3}'), # Polar night (lighter) for separators
|
|
67
|
+
('instruction', f'fg:{NORD3}'), # Polar night (lighter) for instructions
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RalphApp:
|
|
72
|
+
"""Main application with Rich terminal UI."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, project_dir: Path, debug: bool = False):
|
|
75
|
+
self.project_dir = project_dir.resolve()
|
|
76
|
+
self.debug = debug
|
|
77
|
+
self.console = Console()
|
|
78
|
+
self._config = get_config()
|
|
79
|
+
self._workflow: WorkflowEngine | None = None
|
|
80
|
+
self._running = False
|
|
81
|
+
self._last_output = ""
|
|
82
|
+
self._live: Live | None = None
|
|
83
|
+
self._last_status_key: tuple[object, ...] | None = None
|
|
84
|
+
self._spinner = RichSpinner(style=SpinnerStyle.DOTS_6, spinner_color=NORD8)
|
|
85
|
+
self._state_message = ""
|
|
86
|
+
self._harness_available = False # Set by _validate_harness()
|
|
87
|
+
|
|
88
|
+
def _get_status_key(self) -> tuple[object, ...]:
|
|
89
|
+
"""Get a hashable key representing current status for change detection."""
|
|
90
|
+
workflow = self._get_workflow()
|
|
91
|
+
stats = workflow.prd_manager.get_stats()
|
|
92
|
+
return (
|
|
93
|
+
workflow.state,
|
|
94
|
+
workflow.is_paused,
|
|
95
|
+
workflow.current_task.id if workflow.current_task else None,
|
|
96
|
+
self._state_message,
|
|
97
|
+
self._last_output,
|
|
98
|
+
workflow.error_message,
|
|
99
|
+
tuple(stats.items()),
|
|
100
|
+
workflow.completed_this_session,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _update_live(self) -> None:
|
|
104
|
+
"""Update live display only if content has changed."""
|
|
105
|
+
if not self._live:
|
|
106
|
+
return
|
|
107
|
+
status_key = self._get_status_key()
|
|
108
|
+
if status_key != self._last_status_key:
|
|
109
|
+
self._last_status_key = status_key
|
|
110
|
+
self._live.update(self._build_live_status())
|
|
111
|
+
|
|
112
|
+
def _on_state_change(self, state: WorkflowState, message: str) -> None:
|
|
113
|
+
"""Handle workflow state changes."""
|
|
114
|
+
if message:
|
|
115
|
+
self._state_message = message
|
|
116
|
+
else:
|
|
117
|
+
self._state_message = SPINNER_MESSAGES.get(state, "")
|
|
118
|
+
self._update_live()
|
|
119
|
+
|
|
120
|
+
def _on_output(self, message: str) -> None:
|
|
121
|
+
"""Handle output from the workflow."""
|
|
122
|
+
self._last_output = message
|
|
123
|
+
self._update_live()
|
|
124
|
+
|
|
125
|
+
def _get_workflow(self) -> WorkflowEngine:
|
|
126
|
+
"""Get or create the workflow engine."""
|
|
127
|
+
if self._workflow is None:
|
|
128
|
+
self._workflow = WorkflowEngine(
|
|
129
|
+
self.project_dir,
|
|
130
|
+
debug=self.debug,
|
|
131
|
+
on_state_change=self._on_state_change,
|
|
132
|
+
on_output=self._on_output,
|
|
133
|
+
use_spinner=False, # App uses its own Live display
|
|
134
|
+
)
|
|
135
|
+
return self._workflow
|
|
136
|
+
|
|
137
|
+
def _show_header(self) -> None:
|
|
138
|
+
"""Show the Ralph header with ASCII art using Nord theme colors."""
|
|
139
|
+
self.console.print(Panel(
|
|
140
|
+
RALPH_ART,
|
|
141
|
+
title=f"[bold {NORD15}]RALPH CODING[/bold {NORD15}]",
|
|
142
|
+
subtitle=f"[bold {NORD6}]{self.project_dir.name}[/bold {NORD6}]",
|
|
143
|
+
border_style=NORD8,
|
|
144
|
+
))
|
|
145
|
+
|
|
146
|
+
def _validate_harness(self) -> bool:
|
|
147
|
+
"""
|
|
148
|
+
Validate that the configured harness exists and is usable.
|
|
149
|
+
|
|
150
|
+
Checks if the harness path exists and is executable. If not,
|
|
151
|
+
offers auto-detect or reconfigure options.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if a valid harness is available, False otherwise.
|
|
155
|
+
"""
|
|
156
|
+
harness = Harness.from_config(self._config.harness)
|
|
157
|
+
|
|
158
|
+
if harness.is_available:
|
|
159
|
+
self._harness_available = True
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
# Harness not available - show warning
|
|
163
|
+
self.console.print(f"\n[{NORD11} bold]⚠ Warning: Harness not found[/{NORD11} bold]")
|
|
164
|
+
self.console.print(f"[{NORD4}]Configured harness '[{NORD8}]{self._config.harness}[/{NORD8}]' is not available.[/{NORD4}]")
|
|
165
|
+
self.console.print(f"[{NORD3}]The path does not exist or is not executable.[/{NORD3}]\n")
|
|
166
|
+
|
|
167
|
+
# Try auto-detection
|
|
168
|
+
detector = HarnessDetector()
|
|
169
|
+
alternatives = detector.detect_all()
|
|
170
|
+
|
|
171
|
+
if alternatives:
|
|
172
|
+
# Found alternatives - offer to use them
|
|
173
|
+
self.console.print(f"[{NORD14}]Found available alternatives:[/{NORD14}]")
|
|
174
|
+
choices = []
|
|
175
|
+
for alt in alternatives:
|
|
176
|
+
self.console.print(f" [{NORD8}]• {alt.name}[/{NORD8}] [{NORD3}]({alt.path})[/{NORD3}]")
|
|
177
|
+
choices.append({"name": f"Use {alt.name} ({alt.path})", "value": alt.path})
|
|
178
|
+
|
|
179
|
+
choices.append({"name": "Enter custom path", "value": "custom"})
|
|
180
|
+
choices.append({"name": "Continue without harness (workflow disabled)", "value": "skip"})
|
|
181
|
+
|
|
182
|
+
self.console.print()
|
|
183
|
+
result: str | None = questionary.select(
|
|
184
|
+
"What would you like to do?",
|
|
185
|
+
choices=choices,
|
|
186
|
+
style=MENU_STYLE,
|
|
187
|
+
).ask()
|
|
188
|
+
|
|
189
|
+
if result is None:
|
|
190
|
+
# User cancelled
|
|
191
|
+
self._harness_available = False
|
|
192
|
+
return False
|
|
193
|
+
elif result == "skip":
|
|
194
|
+
self.console.print(f"[{NORD13}]Continuing without harness. Workflow commands will be disabled.[/{NORD13}]\n")
|
|
195
|
+
self._harness_available = False
|
|
196
|
+
return False
|
|
197
|
+
elif result == "custom":
|
|
198
|
+
custom_path = questionary.text(
|
|
199
|
+
"Enter harness path or command:",
|
|
200
|
+
style=MENU_STYLE,
|
|
201
|
+
).ask()
|
|
202
|
+
if custom_path:
|
|
203
|
+
self._config.harness = custom_path
|
|
204
|
+
# Recursively validate the new path
|
|
205
|
+
return self._validate_harness()
|
|
206
|
+
else:
|
|
207
|
+
self._harness_available = False
|
|
208
|
+
return False
|
|
209
|
+
else:
|
|
210
|
+
# User selected an alternative
|
|
211
|
+
self._config.harness = result
|
|
212
|
+
self.console.print(f"[{NORD14}]Harness updated to: {result}[/{NORD14}]\n")
|
|
213
|
+
self._harness_available = True
|
|
214
|
+
return True
|
|
215
|
+
else:
|
|
216
|
+
# No alternatives found
|
|
217
|
+
self.console.print(f"[{NORD13}]No alternative harnesses detected on PATH.[/{NORD13}]\n")
|
|
218
|
+
|
|
219
|
+
choices = [
|
|
220
|
+
{"name": "Enter custom path", "value": "custom"},
|
|
221
|
+
{"name": "Continue without harness (workflow disabled)", "value": "skip"},
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
result = questionary.select(
|
|
225
|
+
"What would you like to do?",
|
|
226
|
+
choices=choices,
|
|
227
|
+
style=MENU_STYLE,
|
|
228
|
+
).ask()
|
|
229
|
+
|
|
230
|
+
if result is None or result == "skip":
|
|
231
|
+
self.console.print(f"[{NORD13}]Continuing without harness. Workflow commands will be disabled.[/{NORD13}]\n")
|
|
232
|
+
self._harness_available = False
|
|
233
|
+
return False
|
|
234
|
+
elif result == "custom":
|
|
235
|
+
custom_path = questionary.text(
|
|
236
|
+
"Enter harness path or command:",
|
|
237
|
+
style=MENU_STYLE,
|
|
238
|
+
).ask()
|
|
239
|
+
if custom_path:
|
|
240
|
+
self._config.harness = custom_path
|
|
241
|
+
# Recursively validate the new path
|
|
242
|
+
return self._validate_harness()
|
|
243
|
+
else:
|
|
244
|
+
self._harness_available = False
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
self._harness_available = False
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def _build_status_panel(self) -> Panel:
|
|
251
|
+
"""Build the status panel with Nord theme colors."""
|
|
252
|
+
workflow = self._get_workflow()
|
|
253
|
+
stats = workflow.prd_manager.get_stats()
|
|
254
|
+
|
|
255
|
+
lines = []
|
|
256
|
+
|
|
257
|
+
# PRD stats: Unspecced | Questions | Pending | In Progress | Complete | Errored
|
|
258
|
+
# PRD status colors (US-007):
|
|
259
|
+
# unspecced = dim gray (NORD3)
|
|
260
|
+
# questions = aurora yellow (NORD13)
|
|
261
|
+
# pending = aurora orange (NORD12)
|
|
262
|
+
# in_progress = aurora green (NORD14)
|
|
263
|
+
# completed = aurora green dim (NORD14 dim)
|
|
264
|
+
# errored = aurora red (NORD11)
|
|
265
|
+
parts = []
|
|
266
|
+
if stats['unspecced'] > 0:
|
|
267
|
+
parts.append(f"[{NORD3}]{stats['unspecced']} unspecced[/{NORD3}]")
|
|
268
|
+
if stats['questions'] > 0:
|
|
269
|
+
parts.append(f"[{NORD13}]{stats['questions']} questions[/{NORD13}]")
|
|
270
|
+
parts.append(f"[{NORD12}]{stats['pending']} pending[/{NORD12}]")
|
|
271
|
+
parts.append(f"[{NORD14}]{stats['in_progress']} in progress[/{NORD14}]")
|
|
272
|
+
parts.append(f"[dim {NORD14}]{stats['completed']} complete[/dim {NORD14}]")
|
|
273
|
+
if stats['errored'] > 0:
|
|
274
|
+
parts.append(f"[{NORD11}]{stats['errored']} errored[/{NORD11}]")
|
|
275
|
+
lines.append(f"[{NORD8}]PRDs:[/{NORD8}] [{NORD4}]{' | '.join(parts)}[/{NORD4}]")
|
|
276
|
+
|
|
277
|
+
# Story progress (if tasks.json exists)
|
|
278
|
+
completed, total, percent = workflow.get_story_progress()
|
|
279
|
+
if total > 0:
|
|
280
|
+
lines.append(f"[{NORD8}]Stories:[/{NORD8}] [{NORD4}]{percent}% ({completed}/{total} complete)[/{NORD4}]")
|
|
281
|
+
|
|
282
|
+
# Activity detail (only show if not idle)
|
|
283
|
+
if workflow.state not in (WorkflowState.IDLE, WorkflowState.PAUSED, WorkflowState.QUESTIONS):
|
|
284
|
+
activity = workflow.state.value.replace("_", " ").title()
|
|
285
|
+
if workflow.current_story:
|
|
286
|
+
lines.append(f"[{NORD3}]Activity:[/{NORD3}] [{NORD5}]{activity} - {workflow.current_story.id}: {workflow.current_story.title}[/{NORD5}]")
|
|
287
|
+
elif workflow.current_task:
|
|
288
|
+
lines.append(f"[{NORD3}]Activity:[/{NORD3}] [{NORD5}]{activity} - {workflow.current_task.name}[/{NORD5}]")
|
|
289
|
+
else:
|
|
290
|
+
lines.append(f"[{NORD3}]Activity:[/{NORD3}] [{NORD5}]{activity}[/{NORD5}]")
|
|
291
|
+
|
|
292
|
+
# Questions indicator - Nord aurora orange for attention
|
|
293
|
+
if workflow.state == WorkflowState.QUESTIONS:
|
|
294
|
+
lines.append(f"[{NORD12} bold]*** PRDs HAVE OPEN QUESTIONS ***[/{NORD12} bold]")
|
|
295
|
+
|
|
296
|
+
# Last output - Nord polar night for dim text
|
|
297
|
+
if self._last_output:
|
|
298
|
+
lines.append(f"[{NORD3}]{self._last_output}[/{NORD3}]")
|
|
299
|
+
|
|
300
|
+
# Paused indicator - Nord aurora yellow for warning
|
|
301
|
+
if workflow.is_paused:
|
|
302
|
+
lines.append(f"[{NORD13} bold]*** WORKFLOW PAUSED ***[/{NORD13} bold]")
|
|
303
|
+
|
|
304
|
+
# Harness unavailable indicator - Nord aurora red for warning
|
|
305
|
+
if not self._harness_available:
|
|
306
|
+
lines.append(f"[{NORD11} bold]*** NO HARNESS - WORKFLOW DISABLED ***[/{NORD11} bold]")
|
|
307
|
+
|
|
308
|
+
# Error message - Nord aurora red for errors
|
|
309
|
+
if workflow.error_message:
|
|
310
|
+
lines.append(f"[{NORD11}]Error: {workflow.error_message}[/{NORD11}]")
|
|
311
|
+
|
|
312
|
+
content = "\n".join(lines)
|
|
313
|
+
# Panel with Nord frost blue border and Snow Storm title
|
|
314
|
+
return Panel(
|
|
315
|
+
content,
|
|
316
|
+
title=f"[{NORD6} bold]Status[/{NORD6} bold]",
|
|
317
|
+
border_style=NORD10,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _main_menu(self) -> str | None:
|
|
321
|
+
"""Show the main menu and return the selected action."""
|
|
322
|
+
workflow = self._get_workflow()
|
|
323
|
+
has_questions = len(workflow.get_prds_with_questions()) > 0
|
|
324
|
+
|
|
325
|
+
# Check if we have stories to manage
|
|
326
|
+
has_stories = workflow.story_manager.has_tasks()
|
|
327
|
+
|
|
328
|
+
# If no harness available, show limited menu
|
|
329
|
+
if not self._harness_available:
|
|
330
|
+
choices = [
|
|
331
|
+
{"name": "Add PRD", "value": "add"},
|
|
332
|
+
{"name": "List PRDs", "value": "list"},
|
|
333
|
+
]
|
|
334
|
+
if has_stories:
|
|
335
|
+
choices.append({"name": "Manage stories", "value": "stories"})
|
|
336
|
+
choices.extend([
|
|
337
|
+
{"name": "Settings", "value": "settings"},
|
|
338
|
+
{"name": "Quit", "value": "quit"},
|
|
339
|
+
])
|
|
340
|
+
elif workflow.state == WorkflowState.QUESTIONS or has_questions:
|
|
341
|
+
# Questions need to be answered before continuing
|
|
342
|
+
choices = [
|
|
343
|
+
{"name": "Answer PRD questions", "value": "answer"},
|
|
344
|
+
{"name": "Add PRD", "value": "add"},
|
|
345
|
+
{"name": "List PRDs", "value": "list"},
|
|
346
|
+
]
|
|
347
|
+
if has_stories:
|
|
348
|
+
choices.append({"name": "Manage stories", "value": "stories"})
|
|
349
|
+
choices.extend([
|
|
350
|
+
{"name": "Settings", "value": "settings"},
|
|
351
|
+
{"name": "Quit", "value": "quit"},
|
|
352
|
+
])
|
|
353
|
+
elif workflow.is_paused:
|
|
354
|
+
choices = [
|
|
355
|
+
{"name": "Resume workflow", "value": "resume"},
|
|
356
|
+
{"name": "Add PRD", "value": "add"},
|
|
357
|
+
{"name": "List PRDs", "value": "list"},
|
|
358
|
+
]
|
|
359
|
+
if has_stories:
|
|
360
|
+
choices.append({"name": "Manage stories", "value": "stories"})
|
|
361
|
+
choices.extend([
|
|
362
|
+
{"name": "Settings", "value": "settings"},
|
|
363
|
+
{"name": "Quit", "value": "quit"},
|
|
364
|
+
])
|
|
365
|
+
else:
|
|
366
|
+
choices = [
|
|
367
|
+
{"name": "Run workflow", "value": "run"},
|
|
368
|
+
{"name": "Add PRD", "value": "add"},
|
|
369
|
+
{"name": "List PRDs", "value": "list"},
|
|
370
|
+
]
|
|
371
|
+
if has_stories:
|
|
372
|
+
choices.append({"name": "Manage stories", "value": "stories"})
|
|
373
|
+
choices.extend([
|
|
374
|
+
{"name": "Pause workflow", "value": "pause"},
|
|
375
|
+
{"name": "Settings", "value": "settings"},
|
|
376
|
+
{"name": "Quit", "value": "quit"},
|
|
377
|
+
])
|
|
378
|
+
|
|
379
|
+
result: str | None = questionary.select(
|
|
380
|
+
"What would you like to do?",
|
|
381
|
+
choices=choices,
|
|
382
|
+
style=MENU_STYLE,
|
|
383
|
+
instruction="(Use arrow keys, then Enter)",
|
|
384
|
+
).ask()
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
def _show_add_task_menu(self) -> None:
|
|
388
|
+
"""Show the add task menu."""
|
|
389
|
+
self.console.print()
|
|
390
|
+
|
|
391
|
+
task_type = questionary.select(
|
|
392
|
+
"What type of PRD?",
|
|
393
|
+
choices=[
|
|
394
|
+
{"name": "Quick task (creates .txt for later speccing)", "value": "thin"},
|
|
395
|
+
{"name": "Cancel", "value": "cancel"},
|
|
396
|
+
],
|
|
397
|
+
style=MENU_STYLE,
|
|
398
|
+
).ask()
|
|
399
|
+
|
|
400
|
+
if task_type == "cancel" or task_type is None:
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
if task_type == "thin":
|
|
404
|
+
name = questionary.text(
|
|
405
|
+
"PRD name (will be used as filename):",
|
|
406
|
+
style=MENU_STYLE,
|
|
407
|
+
).ask()
|
|
408
|
+
|
|
409
|
+
if not name:
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
description = questionary.text(
|
|
413
|
+
"Task description:",
|
|
414
|
+
style=MENU_STYLE,
|
|
415
|
+
).ask()
|
|
416
|
+
|
|
417
|
+
if description:
|
|
418
|
+
workflow = self._get_workflow()
|
|
419
|
+
# Create a .txt file in the PRD folder
|
|
420
|
+
from .prd_manager import slugify
|
|
421
|
+
slug = slugify(name)
|
|
422
|
+
prd_file = workflow.prd_manager.prd_dir / f"{slug}.txt"
|
|
423
|
+
prd_file.write_text(description)
|
|
424
|
+
workflow.prd_manager.reload()
|
|
425
|
+
self.console.print(f"\n[green]Added PRD: {prd_file.name}[/green]")
|
|
426
|
+
|
|
427
|
+
def _show_settings_menu(self) -> None:
|
|
428
|
+
"""Show the settings menu."""
|
|
429
|
+
while True:
|
|
430
|
+
self.console.print()
|
|
431
|
+
|
|
432
|
+
setting = questionary.select(
|
|
433
|
+
"Settings",
|
|
434
|
+
choices=[
|
|
435
|
+
{"name": f"Worker model: {self._config.worker_model}", "value": "worker_model"},
|
|
436
|
+
{"name": f"Summary model: {self._config.summary_model}", "value": "summary_model"},
|
|
437
|
+
{"name": f"Harness: {self._config.harness}", "value": "harness"},
|
|
438
|
+
{"name": f"Max iterations: {self._config.max_iterations}", "value": "max_iter"},
|
|
439
|
+
{"name": f"Max story attempts: {self._config.max_story_attempts}", "value": "max_attempts"},
|
|
440
|
+
{"name": f"Branch prefix: {self._config.branch_prefix}", "value": "branch_prefix"},
|
|
441
|
+
{"name": f"On error: {self._config.on_error}", "value": "on_error"},
|
|
442
|
+
{"name": f"Auto spec: {self._config.auto_spec_without_oversight}", "value": "auto_spec"},
|
|
443
|
+
{"name": f"Wait on rate limit: {self._config.wait_on_rate_limit}", "value": "rate_limit"},
|
|
444
|
+
{"name": f"Pause on completion: {self._config.pause_on_completion}", "value": "pause_completion"},
|
|
445
|
+
{"name": f"Always build tests: {self._config.always_build_tests}", "value": "tests"},
|
|
446
|
+
{"name": "Back", "value": "back"},
|
|
447
|
+
],
|
|
448
|
+
style=MENU_STYLE,
|
|
449
|
+
).ask()
|
|
450
|
+
|
|
451
|
+
if setting == "back" or setting is None:
|
|
452
|
+
break
|
|
453
|
+
elif setting == "worker_model":
|
|
454
|
+
self._show_model_selection("worker")
|
|
455
|
+
elif setting == "summary_model":
|
|
456
|
+
self._show_model_selection("summary")
|
|
457
|
+
elif setting == "harness":
|
|
458
|
+
self._show_harness_selection()
|
|
459
|
+
elif setting == "max_iter":
|
|
460
|
+
max_iter = questionary.text(
|
|
461
|
+
"Max iterations:",
|
|
462
|
+
default=str(self._config.max_iterations),
|
|
463
|
+
style=MENU_STYLE,
|
|
464
|
+
).ask()
|
|
465
|
+
if max_iter and max_iter.isdigit():
|
|
466
|
+
self._config.max_iterations = int(max_iter)
|
|
467
|
+
elif setting == "max_attempts":
|
|
468
|
+
max_attempts = questionary.text(
|
|
469
|
+
"Max story attempts (before marking blocked):",
|
|
470
|
+
default=str(self._config.max_story_attempts),
|
|
471
|
+
style=MENU_STYLE,
|
|
472
|
+
).ask()
|
|
473
|
+
if max_attempts and max_attempts.isdigit():
|
|
474
|
+
self._config.max_story_attempts = int(max_attempts)
|
|
475
|
+
elif setting == "branch_prefix":
|
|
476
|
+
prefix = questionary.text(
|
|
477
|
+
"Branch prefix (e.g., 'ralph' creates 'ralph/feature-name'):",
|
|
478
|
+
default=self._config.branch_prefix,
|
|
479
|
+
style=MENU_STYLE,
|
|
480
|
+
).ask()
|
|
481
|
+
if prefix:
|
|
482
|
+
self._config.branch_prefix = prefix
|
|
483
|
+
self.console.print(f"[cyan]Branch prefix: {prefix}[/cyan]")
|
|
484
|
+
elif setting == "on_error":
|
|
485
|
+
on_error = questionary.select(
|
|
486
|
+
"On error:",
|
|
487
|
+
choices=["block", "retry", "pause", "skip"],
|
|
488
|
+
default=self._config.on_error,
|
|
489
|
+
style=MENU_STYLE,
|
|
490
|
+
).ask()
|
|
491
|
+
if on_error:
|
|
492
|
+
self._config.on_error = on_error
|
|
493
|
+
elif setting == "auto_spec":
|
|
494
|
+
self._config.auto_spec_without_oversight = not self._config.auto_spec_without_oversight
|
|
495
|
+
self.console.print(f"[cyan]Auto spec: {self._config.auto_spec_without_oversight}[/cyan]")
|
|
496
|
+
elif setting == "rate_limit":
|
|
497
|
+
self._config.wait_on_rate_limit = not self._config.wait_on_rate_limit
|
|
498
|
+
self.console.print(f"[cyan]Wait on rate limit: {self._config.wait_on_rate_limit}[/cyan]")
|
|
499
|
+
elif setting == "pause_completion":
|
|
500
|
+
self._config.pause_on_completion = not self._config.pause_on_completion
|
|
501
|
+
self.console.print(f"[cyan]Pause on completion: {self._config.pause_on_completion}[/cyan]")
|
|
502
|
+
elif setting == "tests":
|
|
503
|
+
self._config.always_build_tests = not self._config.always_build_tests
|
|
504
|
+
self.console.print(f"[cyan]Always build tests: {self._config.always_build_tests}[/cyan]")
|
|
505
|
+
|
|
506
|
+
def _show_model_selection(self, role: str) -> None:
|
|
507
|
+
"""Show harness-specific model selection menu."""
|
|
508
|
+
self.console.print()
|
|
509
|
+
|
|
510
|
+
# Get the current harness and query for supported models
|
|
511
|
+
harness = Harness.from_config(self._config.harness)
|
|
512
|
+
supported_models = harness.get_supported_models()
|
|
513
|
+
current_model = (
|
|
514
|
+
self._config.worker_model if role == "worker" else self._config.summary_model
|
|
515
|
+
)
|
|
516
|
+
role_label = "Worker model" if role == "worker" else "Summary model"
|
|
517
|
+
|
|
518
|
+
# For unknown harnesses (custom) with no models, fall back to text input
|
|
519
|
+
if not supported_models:
|
|
520
|
+
self.console.print(
|
|
521
|
+
f"[{NORD13}]No predefined models for harness '{harness.name}'.[/{NORD13}]"
|
|
522
|
+
)
|
|
523
|
+
model_name: str | None = questionary.text(
|
|
524
|
+
f"Enter {role_label.lower()} name:",
|
|
525
|
+
default=current_model,
|
|
526
|
+
style=MENU_STYLE,
|
|
527
|
+
).ask()
|
|
528
|
+
if model_name and model_name.strip():
|
|
529
|
+
if role == "worker":
|
|
530
|
+
self._config.worker_model = model_name.strip()
|
|
531
|
+
else:
|
|
532
|
+
self._config.summary_model = model_name.strip()
|
|
533
|
+
self.console.print(
|
|
534
|
+
f"[{NORD14}]{role_label} set to: {model_name.strip()}[/{NORD14}]"
|
|
535
|
+
)
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
# Build choices (model name only)
|
|
539
|
+
from questionary import Choice
|
|
540
|
+
|
|
541
|
+
choices: list[Choice] = []
|
|
542
|
+
|
|
543
|
+
for model_name, label in supported_models:
|
|
544
|
+
choices.append(Choice(title=model_name, value=model_name))
|
|
545
|
+
|
|
546
|
+
# No models available - show alert and return
|
|
547
|
+
if not choices:
|
|
548
|
+
self.console.bell()
|
|
549
|
+
self.console.print(
|
|
550
|
+
f"[{NORD11}]No models available for harness '{harness.name}'.[/{NORD11}]"
|
|
551
|
+
)
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
# Get the valid model values (excluding cancel)
|
|
555
|
+
valid_model_values: list[str] = [str(c.value) for c in choices]
|
|
556
|
+
|
|
557
|
+
# Add cancel option
|
|
558
|
+
choices.append(Choice(title="Cancel", value="cancel"))
|
|
559
|
+
|
|
560
|
+
# Determine the default - MUST be a value that exists in choices
|
|
561
|
+
if current_model in valid_model_values:
|
|
562
|
+
default_choice = current_model
|
|
563
|
+
else:
|
|
564
|
+
# Current model not valid - use first available model as default
|
|
565
|
+
default_choice = valid_model_values[0]
|
|
566
|
+
if role == "worker":
|
|
567
|
+
self._config.worker_model = default_choice
|
|
568
|
+
else:
|
|
569
|
+
self._config.summary_model = default_choice
|
|
570
|
+
self.console.print(
|
|
571
|
+
f"[{NORD13}]{role_label} '{current_model}' not supported by {harness.name}. "
|
|
572
|
+
f"Reset to: {default_choice}[/{NORD13}]"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
selected: str | None = questionary.select(
|
|
577
|
+
f"Select {role_label.lower()} (current: {current_model}):",
|
|
578
|
+
choices=choices,
|
|
579
|
+
default=default_choice,
|
|
580
|
+
style=MENU_STYLE,
|
|
581
|
+
).ask()
|
|
582
|
+
except ValueError as e:
|
|
583
|
+
# Debug info to diagnose questionary issues
|
|
584
|
+
self.console.print(f"[{NORD11}]Error in model selection:[/{NORD11}]")
|
|
585
|
+
self.console.print(f" default_choice: {default_choice!r}")
|
|
586
|
+
self.console.print(f" valid_model_values: {valid_model_values!r}")
|
|
587
|
+
choice_info = [(c.title, c.value) for c in choices]
|
|
588
|
+
self.console.print(f" choices: {choice_info!r}")
|
|
589
|
+
self.console.print(f" harness: {harness.name} (type={harness.type})")
|
|
590
|
+
self.console.print(f" {role_label.lower()}: {current_model!r}")
|
|
591
|
+
raise e
|
|
592
|
+
|
|
593
|
+
if selected and selected != "cancel":
|
|
594
|
+
if role == "worker":
|
|
595
|
+
self._config.worker_model = selected
|
|
596
|
+
else:
|
|
597
|
+
self._config.summary_model = selected
|
|
598
|
+
self.console.print(f"[{NORD14}]{role_label} set to: {selected}[/{NORD14}]")
|
|
599
|
+
|
|
600
|
+
def _show_harness_selection(self) -> None:
|
|
601
|
+
"""Show harness selection submenu with auto-detect, custom, and detected options."""
|
|
602
|
+
self.console.print()
|
|
603
|
+
|
|
604
|
+
# Run auto-detection to find available harnesses
|
|
605
|
+
detector = HarnessDetector()
|
|
606
|
+
detected_harnesses = detector.detect_all()
|
|
607
|
+
|
|
608
|
+
# Build choices list
|
|
609
|
+
choices: list[dict[str, str]] = []
|
|
610
|
+
|
|
611
|
+
# Add Auto-detect option first
|
|
612
|
+
choices.append({
|
|
613
|
+
"name": "Auto-detect (scan PATH for available tools)",
|
|
614
|
+
"value": "auto-detect",
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
# Add detected harnesses with full paths
|
|
618
|
+
for harness in detected_harnesses:
|
|
619
|
+
choices.append({
|
|
620
|
+
"name": f"{harness.name} ({harness.path})",
|
|
621
|
+
"value": harness.path,
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
# Add Custom option
|
|
625
|
+
choices.append({
|
|
626
|
+
"name": "Custom (enter path manually)",
|
|
627
|
+
"value": "custom",
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
# Add Cancel option
|
|
631
|
+
choices.append({
|
|
632
|
+
"name": "Cancel",
|
|
633
|
+
"value": "cancel",
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
result: str | None = questionary.select(
|
|
637
|
+
f"Select harness (current: {self._config.harness}):",
|
|
638
|
+
choices=choices,
|
|
639
|
+
style=MENU_STYLE,
|
|
640
|
+
).ask()
|
|
641
|
+
|
|
642
|
+
if result is None or result == "cancel":
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
if result == "auto-detect":
|
|
646
|
+
# Show auto-detection results
|
|
647
|
+
self._show_autodetect_results(detected_harnesses)
|
|
648
|
+
elif result == "custom":
|
|
649
|
+
# Prompt for custom path
|
|
650
|
+
self._prompt_custom_harness()
|
|
651
|
+
else:
|
|
652
|
+
# User selected a detected harness - save it
|
|
653
|
+
self._set_harness(result)
|
|
654
|
+
|
|
655
|
+
def _show_autodetect_results(self, detected_harnesses: list[Harness]) -> None:
|
|
656
|
+
"""Show auto-detection results and allow selection."""
|
|
657
|
+
self.console.print()
|
|
658
|
+
|
|
659
|
+
if not detected_harnesses:
|
|
660
|
+
self.console.print(f"[{NORD13}]No harnesses detected on PATH.[/{NORD13}]")
|
|
661
|
+
self.console.print(f"[{NORD3}]Looked for: claude, codex[/{NORD3}]")
|
|
662
|
+
self.console.print()
|
|
663
|
+
|
|
664
|
+
# Offer custom path option
|
|
665
|
+
enter_custom = questionary.confirm(
|
|
666
|
+
"Would you like to enter a custom harness path?",
|
|
667
|
+
default=True,
|
|
668
|
+
style=MENU_STYLE,
|
|
669
|
+
).ask()
|
|
670
|
+
|
|
671
|
+
if enter_custom:
|
|
672
|
+
self._prompt_custom_harness()
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
# Show detected harnesses
|
|
676
|
+
self.console.print(f"[{NORD14}]Detected harnesses:[/{NORD14}]")
|
|
677
|
+
for harness in detected_harnesses:
|
|
678
|
+
self.console.print(f" [{NORD8}]{harness.name}[/{NORD8}] [{NORD3}]({harness.path})[/{NORD3}]")
|
|
679
|
+
self.console.print()
|
|
680
|
+
|
|
681
|
+
# Build selection choices
|
|
682
|
+
choices: list[dict[str, str]] = []
|
|
683
|
+
for harness in detected_harnesses:
|
|
684
|
+
choices.append({
|
|
685
|
+
"name": f"{harness.name} ({harness.path})",
|
|
686
|
+
"value": harness.path,
|
|
687
|
+
})
|
|
688
|
+
choices.append({"name": "Cancel", "value": "cancel"})
|
|
689
|
+
|
|
690
|
+
selected: str | None = questionary.select(
|
|
691
|
+
"Select a harness to use:",
|
|
692
|
+
choices=choices,
|
|
693
|
+
style=MENU_STYLE,
|
|
694
|
+
).ask()
|
|
695
|
+
|
|
696
|
+
if selected and selected != "cancel":
|
|
697
|
+
self._set_harness(selected)
|
|
698
|
+
|
|
699
|
+
def _prompt_custom_harness(self) -> None:
|
|
700
|
+
"""Prompt user to enter a custom harness path."""
|
|
701
|
+
self.console.print()
|
|
702
|
+
|
|
703
|
+
custom_path: str | None = questionary.text(
|
|
704
|
+
"Enter harness path or command:",
|
|
705
|
+
style=MENU_STYLE,
|
|
706
|
+
).ask()
|
|
707
|
+
|
|
708
|
+
if custom_path and custom_path.strip():
|
|
709
|
+
self._set_harness(custom_path.strip())
|
|
710
|
+
|
|
711
|
+
def _set_harness(self, harness_path: str) -> None:
|
|
712
|
+
"""Set and validate a harness path, updating model if needed."""
|
|
713
|
+
self._config.harness = harness_path
|
|
714
|
+
|
|
715
|
+
# Validate the harness
|
|
716
|
+
harness_obj = Harness.from_config(harness_path)
|
|
717
|
+
if harness_obj.is_available:
|
|
718
|
+
self._harness_available = True
|
|
719
|
+
self.console.print(f"[{NORD14}]Harness set to: {harness_path}[/{NORD14}]")
|
|
720
|
+
else:
|
|
721
|
+
self._harness_available = False
|
|
722
|
+
self.console.print(f"[{NORD11}]Warning: Harness '{harness_path}' not found or not executable[/{NORD11}]")
|
|
723
|
+
self.console.print(f"[{NORD13}]The harness will be saved, but workflow will be disabled until a valid harness is configured.[/{NORD13}]")
|
|
724
|
+
|
|
725
|
+
# Check if current models are valid for the new harness
|
|
726
|
+
current_worker = self._config.worker_model
|
|
727
|
+
if not harness_obj.is_model_supported(current_worker):
|
|
728
|
+
default_worker = harness_obj.get_default_worker_model()
|
|
729
|
+
if default_worker:
|
|
730
|
+
self._config.worker_model = default_worker
|
|
731
|
+
self.console.print(
|
|
732
|
+
f"[{NORD13}]Worker model '{current_worker}' not supported by {harness_obj.name}. "
|
|
733
|
+
f"Switched to: {default_worker}[/{NORD13}]"
|
|
734
|
+
)
|
|
735
|
+
else:
|
|
736
|
+
self.console.print(
|
|
737
|
+
f"[{NORD13}]Note: Worker model '{current_worker}' may not be supported by {harness_obj.name}. "
|
|
738
|
+
f"You may need to configure a worker model in Settings.[/{NORD13}]"
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
current_summary = self._config.summary_model
|
|
742
|
+
if not harness_obj.is_model_supported(current_summary):
|
|
743
|
+
default_summary = harness_obj.get_default_summary_model()
|
|
744
|
+
if default_summary:
|
|
745
|
+
self._config.summary_model = default_summary
|
|
746
|
+
self.console.print(
|
|
747
|
+
f"[{NORD13}]Summary model '{current_summary}' not supported by {harness_obj.name}. "
|
|
748
|
+
f"Switched to: {default_summary}[/{NORD13}]"
|
|
749
|
+
)
|
|
750
|
+
else:
|
|
751
|
+
self.console.print(
|
|
752
|
+
f"[{NORD13}]Note: Summary model '{current_summary}' may not be supported by {harness_obj.name}. "
|
|
753
|
+
f"You may need to configure a summary model in Settings.[/{NORD13}]"
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
def _show_prd_list(self) -> None:
|
|
757
|
+
"""Show the current PRD list with Nord theme colors."""
|
|
758
|
+
workflow = self._get_workflow()
|
|
759
|
+
prds = workflow.prd_manager.get_all_prds()
|
|
760
|
+
|
|
761
|
+
self.console.print()
|
|
762
|
+
|
|
763
|
+
if not prds:
|
|
764
|
+
self.console.print(f"[{NORD3}]No PRDs yet. Add some tasks to get started![/{NORD3}]")
|
|
765
|
+
return
|
|
766
|
+
|
|
767
|
+
# Table with Nord frost blue border and Snow Storm title
|
|
768
|
+
table = Table(title=f"[{NORD6}]PRDs[/{NORD6}]", border_style=NORD10)
|
|
769
|
+
table.add_column("Status", style=NORD8, width=12)
|
|
770
|
+
table.add_column("Name", style=NORD4)
|
|
771
|
+
table.add_column("Type", style=NORD3, width=8)
|
|
772
|
+
|
|
773
|
+
# Status badges using Nord Aurora colors (US-007):
|
|
774
|
+
# unspecced = dim gray (NORD3)
|
|
775
|
+
# questions = aurora yellow (NORD13)
|
|
776
|
+
# pending = aurora orange (NORD12)
|
|
777
|
+
# in_progress = aurora green (NORD14)
|
|
778
|
+
# completed = aurora green dim (dim NORD14)
|
|
779
|
+
# errored = aurora red (NORD11)
|
|
780
|
+
status_styles = {
|
|
781
|
+
"unspecced": NORD3, # dim gray
|
|
782
|
+
"questions": NORD13, # aurora yellow
|
|
783
|
+
"pending": NORD12, # aurora orange
|
|
784
|
+
"in_progress": NORD14, # aurora green
|
|
785
|
+
"completed": f"dim {NORD14}", # aurora green dim
|
|
786
|
+
"errored": NORD11, # aurora red
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
for prd in prds:
|
|
790
|
+
status_style = status_styles.get(prd.status, NORD4)
|
|
791
|
+
prd_type = "specced" if prd.is_specced else "unspecced"
|
|
792
|
+
table.add_row(
|
|
793
|
+
f"[{status_style}]{prd.status}[/{status_style}]",
|
|
794
|
+
prd.name,
|
|
795
|
+
prd_type,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
self.console.print(table)
|
|
799
|
+
|
|
800
|
+
def _show_manage_stories(self) -> None:
|
|
801
|
+
"""Show UI to manage user stories (mark as passed/unblock)."""
|
|
802
|
+
workflow = self._get_workflow()
|
|
803
|
+
|
|
804
|
+
if not workflow.story_manager.has_tasks():
|
|
805
|
+
self.console.print(f"[{NORD3}]No stories to manage.[/{NORD3}]")
|
|
806
|
+
return
|
|
807
|
+
|
|
808
|
+
tasks_file = workflow.story_manager.get_tasks_file()
|
|
809
|
+
if not tasks_file:
|
|
810
|
+
return
|
|
811
|
+
|
|
812
|
+
# Outer loop: story selection list
|
|
813
|
+
while True:
|
|
814
|
+
# Reload stories to reflect any changes made
|
|
815
|
+
workflow.story_manager.reload()
|
|
816
|
+
tasks_file = workflow.story_manager.get_tasks_file()
|
|
817
|
+
if not tasks_file:
|
|
818
|
+
return
|
|
819
|
+
stories = tasks_file.user_stories
|
|
820
|
+
|
|
821
|
+
self.console.print()
|
|
822
|
+
|
|
823
|
+
# Build story choices (plain text - questionary doesn't support Rich markup)
|
|
824
|
+
max_attempts = self._config.max_story_attempts
|
|
825
|
+
choices = []
|
|
826
|
+
for story in stories:
|
|
827
|
+
if story.passes:
|
|
828
|
+
status = "PASSED"
|
|
829
|
+
elif story.needs_intervention:
|
|
830
|
+
status = "NEEDS HELP"
|
|
831
|
+
elif story.blocked or story.attempts >= max_attempts:
|
|
832
|
+
status = "BLOCKED"
|
|
833
|
+
elif story.attempts > 0:
|
|
834
|
+
status = f"{story.attempts} attempts"
|
|
835
|
+
else:
|
|
836
|
+
status = "pending"
|
|
837
|
+
|
|
838
|
+
choices.append({
|
|
839
|
+
"name": f"{story.id}: {story.title} ({status})",
|
|
840
|
+
"value": story.id,
|
|
841
|
+
})
|
|
842
|
+
choices.append({"name": "Back", "value": "back"})
|
|
843
|
+
|
|
844
|
+
story_id = questionary.select(
|
|
845
|
+
"Select a story to manage:",
|
|
846
|
+
choices=choices,
|
|
847
|
+
style=MENU_STYLE,
|
|
848
|
+
).ask()
|
|
849
|
+
|
|
850
|
+
if story_id == "back" or story_id is None:
|
|
851
|
+
return
|
|
852
|
+
|
|
853
|
+
# Find the story
|
|
854
|
+
story_match = next((s for s in stories if s.id == story_id), None)
|
|
855
|
+
if not story_match:
|
|
856
|
+
continue
|
|
857
|
+
story = story_match
|
|
858
|
+
|
|
859
|
+
# Inner loop: story actions until user selects Back
|
|
860
|
+
while True:
|
|
861
|
+
# Show story details
|
|
862
|
+
self.console.print(f"\n[{NORD6} bold]{story.id}: {story.title}[/{NORD6} bold]")
|
|
863
|
+
self.console.print(f"[{NORD4}]{story.description}[/{NORD4}]")
|
|
864
|
+
self.console.print(f"\n[{NORD8}]Acceptance Criteria:[/{NORD8}]")
|
|
865
|
+
for criterion in story.acceptance_criteria:
|
|
866
|
+
self.console.print(f" [{NORD4}]- {criterion}[/{NORD4}]")
|
|
867
|
+
intervention_str = f" | Needs Help: {story.needs_intervention}" if story.needs_intervention else ""
|
|
868
|
+
self.console.print(f"\n[{NORD3}]Attempts: {story.attempts} | Passes: {story.passes} | Blocked: {story.blocked}{intervention_str}[/{NORD3}]")
|
|
869
|
+
max_attempts = self._config.max_story_attempts
|
|
870
|
+
|
|
871
|
+
# Show action choices
|
|
872
|
+
action_choices = []
|
|
873
|
+
if story.notes:
|
|
874
|
+
action_choices.append({"name": "View notes", "value": "notes"})
|
|
875
|
+
action_choices.append({"name": "Edit acceptance criteria", "value": "edit_criteria"})
|
|
876
|
+
if not story.passes:
|
|
877
|
+
action_choices.append({"name": "Mark as PASSED", "value": "pass"})
|
|
878
|
+
if story.needs_intervention:
|
|
879
|
+
action_choices.append({"name": "Mark intervention complete (retry)", "value": "resolve_intervention"})
|
|
880
|
+
if not story.passes and story.attempts >= max_attempts and not story.blocked:
|
|
881
|
+
action_choices.append({"name": "Retry (reset attempts)", "value": "retry"})
|
|
882
|
+
if story.blocked and not story.needs_intervention:
|
|
883
|
+
action_choices.append({"name": "Unblock (reset attempts)", "value": "unblock"})
|
|
884
|
+
if story.passes:
|
|
885
|
+
action_choices.append({"name": "Mark as NOT passed", "value": "unpass"})
|
|
886
|
+
action_choices.append({"name": "Back", "value": "back"})
|
|
887
|
+
|
|
888
|
+
action = questionary.select(
|
|
889
|
+
"What would you like to do?",
|
|
890
|
+
choices=action_choices,
|
|
891
|
+
style=MENU_STYLE,
|
|
892
|
+
).ask()
|
|
893
|
+
|
|
894
|
+
if action == "back" or action is None:
|
|
895
|
+
break
|
|
896
|
+
elif action == "notes":
|
|
897
|
+
self._view_story_notes(story)
|
|
898
|
+
elif action == "edit_criteria":
|
|
899
|
+
self._edit_acceptance_criteria(story, workflow)
|
|
900
|
+
elif action == "pass":
|
|
901
|
+
story.passes = True
|
|
902
|
+
story.blocked = False
|
|
903
|
+
workflow.story_manager.update_story(story)
|
|
904
|
+
self.console.print(f"[{NORD14}]Marked {story.id} as PASSED[/{NORD14}]")
|
|
905
|
+
elif action == "resolve_intervention":
|
|
906
|
+
story.blocked = False
|
|
907
|
+
story.needs_intervention = False
|
|
908
|
+
story.attempts = 0
|
|
909
|
+
workflow.story_manager.update_story(story)
|
|
910
|
+
self.console.print(f"[{NORD14}]Intervention resolved for {story.id} (ready for retry)[/{NORD14}]")
|
|
911
|
+
elif action == "retry":
|
|
912
|
+
story.blocked = False
|
|
913
|
+
story.needs_intervention = False
|
|
914
|
+
story.attempts = 0
|
|
915
|
+
workflow.story_manager.update_story(story)
|
|
916
|
+
self.console.print(f"[{NORD14}]Reset attempts for {story.id} (ready for retry)[/{NORD14}]")
|
|
917
|
+
elif action == "unblock":
|
|
918
|
+
story.blocked = False
|
|
919
|
+
story.needs_intervention = False
|
|
920
|
+
story.attempts = 0
|
|
921
|
+
workflow.story_manager.update_story(story)
|
|
922
|
+
self.console.print(f"[{NORD14}]Unblocked {story.id} (attempts reset)[/{NORD14}]")
|
|
923
|
+
elif action == "unpass":
|
|
924
|
+
story.passes = False
|
|
925
|
+
workflow.story_manager.update_story(story)
|
|
926
|
+
self.console.print(f"[{NORD13}]Marked {story.id} as NOT passed[/{NORD13}]")
|
|
927
|
+
|
|
928
|
+
def _view_story_notes(self, story: UserStory) -> None:
|
|
929
|
+
"""View story notes in a pager for scrolling."""
|
|
930
|
+
if not story.notes:
|
|
931
|
+
self.console.print(f"[{NORD3}]No notes for this story.[/{NORD3}]")
|
|
932
|
+
return
|
|
933
|
+
|
|
934
|
+
# Build plain text notes content (pager doesn't handle Rich markup well)
|
|
935
|
+
notes_content = f"Notes for {story.id}: {story.title}\n"
|
|
936
|
+
notes_content += "=" * 60 + "\n\n"
|
|
937
|
+
notes_content += story.notes
|
|
938
|
+
notes_content += "\n\n" + "=" * 60
|
|
939
|
+
notes_content += "\n(Press 'q' to exit)"
|
|
940
|
+
|
|
941
|
+
# Use pager for scrollable output - plain text mode
|
|
942
|
+
with self.console.pager():
|
|
943
|
+
self.console.print(notes_content)
|
|
944
|
+
|
|
945
|
+
def _edit_acceptance_criteria(
|
|
946
|
+
self, story: UserStory, workflow: WorkflowEngine
|
|
947
|
+
) -> None:
|
|
948
|
+
"""Edit acceptance criteria for a story."""
|
|
949
|
+
self.console.print(f"\n[{NORD8}]Editing acceptance criteria for {story.id}[/{NORD8}]")
|
|
950
|
+
self.console.print(f"[{NORD3}]Current criteria:[/{NORD3}]")
|
|
951
|
+
|
|
952
|
+
for i, criterion in enumerate(story.acceptance_criteria, 1):
|
|
953
|
+
self.console.print(f" [{NORD4}]{i}. {criterion}[/{NORD4}]")
|
|
954
|
+
|
|
955
|
+
self.console.print()
|
|
956
|
+
|
|
957
|
+
while True:
|
|
958
|
+
action = questionary.select(
|
|
959
|
+
"What would you like to do?",
|
|
960
|
+
choices=[
|
|
961
|
+
{"name": "Edit a criterion", "value": "edit"},
|
|
962
|
+
{"name": "Add a criterion", "value": "add"},
|
|
963
|
+
{"name": "Remove a criterion", "value": "remove"},
|
|
964
|
+
{"name": "Done", "value": "done"},
|
|
965
|
+
],
|
|
966
|
+
style=MENU_STYLE,
|
|
967
|
+
).ask()
|
|
968
|
+
|
|
969
|
+
if action == "done" or action is None:
|
|
970
|
+
break
|
|
971
|
+
elif action == "edit":
|
|
972
|
+
self._edit_single_criterion(story, workflow)
|
|
973
|
+
elif action == "add":
|
|
974
|
+
self._add_criterion(story, workflow)
|
|
975
|
+
elif action == "remove":
|
|
976
|
+
self._remove_criterion(story, workflow)
|
|
977
|
+
|
|
978
|
+
def _edit_single_criterion(
|
|
979
|
+
self, story: UserStory, workflow: WorkflowEngine
|
|
980
|
+
) -> None:
|
|
981
|
+
"""Edit a single acceptance criterion."""
|
|
982
|
+
if not story.acceptance_criteria:
|
|
983
|
+
self.console.print(f"[{NORD13}]No criteria to edit.[/{NORD13}]")
|
|
984
|
+
return
|
|
985
|
+
|
|
986
|
+
# Build choices for criteria
|
|
987
|
+
choices = [
|
|
988
|
+
{"name": f"{i}. {c}", "value": i - 1}
|
|
989
|
+
for i, c in enumerate(story.acceptance_criteria, 1)
|
|
990
|
+
]
|
|
991
|
+
choices.append({"name": "Cancel", "value": -1})
|
|
992
|
+
|
|
993
|
+
idx = questionary.select(
|
|
994
|
+
"Select criterion to edit:",
|
|
995
|
+
choices=choices,
|
|
996
|
+
style=MENU_STYLE,
|
|
997
|
+
).ask()
|
|
998
|
+
|
|
999
|
+
if idx is None or idx == -1:
|
|
1000
|
+
return
|
|
1001
|
+
|
|
1002
|
+
current = story.acceptance_criteria[idx]
|
|
1003
|
+
new_value = questionary.text(
|
|
1004
|
+
"Edit criterion:",
|
|
1005
|
+
default=current,
|
|
1006
|
+
style=MENU_STYLE,
|
|
1007
|
+
).ask()
|
|
1008
|
+
|
|
1009
|
+
if new_value and new_value.strip() and new_value != current:
|
|
1010
|
+
story.acceptance_criteria[idx] = new_value.strip()
|
|
1011
|
+
workflow.story_manager.update_story(story)
|
|
1012
|
+
self.console.print(f"[{NORD14}]Criterion updated.[/{NORD14}]")
|
|
1013
|
+
|
|
1014
|
+
def _add_criterion(self, story: UserStory, workflow: WorkflowEngine) -> None:
|
|
1015
|
+
"""Add a new acceptance criterion."""
|
|
1016
|
+
new_criterion = questionary.text(
|
|
1017
|
+
"Enter new criterion:",
|
|
1018
|
+
style=MENU_STYLE,
|
|
1019
|
+
).ask()
|
|
1020
|
+
|
|
1021
|
+
if new_criterion and new_criterion.strip():
|
|
1022
|
+
story.acceptance_criteria.append(new_criterion.strip())
|
|
1023
|
+
workflow.story_manager.update_story(story)
|
|
1024
|
+
self.console.print(f"[{NORD14}]Criterion added.[/{NORD14}]")
|
|
1025
|
+
|
|
1026
|
+
def _remove_criterion(
|
|
1027
|
+
self, story: UserStory, workflow: WorkflowEngine
|
|
1028
|
+
) -> None:
|
|
1029
|
+
"""Remove an acceptance criterion."""
|
|
1030
|
+
if not story.acceptance_criteria:
|
|
1031
|
+
self.console.print(f"[{NORD13}]No criteria to remove.[/{NORD13}]")
|
|
1032
|
+
return
|
|
1033
|
+
|
|
1034
|
+
if len(story.acceptance_criteria) == 1:
|
|
1035
|
+
self.console.print(
|
|
1036
|
+
f"[{NORD11}]Cannot remove the last criterion. Stories must have at least one.[/{NORD11}]"
|
|
1037
|
+
)
|
|
1038
|
+
return
|
|
1039
|
+
|
|
1040
|
+
# Build choices for criteria
|
|
1041
|
+
choices = [
|
|
1042
|
+
{"name": f"{i}. {c}", "value": i - 1}
|
|
1043
|
+
for i, c in enumerate(story.acceptance_criteria, 1)
|
|
1044
|
+
]
|
|
1045
|
+
choices.append({"name": "Cancel", "value": -1})
|
|
1046
|
+
|
|
1047
|
+
idx = questionary.select(
|
|
1048
|
+
"Select criterion to remove:",
|
|
1049
|
+
choices=choices,
|
|
1050
|
+
style=MENU_STYLE,
|
|
1051
|
+
).ask()
|
|
1052
|
+
|
|
1053
|
+
if idx is None or idx == -1:
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
removed = story.acceptance_criteria.pop(idx)
|
|
1057
|
+
workflow.story_manager.update_story(story)
|
|
1058
|
+
self.console.print(f"[{NORD14}]Removed: {removed}[/{NORD14}]")
|
|
1059
|
+
|
|
1060
|
+
def _show_answer_questions(self) -> None:
|
|
1061
|
+
"""Show UI to answer PRD questions."""
|
|
1062
|
+
workflow = self._get_workflow()
|
|
1063
|
+
prds_with_questions = workflow.get_prds_with_questions()
|
|
1064
|
+
|
|
1065
|
+
if not prds_with_questions:
|
|
1066
|
+
self.console.print("[dim]No PRDs have open questions.[/dim]")
|
|
1067
|
+
return
|
|
1068
|
+
|
|
1069
|
+
for prd in prds_with_questions:
|
|
1070
|
+
self.console.print(f"\n[bold cyan]PRD: {prd.name}[/bold cyan]")
|
|
1071
|
+
self.console.print(f"[dim]{prd.description[:200]}...[/dim]\n")
|
|
1072
|
+
|
|
1073
|
+
answers = []
|
|
1074
|
+
for question in prd.questions:
|
|
1075
|
+
self.console.print(f"[yellow]Q: {question.question}[/yellow]")
|
|
1076
|
+
|
|
1077
|
+
if question.options:
|
|
1078
|
+
# Multi-choice question
|
|
1079
|
+
answer = questionary.select(
|
|
1080
|
+
"Select an answer:",
|
|
1081
|
+
choices=question.options + ["Other (type answer)"],
|
|
1082
|
+
style=MENU_STYLE,
|
|
1083
|
+
).ask()
|
|
1084
|
+
|
|
1085
|
+
if answer is None:
|
|
1086
|
+
return
|
|
1087
|
+
|
|
1088
|
+
if answer == "Other (type answer)":
|
|
1089
|
+
answer = questionary.text(
|
|
1090
|
+
"Your answer:",
|
|
1091
|
+
style=MENU_STYLE,
|
|
1092
|
+
).ask()
|
|
1093
|
+
if answer is None:
|
|
1094
|
+
return
|
|
1095
|
+
else:
|
|
1096
|
+
# Free-form question
|
|
1097
|
+
answer = questionary.text(
|
|
1098
|
+
"Your answer:",
|
|
1099
|
+
style=MENU_STYLE,
|
|
1100
|
+
).ask()
|
|
1101
|
+
if answer is None:
|
|
1102
|
+
return
|
|
1103
|
+
|
|
1104
|
+
answers.append(answer)
|
|
1105
|
+
self.console.print(f"[green]A: {answer}[/green]\n")
|
|
1106
|
+
|
|
1107
|
+
# Confirm and save
|
|
1108
|
+
confirm = questionary.confirm(
|
|
1109
|
+
f"Save answers for '{prd.name}'?",
|
|
1110
|
+
default=True,
|
|
1111
|
+
style=MENU_STYLE,
|
|
1112
|
+
).ask()
|
|
1113
|
+
|
|
1114
|
+
if confirm is None:
|
|
1115
|
+
return
|
|
1116
|
+
|
|
1117
|
+
if confirm:
|
|
1118
|
+
workflow.answer_questions(prd, answers)
|
|
1119
|
+
self.console.print(f"[green]Saved answers for {prd.name}[/green]")
|
|
1120
|
+
|
|
1121
|
+
# Resume workflow if it was paused for questions
|
|
1122
|
+
if workflow.state == WorkflowState.QUESTIONS:
|
|
1123
|
+
workflow.resume()
|
|
1124
|
+
|
|
1125
|
+
def run(self) -> None:
|
|
1126
|
+
"""Run the main application loop."""
|
|
1127
|
+
self._running = True
|
|
1128
|
+
workflow = self._get_workflow()
|
|
1129
|
+
|
|
1130
|
+
# Show header
|
|
1131
|
+
self._show_header()
|
|
1132
|
+
|
|
1133
|
+
if self.debug:
|
|
1134
|
+
self.console.print("[yellow]Debug mode enabled[/yellow]\n")
|
|
1135
|
+
|
|
1136
|
+
# Validate harness on startup
|
|
1137
|
+
self._validate_harness()
|
|
1138
|
+
|
|
1139
|
+
# Notify if workflow was paused from previous session
|
|
1140
|
+
if workflow.is_paused:
|
|
1141
|
+
self.console.print("[yellow]Workflow was paused from previous session.[/yellow]")
|
|
1142
|
+
if workflow.current_task:
|
|
1143
|
+
self.console.print(f"[yellow]PRD in progress: {workflow.current_task.name}[/yellow]")
|
|
1144
|
+
self.console.print()
|
|
1145
|
+
|
|
1146
|
+
while self._running:
|
|
1147
|
+
# Show status
|
|
1148
|
+
self.console.print(self._build_status_panel())
|
|
1149
|
+
|
|
1150
|
+
# Get user choice
|
|
1151
|
+
try:
|
|
1152
|
+
choice = self._main_menu()
|
|
1153
|
+
|
|
1154
|
+
if choice is None or choice == "quit":
|
|
1155
|
+
confirm = questionary.confirm(
|
|
1156
|
+
"Are you sure you want to quit?",
|
|
1157
|
+
default=False,
|
|
1158
|
+
style=MENU_STYLE,
|
|
1159
|
+
).ask()
|
|
1160
|
+
if confirm:
|
|
1161
|
+
self._running = False
|
|
1162
|
+
elif choice == "add":
|
|
1163
|
+
self._show_add_task_menu()
|
|
1164
|
+
elif choice == "answer":
|
|
1165
|
+
self._show_answer_questions()
|
|
1166
|
+
elif choice == "settings":
|
|
1167
|
+
self._show_settings_menu()
|
|
1168
|
+
elif choice == "run" or choice == "resume":
|
|
1169
|
+
self._run_workflow()
|
|
1170
|
+
elif choice == "pause":
|
|
1171
|
+
workflow.pause()
|
|
1172
|
+
self.console.print("[yellow]Paused[/yellow]")
|
|
1173
|
+
elif choice == "list":
|
|
1174
|
+
self._show_prd_list()
|
|
1175
|
+
elif choice == "stories":
|
|
1176
|
+
self._show_manage_stories()
|
|
1177
|
+
|
|
1178
|
+
except KeyboardInterrupt:
|
|
1179
|
+
self.console.print("\n[yellow]Interrupted[/yellow]")
|
|
1180
|
+
confirm = questionary.confirm(
|
|
1181
|
+
"Are you sure you want to quit?",
|
|
1182
|
+
default=False,
|
|
1183
|
+
style=MENU_STYLE,
|
|
1184
|
+
).ask()
|
|
1185
|
+
if confirm:
|
|
1186
|
+
self._running = False
|
|
1187
|
+
|
|
1188
|
+
# Goodbye message with Ralph - Nord theme colors
|
|
1189
|
+
self.console.print(Panel(
|
|
1190
|
+
RALPH_ART_SMALL + f"\n[{NORD4}]Goodbye![/{NORD4}]",
|
|
1191
|
+
border_style=NORD8,
|
|
1192
|
+
))
|
|
1193
|
+
|
|
1194
|
+
def _build_live_status(self) -> Panel:
|
|
1195
|
+
"""Build a live status panel for workflow execution with Nord theme colors."""
|
|
1196
|
+
workflow = self._get_workflow()
|
|
1197
|
+
stats = workflow.prd_manager.get_stats()
|
|
1198
|
+
|
|
1199
|
+
# State colors using Nord palette (US-006)
|
|
1200
|
+
state_colors = {
|
|
1201
|
+
WorkflowState.IDLE: NORD3, # dim (Polar Night)
|
|
1202
|
+
WorkflowState.SPECCING: NORD8, # frost (Frost cyan)
|
|
1203
|
+
WorkflowState.QUESTIONS: NORD8, # frost (Frost cyan)
|
|
1204
|
+
WorkflowState.CONVERTING: NORD8, # frost (Frost cyan)
|
|
1205
|
+
WorkflowState.PICKING: NORD5, # snow (Snow Storm)
|
|
1206
|
+
WorkflowState.IMPLEMENTING: NORD14, # aurora green
|
|
1207
|
+
WorkflowState.REVIEWING: NORD8, # frost cyan
|
|
1208
|
+
WorkflowState.TESTING: NORD10, # frost blue
|
|
1209
|
+
WorkflowState.COMMITTING: NORD15, # aurora purple
|
|
1210
|
+
WorkflowState.ARCHIVING: NORD14, # aurora green
|
|
1211
|
+
WorkflowState.PAUSED: NORD13, # aurora yellow
|
|
1212
|
+
WorkflowState.ERROR: NORD11, # aurora red
|
|
1213
|
+
}
|
|
1214
|
+
state_color = state_colors.get(workflow.state, NORD4)
|
|
1215
|
+
|
|
1216
|
+
lines = []
|
|
1217
|
+
|
|
1218
|
+
# Current state with spinner for active states
|
|
1219
|
+
is_active = (workflow.state in SPINNER_STATES) and (not workflow.is_paused)
|
|
1220
|
+
if is_active:
|
|
1221
|
+
# Update spinner message and get current frame
|
|
1222
|
+
message = self._state_message or SPINNER_MESSAGES.get(
|
|
1223
|
+
workflow.state, workflow.state.value.upper()
|
|
1224
|
+
)
|
|
1225
|
+
self._spinner.message = message
|
|
1226
|
+
self._spinner.spinner_color = state_color
|
|
1227
|
+
spinner_frame = self._spinner.current_frame
|
|
1228
|
+
self._spinner.next_frame()
|
|
1229
|
+
lines.append(f"[bold {state_color}]{spinner_frame} {message}[/bold {state_color}]")
|
|
1230
|
+
else:
|
|
1231
|
+
lines.append(f"[bold {state_color}]● {workflow.state.value.upper()}[/bold {state_color}]")
|
|
1232
|
+
|
|
1233
|
+
# Current PRD - Snow Storm for text
|
|
1234
|
+
if workflow.current_task:
|
|
1235
|
+
lines.append(f" [{NORD6} bold]PRD:[/{NORD6} bold] [{NORD4}]{workflow.current_task.name}[/{NORD4}]")
|
|
1236
|
+
|
|
1237
|
+
# Current story
|
|
1238
|
+
if workflow.current_story:
|
|
1239
|
+
lines.append(f" [{NORD6} bold]Story:[/{NORD6} bold] [{NORD4}]{workflow.current_story.id} - {workflow.current_story.title}[/{NORD4}]")
|
|
1240
|
+
|
|
1241
|
+
# Last output - Polar Night for dim text
|
|
1242
|
+
if self._last_output:
|
|
1243
|
+
lines.append(f" [{NORD3}]{self._last_output}[/{NORD3}]")
|
|
1244
|
+
|
|
1245
|
+
lines.append("")
|
|
1246
|
+
|
|
1247
|
+
# PRD stats: Unspecced | Questions | Pending | In Progress | Complete | Errored
|
|
1248
|
+
# PRD status colors (US-007):
|
|
1249
|
+
# unspecced = dim gray (NORD3)
|
|
1250
|
+
# questions = aurora yellow (NORD13)
|
|
1251
|
+
# pending = aurora orange (NORD12)
|
|
1252
|
+
# in_progress = aurora green (NORD14)
|
|
1253
|
+
# completed = aurora green dim (NORD14 dim)
|
|
1254
|
+
# errored = aurora red (NORD11)
|
|
1255
|
+
parts = []
|
|
1256
|
+
if stats['unspecced'] > 0:
|
|
1257
|
+
parts.append(f"[{NORD3}]{stats['unspecced']} unspecced[/{NORD3}]")
|
|
1258
|
+
if stats['questions'] > 0:
|
|
1259
|
+
parts.append(f"[{NORD13}]{stats['questions']} questions[/{NORD13}]")
|
|
1260
|
+
parts.append(f"[{NORD12}]{stats['pending']} pending[/{NORD12}]")
|
|
1261
|
+
parts.append(f"[{NORD14}]{stats['in_progress']} in progress[/{NORD14}]")
|
|
1262
|
+
parts.append(f"[dim {NORD14}]{stats['completed']} complete[/dim {NORD14}]")
|
|
1263
|
+
if stats['errored'] > 0:
|
|
1264
|
+
parts.append(f"[{NORD11}]{stats['errored']} errored[/{NORD11}]")
|
|
1265
|
+
lines.append(f"[{NORD6} bold]PRDs:[/{NORD6} bold] [{NORD4}]{' | '.join(parts)}[/{NORD4}]")
|
|
1266
|
+
|
|
1267
|
+
# Story progress (if tasks.json exists)
|
|
1268
|
+
story_completed, story_total, story_percent = workflow.get_story_progress()
|
|
1269
|
+
if story_total > 0:
|
|
1270
|
+
lines.append(f"[{NORD6} bold]Stories:[/{NORD6} bold] [{NORD4}]{story_percent}% ({story_completed}/{story_total} complete)[/{NORD4}]")
|
|
1271
|
+
|
|
1272
|
+
lines.append(f"[{NORD3}]Session: {workflow.completed_this_session} PRDs completed[/{NORD3}]")
|
|
1273
|
+
|
|
1274
|
+
# Error message - Nord aurora red
|
|
1275
|
+
if workflow.error_message:
|
|
1276
|
+
lines.append(f"\n[{NORD11}]Error: {workflow.error_message}[/{NORD11}]")
|
|
1277
|
+
|
|
1278
|
+
lines.append(f"\n[{NORD3}]Press Ctrl+C to pause[/{NORD3}]")
|
|
1279
|
+
|
|
1280
|
+
content = "\n".join(lines)
|
|
1281
|
+
# Panel with Nord aurora green border (active workflow) and Snow Storm title
|
|
1282
|
+
return Panel(
|
|
1283
|
+
content,
|
|
1284
|
+
title=f"[{NORD6} bold]Workflow Running[/{NORD6} bold]",
|
|
1285
|
+
border_style=NORD14,
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
def _run_workflow(self) -> None:
|
|
1289
|
+
"""Run the workflow engine."""
|
|
1290
|
+
workflow = self._get_workflow()
|
|
1291
|
+
|
|
1292
|
+
if workflow.is_paused:
|
|
1293
|
+
workflow.resume()
|
|
1294
|
+
|
|
1295
|
+
self.console.print()
|
|
1296
|
+
self._last_status_key = None # Reset change detection
|
|
1297
|
+
|
|
1298
|
+
try:
|
|
1299
|
+
# Higher refresh rate for spinner animation
|
|
1300
|
+
with Live(self._build_live_status(), refresh_per_second=10, console=self.console) as live:
|
|
1301
|
+
self._live = live
|
|
1302
|
+
self._spinner.reset()
|
|
1303
|
+
while workflow.step():
|
|
1304
|
+
# Always update to animate spinner during active states
|
|
1305
|
+
live.update(self._build_live_status())
|
|
1306
|
+
self._live = None
|
|
1307
|
+
|
|
1308
|
+
except KeyboardInterrupt:
|
|
1309
|
+
self._live = None
|
|
1310
|
+
workflow.pause()
|
|
1311
|
+
self.console.print("\n[yellow]Paused by user[/yellow]")
|
|
1312
|
+
|
|
1313
|
+
# Show final status
|
|
1314
|
+
if workflow.state == WorkflowState.IDLE:
|
|
1315
|
+
stats = workflow.prd_manager.get_stats()
|
|
1316
|
+
if stats["pending"] == 0 and stats["unspecced"] == 0:
|
|
1317
|
+
self.console.print("\n[green]All PRDs completed![/green]")
|
|
1318
|
+
else:
|
|
1319
|
+
self.console.print(f"\n[yellow]Paused. {stats['pending']} PRDs pending.[/yellow]")
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def main(project_dir: Path | None = None, debug: bool = False) -> None:
|
|
1323
|
+
"""Main entry point for the application."""
|
|
1324
|
+
if project_dir is None:
|
|
1325
|
+
project_dir = Path.cwd()
|
|
1326
|
+
|
|
1327
|
+
app = RalphApp(project_dir, debug=debug)
|
|
1328
|
+
app.run()
|