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
« prev ^ index » next coverage.py v7.12.0, created at 2026-02-01 22:39 -0600
1"""CLI entry points for little-loops.
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"""
10from __future__ import annotations
12import argparse
13import signal
14import sys
15from pathlib import Path
16from types import FrameType
17from typing import Any
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
39# Module-level shutdown flag for ll-sprint signal handling (ENH-183)
40_sprint_shutdown_requested: bool = False
43def _sprint_signal_handler(signum: int, frame: FrameType | None) -> None:
44 """Handle shutdown signals gracefully for ll-sprint.
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)
58def main_auto() -> int:
59 """Entry point for ll-auto command.
61 Sequential automated issue management with Claude CLI.
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 )
81 # Add common arguments from shared module
82 add_common_auto_args(parser)
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 )
93 args = parser.parse_args()
95 project_root = args.config or Path.cwd()
96 config = BRConfig(project_root)
98 # Parse issue ID filters
99 only_ids = parse_issue_ids(args.only)
100 skip_ids = parse_issue_ids(args.skip)
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 )
113 return manager.run()
116def main_parallel() -> int:
117 """Entry point for ll-parallel command.
119 Parallel issue management using git worktrees.
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 )
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 )
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)
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 )
220 args = parser.parse_args()
222 project_root = args.config or Path.cwd()
223 config = BRConfig(project_root)
225 logger = Logger(verbose=not args.quiet)
227 # Handle cleanup mode
228 if args.cleanup:
229 from little_loops.parallel import WorkerPool
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
237 # Build priority filter
238 priority_filter = (
239 [p.strip().upper() for p in args.priority.split(",")] if args.priority else None
240 )
242 # Parse issue ID filters
243 only_ids = parse_issue_ids(args.only)
244 skip_ids = parse_issue_ids(args.skip)
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 )
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()
270 # Create and run orchestrator
271 from little_loops.parallel import ParallelOrchestrator
273 orchestrator = ParallelOrchestrator(
274 parallel_config=parallel_config,
275 br_config=config,
276 repo_path=project_root,
277 verbose=not args.quiet,
278 )
280 return orchestrator.run()
283def main_messages() -> int:
284 """Entry point for ll-messages command.
286 Extract user messages from Claude Code session logs.
288 Returns:
289 Exit code (0 = success)
290 """
291 from datetime import datetime
293 from little_loops.user_messages import (
294 extract_user_messages,
295 get_project_folder,
296 print_messages_to_stdout,
297 save_messages,
298 )
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 )
358 args = parser.parse_args()
360 logger = Logger(verbose=args.verbose)
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
377 # Get project folder
378 cwd = args.cwd or Path.cwd()
379 project_folder = get_project_folder(cwd)
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
386 logger.info(f"Project folder: {project_folder}")
387 logger.info(f"Limit: {args.limit}")
388 if since:
389 logger.info(f"Since: {since}")
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 )
400 if not messages:
401 logger.warning("No user messages found")
402 return 0
404 logger.info(f"Found {len(messages)} messages")
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}")
413 return 0
416def main_loop() -> int:
417 """Entry point for ll-loop command.
419 Execute FSM-based automation loops.
421 Returns:
422 Exit code (0 = success)
423 """
424 import yaml
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
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 }
452 # Pre-process args: if first positional arg is not a subcommand, insert "run"
453 import sys as _sys
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
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 )
481 subparsers = parser.add_subparsers(dest="command")
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 )
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")
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")
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")
513 # Status subcommand
514 status_parser = subparsers.add_parser("status", help="Show loop status")
515 status_parser.add_argument("loop", help="Loop name")
517 # Stop subcommand
518 stop_parser = subparsers.add_parser("stop", help="Stop a running loop")
519 stop_parser.add_argument("loop", help="Loop name")
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")
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 )
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")
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 )
556 args = parser.parse_args(argv)
558 logger = Logger(verbose=not getattr(args, "quiet", False))
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
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
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
576 raise FileNotFoundError(f"Loop not found: {name_or_path}")
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")
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()
621 current_iteration = [0] # Use list to allow mutation in closure
623 def display_progress(event: dict) -> None:
624 """Display progress for events."""
625 event_type = event.get("event")
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="")
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}")
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}")
649 elif event_type == "route":
650 to_state = event.get("to", "")
651 print(f" -> {to_state}")
653 # Create wrapper to combine persistence callback with progress display
654 original_handle = executor._handle_event
655 quiet = getattr(args, "quiet", False)
657 def combined_handler(event: dict) -> None:
658 original_handle(event)
659 if not quiet:
660 display_progress(event)
662 # Use object.__setattr__ to bypass method assignment check
663 object.__setattr__(executor, "_handle_event", combined_handler)
665 result = executor.run()
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 )
681 return 0 if result.terminated_by == "terminal" else 1
683 def cmd_run(loop_name: str) -> int:
684 """Run a loop."""
685 try:
686 path = resolve_loop_path(loop_name)
688 # Load the file to check format
689 with open(path) as f:
690 spec = yaml.safe_load(f)
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
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
713 # Dry run
714 if args.dry_run:
715 print_execution_plan(fsm)
716 return 0
718 # Background mode not implemented
719 if getattr(args, "background", False):
720 logger.warning("Background mode not yet implemented, running in foreground")
722 # Scope-based locking
723 lock_manager = LockManager()
724 scope = fsm.scope or ["."]
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
747 try:
748 executor = PersistentExecutor(fsm)
749 return run_foreground(executor, fsm)
750 finally:
751 lock_manager.release(fsm.name)
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
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
771 output_path = (
772 Path(args.output)
773 if args.output
774 else Path(str(input_path).replace(".yaml", ".fsm.yaml"))
775 )
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
794 with open(output_path, "w") as f:
795 yaml.dump(fsm_dict, f, default_flow_style=False, sort_keys=False)
797 logger.success(f"Compiled to: {output_path}")
798 return 0
800 def cmd_validate(loop_name: str) -> int:
801 """Validate a loop definition."""
802 try:
803 path = resolve_loop_path(loop_name)
805 # Load the file to check format
806 with open(path) as f:
807 spec = yaml.safe_load(f)
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)
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
828 def cmd_list() -> int:
829 """List loops."""
830 loops_dir = Path(".loops")
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
842 # List all loop files
843 if not loops_dir.exists():
844 print("No .loops/ directory found")
845 return 0
847 yaml_files = list(loops_dir.glob("*.yaml"))
848 if not yaml_files:
849 print("No loops defined")
850 return 0
852 print("Available loops:")
853 for path in sorted(yaml_files):
854 print(f" {path.stem}")
855 return 0
857 def cmd_status(loop_name: str) -> int:
858 """Show loop status."""
859 persistence = StatePersistence(loop_name)
860 state = persistence.load_state()
862 if state is None:
863 logger.error(f"No state found for: {loop_name}")
864 return 1
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
880 def cmd_stop(loop_name: str) -> int:
881 """Stop a running loop."""
882 persistence = StatePersistence(loop_name)
883 state = persistence.load_state()
885 if state is None:
886 logger.error(f"No state found for: {loop_name}")
887 return 1
889 if state.status != "running":
890 logger.error(f"Loop not running: {loop_name} (status: {state.status})")
891 return 1
893 state.status = "interrupted"
894 persistence.save_state(state)
895 logger.success(f"Marked {loop_name} as interrupted")
896 return 0
898 def cmd_resume(loop_name: str) -> int:
899 """Resume an interrupted loop."""
900 try:
901 path = resolve_loop_path(loop_name)
903 # Load the file to check format
904 with open(path) as f:
905 spec = yaml.safe_load(f)
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
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()
933 executor = PersistentExecutor(fsm)
934 result = executor.resume()
936 if result is None:
937 logger.warning(f"Nothing to resume for: {loop_name}")
938 return 1
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"
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
954 def cmd_history(loop_name: str) -> int:
955 """Show loop history."""
956 events = get_loop_history(loop_name)
958 if not events:
959 print(f"No history for: {loop_name}")
960 return 0
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}")
970 return 0
972 def cmd_test(loop_name: str) -> int:
973 """Run a single test iteration to verify loop configuration.
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
982 try:
983 path = resolve_loop_path(loop_name)
985 # Load the file to check format
986 with open(path) as f:
987 spec = yaml.safe_load(f)
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
1001 # Get initial state
1002 initial = fsm.initial
1003 state_config = fsm.states[initial]
1005 print(f"## Test Iteration: {loop_name}")
1006 print()
1007 print(f"State: {initial}")
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
1016 action = state_config.action
1017 is_slash = action.startswith("/") or state_config.action_type in (
1018 "prompt",
1019 "slash_command",
1020 )
1022 print(f"Action: {action}")
1023 print()
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
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)
1038 print(f"Exit code: {result.exit_code}")
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)"
1050 print(f"Output:\n{output_preview}")
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}")
1061 print()
1063 # Evaluate
1064 ctx = InterpolationContext()
1065 eval_result: EvaluationResult
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)"
1080 print(f"Evaluator: {evaluator_type}")
1081 print(f"Verdict: {eval_result.verdict.upper()}")
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}")
1088 # Determine next state based on verdict
1089 verdict = eval_result.verdict
1090 next_state = None
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
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}')")
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
1122 def cmd_simulate(loop_name: str) -> int:
1123 """Run interactive simulation of loop execution.
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
1130 try:
1131 path = resolve_loop_path(loop_name)
1133 # Load the file to check format
1134 with open(path) as f:
1135 spec = yaml.safe_load(f)
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
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
1160 # Create simulation runner
1161 sim_runner = SimulationActionRunner(scenario=args.scenario)
1163 # Track simulation state
1164 states_visited: list[str] = []
1166 def simulation_callback(event: dict) -> None:
1167 """Display simulation progress."""
1168 event_type = event.get("event")
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}")
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}")
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()}")
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}")
1193 # Print header
1194 mode_str = f"scenario={args.scenario}" if args.scenario else "interactive"
1195 print(f"=== SIMULATION: {fsm.name} ({mode_str}) ===")
1197 # Run simulation
1198 executor = FSMExecutor(
1199 fsm,
1200 event_callback=simulation_callback,
1201 action_runner=sim_runner,
1202 )
1203 result = executor.run()
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}")
1213 return 0
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
1241def main_sprint() -> int:
1242 """Entry point for ll-sprint command.
1244 Manage and execute sprint/sequence definitions.
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 )
1264 subparsers = parser.add_subparsers(dest="command", help="Available commands")
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 )
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 )
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 )
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)
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")
1311 args = parser.parse_args()
1313 if not args.command:
1314 parser.print_help()
1315 return 1
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())
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)
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)
1335 return 1
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(",")]
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 )
1354 # Validate issues exist
1355 valid = manager.validate_issues(issues)
1356 invalid = set(issues) - set(valid.keys())
1358 if invalid:
1359 logger.warning(f"Issue IDs not found: {', '.join(sorted(invalid))}")
1361 options = SprintOptions(
1362 max_workers=args.max_workers,
1363 timeout=args.timeout,
1364 )
1366 sprint = manager.create(
1367 name=args.name,
1368 issues=issues,
1369 description=args.description,
1370 options=options,
1371 )
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")
1378 if invalid:
1379 logger.warning(f" Invalid issues: {', '.join(sorted(invalid))}")
1381 return 0
1384def _render_execution_plan(
1385 waves: list[list[Any]],
1386 dep_graph: DependencyGraph,
1387) -> str:
1388 """Render execution plan with wave groupings.
1390 Args:
1391 waves: List of execution waves from get_execution_waves()
1392 dep_graph: DependencyGraph for looking up blockers
1394 Returns:
1395 Formatted string showing wave structure
1396 """
1397 if not waves:
1398 return ""
1400 total_issues = sum(len(wave) for wave in waves)
1401 lines: list[str] = []
1403 lines.append("")
1404 lines.append("=" * 70)
1405 lines.append(f"EXECUTION PLAN ({total_issues} issues, {len(waves)} waves)")
1406 lines.append("=" * 70)
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())
1418 for i, issue in enumerate(wave):
1419 is_last = i == len(wave) - 1
1420 prefix = " └── " if is_last else " ├── "
1422 # Truncate title if too long
1423 title = issue.title
1424 if len(title) > 45:
1425 title = title[:42] + "..."
1427 lines.append(f"{prefix}{issue.issue_id}: {title} ({issue.priority})")
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}")
1436 return "\n".join(lines)
1439def _render_dependency_graph(
1440 waves: list[list[Any]],
1441 dep_graph: DependencyGraph,
1442) -> str:
1443 """Render ASCII dependency graph.
1445 Args:
1446 waves: List of execution waves
1447 dep_graph: DependencyGraph for looking up relationships
1449 Returns:
1450 Formatted string showing dependency arrows
1451 """
1452 if not waves or len(waves) <= 1:
1453 return ""
1455 lines: list[str] = []
1456 lines.append("")
1457 lines.append("=" * 70)
1458 lines.append("DEPENDENCY GRAPH")
1459 lines.append("=" * 70)
1460 lines.append("")
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()
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)
1473 blocked_issues = sorted(dep_graph.blocks.get(issue_id, set()))
1474 if not blocked_issues:
1475 return issue_id
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
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)
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}")
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}")
1504 lines.extend(chains)
1505 lines.append("")
1506 lines.append("Legend: ──→ blocks (must complete before)")
1508 return "\n".join(lines)
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
1519 # Validate issues
1520 valid = manager.validate_issues(sprint.issues)
1521 invalid = set(sprint.issues) - set(valid.keys())
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
1529 if issue_infos:
1530 dep_graph = DependencyGraph.from_issues(issue_infos)
1531 has_cycles = dep_graph.has_cycles()
1533 if not has_cycles:
1534 waves = dep_graph.get_execution_waves()
1536 print(f"Sprint: {sprint.name}")
1537 print(f"Description: {sprint.description or '(none)'}")
1538 print(f"Created: {sprint.created}")
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})")
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)}")
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}")
1564 if invalid:
1565 print(f"\nWarning: {len(invalid)} issue(s) not found")
1567 return 0
1570def _cmd_sprint_list(args: argparse.Namespace, manager: SprintManager) -> int:
1571 """List all sprints."""
1572 sprints = manager.list_all()
1574 if not sprints:
1575 print("No sprints defined")
1576 return 0
1578 print(f"Available sprints ({len(sprints)}):")
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}")
1590 return 0
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
1600 logger.success(f"Deleted sprint: {args.sprint}")
1601 return 0
1604def _get_sprint_state_file() -> Path:
1605 """Get path to sprint state file."""
1606 return Path.cwd() / ".sprint-state.json"
1609def _load_sprint_state(logger: Logger) -> SprintState | None:
1610 """Load sprint state from file."""
1611 import json
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
1626def _save_sprint_state(state: SprintState, logger: Logger) -> None:
1627 """Save sprint state to file."""
1628 import json
1629 from datetime import datetime
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}")
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")
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
1653 logger = Logger(verbose=not args.quiet)
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)
1661 sprint = manager.load(args.sprint)
1662 if not sprint:
1663 logger.error(f"Sprint not found: {args.sprint}")
1664 return 1
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))}")
1676 # Validate issues exist
1677 valid = manager.validate_issues(issues_to_process)
1678 invalid = set(issues_to_process) - set(valid.keys())
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
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
1691 # Build dependency graph
1692 dep_graph = DependencyGraph.from_issues(issue_infos)
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
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
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}")
1715 if args.dry_run:
1716 logger.info("\nDry run mode - no changes will be made")
1717 return 0
1719 # Initialize or load state
1720 state: SprintState
1721 start_wave = 1
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 )
1761 # Track exit status for error handling (ENH-185)
1762 exit_code = 0
1764 try:
1765 # Determine max workers
1766 max_workers = args.max_workers or (sprint.options.max_workers if sprint.options else 2)
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)
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
1782 # Skip already-completed waves when resuming
1783 if wave_num < start_wave:
1784 continue
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)}")
1790 if len(wave) == 1:
1791 # Single issue — process in-place (no worktree overhead)
1792 from little_loops.issue_manager import process_issue_inplace
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 )
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
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
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)
1875 except KeyboardInterrupt:
1876 # Belt-and-suspenders with signal handler (ENH-185)
1877 logger.warning("Sprint interrupted by user (KeyboardInterrupt)")
1878 exit_code = 130
1880 except Exception as e:
1881 # Catch unexpected exceptions (ENH-185)
1882 logger.error(f"Sprint failed unexpectedly: {e}")
1883 exit_code = 1
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")
1891 return exit_code
1894def main_history() -> int:
1895 """Entry point for ll-history command.
1897 Display summary statistics and analysis for completed issues.
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 )
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 )
1928 subparsers = parser.add_subparsers(dest="command", help="Available commands")
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 )
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 )
1982 args = parser.parse_args()
1984 if not args.command:
1985 parser.print_help()
1986 return 1
1988 # Determine directories
1989 issues_dir = args.directory or Path.cwd() / ".issues"
1990 completed_dir = issues_dir / "completed"
1992 if args.command == "summary":
1993 # Existing summary logic
1994 issues = scan_completed_issues(completed_dir)
1995 summary = calculate_summary(issues)
1997 if args.json:
1998 print(format_summary_json(summary))
1999 else:
2000 print(format_summary_text(summary))
2002 return 0
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 )
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))
2023 return 0
2025 return 1
2028if __name__ == "__main__":
2029 sys.exit(main_auto())