doit-toolkit-cli 0.1.10__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.
Potentially problematic release.
This version of doit-toolkit-cli might be problematic. Click here for more details.
- doit_cli/__init__.py +1356 -0
- doit_cli/cli/__init__.py +26 -0
- doit_cli/cli/analytics_command.py +616 -0
- doit_cli/cli/context_command.py +213 -0
- doit_cli/cli/diagram_command.py +304 -0
- doit_cli/cli/fixit_command.py +641 -0
- doit_cli/cli/hooks_command.py +211 -0
- doit_cli/cli/init_command.py +613 -0
- doit_cli/cli/memory_command.py +293 -0
- doit_cli/cli/roadmapit_command.py +10 -0
- doit_cli/cli/status_command.py +117 -0
- doit_cli/cli/sync_prompts_command.py +248 -0
- doit_cli/cli/validate_command.py +196 -0
- doit_cli/cli/verify_command.py +204 -0
- doit_cli/cli/workflow_mixin.py +224 -0
- doit_cli/cli/xref_command.py +555 -0
- doit_cli/formatters/__init__.py +8 -0
- doit_cli/formatters/base.py +38 -0
- doit_cli/formatters/json_formatter.py +126 -0
- doit_cli/formatters/markdown_formatter.py +97 -0
- doit_cli/formatters/rich_formatter.py +257 -0
- doit_cli/main.py +51 -0
- doit_cli/models/__init__.py +139 -0
- doit_cli/models/agent.py +74 -0
- doit_cli/models/analytics_models.py +384 -0
- doit_cli/models/context_config.py +464 -0
- doit_cli/models/crossref_models.py +182 -0
- doit_cli/models/diagram_models.py +363 -0
- doit_cli/models/fixit_models.py +355 -0
- doit_cli/models/hook_config.py +125 -0
- doit_cli/models/project.py +91 -0
- doit_cli/models/results.py +121 -0
- doit_cli/models/search_models.py +228 -0
- doit_cli/models/status_models.py +195 -0
- doit_cli/models/sync_models.py +146 -0
- doit_cli/models/template.py +77 -0
- doit_cli/models/validation_models.py +175 -0
- doit_cli/models/workflow_models.py +319 -0
- doit_cli/prompts/__init__.py +5 -0
- doit_cli/prompts/fixit_prompts.py +344 -0
- doit_cli/prompts/interactive.py +390 -0
- doit_cli/rules/__init__.py +5 -0
- doit_cli/rules/builtin_rules.py +160 -0
- doit_cli/services/__init__.py +79 -0
- doit_cli/services/agent_detector.py +168 -0
- doit_cli/services/analytics_service.py +218 -0
- doit_cli/services/architecture_generator.py +290 -0
- doit_cli/services/backup_service.py +204 -0
- doit_cli/services/config_loader.py +113 -0
- doit_cli/services/context_loader.py +1123 -0
- doit_cli/services/coverage_calculator.py +142 -0
- doit_cli/services/crossref_service.py +237 -0
- doit_cli/services/cycle_time_calculator.py +134 -0
- doit_cli/services/date_inferrer.py +349 -0
- doit_cli/services/diagram_service.py +337 -0
- doit_cli/services/drift_detector.py +109 -0
- doit_cli/services/entity_parser.py +301 -0
- doit_cli/services/er_diagram_generator.py +197 -0
- doit_cli/services/fixit_service.py +699 -0
- doit_cli/services/github_service.py +192 -0
- doit_cli/services/hook_manager.py +258 -0
- doit_cli/services/hook_validator.py +528 -0
- doit_cli/services/input_validator.py +322 -0
- doit_cli/services/memory_search.py +527 -0
- doit_cli/services/mermaid_validator.py +334 -0
- doit_cli/services/prompt_transformer.py +91 -0
- doit_cli/services/prompt_writer.py +133 -0
- doit_cli/services/query_interpreter.py +428 -0
- doit_cli/services/report_exporter.py +219 -0
- doit_cli/services/report_generator.py +256 -0
- doit_cli/services/requirement_parser.py +112 -0
- doit_cli/services/roadmap_summarizer.py +209 -0
- doit_cli/services/rule_engine.py +443 -0
- doit_cli/services/scaffolder.py +215 -0
- doit_cli/services/score_calculator.py +172 -0
- doit_cli/services/section_parser.py +204 -0
- doit_cli/services/spec_scanner.py +327 -0
- doit_cli/services/state_manager.py +355 -0
- doit_cli/services/status_reporter.py +143 -0
- doit_cli/services/task_parser.py +347 -0
- doit_cli/services/template_manager.py +710 -0
- doit_cli/services/template_reader.py +158 -0
- doit_cli/services/user_journey_generator.py +214 -0
- doit_cli/services/user_story_parser.py +232 -0
- doit_cli/services/validation_service.py +188 -0
- doit_cli/services/validator.py +232 -0
- doit_cli/services/velocity_tracker.py +173 -0
- doit_cli/services/workflow_engine.py +405 -0
- doit_cli/templates/agent-file-template.md +28 -0
- doit_cli/templates/checklist-template.md +39 -0
- doit_cli/templates/commands/doit.checkin.md +363 -0
- doit_cli/templates/commands/doit.constitution.md +187 -0
- doit_cli/templates/commands/doit.documentit.md +485 -0
- doit_cli/templates/commands/doit.fixit.md +181 -0
- doit_cli/templates/commands/doit.implementit.md +265 -0
- doit_cli/templates/commands/doit.planit.md +262 -0
- doit_cli/templates/commands/doit.reviewit.md +355 -0
- doit_cli/templates/commands/doit.roadmapit.md +389 -0
- doit_cli/templates/commands/doit.scaffoldit.md +458 -0
- doit_cli/templates/commands/doit.specit.md +521 -0
- doit_cli/templates/commands/doit.taskit.md +304 -0
- doit_cli/templates/commands/doit.testit.md +277 -0
- doit_cli/templates/config/context.yaml +134 -0
- doit_cli/templates/config/hooks.yaml +93 -0
- doit_cli/templates/config/validation-rules.yaml +64 -0
- doit_cli/templates/github-issue-templates/epic.yml +78 -0
- doit_cli/templates/github-issue-templates/feature.yml +116 -0
- doit_cli/templates/github-issue-templates/task.yml +129 -0
- doit_cli/templates/hooks/.gitkeep +0 -0
- doit_cli/templates/hooks/post-commit.sh +25 -0
- doit_cli/templates/hooks/post-merge.sh +75 -0
- doit_cli/templates/hooks/pre-commit.sh +17 -0
- doit_cli/templates/hooks/pre-push.sh +18 -0
- doit_cli/templates/memory/completed_roadmap.md +50 -0
- doit_cli/templates/memory/constitution.md +125 -0
- doit_cli/templates/memory/roadmap.md +61 -0
- doit_cli/templates/plan-template.md +146 -0
- doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
- doit_cli/templates/scripts/bash/common.sh +156 -0
- doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
- doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
- doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
- doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
- doit_cli/templates/scripts/powershell/common.ps1 +137 -0
- doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
- doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
- doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
- doit_cli/templates/spec-template.md +159 -0
- doit_cli/templates/tasks-template.md +313 -0
- doit_cli/templates/vscode-settings.json +14 -0
- doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
- doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
- doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
- doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
- doit_toolkit_cli-0.1.10.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Parser for extracting tasks and cross-references from tasks.md files."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ..models.crossref_models import Task, TaskReference
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskParser:
|
|
12
|
+
"""Parses tasks.md files to extract tasks and their requirement references.
|
|
13
|
+
|
|
14
|
+
This parser extracts task items with checkbox format and identifies
|
|
15
|
+
[FR-XXX] cross-references within task descriptions.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# Pattern to match task lines: - [ ] or - [x] or - [X] followed by description
|
|
19
|
+
# Captures: group(1) = checkbox state (space, x, or X), group(2) = description
|
|
20
|
+
TASK_PATTERN = re.compile(
|
|
21
|
+
r"^\s*-\s*\[(?P<state>[ xX])\]\s*(?P<description>.+)$",
|
|
22
|
+
re.MULTILINE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Pattern to extract [FR-XXX] or [FR-001, FR-002] references
|
|
26
|
+
# Matches single or comma-separated FR references
|
|
27
|
+
REFERENCE_PATTERN = re.compile(
|
|
28
|
+
r"\[(?P<refs>FR-\d{3}(?:,\s*FR-\d{3})*)\]"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Pattern to extract individual FR-XXX from comma-separated list
|
|
32
|
+
FR_PATTERN = re.compile(r"FR-\d{3}")
|
|
33
|
+
|
|
34
|
+
def __init__(self, tasks_path: Optional[Path] = None) -> None:
|
|
35
|
+
"""Initialize parser with optional tasks file path.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
tasks_path: Path to tasks.md file. Can be set later via parse().
|
|
39
|
+
"""
|
|
40
|
+
self.tasks_path = tasks_path
|
|
41
|
+
|
|
42
|
+
def parse(self, tasks_path: Optional[Path] = None) -> list[Task]:
|
|
43
|
+
"""Parse tasks.md and extract all tasks with their references.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
tasks_path: Path to tasks.md file. Overrides constructor path.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
List of Task objects in file order.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
FileNotFoundError: If tasks file doesn't exist.
|
|
53
|
+
ValueError: If no tasks path provided.
|
|
54
|
+
"""
|
|
55
|
+
path = tasks_path or self.tasks_path
|
|
56
|
+
if path is None:
|
|
57
|
+
raise ValueError("No tasks path provided")
|
|
58
|
+
|
|
59
|
+
path = Path(path)
|
|
60
|
+
if not path.exists():
|
|
61
|
+
raise FileNotFoundError(f"Tasks file not found: {path}")
|
|
62
|
+
|
|
63
|
+
content = path.read_text(encoding="utf-8")
|
|
64
|
+
return self.parse_content(content, str(path))
|
|
65
|
+
|
|
66
|
+
def parse_content(self, content: str, tasks_file: str) -> list[Task]:
|
|
67
|
+
"""Parse content string and extract tasks.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
content: Full content of tasks.md file.
|
|
71
|
+
tasks_file: Path to associate with extracted tasks.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of Task objects in file order.
|
|
75
|
+
"""
|
|
76
|
+
tasks: list[Task] = []
|
|
77
|
+
lines = content.split("\n")
|
|
78
|
+
|
|
79
|
+
for line_num, line in enumerate(lines, start=1):
|
|
80
|
+
match = self.TASK_PATTERN.match(line)
|
|
81
|
+
if match:
|
|
82
|
+
state = match.group("state")
|
|
83
|
+
description = match.group("description").strip()
|
|
84
|
+
completed = state.lower() == "x"
|
|
85
|
+
|
|
86
|
+
# Extract FR references from description
|
|
87
|
+
references = self._extract_references(description)
|
|
88
|
+
|
|
89
|
+
# Generate unique ID from normalized description
|
|
90
|
+
task_id = self._generate_task_id(description)
|
|
91
|
+
|
|
92
|
+
task = Task(
|
|
93
|
+
id=task_id,
|
|
94
|
+
tasks_file=tasks_file,
|
|
95
|
+
description=self._clean_description(description),
|
|
96
|
+
completed=completed,
|
|
97
|
+
line_number=line_num,
|
|
98
|
+
references=references,
|
|
99
|
+
)
|
|
100
|
+
tasks.append(task)
|
|
101
|
+
|
|
102
|
+
return tasks
|
|
103
|
+
|
|
104
|
+
def _extract_references(self, description: str) -> list[TaskReference]:
|
|
105
|
+
"""Extract FR-XXX references from task description.
|
|
106
|
+
|
|
107
|
+
Handles both single references [FR-001] and multiple [FR-001, FR-003].
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
description: Task description text.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of TaskReference objects in order found.
|
|
114
|
+
"""
|
|
115
|
+
references: list[TaskReference] = []
|
|
116
|
+
position = 0
|
|
117
|
+
|
|
118
|
+
# Find all reference patterns in the description
|
|
119
|
+
for match in self.REFERENCE_PATTERN.finditer(description):
|
|
120
|
+
refs_str = match.group("refs")
|
|
121
|
+
# Extract individual FR-XXX IDs
|
|
122
|
+
for fr_match in self.FR_PATTERN.finditer(refs_str):
|
|
123
|
+
ref = TaskReference(
|
|
124
|
+
requirement_id=fr_match.group(),
|
|
125
|
+
position=position,
|
|
126
|
+
)
|
|
127
|
+
references.append(ref)
|
|
128
|
+
position += 1
|
|
129
|
+
|
|
130
|
+
return references
|
|
131
|
+
|
|
132
|
+
def _clean_description(self, description: str) -> str:
|
|
133
|
+
"""Remove FR reference brackets from description for clean display.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
description: Raw task description with [FR-XXX] references.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Description with [FR-XXX] patterns removed.
|
|
140
|
+
"""
|
|
141
|
+
# Remove [FR-XXX] or [FR-001, FR-002] patterns
|
|
142
|
+
cleaned = self.REFERENCE_PATTERN.sub("", description)
|
|
143
|
+
# Clean up extra whitespace
|
|
144
|
+
return " ".join(cleaned.split()).strip()
|
|
145
|
+
|
|
146
|
+
def _generate_task_id(self, description: str) -> str:
|
|
147
|
+
"""Generate unique ID from task description.
|
|
148
|
+
|
|
149
|
+
Uses first 8 characters of MD5 hash of normalized description.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
description: Task description text.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
8-character hex ID.
|
|
156
|
+
"""
|
|
157
|
+
# Normalize: lowercase, remove extra whitespace, remove FR refs
|
|
158
|
+
normalized = self._clean_description(description).lower()
|
|
159
|
+
normalized = " ".join(normalized.split())
|
|
160
|
+
return hashlib.md5(normalized.encode()).hexdigest()[:8]
|
|
161
|
+
|
|
162
|
+
def get_tasks_for_requirement(
|
|
163
|
+
self, requirement_id: str, tasks_path: Optional[Path] = None
|
|
164
|
+
) -> list[Task]:
|
|
165
|
+
"""Get all tasks that reference a specific requirement.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
requirement_id: The FR-XXX ID to search for.
|
|
169
|
+
tasks_path: Path to tasks.md file.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of tasks that reference the requirement.
|
|
173
|
+
"""
|
|
174
|
+
tasks = self.parse(tasks_path)
|
|
175
|
+
return [t for t in tasks if requirement_id in t.requirement_ids]
|
|
176
|
+
|
|
177
|
+
def get_all_referenced_ids(self, tasks_path: Optional[Path] = None) -> set[str]:
|
|
178
|
+
"""Get set of all requirement IDs referenced in tasks.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
tasks_path: Path to tasks.md file.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Set of FR-XXX IDs referenced by at least one task.
|
|
185
|
+
"""
|
|
186
|
+
tasks = self.parse(tasks_path)
|
|
187
|
+
all_ids: set[str] = set()
|
|
188
|
+
for task in tasks:
|
|
189
|
+
all_ids.update(task.requirement_ids)
|
|
190
|
+
return all_ids
|
|
191
|
+
|
|
192
|
+
def preserve_references(
|
|
193
|
+
self,
|
|
194
|
+
old_tasks: list[Task],
|
|
195
|
+
new_tasks: list[Task],
|
|
196
|
+
similarity_threshold: float = 0.7,
|
|
197
|
+
) -> list[Task]:
|
|
198
|
+
"""Preserve cross-references from old tasks when regenerating tasks.md.
|
|
199
|
+
|
|
200
|
+
Matches new tasks to old tasks by description similarity and transfers
|
|
201
|
+
[FR-XXX] references to maintain traceability through regeneration.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
old_tasks: Tasks from previous version of tasks.md with references.
|
|
205
|
+
new_tasks: Newly generated tasks that may lack references.
|
|
206
|
+
similarity_threshold: Minimum similarity score (0-1) for matching.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
New tasks with references preserved from matching old tasks.
|
|
210
|
+
"""
|
|
211
|
+
from dataclasses import replace as dataclass_replace
|
|
212
|
+
|
|
213
|
+
result: list[Task] = []
|
|
214
|
+
used_old_tasks: set[str] = set()
|
|
215
|
+
|
|
216
|
+
for new_task in new_tasks:
|
|
217
|
+
best_match: Optional[Task] = None
|
|
218
|
+
best_score = 0.0
|
|
219
|
+
|
|
220
|
+
for old_task in old_tasks:
|
|
221
|
+
# Skip already matched old tasks
|
|
222
|
+
if old_task.id in used_old_tasks:
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
score = self._calculate_similarity(
|
|
226
|
+
new_task.description, old_task.description
|
|
227
|
+
)
|
|
228
|
+
if score > best_score and score >= similarity_threshold:
|
|
229
|
+
best_score = score
|
|
230
|
+
best_match = old_task
|
|
231
|
+
|
|
232
|
+
if best_match and best_match.references:
|
|
233
|
+
# Preserve references from the matched old task
|
|
234
|
+
used_old_tasks.add(best_match.id)
|
|
235
|
+
preserved_task = dataclass_replace(
|
|
236
|
+
new_task, references=list(best_match.references)
|
|
237
|
+
)
|
|
238
|
+
result.append(preserved_task)
|
|
239
|
+
else:
|
|
240
|
+
result.append(new_task)
|
|
241
|
+
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
def _calculate_similarity(self, text1: str, text2: str) -> float:
|
|
245
|
+
"""Calculate text similarity between two task descriptions.
|
|
246
|
+
|
|
247
|
+
Uses Jaccard similarity on word sets after normalization.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
text1: First description text.
|
|
251
|
+
text2: Second description text.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Similarity score between 0.0 (no match) and 1.0 (identical).
|
|
255
|
+
"""
|
|
256
|
+
words1 = set(self._normalize_for_matching(text1).split())
|
|
257
|
+
words2 = set(self._normalize_for_matching(text2).split())
|
|
258
|
+
|
|
259
|
+
if not words1 or not words2:
|
|
260
|
+
return 0.0
|
|
261
|
+
|
|
262
|
+
intersection = words1 & words2
|
|
263
|
+
union = words1 | words2
|
|
264
|
+
|
|
265
|
+
return len(intersection) / len(union) if union else 0.0
|
|
266
|
+
|
|
267
|
+
def _normalize_for_matching(self, text: str) -> str:
|
|
268
|
+
"""Normalize description for similarity comparison.
|
|
269
|
+
|
|
270
|
+
Removes task IDs, reference markers, and normalizes whitespace/case.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
text: Task description text.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Normalized text suitable for comparison.
|
|
277
|
+
"""
|
|
278
|
+
# Remove task IDs (T001, T002, etc.)
|
|
279
|
+
normalized = re.sub(r"\bT\d{3}\b", "", text)
|
|
280
|
+
# Remove parallel markers [P]
|
|
281
|
+
normalized = re.sub(r"\[P\]", "", normalized)
|
|
282
|
+
# Remove user story markers [US1], [US2], etc.
|
|
283
|
+
normalized = re.sub(r"\[US\d+\]", "", normalized)
|
|
284
|
+
# Remove FR references
|
|
285
|
+
normalized = self.REFERENCE_PATTERN.sub("", normalized)
|
|
286
|
+
# Lowercase and normalize whitespace
|
|
287
|
+
normalized = normalized.lower()
|
|
288
|
+
normalized = " ".join(normalized.split())
|
|
289
|
+
return normalized
|
|
290
|
+
|
|
291
|
+
def apply_references_to_content(
|
|
292
|
+
self,
|
|
293
|
+
content: str,
|
|
294
|
+
reference_map: dict[str, list[TaskReference]],
|
|
295
|
+
) -> str:
|
|
296
|
+
"""Apply preserved references to regenerated tasks.md content.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
content: New tasks.md content without references.
|
|
300
|
+
reference_map: Map of task description -> references to apply.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Content with [FR-XXX] references inserted where applicable.
|
|
304
|
+
"""
|
|
305
|
+
lines = content.split("\n")
|
|
306
|
+
result_lines: list[str] = []
|
|
307
|
+
|
|
308
|
+
for line in lines:
|
|
309
|
+
match = self.TASK_PATTERN.match(line)
|
|
310
|
+
if match:
|
|
311
|
+
description = match.group("description").strip()
|
|
312
|
+
cleaned = self._clean_description(description)
|
|
313
|
+
normalized = self._normalize_for_matching(cleaned)
|
|
314
|
+
|
|
315
|
+
# Look for matching references
|
|
316
|
+
refs_to_add: list[TaskReference] = []
|
|
317
|
+
for key, refs in reference_map.items():
|
|
318
|
+
if self._calculate_similarity(normalized, key) >= 0.7:
|
|
319
|
+
refs_to_add = refs
|
|
320
|
+
break
|
|
321
|
+
|
|
322
|
+
if refs_to_add and not self.REFERENCE_PATTERN.search(line):
|
|
323
|
+
# Format references to add
|
|
324
|
+
ref_ids = [r.requirement_id for r in refs_to_add]
|
|
325
|
+
ref_str = f"[{', '.join(ref_ids)}]"
|
|
326
|
+
# Insert before end of line
|
|
327
|
+
line = f"{line.rstrip()} {ref_str}"
|
|
328
|
+
|
|
329
|
+
result_lines.append(line)
|
|
330
|
+
|
|
331
|
+
return "\n".join(result_lines)
|
|
332
|
+
|
|
333
|
+
def build_reference_map(self, tasks: list[Task]) -> dict[str, list[TaskReference]]:
|
|
334
|
+
"""Build a map of normalized descriptions to their references.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
tasks: List of tasks with references.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Dict mapping normalized description to TaskReference list.
|
|
341
|
+
"""
|
|
342
|
+
ref_map: dict[str, list[TaskReference]] = {}
|
|
343
|
+
for task in tasks:
|
|
344
|
+
if task.references:
|
|
345
|
+
key = self._normalize_for_matching(task.description)
|
|
346
|
+
ref_map[key] = list(task.references)
|
|
347
|
+
return ref_map
|