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.
Files changed (46) hide show
  1. up/__init__.py +1 -1
  2. up/ai_cli.py +229 -0
  3. up/cli.py +54 -9
  4. up/commands/agent.py +521 -0
  5. up/commands/bisect.py +343 -0
  6. up/commands/branch.py +350 -0
  7. up/commands/init.py +195 -6
  8. up/commands/learn.py +1392 -32
  9. up/commands/memory.py +545 -0
  10. up/commands/provenance.py +267 -0
  11. up/commands/review.py +239 -0
  12. up/commands/start.py +752 -42
  13. up/commands/status.py +173 -18
  14. up/commands/sync.py +317 -0
  15. up/commands/vibe.py +304 -0
  16. up/context.py +64 -10
  17. up/core/__init__.py +69 -0
  18. up/core/checkpoint.py +479 -0
  19. up/core/provenance.py +364 -0
  20. up/core/state.py +678 -0
  21. up/events.py +512 -0
  22. up/git/__init__.py +37 -0
  23. up/git/utils.py +270 -0
  24. up/git/worktree.py +331 -0
  25. up/learn/__init__.py +155 -0
  26. up/learn/analyzer.py +227 -0
  27. up/learn/plan.py +374 -0
  28. up/learn/research.py +511 -0
  29. up/learn/utils.py +117 -0
  30. up/memory.py +1096 -0
  31. up/parallel.py +551 -0
  32. up/templates/config/__init__.py +1 -1
  33. up/templates/docs/SKILL.md +28 -0
  34. up/templates/docs/__init__.py +341 -0
  35. up/templates/docs/standards/HEADERS.md +24 -0
  36. up/templates/docs/standards/STRUCTURE.md +18 -0
  37. up/templates/docs/standards/TEMPLATES.md +19 -0
  38. up/templates/loop/__init__.py +92 -32
  39. up/ui/__init__.py +14 -0
  40. up/ui/loop_display.py +650 -0
  41. up/ui/theme.py +137 -0
  42. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/METADATA +160 -15
  43. up_cli-0.5.0.dist-info/RECORD +55 -0
  44. up_cli-0.2.0.dist-info/RECORD +0 -23
  45. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
  46. {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
- console = Console()
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
- def start_cmd(resume: bool, dry_run: bool, task: str, prd: str, interactive: bool):
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 # Start fresh
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 # Start specific task
109
+ up start --task US-003 # Implement specific task
37
110
  up start --dry-run # Preview mode
38
- up start -i # Interactive 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
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
- # Dry run mode
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
- # Interactive confirmation
105
- if interactive:
106
- if not click.confirm("\nStart the product loop?"):
107
- console.print("[dim]Cancelled[/]")
108
- return
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
- _run_product_loop_with_progress(cwd, state, task_source, task, resume)
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
- if state_file.exists():
186
- try:
187
- return json.loads(state_file.read_text())
188
- except json.JSONDecodeError:
189
- pass
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": "1.0",
193
- "iteration": 0,
194
- "phase": "INIT",
195
- "tasks_completed": [],
196
- "tasks_remaining": [],
197
- "circuit_breaker": {},
198
- "metrics": {"total_edits": 0, "total_rollbacks": 0, "success_rate": 1.0},
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
- from datetime import datetime
205
- state["last_updated"] = datetime.now().isoformat()
206
- (workspace / ".loop_state.json").write_text(json.dumps(state, indent=2))
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) and circuit.get("state") == "OPEN":
239
- return {
240
- "open": True,
241
- "circuit": name,
242
- "reason": f"{name} circuit opened after {circuit.get('failures', 0)} failures",
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: git stash push -m "cp-{state.get('iteration', 1)}"
562
+ ├─ Checkpoint: up save (creates git checkpoint)
404
563
  ├─ Verify: pytest && mypy src/ && ruff check src/
405
- ├─ Rollback: git stash pop
406
- └─ Complete: Update .loop_state.json
564
+ ├─ Rollback: up reset (restores last checkpoint)
565
+ └─ Complete: up status (view progress)
407
566
 
408
- Circuit Breaker: 3 failures → OPEN
409
- State File: .loop_state.json
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()