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/spinner.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Rich-based spinner utility module for ralph-coding.
|
|
2
|
+
|
|
3
|
+
This module provides spinner functionality using Rich's rendering system,
|
|
4
|
+
allowing spinners to be embedded in Live displays and panels.
|
|
5
|
+
|
|
6
|
+
The module provides:
|
|
7
|
+
- Multiple braille spinner styles including 6-dot rotating pattern
|
|
8
|
+
- Integration with Rich's Live display system
|
|
9
|
+
- Nord theme color support
|
|
10
|
+
- Thread-safe operation for background spinning
|
|
11
|
+
|
|
12
|
+
Usage Example:
|
|
13
|
+
from ralph.spinner import RichSpinner, SpinnerStyle
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
|
|
17
|
+
# Create spinner for use in Live display
|
|
18
|
+
spinner = RichSpinner("Processing...", style=SpinnerStyle.DOTS_6)
|
|
19
|
+
|
|
20
|
+
with Live(Panel(spinner), refresh_per_second=10) as live:
|
|
21
|
+
while working:
|
|
22
|
+
live.update(Panel(spinner)) # Spinner auto-advances
|
|
23
|
+
|
|
24
|
+
# Or use standalone with context manager
|
|
25
|
+
with RichSpinner("Loading...") as spinner:
|
|
26
|
+
do_work()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from enum import Enum
|
|
35
|
+
from typing import Iterator
|
|
36
|
+
|
|
37
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
38
|
+
from rich.live import Live
|
|
39
|
+
from rich.style import Style
|
|
40
|
+
from rich.text import Text
|
|
41
|
+
|
|
42
|
+
from ralph.colors import FROST_CYAN, FG_PRIMARY
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SpinnerStyle(Enum):
|
|
46
|
+
"""Available spinner animation styles using braille patterns.
|
|
47
|
+
|
|
48
|
+
DOTS_6: Six dots rotating around the braille cell (2x3 pattern)
|
|
49
|
+
DOTS_8: Eight dots rotating around the full braille cell (2x4 pattern)
|
|
50
|
+
DOTS_BOUNCE: Dots bouncing left-right pattern
|
|
51
|
+
DOTS_GROW: Dots growing/shrinking pattern
|
|
52
|
+
CLASSIC: Traditional |/-\\ spinner
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# Six dots rotating around the 2x3 braille cell
|
|
56
|
+
# Pattern: top-left -> mid-left -> bot-left -> top-right -> mid-right -> bot-right
|
|
57
|
+
DOTS_6 = ("⠁", "⠂", "⠄", "⠈", "⠐", "⠠")
|
|
58
|
+
|
|
59
|
+
# Eight dots rotating around full 2x4 braille cell
|
|
60
|
+
DOTS_8 = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷")
|
|
61
|
+
|
|
62
|
+
# Classic braille dots pattern (10 frames)
|
|
63
|
+
DOTS = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
64
|
+
|
|
65
|
+
# Dots bouncing
|
|
66
|
+
DOTS_BOUNCE = ("⠁", "⠂", "⠄", "⠂")
|
|
67
|
+
|
|
68
|
+
# Growing dots
|
|
69
|
+
DOTS_GROW = ("⠀", "⠁", "⠃", "⠇", "⠏", "⠟", "⠿", "⠟", "⠏", "⠇", "⠃", "⠁")
|
|
70
|
+
|
|
71
|
+
# Classic ASCII spinner
|
|
72
|
+
CLASSIC = ("|", "/", "-", "\\")
|
|
73
|
+
|
|
74
|
+
# Circle quadrants
|
|
75
|
+
CIRCLE = ("◴", "◷", "◶", "◵")
|
|
76
|
+
|
|
77
|
+
# Half circles
|
|
78
|
+
HALF_CIRCLE = ("◐", "◓", "◑", "◒")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class RichSpinner:
|
|
83
|
+
"""A Rich-compatible spinner that can be rendered in Live displays.
|
|
84
|
+
|
|
85
|
+
This spinner implements Rich's console protocol, allowing it to be
|
|
86
|
+
used directly in Rich's rendering system including Live, Panel, etc.
|
|
87
|
+
|
|
88
|
+
The spinner automatically advances frames on each render, or can be
|
|
89
|
+
manually advanced with next_frame().
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
message: Text displayed next to the spinner.
|
|
93
|
+
style: The spinner animation style.
|
|
94
|
+
spinner_color: Color for the spinner character.
|
|
95
|
+
message_color: Color for the message text.
|
|
96
|
+
interval: Time between frames in seconds (for threaded mode).
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
# In a Live display (auto-advances on render)
|
|
100
|
+
spinner = RichSpinner("Loading...")
|
|
101
|
+
with Live(spinner, refresh_per_second=10):
|
|
102
|
+
time.sleep(2)
|
|
103
|
+
|
|
104
|
+
# Standalone with threading
|
|
105
|
+
with RichSpinner("Working...") as spinner:
|
|
106
|
+
time.sleep(2)
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
message: str = ""
|
|
110
|
+
style: SpinnerStyle = SpinnerStyle.DOTS_6
|
|
111
|
+
spinner_color: str = FROST_CYAN
|
|
112
|
+
message_color: str = FG_PRIMARY
|
|
113
|
+
interval: float = 0.1
|
|
114
|
+
|
|
115
|
+
_frame_index: int = field(default=0, init=False, repr=False)
|
|
116
|
+
_running: bool = field(default=False, init=False, repr=False)
|
|
117
|
+
_thread: threading.Thread | None = field(default=None, init=False, repr=False)
|
|
118
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
|
119
|
+
_console: Console | None = field(default=None, init=False, repr=False)
|
|
120
|
+
_live: Live | None = field(default=None, init=False, repr=False)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def frames(self) -> tuple[str, ...]:
|
|
124
|
+
"""Get the frame sequence for the current style."""
|
|
125
|
+
value: tuple[str, ...] = self.style.value
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def current_frame(self) -> str:
|
|
130
|
+
"""Get the current spinner frame character."""
|
|
131
|
+
return self.frames[self._frame_index % len(self.frames)]
|
|
132
|
+
|
|
133
|
+
def next_frame(self) -> str:
|
|
134
|
+
"""Advance to the next frame and return it."""
|
|
135
|
+
with self._lock:
|
|
136
|
+
self._frame_index = (self._frame_index + 1) % len(self.frames)
|
|
137
|
+
return self.current_frame
|
|
138
|
+
|
|
139
|
+
def reset(self) -> None:
|
|
140
|
+
"""Reset the spinner to the first frame."""
|
|
141
|
+
with self._lock:
|
|
142
|
+
self._frame_index = 0
|
|
143
|
+
|
|
144
|
+
def update_message(self, message: str) -> None:
|
|
145
|
+
"""Update the spinner message."""
|
|
146
|
+
self.message = message
|
|
147
|
+
|
|
148
|
+
def __rich_console__(
|
|
149
|
+
self, console: Console, options: ConsoleOptions
|
|
150
|
+
) -> RenderResult:
|
|
151
|
+
"""Render the spinner for Rich console output.
|
|
152
|
+
|
|
153
|
+
This method is called by Rich when rendering the spinner.
|
|
154
|
+
It advances the frame on each render for automatic animation
|
|
155
|
+
when used with Live displays.
|
|
156
|
+
"""
|
|
157
|
+
# Build the text with styled spinner and message
|
|
158
|
+
text = Text()
|
|
159
|
+
text.append(self.current_frame, style=Style(color=self.spinner_color))
|
|
160
|
+
if self.message:
|
|
161
|
+
text.append(" ")
|
|
162
|
+
text.append(self.message, style=Style(color=self.message_color))
|
|
163
|
+
|
|
164
|
+
# Advance frame for next render (makes Live auto-animate)
|
|
165
|
+
self.next_frame()
|
|
166
|
+
|
|
167
|
+
yield text
|
|
168
|
+
|
|
169
|
+
def _spin_loop(self) -> None:
|
|
170
|
+
"""Internal loop for threaded spinning."""
|
|
171
|
+
while self._running:
|
|
172
|
+
if self._live:
|
|
173
|
+
self._live.update(self)
|
|
174
|
+
time.sleep(self.interval)
|
|
175
|
+
|
|
176
|
+
def start(self, console: Console | None = None) -> None:
|
|
177
|
+
"""Start the spinner with its own Live display.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
console: Optional Rich console to use. Creates one if not provided.
|
|
181
|
+
"""
|
|
182
|
+
if self._running:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
self._console = console or Console()
|
|
186
|
+
self._running = True
|
|
187
|
+
self.reset()
|
|
188
|
+
|
|
189
|
+
# Create Live display for standalone usage
|
|
190
|
+
self._live = Live(
|
|
191
|
+
self,
|
|
192
|
+
console=self._console,
|
|
193
|
+
refresh_per_second=int(1 / self.interval),
|
|
194
|
+
transient=True,
|
|
195
|
+
)
|
|
196
|
+
self._live.start()
|
|
197
|
+
|
|
198
|
+
# Start background thread to keep updating
|
|
199
|
+
self._thread = threading.Thread(target=self._spin_loop, daemon=True)
|
|
200
|
+
self._thread.start()
|
|
201
|
+
|
|
202
|
+
def stop(self) -> None:
|
|
203
|
+
"""Stop the spinner and clean up."""
|
|
204
|
+
self._running = False
|
|
205
|
+
|
|
206
|
+
if self._thread:
|
|
207
|
+
self._thread.join(timeout=1.0)
|
|
208
|
+
self._thread = None
|
|
209
|
+
|
|
210
|
+
if self._live:
|
|
211
|
+
self._live.stop()
|
|
212
|
+
self._live = None
|
|
213
|
+
|
|
214
|
+
def __enter__(self) -> RichSpinner:
|
|
215
|
+
"""Context manager entry - start the spinner."""
|
|
216
|
+
self.start()
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
def __exit__(self, exc_type: type | None, exc_val: BaseException | None, exc_tb: object | None) -> None:
|
|
220
|
+
"""Context manager exit - stop the spinner."""
|
|
221
|
+
self.stop()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def create_spinner_text(
|
|
225
|
+
message: str = "",
|
|
226
|
+
style: SpinnerStyle = SpinnerStyle.DOTS_6,
|
|
227
|
+
spinner_color: str = FROST_CYAN,
|
|
228
|
+
message_color: str = FG_PRIMARY,
|
|
229
|
+
frame_index: int = 0,
|
|
230
|
+
) -> Text:
|
|
231
|
+
"""Create a Rich Text object with spinner and message.
|
|
232
|
+
|
|
233
|
+
This is a utility function for creating spinner text without
|
|
234
|
+
the full RichSpinner class. Useful for manual frame control.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
message: Text to display after the spinner.
|
|
238
|
+
style: Spinner animation style.
|
|
239
|
+
spinner_color: Color for the spinner character.
|
|
240
|
+
message_color: Color for the message text.
|
|
241
|
+
frame_index: Which frame to display (0-indexed).
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Rich Text object with styled spinner and message.
|
|
245
|
+
|
|
246
|
+
Example:
|
|
247
|
+
for i in range(20):
|
|
248
|
+
text = create_spinner_text("Loading...", frame_index=i)
|
|
249
|
+
console.print(text, end="\\r")
|
|
250
|
+
time.sleep(0.1)
|
|
251
|
+
"""
|
|
252
|
+
frames = style.value
|
|
253
|
+
frame = frames[frame_index % len(frames)]
|
|
254
|
+
|
|
255
|
+
text = Text()
|
|
256
|
+
text.append(frame, style=Style(color=spinner_color))
|
|
257
|
+
if message:
|
|
258
|
+
text.append(" ")
|
|
259
|
+
text.append(message, style=Style(color=message_color))
|
|
260
|
+
|
|
261
|
+
return text
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# Convenience aliases for backwards compatibility
|
|
265
|
+
SpinnerManager = RichSpinner
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def spinner_frames(style: SpinnerStyle = SpinnerStyle.DOTS_6) -> Iterator[str]:
|
|
269
|
+
"""Generate infinite spinner frames.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
style: Spinner animation style.
|
|
273
|
+
|
|
274
|
+
Yields:
|
|
275
|
+
Spinner frame characters in sequence, repeating indefinitely.
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
frames = spinner_frames(SpinnerStyle.DOTS_6)
|
|
279
|
+
for _ in range(30):
|
|
280
|
+
print(next(frames), end="\\r", flush=True)
|
|
281
|
+
time.sleep(0.1)
|
|
282
|
+
"""
|
|
283
|
+
frames_tuple = style.value
|
|
284
|
+
index = 0
|
|
285
|
+
while True:
|
|
286
|
+
yield frames_tuple[index % len(frames_tuple)]
|
|
287
|
+
index += 1
|
ralph/storage.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Platform-specific storage paths for ralph-coding application."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from platformdirs import user_data_dir
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
APP_NAME = "ralph-coding"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_app_data_dir() -> Path:
|
|
11
|
+
"""
|
|
12
|
+
Get the platform-specific application data directory.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
- Windows: %APPDATA%/ralph-coding/
|
|
16
|
+
- macOS: ~/Library/Application Support/ralph-coding/
|
|
17
|
+
- Linux: ~/.local/share/ralph-coding/
|
|
18
|
+
"""
|
|
19
|
+
data_dir = Path(user_data_dir(APP_NAME))
|
|
20
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return data_dir
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_config_path() -> Path:
|
|
25
|
+
"""Get the path to the global config file."""
|
|
26
|
+
return get_app_data_dir() / "config.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_project_ralph_dir(project_dir: Path) -> Path:
|
|
30
|
+
"""
|
|
31
|
+
Get the .ralph directory for a specific project.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
project_dir: The root directory of the project
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Path to the .ralph directory within the project
|
|
38
|
+
"""
|
|
39
|
+
ralph_dir = project_dir / ".ralph"
|
|
40
|
+
ralph_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return ralph_dir
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_project_tasks_path(project_dir: Path) -> Path:
|
|
45
|
+
"""Get the path to the project's tasks.json file."""
|
|
46
|
+
return get_project_ralph_dir(project_dir) / "tasks.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_project_state_path(project_dir: Path) -> Path:
|
|
50
|
+
"""Get the path to the project's state.json file."""
|
|
51
|
+
return get_project_ralph_dir(project_dir) / "state.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_project_logs_dir(project_dir: Path) -> Path:
|
|
55
|
+
"""Get the path to the project's logs directory (for debug mode)."""
|
|
56
|
+
logs_dir = get_project_ralph_dir(project_dir) / "logs"
|
|
57
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
return logs_dir
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_progress_md_path(project_dir: Path) -> Path:
|
|
62
|
+
"""Get the path to progress.md in the project root."""
|
|
63
|
+
return project_dir / "progress.md"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_learnings_md_path(project_dir: Path) -> Path:
|
|
67
|
+
"""Get the path to learnings.md in the project root."""
|
|
68
|
+
return project_dir / "learnings.md"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_summarised_notes_path(project_dir: Path) -> Path:
|
|
72
|
+
"""Get the path to summarised_notes.txt in the .ralph directory.
|
|
73
|
+
|
|
74
|
+
This file archives full story notes along with their summaries
|
|
75
|
+
when stories hit max attempts (debug mode only).
|
|
76
|
+
"""
|
|
77
|
+
return get_project_ralph_dir(project_dir) / "summarised_notes.txt"
|
ralph/tasks.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Task management and JSON schema validation for ralph-coding."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
import jsonschema
|
|
10
|
+
|
|
11
|
+
from .storage import get_project_tasks_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
TaskStatus = Literal["pending", "in_progress", "completed", "errored"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_schema() -> dict[str, Any]:
|
|
18
|
+
"""Load the task schema from the schemas directory."""
|
|
19
|
+
schema_path = Path(__file__).parent / "schemas" / "task_schema.json"
|
|
20
|
+
with open(schema_path, "r", encoding="utf-8") as f:
|
|
21
|
+
result: dict[str, Any] = json.load(f)
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate_tasks_data(data: dict[str, Any]) -> None:
|
|
26
|
+
"""Validate tasks data against the JSON schema."""
|
|
27
|
+
schema = _get_schema()
|
|
28
|
+
jsonschema.validate(instance=data, schema=schema)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Task:
|
|
32
|
+
"""Represents a single task (either thin or full spec)."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
name: str,
|
|
37
|
+
id: str | None = None,
|
|
38
|
+
description: str = "",
|
|
39
|
+
status: TaskStatus = "pending",
|
|
40
|
+
prerequisites: list[str] | None = None,
|
|
41
|
+
acceptance_criteria: list[str] | None = None,
|
|
42
|
+
files_to_modify: list[str] | None = None,
|
|
43
|
+
notes: str = "",
|
|
44
|
+
created_at: str | None = None,
|
|
45
|
+
started_at: str | None = None,
|
|
46
|
+
completed_at: str | None = None,
|
|
47
|
+
is_thin: bool = False,
|
|
48
|
+
):
|
|
49
|
+
self.id = id or str(uuid.uuid4())
|
|
50
|
+
self.name = name
|
|
51
|
+
self.description = description
|
|
52
|
+
self.status = status
|
|
53
|
+
self.prerequisites = prerequisites or []
|
|
54
|
+
self.acceptance_criteria = acceptance_criteria or []
|
|
55
|
+
self.files_to_modify = files_to_modify or []
|
|
56
|
+
self.notes = notes
|
|
57
|
+
self.created_at = created_at or datetime.utcnow().isoformat()
|
|
58
|
+
self.started_at = started_at
|
|
59
|
+
self.completed_at = completed_at
|
|
60
|
+
self._is_thin = is_thin
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_thin(cls, description: str) -> "Task":
|
|
64
|
+
"""Create a task from a thin task string."""
|
|
65
|
+
return cls(name=description, is_thin=True)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, data: dict[str, Any] | str) -> "Task":
|
|
69
|
+
"""Create a task from a dictionary or thin task string."""
|
|
70
|
+
if isinstance(data, str):
|
|
71
|
+
return cls.from_thin(data)
|
|
72
|
+
|
|
73
|
+
return cls(
|
|
74
|
+
id=data.get("id"),
|
|
75
|
+
name=data["name"],
|
|
76
|
+
description=data.get("description", ""),
|
|
77
|
+
status=data.get("status", "pending"),
|
|
78
|
+
prerequisites=data.get("prerequisites"),
|
|
79
|
+
acceptance_criteria=data.get("acceptance_criteria"),
|
|
80
|
+
files_to_modify=data.get("files_to_modify"),
|
|
81
|
+
notes=data.get("notes", ""),
|
|
82
|
+
created_at=data.get("created_at"),
|
|
83
|
+
started_at=data.get("started_at"),
|
|
84
|
+
completed_at=data.get("completed_at"),
|
|
85
|
+
is_thin=False,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
"""Convert task to a dictionary for serialization."""
|
|
90
|
+
return {
|
|
91
|
+
"id": self.id,
|
|
92
|
+
"name": self.name,
|
|
93
|
+
"description": self.description,
|
|
94
|
+
"status": self.status,
|
|
95
|
+
"prerequisites": self.prerequisites,
|
|
96
|
+
"acceptance_criteria": self.acceptance_criteria,
|
|
97
|
+
"files_to_modify": self.files_to_modify,
|
|
98
|
+
"notes": self.notes,
|
|
99
|
+
"created_at": self.created_at,
|
|
100
|
+
"started_at": self.started_at,
|
|
101
|
+
"completed_at": self.completed_at,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def to_serializable(self) -> str | dict[str, Any]:
|
|
105
|
+
"""Convert task to serializable format (thin string or full dict)."""
|
|
106
|
+
if self._is_thin and self.status == "pending":
|
|
107
|
+
return self.name
|
|
108
|
+
return self.to_dict()
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_thin(self) -> bool:
|
|
112
|
+
"""Check if this is a thin (unspecced) task."""
|
|
113
|
+
return self._is_thin
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def is_specced(self) -> bool:
|
|
117
|
+
"""Check if this task has been fully specified."""
|
|
118
|
+
return not self._is_thin
|
|
119
|
+
|
|
120
|
+
def spec(
|
|
121
|
+
self,
|
|
122
|
+
description: str,
|
|
123
|
+
acceptance_criteria: list[str] | None = None,
|
|
124
|
+
files_to_modify: list[str] | None = None,
|
|
125
|
+
prerequisites: list[str] | None = None,
|
|
126
|
+
notes: str = "",
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Convert a thin task to a full spec."""
|
|
129
|
+
self.description = description
|
|
130
|
+
self.acceptance_criteria = acceptance_criteria or []
|
|
131
|
+
self.files_to_modify = files_to_modify or []
|
|
132
|
+
self.prerequisites = prerequisites or []
|
|
133
|
+
self.notes = notes
|
|
134
|
+
self._is_thin = False
|
|
135
|
+
|
|
136
|
+
def start(self) -> None:
|
|
137
|
+
"""Mark the task as in progress."""
|
|
138
|
+
self.status = "in_progress"
|
|
139
|
+
self.started_at = datetime.utcnow().isoformat()
|
|
140
|
+
|
|
141
|
+
def complete(self) -> None:
|
|
142
|
+
"""Mark the task as completed."""
|
|
143
|
+
self.status = "completed"
|
|
144
|
+
self.completed_at = datetime.utcnow().isoformat()
|
|
145
|
+
|
|
146
|
+
def error(self, reason: str = "") -> None:
|
|
147
|
+
"""Mark the task as errored."""
|
|
148
|
+
self.status = "errored"
|
|
149
|
+
if reason:
|
|
150
|
+
self.notes = f"{self.notes}\n\nError: {reason}".strip()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TaskManager:
|
|
154
|
+
"""Manages the collection of tasks for a project."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, project_dir: Path):
|
|
157
|
+
self.project_dir = project_dir
|
|
158
|
+
self._tasks: list[Task] = []
|
|
159
|
+
self._load()
|
|
160
|
+
|
|
161
|
+
def _load(self) -> None:
|
|
162
|
+
"""Load tasks from the project's tasks.json file."""
|
|
163
|
+
tasks_path = get_project_tasks_path(self.project_dir)
|
|
164
|
+
|
|
165
|
+
if tasks_path.exists():
|
|
166
|
+
try:
|
|
167
|
+
with open(tasks_path, "r", encoding="utf-8") as f:
|
|
168
|
+
data = json.load(f)
|
|
169
|
+
_validate_tasks_data(data)
|
|
170
|
+
self._tasks = [Task.from_dict(t) for t in data.get("tasks", [])]
|
|
171
|
+
except (json.JSONDecodeError, jsonschema.ValidationError, IOError) as e:
|
|
172
|
+
# Start fresh if file is corrupted
|
|
173
|
+
self._tasks = []
|
|
174
|
+
else:
|
|
175
|
+
self._tasks = []
|
|
176
|
+
|
|
177
|
+
def _save(self) -> None:
|
|
178
|
+
"""Save tasks to the project's tasks.json file."""
|
|
179
|
+
tasks_path = get_project_tasks_path(self.project_dir)
|
|
180
|
+
tasks_path.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
|
|
182
|
+
data = {"tasks": [t.to_serializable() for t in self._tasks]}
|
|
183
|
+
_validate_tasks_data(data)
|
|
184
|
+
|
|
185
|
+
with open(tasks_path, "w", encoding="utf-8") as f:
|
|
186
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def tasks(self) -> list[Task]:
|
|
190
|
+
"""Get all tasks."""
|
|
191
|
+
return self._tasks.copy()
|
|
192
|
+
|
|
193
|
+
def add_thin_task(self, description: str) -> Task:
|
|
194
|
+
"""Add a thin (unspecced) task."""
|
|
195
|
+
task = Task.from_thin(description)
|
|
196
|
+
self._tasks.append(task)
|
|
197
|
+
self._save()
|
|
198
|
+
return task
|
|
199
|
+
|
|
200
|
+
def add_full_task(
|
|
201
|
+
self,
|
|
202
|
+
name: str,
|
|
203
|
+
description: str,
|
|
204
|
+
acceptance_criteria: list[str] | None = None,
|
|
205
|
+
files_to_modify: list[str] | None = None,
|
|
206
|
+
prerequisites: list[str] | None = None,
|
|
207
|
+
notes: str = "",
|
|
208
|
+
) -> Task:
|
|
209
|
+
"""Add a fully specified task."""
|
|
210
|
+
task = Task(
|
|
211
|
+
name=name,
|
|
212
|
+
description=description,
|
|
213
|
+
acceptance_criteria=acceptance_criteria,
|
|
214
|
+
files_to_modify=files_to_modify,
|
|
215
|
+
prerequisites=prerequisites,
|
|
216
|
+
notes=notes,
|
|
217
|
+
)
|
|
218
|
+
self._tasks.append(task)
|
|
219
|
+
self._save()
|
|
220
|
+
return task
|
|
221
|
+
|
|
222
|
+
def get_task_by_id(self, task_id: str) -> Task | None:
|
|
223
|
+
"""Get a task by its ID."""
|
|
224
|
+
for task in self._tasks:
|
|
225
|
+
if task.id == task_id:
|
|
226
|
+
return task
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def get_unspecced_tasks(self) -> list[Task]:
|
|
230
|
+
"""Get all thin (unspecced) tasks."""
|
|
231
|
+
return [t for t in self._tasks if t.is_thin]
|
|
232
|
+
|
|
233
|
+
def get_specced_tasks(self) -> list[Task]:
|
|
234
|
+
"""Get all fully specified tasks."""
|
|
235
|
+
return [t for t in self._tasks if t.is_specced]
|
|
236
|
+
|
|
237
|
+
def get_pending_tasks(self) -> list[Task]:
|
|
238
|
+
"""Get all pending tasks that are ready to work on."""
|
|
239
|
+
return [
|
|
240
|
+
t for t in self._tasks
|
|
241
|
+
if t.status == "pending" and t.is_specced and self._prerequisites_met(t)
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
def get_in_progress_task(self) -> Task | None:
|
|
245
|
+
"""Get the currently in-progress task, if any."""
|
|
246
|
+
for task in self._tasks:
|
|
247
|
+
if task.status == "in_progress":
|
|
248
|
+
return task
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def get_completed_tasks(self) -> list[Task]:
|
|
252
|
+
"""Get all completed tasks."""
|
|
253
|
+
return [t for t in self._tasks if t.status == "completed"]
|
|
254
|
+
|
|
255
|
+
def get_errored_tasks(self) -> list[Task]:
|
|
256
|
+
"""Get all errored tasks."""
|
|
257
|
+
return [t for t in self._tasks if t.status == "errored"]
|
|
258
|
+
|
|
259
|
+
def _prerequisites_met(self, task: Task) -> bool:
|
|
260
|
+
"""Check if all prerequisites for a task are completed.
|
|
261
|
+
|
|
262
|
+
Only considers prerequisites that match existing task IDs.
|
|
263
|
+
Descriptive prerequisites (non-UUID strings) are ignored.
|
|
264
|
+
"""
|
|
265
|
+
for prereq_id in task.prerequisites:
|
|
266
|
+
prereq = self.get_task_by_id(prereq_id)
|
|
267
|
+
# Only block on prerequisites that actually exist
|
|
268
|
+
if prereq is not None and prereq.status != "completed":
|
|
269
|
+
return False
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
def update_task(self, task: Task) -> None:
|
|
273
|
+
"""Update a task and save changes."""
|
|
274
|
+
for i, t in enumerate(self._tasks):
|
|
275
|
+
if t.id == task.id:
|
|
276
|
+
self._tasks[i] = task
|
|
277
|
+
self._save()
|
|
278
|
+
return
|
|
279
|
+
raise ValueError(f"Task not found: {task.id}")
|
|
280
|
+
|
|
281
|
+
def remove_task(self, task_id: str) -> bool:
|
|
282
|
+
"""Remove a task by ID."""
|
|
283
|
+
for i, task in enumerate(self._tasks):
|
|
284
|
+
if task.id == task_id:
|
|
285
|
+
del self._tasks[i]
|
|
286
|
+
self._save()
|
|
287
|
+
return True
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
def get_stats(self) -> dict[str, int]:
|
|
291
|
+
"""Get task statistics."""
|
|
292
|
+
return {
|
|
293
|
+
"unspecced": len(self.get_unspecced_tasks()),
|
|
294
|
+
"pending": sum(1 for t in self._tasks if t.status == "pending" and t.is_specced),
|
|
295
|
+
"in_progress": sum(1 for t in self._tasks if t.status == "in_progress"),
|
|
296
|
+
"completed": sum(1 for t in self._tasks if t.status == "completed"),
|
|
297
|
+
"errored": sum(1 for t in self._tasks if t.status == "errored"),
|
|
298
|
+
}
|