Coverage for little_loops / cli.py: 14%

1042 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-02-01 22:39 -0600

1"""CLI entry points for little-loops. 

2 

3Provides command-line interfaces for automated issue management: 

4- ll-auto: Sequential issue processing 

5- ll-parallel: Parallel issue processing with git worktrees 

6- ll-messages: Extract user messages from Claude Code logs 

7- ll-sprint: Sprint and sequence management 

8""" 

9 

10from __future__ import annotations 

11 

12import argparse 

13import signal 

14import sys 

15from pathlib import Path 

16from types import FrameType 

17from typing import Any 

18 

19from little_loops.cli_args import ( 

20 add_common_auto_args, 

21 add_config_arg, 

22 add_dry_run_arg, 

23 add_max_issues_arg, 

24 add_max_workers_arg, 

25 add_only_arg, 

26 add_quiet_arg, 

27 add_resume_arg, 

28 add_skip_arg, 

29 add_timeout_arg, 

30 parse_issue_ids, 

31) 

32from little_loops.config import BRConfig 

33from little_loops.dependency_graph import DependencyGraph 

34from little_loops.issue_manager import AutoManager 

35from little_loops.logger import Logger, format_duration 

36from little_loops.parallel.orchestrator import ParallelOrchestrator 

37from little_loops.sprint import SprintManager, SprintOptions, SprintState 

38 

39# Module-level shutdown flag for ll-sprint signal handling (ENH-183) 

40_sprint_shutdown_requested: bool = False 

41 

42 

43def _sprint_signal_handler(signum: int, frame: FrameType | None) -> None: 

44 """Handle shutdown signals gracefully for ll-sprint. 

45 

46 First signal: Set shutdown flag for graceful exit after current wave. 

47 Second signal: Force immediate exit. 

48 """ 

49 global _sprint_shutdown_requested 

50 if _sprint_shutdown_requested: 

51 # Second signal - force exit 

52 print("\nForce shutdown requested", file=sys.stderr) 

53 sys.exit(1) 

54 _sprint_shutdown_requested = True 

55 print("\nShutdown requested, will exit after current wave...", file=sys.stderr) 

56 

57 

58def main_auto() -> int: 

59 """Entry point for ll-auto command. 

60 

61 Sequential automated issue management with Claude CLI. 

62 

63 Returns: 

64 Exit code (0 = success) 

65 """ 

66 parser = argparse.ArgumentParser( 

67 description="Automated sequential issue management with Claude CLI", 

68 formatter_class=argparse.RawDescriptionHelpFormatter, 

69 epilog=""" 

70Examples: 

71 %(prog)s # Process all issues in priority order 

72 %(prog)s --max-issues 5 # Process at most 5 issues 

73 %(prog)s --resume # Resume from previous state 

74 %(prog)s --dry-run # Preview what would be processed 

75 %(prog)s --category bugs # Only process bugs 

76 %(prog)s --only BUG-001,BUG-002 # Process only specific issues 

77 %(prog)s --skip BUG-003 # Skip specific issues 

78""", 

79 ) 

80 

81 # Add common arguments from shared module 

82 add_common_auto_args(parser) 

83 

84 # Add tool-specific arguments 

85 parser.add_argument( 

86 "--category", 

87 "-c", 

88 type=str, 

89 default=None, 

90 help="Filter to specific category (bugs, features, enhancements)", 

91 ) 

92 

93 args = parser.parse_args() 

94 

95 project_root = args.config or Path.cwd() 

96 config = BRConfig(project_root) 

97 

98 # Parse issue ID filters 

99 only_ids = parse_issue_ids(args.only) 

100 skip_ids = parse_issue_ids(args.skip) 

101 

102 manager = AutoManager( 

103 config=config, 

104 dry_run=args.dry_run, 

105 max_issues=args.max_issues, 

106 resume=args.resume, 

107 category=args.category, 

108 only_ids=only_ids, 

109 skip_ids=skip_ids, 

110 verbose=not args.quiet, 

111 ) 

112 

113 return manager.run() 

114 

115 

116def main_parallel() -> int: 

117 """Entry point for ll-parallel command. 

118 

119 Parallel issue management using git worktrees. 

120 

121 Returns: 

122 Exit code (0 = success) 

123 """ 

124 parser = argparse.ArgumentParser( 

125 description="Parallel issue management with git worktrees", 

126 formatter_class=argparse.RawDescriptionHelpFormatter, 

127 epilog=""" 

128Examples: 

129 %(prog)s # Process with default workers 

130 %(prog)s --workers 3 # Use 3 parallel workers 

131 %(prog)s --dry-run # Preview what would be processed 

132 %(prog)s --priority P1,P2 # Only process P1 and P2 issues 

133 %(prog)s --cleanup # Clean up worktrees and exit 

134 %(prog)s --stream-output # Stream Claude CLI output in real-time 

135 %(prog)s --only BUG-001,BUG-002 # Process only specific issues 

136 %(prog)s --skip BUG-003 # Skip specific issues 

137""", 

138 ) 

139 

140 # Parallel-specific arguments (--workers, not --max-workers) 

141 parser.add_argument( 

142 "--workers", 

143 "-w", 

144 type=int, 

145 default=None, 

146 help="Number of parallel workers (default: from config or 2)", 

147 ) 

148 parser.add_argument( 

149 "--priority", 

150 "-p", 

151 type=str, 

152 default=None, 

153 help="Comma-separated priorities to process (default: all)", 

154 ) 

155 parser.add_argument( 

156 "--worktree-base", 

157 type=Path, 

158 default=None, 

159 help="Base directory for git worktrees", 

160 ) 

161 parser.add_argument( 

162 "--cleanup", 

163 "-c", 

164 action="store_true", 

165 help="Clean up all worktrees and exit", 

166 ) 

167 parser.add_argument( 

168 "--merge-pending", 

169 action="store_true", 

170 help="Attempt to merge pending work from previous interrupted runs", 

171 ) 

172 parser.add_argument( 

173 "--clean-start", 

174 action="store_true", 

175 help="Remove all worktrees and start fresh (skip pending work check)", 

176 ) 

177 parser.add_argument( 

178 "--ignore-pending", 

179 action="store_true", 

180 help="Report pending work but continue without merging", 

181 ) 

182 parser.add_argument( 

183 "--stream-output", 

184 action="store_true", 

185 help="Stream Claude CLI subprocess output to console", 

186 ) 

187 parser.add_argument( 

188 "--show-model", 

189 action="store_true", 

190 help="Make API call to verify and display model on worktree setup", 

191 ) 

192 parser.add_argument( 

193 "--overlap-detection", 

194 action="store_true", 

195 help="Enable pre-flight overlap detection to reduce merge conflicts (ENH-143)", 

196 ) 

197 parser.add_argument( 

198 "--warn-only", 

199 action="store_true", 

200 help="With --overlap-detection, warn about overlaps instead of serializing", 

201 ) 

202 

203 # Add common arguments from shared module 

204 add_dry_run_arg(parser) 

205 add_resume_arg(parser) 

206 add_timeout_arg(parser) 

207 add_quiet_arg(parser) 

208 add_only_arg(parser) 

209 add_skip_arg(parser) 

210 

211 # Add max-issues and config individually (different help text needed) 

212 add_max_issues_arg(parser) 

213 parser.add_argument( 

214 "--config", 

215 type=Path, 

216 default=None, 

217 help="Path to project root", 

218 ) 

219 

220 args = parser.parse_args() 

221 

222 project_root = args.config or Path.cwd() 

223 config = BRConfig(project_root) 

224 

225 logger = Logger(verbose=not args.quiet) 

226 

227 # Handle cleanup mode 

228 if args.cleanup: 

229 from little_loops.parallel import WorkerPool 

230 

231 parallel_config = config.create_parallel_config() 

232 pool = WorkerPool(parallel_config, config, logger, project_root) 

233 pool.cleanup_all_worktrees() 

234 logger.success("Cleanup complete") 

235 return 0 

236 

237 # Build priority filter 

238 priority_filter = ( 

239 [p.strip().upper() for p in args.priority.split(",")] if args.priority else None 

240 ) 

241 

242 # Parse issue ID filters 

243 only_ids = parse_issue_ids(args.only) 

244 skip_ids = parse_issue_ids(args.skip) 

245 

246 # Create parallel config with CLI overrides 

247 parallel_config = config.create_parallel_config( 

248 max_workers=args.workers, 

249 priority_filter=priority_filter, 

250 max_issues=args.max_issues, 

251 dry_run=args.dry_run, 

252 timeout_seconds=args.timeout, 

253 stream_output=args.stream_output if args.stream_output else None, 

254 show_model=args.show_model if args.show_model else None, 

255 only_ids=only_ids, 

256 skip_ids=skip_ids, 

257 merge_pending=args.merge_pending, 

258 clean_start=args.clean_start, 

259 ignore_pending=args.ignore_pending, 

260 overlap_detection=args.overlap_detection, 

261 serialize_overlapping=not args.warn_only, 

262 ) 

263 

264 # Delete state file if not resuming 

265 if not args.resume: 

266 state_file = config.get_parallel_state_file() 

267 if state_file.exists(): 

268 state_file.unlink() 

269 

270 # Create and run orchestrator 

271 from little_loops.parallel import ParallelOrchestrator 

272 

273 orchestrator = ParallelOrchestrator( 

274 parallel_config=parallel_config, 

275 br_config=config, 

276 repo_path=project_root, 

277 verbose=not args.quiet, 

278 ) 

279 

280 return orchestrator.run() 

281 

282 

283def main_messages() -> int: 

284 """Entry point for ll-messages command. 

285 

286 Extract user messages from Claude Code session logs. 

287 

288 Returns: 

289 Exit code (0 = success) 

290 """ 

291 from datetime import datetime 

292 

293 from little_loops.user_messages import ( 

294 extract_user_messages, 

295 get_project_folder, 

296 print_messages_to_stdout, 

297 save_messages, 

298 ) 

299 

300 parser = argparse.ArgumentParser( 

301 description="Extract user messages from Claude Code logs", 

302 formatter_class=argparse.RawDescriptionHelpFormatter, 

303 epilog=""" 

304Examples: 

305 %(prog)s # Last 100 messages to file 

306 %(prog)s -n 50 # Last 50 messages 

307 %(prog)s --since 2026-01-01 # Messages since date 

308 %(prog)s -o output.jsonl # Custom output path 

309 %(prog)s --stdout # Print to terminal 

310 %(prog)s --include-response-context # Include response metadata 

311""", 

312 ) 

313 parser.add_argument( 

314 "-n", 

315 "--limit", 

316 type=int, 

317 default=100, 

318 help="Maximum number of messages to extract (default: 100)", 

319 ) 

320 parser.add_argument( 

321 "--since", 

322 type=str, 

323 help="Only include messages after this date (YYYY-MM-DD or ISO format)", 

324 ) 

325 parser.add_argument( 

326 "-o", 

327 "--output", 

328 type=Path, 

329 help="Output file path (default: .claude/user-messages-{timestamp}.jsonl)", 

330 ) 

331 parser.add_argument( 

332 "--cwd", 

333 type=Path, 

334 help="Working directory to use (default: current directory)", 

335 ) 

336 parser.add_argument( 

337 "--exclude-agents", 

338 action="store_true", 

339 help="Exclude agent session files (agent-*.jsonl)", 

340 ) 

341 parser.add_argument( 

342 "--stdout", 

343 action="store_true", 

344 help="Print messages to stdout instead of writing to file", 

345 ) 

346 parser.add_argument( 

347 "-v", 

348 "--verbose", 

349 action="store_true", 

350 help="Print verbose progress information", 

351 ) 

352 parser.add_argument( 

353 "--include-response-context", 

354 action="store_true", 

355 help="Include metadata from assistant responses (tools used, files modified)", 

356 ) 

357 

358 args = parser.parse_args() 

359 

360 logger = Logger(verbose=args.verbose) 

361 

362 # Parse since date if provided 

363 since = None 

364 if args.since: 

365 try: 

366 # Try ISO format first 

367 since = datetime.fromisoformat(args.since.replace("Z", "+00:00")) 

368 except ValueError: 

369 try: 

370 # Try YYYY-MM-DD format 

371 since = datetime.strptime(args.since, "%Y-%m-%d") 

372 except ValueError: 

373 logger.error(f"Invalid date format: {args.since}") 

374 logger.error("Use YYYY-MM-DD or ISO format") 

375 return 1 

376 

377 # Get project folder 

378 cwd = args.cwd or Path.cwd() 

379 project_folder = get_project_folder(cwd) 

380 

381 if project_folder is None: 

382 logger.error(f"No Claude project folder found for: {cwd}") 

383 logger.error(f"Expected: ~/.claude/projects/{str(cwd).replace('/', '-')}") 

384 return 1 

385 

386 logger.info(f"Project folder: {project_folder}") 

387 logger.info(f"Limit: {args.limit}") 

388 if since: 

389 logger.info(f"Since: {since}") 

390 

391 # Extract messages 

392 messages = extract_user_messages( 

393 project_folder=project_folder, 

394 limit=args.limit, 

395 since=since, 

396 include_agent_sessions=not args.exclude_agents, 

397 include_response_context=args.include_response_context, 

398 ) 

399 

400 if not messages: 

401 logger.warning("No user messages found") 

402 return 0 

403 

404 logger.info(f"Found {len(messages)} messages") 

405 

406 # Output messages 

407 if args.stdout: 

408 print_messages_to_stdout(messages) 

409 else: 

410 output_path = save_messages(messages, args.output) 

411 logger.success(f"Saved {len(messages)} messages to: {output_path}") 

412 

413 return 0 

414 

415 

416def main_loop() -> int: 

417 """Entry point for ll-loop command. 

418 

419 Execute FSM-based automation loops. 

420 

421 Returns: 

422 Exit code (0 = success) 

423 """ 

424 import yaml 

425 

426 from little_loops.fsm.compilers import compile_paradigm 

427 from little_loops.fsm.concurrency import LockManager 

428 from little_loops.fsm.persistence import ( 

429 PersistentExecutor, 

430 StatePersistence, 

431 get_loop_history, 

432 list_running_loops, 

433 ) 

434 from little_loops.fsm.schema import FSMLoop 

435 from little_loops.fsm.validation import load_and_validate 

436 

437 # Check if first positional arg is a subcommand or a loop name 

438 # This enables "ll-loop fix-types" shorthand for "ll-loop run fix-types" 

439 known_subcommands = { 

440 "run", 

441 "compile", 

442 "validate", 

443 "list", 

444 "status", 

445 "stop", 

446 "resume", 

447 "history", 

448 "test", 

449 "simulate", 

450 } 

451 

452 # Pre-process args: if first positional arg is not a subcommand, insert "run" 

453 import sys as _sys 

454 

455 argv = _sys.argv[1:] 

456 if argv and not argv[0].startswith("-") and argv[0] not in known_subcommands: 

457 # First arg is a loop name, not a subcommand - insert "run" 

458 argv = ["run"] + argv 

459 

460 parser = argparse.ArgumentParser( 

461 prog="ll-loop", 

462 description="Execute FSM-based automation loops", 

463 formatter_class=argparse.RawDescriptionHelpFormatter, 

464 epilog=""" 

465Examples: 

466 %(prog)s fix-types # Run loop from .loops/fix-types.yaml 

467 %(prog)s run fix-types --dry-run # Show execution plan 

468 %(prog)s validate fix-types # Validate loop definition 

469 %(prog)s test fix-types # Run single test iteration 

470 %(prog)s simulate fix-types # Interactive simulation (dry-run with prompts) 

471 %(prog)s compile paradigm.yaml # Compile paradigm to FSM 

472 %(prog)s list # List available loops 

473 %(prog)s list --running # List running loops 

474 %(prog)s status fix-types # Show loop status 

475 %(prog)s stop fix-types # Stop a running loop 

476 %(prog)s resume fix-types # Resume interrupted loop 

477 %(prog)s history fix-types # Show execution history 

478""", 

479 ) 

480 

481 subparsers = parser.add_subparsers(dest="command") 

482 

483 # Run subcommand 

484 run_parser = subparsers.add_parser("run", help="Run a loop") 

485 run_parser.add_argument("loop", help="Loop name or path") 

486 run_parser.add_argument("--max-iterations", "-n", type=int, help="Override iteration limit") 

487 run_parser.add_argument("--no-llm", action="store_true", help="Disable LLM evaluation") 

488 run_parser.add_argument("--llm-model", type=str, help="Override LLM model") 

489 run_parser.add_argument( 

490 "--dry-run", action="store_true", help="Show execution plan without running" 

491 ) 

492 run_parser.add_argument( 

493 "--background", "-b", action="store_true", help="Run as daemon (not yet implemented)" 

494 ) 

495 run_parser.add_argument("--quiet", "-q", action="store_true", help="Suppress progress output") 

496 run_parser.add_argument( 

497 "--queue", action="store_true", help="Wait for conflicting loops to finish" 

498 ) 

499 

500 # Compile subcommand 

501 compile_parser = subparsers.add_parser("compile", help="Compile paradigm to FSM") 

502 compile_parser.add_argument("input", help="Input paradigm YAML file") 

503 compile_parser.add_argument("-o", "--output", help="Output FSM YAML file") 

504 

505 # Validate subcommand 

506 validate_parser = subparsers.add_parser("validate", help="Validate loop definition") 

507 validate_parser.add_argument("loop", help="Loop name or path") 

508 

509 # List subcommand 

510 list_parser = subparsers.add_parser("list", help="List loops") 

511 list_parser.add_argument("--running", action="store_true", help="Only show running loops") 

512 

513 # Status subcommand 

514 status_parser = subparsers.add_parser("status", help="Show loop status") 

515 status_parser.add_argument("loop", help="Loop name") 

516 

517 # Stop subcommand 

518 stop_parser = subparsers.add_parser("stop", help="Stop a running loop") 

519 stop_parser.add_argument("loop", help="Loop name") 

520 

521 # Resume subcommand 

522 resume_parser = subparsers.add_parser("resume", help="Resume an interrupted loop") 

523 resume_parser.add_argument("loop", help="Loop name or path") 

524 

525 # History subcommand 

526 history_parser = subparsers.add_parser("history", help="Show loop execution history") 

527 history_parser.add_argument("loop", help="Loop name") 

528 history_parser.add_argument( 

529 "--tail", "-n", type=int, default=50, help="Last N events (default: 50)" 

530 ) 

531 

532 # Test subcommand 

533 test_parser = subparsers.add_parser( 

534 "test", help="Run a single test iteration to verify loop configuration" 

535 ) 

536 test_parser.add_argument("loop", help="Loop name") 

537 

538 # Simulate subcommand 

539 simulate_parser = subparsers.add_parser( 

540 "simulate", 

541 help="Trace loop execution interactively without running commands", 

542 ) 

543 simulate_parser.add_argument("loop", help="Loop name or path") 

544 simulate_parser.add_argument( 

545 "--scenario", 

546 choices=["all-pass", "all-fail", "first-fail", "alternating"], 

547 help="Auto-select results based on pattern instead of prompting", 

548 ) 

549 simulate_parser.add_argument( 

550 "--max-iterations", 

551 "-n", 

552 type=int, 

553 help="Override max iterations for simulation (default: min of loop config or 20)", 

554 ) 

555 

556 args = parser.parse_args(argv) 

557 

558 logger = Logger(verbose=not getattr(args, "quiet", False)) 

559 

560 def resolve_loop_path(name_or_path: str) -> Path: 

561 """Resolve loop name to path, preferring compiled FSM over paradigm.""" 

562 path = Path(name_or_path) 

563 if path.exists(): 

564 return path 

565 

566 # Try .loops/<name>.fsm.yaml first (compiled FSM) 

567 fsm_path = Path(".loops") / f"{name_or_path}.fsm.yaml" 

568 if fsm_path.exists(): 

569 return fsm_path 

570 

571 # Fall back to .loops/<name>.yaml (paradigm) 

572 loops_path = Path(".loops") / f"{name_or_path}.yaml" 

573 if loops_path.exists(): 

574 return loops_path 

575 

576 raise FileNotFoundError(f"Loop not found: {name_or_path}") 

577 

578 def print_execution_plan(fsm: FSMLoop) -> None: 

579 """Print dry-run execution plan.""" 

580 print(f"Execution plan for: {fsm.name}") 

581 print() 

582 print("States:") 

583 for name, state in fsm.states.items(): 

584 terminal_marker = " [TERMINAL]" if state.terminal else "" 

585 print(f" [{name}]{terminal_marker}") 

586 if state.action: 

587 if len(state.action) > 70: 

588 action_display = state.action[:70] + "..." 

589 else: 

590 action_display = state.action 

591 print(f" action: {action_display}") 

592 if state.evaluate: 

593 print(f" evaluate: {state.evaluate.type}") 

594 if state.on_success: 

595 print(f" on_success -> {state.on_success}") 

596 if state.on_failure: 

597 print(f" on_failure -> {state.on_failure}") 

598 if state.on_error: 

599 print(f" on_error -> {state.on_error}") 

600 if state.next: 

601 print(f" next -> {state.next}") 

602 if state.route: 

603 print(" route:") 

604 for verdict, target in state.route.routes.items(): 

605 print(f" {verdict} -> {target}") 

606 if state.route.default: 

607 print(f" _ -> {state.route.default}") 

608 print() 

609 print(f"Initial state: {fsm.initial}") 

610 print(f"Max iterations: {fsm.max_iterations}") 

611 if fsm.timeout: 

612 print(f"Timeout: {fsm.timeout}s") 

613 

614 def run_foreground(executor: PersistentExecutor, fsm: FSMLoop) -> int: 

615 """Run loop with progress display.""" 

616 if not getattr(args, "quiet", False): 

617 print(f"Running loop: {fsm.name}") 

618 print(f"Max iterations: {fsm.max_iterations}") 

619 print() 

620 

621 current_iteration = [0] # Use list to allow mutation in closure 

622 

623 def display_progress(event: dict) -> None: 

624 """Display progress for events.""" 

625 event_type = event.get("event") 

626 

627 if event_type == "state_enter": 

628 current_iteration[0] = event.get("iteration", 0) 

629 state = event.get("state", "") 

630 print(f"[{current_iteration[0]}/{fsm.max_iterations}] {state}", end="") 

631 

632 elif event_type == "action_start": 

633 action = event.get("action", "") 

634 action_display = action[:60] + "..." if len(action) > 60 else action 

635 print(f" -> {action_display}") 

636 

637 elif event_type == "evaluate": 

638 verdict = event.get("verdict", "") 

639 confidence = event.get("confidence") 

640 if verdict in ("success", "target", "progress"): 

641 symbol = "\u2713" # checkmark 

642 else: 

643 symbol = "\u2717" # x mark 

644 if confidence is not None: 

645 print(f" {symbol} {verdict} (confidence: {confidence:.2f})") 

646 else: 

647 print(f" {symbol} {verdict}") 

648 

649 elif event_type == "route": 

650 to_state = event.get("to", "") 

651 print(f" -> {to_state}") 

652 

653 # Create wrapper to combine persistence callback with progress display 

654 original_handle = executor._handle_event 

655 quiet = getattr(args, "quiet", False) 

656 

657 def combined_handler(event: dict) -> None: 

658 original_handle(event) 

659 if not quiet: 

660 display_progress(event) 

661 

662 # Use object.__setattr__ to bypass method assignment check 

663 object.__setattr__(executor, "_handle_event", combined_handler) 

664 

665 result = executor.run() 

666 

667 if not quiet: 

668 print() 

669 duration_sec = result.duration_ms / 1000 

670 if duration_sec < 60: 

671 duration_str = f"{duration_sec:.1f}s" 

672 else: 

673 minutes = int(duration_sec // 60) 

674 seconds = duration_sec % 60 

675 duration_str = f"{minutes}m {seconds:.0f}s" 

676 print( 

677 f"Loop completed: {result.final_state} " 

678 f"({result.iterations} iterations, {duration_str})" 

679 ) 

680 

681 return 0 if result.terminated_by == "terminal" else 1 

682 

683 def cmd_run(loop_name: str) -> int: 

684 """Run a loop.""" 

685 try: 

686 path = resolve_loop_path(loop_name) 

687 

688 # Load the file to check format 

689 with open(path) as f: 

690 spec = yaml.safe_load(f) 

691 

692 # Auto-compile if it's a paradigm file (has 'paradigm' but no 'initial') 

693 if "paradigm" in spec and "initial" not in spec: 

694 logger.info(f"Auto-compiling paradigm file: {path}") 

695 fsm = compile_paradigm(spec) 

696 else: 

697 fsm = load_and_validate(path) 

698 except FileNotFoundError as e: 

699 logger.error(str(e)) 

700 return 1 

701 except ValueError as e: 

702 logger.error(f"Validation error: {e}") 

703 return 1 

704 

705 # Apply overrides 

706 if args.max_iterations: 

707 fsm.max_iterations = args.max_iterations 

708 if args.no_llm: 

709 fsm.llm.enabled = False 

710 if args.llm_model: 

711 fsm.llm.model = args.llm_model 

712 

713 # Dry run 

714 if args.dry_run: 

715 print_execution_plan(fsm) 

716 return 0 

717 

718 # Background mode not implemented 

719 if getattr(args, "background", False): 

720 logger.warning("Background mode not yet implemented, running in foreground") 

721 

722 # Scope-based locking 

723 lock_manager = LockManager() 

724 scope = fsm.scope or ["."] 

725 

726 if not lock_manager.acquire(fsm.name, scope): 

727 conflict = lock_manager.find_conflict(scope) 

728 if conflict and getattr(args, "queue", False): 

729 logger.info(f"Waiting for conflicting loop '{conflict.loop_name}' to finish...") 

730 if not lock_manager.wait_for_scope(scope, timeout=3600): 

731 logger.error("Timeout waiting for scope to become available") 

732 return 1 

733 # Re-acquire after waiting 

734 if not lock_manager.acquire(fsm.name, scope): 

735 logger.error("Failed to acquire lock after waiting") 

736 return 1 

737 elif conflict: 

738 logger.error(f"Scope conflict with running loop: {conflict.loop_name}") 

739 logger.info(f" Conflicting scope: {conflict.scope}") 

740 logger.info(" Use --queue to wait for it to finish") 

741 return 1 

742 else: 

743 # Unexpected: find_conflict returned None but acquire failed 

744 logger.error("Failed to acquire scope lock (unknown reason)") 

745 return 1 

746 

747 try: 

748 executor = PersistentExecutor(fsm) 

749 return run_foreground(executor, fsm) 

750 finally: 

751 lock_manager.release(fsm.name) 

752 

753 def cmd_compile() -> int: 

754 """Compile paradigm YAML to FSM.""" 

755 input_path = Path(args.input) 

756 if not input_path.exists(): 

757 logger.error(f"Input file not found: {input_path}") 

758 return 1 

759 

760 try: 

761 with open(input_path) as f: 

762 spec = yaml.safe_load(f) 

763 fsm = compile_paradigm(spec) 

764 except ValueError as e: 

765 logger.error(f"Compilation error: {e}") 

766 return 1 

767 except yaml.YAMLError as e: 

768 logger.error(f"YAML parse error: {e}") 

769 return 1 

770 

771 output_path = ( 

772 Path(args.output) 

773 if args.output 

774 else Path(str(input_path).replace(".yaml", ".fsm.yaml")) 

775 ) 

776 

777 # Convert FSMLoop to dict for YAML output 

778 fsm_dict: dict[str, Any] = { 

779 "name": fsm.name, 

780 "paradigm": fsm.paradigm, 

781 "initial": fsm.initial, 

782 "states": {name: state.to_dict() for name, state in fsm.states.items()}, 

783 "max_iterations": fsm.max_iterations, 

784 } 

785 if fsm.context: 

786 fsm_dict["context"] = fsm.context 

787 if fsm.maintain: 

788 fsm_dict["maintain"] = fsm.maintain 

789 if fsm.backoff: 

790 fsm_dict["backoff"] = fsm.backoff 

791 if fsm.timeout: 

792 fsm_dict["timeout"] = fsm.timeout 

793 

794 with open(output_path, "w") as f: 

795 yaml.dump(fsm_dict, f, default_flow_style=False, sort_keys=False) 

796 

797 logger.success(f"Compiled to: {output_path}") 

798 return 0 

799 

800 def cmd_validate(loop_name: str) -> int: 

801 """Validate a loop definition.""" 

802 try: 

803 path = resolve_loop_path(loop_name) 

804 

805 # Load the file to check format 

806 with open(path) as f: 

807 spec = yaml.safe_load(f) 

808 

809 # Auto-compile if it's a paradigm file (has 'paradigm' but no 'initial') 

810 if "paradigm" in spec and "initial" not in spec: 

811 logger.info(f"Compiling paradigm file for validation: {path}") 

812 fsm = compile_paradigm(spec) 

813 else: 

814 fsm = load_and_validate(path) 

815 

816 logger.success(f"{loop_name} is valid") 

817 print(f" States: {', '.join(fsm.states.keys())}") 

818 print(f" Initial: {fsm.initial}") 

819 print(f" Max iterations: {fsm.max_iterations}") 

820 return 0 

821 except FileNotFoundError as e: 

822 logger.error(str(e)) 

823 return 1 

824 except ValueError as e: 

825 logger.error(f"{loop_name} is invalid: {e}") 

826 return 1 

827 

828 def cmd_list() -> int: 

829 """List loops.""" 

830 loops_dir = Path(".loops") 

831 

832 if getattr(args, "running", False): 

833 states = list_running_loops(loops_dir) 

834 if not states: 

835 print("No running loops") 

836 return 0 

837 print("Running loops:") 

838 for state in states: 

839 print(f" {state.loop_name}: {state.current_state} (iteration {state.iteration})") 

840 return 0 

841 

842 # List all loop files 

843 if not loops_dir.exists(): 

844 print("No .loops/ directory found") 

845 return 0 

846 

847 yaml_files = list(loops_dir.glob("*.yaml")) 

848 if not yaml_files: 

849 print("No loops defined") 

850 return 0 

851 

852 print("Available loops:") 

853 for path in sorted(yaml_files): 

854 print(f" {path.stem}") 

855 return 0 

856 

857 def cmd_status(loop_name: str) -> int: 

858 """Show loop status.""" 

859 persistence = StatePersistence(loop_name) 

860 state = persistence.load_state() 

861 

862 if state is None: 

863 logger.error(f"No state found for: {loop_name}") 

864 return 1 

865 

866 print(f"Loop: {state.loop_name}") 

867 print(f"Status: {state.status}") 

868 print(f"Current state: {state.current_state}") 

869 print(f"Iteration: {state.iteration}") 

870 print(f"Started: {state.started_at}") 

871 print(f"Updated: {state.updated_at}") 

872 if state.continuation_prompt: 

873 # Show truncated continuation context 

874 prompt_preview = state.continuation_prompt[:200] 

875 if len(state.continuation_prompt) > 200: 

876 prompt_preview += "..." 

877 print(f"Continuation context: {prompt_preview}") 

878 return 0 

879 

880 def cmd_stop(loop_name: str) -> int: 

881 """Stop a running loop.""" 

882 persistence = StatePersistence(loop_name) 

883 state = persistence.load_state() 

884 

885 if state is None: 

886 logger.error(f"No state found for: {loop_name}") 

887 return 1 

888 

889 if state.status != "running": 

890 logger.error(f"Loop not running: {loop_name} (status: {state.status})") 

891 return 1 

892 

893 state.status = "interrupted" 

894 persistence.save_state(state) 

895 logger.success(f"Marked {loop_name} as interrupted") 

896 return 0 

897 

898 def cmd_resume(loop_name: str) -> int: 

899 """Resume an interrupted loop.""" 

900 try: 

901 path = resolve_loop_path(loop_name) 

902 

903 # Load the file to check format 

904 with open(path) as f: 

905 spec = yaml.safe_load(f) 

906 

907 # Auto-compile if it's a paradigm file (has 'paradigm' but no 'initial') 

908 if "paradigm" in spec and "initial" not in spec: 

909 logger.info(f"Auto-compiling paradigm file: {path}") 

910 fsm = compile_paradigm(spec) 

911 else: 

912 fsm = load_and_validate(path) 

913 except FileNotFoundError as e: 

914 logger.error(str(e)) 

915 return 1 

916 except ValueError as e: 

917 logger.error(f"Validation error: {e}") 

918 return 1 

919 

920 # Check state before resuming to show context 

921 persistence = StatePersistence(loop_name) 

922 state = persistence.load_state() 

923 if state and state.status == "awaiting_continuation": 

924 print(f"Resuming from context handoff (iteration {state.iteration})...") 

925 if state.continuation_prompt: 

926 # Show truncated continuation context 

927 prompt_preview = state.continuation_prompt[:500] 

928 if len(state.continuation_prompt) > 500: 

929 prompt_preview += "..." 

930 print(f"Context: {prompt_preview}") 

931 print() 

932 

933 executor = PersistentExecutor(fsm) 

934 result = executor.resume() 

935 

936 if result is None: 

937 logger.warning(f"Nothing to resume for: {loop_name}") 

938 return 1 

939 

940 duration_sec = result.duration_ms / 1000 

941 if duration_sec < 60: 

942 duration_str = f"{duration_sec:.1f}s" 

943 else: 

944 minutes = int(duration_sec // 60) 

945 seconds = duration_sec % 60 

946 duration_str = f"{minutes}m {seconds:.0f}s" 

947 

948 logger.success( 

949 f"Resumed and completed: {result.final_state} " 

950 f"({result.iterations} iterations, {duration_str})" 

951 ) 

952 return 0 if result.terminated_by == "terminal" else 1 

953 

954 def cmd_history(loop_name: str) -> int: 

955 """Show loop history.""" 

956 events = get_loop_history(loop_name) 

957 

958 if not events: 

959 print(f"No history for: {loop_name}") 

960 return 0 

961 

962 # Show last N events 

963 tail = getattr(args, "tail", 50) 

964 for event in events[-tail:]: 

965 ts = event.get("ts", "")[:19] # Truncate to seconds 

966 event_type = event.get("event", "") 

967 details = {k: v for k, v in event.items() if k not in ("event", "ts")} 

968 print(f"{ts} {event_type}: {details}") 

969 

970 return 0 

971 

972 def cmd_test(loop_name: str) -> int: 

973 """Run a single test iteration to verify loop configuration. 

974 

975 Executes the initial state's action and evaluation, then reports 

976 what the loop would do without actually transitioning further. 

977 """ 

978 from little_loops.fsm.evaluators import EvaluationResult, evaluate, evaluate_exit_code 

979 from little_loops.fsm.executor import DefaultActionRunner 

980 from little_loops.fsm.interpolation import InterpolationContext 

981 

982 try: 

983 path = resolve_loop_path(loop_name) 

984 

985 # Load the file to check format 

986 with open(path) as f: 

987 spec = yaml.safe_load(f) 

988 

989 # Auto-compile if it's a paradigm file 

990 if "paradigm" in spec and "initial" not in spec: 

991 fsm = compile_paradigm(spec) 

992 else: 

993 fsm = load_and_validate(path) 

994 except FileNotFoundError as e: 

995 logger.error(str(e)) 

996 return 1 

997 except ValueError as e: 

998 logger.error(f"Validation error: {e}") 

999 return 1 

1000 

1001 # Get initial state 

1002 initial = fsm.initial 

1003 state_config = fsm.states[initial] 

1004 

1005 print(f"## Test Iteration: {loop_name}") 

1006 print() 

1007 print(f"State: {initial}") 

1008 

1009 # If no action, report and exit 

1010 if not state_config.action: 

1011 print(f"Initial state '{initial}' has no action to test") 

1012 print() 

1013 print("✓ Loop structure is valid (no check action to execute)") 

1014 return 0 

1015 

1016 action = state_config.action 

1017 is_slash = action.startswith("/") or state_config.action_type in ( 

1018 "prompt", 

1019 "slash_command", 

1020 ) 

1021 

1022 print(f"Action: {action}") 

1023 print() 

1024 

1025 if is_slash: 

1026 print("Note: Slash commands require Claude CLI; skipping actual execution.") 

1027 print() 

1028 print("Verdict: SKIPPED (slash command)") 

1029 print() 

1030 print("✓ Loop structure is valid (slash command not executed)") 

1031 return 0 

1032 

1033 # Run the action 

1034 runner = DefaultActionRunner() 

1035 timeout = state_config.timeout or 120 

1036 result = runner.run(action, timeout=timeout, is_slash_command=False) 

1037 

1038 print(f"Exit code: {result.exit_code}") 

1039 

1040 # Truncate output for display 

1041 output_lines = result.output.strip().split("\n") 

1042 if len(output_lines) > 10: 

1043 extra = len(output_lines) - 10 

1044 output_preview = "\n".join(output_lines[:10]) + f"\n... ({extra} more lines)" 

1045 elif len(result.output) > 500: 

1046 output_preview = result.output[:500] + "..." 

1047 else: 

1048 output_preview = result.output.strip() if result.output.strip() else "(empty)" 

1049 

1050 print(f"Output:\n{output_preview}") 

1051 

1052 if result.stderr: 

1053 stderr_lines = result.stderr.strip().split("\n") 

1054 if len(stderr_lines) > 5: 

1055 extra = len(stderr_lines) - 5 

1056 stderr_preview = "\n".join(stderr_lines[:5]) + f"\n... ({extra} more lines)" 

1057 else: 

1058 stderr_preview = result.stderr.strip() 

1059 print(f"Stderr:\n{stderr_preview}") 

1060 

1061 print() 

1062 

1063 # Evaluate 

1064 ctx = InterpolationContext() 

1065 eval_result: EvaluationResult 

1066 

1067 if state_config.evaluate: 

1068 eval_result = evaluate( 

1069 config=state_config.evaluate, 

1070 output=result.output, 

1071 exit_code=result.exit_code, 

1072 context=ctx, 

1073 ) 

1074 evaluator_type: str = state_config.evaluate.type 

1075 else: 

1076 # Default to exit_code evaluation 

1077 eval_result = evaluate_exit_code(result.exit_code) 

1078 evaluator_type = "exit_code (default)" 

1079 

1080 print(f"Evaluator: {evaluator_type}") 

1081 print(f"Verdict: {eval_result.verdict.upper()}") 

1082 

1083 if eval_result.details: 

1084 for key, value in eval_result.details.items(): 

1085 if key != "exit_code" or evaluator_type != "exit_code (default)": 

1086 print(f" {key}: {value}") 

1087 

1088 # Determine next state based on verdict 

1089 verdict = eval_result.verdict 

1090 next_state = None 

1091 

1092 if state_config.route: 

1093 routes = state_config.route.routes 

1094 if verdict in routes: 

1095 next_state = routes[verdict] 

1096 elif state_config.route.default: 

1097 next_state = state_config.route.default 

1098 else: 

1099 if verdict == "success" and state_config.on_success: 

1100 next_state = state_config.on_success 

1101 elif verdict == "failure" and state_config.on_failure: 

1102 next_state = state_config.on_failure 

1103 elif verdict == "error" and state_config.on_error: 

1104 next_state = state_config.on_error 

1105 

1106 print() 

1107 if next_state: 

1108 print(f"Would transition: {initial}{next_state}") 

1109 else: 

1110 print(f"Would transition: {initial} → (no route for '{verdict}')") 

1111 

1112 # Summary 

1113 print() 

1114 has_error = eval_result.verdict == "error" or "error" in eval_result.details 

1115 if has_error: 

1116 print("⚠ Loop has issues - review the error details above") 

1117 return 1 

1118 else: 

1119 print("✓ Loop appears to be configured correctly") 

1120 return 0 

1121 

1122 def cmd_simulate(loop_name: str) -> int: 

1123 """Run interactive simulation of loop execution. 

1124 

1125 Traces through loop logic without executing commands, allowing users 

1126 to verify state transitions and understand loop behavior. 

1127 """ 

1128 from little_loops.fsm.executor import FSMExecutor, SimulationActionRunner 

1129 

1130 try: 

1131 path = resolve_loop_path(loop_name) 

1132 

1133 # Load the file to check format 

1134 with open(path) as f: 

1135 spec = yaml.safe_load(f) 

1136 

1137 # Auto-compile if it's a paradigm file 

1138 if "paradigm" in spec and "initial" not in spec: 

1139 fsm = compile_paradigm(spec) 

1140 else: 

1141 fsm = load_and_validate(path) 

1142 except FileNotFoundError as e: 

1143 logger.error(str(e)) 

1144 return 1 

1145 except ValueError as e: 

1146 logger.error(f"Validation error: {e}") 

1147 return 1 

1148 

1149 # Apply CLI overrides 

1150 if args.max_iterations: 

1151 fsm.max_iterations = args.max_iterations 

1152 else: 

1153 # Limit iterations for simulation safety (cap at 20 unless overridden) 

1154 if fsm.max_iterations > 20: 

1155 logger.info( 

1156 f"Limiting simulation to 20 iterations (loop config: {fsm.max_iterations})" 

1157 ) 

1158 fsm.max_iterations = 20 

1159 

1160 # Create simulation runner 

1161 sim_runner = SimulationActionRunner(scenario=args.scenario) 

1162 

1163 # Track simulation state 

1164 states_visited: list[str] = [] 

1165 

1166 def simulation_callback(event: dict) -> None: 

1167 """Display simulation progress.""" 

1168 event_type = event.get("event") 

1169 

1170 if event_type == "state_enter": 

1171 iteration = event.get("iteration", 0) 

1172 state = event.get("state", "") 

1173 states_visited.append(state) 

1174 print() 

1175 print(f"[{iteration}] State: {state}") 

1176 

1177 elif event_type == "action_start": 

1178 action = event.get("action", "") 

1179 action_display = action[:70] + "..." if len(action) > 70 else action 

1180 print(f" Action: {action_display}") 

1181 

1182 elif event_type == "evaluate": 

1183 evaluator = event.get("type", "exit_code") 

1184 verdict = event.get("verdict", "") 

1185 print(f" Evaluator: {evaluator}") 

1186 print(f" Result: {verdict.upper()}") 

1187 

1188 elif event_type == "route": 

1189 from_state = event.get("from", "") 

1190 to_state = event.get("to", "") 

1191 print(f" Transition: {from_state}{to_state}") 

1192 

1193 # Print header 

1194 mode_str = f"scenario={args.scenario}" if args.scenario else "interactive" 

1195 print(f"=== SIMULATION: {fsm.name} ({mode_str}) ===") 

1196 

1197 # Run simulation 

1198 executor = FSMExecutor( 

1199 fsm, 

1200 event_callback=simulation_callback, 

1201 action_runner=sim_runner, 

1202 ) 

1203 result = executor.run() 

1204 

1205 # Print summary 

1206 print() 

1207 print("=== Summary ===") 

1208 print(f"States visited: {' → '.join(states_visited)}") 

1209 print(f"Iterations: {result.iterations}") 

1210 print(f"Would have executed {len(sim_runner.calls)} commands") 

1211 print(f"Terminated by: {result.terminated_by}") 

1212 

1213 return 0 

1214 

1215 # Dispatch commands 

1216 if args.command == "run": 

1217 return cmd_run(args.loop) 

1218 elif args.command == "compile": 

1219 return cmd_compile() 

1220 elif args.command == "validate": 

1221 return cmd_validate(args.loop) 

1222 elif args.command == "list": 

1223 return cmd_list() 

1224 elif args.command == "status": 

1225 return cmd_status(args.loop) 

1226 elif args.command == "stop": 

1227 return cmd_stop(args.loop) 

1228 elif args.command == "resume": 

1229 return cmd_resume(args.loop) 

1230 elif args.command == "history": 

1231 return cmd_history(args.loop) 

1232 elif args.command == "test": 

1233 return cmd_test(args.loop) 

1234 elif args.command == "simulate": 

1235 return cmd_simulate(args.loop) 

1236 else: 

1237 parser.print_help() 

1238 return 1 

1239 

1240 

1241def main_sprint() -> int: 

1242 """Entry point for ll-sprint command. 

1243 

1244 Manage and execute sprint/sequence definitions. 

1245 

1246 Returns: 

1247 Exit code (0 = success) 

1248 """ 

1249 parser = argparse.ArgumentParser( 

1250 prog="ll-sprint", 

1251 description="Manage and execute sprint/sequence definitions", 

1252 formatter_class=argparse.RawDescriptionHelpFormatter, 

1253 epilog=""" 

1254Examples: 

1255 %(prog)s create sprint-1 --issues BUG-001,FEAT-010 --description "Q1 fixes" 

1256 %(prog)s run sprint-1 

1257 %(prog)s run sprint-1 --dry-run 

1258 %(prog)s list 

1259 %(prog)s show sprint-1 

1260 %(prog)s delete sprint-1 

1261""", 

1262 ) 

1263 

1264 subparsers = parser.add_subparsers(dest="command", help="Available commands") 

1265 

1266 # create subcommand 

1267 create_parser = subparsers.add_parser("create", help="Create a new sprint") 

1268 create_parser.add_argument("name", help="Sprint name (used as filename)") 

1269 create_parser.add_argument( 

1270 "--issues", 

1271 required=True, 

1272 help="Comma-separated issue IDs (e.g., BUG-001,FEAT-010)", 

1273 ) 

1274 create_parser.add_argument("--description", "-d", default="", help="Sprint description") 

1275 add_max_workers_arg(create_parser, default=2) 

1276 add_timeout_arg(create_parser, default=3600) 

1277 add_skip_arg( 

1278 create_parser, 

1279 help_text="Comma-separated list of issue IDs to exclude from sprint (e.g., BUG-003,FEAT-004)", 

1280 ) 

1281 

1282 # run subcommand 

1283 run_parser = subparsers.add_parser("run", help="Execute a sprint") 

1284 run_parser.add_argument("sprint", help="Sprint name to execute") 

1285 add_dry_run_arg(run_parser) 

1286 add_max_workers_arg(run_parser) 

1287 add_timeout_arg(run_parser) 

1288 add_config_arg(run_parser) 

1289 add_resume_arg(run_parser) 

1290 add_quiet_arg(run_parser) 

1291 add_skip_arg( 

1292 run_parser, 

1293 help_text="Comma-separated list of issue IDs to skip during execution (e.g., BUG-003,FEAT-004)", 

1294 ) 

1295 

1296 # list subcommand 

1297 list_parser = subparsers.add_parser("list", help="List all sprints") 

1298 list_parser.add_argument( 

1299 "--verbose", "-v", action="store_true", help="Show detailed information" 

1300 ) 

1301 

1302 # show subcommand 

1303 show_parser = subparsers.add_parser("show", help="Show sprint details") 

1304 show_parser.add_argument("sprint", help="Sprint name to show") 

1305 add_config_arg(show_parser) 

1306 

1307 # delete subcommand 

1308 delete_parser = subparsers.add_parser("delete", help="Delete a sprint") 

1309 delete_parser.add_argument("sprint", help="Sprint name to delete") 

1310 

1311 args = parser.parse_args() 

1312 

1313 if not args.command: 

1314 parser.print_help() 

1315 return 1 

1316 

1317 # Commands that don't need project root 

1318 if args.command == "list": 

1319 return _cmd_sprint_list(args, SprintManager()) 

1320 if args.command == "delete": 

1321 return _cmd_sprint_delete(args, SprintManager()) 

1322 

1323 # Commands that need project root 

1324 project_root = args.config if hasattr(args, "config") and args.config else Path.cwd() 

1325 config = BRConfig(project_root) 

1326 manager = SprintManager(config=config) 

1327 

1328 if args.command == "create": 

1329 return _cmd_sprint_create(args, manager) 

1330 if args.command == "show": 

1331 return _cmd_sprint_show(args, manager) 

1332 if args.command == "run": 

1333 return _cmd_sprint_run(args, manager, config) 

1334 

1335 return 1 

1336 

1337 

1338def _cmd_sprint_create(args: argparse.Namespace, manager: SprintManager) -> int: 

1339 """Create a new sprint.""" 

1340 logger = Logger() 

1341 issues = [i.strip().upper() for i in args.issues.split(",")] 

1342 

1343 # Apply skip filter if provided 

1344 skip_ids = parse_issue_ids(args.skip) 

1345 if skip_ids: 

1346 original_count = len(issues) 

1347 issues = [i for i in issues if i not in skip_ids] 

1348 skipped = original_count - len(issues) 

1349 if skipped > 0: 

1350 logger.info( 

1351 f"Skipping {skipped} issue(s): {', '.join(sorted(skip_ids & set(issues) | skip_ids))}" 

1352 ) 

1353 

1354 # Validate issues exist 

1355 valid = manager.validate_issues(issues) 

1356 invalid = set(issues) - set(valid.keys()) 

1357 

1358 if invalid: 

1359 logger.warning(f"Issue IDs not found: {', '.join(sorted(invalid))}") 

1360 

1361 options = SprintOptions( 

1362 max_workers=args.max_workers, 

1363 timeout=args.timeout, 

1364 ) 

1365 

1366 sprint = manager.create( 

1367 name=args.name, 

1368 issues=issues, 

1369 description=args.description, 

1370 options=options, 

1371 ) 

1372 

1373 logger.success(f"Created sprint: {sprint.name}") 

1374 logger.info(f" Description: {sprint.description or '(none)'}") 

1375 logger.info(f" Issues: {', '.join(sprint.issues)}") 

1376 logger.info(f" File: .sprints/{sprint.name}.yaml") 

1377 

1378 if invalid: 

1379 logger.warning(f" Invalid issues: {', '.join(sorted(invalid))}") 

1380 

1381 return 0 

1382 

1383 

1384def _render_execution_plan( 

1385 waves: list[list[Any]], 

1386 dep_graph: DependencyGraph, 

1387) -> str: 

1388 """Render execution plan with wave groupings. 

1389 

1390 Args: 

1391 waves: List of execution waves from get_execution_waves() 

1392 dep_graph: DependencyGraph for looking up blockers 

1393 

1394 Returns: 

1395 Formatted string showing wave structure 

1396 """ 

1397 if not waves: 

1398 return "" 

1399 

1400 total_issues = sum(len(wave) for wave in waves) 

1401 lines: list[str] = [] 

1402 

1403 lines.append("") 

1404 lines.append("=" * 70) 

1405 lines.append(f"EXECUTION PLAN ({total_issues} issues, {len(waves)} waves)") 

1406 lines.append("=" * 70) 

1407 

1408 for wave_num, wave in enumerate(waves, 1): 

1409 lines.append("") 

1410 if wave_num == 1: 

1411 parallel_note = "(parallel)" if len(wave) > 1 else "" 

1412 else: 

1413 parallel_note = f"(after Wave {wave_num - 1})" 

1414 if len(wave) > 1: 

1415 parallel_note += " parallel" 

1416 lines.append(f"Wave {wave_num} {parallel_note}:".strip()) 

1417 

1418 for i, issue in enumerate(wave): 

1419 is_last = i == len(wave) - 1 

1420 prefix = " └── " if is_last else " ├── " 

1421 

1422 # Truncate title if too long 

1423 title = issue.title 

1424 if len(title) > 45: 

1425 title = title[:42] + "..." 

1426 

1427 lines.append(f"{prefix}{issue.issue_id}: {title} ({issue.priority})") 

1428 

1429 # Show blockers for this issue 

1430 blockers = dep_graph.blocked_by.get(issue.issue_id, set()) 

1431 if blockers: 

1432 blocker_prefix = " └── " if is_last else " │ └── " 

1433 blockers_str = ", ".join(sorted(blockers)) 

1434 lines.append(f"{blocker_prefix}blocked by: {blockers_str}") 

1435 

1436 return "\n".join(lines) 

1437 

1438 

1439def _render_dependency_graph( 

1440 waves: list[list[Any]], 

1441 dep_graph: DependencyGraph, 

1442) -> str: 

1443 """Render ASCII dependency graph. 

1444 

1445 Args: 

1446 waves: List of execution waves 

1447 dep_graph: DependencyGraph for looking up relationships 

1448 

1449 Returns: 

1450 Formatted string showing dependency arrows 

1451 """ 

1452 if not waves or len(waves) <= 1: 

1453 return "" 

1454 

1455 lines: list[str] = [] 

1456 lines.append("") 

1457 lines.append("=" * 70) 

1458 lines.append("DEPENDENCY GRAPH") 

1459 lines.append("=" * 70) 

1460 lines.append("") 

1461 

1462 # Build chains: track which issues block what 

1463 # Show each independent chain on its own line 

1464 chains: list[str] = [] 

1465 visited: set[str] = set() 

1466 

1467 def build_chain(issue_id: str) -> str: 

1468 """Recursively build chain string from issue.""" 

1469 if issue_id in visited: 

1470 return issue_id 

1471 visited.add(issue_id) 

1472 

1473 blocked_issues = sorted(dep_graph.blocks.get(issue_id, set())) 

1474 if not blocked_issues: 

1475 return issue_id 

1476 

1477 if len(blocked_issues) == 1: 

1478 return f"{issue_id} ──→ {build_chain(blocked_issues[0])}" 

1479 else: 

1480 # Multiple branches - show first inline, note others 

1481 result = f"{issue_id} ──→ {build_chain(blocked_issues[0])}" 

1482 for other in blocked_issues[1:]: 

1483 if other not in visited: 

1484 chains.append(f" {issue_id} ──→ {build_chain(other)}") 

1485 return result 

1486 

1487 # Find root issues (no blockers in this graph) 

1488 roots: list[str] = [] 

1489 for wave in waves[:1]: # First wave has roots 

1490 for issue in wave: 

1491 roots.append(issue.issue_id) 

1492 

1493 for root in roots: 

1494 if root not in visited: 

1495 chain = build_chain(root) 

1496 if chain: 

1497 chains.insert(0, f" {chain}") 

1498 

1499 # Handle any isolated issues not in chains 

1500 all_ids = {issue.issue_id for wave in waves for issue in wave} 

1501 for issue_id in sorted(all_ids - visited): 

1502 chains.append(f" {issue_id}") 

1503 

1504 lines.extend(chains) 

1505 lines.append("") 

1506 lines.append("Legend: ──→ blocks (must complete before)") 

1507 

1508 return "\n".join(lines) 

1509 

1510 

1511def _cmd_sprint_show(args: argparse.Namespace, manager: SprintManager) -> int: 

1512 """Show sprint details with dependency visualization.""" 

1513 logger = Logger() 

1514 sprint = manager.load(args.sprint) 

1515 if not sprint: 

1516 logger.error(f"Sprint not found: {args.sprint}") 

1517 return 1 

1518 

1519 # Validate issues 

1520 valid = manager.validate_issues(sprint.issues) 

1521 invalid = set(sprint.issues) - set(valid.keys()) 

1522 

1523 # Load full IssueInfo objects for dependency analysis 

1524 issue_infos = manager.load_issue_infos(list(valid.keys())) 

1525 dep_graph: DependencyGraph | None = None 

1526 waves: list[list[Any]] = [] 

1527 has_cycles = False 

1528 

1529 if issue_infos: 

1530 dep_graph = DependencyGraph.from_issues(issue_infos) 

1531 has_cycles = dep_graph.has_cycles() 

1532 

1533 if not has_cycles: 

1534 waves = dep_graph.get_execution_waves() 

1535 

1536 print(f"Sprint: {sprint.name}") 

1537 print(f"Description: {sprint.description or '(none)'}") 

1538 print(f"Created: {sprint.created}") 

1539 

1540 # Show execution plan if we have dependency info and no cycles 

1541 if waves and dep_graph: 

1542 print(_render_execution_plan(waves, dep_graph)) 

1543 print(_render_dependency_graph(waves, dep_graph)) 

1544 else: 

1545 # Fallback to simple list if no valid issues or cycles 

1546 print(f"Issues ({len(sprint.issues)}):") 

1547 for issue_id in sprint.issues: 

1548 status = "valid" if issue_id in valid else "NOT FOUND" 

1549 print(f" - {issue_id} ({status})") 

1550 

1551 # Warn about cycles if detected 

1552 if has_cycles and dep_graph: 

1553 cycles = dep_graph.detect_cycles() 

1554 print("\nWarning: Dependency cycles detected:") 

1555 for cycle in cycles: 

1556 print(f" {' -> '.join(cycle)}") 

1557 

1558 if sprint.options: 

1559 print("\nOptions:") 

1560 print(f" Max iterations: {sprint.options.max_iterations}") 

1561 print(f" Timeout: {sprint.options.timeout}s") 

1562 print(f" Max workers: {sprint.options.max_workers}") 

1563 

1564 if invalid: 

1565 print(f"\nWarning: {len(invalid)} issue(s) not found") 

1566 

1567 return 0 

1568 

1569 

1570def _cmd_sprint_list(args: argparse.Namespace, manager: SprintManager) -> int: 

1571 """List all sprints.""" 

1572 sprints = manager.list_all() 

1573 

1574 if not sprints: 

1575 print("No sprints defined") 

1576 return 0 

1577 

1578 print(f"Available sprints ({len(sprints)}):") 

1579 

1580 for sprint in sprints: 

1581 if args.verbose: 

1582 print(f"\n{sprint.name}:") 

1583 print(f" Description: {sprint.description or '(none)'}") 

1584 print(f" Issues: {', '.join(sprint.issues)}") 

1585 print(f" Created: {sprint.created}") 

1586 else: 

1587 desc = f" - {sprint.description}" if sprint.description else "" 

1588 print(f" {sprint.name}{desc}") 

1589 

1590 return 0 

1591 

1592 

1593def _cmd_sprint_delete(args: argparse.Namespace, manager: SprintManager) -> int: 

1594 """Delete a sprint.""" 

1595 logger = Logger() 

1596 if not manager.delete(args.sprint): 

1597 logger.error(f"Sprint not found: {args.sprint}") 

1598 return 1 

1599 

1600 logger.success(f"Deleted sprint: {args.sprint}") 

1601 return 0 

1602 

1603 

1604def _get_sprint_state_file() -> Path: 

1605 """Get path to sprint state file.""" 

1606 return Path.cwd() / ".sprint-state.json" 

1607 

1608 

1609def _load_sprint_state(logger: Logger) -> SprintState | None: 

1610 """Load sprint state from file.""" 

1611 import json 

1612 

1613 state_file = _get_sprint_state_file() 

1614 if not state_file.exists(): 

1615 return None 

1616 try: 

1617 data = json.loads(state_file.read_text()) 

1618 state = SprintState.from_dict(data) 

1619 logger.info(f"State loaded from {state_file}") 

1620 return state 

1621 except (json.JSONDecodeError, KeyError) as e: 

1622 logger.warning(f"Failed to load state: {e}") 

1623 return None 

1624 

1625 

1626def _save_sprint_state(state: SprintState, logger: Logger) -> None: 

1627 """Save sprint state to file.""" 

1628 import json 

1629 from datetime import datetime 

1630 

1631 state.last_checkpoint = datetime.now().isoformat() 

1632 state_file = _get_sprint_state_file() 

1633 state_file.write_text(json.dumps(state.to_dict(), indent=2)) 

1634 logger.info(f"State saved to {state_file}") 

1635 

1636 

1637def _cleanup_sprint_state(logger: Logger) -> None: 

1638 """Remove sprint state file.""" 

1639 state_file = _get_sprint_state_file() 

1640 if state_file.exists(): 

1641 state_file.unlink() 

1642 logger.info("Sprint state file cleaned up") 

1643 

1644 

1645def _cmd_sprint_run( 

1646 args: argparse.Namespace, 

1647 manager: SprintManager, 

1648 config: BRConfig, 

1649) -> int: 

1650 """Execute a sprint with dependency-aware scheduling.""" 

1651 from datetime import datetime 

1652 

1653 logger = Logger(verbose=not args.quiet) 

1654 

1655 # Setup signal handlers for graceful shutdown (ENH-183) 

1656 global _sprint_shutdown_requested 

1657 _sprint_shutdown_requested = False # Reset in case of multiple runs 

1658 signal.signal(signal.SIGINT, _sprint_signal_handler) 

1659 signal.signal(signal.SIGTERM, _sprint_signal_handler) 

1660 

1661 sprint = manager.load(args.sprint) 

1662 if not sprint: 

1663 logger.error(f"Sprint not found: {args.sprint}") 

1664 return 1 

1665 

1666 # Apply skip filter if provided 

1667 issues_to_process = list(sprint.issues) 

1668 skip_ids = parse_issue_ids(args.skip) 

1669 if skip_ids: 

1670 original_count = len(issues_to_process) 

1671 issues_to_process = [i for i in issues_to_process if i not in skip_ids] 

1672 skipped = original_count - len(issues_to_process) 

1673 if skipped > 0: 

1674 logger.info(f"Skipping {skipped} issue(s): {', '.join(sorted(skip_ids))}") 

1675 

1676 # Validate issues exist 

1677 valid = manager.validate_issues(issues_to_process) 

1678 invalid = set(issues_to_process) - set(valid.keys()) 

1679 

1680 if invalid: 

1681 logger.error(f"Issue IDs not found: {', '.join(sorted(invalid))}") 

1682 logger.info("Cannot execute sprint with missing issues") 

1683 return 1 

1684 

1685 # Load full IssueInfo objects for dependency analysis 

1686 issue_infos = manager.load_issue_infos(issues_to_process) 

1687 if not issue_infos: 

1688 logger.error("No issue files found") 

1689 return 1 

1690 

1691 # Build dependency graph 

1692 dep_graph = DependencyGraph.from_issues(issue_infos) 

1693 

1694 # Detect cycles 

1695 if dep_graph.has_cycles(): 

1696 cycles = dep_graph.detect_cycles() 

1697 for cycle in cycles: 

1698 logger.error(f"Dependency cycle detected: {' -> '.join(cycle)}") 

1699 return 1 

1700 

1701 # Get execution waves 

1702 try: 

1703 waves = dep_graph.get_execution_waves() 

1704 except ValueError as e: 

1705 logger.error(str(e)) 

1706 return 1 

1707 

1708 # Display execution plan 

1709 logger.info(f"Running sprint: {sprint.name}") 

1710 logger.info("Dependency analysis:") 

1711 for i, wave in enumerate(waves, 1): 

1712 issue_ids = ", ".join(issue.issue_id for issue in wave) 

1713 logger.info(f" Wave {i}: {issue_ids}") 

1714 

1715 if args.dry_run: 

1716 logger.info("\nDry run mode - no changes will be made") 

1717 return 0 

1718 

1719 # Initialize or load state 

1720 state: SprintState 

1721 start_wave = 1 

1722 

1723 if args.resume: 

1724 loaded_state = _load_sprint_state(logger) 

1725 if loaded_state and loaded_state.sprint_name == args.sprint: 

1726 state = loaded_state 

1727 # Find first incomplete wave by checking completed issues 

1728 completed_set = set(state.completed_issues) 

1729 for i, wave in enumerate(waves, 1): 

1730 wave_issue_ids = {issue.issue_id for issue in wave} 

1731 if not wave_issue_ids.issubset(completed_set): 

1732 start_wave = i 

1733 break 

1734 else: 

1735 # All waves completed 

1736 logger.info("Sprint already completed - nothing to resume") 

1737 _cleanup_sprint_state(logger) 

1738 return 0 

1739 logger.info(f"Resuming from wave {start_wave}/{len(waves)}") 

1740 logger.info(f" Previously completed: {len(state.completed_issues)} issues") 

1741 else: 

1742 if loaded_state: 

1743 logger.warning( 

1744 f"State file is for sprint '{loaded_state.sprint_name}', " 

1745 f"not '{args.sprint}' - starting fresh" 

1746 ) 

1747 else: 

1748 logger.warning("No valid state found - starting fresh") 

1749 state = SprintState( 

1750 sprint_name=args.sprint, 

1751 started_at=datetime.now().isoformat(), 

1752 ) 

1753 else: 

1754 # Fresh start - delete any old state 

1755 _cleanup_sprint_state(logger) 

1756 state = SprintState( 

1757 sprint_name=args.sprint, 

1758 started_at=datetime.now().isoformat(), 

1759 ) 

1760 

1761 # Track exit status for error handling (ENH-185) 

1762 exit_code = 0 

1763 

1764 try: 

1765 # Determine max workers 

1766 max_workers = args.max_workers or (sprint.options.max_workers if sprint.options else 2) 

1767 

1768 # Execute wave by wave 

1769 completed: set[str] = set(state.completed_issues) 

1770 failed_waves = 0 

1771 total_duration = 0.0 

1772 total_waves = len(waves) 

1773 

1774 for wave_num, wave in enumerate(waves, 1): 

1775 # Check for shutdown request (ENH-183) 

1776 if _sprint_shutdown_requested: 

1777 logger.warning("Shutdown requested - saving state and exiting") 

1778 _save_sprint_state(state, logger) 

1779 exit_code = 1 

1780 return exit_code 

1781 

1782 # Skip already-completed waves when resuming 

1783 if wave_num < start_wave: 

1784 continue 

1785 

1786 wave_ids = [issue.issue_id for issue in wave] 

1787 state.current_wave = wave_num 

1788 logger.info(f"\nProcessing wave {wave_num}/{total_waves}: {', '.join(wave_ids)}") 

1789 

1790 if len(wave) == 1: 

1791 # Single issue — process in-place (no worktree overhead) 

1792 from little_loops.issue_manager import process_issue_inplace 

1793 

1794 issue_result = process_issue_inplace( 

1795 info=wave[0], 

1796 config=config, 

1797 logger=logger, 

1798 dry_run=args.dry_run, 

1799 ) 

1800 total_duration += issue_result.duration 

1801 if issue_result.success: 

1802 completed.update(wave_ids) 

1803 state.completed_issues.extend(wave_ids) 

1804 state.timing[wave_ids[0]] = {"total": issue_result.duration} 

1805 logger.success(f"Wave {wave_num}/{total_waves} completed: {wave_ids[0]}") 

1806 else: 

1807 failed_waves += 1 

1808 completed.update(wave_ids) 

1809 state.completed_issues.extend(wave_ids) 

1810 state.failed_issues[wave_ids[0]] = "Issue processing failed" 

1811 logger.warning(f"Wave {wave_num}/{total_waves} had failures") 

1812 _save_sprint_state(state, logger) 

1813 if wave_num < total_waves: 

1814 logger.info(f"Continuing to wave {wave_num + 1}/{total_waves}...") 

1815 # Check for shutdown before next wave (ENH-183) 

1816 if _sprint_shutdown_requested: 

1817 logger.warning("Shutdown requested - exiting after wave completion") 

1818 exit_code = 1 

1819 return exit_code 

1820 else: 

1821 # Multi-issue — use ParallelOrchestrator with worktrees 

1822 only_ids = set(wave_ids) 

1823 parallel_config = config.create_parallel_config( 

1824 max_workers=min(max_workers, len(wave)), 

1825 only_ids=only_ids, 

1826 dry_run=args.dry_run, 

1827 ) 

1828 

1829 orchestrator = ParallelOrchestrator( 

1830 parallel_config, config, Path.cwd(), wave_label=f"Wave {wave_num}/{total_waves}" 

1831 ) 

1832 result = orchestrator.run() 

1833 total_duration += orchestrator.execution_duration 

1834 

1835 # Track completed/failed from this wave 

1836 if result == 0: 

1837 completed.update(wave_ids) 

1838 state.completed_issues.extend(wave_ids) 

1839 for issue_id in wave_ids: 

1840 state.timing[issue_id] = { 

1841 "total": orchestrator.execution_duration / len(wave) 

1842 } 

1843 logger.success( 

1844 f"Wave {wave_num}/{total_waves} completed: {', '.join(wave_ids)}" 

1845 ) 

1846 else: 

1847 # Some issues failed - continue but track failures 

1848 failed_waves += 1 

1849 completed.update(wave_ids) 

1850 state.completed_issues.extend(wave_ids) 

1851 for issue_id in wave_ids: 

1852 state.failed_issues[issue_id] = "Wave execution had failures" 

1853 logger.warning(f"Wave {wave_num}/{total_waves} had failures") 

1854 _save_sprint_state(state, logger) 

1855 if wave_num < total_waves: 

1856 logger.info(f"Continuing to wave {wave_num + 1}/{total_waves}...") 

1857 # Check for shutdown before next wave (ENH-183) 

1858 if _sprint_shutdown_requested: 

1859 logger.warning("Shutdown requested - exiting after wave completion") 

1860 exit_code = 1 

1861 return exit_code 

1862 

1863 wave_word = "wave" if len(waves) == 1 else "waves" 

1864 logger.info( 

1865 f"\nSprint completed: {len(completed)} issues processed ({len(waves)} {wave_word})" 

1866 ) 

1867 logger.timing(f"Total execution time: {format_duration(total_duration)}") 

1868 if failed_waves > 0: 

1869 logger.warning(f"{failed_waves} wave(s) had failures") 

1870 exit_code = 1 

1871 else: 

1872 # Clean up state on successful completion 

1873 _cleanup_sprint_state(logger) 

1874 

1875 except KeyboardInterrupt: 

1876 # Belt-and-suspenders with signal handler (ENH-185) 

1877 logger.warning("Sprint interrupted by user (KeyboardInterrupt)") 

1878 exit_code = 130 

1879 

1880 except Exception as e: 

1881 # Catch unexpected exceptions (ENH-185) 

1882 logger.error(f"Sprint failed unexpectedly: {e}") 

1883 exit_code = 1 

1884 

1885 finally: 

1886 # Guaranteed state save on any non-success exit (ENH-185) 

1887 if exit_code != 0: 

1888 _save_sprint_state(state, logger) 

1889 logger.info("State saved before exit") 

1890 

1891 return exit_code 

1892 

1893 

1894def main_history() -> int: 

1895 """Entry point for ll-history command. 

1896 

1897 Display summary statistics and analysis for completed issues. 

1898 

1899 Returns: 

1900 Exit code (0 = success) 

1901 """ 

1902 from little_loops.issue_history import ( 

1903 calculate_analysis, 

1904 calculate_summary, 

1905 format_analysis_json, 

1906 format_analysis_markdown, 

1907 format_analysis_text, 

1908 format_analysis_yaml, 

1909 format_summary_json, 

1910 format_summary_text, 

1911 scan_completed_issues, 

1912 ) 

1913 

1914 parser = argparse.ArgumentParser( 

1915 prog="ll-history", 

1916 description="Display summary statistics and analysis for completed issues", 

1917 formatter_class=argparse.RawDescriptionHelpFormatter, 

1918 epilog=""" 

1919Examples: 

1920 %(prog)s summary # Show summary statistics 

1921 %(prog)s summary --json # Output as JSON 

1922 %(prog)s analyze # Full analysis report 

1923 %(prog)s analyze --format markdown # Markdown report 

1924 %(prog)s analyze --compare 30 # Compare last 30 days to previous 

1925""", 

1926 ) 

1927 

1928 subparsers = parser.add_subparsers(dest="command", help="Available commands") 

1929 

1930 # summary subcommand (existing) 

1931 summary_parser = subparsers.add_parser("summary", help="Show issue statistics") 

1932 summary_parser.add_argument( 

1933 "--json", 

1934 action="store_true", 

1935 help="Output as JSON instead of formatted text", 

1936 ) 

1937 summary_parser.add_argument( 

1938 "-d", 

1939 "--directory", 

1940 type=Path, 

1941 default=None, 

1942 help="Path to issues directory (default: .issues)", 

1943 ) 

1944 

1945 # analyze subcommand (new - FEAT-110) 

1946 analyze_parser = subparsers.add_parser( 

1947 "analyze", 

1948 help="Full analysis with trends, subsystems, and debt metrics", 

1949 ) 

1950 analyze_parser.add_argument( 

1951 "-f", 

1952 "--format", 

1953 type=str, 

1954 choices=["text", "json", "markdown", "yaml"], 

1955 default="text", 

1956 help="Output format (default: text)", 

1957 ) 

1958 analyze_parser.add_argument( 

1959 "-d", 

1960 "--directory", 

1961 type=Path, 

1962 default=None, 

1963 help="Path to issues directory (default: .issues)", 

1964 ) 

1965 analyze_parser.add_argument( 

1966 "-p", 

1967 "--period", 

1968 type=str, 

1969 choices=["weekly", "monthly", "quarterly"], 

1970 default="monthly", 

1971 help="Grouping period for trends (default: monthly)", 

1972 ) 

1973 analyze_parser.add_argument( 

1974 "-c", 

1975 "--compare", 

1976 type=int, 

1977 default=None, 

1978 metavar="DAYS", 

1979 help="Compare last N days to previous N days", 

1980 ) 

1981 

1982 args = parser.parse_args() 

1983 

1984 if not args.command: 

1985 parser.print_help() 

1986 return 1 

1987 

1988 # Determine directories 

1989 issues_dir = args.directory or Path.cwd() / ".issues" 

1990 completed_dir = issues_dir / "completed" 

1991 

1992 if args.command == "summary": 

1993 # Existing summary logic 

1994 issues = scan_completed_issues(completed_dir) 

1995 summary = calculate_summary(issues) 

1996 

1997 if args.json: 

1998 print(format_summary_json(summary)) 

1999 else: 

2000 print(format_summary_text(summary)) 

2001 

2002 return 0 

2003 

2004 if args.command == "analyze": 

2005 # New analyze logic (FEAT-110) 

2006 issues = scan_completed_issues(completed_dir) 

2007 analysis = calculate_analysis( 

2008 issues, 

2009 issues_dir=issues_dir, 

2010 period_type=args.period, 

2011 compare_days=args.compare, 

2012 ) 

2013 

2014 if args.format == "json": 

2015 print(format_analysis_json(analysis)) 

2016 elif args.format == "yaml": 

2017 print(format_analysis_yaml(analysis)) 

2018 elif args.format == "markdown": 

2019 print(format_analysis_markdown(analysis)) 

2020 else: 

2021 print(format_analysis_text(analysis)) 

2022 

2023 return 0 

2024 

2025 return 1 

2026 

2027 

2028if __name__ == "__main__": 

2029 sys.exit(main_auto())