up-cli 0.1.1__py3-none-any.whl → 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.
- up/__init__.py +1 -1
- up/ai_cli.py +229 -0
- up/cli.py +75 -4
- up/commands/agent.py +521 -0
- up/commands/bisect.py +343 -0
- up/commands/branch.py +350 -0
- up/commands/dashboard.py +248 -0
- up/commands/init.py +195 -6
- up/commands/learn.py +1741 -0
- up/commands/memory.py +545 -0
- up/commands/new.py +108 -10
- up/commands/provenance.py +267 -0
- up/commands/review.py +239 -0
- up/commands/start.py +1124 -0
- up/commands/status.py +360 -0
- up/commands/summarize.py +122 -0
- up/commands/sync.py +317 -0
- up/commands/vibe.py +304 -0
- up/context.py +421 -0
- up/core/__init__.py +69 -0
- up/core/checkpoint.py +479 -0
- up/core/provenance.py +364 -0
- up/core/state.py +678 -0
- up/events.py +512 -0
- up/git/__init__.py +37 -0
- up/git/utils.py +270 -0
- up/git/worktree.py +331 -0
- up/learn/__init__.py +155 -0
- up/learn/analyzer.py +227 -0
- up/learn/plan.py +374 -0
- up/learn/research.py +511 -0
- up/learn/utils.py +117 -0
- up/memory.py +1096 -0
- up/parallel.py +551 -0
- up/summarizer.py +407 -0
- up/templates/__init__.py +70 -2
- up/templates/config/__init__.py +502 -20
- up/templates/docs/SKILL.md +28 -0
- up/templates/docs/__init__.py +341 -0
- up/templates/docs/standards/HEADERS.md +24 -0
- up/templates/docs/standards/STRUCTURE.md +18 -0
- up/templates/docs/standards/TEMPLATES.md +19 -0
- up/templates/learn/__init__.py +567 -14
- up/templates/loop/__init__.py +546 -27
- up/templates/mcp/__init__.py +474 -0
- up/templates/projects/__init__.py +786 -0
- up/ui/__init__.py +14 -0
- up/ui/loop_display.py +650 -0
- up/ui/theme.py +137 -0
- up_cli-0.5.0.dist-info/METADATA +519 -0
- up_cli-0.5.0.dist-info/RECORD +55 -0
- up_cli-0.1.1.dist-info/METADATA +0 -186
- up_cli-0.1.1.dist-info/RECORD +0 -14
- {up_cli-0.1.1.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
- {up_cli-0.1.1.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/commands/start.py
ADDED
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
"""up start - Start the product loop."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from tqdm import tqdm
|
|
16
|
+
|
|
17
|
+
from up.ai_cli import check_ai_cli, run_ai_task, get_ai_cli_install_instructions
|
|
18
|
+
from up.core.state import get_state_manager, StateManager
|
|
19
|
+
from up.core.checkpoint import (
|
|
20
|
+
get_checkpoint_manager,
|
|
21
|
+
CheckpointManager,
|
|
22
|
+
NotAGitRepoError,
|
|
23
|
+
)
|
|
24
|
+
from up.core.provenance import get_provenance_manager, ProvenanceEntry
|
|
25
|
+
from up.ui import ProductLoopDisplay, TaskStatus, THEME
|
|
26
|
+
from up.ui.loop_display import LoopStatus
|
|
27
|
+
|
|
28
|
+
console = Console(theme=THEME)
|
|
29
|
+
|
|
30
|
+
# Global state for interrupt handling
|
|
31
|
+
_state_manager: StateManager = None
|
|
32
|
+
_checkpoint_manager: CheckpointManager = None
|
|
33
|
+
_current_workspace = None
|
|
34
|
+
_current_provenance_entry: ProvenanceEntry = None
|
|
35
|
+
_current_display: Optional[ProductLoopDisplay] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _handle_interrupt(signum, frame):
|
|
39
|
+
"""Handle Ctrl+C interrupt - save state and checkpoint info."""
|
|
40
|
+
global _current_display
|
|
41
|
+
|
|
42
|
+
# Stop the display first if active
|
|
43
|
+
if _current_display:
|
|
44
|
+
_current_display.set_status(LoopStatus.PAUSED)
|
|
45
|
+
_current_display.log_warning("Interrupted by user")
|
|
46
|
+
time.sleep(0.3) # Brief pause to show status
|
|
47
|
+
_current_display.stop()
|
|
48
|
+
_current_display = None
|
|
49
|
+
|
|
50
|
+
console.print("\n\n[yellow]Interrupted! Saving state...[/]")
|
|
51
|
+
|
|
52
|
+
if _state_manager and _current_workspace:
|
|
53
|
+
_state_manager.update_loop(
|
|
54
|
+
phase="INTERRUPTED",
|
|
55
|
+
interrupted_at=time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
56
|
+
)
|
|
57
|
+
console.print(f"[green]✓[/] State saved to .up/state.json")
|
|
58
|
+
last_cp = _state_manager.state.loop.last_checkpoint
|
|
59
|
+
console.print(f"[dim]Checkpoint: {last_cp or 'none'}[/]")
|
|
60
|
+
|
|
61
|
+
# Mark any in-progress provenance entry as rejected
|
|
62
|
+
if _current_provenance_entry and _current_workspace:
|
|
63
|
+
try:
|
|
64
|
+
prov_mgr = get_provenance_manager(_current_workspace)
|
|
65
|
+
prov_mgr.reject_operation(
|
|
66
|
+
_current_provenance_entry.id,
|
|
67
|
+
reason="User interrupted operation"
|
|
68
|
+
)
|
|
69
|
+
console.print(f"[dim]Provenance entry marked as interrupted[/]")
|
|
70
|
+
except Exception:
|
|
71
|
+
pass # Don't fail on provenance error during interrupt
|
|
72
|
+
|
|
73
|
+
console.print("\nTo resume: [cyan]up start --resume[/]")
|
|
74
|
+
console.print("To rollback: [cyan]up reset[/]")
|
|
75
|
+
|
|
76
|
+
sys.exit(130) # Standard interrupt exit code
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@click.command()
|
|
80
|
+
@click.option("--resume", "-r", is_flag=True, help="Resume from last checkpoint")
|
|
81
|
+
@click.option("--dry-run", is_flag=True, help="Preview without executing")
|
|
82
|
+
@click.option("--task", "-t", help="Start with specific task ID")
|
|
83
|
+
@click.option("--prd", "-p", type=click.Path(exists=True), help="Path to PRD file")
|
|
84
|
+
@click.option("--interactive", "-i", is_flag=True, help="Interactive mode with confirmations")
|
|
85
|
+
@click.option("--no-ai", is_flag=True, help="Disable auto AI implementation")
|
|
86
|
+
@click.option("--all", "run_all", is_flag=True, help="Run all tasks automatically")
|
|
87
|
+
@click.option("--timeout", default=600, help="AI task timeout in seconds (default: 600)")
|
|
88
|
+
@click.option("--parallel", is_flag=True, help="Run tasks in parallel Git worktrees")
|
|
89
|
+
@click.option("--jobs", "-j", default=3, help="Number of parallel tasks (default: 3)")
|
|
90
|
+
@click.option("--auto-commit", is_flag=True, help="Auto-commit after each successful task")
|
|
91
|
+
@click.option("--verify/--no-verify", default=True, help="Run tests before commit (default: True)")
|
|
92
|
+
def start_cmd(resume: bool, dry_run: bool, task: str, prd: str, interactive: bool, no_ai: bool, run_all: bool, timeout: int, parallel: bool, jobs: int, auto_commit: bool, verify: bool):
|
|
93
|
+
"""Start the product loop for autonomous development.
|
|
94
|
+
|
|
95
|
+
Uses Claude/Cursor AI by default to implement tasks automatically.
|
|
96
|
+
|
|
97
|
+
The product loop implements SESRC principles:
|
|
98
|
+
- Stable: Graceful degradation
|
|
99
|
+
- Efficient: Token budgets
|
|
100
|
+
- Safe: Input validation
|
|
101
|
+
- Reliable: Checkpoints & rollback
|
|
102
|
+
- Cost-effective: Early termination
|
|
103
|
+
|
|
104
|
+
\b
|
|
105
|
+
Examples:
|
|
106
|
+
up start # Auto-implement next task with AI
|
|
107
|
+
up start --all # Auto-implement ALL tasks
|
|
108
|
+
up start --resume # Resume from checkpoint
|
|
109
|
+
up start --task US-003 # Implement specific task
|
|
110
|
+
up start --dry-run # Preview mode
|
|
111
|
+
up start --no-ai # Manual mode (show instructions only)
|
|
112
|
+
up start --parallel # Run 3 tasks in parallel worktrees
|
|
113
|
+
up start --parallel -j 5 # Run 5 tasks in parallel
|
|
114
|
+
up start --parallel --all # Run ALL tasks in parallel batches
|
|
115
|
+
"""
|
|
116
|
+
cwd = Path.cwd()
|
|
117
|
+
|
|
118
|
+
# Check if initialized with progress
|
|
119
|
+
console.print()
|
|
120
|
+
with tqdm(total=4, desc="Initializing", bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}") as pbar:
|
|
121
|
+
|
|
122
|
+
# Step 1: Check initialization
|
|
123
|
+
pbar.set_description("Checking project")
|
|
124
|
+
if not _is_initialized(cwd):
|
|
125
|
+
pbar.close()
|
|
126
|
+
console.print("\n[red]Error:[/] Project not initialized.")
|
|
127
|
+
console.print("Run [cyan]up init[/] first.")
|
|
128
|
+
raise SystemExit(1)
|
|
129
|
+
pbar.update(1)
|
|
130
|
+
time.sleep(0.2)
|
|
131
|
+
|
|
132
|
+
# Step 2: Find task source
|
|
133
|
+
pbar.set_description("Finding tasks")
|
|
134
|
+
task_source = _find_task_source(cwd, prd)
|
|
135
|
+
pbar.update(1)
|
|
136
|
+
time.sleep(0.2)
|
|
137
|
+
|
|
138
|
+
# Step 3: Load state
|
|
139
|
+
pbar.set_description("Loading state")
|
|
140
|
+
state = _load_loop_state(cwd)
|
|
141
|
+
pbar.update(1)
|
|
142
|
+
time.sleep(0.2)
|
|
143
|
+
|
|
144
|
+
# Step 4: Check circuit breaker
|
|
145
|
+
pbar.set_description("Checking circuits")
|
|
146
|
+
cb_status = _check_circuit_breaker(state)
|
|
147
|
+
pbar.update(1)
|
|
148
|
+
time.sleep(0.1)
|
|
149
|
+
|
|
150
|
+
console.print()
|
|
151
|
+
console.print(Panel.fit(
|
|
152
|
+
"[bold blue]Product Loop[/] - SESRC Autonomous Development",
|
|
153
|
+
border_style="blue"
|
|
154
|
+
))
|
|
155
|
+
|
|
156
|
+
# Display status table
|
|
157
|
+
_display_status_table(state, task_source, cwd, resume)
|
|
158
|
+
|
|
159
|
+
# Check for task sources
|
|
160
|
+
if not task_source and not resume:
|
|
161
|
+
console.print("\n[yellow]Warning:[/] No task source found.")
|
|
162
|
+
console.print("\nCreate one of:")
|
|
163
|
+
console.print(" • [cyan]prd.json[/] - Structured user stories")
|
|
164
|
+
console.print(" • [cyan]TODO.md[/] - Task list")
|
|
165
|
+
console.print("\nOr run [cyan]up learn plan[/] to generate a PRD.")
|
|
166
|
+
raise SystemExit(1)
|
|
167
|
+
|
|
168
|
+
# Check circuit breaker
|
|
169
|
+
if cb_status.get("open"):
|
|
170
|
+
console.print(f"\n[red]Circuit breaker OPEN:[/] {cb_status.get('reason')}")
|
|
171
|
+
console.print("Run [cyan]up start --resume[/] after fixing the issue.")
|
|
172
|
+
raise SystemExit(1)
|
|
173
|
+
|
|
174
|
+
# Interactive confirmation (before parallel or sequential)
|
|
175
|
+
if interactive and not dry_run:
|
|
176
|
+
if not click.confirm("\nStart the product loop?"):
|
|
177
|
+
console.print("[dim]Cancelled[/]")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# Parallel execution mode
|
|
181
|
+
if parallel:
|
|
182
|
+
from up.parallel import run_parallel_loop
|
|
183
|
+
from up.git.worktree import is_git_repo
|
|
184
|
+
|
|
185
|
+
if not is_git_repo(cwd):
|
|
186
|
+
console.print("\n[red]Error:[/] Parallel mode requires a Git repository.")
|
|
187
|
+
console.print("Run [cyan]git init[/] first.")
|
|
188
|
+
raise SystemExit(1)
|
|
189
|
+
|
|
190
|
+
prd_path = cwd / (prd or task_source or "prd.json")
|
|
191
|
+
if not prd_path.exists():
|
|
192
|
+
console.print(f"\n[red]Error:[/] PRD file not found: {prd_path}")
|
|
193
|
+
console.print("Run [cyan]up learn plan[/] to generate one.")
|
|
194
|
+
raise SystemExit(1)
|
|
195
|
+
|
|
196
|
+
console.print(f"\n[bold cyan]PARALLEL MODE[/] - {jobs} workers")
|
|
197
|
+
console.print(f"PRD: {prd_path}")
|
|
198
|
+
|
|
199
|
+
run_parallel_loop(
|
|
200
|
+
workspace=cwd,
|
|
201
|
+
prd_path=prd_path,
|
|
202
|
+
max_workers=jobs,
|
|
203
|
+
run_all=run_all,
|
|
204
|
+
timeout=timeout,
|
|
205
|
+
dry_run=dry_run
|
|
206
|
+
)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Sequential dry run mode
|
|
210
|
+
if dry_run:
|
|
211
|
+
console.print("\n[yellow]DRY RUN MODE[/] - No changes will be made")
|
|
212
|
+
_preview_loop(cwd, state, task_source, task)
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
# Check AI availability
|
|
216
|
+
use_ai = not no_ai
|
|
217
|
+
cli_name, cli_available = check_ai_cli()
|
|
218
|
+
|
|
219
|
+
if use_ai and not cli_available:
|
|
220
|
+
console.print("\n[yellow]No AI CLI found. Running in manual mode.[/]")
|
|
221
|
+
console.print(get_ai_cli_install_instructions())
|
|
222
|
+
use_ai = False
|
|
223
|
+
|
|
224
|
+
# Start the loop with progress
|
|
225
|
+
console.print("\n[bold green]Starting product loop...[/]")
|
|
226
|
+
|
|
227
|
+
if use_ai:
|
|
228
|
+
_run_ai_product_loop(cwd, state, task_source, task, cli_name, run_all, timeout, auto_commit, verify, interactive)
|
|
229
|
+
else:
|
|
230
|
+
_run_product_loop_with_progress(cwd, state, task_source, task, resume)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _display_status_table(state: dict, task_source: str, workspace: Path, resume: bool):
|
|
234
|
+
"""Display status table."""
|
|
235
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
236
|
+
table.add_column("Key", style="dim")
|
|
237
|
+
table.add_column("Value")
|
|
238
|
+
|
|
239
|
+
# Iteration
|
|
240
|
+
iteration = state.get("iteration", 0)
|
|
241
|
+
table.add_row("Iteration", f"[cyan]{iteration}[/]")
|
|
242
|
+
|
|
243
|
+
# Phase
|
|
244
|
+
phase = state.get("phase", "INIT")
|
|
245
|
+
table.add_row("Phase", f"[cyan]{phase}[/]")
|
|
246
|
+
|
|
247
|
+
# Task source
|
|
248
|
+
if task_source:
|
|
249
|
+
task_count = _count_tasks(workspace, task_source)
|
|
250
|
+
table.add_row("Tasks", f"[cyan]{task_count}[/] remaining from {task_source}")
|
|
251
|
+
else:
|
|
252
|
+
table.add_row("Tasks", "[dim]No task source[/]")
|
|
253
|
+
|
|
254
|
+
# Completed
|
|
255
|
+
completed = len(state.get("tasks_completed", []))
|
|
256
|
+
table.add_row("Completed", f"[green]{completed}[/]")
|
|
257
|
+
|
|
258
|
+
# Success rate
|
|
259
|
+
success_rate = state.get("metrics", {}).get("success_rate", 1.0)
|
|
260
|
+
table.add_row("Success Rate", f"[green]{success_rate*100:.0f}%[/]")
|
|
261
|
+
|
|
262
|
+
# Mode
|
|
263
|
+
mode = "Resume" if resume else "Fresh Start"
|
|
264
|
+
table.add_row("Mode", mode)
|
|
265
|
+
|
|
266
|
+
console.print(table)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _is_initialized(workspace: Path) -> bool:
|
|
270
|
+
"""Check if project is initialized with up systems."""
|
|
271
|
+
return (
|
|
272
|
+
(workspace / ".claude").exists() or
|
|
273
|
+
(workspace / ".cursor").exists() or
|
|
274
|
+
(workspace / "CLAUDE.md").exists()
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _find_task_source(workspace: Path, prd_path: str = None) -> str:
|
|
279
|
+
"""Find task source file."""
|
|
280
|
+
if prd_path:
|
|
281
|
+
return prd_path
|
|
282
|
+
|
|
283
|
+
# Check common locations
|
|
284
|
+
sources = [
|
|
285
|
+
"prd.json",
|
|
286
|
+
".claude/skills/learning-system/prd.json",
|
|
287
|
+
".cursor/skills/learning-system/prd.json",
|
|
288
|
+
"TODO.md",
|
|
289
|
+
"docs/todo/TODO.md",
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
for source in sources:
|
|
293
|
+
if (workspace / source).exists():
|
|
294
|
+
return source
|
|
295
|
+
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _load_loop_state(workspace: Path) -> dict:
|
|
300
|
+
"""Load loop state from unified state file.
|
|
301
|
+
|
|
302
|
+
Returns a dict for backwards compatibility with existing code.
|
|
303
|
+
Internally uses the new StateManager.
|
|
304
|
+
"""
|
|
305
|
+
manager = get_state_manager(workspace)
|
|
306
|
+
state = manager.state
|
|
307
|
+
|
|
308
|
+
# Convert to dict format for backwards compatibility
|
|
309
|
+
return {
|
|
310
|
+
"version": state.version,
|
|
311
|
+
"iteration": state.loop.iteration,
|
|
312
|
+
"phase": state.loop.phase,
|
|
313
|
+
"current_task": state.loop.current_task,
|
|
314
|
+
"tasks_completed": state.loop.tasks_completed,
|
|
315
|
+
"tasks_failed": state.loop.tasks_failed,
|
|
316
|
+
"last_checkpoint": state.loop.last_checkpoint,
|
|
317
|
+
"circuit_breaker": {
|
|
318
|
+
name: {"failures": cb.failures, "state": cb.state}
|
|
319
|
+
for name, cb in state.circuit_breakers.items()
|
|
320
|
+
},
|
|
321
|
+
"metrics": {
|
|
322
|
+
"total_edits": state.metrics.total_tasks,
|
|
323
|
+
"total_rollbacks": state.metrics.total_rollbacks,
|
|
324
|
+
"success_rate": state.metrics.success_rate,
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _save_loop_state(workspace: Path, state: dict) -> None:
|
|
330
|
+
"""Save loop state to unified state file.
|
|
331
|
+
|
|
332
|
+
Accepts dict for backwards compatibility, converts to StateManager.
|
|
333
|
+
"""
|
|
334
|
+
manager = get_state_manager(workspace)
|
|
335
|
+
|
|
336
|
+
# Update loop state from dict
|
|
337
|
+
manager.state.loop.iteration = state.get("iteration", 0)
|
|
338
|
+
manager.state.loop.phase = state.get("phase", "IDLE")
|
|
339
|
+
manager.state.loop.current_task = state.get("current_task")
|
|
340
|
+
manager.state.loop.last_checkpoint = state.get("last_checkpoint")
|
|
341
|
+
|
|
342
|
+
if "tasks_completed" in state:
|
|
343
|
+
manager.state.loop.tasks_completed = state["tasks_completed"]
|
|
344
|
+
|
|
345
|
+
# Update circuit breakers
|
|
346
|
+
if "circuit_breaker" in state:
|
|
347
|
+
from up.core.state import CircuitBreakerState
|
|
348
|
+
for name, cb_data in state["circuit_breaker"].items():
|
|
349
|
+
if isinstance(cb_data, dict):
|
|
350
|
+
manager.state.circuit_breakers[name] = CircuitBreakerState(
|
|
351
|
+
failures=cb_data.get("failures", 0),
|
|
352
|
+
state=cb_data.get("state", "CLOSED"),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
manager.save()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _count_tasks(workspace: Path, task_source: str) -> int:
|
|
359
|
+
"""Count tasks in source file."""
|
|
360
|
+
filepath = workspace / task_source
|
|
361
|
+
|
|
362
|
+
if not filepath.exists():
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
if task_source.endswith(".json"):
|
|
366
|
+
try:
|
|
367
|
+
data = json.loads(filepath.read_text())
|
|
368
|
+
stories = data.get("userStories", [])
|
|
369
|
+
return len([s for s in stories if not s.get("passes", False)])
|
|
370
|
+
except json.JSONDecodeError:
|
|
371
|
+
return 0
|
|
372
|
+
|
|
373
|
+
elif task_source.endswith(".md"):
|
|
374
|
+
content = filepath.read_text()
|
|
375
|
+
# Count unchecked items
|
|
376
|
+
import re
|
|
377
|
+
return len(re.findall(r"- \[ \]", content))
|
|
378
|
+
|
|
379
|
+
return 0
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _check_circuit_breaker(state: dict) -> dict:
|
|
383
|
+
"""Check circuit breaker status.
|
|
384
|
+
|
|
385
|
+
Now uses the can_execute() method which includes cooldown check.
|
|
386
|
+
"""
|
|
387
|
+
cb = state.get("circuit_breaker", {})
|
|
388
|
+
|
|
389
|
+
for name, circuit in cb.items():
|
|
390
|
+
if isinstance(circuit, dict):
|
|
391
|
+
cb_state = circuit.get("state", "CLOSED")
|
|
392
|
+
failures = circuit.get("failures", 0)
|
|
393
|
+
|
|
394
|
+
if cb_state == "OPEN":
|
|
395
|
+
# Check if we can try again (cooldown expired)
|
|
396
|
+
# This is done via StateManager in the actual loop
|
|
397
|
+
return {
|
|
398
|
+
"open": True,
|
|
399
|
+
"circuit": name,
|
|
400
|
+
"reason": f"{name} circuit opened after {failures} failures",
|
|
401
|
+
"can_retry": False, # Will be checked properly in loop
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {"open": False}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _preview_loop(workspace: Path, state: dict, task_source: str, specific_task: str = None):
|
|
408
|
+
"""Preview what the loop would do."""
|
|
409
|
+
console.print("\n[bold]Preview:[/]")
|
|
410
|
+
|
|
411
|
+
# Show phases with progress simulation
|
|
412
|
+
phases = [
|
|
413
|
+
("OBSERVE", "Read task and understand requirements"),
|
|
414
|
+
("CHECKPOINT", "Create git stash checkpoint"),
|
|
415
|
+
("EXECUTE", "Implement the task"),
|
|
416
|
+
("VERIFY", "Run tests, types, lint"),
|
|
417
|
+
("COMMIT", "Update state and commit"),
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
console.print()
|
|
421
|
+
for phase, desc in tqdm(phases, desc="Phases", bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}"):
|
|
422
|
+
time.sleep(0.3)
|
|
423
|
+
|
|
424
|
+
console.print()
|
|
425
|
+
for phase, desc in phases:
|
|
426
|
+
console.print(f" [cyan]{phase}[/]: {desc}")
|
|
427
|
+
|
|
428
|
+
# Show next task
|
|
429
|
+
if specific_task:
|
|
430
|
+
console.print(f"\n Target task: [cyan]{specific_task}[/]")
|
|
431
|
+
elif task_source and task_source.endswith(".json"):
|
|
432
|
+
next_task = _get_next_task_from_prd(workspace / task_source)
|
|
433
|
+
if next_task:
|
|
434
|
+
console.print(f"\n Next task: [cyan]{next_task.get('id')}[/] - {next_task.get('title')}")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _get_next_task_from_prd(prd_path: Path) -> dict:
|
|
438
|
+
"""Get next incomplete task from PRD."""
|
|
439
|
+
if not prd_path.exists():
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
data = json.loads(prd_path.read_text())
|
|
444
|
+
stories = data.get("userStories", [])
|
|
445
|
+
|
|
446
|
+
# Find first incomplete task
|
|
447
|
+
for story in sorted(stories, key=lambda s: s.get("priority", 999)):
|
|
448
|
+
if not story.get("passes", False):
|
|
449
|
+
return story
|
|
450
|
+
|
|
451
|
+
return None
|
|
452
|
+
except json.JSONDecodeError:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _run_product_loop_with_progress(
|
|
457
|
+
workspace: Path,
|
|
458
|
+
state: dict,
|
|
459
|
+
task_source: str,
|
|
460
|
+
specific_task: str = None,
|
|
461
|
+
resume: bool = False
|
|
462
|
+
):
|
|
463
|
+
"""Run the product loop with progress indicators."""
|
|
464
|
+
from datetime import datetime
|
|
465
|
+
|
|
466
|
+
# Update state
|
|
467
|
+
if not resume:
|
|
468
|
+
state["iteration"] = state.get("iteration", 0) + 1
|
|
469
|
+
state["phase"] = "OBSERVE"
|
|
470
|
+
state["started_at"] = datetime.now().isoformat()
|
|
471
|
+
|
|
472
|
+
# Get task info
|
|
473
|
+
next_task = None
|
|
474
|
+
if specific_task:
|
|
475
|
+
next_task = {"id": specific_task, "title": specific_task}
|
|
476
|
+
elif task_source and task_source.endswith(".json"):
|
|
477
|
+
next_task = _get_next_task_from_prd(workspace / task_source)
|
|
478
|
+
|
|
479
|
+
# Show task info
|
|
480
|
+
if next_task:
|
|
481
|
+
console.print(f"\n[bold]Task:[/] [cyan]{next_task.get('id')}[/] - {next_task.get('title', 'N/A')}")
|
|
482
|
+
|
|
483
|
+
# Simulate loop phases with progress
|
|
484
|
+
phases = [
|
|
485
|
+
("OBSERVE", "Reading task requirements"),
|
|
486
|
+
("CHECKPOINT", "Creating checkpoint"),
|
|
487
|
+
("EXECUTE", "Ready for implementation"),
|
|
488
|
+
("VERIFY", "Verification pending"),
|
|
489
|
+
("COMMIT", "Awaiting completion"),
|
|
490
|
+
]
|
|
491
|
+
|
|
492
|
+
console.print("\n[bold]Loop Progress:[/]")
|
|
493
|
+
|
|
494
|
+
with tqdm(total=len(phases), desc="Initializing loop",
|
|
495
|
+
bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]") as pbar:
|
|
496
|
+
|
|
497
|
+
for i, (phase, desc) in enumerate(phases):
|
|
498
|
+
state["phase"] = phase
|
|
499
|
+
pbar.set_description(f"{phase}: {desc}")
|
|
500
|
+
pbar.update(1)
|
|
501
|
+
|
|
502
|
+
# Only run through OBSERVE and CHECKPOINT automatically
|
|
503
|
+
if i >= 2:
|
|
504
|
+
break
|
|
505
|
+
|
|
506
|
+
time.sleep(0.5)
|
|
507
|
+
|
|
508
|
+
# Save state
|
|
509
|
+
_save_loop_state(workspace, state)
|
|
510
|
+
|
|
511
|
+
# Generate instructions for AI
|
|
512
|
+
console.print("\n" + "─" * 50)
|
|
513
|
+
console.print("\n[bold green]✓[/] Loop initialized at [cyan]EXECUTE[/] phase")
|
|
514
|
+
|
|
515
|
+
# Show instructions panel
|
|
516
|
+
instructions = _generate_loop_instructions(workspace, state, task_source, specific_task)
|
|
517
|
+
console.print(Panel(
|
|
518
|
+
instructions,
|
|
519
|
+
title="[bold]AI Instructions[/]",
|
|
520
|
+
border_style="green"
|
|
521
|
+
))
|
|
522
|
+
|
|
523
|
+
# Show next steps
|
|
524
|
+
console.print("\n[bold]Next Steps:[/]")
|
|
525
|
+
console.print(" 1. Use [cyan]/product-loop[/] in your AI assistant")
|
|
526
|
+
console.print(" 2. Or implement the task manually")
|
|
527
|
+
console.print(" 3. Run [cyan]up status[/] to check progress")
|
|
528
|
+
console.print(" 4. Run [cyan]up dashboard[/] for live monitoring")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _generate_loop_instructions(
|
|
532
|
+
workspace: Path,
|
|
533
|
+
state: dict,
|
|
534
|
+
task_source: str,
|
|
535
|
+
specific_task: str = None
|
|
536
|
+
) -> str:
|
|
537
|
+
"""Generate instructions for the AI to execute the loop."""
|
|
538
|
+
|
|
539
|
+
task_info = ""
|
|
540
|
+
if specific_task:
|
|
541
|
+
task_info = f"Task: {specific_task}"
|
|
542
|
+
elif task_source:
|
|
543
|
+
next_task = None
|
|
544
|
+
if task_source.endswith(".json"):
|
|
545
|
+
next_task = _get_next_task_from_prd(workspace / task_source)
|
|
546
|
+
|
|
547
|
+
if next_task:
|
|
548
|
+
task_info = f"Task: {next_task.get('id')} - {next_task.get('title')}"
|
|
549
|
+
if next_task.get("acceptanceCriteria"):
|
|
550
|
+
criteria = next_task.get("acceptanceCriteria", [])[:3]
|
|
551
|
+
task_info += "\n\nAcceptance Criteria:"
|
|
552
|
+
for c in criteria:
|
|
553
|
+
task_info += f"\n • {c}"
|
|
554
|
+
else:
|
|
555
|
+
task_info = f"Source: {task_source}"
|
|
556
|
+
|
|
557
|
+
return f"""Iteration #{state.get('iteration', 1)} - Phase: EXECUTE
|
|
558
|
+
|
|
559
|
+
{task_info}
|
|
560
|
+
|
|
561
|
+
SESRC Loop Commands:
|
|
562
|
+
├─ Checkpoint: up save (creates git checkpoint)
|
|
563
|
+
├─ Verify: pytest && mypy src/ && ruff check src/
|
|
564
|
+
├─ Rollback: up reset (restores last checkpoint)
|
|
565
|
+
└─ Complete: up status (view progress)
|
|
566
|
+
|
|
567
|
+
Circuit Breaker: 3 consecutive failures → OPEN
|
|
568
|
+
State File: .up/state.json
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _run_ai_product_loop(
|
|
573
|
+
workspace: Path,
|
|
574
|
+
state: dict,
|
|
575
|
+
task_source: str,
|
|
576
|
+
specific_task: str = None,
|
|
577
|
+
cli_name: str = "claude",
|
|
578
|
+
run_all: bool = False,
|
|
579
|
+
timeout: int = 600,
|
|
580
|
+
auto_commit: bool = False,
|
|
581
|
+
verify: bool = True,
|
|
582
|
+
interactive: bool = False
|
|
583
|
+
):
|
|
584
|
+
"""Run the product loop with AI auto-implementation.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
workspace: Project root directory
|
|
588
|
+
state: Loop state dict
|
|
589
|
+
task_source: Path to PRD or task file
|
|
590
|
+
specific_task: Specific task ID to run
|
|
591
|
+
cli_name: AI CLI to use (claude, cursor)
|
|
592
|
+
run_all: Run all tasks automatically
|
|
593
|
+
timeout: AI task timeout in seconds
|
|
594
|
+
auto_commit: Commit after each successful task
|
|
595
|
+
verify: Run tests before commit
|
|
596
|
+
interactive: Ask for confirmation before commit
|
|
597
|
+
"""
|
|
598
|
+
global _state_manager, _checkpoint_manager, _current_workspace, _current_provenance_entry, _current_display
|
|
599
|
+
from datetime import datetime
|
|
600
|
+
|
|
601
|
+
# Set up state, checkpoint, and provenance managers
|
|
602
|
+
_current_workspace = workspace
|
|
603
|
+
_state_manager = get_state_manager(workspace)
|
|
604
|
+
_checkpoint_manager = get_checkpoint_manager(workspace)
|
|
605
|
+
provenance_manager = get_provenance_manager(workspace)
|
|
606
|
+
signal.signal(signal.SIGINT, _handle_interrupt)
|
|
607
|
+
|
|
608
|
+
# Get all tasks (including completed for display)
|
|
609
|
+
all_tasks = []
|
|
610
|
+
tasks_to_run = []
|
|
611
|
+
|
|
612
|
+
if specific_task:
|
|
613
|
+
tasks_to_run = [{"id": specific_task, "title": specific_task, "description": specific_task}]
|
|
614
|
+
all_tasks = tasks_to_run
|
|
615
|
+
elif task_source and task_source.endswith(".json"):
|
|
616
|
+
prd_path = workspace / task_source
|
|
617
|
+
if prd_path.exists():
|
|
618
|
+
try:
|
|
619
|
+
data = json.loads(prd_path.read_text())
|
|
620
|
+
stories = data.get("userStories", [])
|
|
621
|
+
all_tasks = stories # All tasks for display
|
|
622
|
+
# Get incomplete tasks
|
|
623
|
+
for story in stories:
|
|
624
|
+
if not story.get("passes", False):
|
|
625
|
+
tasks_to_run.append(story)
|
|
626
|
+
if not run_all:
|
|
627
|
+
break # Only first task if not --all
|
|
628
|
+
except json.JSONDecodeError:
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
if not tasks_to_run:
|
|
632
|
+
console.print("\n[green]✓[/] All tasks completed!")
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
# Initialize the display
|
|
636
|
+
display = ProductLoopDisplay(console)
|
|
637
|
+
_current_display = display # Track for interrupt handler
|
|
638
|
+
display.set_tasks(all_tasks)
|
|
639
|
+
display.start()
|
|
640
|
+
|
|
641
|
+
display.log(f"Starting product loop with {len(tasks_to_run)} tasks")
|
|
642
|
+
display.log(f"AI CLI: {cli_name} (timeout: {timeout}s)")
|
|
643
|
+
|
|
644
|
+
# Process each task
|
|
645
|
+
completed = 0
|
|
646
|
+
failed = 0
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
for task in tasks_to_run:
|
|
650
|
+
task_id = task.get("id", "unknown")
|
|
651
|
+
task_title = task.get("title", "No title")
|
|
652
|
+
task_desc = task.get("description", task_title)
|
|
653
|
+
|
|
654
|
+
# Update display
|
|
655
|
+
display.set_current_task(task_id, "CHECKPOINT")
|
|
656
|
+
display.increment_iteration()
|
|
657
|
+
display.log(f"Starting task {task_id}: {task_title[:40]}...")
|
|
658
|
+
|
|
659
|
+
# Update state
|
|
660
|
+
state["iteration"] = state.get("iteration", 0) + 1
|
|
661
|
+
state["phase"] = "EXECUTE"
|
|
662
|
+
state["current_task"] = task_id
|
|
663
|
+
_save_loop_state(workspace, state)
|
|
664
|
+
|
|
665
|
+
# Create checkpoint
|
|
666
|
+
display.log("Creating checkpoint...")
|
|
667
|
+
checkpoint_name = f"cp-{task_id}-{state['iteration']}"
|
|
668
|
+
_create_checkpoint(workspace, checkpoint_name, task_id=task_id)
|
|
669
|
+
|
|
670
|
+
# Build prompt for AI
|
|
671
|
+
prompt = _build_implementation_prompt(workspace, task, task_source)
|
|
672
|
+
|
|
673
|
+
# Start provenance tracking
|
|
674
|
+
try:
|
|
675
|
+
_current_provenance_entry = provenance_manager.start_operation(
|
|
676
|
+
task_id=task_id,
|
|
677
|
+
task_title=task_title,
|
|
678
|
+
prompt=prompt,
|
|
679
|
+
ai_model=cli_name,
|
|
680
|
+
context_files=[task_source] if task_source else []
|
|
681
|
+
)
|
|
682
|
+
display.log(f"Provenance: {_current_provenance_entry.id[:8]}...")
|
|
683
|
+
except Exception as e:
|
|
684
|
+
_current_provenance_entry = None
|
|
685
|
+
|
|
686
|
+
# Run AI
|
|
687
|
+
display.set_phase("EXECUTE")
|
|
688
|
+
display.log(f"Running {cli_name}...")
|
|
689
|
+
success, output = _run_ai_implementation(workspace, prompt, cli_name, timeout)
|
|
690
|
+
|
|
691
|
+
if success:
|
|
692
|
+
display.log_success(f"Task {task_id} implemented")
|
|
693
|
+
|
|
694
|
+
# Phase: VERIFY
|
|
695
|
+
verification_passed = True
|
|
696
|
+
tests_passed = None
|
|
697
|
+
lint_passed = None
|
|
698
|
+
|
|
699
|
+
if verify:
|
|
700
|
+
display.set_phase("VERIFY")
|
|
701
|
+
display.set_status(LoopStatus.VERIFYING)
|
|
702
|
+
display.log("Running verification...")
|
|
703
|
+
tests_passed, lint_passed = _run_verification_with_results(workspace)
|
|
704
|
+
verification_passed = tests_passed is not False
|
|
705
|
+
|
|
706
|
+
if not verification_passed:
|
|
707
|
+
display.log_warning(f"Verification failed for {task_id}")
|
|
708
|
+
|
|
709
|
+
# Mark provenance as rejected
|
|
710
|
+
if _current_provenance_entry:
|
|
711
|
+
try:
|
|
712
|
+
provenance_manager.reject_operation(
|
|
713
|
+
_current_provenance_entry.id,
|
|
714
|
+
reason="Verification failed"
|
|
715
|
+
)
|
|
716
|
+
except Exception:
|
|
717
|
+
pass
|
|
718
|
+
_current_provenance_entry = None
|
|
719
|
+
|
|
720
|
+
if interactive:
|
|
721
|
+
display.stop() # Pause display for interaction
|
|
722
|
+
if not click.confirm("Continue anyway?"):
|
|
723
|
+
console.print("[yellow]Rolling back...[/]")
|
|
724
|
+
_rollback_checkpoint(workspace)
|
|
725
|
+
failed += 1
|
|
726
|
+
_state_manager.record_task_failed(task_id)
|
|
727
|
+
display.start()
|
|
728
|
+
display.update_task_status(task_id, TaskStatus.ROLLED_BACK)
|
|
729
|
+
continue
|
|
730
|
+
display.start()
|
|
731
|
+
else:
|
|
732
|
+
display.log("Rolling back changes...")
|
|
733
|
+
_rollback_checkpoint(workspace)
|
|
734
|
+
failed += 1
|
|
735
|
+
_state_manager.record_task_failed(task_id)
|
|
736
|
+
display.update_task_status(task_id, TaskStatus.ROLLED_BACK)
|
|
737
|
+
continue
|
|
738
|
+
|
|
739
|
+
completed += 1
|
|
740
|
+
|
|
741
|
+
# Complete provenance tracking
|
|
742
|
+
if _current_provenance_entry:
|
|
743
|
+
try:
|
|
744
|
+
modified_files = _get_modified_files(workspace)
|
|
745
|
+
provenance_manager.complete_operation(
|
|
746
|
+
entry_id=_current_provenance_entry.id,
|
|
747
|
+
files_modified=modified_files,
|
|
748
|
+
tests_passed=tests_passed,
|
|
749
|
+
lint_passed=lint_passed,
|
|
750
|
+
status="accepted"
|
|
751
|
+
)
|
|
752
|
+
except Exception:
|
|
753
|
+
pass
|
|
754
|
+
_current_provenance_entry = None
|
|
755
|
+
|
|
756
|
+
# Mark task as complete in PRD
|
|
757
|
+
_mark_task_complete(workspace, task_source, task_id)
|
|
758
|
+
|
|
759
|
+
# Update state via state manager
|
|
760
|
+
_state_manager.record_task_complete(task_id)
|
|
761
|
+
|
|
762
|
+
# Update display
|
|
763
|
+
display.update_task_status(task_id, TaskStatus.COMPLETE)
|
|
764
|
+
display.set_status(LoopStatus.RUNNING)
|
|
765
|
+
|
|
766
|
+
# Update legacy state dict for compatibility
|
|
767
|
+
state["tasks_completed"] = state.get("tasks_completed", []) + [task_id]
|
|
768
|
+
state["phase"] = "COMMIT"
|
|
769
|
+
|
|
770
|
+
# Phase: COMMIT
|
|
771
|
+
if auto_commit:
|
|
772
|
+
should_commit = True
|
|
773
|
+
if interactive:
|
|
774
|
+
display.stop()
|
|
775
|
+
console.print("\n[bold]Phase: COMMIT[/]")
|
|
776
|
+
console.print(_get_diff_summary(workspace))
|
|
777
|
+
should_commit = click.confirm("Commit changes?")
|
|
778
|
+
display.start()
|
|
779
|
+
|
|
780
|
+
if should_commit:
|
|
781
|
+
commit_msg = f"feat({task_id}): {task_title}"
|
|
782
|
+
_commit_changes(workspace, commit_msg)
|
|
783
|
+
display.log_success(f"Committed: {commit_msg[:40]}...")
|
|
784
|
+
else:
|
|
785
|
+
display.log("Changes staged (--auto-commit to commit)")
|
|
786
|
+
else:
|
|
787
|
+
display.log_error(f"Task {task_id} failed")
|
|
788
|
+
failed += 1
|
|
789
|
+
|
|
790
|
+
# Reject provenance entry
|
|
791
|
+
if _current_provenance_entry:
|
|
792
|
+
try:
|
|
793
|
+
provenance_manager.reject_operation(
|
|
794
|
+
_current_provenance_entry.id,
|
|
795
|
+
reason=output[:500] if output else "AI implementation failed"
|
|
796
|
+
)
|
|
797
|
+
except Exception:
|
|
798
|
+
pass
|
|
799
|
+
_current_provenance_entry = None
|
|
800
|
+
|
|
801
|
+
# Rollback
|
|
802
|
+
display.log("Rolling back changes...")
|
|
803
|
+
_rollback_checkpoint(workspace)
|
|
804
|
+
display.update_task_status(task_id, TaskStatus.FAILED)
|
|
805
|
+
|
|
806
|
+
# Update circuit breaker and doom loop detection
|
|
807
|
+
_state_manager.record_task_failed(task_id)
|
|
808
|
+
|
|
809
|
+
# Check for doom loop
|
|
810
|
+
is_doom, doom_msg = _state_manager.check_doom_loop()
|
|
811
|
+
if is_doom:
|
|
812
|
+
display.log_error(doom_msg[:50])
|
|
813
|
+
|
|
814
|
+
# Check circuit breaker with cooldown
|
|
815
|
+
cb = _state_manager.get_circuit_breaker("task")
|
|
816
|
+
cb.record_failure()
|
|
817
|
+
_state_manager.save()
|
|
818
|
+
|
|
819
|
+
if not cb.can_execute():
|
|
820
|
+
cooldown = _state_manager.config.circuit_breaker_cooldown_minutes
|
|
821
|
+
display.log_error(f"Circuit breaker OPEN - cooldown {cooldown}m")
|
|
822
|
+
display.set_status(LoopStatus.FAILED)
|
|
823
|
+
break
|
|
824
|
+
|
|
825
|
+
# Update legacy state dict for compatibility
|
|
826
|
+
state["circuit_breaker"] = {
|
|
827
|
+
"failures": cb.failures,
|
|
828
|
+
"state": cb.state
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
_save_loop_state(workspace, state)
|
|
832
|
+
|
|
833
|
+
finally:
|
|
834
|
+
# Always stop the display
|
|
835
|
+
display.set_status(LoopStatus.COMPLETE if failed == 0 else LoopStatus.FAILED)
|
|
836
|
+
time.sleep(0.5) # Brief pause to show final state
|
|
837
|
+
display.stop()
|
|
838
|
+
_current_display = None
|
|
839
|
+
|
|
840
|
+
# Reset interrupt handler
|
|
841
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
842
|
+
|
|
843
|
+
# Summary (after display stopped)
|
|
844
|
+
console.print(f"\n{'─' * 50}")
|
|
845
|
+
console.print(Panel.fit(
|
|
846
|
+
f"[bold]Loop Complete[/]\n\n"
|
|
847
|
+
f"Completed: [green]{completed}[/]\n"
|
|
848
|
+
f"Failed: [red]{failed}[/]\n"
|
|
849
|
+
f"Remaining: {len(tasks_to_run) - completed - failed}",
|
|
850
|
+
border_style="cyan"
|
|
851
|
+
))
|
|
852
|
+
|
|
853
|
+
if completed > 0:
|
|
854
|
+
if auto_commit:
|
|
855
|
+
console.print("\n[green]✓[/] All changes committed automatically")
|
|
856
|
+
else:
|
|
857
|
+
console.print("\n[bold]Next Steps:[/]")
|
|
858
|
+
console.print(" 1. Review changes: [cyan]up diff[/] or [cyan]git diff[/]")
|
|
859
|
+
console.print(" 2. Run tests: [cyan]pytest[/]")
|
|
860
|
+
console.print(" 3. Commit if satisfied: [cyan]git commit -am 'Implement tasks'[/]")
|
|
861
|
+
console.print("\n [dim]Tip: Use --auto-commit to commit automatically after each task[/]")
|
|
862
|
+
|
|
863
|
+
if failed > 0:
|
|
864
|
+
console.print("\n[bold]Recovery Options:[/]")
|
|
865
|
+
console.print(" • Reset to last checkpoint: [cyan]up reset[/]")
|
|
866
|
+
console.print(" • View checkpoint history: [cyan]up status[/]")
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _build_implementation_prompt(workspace: Path, task: dict, task_source: str) -> str:
|
|
870
|
+
"""Build a prompt for the AI to implement the task."""
|
|
871
|
+
task_id = task.get("id", "unknown")
|
|
872
|
+
task_title = task.get("title", "")
|
|
873
|
+
task_desc = task.get("description", task_title)
|
|
874
|
+
priority = task.get("priority", "medium")
|
|
875
|
+
|
|
876
|
+
# Read project context
|
|
877
|
+
context = ""
|
|
878
|
+
readme = workspace / "README.md"
|
|
879
|
+
if not readme.exists():
|
|
880
|
+
readme = workspace / "Readme.md"
|
|
881
|
+
if readme.exists():
|
|
882
|
+
content = readme.read_text()
|
|
883
|
+
if len(content) > 2000:
|
|
884
|
+
content = content[:2000] + "..."
|
|
885
|
+
context = f"\n\nProject README:\n{content}"
|
|
886
|
+
|
|
887
|
+
return f"""Implement this task in the current project:
|
|
888
|
+
|
|
889
|
+
Task ID: {task_id}
|
|
890
|
+
Title: {task_title}
|
|
891
|
+
Description: {task_desc}
|
|
892
|
+
Priority: {priority}
|
|
893
|
+
|
|
894
|
+
Requirements:
|
|
895
|
+
1. Make minimal, focused changes
|
|
896
|
+
2. Follow existing code style and patterns
|
|
897
|
+
3. Add tests if appropriate
|
|
898
|
+
4. Update documentation if needed
|
|
899
|
+
{context}
|
|
900
|
+
|
|
901
|
+
Implement this task now. Make the necessary code changes."""
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _run_ai_implementation(workspace: Path, prompt: str, cli_name: str, timeout: int = 600) -> tuple[bool, str]:
|
|
905
|
+
"""Run AI CLI to implement the task."""
|
|
906
|
+
return run_ai_task(workspace, prompt, cli_name, timeout=timeout)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _create_checkpoint(workspace: Path, name: str, task_id: str = None) -> bool:
|
|
910
|
+
"""Create a git checkpoint using the unified CheckpointManager.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
workspace: Project root directory
|
|
914
|
+
name: Checkpoint name/message
|
|
915
|
+
task_id: Associated task ID
|
|
916
|
+
|
|
917
|
+
Returns:
|
|
918
|
+
True if checkpoint created successfully
|
|
919
|
+
"""
|
|
920
|
+
try:
|
|
921
|
+
manager = get_checkpoint_manager(workspace)
|
|
922
|
+
manager.save(message=name, task_id=task_id)
|
|
923
|
+
return True
|
|
924
|
+
except NotAGitRepoError:
|
|
925
|
+
# Not a git repo, skip checkpoint
|
|
926
|
+
return False
|
|
927
|
+
except Exception:
|
|
928
|
+
return False
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _rollback_checkpoint(workspace: Path, checkpoint_id: str = None) -> bool:
|
|
932
|
+
"""Rollback to checkpoint using the unified CheckpointManager.
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
workspace: Project root directory
|
|
936
|
+
checkpoint_id: Specific checkpoint to restore (defaults to last)
|
|
937
|
+
|
|
938
|
+
Returns:
|
|
939
|
+
True if rollback successful
|
|
940
|
+
"""
|
|
941
|
+
try:
|
|
942
|
+
manager = get_checkpoint_manager(workspace)
|
|
943
|
+
manager.restore(checkpoint_id=checkpoint_id)
|
|
944
|
+
return True
|
|
945
|
+
except Exception:
|
|
946
|
+
return False
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _mark_task_complete(workspace: Path, task_source: str, task_id: str) -> None:
|
|
950
|
+
"""Mark a task as complete in the PRD."""
|
|
951
|
+
if not task_source or not task_source.endswith(".json"):
|
|
952
|
+
return
|
|
953
|
+
|
|
954
|
+
prd_path = workspace / task_source
|
|
955
|
+
if not prd_path.exists():
|
|
956
|
+
return
|
|
957
|
+
|
|
958
|
+
try:
|
|
959
|
+
data = json.loads(prd_path.read_text())
|
|
960
|
+
stories = data.get("userStories", [])
|
|
961
|
+
|
|
962
|
+
for story in stories:
|
|
963
|
+
if story.get("id") == task_id:
|
|
964
|
+
story["passes"] = True
|
|
965
|
+
story["completedAt"] = time.strftime("%Y-%m-%d")
|
|
966
|
+
break
|
|
967
|
+
|
|
968
|
+
prd_path.write_text(json.dumps(data, indent=2))
|
|
969
|
+
except Exception:
|
|
970
|
+
pass
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _run_verification(workspace: Path) -> bool:
|
|
974
|
+
"""Run verification steps (tests, lint, type check).
|
|
975
|
+
|
|
976
|
+
Returns:
|
|
977
|
+
True if all verification passes
|
|
978
|
+
"""
|
|
979
|
+
tests_passed, _ = _run_verification_with_results(workspace)
|
|
980
|
+
return tests_passed is not False
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _run_verification_with_results(workspace: Path) -> tuple:
|
|
984
|
+
"""Run verification steps and return individual results.
|
|
985
|
+
|
|
986
|
+
Returns:
|
|
987
|
+
Tuple of (tests_passed, lint_passed) where:
|
|
988
|
+
- True = passed
|
|
989
|
+
- False = failed
|
|
990
|
+
- None = not run or not applicable
|
|
991
|
+
"""
|
|
992
|
+
import subprocess
|
|
993
|
+
|
|
994
|
+
tests_passed = None
|
|
995
|
+
lint_passed = None
|
|
996
|
+
|
|
997
|
+
# Check if pytest exists and run tests
|
|
998
|
+
try:
|
|
999
|
+
pytest_result = subprocess.run(
|
|
1000
|
+
["pytest", "--tb=no", "-q"],
|
|
1001
|
+
cwd=workspace,
|
|
1002
|
+
capture_output=True,
|
|
1003
|
+
timeout=300
|
|
1004
|
+
)
|
|
1005
|
+
if pytest_result.returncode == 0:
|
|
1006
|
+
console.print(" [green]✓[/] Tests passed")
|
|
1007
|
+
tests_passed = True
|
|
1008
|
+
elif pytest_result.returncode == 5:
|
|
1009
|
+
# No tests collected - that's OK
|
|
1010
|
+
console.print(" [dim]○[/] No tests found")
|
|
1011
|
+
tests_passed = None
|
|
1012
|
+
else:
|
|
1013
|
+
console.print(" [red]✗[/] Tests failed")
|
|
1014
|
+
tests_passed = False
|
|
1015
|
+
except FileNotFoundError:
|
|
1016
|
+
console.print(" [dim]○[/] pytest not installed")
|
|
1017
|
+
tests_passed = None
|
|
1018
|
+
except subprocess.TimeoutExpired:
|
|
1019
|
+
console.print(" [yellow]⚠[/] Tests timeout")
|
|
1020
|
+
tests_passed = False
|
|
1021
|
+
|
|
1022
|
+
# Check for lint (optional - don't fail if not installed)
|
|
1023
|
+
try:
|
|
1024
|
+
ruff_result = subprocess.run(
|
|
1025
|
+
["ruff", "check", ".", "--quiet"],
|
|
1026
|
+
cwd=workspace,
|
|
1027
|
+
capture_output=True,
|
|
1028
|
+
timeout=60
|
|
1029
|
+
)
|
|
1030
|
+
if ruff_result.returncode == 0:
|
|
1031
|
+
console.print(" [green]✓[/] Lint passed")
|
|
1032
|
+
lint_passed = True
|
|
1033
|
+
else:
|
|
1034
|
+
console.print(" [yellow]⚠[/] Lint warnings")
|
|
1035
|
+
lint_passed = False # Track but don't fail
|
|
1036
|
+
except FileNotFoundError:
|
|
1037
|
+
lint_passed = None # ruff not installed, skip
|
|
1038
|
+
except subprocess.TimeoutExpired:
|
|
1039
|
+
console.print(" [yellow]⚠[/] Lint timeout")
|
|
1040
|
+
lint_passed = None
|
|
1041
|
+
|
|
1042
|
+
return tests_passed, lint_passed
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _get_modified_files(workspace: Path) -> list:
|
|
1046
|
+
"""Get list of files modified since HEAD (uncommitted changes).
|
|
1047
|
+
|
|
1048
|
+
Returns:
|
|
1049
|
+
List of modified file paths relative to workspace
|
|
1050
|
+
"""
|
|
1051
|
+
import subprocess
|
|
1052
|
+
|
|
1053
|
+
try:
|
|
1054
|
+
# Get staged and unstaged changes
|
|
1055
|
+
result = subprocess.run(
|
|
1056
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
1057
|
+
cwd=workspace,
|
|
1058
|
+
capture_output=True,
|
|
1059
|
+
text=True,
|
|
1060
|
+
timeout=30
|
|
1061
|
+
)
|
|
1062
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1063
|
+
return result.stdout.strip().split("\n")
|
|
1064
|
+
|
|
1065
|
+
# Also check for untracked files
|
|
1066
|
+
result = subprocess.run(
|
|
1067
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
1068
|
+
cwd=workspace,
|
|
1069
|
+
capture_output=True,
|
|
1070
|
+
text=True,
|
|
1071
|
+
timeout=30
|
|
1072
|
+
)
|
|
1073
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1074
|
+
return result.stdout.strip().split("\n")
|
|
1075
|
+
|
|
1076
|
+
return []
|
|
1077
|
+
except Exception:
|
|
1078
|
+
return []
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _get_diff_summary(workspace: Path) -> str:
|
|
1082
|
+
"""Get a summary of current changes."""
|
|
1083
|
+
import subprocess
|
|
1084
|
+
|
|
1085
|
+
result = subprocess.run(
|
|
1086
|
+
["git", "diff", "--stat", "HEAD"],
|
|
1087
|
+
cwd=workspace,
|
|
1088
|
+
capture_output=True,
|
|
1089
|
+
text=True
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1093
|
+
return f"[dim]{result.stdout.strip()}[/]"
|
|
1094
|
+
return "[dim]No changes[/]"
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _commit_changes(workspace: Path, message: str) -> bool:
|
|
1098
|
+
"""Commit all changes with given message.
|
|
1099
|
+
|
|
1100
|
+
Returns:
|
|
1101
|
+
True if commit successful
|
|
1102
|
+
"""
|
|
1103
|
+
import subprocess
|
|
1104
|
+
|
|
1105
|
+
# Stage all changes
|
|
1106
|
+
subprocess.run(
|
|
1107
|
+
["git", "add", "-A"],
|
|
1108
|
+
cwd=workspace,
|
|
1109
|
+
capture_output=True
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
# Commit
|
|
1113
|
+
result = subprocess.run(
|
|
1114
|
+
["git", "commit", "-m", message],
|
|
1115
|
+
cwd=workspace,
|
|
1116
|
+
capture_output=True,
|
|
1117
|
+
text=True
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
return result.returncode == 0
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
if __name__ == "__main__":
|
|
1124
|
+
start_cmd()
|