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,464 @@
1
+ """Context configuration models for AI context injection.
2
+
3
+ This module provides dataclasses for configuring and managing context loading
4
+ for doit commands. Context includes constitution, roadmap, current spec,
5
+ and related specifications.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import date, datetime
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import yaml
14
+
15
+
16
+ @dataclass
17
+ class SummarizationConfig:
18
+ """Configuration for context summarization behavior.
19
+
20
+ Attributes:
21
+ enabled: Whether summarization features are enabled
22
+ threshold_percentage: Percentage of total_max_tokens that triggers condensation
23
+ source_priorities: Order of priority for preserving content during condensation
24
+ timeout_seconds: Timeout for AI summarization API calls (if used)
25
+ fallback_to_truncation: Whether to fall back to truncation on AI failure
26
+ completed_items_max_count: Maximum completed items to include
27
+ completed_items_min_relevance: Minimum relevance score for completed items
28
+ """
29
+
30
+ enabled: bool = True
31
+ threshold_percentage: float = 80.0
32
+ source_priorities: list[str] = field(
33
+ default_factory=lambda: ["constitution", "roadmap", "completed_roadmap", "current_spec"]
34
+ )
35
+ timeout_seconds: float = 10.0
36
+ fallback_to_truncation: bool = True
37
+ completed_items_max_count: int = 5
38
+ completed_items_min_relevance: float = 0.3
39
+
40
+ def __post_init__(self) -> None:
41
+ """Validate configuration after initialization."""
42
+ if self.threshold_percentage < 50.0:
43
+ self.threshold_percentage = 50.0
44
+ if self.threshold_percentage > 100.0:
45
+ self.threshold_percentage = 100.0
46
+ if self.timeout_seconds <= 0:
47
+ self.timeout_seconds = 10.0
48
+
49
+
50
+ @dataclass
51
+ class RoadmapItem:
52
+ """Represents a single item from roadmap.md.
53
+
54
+ Attributes:
55
+ text: The item text/description
56
+ priority: Priority level (P1, P2, P3, P4)
57
+ rationale: Optional rationale/reason for the item
58
+ feature_ref: Optional feature branch reference (e.g., '034-fixit-workflow')
59
+ completed: Whether the item is marked as completed
60
+ """
61
+
62
+ text: str
63
+ priority: str = "P4"
64
+ rationale: str = ""
65
+ feature_ref: str = ""
66
+ completed: bool = False
67
+
68
+
69
+ @dataclass
70
+ class CompletedItem:
71
+ """Represents a matched item from completed_roadmap.md.
72
+
73
+ Attributes:
74
+ text: The completed item description
75
+ priority: Original priority when active
76
+ completion_date: Date the item was completed
77
+ feature_branch: Feature branch that implemented it
78
+ relevance_score: Similarity score for matching (0.0 - 1.0)
79
+ """
80
+
81
+ text: str
82
+ priority: str = ""
83
+ completion_date: Optional[date] = None
84
+ feature_branch: str = ""
85
+ relevance_score: float = 0.0
86
+
87
+
88
+ @dataclass
89
+ class RoadmapSummary:
90
+ """Condensed representation of the roadmap for context injection.
91
+
92
+ Attributes:
93
+ condensed_text: Summarized markdown content
94
+ item_count: Number of items included in summary
95
+ priorities_included: Which priority levels were included
96
+ """
97
+
98
+ condensed_text: str
99
+ item_count: int = 0
100
+ priorities_included: list[str] = field(default_factory=list)
101
+
102
+
103
+ @dataclass
104
+ class SourceConfig:
105
+ """Configuration for an individual context source type.
106
+
107
+ Attributes:
108
+ source_type: Type identifier (constitution, roadmap, current_spec, related_specs, custom)
109
+ enabled: Whether this source should be loaded
110
+ priority: Loading priority (lower = higher priority, loaded first)
111
+ max_count: Maximum items for multi-item sources (e.g., related_specs)
112
+ """
113
+
114
+ source_type: str = ""
115
+ enabled: bool = True
116
+ priority: int = 99
117
+ max_count: int = 1
118
+
119
+ @classmethod
120
+ def default_sources(cls) -> dict[str, "SourceConfig"]:
121
+ """Get default source configurations."""
122
+ return {
123
+ "constitution": cls(source_type="constitution", enabled=True, priority=1),
124
+ "roadmap": cls(source_type="roadmap", enabled=True, priority=2),
125
+ "completed_roadmap": cls(source_type="completed_roadmap", enabled=True, priority=3, max_count=5),
126
+ "current_spec": cls(source_type="current_spec", enabled=True, priority=4),
127
+ "related_specs": cls(source_type="related_specs", enabled=True, priority=5, max_count=3),
128
+ }
129
+
130
+
131
+ @dataclass
132
+ class CommandOverride:
133
+ """Per-command configuration overrides.
134
+
135
+ Attributes:
136
+ command_name: Command to override (specit, planit, etc.)
137
+ sources: Source configuration overrides for this command
138
+ """
139
+
140
+ command_name: str = ""
141
+ sources: dict[str, SourceConfig] = field(default_factory=dict)
142
+
143
+
144
+ @dataclass
145
+ class ContextConfig:
146
+ """Configuration for context loading behavior.
147
+
148
+ Loaded from `.doit/config/context.yaml`.
149
+
150
+ Attributes:
151
+ version: Config schema version (must be 1)
152
+ enabled: Master toggle for context loading
153
+ max_tokens_per_source: Token limit per individual source
154
+ total_max_tokens: Token limit for all context combined
155
+ sources: Per-source configuration
156
+ commands: Per-command overrides
157
+ summarization: Configuration for summarization behavior
158
+ """
159
+
160
+ version: int = 1
161
+ enabled: bool = True
162
+ max_tokens_per_source: int = 4000
163
+ total_max_tokens: int = 16000
164
+ sources: dict[str, SourceConfig] = field(default_factory=SourceConfig.default_sources)
165
+ commands: dict[str, CommandOverride] = field(default_factory=dict)
166
+ summarization: SummarizationConfig = field(default_factory=SummarizationConfig)
167
+
168
+ def __post_init__(self) -> None:
169
+ """Validate configuration after initialization."""
170
+ if self.max_tokens_per_source <= 0:
171
+ self.max_tokens_per_source = 4000
172
+ if self.total_max_tokens < self.max_tokens_per_source:
173
+ self.total_max_tokens = self.max_tokens_per_source
174
+
175
+ @classmethod
176
+ def default(cls) -> "ContextConfig":
177
+ """Create default configuration."""
178
+ return cls()
179
+
180
+ @classmethod
181
+ def from_yaml(cls, path: Path) -> "ContextConfig":
182
+ """Load configuration from YAML file.
183
+
184
+ Args:
185
+ path: Path to the context.yaml configuration file.
186
+
187
+ Returns:
188
+ ContextConfig instance with values from file or defaults.
189
+ """
190
+ if not path.exists():
191
+ return cls.default()
192
+
193
+ try:
194
+ with open(path, encoding="utf-8") as f:
195
+ data = yaml.safe_load(f) or {}
196
+ except (yaml.YAMLError, OSError):
197
+ # Return default config on parse error
198
+ return cls.default()
199
+
200
+ return cls._from_dict(data)
201
+
202
+ @classmethod
203
+ def _from_dict(cls, data: dict) -> "ContextConfig":
204
+ """Create ContextConfig from dictionary."""
205
+ # Parse sources
206
+ sources_data = data.get("sources", {})
207
+ sources = SourceConfig.default_sources()
208
+
209
+ for source_type, source_config in sources_data.items():
210
+ if isinstance(source_config, dict):
211
+ if source_type in sources:
212
+ # Update existing source config
213
+ for key, value in source_config.items():
214
+ if hasattr(sources[source_type], key):
215
+ setattr(sources[source_type], key, value)
216
+ else:
217
+ # Create new custom source
218
+ sources[source_type] = SourceConfig(
219
+ source_type=source_type,
220
+ **{k: v for k, v in source_config.items()
221
+ if k in SourceConfig.__dataclass_fields__}
222
+ )
223
+
224
+ # Parse command overrides
225
+ commands_data = data.get("commands", {})
226
+ commands: dict[str, CommandOverride] = {}
227
+
228
+ for cmd_name, cmd_config in commands_data.items():
229
+ if isinstance(cmd_config, dict):
230
+ cmd_sources: dict[str, SourceConfig] = {}
231
+ cmd_sources_data = cmd_config.get("sources", {})
232
+
233
+ for source_type, source_config in cmd_sources_data.items():
234
+ if isinstance(source_config, dict):
235
+ cmd_sources[source_type] = SourceConfig(
236
+ source_type=source_type,
237
+ **{k: v for k, v in source_config.items()
238
+ if k in SourceConfig.__dataclass_fields__}
239
+ )
240
+
241
+ commands[cmd_name] = CommandOverride(
242
+ command_name=cmd_name,
243
+ sources=cmd_sources
244
+ )
245
+
246
+ # Parse summarization config
247
+ summarization_data = data.get("summarization", {})
248
+ summarization = SummarizationConfig()
249
+ if isinstance(summarization_data, dict):
250
+ # Handle nested completed_items config
251
+ completed_items = summarization_data.get("completed_items", {})
252
+ if isinstance(completed_items, dict):
253
+ if "max_count" in completed_items:
254
+ summarization_data["completed_items_max_count"] = completed_items["max_count"]
255
+ if "min_relevance" in completed_items:
256
+ summarization_data["completed_items_min_relevance"] = completed_items["min_relevance"]
257
+
258
+ # Build SummarizationConfig from data
259
+ summarization = SummarizationConfig(
260
+ enabled=summarization_data.get("enabled", True),
261
+ threshold_percentage=summarization_data.get("threshold_percentage", 80.0),
262
+ source_priorities=summarization_data.get(
263
+ "source_priorities",
264
+ ["constitution", "roadmap", "completed_roadmap", "current_spec"]
265
+ ),
266
+ timeout_seconds=summarization_data.get("timeout_seconds", 10.0),
267
+ fallback_to_truncation=summarization_data.get("fallback_to_truncation", True),
268
+ completed_items_max_count=summarization_data.get("completed_items_max_count", 5),
269
+ completed_items_min_relevance=summarization_data.get("completed_items_min_relevance", 0.3),
270
+ )
271
+
272
+ return cls(
273
+ version=data.get("version", 1),
274
+ enabled=data.get("enabled", True),
275
+ max_tokens_per_source=data.get("max_tokens_per_source", 4000),
276
+ total_max_tokens=data.get("total_max_tokens", 16000),
277
+ sources=sources,
278
+ commands=commands,
279
+ summarization=summarization,
280
+ )
281
+
282
+ @classmethod
283
+ def get_default_config_path(cls) -> Path:
284
+ """Get the default configuration file path."""
285
+ return Path(".doit/config/context.yaml")
286
+
287
+ @classmethod
288
+ def load_default(cls) -> "ContextConfig":
289
+ """Load configuration from default location."""
290
+ return cls.from_yaml(cls.get_default_config_path())
291
+
292
+ @classmethod
293
+ def load_from_project(cls, project_root: Path) -> "ContextConfig":
294
+ """Load configuration from a project directory.
295
+
296
+ Args:
297
+ project_root: Root directory of the project.
298
+
299
+ Returns:
300
+ ContextConfig loaded from the project or defaults.
301
+ """
302
+ config_path = project_root / ".doit" / "config" / "context.yaml"
303
+ return cls.from_yaml(config_path)
304
+
305
+ def get_source_config(
306
+ self, source_type: str, command: Optional[str] = None
307
+ ) -> SourceConfig:
308
+ """Get effective source configuration, considering command overrides.
309
+
310
+ Args:
311
+ source_type: Type of source (constitution, roadmap, etc.)
312
+ command: Current command name for per-command overrides
313
+
314
+ Returns:
315
+ Effective SourceConfig for the given source and command.
316
+ """
317
+ # Start with base source config
318
+ base_config = self.sources.get(
319
+ source_type,
320
+ SourceConfig(source_type=source_type)
321
+ )
322
+
323
+ # Check for command-specific override
324
+ if command and command in self.commands:
325
+ cmd_override = self.commands[command]
326
+ if source_type in cmd_override.sources:
327
+ override = cmd_override.sources[source_type]
328
+ # Merge override with base (override takes precedence)
329
+ return SourceConfig(
330
+ source_type=source_type,
331
+ enabled=override.enabled,
332
+ priority=override.priority if override.priority != 99 else base_config.priority,
333
+ max_count=override.max_count if override.max_count != 1 else base_config.max_count,
334
+ )
335
+
336
+ return base_config
337
+
338
+
339
+ @dataclass
340
+ class ContextSource:
341
+ """A loaded context source ready for injection.
342
+
343
+ Attributes:
344
+ source_type: Type of source (constitution, roadmap, etc.)
345
+ path: File path that was loaded
346
+ content: Loaded content (possibly truncated)
347
+ token_count: Estimated token count
348
+ truncated: Whether content was truncated
349
+ original_tokens: Original token count before truncation (if truncated)
350
+ """
351
+
352
+ source_type: str
353
+ path: Path
354
+ content: str
355
+ token_count: int
356
+ truncated: bool = False
357
+ original_tokens: Optional[int] = None
358
+
359
+ def __lt__(self, other: "ContextSource") -> bool:
360
+ """Compare sources by their source type for sorting."""
361
+ return self.source_type < other.source_type
362
+
363
+
364
+ @dataclass
365
+ class LoadedContext:
366
+ """Aggregated context ready for injection into command prompt.
367
+
368
+ Attributes:
369
+ sources: All loaded sources, ordered by priority
370
+ total_tokens: Sum of all source token counts
371
+ any_truncated: True if any source was truncated
372
+ loaded_at: Timestamp when context was loaded
373
+ _guidance_prompt: Optional AI guidance prompt when context is condensed
374
+ """
375
+
376
+ sources: list[ContextSource] = field(default_factory=list)
377
+ total_tokens: int = 0
378
+ any_truncated: bool = False
379
+ loaded_at: datetime = field(default_factory=datetime.now)
380
+ _guidance_prompt: Optional[str] = None
381
+
382
+ def to_markdown(self) -> str:
383
+ """Format all sources as markdown for injection.
384
+
385
+ If a guidance prompt is set (due to context condensation), it will be
386
+ included at the start of the output to help the AI prioritize context.
387
+
388
+ Returns:
389
+ Markdown-formatted context suitable for AI consumption.
390
+ """
391
+ if not self.sources:
392
+ return ""
393
+
394
+ sections = ["<!-- PROJECT CONTEXT - Auto-loaded by doit -->", ""]
395
+
396
+ # Add guidance prompt if context was condensed
397
+ if self._guidance_prompt:
398
+ sections.append(self._guidance_prompt)
399
+ sections.append("")
400
+
401
+ # Map source types to display names
402
+ display_names = {
403
+ "constitution": "Constitution",
404
+ "roadmap": "Roadmap",
405
+ "completed_roadmap": "Completed Roadmap",
406
+ "current_spec": "Current Spec",
407
+ "related_specs": "Related Specs",
408
+ }
409
+
410
+ for source in self.sources:
411
+ display_name = display_names.get(source.source_type, source.source_type.title())
412
+
413
+ # Add section header
414
+ if source.source_type == "current_spec":
415
+ # Extract feature name from path if possible
416
+ feature_name = source.path.parent.name if source.path.parent.name != "." else ""
417
+ if feature_name:
418
+ sections.append(f"## {display_name}: {feature_name}")
419
+ else:
420
+ sections.append(f"## {display_name}")
421
+ elif source.source_type == "related_specs":
422
+ # For related specs, use individual headers
423
+ feature_name = source.path.parent.name if source.path.parent.name != "." else ""
424
+ sections.append(f"### {feature_name}")
425
+ else:
426
+ sections.append(f"## {display_name}")
427
+
428
+ sections.append("")
429
+ sections.append(source.content)
430
+
431
+ if source.truncated and source.original_tokens:
432
+ sections.append("")
433
+ sections.append(f"<!-- Content truncated from {source.original_tokens} to {source.token_count} tokens. Full file at: {source.path} -->")
434
+
435
+ sections.append("")
436
+
437
+ sections.append("<!-- End of project context -->")
438
+
439
+ return "\n".join(sections)
440
+
441
+ def get_source(self, source_type: str) -> Optional[ContextSource]:
442
+ """Get specific source by type.
443
+
444
+ Args:
445
+ source_type: Type of source to retrieve.
446
+
447
+ Returns:
448
+ ContextSource if found, None otherwise.
449
+ """
450
+ for source in self.sources:
451
+ if source.source_type == source_type:
452
+ return source
453
+ return None
454
+
455
+ def has_source(self, source_type: str) -> bool:
456
+ """Check if source type is loaded.
457
+
458
+ Args:
459
+ source_type: Type of source to check.
460
+
461
+ Returns:
462
+ True if source is loaded, False otherwise.
463
+ """
464
+ return self.get_source(source_type) is not None
@@ -0,0 +1,182 @@
1
+ """Models for cross-reference support between specs and tasks."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import Optional
6
+
7
+
8
+ class CoverageStatus(str, Enum):
9
+ """Coverage status for a requirement."""
10
+
11
+ UNCOVERED = "uncovered"
12
+ PARTIAL = "partial"
13
+ COVERED = "covered"
14
+
15
+
16
+ @dataclass
17
+ class Requirement:
18
+ """A functional requirement extracted from spec.md.
19
+
20
+ Attributes:
21
+ id: Requirement ID in FR-XXX format (e.g., FR-001)
22
+ spec_path: Path to the spec.md file containing this requirement
23
+ description: Full requirement text
24
+ line_number: Line number in spec.md where requirement is defined
25
+ """
26
+
27
+ id: str
28
+ spec_path: str
29
+ description: str
30
+ line_number: int
31
+
32
+
33
+ @dataclass
34
+ class TaskReference:
35
+ """A cross-reference from a task to a requirement.
36
+
37
+ Attributes:
38
+ requirement_id: The FR-XXX ID being referenced
39
+ position: Order in the reference list (0-indexed)
40
+ """
41
+
42
+ requirement_id: str
43
+ position: int = 0
44
+
45
+
46
+ @dataclass
47
+ class Task:
48
+ """An implementation task extracted from tasks.md.
49
+
50
+ Attributes:
51
+ id: Auto-generated hash of normalized description
52
+ tasks_file: Path to the tasks.md file containing this task
53
+ description: Task text (without references)
54
+ completed: Checkbox state (True if [x] or [X])
55
+ line_number: Line number in tasks.md
56
+ references: List of FR-XXX references found in this task
57
+ """
58
+
59
+ id: str
60
+ tasks_file: str
61
+ description: str
62
+ completed: bool
63
+ line_number: int
64
+ references: list[TaskReference] = field(default_factory=list)
65
+
66
+ @property
67
+ def requirement_ids(self) -> list[str]:
68
+ """Get list of requirement IDs this task references."""
69
+ return [ref.requirement_id for ref in self.references]
70
+
71
+
72
+ @dataclass
73
+ class CrossReference:
74
+ """A link between a task and a requirement.
75
+
76
+ Attributes:
77
+ requirement_id: FK to Requirement.id (FR-XXX format)
78
+ task_id: FK to Task.id
79
+ position: Order when multiple refs in same task (0-indexed)
80
+ """
81
+
82
+ requirement_id: str
83
+ task_id: str
84
+ position: int = 0
85
+
86
+ @property
87
+ def id(self) -> str:
88
+ """Composite key for the cross-reference."""
89
+ return f"{self.requirement_id}:{self.task_id}"
90
+
91
+
92
+ @dataclass
93
+ class RequirementCoverage:
94
+ """Coverage information for a single requirement.
95
+
96
+ Attributes:
97
+ requirement: The requirement being tracked
98
+ tasks: List of tasks that implement this requirement
99
+ status: Coverage status (uncovered, partial, covered)
100
+ """
101
+
102
+ requirement: Requirement
103
+ tasks: list[Task] = field(default_factory=list)
104
+
105
+ @property
106
+ def task_count(self) -> int:
107
+ """Number of tasks implementing this requirement."""
108
+ return len(self.tasks)
109
+
110
+ @property
111
+ def completed_count(self) -> int:
112
+ """Number of completed tasks implementing this requirement."""
113
+ return sum(1 for t in self.tasks if t.completed)
114
+
115
+ @property
116
+ def status(self) -> CoverageStatus:
117
+ """Derive coverage status from task states."""
118
+ if not self.tasks:
119
+ return CoverageStatus.UNCOVERED
120
+ if all(t.completed for t in self.tasks):
121
+ return CoverageStatus.COVERED
122
+ return CoverageStatus.PARTIAL
123
+
124
+ @property
125
+ def is_covered(self) -> bool:
126
+ """Whether this requirement has at least one linked task."""
127
+ return len(self.tasks) > 0
128
+
129
+
130
+ @dataclass
131
+ class CoverageReport:
132
+ """Aggregate coverage report for a specification.
133
+
134
+ Attributes:
135
+ spec_path: Path to the spec.md file
136
+ requirements: List of RequirementCoverage objects
137
+ orphaned_references: Task references to non-existent requirements
138
+ """
139
+
140
+ spec_path: str
141
+ requirements: list[RequirementCoverage] = field(default_factory=list)
142
+ orphaned_references: list[tuple[Task, str]] = field(default_factory=list)
143
+
144
+ @property
145
+ def total_count(self) -> int:
146
+ """Total number of requirements."""
147
+ return len(self.requirements)
148
+
149
+ @property
150
+ def covered_count(self) -> int:
151
+ """Number of requirements with at least one linked task."""
152
+ return sum(1 for r in self.requirements if r.is_covered)
153
+
154
+ @property
155
+ def uncovered_count(self) -> int:
156
+ """Number of requirements with no linked tasks."""
157
+ return self.total_count - self.covered_count
158
+
159
+ @property
160
+ def coverage_percent(self) -> float:
161
+ """Coverage percentage (0-100)."""
162
+ if self.total_count == 0:
163
+ return 100.0
164
+ return (self.covered_count / self.total_count) * 100
165
+
166
+ @property
167
+ def is_fully_covered(self) -> bool:
168
+ """Whether all requirements have at least one linked task."""
169
+ return self.covered_count == self.total_count
170
+
171
+ def get_uncovered_requirements(self) -> list[Requirement]:
172
+ """Get list of requirements with no linked tasks."""
173
+ return [rc.requirement for rc in self.requirements if not rc.is_covered]
174
+
175
+ def get_requirement_coverage(
176
+ self, requirement_id: str
177
+ ) -> Optional[RequirementCoverage]:
178
+ """Get coverage info for a specific requirement."""
179
+ for rc in self.requirements:
180
+ if rc.requirement.id == requirement_id:
181
+ return rc
182
+ return None