Coverage for little_loops/cli.py: 14%

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

2

3 Provides 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

10 from __future__ import annotations

11

12 import argparse

13 import signal

14 import sys

15 from pathlib import Path

16 from types import FrameType

17 from typing import Any

18

19 from 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)

32 from little_loops.config import BRConfig

33 from little_loops.dependency_graph import DependencyGraph

34 from little_loops.issue_manager import AutoManager

35 from little_loops.logger import Logger, format_duration

36 from little_loops.parallel.orchestrator import ParallelOrchestrator

37 from 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

43 def _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

58 def 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="""

70 Examples:

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

116 def 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="""

128 Examples:

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

283 def 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="""

304 Examples:

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

416 def 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="""

465 Examples:

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

1241 def 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="""

1254 Examples:

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

1338 def _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

1384 def _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

1439 def _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

1511 def _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

1570 def _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

1593 def _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

1604 def _get_sprint_state_file() -> Path:

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

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

1607

1608

1609 def _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

1626 def _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

1637 def _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

1645 def _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

1894 def 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="""

1919 Examples:

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

2028 if __name__ == "__main__":

2029 sys.exit(main_auto())