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.
Files changed (55) hide show
  1. up/__init__.py +1 -1
  2. up/ai_cli.py +229 -0
  3. up/cli.py +75 -4
  4. up/commands/agent.py +521 -0
  5. up/commands/bisect.py +343 -0
  6. up/commands/branch.py +350 -0
  7. up/commands/dashboard.py +248 -0
  8. up/commands/init.py +195 -6
  9. up/commands/learn.py +1741 -0
  10. up/commands/memory.py +545 -0
  11. up/commands/new.py +108 -10
  12. up/commands/provenance.py +267 -0
  13. up/commands/review.py +239 -0
  14. up/commands/start.py +1124 -0
  15. up/commands/status.py +360 -0
  16. up/commands/summarize.py +122 -0
  17. up/commands/sync.py +317 -0
  18. up/commands/vibe.py +304 -0
  19. up/context.py +421 -0
  20. up/core/__init__.py +69 -0
  21. up/core/checkpoint.py +479 -0
  22. up/core/provenance.py +364 -0
  23. up/core/state.py +678 -0
  24. up/events.py +512 -0
  25. up/git/__init__.py +37 -0
  26. up/git/utils.py +270 -0
  27. up/git/worktree.py +331 -0
  28. up/learn/__init__.py +155 -0
  29. up/learn/analyzer.py +227 -0
  30. up/learn/plan.py +374 -0
  31. up/learn/research.py +511 -0
  32. up/learn/utils.py +117 -0
  33. up/memory.py +1096 -0
  34. up/parallel.py +551 -0
  35. up/summarizer.py +407 -0
  36. up/templates/__init__.py +70 -2
  37. up/templates/config/__init__.py +502 -20
  38. up/templates/docs/SKILL.md +28 -0
  39. up/templates/docs/__init__.py +341 -0
  40. up/templates/docs/standards/HEADERS.md +24 -0
  41. up/templates/docs/standards/STRUCTURE.md +18 -0
  42. up/templates/docs/standards/TEMPLATES.md +19 -0
  43. up/templates/learn/__init__.py +567 -14
  44. up/templates/loop/__init__.py +546 -27
  45. up/templates/mcp/__init__.py +474 -0
  46. up/templates/projects/__init__.py +786 -0
  47. up/ui/__init__.py +14 -0
  48. up/ui/loop_display.py +650 -0
  49. up/ui/theme.py +137 -0
  50. up_cli-0.5.0.dist-info/METADATA +519 -0
  51. up_cli-0.5.0.dist-info/RECORD +55 -0
  52. up_cli-0.1.1.dist-info/METADATA +0 -186
  53. up_cli-0.1.1.dist-info/RECORD +0 -14
  54. {up_cli-0.1.1.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
  55. {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()