Coverage for little_loops / cli / sprint.py: 86%
783 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-02-15 15:23 -0600
« prev ^ index » next coverage.py v7.12.0, created at 2026-02-15 15:23 -0600
1"""ll-sprint: Sprint and sequence management with dependency-aware execution."""
3from __future__ import annotations
5import argparse
6import signal
7import sys
8from pathlib import Path
9from types import FrameType
10from typing import Any
12from little_loops.cli_args import (
13 add_config_arg,
14 add_dry_run_arg,
15 add_max_workers_arg,
16 add_quiet_arg,
17 add_resume_arg,
18 add_skip_analysis_arg,
19 add_skip_arg,
20 add_timeout_arg,
21 add_type_arg,
22 parse_issue_ids,
23 parse_issue_types,
24)
25from little_loops.config import BRConfig
26from little_loops.dependency_graph import (
27 DependencyGraph,
28 WaveContentionNote,
29 refine_waves_for_contention,
30)
31from little_loops.logger import Logger, format_duration
32from little_loops.parallel.orchestrator import ParallelOrchestrator
33from little_loops.sprint import SprintManager, SprintOptions, SprintState
35# Module-level shutdown flag for ll-sprint signal handling (ENH-183)
36_sprint_shutdown_requested: bool = False
39def _sprint_signal_handler(signum: int, frame: FrameType | None) -> None:
40 """Handle shutdown signals gracefully for ll-sprint.
42 First signal: Set shutdown flag for graceful exit after current wave.
43 Second signal: Force immediate exit.
44 """
45 global _sprint_shutdown_requested
46 if _sprint_shutdown_requested:
47 # Second signal - force exit
48 print("\nForce shutdown requested", file=sys.stderr)
49 sys.exit(1)
50 _sprint_shutdown_requested = True
51 print("\nShutdown requested, will exit after current wave...", file=sys.stderr)
54def main_sprint() -> int:
55 """Entry point for ll-sprint command.
57 Manage and execute sprint/sequence definitions.
59 Returns:
60 Exit code (0 = success)
61 """
62 parser = argparse.ArgumentParser(
63 prog="ll-sprint",
64 description="Manage and execute sprint/sequence definitions",
65 formatter_class=argparse.RawDescriptionHelpFormatter,
66 epilog="""
67Examples:
68 %(prog)s create sprint-1 --issues BUG-001,FEAT-010 --description "Q1 fixes"
69 %(prog)s run sprint-1
70 %(prog)s run sprint-1 --dry-run
71 %(prog)s list
72 %(prog)s show sprint-1
73 %(prog)s edit sprint-1 --add BUG-045,ENH-050
74 %(prog)s edit sprint-1 --remove BUG-001
75 %(prog)s edit sprint-1 --prune
76 %(prog)s edit sprint-1 --revalidate
77 %(prog)s delete sprint-1
78 %(prog)s analyze sprint-1
79 %(prog)s analyze sprint-1 --format json
80""",
81 )
83 subparsers = parser.add_subparsers(dest="command", help="Available commands")
85 # create subcommand
86 create_parser = subparsers.add_parser("create", help="Create a new sprint")
87 create_parser.add_argument("name", help="Sprint name (used as filename)")
88 create_parser.add_argument(
89 "--issues",
90 required=True,
91 help="Comma-separated issue IDs (e.g., BUG-001,FEAT-010)",
92 )
93 create_parser.add_argument("--description", "-d", default="", help="Sprint description")
94 add_max_workers_arg(create_parser, default=2)
95 add_timeout_arg(create_parser, default=3600)
96 add_skip_arg(
97 create_parser,
98 help_text=(
99 "Comma-separated list of issue IDs to exclude from sprint (e.g., BUG-003,FEAT-004)"
100 ),
101 )
102 add_type_arg(create_parser)
104 # run subcommand
105 run_parser = subparsers.add_parser("run", help="Execute a sprint")
106 run_parser.add_argument("sprint", help="Sprint name to execute")
107 add_dry_run_arg(run_parser)
108 add_max_workers_arg(run_parser)
109 add_timeout_arg(run_parser)
110 add_config_arg(run_parser)
111 add_resume_arg(run_parser)
112 add_quiet_arg(run_parser)
113 add_skip_arg(
114 run_parser,
115 help_text=(
116 "Comma-separated list of issue IDs to skip during execution (e.g., BUG-003,FEAT-004)"
117 ),
118 )
119 add_skip_analysis_arg(run_parser)
120 add_type_arg(run_parser)
122 # list subcommand
123 list_parser = subparsers.add_parser("list", help="List all sprints")
124 list_parser.add_argument(
125 "--verbose", "-v", action="store_true", help="Show detailed information"
126 )
128 # show subcommand
129 show_parser = subparsers.add_parser("show", help="Show sprint details")
130 show_parser.add_argument("sprint", help="Sprint name to show")
131 add_config_arg(show_parser)
132 add_skip_analysis_arg(show_parser)
134 # edit subcommand
135 edit_parser = subparsers.add_parser("edit", help="Edit a sprint's issue list")
136 edit_parser.add_argument("sprint", help="Sprint name to edit")
137 edit_parser.add_argument(
138 "--add",
139 default=None,
140 help="Comma-separated issue IDs to add (e.g., BUG-045,ENH-050)",
141 )
142 edit_parser.add_argument(
143 "--remove",
144 default=None,
145 help="Comma-separated issue IDs to remove",
146 )
147 edit_parser.add_argument(
148 "--prune",
149 action="store_true",
150 help="Remove invalid (missing file) and completed issue references",
151 )
152 edit_parser.add_argument(
153 "--revalidate",
154 action="store_true",
155 help="Re-run dependency analysis after edits",
156 )
157 add_config_arg(edit_parser)
159 # delete subcommand
160 delete_parser = subparsers.add_parser("delete", help="Delete a sprint")
161 delete_parser.add_argument("sprint", help="Sprint name to delete")
163 # analyze subcommand
164 analyze_parser = subparsers.add_parser(
165 "analyze", help="Analyze sprint for file conflicts between issues"
166 )
167 analyze_parser.add_argument("sprint", help="Sprint name to analyze")
168 analyze_parser.add_argument(
169 "-f",
170 "--format",
171 type=str,
172 choices=["text", "json"],
173 default="text",
174 help="Output format (default: text)",
175 )
176 add_config_arg(analyze_parser)
178 args = parser.parse_args()
180 if not args.command:
181 parser.print_help()
182 return 1
184 # Commands that don't need project root
185 if args.command == "list":
186 return _cmd_sprint_list(args, SprintManager())
187 if args.command == "delete":
188 return _cmd_sprint_delete(args, SprintManager())
190 # Commands that need project root
191 project_root = args.config if hasattr(args, "config") and args.config else Path.cwd()
192 config = BRConfig(project_root)
193 manager = SprintManager(config=config)
195 if args.command == "create":
196 return _cmd_sprint_create(args, manager)
197 if args.command == "show":
198 return _cmd_sprint_show(args, manager)
199 if args.command == "edit":
200 return _cmd_sprint_edit(args, manager)
201 if args.command == "run":
202 return _cmd_sprint_run(args, manager, config)
203 if args.command == "analyze":
204 return _cmd_sprint_analyze(args, manager)
206 return 1
209def _cmd_sprint_create(args: argparse.Namespace, manager: SprintManager) -> int:
210 """Create a new sprint."""
211 logger = Logger()
212 issues = [i.strip().upper() for i in args.issues.split(",")]
214 # Apply skip filter if provided
215 skip_ids = parse_issue_ids(args.skip)
216 if skip_ids:
217 original_count = len(issues)
218 issues = [i for i in issues if i not in skip_ids]
219 skipped = original_count - len(issues)
220 if skipped > 0:
221 logger.info(
222 f"Skipping {skipped} issue(s): "
223 f"{', '.join(sorted(skip_ids & set(issues) | skip_ids))}"
224 )
226 # Apply type filter if provided
227 type_prefixes = parse_issue_types(getattr(args, "type", None))
228 if type_prefixes:
229 original_count = len(issues)
230 issues = [i for i in issues if i.split("-", 1)[0] in type_prefixes]
231 filtered = original_count - len(issues)
232 if filtered > 0:
233 logger.info(f"Filtered {filtered} issue(s) by type: {', '.join(sorted(type_prefixes))}")
235 # Validate issues exist
236 valid = manager.validate_issues(issues)
237 invalid = set(issues) - set(valid.keys())
239 if invalid:
240 logger.warning(f"Issue IDs not found: {', '.join(sorted(invalid))}")
242 options = SprintOptions(
243 max_workers=args.max_workers,
244 timeout=args.timeout,
245 )
247 sprint = manager.create(
248 name=args.name,
249 issues=issues,
250 description=args.description,
251 options=options,
252 )
254 logger.success(f"Created sprint: {sprint.name}")
255 logger.info(f" Description: {sprint.description or '(none)'}")
256 logger.info(f" Issues: {', '.join(sprint.issues)}")
257 logger.info(f" File: .sprints/{sprint.name}.yaml")
259 if invalid:
260 logger.warning(f" Invalid issues: {', '.join(sorted(invalid))}")
262 return 0
265def _render_execution_plan(
266 waves: list[list[Any]],
267 dep_graph: DependencyGraph,
268 contention_notes: list[WaveContentionNote | None] | None = None,
269) -> str:
270 """Render execution plan with wave groupings.
272 Args:
273 waves: List of execution waves from get_execution_waves()
274 dep_graph: DependencyGraph for looking up blockers
275 contention_notes: Optional per-wave contention annotations from
276 refine_waves_for_contention(). Same length as waves.
278 Returns:
279 Formatted string showing wave structure
280 """
281 if not waves:
282 return ""
284 # Build logical wave groups: consecutive sub-waves from the same
285 # parent_wave_index are grouped together.
286 logical_waves: list[list[int]] = [] # each entry is list of indices into waves
287 notes = contention_notes or [None] * len(waves)
289 for idx in range(len(waves)):
290 note = notes[idx] if idx < len(notes) else None
291 if note is not None:
292 # Check if this belongs to the same parent as the previous group
293 if logical_waves and notes[logical_waves[-1][0]] is not None:
294 prev_note = notes[logical_waves[-1][0]]
295 if prev_note and prev_note.parent_wave_index == note.parent_wave_index:
296 logical_waves[-1].append(idx)
297 continue
298 logical_waves.append([idx])
299 else:
300 logical_waves.append([idx])
302 total_issues = sum(len(wave) for wave in waves)
303 num_logical = len(logical_waves)
304 lines: list[str] = []
306 lines.append("")
307 lines.append("=" * 70)
308 wave_word = "wave" if num_logical == 1 else "waves"
309 lines.append(f"EXECUTION PLAN ({total_issues} issues, {num_logical} {wave_word})")
310 lines.append("=" * 70)
312 for logical_idx, group in enumerate(logical_waves):
313 lines.append("")
314 logical_num = logical_idx + 1
315 group_issues = [issue for widx in group for issue in waves[widx]]
316 group_count = len(group_issues)
317 is_contention = len(group) > 1
319 if is_contention:
320 # Multiple sub-waves from overlap splitting
321 lines.append(
322 f"Wave {logical_num} ({group_count} issues, serialized \u2014 file overlap):"
323 )
324 step = 0
325 for widx in group:
326 for issue in waves[widx]:
327 step += 1
328 lines.append(f" Step {step}/{group_count}:")
330 # Truncate title if too long
331 title = issue.title
332 if len(title) > 45:
333 title = title[:42] + "..."
335 lines.append(
336 f" \u2514\u2500\u2500 {issue.issue_id}: {title} ({issue.priority})"
337 )
339 # Show blockers for this issue
340 blockers = dep_graph.blocked_by.get(issue.issue_id, set())
341 if blockers:
342 blockers_str = ", ".join(sorted(blockers))
343 lines.append(f" blocked by: {blockers_str}")
345 # Show contended files once at the end of the group
346 first_note = notes[group[0]]
347 if first_note:
348 paths_str = ", ".join(first_note.contended_paths[:2])
349 extra = len(first_note.contended_paths) - 2
350 if extra > 0:
351 paths_str += f" +{extra} more"
352 lines.append(f" Contended files: {paths_str}")
353 else:
354 # Single wave (no overlap splitting)
355 widx = group[0]
356 wave = waves[widx]
358 if logical_num == 1:
359 parallel_note = "(parallel)" if len(wave) > 1 else ""
360 else:
361 parallel_note = f"(after Wave {logical_num - 1})"
362 if len(wave) > 1:
363 parallel_note += " parallel"
364 lines.append(f"Wave {logical_num} {parallel_note}:".strip())
366 for i, issue in enumerate(wave):
367 is_last = i == len(wave) - 1
368 prefix = " \u2514\u2500\u2500 " if is_last else " \u251c\u2500\u2500 "
370 # Truncate title if too long
371 title = issue.title
372 if len(title) > 45:
373 title = title[:42] + "..."
375 lines.append(f"{prefix}{issue.issue_id}: {title} ({issue.priority})")
377 # Show blockers for this issue
378 blockers = dep_graph.blocked_by.get(issue.issue_id, set())
379 if blockers:
380 blocker_prefix = (
381 " \u2514\u2500\u2500 " if is_last else " \u2502 \u2514\u2500\u2500 "
382 )
383 blockers_str = ", ".join(sorted(blockers))
384 lines.append(f"{blocker_prefix}blocked by: {blockers_str}")
386 return "\n".join(lines)
389def _render_dependency_graph(
390 waves: list[list[Any]],
391 dep_graph: DependencyGraph,
392) -> str:
393 """Render ASCII dependency graph.
395 Args:
396 waves: List of execution waves
397 dep_graph: DependencyGraph for looking up relationships
399 Returns:
400 Formatted string showing dependency arrows
401 """
402 if not waves or len(waves) <= 1:
403 return ""
405 # Don't render graph if there are no actual dependency edges
406 # (waves > 1 can happen from file overlap splitting alone)
407 all_ids = {issue.issue_id for wave in waves for issue in wave}
408 has_edges = any(dep_graph.blocks.get(issue_id, set()) & all_ids for issue_id in all_ids)
409 if not has_edges:
410 return ""
412 lines: list[str] = []
413 lines.append("")
414 lines.append("=" * 70)
415 lines.append("DEPENDENCY GRAPH")
416 lines.append("=" * 70)
417 lines.append("")
419 # Build chains: track which issues block what
420 # Show each independent chain on its own line
421 chains: list[str] = []
422 visited: set[str] = set()
424 def build_chain(issue_id: str) -> str:
425 """Recursively build chain string from issue."""
426 if issue_id in visited:
427 return issue_id
428 visited.add(issue_id)
430 blocked_issues = sorted(dep_graph.blocks.get(issue_id, set()))
431 if not blocked_issues:
432 return issue_id
434 if len(blocked_issues) == 1:
435 return f"{issue_id} \u2500\u2500\u2192 {build_chain(blocked_issues[0])}"
436 else:
437 # Multiple branches - show first inline, note others
438 result = f"{issue_id} \u2500\u2500\u2192 {build_chain(blocked_issues[0])}"
439 for other in blocked_issues[1:]:
440 if other not in visited:
441 chains.append(f" {issue_id} \u2500\u2500\u2192 {build_chain(other)}")
442 return result
444 # Find root issues structurally (not blocked by anything in this graph)
445 roots = [iid for iid in sorted(all_ids) if not (dep_graph.blocked_by.get(iid, set()) & all_ids)]
447 for root in roots:
448 if root not in visited:
449 chain = build_chain(root)
450 if chain and "──→" in chain:
451 chains.append(f" {chain}")
453 lines.extend(chains)
454 lines.append("")
455 lines.append("Legend: \u2500\u2500\u2192 blocks (must complete before)")
457 return "\n".join(lines)
460def _render_health_summary(
461 waves: list[list[Any]],
462 contention_notes: list[WaveContentionNote | None] | None,
463 has_cycles: bool,
464 invalid: set[str],
465 dep_report: Any | None = None,
466 issue_to_wave: dict[str, int] | None = None,
467) -> str:
468 """Render a one-line sprint health summary.
470 Returns:
471 Health summary string like "OK -- 5 issues in 1 wave, contention serialized"
472 """
473 total_issues = sum(len(w) for w in waves)
475 if has_cycles:
476 return "BLOCKED -- dependency cycles detected"
478 if invalid:
479 return f"WARNING -- {len(invalid)} issue(s) not found on disk"
481 # Check for novel (unsatisfied) high-confidence proposals
482 if dep_report and dep_report.proposals and issue_to_wave is not None:
483 novel_count = 0
484 for p in dep_report.proposals:
485 target_wave = issue_to_wave.get(p.target_id)
486 source_wave = issue_to_wave.get(p.source_id)
487 if target_wave is None or source_wave is None or target_wave >= source_wave:
488 if p.confidence >= 0.5:
489 novel_count += 1
490 if novel_count > 0:
491 return f"REVIEW -- {novel_count} potential dependency(ies) to review"
493 # Count logical waves (group contention sub-waves)
494 notes = contention_notes or [None] * len(waves)
495 logical_count = 0
496 has_contention = False
497 prev_parent: int | None = None
498 for idx in range(len(waves)):
499 note = notes[idx] if idx < len(notes) else None
500 if note is not None:
501 has_contention = True
502 if prev_parent is None or note.parent_wave_index != prev_parent:
503 logical_count += 1
504 prev_parent = note.parent_wave_index
505 else:
506 logical_count += 1
507 prev_parent = None
509 wave_word = "wave" if logical_count == 1 else "waves"
510 suffix = ", overlap serialized" if has_contention else ", all parallelizable"
511 if logical_count == 1 and total_issues == 1:
512 suffix = ""
514 return f"OK -- {total_issues} issues in {logical_count} {wave_word}{suffix}"
517def _cmd_sprint_show(args: argparse.Namespace, manager: SprintManager) -> int:
518 """Show sprint details with dependency visualization."""
519 logger = Logger()
520 sprint = manager.load(args.sprint)
521 if not sprint:
522 logger.error(f"Sprint not found: {args.sprint}")
523 return 1
525 # Validate issues
526 valid = manager.validate_issues(sprint.issues)
527 invalid = set(sprint.issues) - set(valid.keys())
529 # Load full IssueInfo objects for dependency analysis
530 issue_infos = manager.load_issue_infos(list(valid.keys()))
531 dep_graph: DependencyGraph | None = None
532 waves: list[list[Any]] = []
533 contention_notes: list[WaveContentionNote | None] | None = None
534 has_cycles = False
536 # Gather all issue IDs on disk to avoid false "nonexistent" warnings
537 from little_loops.dependency_mapper import gather_all_issue_ids
539 config = manager.config
540 issues_dir = config.project_root / config.issues.base_dir if config else Path(".issues")
541 all_known_ids = gather_all_issue_ids(issues_dir)
543 if issue_infos:
544 dep_graph = DependencyGraph.from_issues(issue_infos, all_known_ids=all_known_ids)
545 has_cycles = dep_graph.has_cycles()
547 if not has_cycles:
548 waves = dep_graph.get_execution_waves()
549 waves, contention_notes = refine_waves_for_contention(waves)
551 print(f"Sprint: {sprint.name}")
552 print(f"Description: {sprint.description or '(none)'}")
553 print(f"Created: {sprint.created}")
555 # Options on a single compact line right after metadata
556 if sprint.options:
557 opts = sprint.options
558 print(
559 f"Options: max_workers={opts.max_workers}, timeout={opts.timeout}s, max_iterations={opts.max_iterations}"
560 )
562 # Dependency analysis (ENH-301) - run before health summary so we can reference it
563 dep_report: Any = None
564 issue_to_wave: dict[str, int] = {}
565 if issue_infos and not args.skip_analysis:
566 from little_loops.dependency_mapper import analyze_dependencies
568 issue_contents = _build_issue_contents(issue_infos)
569 dep_report = analyze_dependencies(issue_infos, issue_contents, all_known_ids=all_known_ids)
571 # Build wave ordering map so we can filter already-satisfied proposals
572 for wave_idx, wave in enumerate(waves):
573 for issue in wave:
574 issue_to_wave[issue.issue_id] = wave_idx
576 # Sprint health summary
577 if waves:
578 health = _render_health_summary(
579 waves,
580 contention_notes,
581 has_cycles,
582 invalid,
583 dep_report=dep_report,
584 issue_to_wave=issue_to_wave if issue_to_wave else None,
585 )
586 print(f"Sprint health: {health}")
588 # Show execution plan if we have dependency info and no cycles
589 if waves and dep_graph:
590 print(_render_execution_plan(waves, dep_graph, contention_notes))
591 print(_render_dependency_graph(waves, dep_graph))
592 else:
593 # Fallback to simple list if no valid issues or cycles
594 print(f"Issues ({len(sprint.issues)}):")
595 for issue_id in sprint.issues:
596 status = "valid" if issue_id in valid else "NOT FOUND"
597 print(f" - {issue_id} ({status})")
599 # Warn about cycles if detected
600 if has_cycles and dep_graph:
601 cycles = dep_graph.detect_cycles()
602 print("\nWarning: Dependency cycles detected:")
603 for cycle in cycles:
604 print(f" {' -> '.join(cycle)}")
606 # Render dependency analysis output
607 if dep_report is not None:
608 _render_dependency_analysis(
609 dep_report, logger, issue_to_wave=issue_to_wave if issue_to_wave else None
610 )
612 if invalid:
613 print(f"\nWarning: {len(invalid)} issue(s) not found")
615 return 0
618def _cmd_sprint_edit(args: argparse.Namespace, manager: SprintManager) -> int:
619 """Edit a sprint's issue list."""
620 import re
622 logger = Logger()
623 sprint = manager.load(args.sprint)
624 if not sprint:
625 logger.error(f"Sprint not found: {args.sprint}")
626 return 1
628 if not args.add and not args.remove and not args.prune and not args.revalidate:
629 logger.error("No edit flags specified. Use --add, --remove, --prune, or --revalidate.")
630 return 1
632 original_issues = list(sprint.issues)
633 changed = False
635 # --add: add new issue IDs
636 if args.add:
637 add_ids = parse_issue_ids(args.add)
638 if add_ids:
639 valid = manager.validate_issues(list(add_ids))
640 invalid = add_ids - set(valid.keys())
641 if invalid:
642 logger.warning(f"Issue IDs not found (skipping): {', '.join(sorted(invalid))}")
644 existing = set(sprint.issues)
645 added = []
646 for issue_id in sorted(valid.keys()):
647 if issue_id not in existing:
648 sprint.issues.append(issue_id)
649 added.append(issue_id)
650 else:
651 logger.info(f"Already in sprint: {issue_id}")
652 if added:
653 logger.success(f"Added: {', '.join(added)}")
654 changed = True
656 # --remove: remove issue IDs
657 if args.remove:
658 remove_ids = parse_issue_ids(args.remove)
659 if remove_ids:
660 before = len(sprint.issues)
661 sprint.issues = [i for i in sprint.issues if i not in remove_ids]
662 removed_count = before - len(sprint.issues)
663 not_found = remove_ids - set(original_issues)
664 if not_found:
665 logger.warning(f"Not in sprint: {', '.join(sorted(not_found))}")
666 if removed_count > 0:
667 logger.success(f"Removed {removed_count} issue(s)")
668 changed = True
670 # --prune: remove invalid and completed references
671 if args.prune:
672 valid = manager.validate_issues(sprint.issues)
673 invalid_ids = set(sprint.issues) - set(valid.keys())
675 # Also detect completed issues
676 completed_ids: set[str] = set()
677 if manager.config:
678 completed_dir = manager.config.get_completed_dir()
679 if completed_dir.exists():
680 for path in completed_dir.glob("*.md"):
681 match = re.search(r"(BUG|FEAT|ENH)-(\d+)", path.name)
682 if match:
683 completed_ids.add(f"{match.group(1)}-{match.group(2)}")
685 prune_ids = invalid_ids | (completed_ids & set(sprint.issues))
686 if prune_ids:
687 sprint.issues = [i for i in sprint.issues if i not in prune_ids]
688 pruned_invalid = invalid_ids & prune_ids
689 pruned_completed = (completed_ids & set(original_issues)) - invalid_ids
690 if pruned_invalid:
691 logger.success(f"Pruned invalid: {', '.join(sorted(pruned_invalid))}")
692 if pruned_completed:
693 logger.success(f"Pruned completed: {', '.join(sorted(pruned_completed))}")
694 changed = True
695 else:
696 logger.info("Nothing to prune — all issues are valid and active")
698 # Save if changed
699 if changed:
700 sprint.save(manager.sprints_dir)
701 logger.success(f"Saved {args.sprint} ({len(sprint.issues)} issues)")
702 if original_issues != sprint.issues:
703 logger.info(f" Was: {', '.join(original_issues)}")
704 logger.info(f" Now: {', '.join(sprint.issues)}")
706 # --revalidate: re-run dependency analysis
707 if args.revalidate:
708 valid = manager.validate_issues(sprint.issues)
709 issue_infos = manager.load_issue_infos(list(valid.keys()))
710 if issue_infos:
711 from little_loops.dependency_mapper import (
712 analyze_dependencies,
713 gather_all_issue_ids,
714 )
716 _config = manager.config
717 _issues_dir = (
718 _config.project_root / _config.issues.base_dir if _config else Path(".issues")
719 )
720 _all_known_ids = gather_all_issue_ids(_issues_dir)
721 issue_contents = _build_issue_contents(issue_infos)
722 dep_report = analyze_dependencies(
723 issue_infos, issue_contents, all_known_ids=_all_known_ids
724 )
725 _render_dependency_analysis(dep_report, logger)
726 else:
727 logger.info("No valid issues to analyze")
729 invalid = set(sprint.issues) - set(valid.keys())
730 if invalid:
731 logger.warning(f"{len(invalid)} issue(s) not found: {', '.join(sorted(invalid))}")
733 return 0
736def _cmd_sprint_list(args: argparse.Namespace, manager: SprintManager) -> int:
737 """List all sprints."""
738 sprints = manager.list_all()
740 if not sprints:
741 print("No sprints defined")
742 return 0
744 print(f"Available sprints ({len(sprints)}):")
746 for sprint in sprints:
747 if args.verbose:
748 print(f"\n{sprint.name}:")
749 print(f" Description: {sprint.description or '(none)'}")
750 print(f" Issues: {', '.join(sprint.issues)}")
751 print(f" Created: {sprint.created}")
752 else:
753 desc = f" - {sprint.description}" if sprint.description else ""
754 print(f" {sprint.name}{desc}")
756 return 0
759def _cmd_sprint_delete(args: argparse.Namespace, manager: SprintManager) -> int:
760 """Delete a sprint."""
761 logger = Logger()
762 if not manager.delete(args.sprint):
763 logger.error(f"Sprint not found: {args.sprint}")
764 return 1
766 logger.success(f"Deleted sprint: {args.sprint}")
767 return 0
770def _cmd_sprint_analyze(args: argparse.Namespace, manager: SprintManager) -> int:
771 """Analyze sprint for file conflicts between issues."""
772 import json as _json
774 from little_loops.parallel.file_hints import FileHints, extract_file_hints
776 logger = Logger()
777 sprint = manager.load(args.sprint)
778 if not sprint:
779 logger.error(f"Sprint not found: {args.sprint}")
780 return 1
782 # Validate issues
783 valid = manager.validate_issues(sprint.issues)
784 invalid = set(sprint.issues) - set(valid.keys())
786 if invalid:
787 logger.warning(f"Issue IDs not found: {', '.join(sorted(invalid))}")
789 # Load full IssueInfo objects
790 issue_infos = manager.load_issue_infos(list(valid.keys()))
791 if not issue_infos:
792 logger.error("No valid issue files found")
793 return 1
795 # Gather all known IDs
796 from little_loops.dependency_mapper import gather_all_issue_ids
798 config = manager.config
799 issues_dir = config.project_root / config.issues.base_dir if config else Path(".issues")
800 all_known_ids = gather_all_issue_ids(issues_dir)
802 # Build dependency graph
803 dep_graph = DependencyGraph.from_issues(issue_infos, all_known_ids=all_known_ids)
804 has_cycles = dep_graph.has_cycles()
806 if has_cycles:
807 cycles = dep_graph.detect_cycles()
808 for cycle in cycles:
809 logger.error(f"Dependency cycle detected: {' -> '.join(cycle)}")
810 return 1
812 # Generate waves and refine for contention
813 waves = dep_graph.get_execution_waves()
814 waves, contention_notes = refine_waves_for_contention(waves)
816 # Extract file hints and detect pairwise conflicts
817 hints: dict[str, FileHints] = {}
818 for info in issue_infos:
819 content = info.path.read_text() if info.path.exists() else ""
820 hints[info.issue_id] = extract_file_hints(content, info.issue_id)
822 conflict_pairs: list[dict[str, Any]] = []
823 for i, a in enumerate(issue_infos):
824 for b in issue_infos[i + 1 :]:
825 if hints[a.issue_id].overlaps_with(hints[b.issue_id]):
826 overlapping = sorted(hints[a.issue_id].get_overlapping_paths(hints[b.issue_id]))
827 conflict_pairs.append(
828 {
829 "issue_a": a.issue_id,
830 "issue_b": b.issue_id,
831 "overlapping_files": overlapping,
832 }
833 )
835 # Build parallel-safe groups from waves (issues in same wave with no conflicts)
836 conflicting_ids = set()
837 for pair in conflict_pairs:
838 conflicting_ids.add(pair["issue_a"])
839 conflicting_ids.add(pair["issue_b"])
841 parallel_safe: list[list[str]] = []
842 notes_list = contention_notes or [None] * len(waves)
843 for idx, wave in enumerate(waves):
844 note = notes_list[idx] if idx < len(notes_list) else None
845 if note is None and len(wave) > 1:
846 # Non-contention wave with multiple issues = parallel-safe group
847 group = [issue.issue_id for issue in wave]
848 parallel_safe.append(group)
850 has_conflicts = len(conflict_pairs) > 0
852 # Build wave plan for report
853 wave_plan: list[dict[str, Any]] = []
854 for idx, wave in enumerate(waves):
855 note = notes_list[idx] if idx < len(notes_list) else None
856 wave_info: dict[str, Any] = {
857 "wave": idx + 1,
858 "issues": [issue.issue_id for issue in wave],
859 }
860 if note is not None:
861 wave_info["serialized"] = True
862 wave_info["sub_wave"] = note.sub_wave_index + 1
863 wave_info["total_sub_waves"] = note.total_sub_waves
864 wave_info["contended_paths"] = note.contended_paths
865 else:
866 wave_info["serialized"] = False
867 wave_plan.append(wave_info)
869 if args.format == "json":
870 data = {
871 "sprint": sprint.name,
872 "issue_count": len(issue_infos),
873 "has_conflicts": has_conflicts,
874 "conflicts": conflict_pairs,
875 "waves": wave_plan,
876 "parallel_safe_groups": parallel_safe,
877 }
878 print(_json.dumps(data, indent=2))
879 else:
880 # Text report
881 total_issues = len(issue_infos)
882 print(f"Sprint: {sprint.name}")
883 print(f"Issues: {total_issues}")
884 print()
885 print("=" * 70)
886 print("CONFLICT ANALYSIS")
887 print("=" * 70)
889 if not has_conflicts:
890 print()
891 print("No file conflicts detected. All issues can run in parallel.")
892 else:
893 print()
894 print(f"Conflicts found: {len(conflict_pairs)} pair(s)")
896 for i, pair in enumerate(conflict_pairs, 1):
897 print()
898 print(f" {i}. {pair['issue_a']} <-> {pair['issue_b']}")
899 files_str = ", ".join(pair["overlapping_files"][:3])
900 if len(pair["overlapping_files"]) > 3:
901 files_str += f" +{len(pair['overlapping_files']) - 3} more"
902 print(f" Overlapping files: {files_str}")
903 print(" Recommendation: Serialize execution")
905 # Execution plan
906 print()
907 print(_render_execution_plan(waves, dep_graph, contention_notes))
909 # Parallel-safe groups
910 if parallel_safe:
911 print()
912 print("Parallel-safe groups:")
913 for group in parallel_safe:
914 print(f" - {', '.join(group)} (no shared files)")
916 return 1 if has_conflicts else 0
919def _get_sprint_state_file() -> Path:
920 """Get path to sprint state file."""
921 return Path.cwd() / ".sprint-state.json"
924def _load_sprint_state(logger: Logger) -> SprintState | None:
925 """Load sprint state from file."""
926 import json
928 state_file = _get_sprint_state_file()
929 if not state_file.exists():
930 return None
931 try:
932 data = json.loads(state_file.read_text())
933 state = SprintState.from_dict(data)
934 logger.info(f"State loaded from {state_file}")
935 return state
936 except (json.JSONDecodeError, KeyError) as e:
937 logger.warning(f"Failed to load state: {e}")
938 return None
941def _save_sprint_state(state: SprintState, logger: Logger) -> None:
942 """Save sprint state to file."""
943 import json
944 from datetime import datetime
946 state.last_checkpoint = datetime.now().isoformat()
947 state_file = _get_sprint_state_file()
948 state_file.write_text(json.dumps(state.to_dict(), indent=2))
949 logger.info(f"State saved to {state_file}")
952def _cleanup_sprint_state(logger: Logger) -> None:
953 """Remove sprint state file."""
954 state_file = _get_sprint_state_file()
955 if state_file.exists():
956 state_file.unlink()
957 logger.info("Sprint state file cleaned up")
960def _build_issue_contents(issue_infos: list) -> dict[str, str]:
961 """Build issue_id -> file content mapping for dependency analysis."""
962 return {info.issue_id: info.path.read_text() for info in issue_infos if info.path.exists()}
965def _render_dependency_analysis(
966 report: Any,
967 logger: Logger,
968 issue_to_wave: dict[str, int] | None = None,
969) -> None:
970 """Display dependency analysis results in CLI format.
972 Args:
973 report: DependencyReport from analyze_dependencies()
974 logger: Logger instance
975 issue_to_wave: Optional mapping of issue_id -> wave index. When
976 provided, proposals where the target already runs before the
977 source in wave ordering are counted as "already handled".
978 """
979 if not report.proposals and not report.validation.has_issues:
980 return
982 logger.header("Dependency Analysis", char="-", width=60)
984 if report.proposals:
985 # Partition proposals into novel vs already-satisfied
986 novel: list[Any] = []
987 satisfied_count = 0
988 for p in report.proposals:
989 if issue_to_wave is not None:
990 target_wave = issue_to_wave.get(p.target_id)
991 source_wave = issue_to_wave.get(p.source_id)
992 if (
993 target_wave is not None
994 and source_wave is not None
995 and target_wave < source_wave
996 ):
997 satisfied_count += 1
998 continue
999 novel.append(p)
1001 if novel:
1002 logger.warning(f"Found {len(novel)} potential missing dependency(ies):")
1003 for p in novel:
1004 if p.conflict_score >= 0.7:
1005 conflict = "HIGH"
1006 elif p.conflict_score >= 0.4:
1007 conflict = "MEDIUM"
1008 else:
1009 conflict = "LOW"
1010 logger.warning(
1011 f" {p.source_id} may depend on {p.target_id} "
1012 f"({conflict} conflict, {p.confidence:.0%} confidence)"
1013 )
1014 if p.overlapping_files:
1015 files = ", ".join(p.overlapping_files[:3])
1016 if len(p.overlapping_files) > 3:
1017 files += " and more"
1018 logger.info(f" Shared files: {files}")
1020 if satisfied_count > 0:
1021 total = len(report.proposals)
1022 if not novel:
1023 dep_word = "dependency" if total == 1 else "dependencies"
1024 logger.info(f"All {total} potential {dep_word} already handled by wave ordering.")
1025 else:
1026 logger.info(f"({satisfied_count} additional already handled by wave ordering)")
1028 if report.validation.has_issues:
1029 v = report.validation
1030 if v.broken_refs:
1031 for issue_id, ref_id in v.broken_refs:
1032 logger.warning(f" {issue_id}: references nonexistent {ref_id}")
1033 if v.stale_completed_refs:
1034 for issue_id, ref_id in v.stale_completed_refs:
1035 logger.warning(f" {issue_id}: blocked by {ref_id} (completed)")
1036 if v.missing_backlinks:
1037 for issue_id, ref_id in v.missing_backlinks:
1038 logger.warning(f" {issue_id} blocked by {ref_id}, but {ref_id} missing backlink")
1040 logger.info("Run /ll:map-dependencies to apply discovered dependencies")
1041 print() # blank line separator
1044def _cmd_sprint_run(
1045 args: argparse.Namespace,
1046 manager: SprintManager,
1047 config: BRConfig,
1048) -> int:
1049 """Execute a sprint with dependency-aware scheduling."""
1050 from datetime import datetime
1052 logger = Logger(verbose=not args.quiet)
1054 # Setup signal handlers for graceful shutdown (ENH-183)
1055 global _sprint_shutdown_requested
1056 _sprint_shutdown_requested = False # Reset in case of multiple runs
1057 signal.signal(signal.SIGINT, _sprint_signal_handler)
1058 signal.signal(signal.SIGTERM, _sprint_signal_handler)
1060 sprint = manager.load(args.sprint)
1061 if not sprint:
1062 logger.error(f"Sprint not found: {args.sprint}")
1063 return 1
1065 # Apply skip filter if provided
1066 issues_to_process = list(sprint.issues)
1067 skip_ids = parse_issue_ids(args.skip)
1068 if skip_ids:
1069 original_count = len(issues_to_process)
1070 issues_to_process = [i for i in issues_to_process if i not in skip_ids]
1071 skipped = original_count - len(issues_to_process)
1072 if skipped > 0:
1073 logger.info(f"Skipping {skipped} issue(s): {', '.join(sorted(skip_ids))}")
1075 # Apply type filter if provided
1076 type_prefixes = parse_issue_types(getattr(args, "type", None))
1077 if type_prefixes:
1078 original_count = len(issues_to_process)
1079 issues_to_process = [i for i in issues_to_process if i.split("-", 1)[0] in type_prefixes]
1080 filtered = original_count - len(issues_to_process)
1081 if filtered > 0:
1082 logger.info(f"Filtered {filtered} issue(s) by type: {', '.join(sorted(type_prefixes))}")
1084 # Validate issues exist
1085 valid = manager.validate_issues(issues_to_process)
1086 invalid = set(issues_to_process) - set(valid.keys())
1088 if invalid:
1089 logger.error(f"Issue IDs not found: {', '.join(sorted(invalid))}")
1090 logger.info("Cannot execute sprint with missing issues")
1091 return 1
1093 # Load full IssueInfo objects for dependency analysis
1094 issue_infos = manager.load_issue_infos(issues_to_process)
1095 if not issue_infos:
1096 logger.error("No issue files found")
1097 return 1
1099 # Gather all issue IDs on disk to avoid false "nonexistent" warnings
1100 from little_loops.dependency_mapper import gather_all_issue_ids
1102 issues_dir = config.project_root / config.issues.base_dir
1103 all_known_ids = gather_all_issue_ids(issues_dir)
1105 # Dependency analysis (ENH-301)
1106 if not getattr(args, "skip_analysis", False):
1107 from little_loops.dependency_mapper import analyze_dependencies
1109 issue_contents = _build_issue_contents(issue_infos)
1110 dep_report = analyze_dependencies(issue_infos, issue_contents, all_known_ids=all_known_ids)
1111 _render_dependency_analysis(dep_report, logger)
1113 # Build dependency graph
1114 dep_graph = DependencyGraph.from_issues(issue_infos, all_known_ids=all_known_ids)
1116 # Detect cycles
1117 if dep_graph.has_cycles():
1118 cycles = dep_graph.detect_cycles()
1119 for cycle in cycles:
1120 logger.error(f"Dependency cycle detected: {' -> '.join(cycle)}")
1121 return 1
1123 # Get execution waves
1124 try:
1125 waves = dep_graph.get_execution_waves()
1126 except ValueError as e:
1127 logger.error(str(e))
1128 return 1
1130 # Refine waves for file overlap (ENH-306)
1131 waves, contention_notes = refine_waves_for_contention(waves)
1133 # Display execution plan
1134 logger.info(f"Running sprint: {sprint.name}")
1135 logger.info("Dependency analysis:")
1136 for i, wave in enumerate(waves, 1):
1137 issue_ids = ", ".join(issue.issue_id for issue in wave)
1138 note = contention_notes[i - 1] if contention_notes else None
1139 if note:
1140 logger.info(
1141 f" Wave {i}: {issue_ids}"
1142 f" [sub-wave {note.sub_wave_index + 1}/{note.total_sub_waves}]"
1143 )
1144 else:
1145 logger.info(f" Wave {i}: {issue_ids}")
1147 if args.dry_run:
1148 logger.info("\nDry run mode - no changes will be made")
1149 return 0
1151 # Initialize or load state
1152 state: SprintState
1153 start_wave = 1
1155 if args.resume:
1156 loaded_state = _load_sprint_state(logger)
1157 if loaded_state and loaded_state.sprint_name == args.sprint:
1158 state = loaded_state
1159 # Find first incomplete wave by checking completed issues
1160 completed_set = set(state.completed_issues)
1161 for i, wave in enumerate(waves, 1):
1162 wave_issue_ids = {issue.issue_id for issue in wave}
1163 if not wave_issue_ids.issubset(completed_set):
1164 start_wave = i
1165 break
1166 else:
1167 # All waves completed
1168 logger.info("Sprint already completed - nothing to resume")
1169 _cleanup_sprint_state(logger)
1170 return 0
1171 logger.info(f"Resuming from wave {start_wave}/{len(waves)}")
1172 logger.info(f" Previously completed: {len(state.completed_issues)} issues")
1173 else:
1174 if loaded_state:
1175 logger.warning(
1176 f"State file is for sprint '{loaded_state.sprint_name}', "
1177 f"not '{args.sprint}' - starting fresh"
1178 )
1179 else:
1180 logger.warning("No valid state found - starting fresh")
1181 state = SprintState(
1182 sprint_name=args.sprint,
1183 started_at=datetime.now().isoformat(),
1184 )
1185 else:
1186 # Fresh start - delete any old state
1187 _cleanup_sprint_state(logger)
1188 state = SprintState(
1189 sprint_name=args.sprint,
1190 started_at=datetime.now().isoformat(),
1191 )
1193 # Track exit status for error handling (ENH-185)
1194 exit_code = 0
1196 try:
1197 # Determine max workers
1198 max_workers = args.max_workers or (sprint.options.max_workers if sprint.options else 2)
1200 # Execute wave by wave
1201 completed: set[str] = set(state.completed_issues)
1202 failed_waves = 0
1203 total_duration = 0.0
1204 total_waves = len(waves)
1206 for wave_num, wave in enumerate(waves, 1):
1207 # Check for shutdown request (ENH-183)
1208 if _sprint_shutdown_requested:
1209 logger.warning("Shutdown requested - saving state and exiting")
1210 _save_sprint_state(state, logger)
1211 exit_code = 1
1212 return exit_code
1214 # Skip already-completed waves when resuming
1215 if wave_num < start_wave:
1216 continue
1218 wave_ids = [issue.issue_id for issue in wave]
1219 state.current_wave = wave_num
1220 logger.info(f"\nProcessing wave {wave_num}/{total_waves}: {', '.join(wave_ids)}")
1222 if len(wave) == 1:
1223 # Single issue — process in-place (no worktree overhead)
1224 from little_loops.issue_manager import process_issue_inplace
1226 issue_result = process_issue_inplace(
1227 info=wave[0],
1228 config=config,
1229 logger=logger,
1230 dry_run=args.dry_run,
1231 )
1232 total_duration += issue_result.duration
1233 if issue_result.success:
1234 completed.update(wave_ids)
1235 state.completed_issues.extend(wave_ids)
1236 state.timing[wave_ids[0]] = {"total": issue_result.duration}
1237 logger.success(f"Wave {wave_num}/{total_waves} completed: {wave_ids[0]}")
1238 else:
1239 failed_waves += 1
1240 completed.update(wave_ids)
1241 state.completed_issues.extend(wave_ids)
1242 state.failed_issues[wave_ids[0]] = "Issue processing failed"
1243 logger.warning(f"Wave {wave_num}/{total_waves} had failures")
1244 _save_sprint_state(state, logger)
1245 if wave_num < total_waves:
1246 logger.info(f"Continuing to wave {wave_num + 1}/{total_waves}...")
1247 # Check for shutdown before next wave (ENH-183)
1248 if _sprint_shutdown_requested:
1249 logger.warning("Shutdown requested - exiting after wave completion")
1250 exit_code = 1
1251 return exit_code
1252 else:
1253 # Multi-issue — use ParallelOrchestrator with worktrees
1254 only_ids = set(wave_ids)
1255 parallel_config = config.create_parallel_config(
1256 max_workers=min(max_workers, len(wave)),
1257 only_ids=only_ids,
1258 dry_run=args.dry_run,
1259 overlap_detection=True,
1260 serialize_overlapping=True,
1261 )
1263 orchestrator = ParallelOrchestrator(
1264 parallel_config, config, Path.cwd(), wave_label=f"Wave {wave_num}/{total_waves}"
1265 )
1266 result = orchestrator.run()
1267 total_duration += orchestrator.execution_duration
1269 # Track completed/failed from this wave using per-issue results
1270 actually_completed = set(orchestrator.queue.completed_ids)
1271 actually_failed = set(orchestrator.queue.failed_ids)
1273 for issue_id in wave_ids:
1274 if issue_id in actually_completed:
1275 completed.add(issue_id)
1276 state.completed_issues.append(issue_id)
1277 state.timing[issue_id] = {
1278 "total": orchestrator.execution_duration / len(wave)
1279 }
1280 elif issue_id in actually_failed:
1281 completed.add(issue_id)
1282 state.completed_issues.append(issue_id)
1283 state.failed_issues[issue_id] = "Issue failed during wave execution"
1284 # else: issue was neither completed nor failed (interrupted/stranded)
1285 # — leave untracked so it can be retried on resume
1287 # Sequential retry for failed issues (ENH-308)
1288 if actually_failed:
1289 logger.info(f"Retrying {len(actually_failed)} failed issue(s) sequentially...")
1290 from little_loops.issue_manager import process_issue_inplace
1292 retried_ok = 0
1293 for issue in wave:
1294 if issue.issue_id not in actually_failed:
1295 continue
1296 logger.info(f" Retrying {issue.issue_id} in-place...")
1297 retry_result = process_issue_inplace(
1298 info=issue,
1299 config=config,
1300 logger=logger,
1301 dry_run=args.dry_run,
1302 )
1303 total_duration += retry_result.duration
1304 if retry_result.success:
1305 retried_ok += 1
1306 state.failed_issues.pop(issue.issue_id, None)
1307 state.timing[issue.issue_id] = {"total": retry_result.duration}
1308 logger.success(f" Retry succeeded: {issue.issue_id}")
1309 else:
1310 logger.warning(f" Retry failed: {issue.issue_id}")
1311 if retried_ok > 0:
1312 logger.info(
1313 f"Sequential retry recovered {retried_ok}/{len(actually_failed)} issue(s)"
1314 )
1316 # Check whether failures remain after retry (ENH-308)
1317 remaining_failures = {iid for iid in actually_failed if iid in state.failed_issues}
1318 if result == 0 or not remaining_failures:
1319 logger.success(
1320 f"Wave {wave_num}/{total_waves} completed: {', '.join(wave_ids)}"
1321 )
1322 else:
1323 failed_waves += 1
1324 logger.warning(f"Wave {wave_num}/{total_waves} had failures")
1325 _save_sprint_state(state, logger)
1326 if wave_num < total_waves:
1327 logger.info(f"Continuing to wave {wave_num + 1}/{total_waves}...")
1328 # Check for shutdown before next wave (ENH-183)
1329 if _sprint_shutdown_requested:
1330 logger.warning("Shutdown requested - exiting after wave completion")
1331 exit_code = 1
1332 return exit_code
1334 wave_word = "wave" if len(waves) == 1 else "waves"
1335 logger.info(
1336 f"\nSprint completed: {len(completed)} issues processed ({len(waves)} {wave_word})"
1337 )
1338 logger.timing(f"Total execution time: {format_duration(total_duration)}")
1339 if failed_waves > 0:
1340 logger.warning(f"{failed_waves} wave(s) had failures")
1341 exit_code = 1
1342 else:
1343 # Clean up state on successful completion
1344 _cleanup_sprint_state(logger)
1346 except KeyboardInterrupt:
1347 # Belt-and-suspenders with signal handler (ENH-185)
1348 logger.warning("Sprint interrupted by user (KeyboardInterrupt)")
1349 exit_code = 130
1351 except Exception as e:
1352 # Catch unexpected exceptions (ENH-185)
1353 logger.error(f"Sprint failed unexpectedly: {e}")
1354 exit_code = 1
1356 finally:
1357 # Guaranteed state save on any non-success exit (ENH-185)
1358 if exit_code != 0:
1359 _save_sprint_state(state, logger)
1360 logger.info("State saved before exit")
1362 return exit_code