doit-toolkit-cli 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. doit_cli/__init__.py +1356 -0
  2. doit_cli/cli/__init__.py +26 -0
  3. doit_cli/cli/analytics_command.py +616 -0
  4. doit_cli/cli/context_command.py +213 -0
  5. doit_cli/cli/diagram_command.py +304 -0
  6. doit_cli/cli/fixit_command.py +641 -0
  7. doit_cli/cli/hooks_command.py +211 -0
  8. doit_cli/cli/init_command.py +613 -0
  9. doit_cli/cli/memory_command.py +293 -0
  10. doit_cli/cli/status_command.py +117 -0
  11. doit_cli/cli/sync_prompts_command.py +248 -0
  12. doit_cli/cli/validate_command.py +196 -0
  13. doit_cli/cli/verify_command.py +204 -0
  14. doit_cli/cli/workflow_mixin.py +224 -0
  15. doit_cli/cli/xref_command.py +555 -0
  16. doit_cli/formatters/__init__.py +8 -0
  17. doit_cli/formatters/base.py +38 -0
  18. doit_cli/formatters/json_formatter.py +126 -0
  19. doit_cli/formatters/markdown_formatter.py +97 -0
  20. doit_cli/formatters/rich_formatter.py +257 -0
  21. doit_cli/main.py +49 -0
  22. doit_cli/models/__init__.py +139 -0
  23. doit_cli/models/agent.py +74 -0
  24. doit_cli/models/analytics_models.py +384 -0
  25. doit_cli/models/context_config.py +464 -0
  26. doit_cli/models/crossref_models.py +182 -0
  27. doit_cli/models/diagram_models.py +363 -0
  28. doit_cli/models/fixit_models.py +355 -0
  29. doit_cli/models/hook_config.py +125 -0
  30. doit_cli/models/project.py +91 -0
  31. doit_cli/models/results.py +121 -0
  32. doit_cli/models/search_models.py +228 -0
  33. doit_cli/models/status_models.py +195 -0
  34. doit_cli/models/sync_models.py +146 -0
  35. doit_cli/models/template.py +77 -0
  36. doit_cli/models/validation_models.py +175 -0
  37. doit_cli/models/workflow_models.py +319 -0
  38. doit_cli/prompts/__init__.py +5 -0
  39. doit_cli/prompts/fixit_prompts.py +344 -0
  40. doit_cli/prompts/interactive.py +390 -0
  41. doit_cli/rules/__init__.py +5 -0
  42. doit_cli/rules/builtin_rules.py +160 -0
  43. doit_cli/services/__init__.py +79 -0
  44. doit_cli/services/agent_detector.py +168 -0
  45. doit_cli/services/analytics_service.py +218 -0
  46. doit_cli/services/architecture_generator.py +290 -0
  47. doit_cli/services/backup_service.py +204 -0
  48. doit_cli/services/config_loader.py +113 -0
  49. doit_cli/services/context_loader.py +1121 -0
  50. doit_cli/services/coverage_calculator.py +142 -0
  51. doit_cli/services/crossref_service.py +237 -0
  52. doit_cli/services/cycle_time_calculator.py +134 -0
  53. doit_cli/services/date_inferrer.py +349 -0
  54. doit_cli/services/diagram_service.py +337 -0
  55. doit_cli/services/drift_detector.py +109 -0
  56. doit_cli/services/entity_parser.py +301 -0
  57. doit_cli/services/er_diagram_generator.py +197 -0
  58. doit_cli/services/fixit_service.py +699 -0
  59. doit_cli/services/github_service.py +192 -0
  60. doit_cli/services/hook_manager.py +258 -0
  61. doit_cli/services/hook_validator.py +528 -0
  62. doit_cli/services/input_validator.py +322 -0
  63. doit_cli/services/memory_search.py +527 -0
  64. doit_cli/services/mermaid_validator.py +334 -0
  65. doit_cli/services/prompt_transformer.py +91 -0
  66. doit_cli/services/prompt_writer.py +133 -0
  67. doit_cli/services/query_interpreter.py +428 -0
  68. doit_cli/services/report_exporter.py +219 -0
  69. doit_cli/services/report_generator.py +256 -0
  70. doit_cli/services/requirement_parser.py +112 -0
  71. doit_cli/services/roadmap_summarizer.py +209 -0
  72. doit_cli/services/rule_engine.py +443 -0
  73. doit_cli/services/scaffolder.py +215 -0
  74. doit_cli/services/score_calculator.py +172 -0
  75. doit_cli/services/section_parser.py +204 -0
  76. doit_cli/services/spec_scanner.py +327 -0
  77. doit_cli/services/state_manager.py +355 -0
  78. doit_cli/services/status_reporter.py +143 -0
  79. doit_cli/services/task_parser.py +347 -0
  80. doit_cli/services/template_manager.py +710 -0
  81. doit_cli/services/template_reader.py +158 -0
  82. doit_cli/services/user_journey_generator.py +214 -0
  83. doit_cli/services/user_story_parser.py +232 -0
  84. doit_cli/services/validation_service.py +188 -0
  85. doit_cli/services/validator.py +232 -0
  86. doit_cli/services/velocity_tracker.py +173 -0
  87. doit_cli/services/workflow_engine.py +405 -0
  88. doit_cli/templates/agent-file-template.md +28 -0
  89. doit_cli/templates/checklist-template.md +39 -0
  90. doit_cli/templates/commands/doit.checkin.md +363 -0
  91. doit_cli/templates/commands/doit.constitution.md +187 -0
  92. doit_cli/templates/commands/doit.documentit.md +485 -0
  93. doit_cli/templates/commands/doit.fixit.md +181 -0
  94. doit_cli/templates/commands/doit.implementit.md +265 -0
  95. doit_cli/templates/commands/doit.planit.md +262 -0
  96. doit_cli/templates/commands/doit.reviewit.md +355 -0
  97. doit_cli/templates/commands/doit.roadmapit.md +368 -0
  98. doit_cli/templates/commands/doit.scaffoldit.md +458 -0
  99. doit_cli/templates/commands/doit.specit.md +521 -0
  100. doit_cli/templates/commands/doit.taskit.md +304 -0
  101. doit_cli/templates/commands/doit.testit.md +277 -0
  102. doit_cli/templates/config/context.yaml +134 -0
  103. doit_cli/templates/config/hooks.yaml +93 -0
  104. doit_cli/templates/config/validation-rules.yaml +64 -0
  105. doit_cli/templates/github-issue-templates/epic.yml +78 -0
  106. doit_cli/templates/github-issue-templates/feature.yml +116 -0
  107. doit_cli/templates/github-issue-templates/task.yml +129 -0
  108. doit_cli/templates/hooks/.gitkeep +0 -0
  109. doit_cli/templates/hooks/post-commit.sh +25 -0
  110. doit_cli/templates/hooks/post-merge.sh +75 -0
  111. doit_cli/templates/hooks/pre-commit.sh +17 -0
  112. doit_cli/templates/hooks/pre-push.sh +18 -0
  113. doit_cli/templates/memory/completed_roadmap.md +50 -0
  114. doit_cli/templates/memory/constitution.md +125 -0
  115. doit_cli/templates/memory/roadmap.md +61 -0
  116. doit_cli/templates/plan-template.md +146 -0
  117. doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
  118. doit_cli/templates/scripts/bash/common.sh +156 -0
  119. doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
  120. doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
  121. doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
  122. doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
  123. doit_cli/templates/scripts/powershell/common.ps1 +137 -0
  124. doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
  125. doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
  126. doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
  127. doit_cli/templates/spec-template.md +159 -0
  128. doit_cli/templates/tasks-template.md +313 -0
  129. doit_cli/templates/vscode-settings.json +14 -0
  130. doit_toolkit_cli-0.1.9.dist-info/METADATA +324 -0
  131. doit_toolkit_cli-0.1.9.dist-info/RECORD +134 -0
  132. doit_toolkit_cli-0.1.9.dist-info/WHEEL +4 -0
  133. doit_toolkit_cli-0.1.9.dist-info/entry_points.txt +2 -0
  134. doit_toolkit_cli-0.1.9.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,142 @@
1
+ """Calculator for requirement coverage metrics."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from ..models.crossref_models import (
7
+ CoverageReport,
8
+ Requirement,
9
+ RequirementCoverage,
10
+ Task,
11
+ )
12
+ from .requirement_parser import RequirementParser
13
+ from .task_parser import TaskParser
14
+
15
+
16
+ class CoverageCalculator:
17
+ """Calculates coverage metrics for requirements.
18
+
19
+ This service matches requirements from spec.md to tasks from tasks.md
20
+ and computes coverage percentages and identifies gaps.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ requirement_parser: Optional[RequirementParser] = None,
26
+ task_parser: Optional[TaskParser] = None,
27
+ ) -> None:
28
+ """Initialize calculator with optional parsers.
29
+
30
+ Args:
31
+ requirement_parser: Parser for spec.md. Created if not provided.
32
+ task_parser: Parser for tasks.md. Created if not provided.
33
+ """
34
+ self.requirement_parser = requirement_parser or RequirementParser()
35
+ self.task_parser = task_parser or TaskParser()
36
+
37
+ def calculate(
38
+ self,
39
+ spec_path: Path,
40
+ tasks_path: Optional[Path] = None,
41
+ ) -> CoverageReport:
42
+ """Calculate coverage for a specification.
43
+
44
+ Args:
45
+ spec_path: Path to spec.md file.
46
+ tasks_path: Path to tasks.md file. Defaults to tasks.md in same directory.
47
+
48
+ Returns:
49
+ CoverageReport with all coverage metrics.
50
+
51
+ Raises:
52
+ FileNotFoundError: If spec.md doesn't exist.
53
+ """
54
+ spec_path = Path(spec_path)
55
+ if not spec_path.exists():
56
+ raise FileNotFoundError(f"Spec file not found: {spec_path}")
57
+
58
+ # Default tasks path to same directory as spec
59
+ if tasks_path is None:
60
+ tasks_path = spec_path.parent / "tasks.md"
61
+
62
+ # Parse requirements from spec
63
+ requirements = self.requirement_parser.parse(spec_path)
64
+
65
+ # Parse tasks (empty list if tasks.md doesn't exist)
66
+ tasks: list[Task] = []
67
+ if tasks_path.exists():
68
+ tasks = self.task_parser.parse(tasks_path)
69
+
70
+ # Build coverage data
71
+ return self._build_coverage_report(
72
+ spec_path=str(spec_path),
73
+ requirements=requirements,
74
+ tasks=tasks,
75
+ )
76
+
77
+ def _build_coverage_report(
78
+ self,
79
+ spec_path: str,
80
+ requirements: list[Requirement],
81
+ tasks: list[Task],
82
+ ) -> CoverageReport:
83
+ """Build a CoverageReport from parsed data.
84
+
85
+ Args:
86
+ spec_path: Path to spec.md file.
87
+ requirements: List of requirements from spec.
88
+ tasks: List of tasks from tasks.md.
89
+
90
+ Returns:
91
+ CoverageReport with requirement-task mappings.
92
+ """
93
+ # Build map of requirement ID -> requirement
94
+ req_map: dict[str, Requirement] = {r.id: r for r in requirements}
95
+
96
+ # Build requirement coverage entries
97
+ coverage_list: list[RequirementCoverage] = []
98
+ for req in requirements:
99
+ # Find tasks that reference this requirement
100
+ matching_tasks = [t for t in tasks if req.id in t.requirement_ids]
101
+ coverage_list.append(
102
+ RequirementCoverage(
103
+ requirement=req,
104
+ tasks=matching_tasks,
105
+ )
106
+ )
107
+
108
+ # Find orphaned references (tasks referencing non-existent requirements)
109
+ orphaned: list[tuple[Task, str]] = []
110
+ for task in tasks:
111
+ for ref in task.references:
112
+ if ref.requirement_id not in req_map:
113
+ orphaned.append((task, ref.requirement_id))
114
+
115
+ return CoverageReport(
116
+ spec_path=spec_path,
117
+ requirements=coverage_list,
118
+ orphaned_references=orphaned,
119
+ )
120
+
121
+ def calculate_for_feature(
122
+ self,
123
+ feature_dir: Path,
124
+ ) -> CoverageReport:
125
+ """Calculate coverage for a feature directory.
126
+
127
+ Convenience method that finds spec.md and tasks.md in the feature directory.
128
+
129
+ Args:
130
+ feature_dir: Path to feature directory (e.g., specs/033-my-feature/)
131
+
132
+ Returns:
133
+ CoverageReport for the feature.
134
+
135
+ Raises:
136
+ FileNotFoundError: If spec.md doesn't exist in feature directory.
137
+ """
138
+ feature_dir = Path(feature_dir)
139
+ spec_path = feature_dir / "spec.md"
140
+ tasks_path = feature_dir / "tasks.md"
141
+
142
+ return self.calculate(spec_path, tasks_path)
@@ -0,0 +1,237 @@
1
+ """Cross-reference service for spec-task traceability."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from ..models.crossref_models import (
7
+ CoverageReport,
8
+ Requirement,
9
+ RequirementCoverage,
10
+ Task,
11
+ )
12
+ from .coverage_calculator import CoverageCalculator
13
+ from .requirement_parser import RequirementParser
14
+ from .task_parser import TaskParser
15
+
16
+
17
+ class CrossReferenceService:
18
+ """Service for managing cross-references between specs and tasks.
19
+
20
+ This service provides a unified API for:
21
+ - Getting coverage reports
22
+ - Finding tasks for requirements
23
+ - Finding requirements for tasks
24
+ - Locating requirement definitions
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ project_root: Optional[Path] = None,
30
+ specs_dir: str = "specs",
31
+ ) -> None:
32
+ """Initialize the service.
33
+
34
+ Args:
35
+ project_root: Root directory of the project. Defaults to cwd.
36
+ specs_dir: Name of the specs directory.
37
+ """
38
+ self.project_root = Path(project_root) if project_root else Path.cwd()
39
+ self.specs_dir = specs_dir
40
+ self.requirement_parser = RequirementParser()
41
+ self.task_parser = TaskParser()
42
+ self.coverage_calculator = CoverageCalculator(
43
+ self.requirement_parser,
44
+ self.task_parser,
45
+ )
46
+
47
+ def get_coverage(
48
+ self,
49
+ spec_name: Optional[str] = None,
50
+ spec_path: Optional[Path] = None,
51
+ ) -> CoverageReport:
52
+ """Get coverage report for a specification.
53
+
54
+ Args:
55
+ spec_name: Name of spec directory (e.g., "033-my-feature").
56
+ spec_path: Direct path to spec.md. Overrides spec_name.
57
+
58
+ Returns:
59
+ CoverageReport for the specification.
60
+
61
+ Raises:
62
+ FileNotFoundError: If spec doesn't exist.
63
+ ValueError: If neither spec_name nor spec_path provided.
64
+ """
65
+ if spec_path:
66
+ return self.coverage_calculator.calculate(spec_path)
67
+
68
+ if spec_name:
69
+ feature_dir = self.project_root / self.specs_dir / spec_name
70
+ return self.coverage_calculator.calculate_for_feature(feature_dir)
71
+
72
+ raise ValueError("Either spec_name or spec_path must be provided")
73
+
74
+ def get_tasks_for_requirement(
75
+ self,
76
+ requirement_id: str,
77
+ spec_name: Optional[str] = None,
78
+ tasks_path: Optional[Path] = None,
79
+ ) -> list[Task]:
80
+ """Get all tasks that implement a specific requirement.
81
+
82
+ Args:
83
+ requirement_id: The FR-XXX ID to search for.
84
+ spec_name: Name of spec directory.
85
+ tasks_path: Direct path to tasks.md.
86
+
87
+ Returns:
88
+ List of tasks that reference the requirement.
89
+
90
+ Raises:
91
+ ValueError: If neither spec_name nor tasks_path provided.
92
+ """
93
+ if tasks_path:
94
+ return self.task_parser.get_tasks_for_requirement(
95
+ requirement_id, tasks_path
96
+ )
97
+
98
+ if spec_name:
99
+ feature_dir = self.project_root / self.specs_dir / spec_name
100
+ tasks_file = feature_dir / "tasks.md"
101
+ if not tasks_file.exists():
102
+ return []
103
+ return self.task_parser.get_tasks_for_requirement(
104
+ requirement_id, tasks_file
105
+ )
106
+
107
+ raise ValueError("Either spec_name or tasks_path must be provided")
108
+
109
+ def locate_requirement(
110
+ self,
111
+ requirement_id: str,
112
+ spec_name: Optional[str] = None,
113
+ spec_path: Optional[Path] = None,
114
+ ) -> Optional[Requirement]:
115
+ """Find a requirement definition in a spec.
116
+
117
+ Args:
118
+ requirement_id: The FR-XXX ID to find.
119
+ spec_name: Name of spec directory.
120
+ spec_path: Direct path to spec.md.
121
+
122
+ Returns:
123
+ Requirement if found, None otherwise.
124
+
125
+ Raises:
126
+ ValueError: If neither spec_name nor spec_path provided.
127
+ """
128
+ if spec_path:
129
+ return self.requirement_parser.get_requirement(requirement_id, spec_path)
130
+
131
+ if spec_name:
132
+ feature_dir = self.project_root / self.specs_dir / spec_name
133
+ spec_file = feature_dir / "spec.md"
134
+ if not spec_file.exists():
135
+ return None
136
+ return self.requirement_parser.get_requirement(requirement_id, spec_file)
137
+
138
+ raise ValueError("Either spec_name or spec_path must be provided")
139
+
140
+ def get_requirement_coverage(
141
+ self,
142
+ requirement_id: str,
143
+ spec_name: Optional[str] = None,
144
+ spec_path: Optional[Path] = None,
145
+ ) -> Optional[RequirementCoverage]:
146
+ """Get coverage details for a specific requirement.
147
+
148
+ Args:
149
+ requirement_id: The FR-XXX ID to check.
150
+ spec_name: Name of spec directory.
151
+ spec_path: Direct path to spec.md.
152
+
153
+ Returns:
154
+ RequirementCoverage if requirement exists, None otherwise.
155
+ """
156
+ report = self.get_coverage(spec_name=spec_name, spec_path=spec_path)
157
+ return report.get_requirement_coverage(requirement_id)
158
+
159
+ def validate_references(
160
+ self,
161
+ spec_name: Optional[str] = None,
162
+ spec_path: Optional[Path] = None,
163
+ ) -> tuple[list[str], list[tuple[Task, str]]]:
164
+ """Validate cross-references and find issues.
165
+
166
+ Args:
167
+ spec_name: Name of spec directory.
168
+ spec_path: Direct path to spec.md.
169
+
170
+ Returns:
171
+ Tuple of (uncovered_requirement_ids, orphaned_references).
172
+ orphaned_references is list of (task, invalid_requirement_id) tuples.
173
+ """
174
+ report = self.get_coverage(spec_name=spec_name, spec_path=spec_path)
175
+
176
+ # Find uncovered requirements
177
+ uncovered = [r.id for r in report.get_uncovered_requirements()]
178
+
179
+ return uncovered, report.orphaned_references
180
+
181
+ def get_all_requirements(
182
+ self,
183
+ spec_name: Optional[str] = None,
184
+ spec_path: Optional[Path] = None,
185
+ ) -> list[Requirement]:
186
+ """Get all requirements from a spec.
187
+
188
+ Args:
189
+ spec_name: Name of spec directory.
190
+ spec_path: Direct path to spec.md.
191
+
192
+ Returns:
193
+ List of all requirements in the spec.
194
+
195
+ Raises:
196
+ ValueError: If neither spec_name nor spec_path provided.
197
+ """
198
+ if spec_path:
199
+ return self.requirement_parser.parse(spec_path)
200
+
201
+ if spec_name:
202
+ feature_dir = self.project_root / self.specs_dir / spec_name
203
+ spec_file = feature_dir / "spec.md"
204
+ return self.requirement_parser.parse(spec_file)
205
+
206
+ raise ValueError("Either spec_name or spec_path must be provided")
207
+
208
+ def get_all_tasks(
209
+ self,
210
+ spec_name: Optional[str] = None,
211
+ tasks_path: Optional[Path] = None,
212
+ ) -> list[Task]:
213
+ """Get all tasks from a tasks file.
214
+
215
+ Args:
216
+ spec_name: Name of spec directory.
217
+ tasks_path: Direct path to tasks.md.
218
+
219
+ Returns:
220
+ List of all tasks. Empty list if tasks.md doesn't exist.
221
+
222
+ Raises:
223
+ ValueError: If neither spec_name nor tasks_path provided.
224
+ """
225
+ if tasks_path:
226
+ if not tasks_path.exists():
227
+ return []
228
+ return self.task_parser.parse(tasks_path)
229
+
230
+ if spec_name:
231
+ feature_dir = self.project_root / self.specs_dir / spec_name
232
+ tasks_file = feature_dir / "tasks.md"
233
+ if not tasks_file.exists():
234
+ return []
235
+ return self.task_parser.parse(tasks_file)
236
+
237
+ raise ValueError("Either spec_name or tasks_path must be provided")
@@ -0,0 +1,134 @@
1
+ """Cycle time calculator service for spec analytics.
2
+
3
+ Provides statistical analysis of spec completion cycle times.
4
+ """
5
+
6
+ from datetime import date, timedelta
7
+ from typing import Optional
8
+
9
+ from ..models.analytics_models import (
10
+ CycleTimeRecord,
11
+ CycleTimeStats,
12
+ SpecMetadata,
13
+ )
14
+
15
+
16
+ class CycleTimeCalculator:
17
+ """Calculator for cycle time statistics.
18
+
19
+ Processes SpecMetadata to extract cycle time records and
20
+ calculate statistical summaries.
21
+ """
22
+
23
+ def __init__(self, specs: list[SpecMetadata]):
24
+ """Initialize calculator with spec metadata.
25
+
26
+ Args:
27
+ specs: List of SpecMetadata objects to analyze
28
+ """
29
+ self.specs = specs
30
+ self._records: Optional[list[CycleTimeRecord]] = None
31
+
32
+ @property
33
+ def records(self) -> list[CycleTimeRecord]:
34
+ """Get all cycle time records from completed specs.
35
+
36
+ Returns:
37
+ List of CycleTimeRecord for specs with both dates
38
+ """
39
+ if self._records is None:
40
+ self._records = []
41
+ for spec in self.specs:
42
+ record = CycleTimeRecord.from_metadata(spec)
43
+ if record:
44
+ self._records.append(record)
45
+ return self._records
46
+
47
+ def calculate_stats(
48
+ self,
49
+ days: Optional[int] = None,
50
+ since: Optional[date] = None,
51
+ ) -> Optional[CycleTimeStats]:
52
+ """Calculate cycle time statistics.
53
+
54
+ Args:
55
+ days: Filter to specs completed in last N days
56
+ since: Filter to specs completed since this date
57
+
58
+ Returns:
59
+ CycleTimeStats or None if no matching records
60
+ """
61
+ filtered = self.filter_records(days=days, since=since)
62
+ return CycleTimeStats.calculate(filtered)
63
+
64
+ def filter_records(
65
+ self,
66
+ days: Optional[int] = None,
67
+ since: Optional[date] = None,
68
+ ) -> list[CycleTimeRecord]:
69
+ """Filter cycle time records by time period.
70
+
71
+ Args:
72
+ days: Filter to specs completed in last N days
73
+ since: Filter to specs completed since this date (overrides days)
74
+
75
+ Returns:
76
+ Filtered list of CycleTimeRecord objects
77
+ """
78
+ records = self.records
79
+
80
+ if since is not None:
81
+ records = [r for r in records if r.end_date >= since]
82
+ elif days is not None:
83
+ cutoff = date.today() - timedelta(days=days)
84
+ records = [r for r in records if r.end_date >= cutoff]
85
+
86
+ # Sort by end date (most recent first)
87
+ return sorted(records, key=lambda r: r.end_date, reverse=True)
88
+
89
+ def get_recent_completions(self, limit: int = 10) -> list[CycleTimeRecord]:
90
+ """Get most recently completed specs.
91
+
92
+ Args:
93
+ limit: Maximum number of records to return
94
+
95
+ Returns:
96
+ List of CycleTimeRecord sorted by completion date
97
+ """
98
+ sorted_records = sorted(
99
+ self.records,
100
+ key=lambda r: r.end_date,
101
+ reverse=True,
102
+ )
103
+ return sorted_records[:limit]
104
+
105
+ def get_slowest_completions(self, limit: int = 5) -> list[CycleTimeRecord]:
106
+ """Get specs with longest cycle times.
107
+
108
+ Args:
109
+ limit: Maximum number of records to return
110
+
111
+ Returns:
112
+ List of CycleTimeRecord sorted by cycle time descending
113
+ """
114
+ sorted_records = sorted(
115
+ self.records,
116
+ key=lambda r: r.days_to_complete,
117
+ reverse=True,
118
+ )
119
+ return sorted_records[:limit]
120
+
121
+ def get_fastest_completions(self, limit: int = 5) -> list[CycleTimeRecord]:
122
+ """Get specs with shortest cycle times.
123
+
124
+ Args:
125
+ limit: Maximum number of records to return
126
+
127
+ Returns:
128
+ List of CycleTimeRecord sorted by cycle time ascending
129
+ """
130
+ sorted_records = sorted(
131
+ self.records,
132
+ key=lambda r: r.days_to_complete,
133
+ )
134
+ return sorted_records[:limit]