up-cli 0.2.0__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 +54 -9
- up/commands/agent.py +521 -0
- up/commands/bisect.py +343 -0
- up/commands/branch.py +350 -0
- up/commands/init.py +195 -6
- up/commands/learn.py +1392 -32
- up/commands/memory.py +545 -0
- up/commands/provenance.py +267 -0
- up/commands/review.py +239 -0
- up/commands/start.py +752 -42
- up/commands/status.py +173 -18
- up/commands/sync.py +317 -0
- up/commands/vibe.py +304 -0
- up/context.py +64 -10
- 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/templates/config/__init__.py +1 -1
- 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/loop/__init__.py +92 -32
- up/ui/__init__.py +14 -0
- up/ui/loop_display.py +650 -0
- up/ui/theme.py +137 -0
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/METADATA +160 -15
- up_cli-0.5.0.dist-info/RECORD +55 -0
- up_cli-0.2.0.dist-info/RECORD +0 -23
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/commands/start.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""up start - Start the product loop."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
4
7
|
import time
|
|
5
8
|
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
6
10
|
|
|
7
11
|
import click
|
|
8
12
|
from rich.console import Console
|
|
@@ -10,7 +14,66 @@ from rich.panel import Panel
|
|
|
10
14
|
from rich.table import Table
|
|
11
15
|
from tqdm import tqdm
|
|
12
16
|
|
|
13
|
-
|
|
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
|
|
14
77
|
|
|
15
78
|
|
|
16
79
|
@click.command()
|
|
@@ -19,9 +82,18 @@ console = Console()
|
|
|
19
82
|
@click.option("--task", "-t", help="Start with specific task ID")
|
|
20
83
|
@click.option("--prd", "-p", type=click.Path(exists=True), help="Path to PRD file")
|
|
21
84
|
@click.option("--interactive", "-i", is_flag=True, help="Interactive mode with confirmations")
|
|
22
|
-
|
|
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):
|
|
23
93
|
"""Start the product loop for autonomous development.
|
|
24
94
|
|
|
95
|
+
Uses Claude/Cursor AI by default to implement tasks automatically.
|
|
96
|
+
|
|
25
97
|
The product loop implements SESRC principles:
|
|
26
98
|
- Stable: Graceful degradation
|
|
27
99
|
- Efficient: Token budgets
|
|
@@ -31,11 +103,15 @@ def start_cmd(resume: bool, dry_run: bool, task: str, prd: str, interactive: boo
|
|
|
31
103
|
|
|
32
104
|
\b
|
|
33
105
|
Examples:
|
|
34
|
-
up start #
|
|
106
|
+
up start # Auto-implement next task with AI
|
|
107
|
+
up start --all # Auto-implement ALL tasks
|
|
35
108
|
up start --resume # Resume from checkpoint
|
|
36
|
-
up start --task US-003 #
|
|
109
|
+
up start --task US-003 # Implement specific task
|
|
37
110
|
up start --dry-run # Preview mode
|
|
38
|
-
up start -
|
|
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
|
|
39
115
|
"""
|
|
40
116
|
cwd = Path.cwd()
|
|
41
117
|
|
|
@@ -95,21 +171,63 @@ def start_cmd(resume: bool, dry_run: bool, task: str, prd: str, interactive: boo
|
|
|
95
171
|
console.print("Run [cyan]up start --resume[/] after fixing the issue.")
|
|
96
172
|
raise SystemExit(1)
|
|
97
173
|
|
|
98
|
-
#
|
|
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
|
|
99
210
|
if dry_run:
|
|
100
211
|
console.print("\n[yellow]DRY RUN MODE[/] - No changes will be made")
|
|
101
212
|
_preview_loop(cwd, state, task_source, task)
|
|
102
213
|
return
|
|
103
214
|
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
109
223
|
|
|
110
224
|
# Start the loop with progress
|
|
111
225
|
console.print("\n[bold green]Starting product loop...[/]")
|
|
112
|
-
|
|
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)
|
|
113
231
|
|
|
114
232
|
|
|
115
233
|
def _display_status_table(state: dict, task_source: str, workspace: Path, resume: bool):
|
|
@@ -179,31 +297,62 @@ def _find_task_source(workspace: Path, prd_path: str = None) -> str:
|
|
|
179
297
|
|
|
180
298
|
|
|
181
299
|
def _load_loop_state(workspace: Path) -> dict:
|
|
182
|
-
"""Load loop state from file.
|
|
183
|
-
state_file = workspace / ".loop_state.json"
|
|
300
|
+
"""Load loop state from unified state file.
|
|
184
301
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
190
307
|
|
|
308
|
+
# Convert to dict format for backwards compatibility
|
|
191
309
|
return {
|
|
192
|
-
"version":
|
|
193
|
-
"iteration":
|
|
194
|
-
"phase":
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
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
|
+
},
|
|
199
326
|
}
|
|
200
327
|
|
|
201
328
|
|
|
202
329
|
def _save_loop_state(workspace: Path, state: dict) -> None:
|
|
203
|
-
"""Save loop state to file.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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()
|
|
207
356
|
|
|
208
357
|
|
|
209
358
|
def _count_tasks(workspace: Path, task_source: str) -> int:
|
|
@@ -231,16 +380,26 @@ def _count_tasks(workspace: Path, task_source: str) -> int:
|
|
|
231
380
|
|
|
232
381
|
|
|
233
382
|
def _check_circuit_breaker(state: dict) -> dict:
|
|
234
|
-
"""Check circuit breaker status.
|
|
383
|
+
"""Check circuit breaker status.
|
|
384
|
+
|
|
385
|
+
Now uses the can_execute() method which includes cooldown check.
|
|
386
|
+
"""
|
|
235
387
|
cb = state.get("circuit_breaker", {})
|
|
236
388
|
|
|
237
389
|
for name, circuit in cb.items():
|
|
238
|
-
if isinstance(circuit, dict)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|
|
244
403
|
|
|
245
404
|
return {"open": False}
|
|
246
405
|
|
|
@@ -400,15 +559,566 @@ def _generate_loop_instructions(
|
|
|
400
559
|
{task_info}
|
|
401
560
|
|
|
402
561
|
SESRC Loop Commands:
|
|
403
|
-
├─ Checkpoint:
|
|
562
|
+
├─ Checkpoint: up save (creates git checkpoint)
|
|
404
563
|
├─ Verify: pytest && mypy src/ && ruff check src/
|
|
405
|
-
├─ Rollback:
|
|
406
|
-
└─ Complete:
|
|
564
|
+
├─ Rollback: up reset (restores last checkpoint)
|
|
565
|
+
└─ Complete: up status (view progress)
|
|
407
566
|
|
|
408
|
-
Circuit Breaker: 3 failures → OPEN
|
|
409
|
-
State File: .
|
|
567
|
+
Circuit Breaker: 3 consecutive failures → OPEN
|
|
568
|
+
State File: .up/state.json
|
|
410
569
|
"""
|
|
411
570
|
|
|
412
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
|
+
|
|
413
1123
|
if __name__ == "__main__":
|
|
414
1124
|
start_cmd()
|