specfact-cli 0.4.2__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 (62) hide show
  1. specfact_cli/__init__.py +14 -0
  2. specfact_cli/agents/__init__.py +24 -0
  3. specfact_cli/agents/analyze_agent.py +392 -0
  4. specfact_cli/agents/base.py +95 -0
  5. specfact_cli/agents/plan_agent.py +202 -0
  6. specfact_cli/agents/registry.py +176 -0
  7. specfact_cli/agents/sync_agent.py +133 -0
  8. specfact_cli/analyzers/__init__.py +11 -0
  9. specfact_cli/analyzers/code_analyzer.py +796 -0
  10. specfact_cli/cli.py +396 -0
  11. specfact_cli/commands/__init__.py +7 -0
  12. specfact_cli/commands/enforce.py +88 -0
  13. specfact_cli/commands/import_cmd.py +365 -0
  14. specfact_cli/commands/init.py +125 -0
  15. specfact_cli/commands/plan.py +1089 -0
  16. specfact_cli/commands/repro.py +192 -0
  17. specfact_cli/commands/sync.py +408 -0
  18. specfact_cli/common/__init__.py +25 -0
  19. specfact_cli/common/logger_setup.py +654 -0
  20. specfact_cli/common/logging_utils.py +41 -0
  21. specfact_cli/common/text_utils.py +52 -0
  22. specfact_cli/common/utils.py +48 -0
  23. specfact_cli/comparators/__init__.py +11 -0
  24. specfact_cli/comparators/plan_comparator.py +391 -0
  25. specfact_cli/generators/__init__.py +14 -0
  26. specfact_cli/generators/plan_generator.py +105 -0
  27. specfact_cli/generators/protocol_generator.py +115 -0
  28. specfact_cli/generators/report_generator.py +200 -0
  29. specfact_cli/generators/workflow_generator.py +120 -0
  30. specfact_cli/importers/__init__.py +7 -0
  31. specfact_cli/importers/speckit_converter.py +773 -0
  32. specfact_cli/importers/speckit_scanner.py +711 -0
  33. specfact_cli/models/__init__.py +33 -0
  34. specfact_cli/models/deviation.py +105 -0
  35. specfact_cli/models/enforcement.py +150 -0
  36. specfact_cli/models/plan.py +97 -0
  37. specfact_cli/models/protocol.py +28 -0
  38. specfact_cli/modes/__init__.py +19 -0
  39. specfact_cli/modes/detector.py +126 -0
  40. specfact_cli/modes/router.py +153 -0
  41. specfact_cli/resources/semgrep/async.yml +285 -0
  42. specfact_cli/sync/__init__.py +12 -0
  43. specfact_cli/sync/repository_sync.py +279 -0
  44. specfact_cli/sync/speckit_sync.py +388 -0
  45. specfact_cli/utils/__init__.py +58 -0
  46. specfact_cli/utils/console.py +70 -0
  47. specfact_cli/utils/feature_keys.py +212 -0
  48. specfact_cli/utils/git.py +241 -0
  49. specfact_cli/utils/github_annotations.py +399 -0
  50. specfact_cli/utils/ide_setup.py +382 -0
  51. specfact_cli/utils/prompts.py +180 -0
  52. specfact_cli/utils/structure.py +497 -0
  53. specfact_cli/utils/yaml_utils.py +200 -0
  54. specfact_cli/validators/__init__.py +20 -0
  55. specfact_cli/validators/fsm.py +262 -0
  56. specfact_cli/validators/repro_checker.py +759 -0
  57. specfact_cli/validators/schema.py +196 -0
  58. specfact_cli-0.4.2.dist-info/METADATA +370 -0
  59. specfact_cli-0.4.2.dist-info/RECORD +62 -0
  60. specfact_cli-0.4.2.dist-info/WHEEL +4 -0
  61. specfact_cli-0.4.2.dist-info/entry_points.txt +2 -0
  62. specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +61 -0
@@ -0,0 +1,711 @@
1
+ """
2
+ Spec-Kit scanner for importing Spec-Kit projects.
3
+
4
+ This module provides functionality to scan Spec-Kit repositories,
5
+ parse their structure, and extract features, stories, and requirements
6
+ from markdown artifacts generated by Spec-Kit commands.
7
+
8
+ Spec-Kit uses slash commands (/speckit.specify, /speckit.plan, etc.) to
9
+ generate markdown artifacts in specs/ and .specify/ directories.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from beartype import beartype
19
+ from icontract import ensure, require
20
+
21
+
22
+ class SpecKitScanner:
23
+ """
24
+ Scanner for Spec-Kit repositories.
25
+
26
+ Scans Spec-Kit directory structure, parses markdown files (spec.md, plan.md, tasks.md),
27
+ and extracts features, user stories, requirements, and tasks.
28
+ """
29
+
30
+ # Spec-Kit directory structure
31
+ SPECIFY_DIR = ".specify"
32
+ SPECIFY_MEMORY_DIR = ".specify/memory"
33
+ SPECS_DIR = "specs"
34
+
35
+ @beartype
36
+ def __init__(self, repo_path: Path) -> None:
37
+ """
38
+ Initialize Spec-Kit scanner.
39
+
40
+ Args:
41
+ repo_path: Path to Spec-Kit repository root
42
+ """
43
+ self.repo_path = Path(repo_path)
44
+
45
+ @beartype
46
+ @ensure(lambda result: isinstance(result, bool), "Must return boolean")
47
+ def is_speckit_repo(self) -> bool:
48
+ """
49
+ Check if repository is a Spec-Kit project.
50
+
51
+ Returns:
52
+ True if Spec-Kit structure detected, False otherwise
53
+ """
54
+ # Check for Spec-Kit format (.specify directory)
55
+ specify_dir = self.repo_path / self.SPECIFY_DIR
56
+ return specify_dir.exists() and specify_dir.is_dir()
57
+
58
+ @beartype
59
+ @ensure(lambda result: isinstance(result, dict), "Must return dictionary")
60
+ @ensure(lambda result: "is_speckit" in result, "Must include is_speckit key")
61
+ def scan_structure(self) -> dict[str, Any]:
62
+ """
63
+ Scan Spec-Kit directory structure.
64
+
65
+ Returns:
66
+ Dictionary with detected structure information
67
+ """
68
+ structure: dict[str, Any] = {
69
+ "is_speckit": False,
70
+ "specify_dir": None,
71
+ "specify_memory_dir": None,
72
+ "specs_dir": None,
73
+ "spec_files": [],
74
+ "feature_dirs": [],
75
+ "memory_files": [],
76
+ }
77
+ # Explicitly type the list values for type checker
78
+ spec_files: list[str] = []
79
+ feature_dirs: list[str] = []
80
+ memory_files: list[str] = []
81
+ structure["spec_files"] = spec_files
82
+ structure["feature_dirs"] = feature_dirs
83
+ structure["memory_files"] = memory_files
84
+
85
+ if not self.is_speckit_repo():
86
+ return structure
87
+
88
+ structure["is_speckit"] = True
89
+
90
+ # Check for .specify directory
91
+ specify_dir = self.repo_path / self.SPECIFY_DIR
92
+ if specify_dir.exists() and specify_dir.is_dir():
93
+ structure["specify_dir"] = str(specify_dir)
94
+
95
+ # Check for .specify/memory directory
96
+ specify_memory_dir = self.repo_path / self.SPECIFY_MEMORY_DIR
97
+ if specify_memory_dir.exists():
98
+ structure["specify_memory_dir"] = str(specify_memory_dir)
99
+ structure["memory_files"] = [str(f) for f in specify_memory_dir.glob("*.md")]
100
+
101
+ # Check for specs directory
102
+ specs_dir = self.repo_path / self.SPECS_DIR
103
+ if specs_dir.exists():
104
+ structure["specs_dir"] = str(specs_dir)
105
+ # Find all feature directories (specs/*/)
106
+ for spec_dir in specs_dir.iterdir():
107
+ if spec_dir.is_dir():
108
+ feature_dirs.append(str(spec_dir))
109
+ # Find all markdown files in each feature directory
110
+ for md_file in spec_dir.glob("*.md"):
111
+ spec_files.append(str(md_file))
112
+ # Also check for contracts/*.yaml
113
+ contracts_dir = spec_dir / "contracts"
114
+ if contracts_dir.exists():
115
+ for yaml_file in contracts_dir.glob("*.yaml"):
116
+ spec_files.append(str(yaml_file))
117
+
118
+ return structure
119
+
120
+ @beartype
121
+ @ensure(lambda result: isinstance(result, list), "Must return list")
122
+ @ensure(lambda result: all(isinstance(f, dict) for f in result), "All items must be dictionaries")
123
+ def discover_features(self) -> list[dict[str, Any]]:
124
+ """
125
+ Discover all features from specs directory.
126
+
127
+ Returns:
128
+ List of feature dictionaries with parsed data from spec.md, plan.md, tasks.md
129
+ """
130
+ features: list[dict[str, Any]] = []
131
+ structure = self.scan_structure()
132
+
133
+ if not structure["is_speckit"] or not structure["feature_dirs"]:
134
+ return features
135
+
136
+ for feature_dir_path in structure["feature_dirs"]:
137
+ feature_dir = Path(feature_dir_path)
138
+ spec_file = feature_dir / "spec.md"
139
+
140
+ if spec_file.exists():
141
+ spec_data = self.parse_spec_markdown(spec_file)
142
+ if spec_data:
143
+ # Parse plan.md if it exists
144
+ plan_file = feature_dir / "plan.md"
145
+ if plan_file.exists():
146
+ plan_data = self.parse_plan_markdown(plan_file)
147
+ spec_data["plan"] = plan_data
148
+
149
+ # Parse tasks.md if it exists
150
+ tasks_file = feature_dir / "tasks.md"
151
+ if tasks_file.exists():
152
+ tasks_data = self.parse_tasks_markdown(tasks_file)
153
+ spec_data["tasks"] = tasks_data
154
+
155
+ features.append(spec_data)
156
+
157
+ return features
158
+
159
+ @beartype
160
+ @require(lambda spec_file: spec_file is not None, "Spec file path must not be None")
161
+ @require(lambda spec_file: spec_file.suffix == ".md", "Spec file must be markdown")
162
+ @ensure(
163
+ lambda result, spec_file: result is None or (isinstance(result, dict) and "feature_key" in result),
164
+ "Must return None or dict with feature_key",
165
+ )
166
+ def parse_spec_markdown(self, spec_file: Path) -> dict[str, Any] | None:
167
+ """
168
+ Parse a Spec-Kit spec.md file to extract features, stories, requirements, and success criteria.
169
+
170
+ Args:
171
+ spec_file: Path to spec.md file
172
+
173
+ Returns:
174
+ Dictionary with extracted feature and story information, or None if file doesn't exist
175
+ """
176
+ if not spec_file.exists():
177
+ return None
178
+
179
+ try:
180
+ content = spec_file.read_text(encoding="utf-8")
181
+ spec_data: dict[str, Any] = {
182
+ "feature_key": None,
183
+ "feature_title": None,
184
+ "feature_branch": None,
185
+ "created_date": None,
186
+ "status": None,
187
+ "stories": [],
188
+ "requirements": [],
189
+ "success_criteria": [],
190
+ "edge_cases": [],
191
+ }
192
+
193
+ # Extract frontmatter (if present)
194
+ frontmatter_match = re.search(r"^---\n(.*?)\n---", content, re.MULTILINE | re.DOTALL)
195
+ if frontmatter_match:
196
+ frontmatter = frontmatter_match.group(1)
197
+ # Extract Feature Branch
198
+ branch_match = re.search(r"\*\*Feature Branch\*\*:\s*`(.+?)`", frontmatter)
199
+ if branch_match:
200
+ spec_data["feature_branch"] = branch_match.group(1).strip()
201
+ # Extract Created date
202
+ created_match = re.search(r"\*\*Created\*\*:\s*(\d{4}-\d{2}-\d{2})", frontmatter)
203
+ if created_match:
204
+ spec_data["created_date"] = created_match.group(1).strip()
205
+ # Extract Status
206
+ status_match = re.search(r"\*\*Status\*\*:\s*(.+?)(?:\n|$)", frontmatter)
207
+ if status_match:
208
+ spec_data["status"] = status_match.group(1).strip()
209
+
210
+ # Extract feature key from directory name (specs/001-feature-name/spec.md)
211
+ spec_dir = spec_file.parent
212
+ if spec_dir.name:
213
+ spec_data["feature_key"] = spec_dir.name.upper().replace("-", "_")
214
+ # If feature_branch not found in frontmatter, use directory name
215
+ if not spec_data["feature_branch"]:
216
+ spec_data["feature_branch"] = spec_dir.name
217
+
218
+ # Extract feature title from spec.md header
219
+ title_match = re.search(r"^#\s+Feature Specification:\s*(.+)$", content, re.MULTILINE)
220
+ if title_match:
221
+ spec_data["feature_title"] = title_match.group(1).strip()
222
+
223
+ # Extract user stories with full context
224
+ story_pattern = r"###\s+User Story\s+(\d+)\s*-\s*(.+?)\s*\(Priority:\s*(P\d+)\)"
225
+ stories = re.finditer(story_pattern, content, re.MULTILINE | re.DOTALL)
226
+
227
+ story_counter = 1
228
+ for story_match in stories:
229
+ story_number = story_match.group(1)
230
+ story_title = story_match.group(2).strip()
231
+ priority = story_match.group(3)
232
+
233
+ # Find story content (between this story and next story or end of section)
234
+ story_start = story_match.end()
235
+ next_story_match = re.search(r"###\s+User Story\s+\d+", content[story_start:], re.MULTILINE)
236
+ story_end = story_start + next_story_match.start() if next_story_match else len(content)
237
+ story_content = content[story_start:story_end]
238
+
239
+ # Extract "As a..." description
240
+ as_a_match = re.search(
241
+ r"As a (.+?), I want (.+?) so that (.+?)(?=\n\n|\*\*Why|\*\*Independent|\*\*Acceptance)",
242
+ story_content,
243
+ re.DOTALL,
244
+ )
245
+ as_a_text = ""
246
+ if as_a_match:
247
+ as_a_text = f"As a {as_a_match.group(1)}, I want {as_a_match.group(2)}, so that {as_a_match.group(3)}".strip()
248
+
249
+ # Extract "Why this priority" text
250
+ why_priority_match = re.search(
251
+ r"\*\*Why this priority\*\*:\s*(.+?)(?=\n\n|\*\*Independent|$)", story_content, re.DOTALL
252
+ )
253
+ why_priority = why_priority_match.group(1).strip() if why_priority_match else ""
254
+
255
+ # Extract INVSEST criteria
256
+ invsest_criteria: dict[str, str | None] = {
257
+ "independent": None,
258
+ "negotiable": None,
259
+ "valuable": None,
260
+ "estimable": None,
261
+ "small": None,
262
+ "testable": None,
263
+ }
264
+ for criterion in ["Independent", "Negotiable", "Valuable", "Estimable", "Small", "Testable"]:
265
+ criterion_match = re.search(rf"\*\*{criterion}\*\*:\s*(YES|NO)", story_content, re.IGNORECASE)
266
+ if criterion_match:
267
+ invsest_criteria[criterion.lower()] = criterion_match.group(1).upper()
268
+
269
+ # Extract acceptance scenarios
270
+ acceptance_pattern = r"(\d+)\.\s+\*\*Given\*\*\s+(.+?),\s+\*\*When\*\*\s+(.+?),\s+\*\*Then\*\*\s+(.+?)(?=\n\n|\n\d+\.|\n###|$)"
271
+ acceptances = re.finditer(acceptance_pattern, story_content, re.DOTALL)
272
+
273
+ acceptance_criteria: list[str] = []
274
+ for acc_match in acceptances:
275
+ given = acc_match.group(2).strip()
276
+ when = acc_match.group(3).strip()
277
+ then = acc_match.group(4).strip()
278
+ acceptance_criteria.append(f"Given {given}, When {when}, Then {then}")
279
+
280
+ # Extract scenarios (Primary, Alternate, Exception, Recovery)
281
+ scenarios: dict[str, list[str]] = {
282
+ "primary": [],
283
+ "alternate": [],
284
+ "exception": [],
285
+ "recovery": [],
286
+ }
287
+ scenarios_section = re.search(r"\*\*Scenarios:\*\*\s*\n(.*?)(?=\n\n|\*\*|$)", story_content, re.DOTALL)
288
+ if scenarios_section:
289
+ scenarios_text = scenarios_section.group(1)
290
+ # Extract Primary scenarios
291
+ primary_matches = re.finditer(
292
+ r"- \*\*Primary Scenario\*\*:\s*(.+?)(?=\n-|\n|$)", scenarios_text, re.DOTALL
293
+ )
294
+ for match in primary_matches:
295
+ scenarios["primary"].append(match.group(1).strip())
296
+ # Extract Alternate scenarios
297
+ alternate_matches = re.finditer(
298
+ r"- \*\*Alternate Scenario\*\*:\s*(.+?)(?=\n-|\n|$)", scenarios_text, re.DOTALL
299
+ )
300
+ for match in alternate_matches:
301
+ scenarios["alternate"].append(match.group(1).strip())
302
+ # Extract Exception scenarios
303
+ exception_matches = re.finditer(
304
+ r"- \*\*Exception Scenario\*\*:\s*(.+?)(?=\n-|\n|$)", scenarios_text, re.DOTALL
305
+ )
306
+ for match in exception_matches:
307
+ scenarios["exception"].append(match.group(1).strip())
308
+ # Extract Recovery scenarios
309
+ recovery_matches = re.finditer(
310
+ r"- \*\*Recovery Scenario\*\*:\s*(.+?)(?=\n-|\n|$)", scenarios_text, re.DOTALL
311
+ )
312
+ for match in recovery_matches:
313
+ scenarios["recovery"].append(match.group(1).strip())
314
+
315
+ story_key = f"STORY-{story_counter:03d}"
316
+ spec_data["stories"].append(
317
+ {
318
+ "key": story_key,
319
+ "number": story_number,
320
+ "title": story_title,
321
+ "priority": priority,
322
+ "as_a": as_a_text,
323
+ "why_priority": why_priority,
324
+ "invsest": invsest_criteria,
325
+ "acceptance": acceptance_criteria,
326
+ "scenarios": scenarios,
327
+ }
328
+ )
329
+ story_counter += 1
330
+
331
+ # Extract functional requirements (FR-XXX)
332
+ req_pattern = r"-?\s*\*\*FR-(\d+)\*\*:\s*System MUST\s+(.+?)(?=\n-|\n\*|\n\n|\*\*FR-|$)"
333
+ requirements = re.finditer(req_pattern, content, re.MULTILINE | re.DOTALL)
334
+
335
+ for req_match in requirements:
336
+ req_id = req_match.group(1)
337
+ req_text = req_match.group(2).strip()
338
+ spec_data["requirements"].append(
339
+ {
340
+ "id": f"FR-{req_id}",
341
+ "text": req_text,
342
+ }
343
+ )
344
+
345
+ # Extract success criteria (SC-XXX)
346
+ sc_pattern = r"-?\s*\*\*SC-(\d+)\*\*:\s*(.+?)(?=\n-|\n\*|\n\n|\*\*SC-|$)"
347
+ success_criteria = re.finditer(sc_pattern, content, re.MULTILINE | re.DOTALL)
348
+
349
+ for sc_match in success_criteria:
350
+ sc_id = sc_match.group(1)
351
+ sc_text = sc_match.group(2).strip()
352
+ spec_data["success_criteria"].append(
353
+ {
354
+ "id": f"SC-{sc_id}",
355
+ "text": sc_text,
356
+ }
357
+ )
358
+
359
+ # Extract edge cases section
360
+ edge_case_section = re.search(r"### Edge Cases\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL)
361
+ if edge_case_section:
362
+ edge_case_text = edge_case_section.group(1)
363
+ # Extract individual edge cases (lines starting with -)
364
+ edge_case_pattern = r"- (.+?)(?=\n-|\n|$)"
365
+ edge_cases = re.finditer(edge_case_pattern, edge_case_text, re.MULTILINE)
366
+ for ec_match in edge_cases:
367
+ ec_text = ec_match.group(1).strip()
368
+ if ec_text:
369
+ spec_data["edge_cases"].append(ec_text)
370
+
371
+ return spec_data
372
+
373
+ except Exception as e:
374
+ raise ValueError(f"Failed to parse spec.md: {e}") from e
375
+
376
+ @beartype
377
+ @require(lambda plan_file: plan_file is not None, "Plan file path must not be None")
378
+ @require(lambda plan_file: plan_file.suffix == ".md", "Plan file must be markdown")
379
+ @ensure(
380
+ lambda result: result is None or (isinstance(result, dict) and "dependencies" in result),
381
+ "Must return None or dict with dependencies",
382
+ )
383
+ def parse_plan_markdown(self, plan_file: Path) -> dict[str, Any] | None:
384
+ """
385
+ Parse a Spec-Kit plan.md file to extract technical context and architecture.
386
+
387
+ Args:
388
+ plan_file: Path to plan.md file
389
+
390
+ Returns:
391
+ Dictionary with extracted plan information, or None if file doesn't exist
392
+ """
393
+ if not plan_file.exists():
394
+ return None
395
+
396
+ try:
397
+ content = plan_file.read_text(encoding="utf-8")
398
+ plan_data: dict[str, Any] = {
399
+ "summary": None,
400
+ "language_version": None,
401
+ "dependencies": [],
402
+ "technology_stack": [],
403
+ "constraints": [],
404
+ "unknowns": [],
405
+ "constitution_check": {},
406
+ "phases": [],
407
+ "architecture": {},
408
+ }
409
+
410
+ # Extract summary
411
+ summary_match = re.search(r"^## Summary\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL)
412
+ if summary_match:
413
+ plan_data["summary"] = summary_match.group(1).strip()
414
+
415
+ # Extract technical context
416
+ tech_context_match = re.search(r"^## Technical Context\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL)
417
+ if tech_context_match:
418
+ tech_context = tech_context_match.group(1)
419
+ # Extract language/version
420
+ lang_match = re.search(r"\*\*Language/Version\*\*:\s*(.+?)(?=\n|$)", tech_context, re.MULTILINE)
421
+ if lang_match:
422
+ plan_data["language_version"] = lang_match.group(1).strip()
423
+
424
+ # Extract dependencies
425
+ deps_match = re.search(
426
+ r"\*\*Primary Dependencies\*\*:\s*\n(.*?)(?=\n\*\*|$)", tech_context, re.MULTILINE | re.DOTALL
427
+ )
428
+ if deps_match:
429
+ deps_text = deps_match.group(1)
430
+ # Extract list items
431
+ dep_items = re.finditer(r"- `(.+?)`\s*-?\s*(.+?)(?=\n-|\n|$)", deps_text, re.MULTILINE)
432
+ for dep_match in dep_items:
433
+ dep_name = dep_match.group(1).strip()
434
+ dep_desc = dep_match.group(2).strip() if dep_match.group(2) else ""
435
+ plan_data["dependencies"].append({"name": dep_name, "description": dep_desc})
436
+
437
+ # Extract Technology Stack
438
+ stack_match = re.search(
439
+ r"\*\*Technology Stack\*\*:\s*\n(.*?)(?=\n\*\*|$)", tech_context, re.MULTILINE | re.DOTALL
440
+ )
441
+ if stack_match:
442
+ stack_text = stack_match.group(1)
443
+ stack_items = re.finditer(r"- (.+?)(?=\n-|\n|$)", stack_text, re.MULTILINE)
444
+ for item_match in stack_items:
445
+ plan_data["technology_stack"].append(item_match.group(1).strip())
446
+
447
+ # Extract Constraints
448
+ constraints_match = re.search(
449
+ r"\*\*Constraints\*\*:\s*\n(.*?)(?=\n\*\*|$)", tech_context, re.MULTILINE | re.DOTALL
450
+ )
451
+ if constraints_match:
452
+ constraints_text = constraints_match.group(1)
453
+ constraint_items = re.finditer(r"- (.+?)(?=\n-|\n|$)", constraints_text, re.MULTILINE)
454
+ for item_match in constraint_items:
455
+ plan_data["constraints"].append(item_match.group(1).strip())
456
+
457
+ # Extract Unknowns
458
+ unknowns_match = re.search(
459
+ r"\*\*Unknowns\*\*:\s*\n(.*?)(?=\n\*\*|$)", tech_context, re.MULTILINE | re.DOTALL
460
+ )
461
+ if unknowns_match:
462
+ unknowns_text = unknowns_match.group(1)
463
+ unknown_items = re.finditer(r"- (.+?)(?=\n-|\n|$)", unknowns_text, re.MULTILINE)
464
+ for item_match in unknown_items:
465
+ plan_data["unknowns"].append(item_match.group(1).strip())
466
+
467
+ # Extract Constitution Check section (CRITICAL for /speckit.analyze)
468
+ constitution_match = re.search(
469
+ r"^## Constitution Check\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL
470
+ )
471
+ if constitution_match:
472
+ constitution_text = constitution_match.group(1)
473
+ plan_data["constitution_check"] = {
474
+ "article_vii": {},
475
+ "article_viii": {},
476
+ "article_ix": {},
477
+ "status": None,
478
+ }
479
+ # Extract Article VII (Simplicity)
480
+ article_vii_match = re.search(
481
+ r"\*\*Article VII \(Simplicity\)\*\*:\s*\n(.*?)(?=\n\*\*|$)",
482
+ constitution_text,
483
+ re.MULTILINE | re.DOTALL,
484
+ )
485
+ if article_vii_match:
486
+ article_vii_text = article_vii_match.group(1)
487
+ plan_data["constitution_check"]["article_vii"] = {
488
+ "using_3_projects": re.search(r"- \[([ x])\]", article_vii_text) is not None,
489
+ "no_future_proofing": re.search(r"- \[([ x])\]", article_vii_text) is not None,
490
+ }
491
+ # Extract Article VIII (Anti-Abstraction)
492
+ article_viii_match = re.search(
493
+ r"\*\*Article VIII \(Anti-Abstraction\)\*\*:\s*\n(.*?)(?=\n\*\*|$)",
494
+ constitution_text,
495
+ re.MULTILINE | re.DOTALL,
496
+ )
497
+ if article_viii_match:
498
+ article_viii_text = article_viii_match.group(1)
499
+ plan_data["constitution_check"]["article_viii"] = {
500
+ "using_framework_directly": re.search(r"- \[([ x])\]", article_viii_text) is not None,
501
+ "single_model_representation": re.search(r"- \[([ x])\]", article_viii_text) is not None,
502
+ }
503
+ # Extract Article IX (Integration-First)
504
+ article_ix_match = re.search(
505
+ r"\*\*Article IX \(Integration-First\)\*\*:\s*\n(.*?)(?=\n\*\*|$)",
506
+ constitution_text,
507
+ re.MULTILINE | re.DOTALL,
508
+ )
509
+ if article_ix_match:
510
+ article_ix_text = article_ix_match.group(1)
511
+ plan_data["constitution_check"]["article_ix"] = {
512
+ "contracts_defined": re.search(r"- \[([ x])\]", article_ix_text) is not None,
513
+ "contract_tests_written": re.search(r"- \[([ x])\]", article_ix_text) is not None,
514
+ }
515
+ # Extract Status
516
+ status_match = re.search(r"\*\*Status\*\*:\s*(PASS|FAIL)", constitution_text, re.IGNORECASE)
517
+ if status_match:
518
+ plan_data["constitution_check"]["status"] = status_match.group(1).upper()
519
+
520
+ # Extract Phases
521
+ phase_pattern = r"^## Phase (-?\d+):\s*(.+?)\n(.*?)(?=\n## Phase|$)"
522
+ phases = re.finditer(phase_pattern, content, re.MULTILINE | re.DOTALL)
523
+ for phase_match in phases:
524
+ phase_num = phase_match.group(1)
525
+ phase_name = phase_match.group(2).strip()
526
+ phase_content = phase_match.group(3).strip()
527
+ plan_data["phases"].append(
528
+ {
529
+ "number": phase_num,
530
+ "name": phase_name,
531
+ "content": phase_content,
532
+ }
533
+ )
534
+
535
+ return plan_data
536
+
537
+ except Exception as e:
538
+ raise ValueError(f"Failed to parse plan.md: {e}") from e
539
+
540
+ @beartype
541
+ @require(lambda tasks_file: tasks_file is not None, "Tasks file path must not be None")
542
+ @require(lambda tasks_file: tasks_file.suffix == ".md", "Tasks file must be markdown")
543
+ @ensure(
544
+ lambda result: result is None or (isinstance(result, dict) and "tasks" in result),
545
+ "Must return None or dict with tasks",
546
+ )
547
+ def parse_tasks_markdown(self, tasks_file: Path) -> dict[str, Any] | None:
548
+ """
549
+ Parse a Spec-Kit tasks.md file to extract tasks with IDs, story mappings, and dependencies.
550
+
551
+ Args:
552
+ tasks_file: Path to tasks.md file
553
+
554
+ Returns:
555
+ Dictionary with extracted task information, or None if file doesn't exist
556
+ """
557
+ if not tasks_file.exists():
558
+ return None
559
+
560
+ try:
561
+ content = tasks_file.read_text(encoding="utf-8")
562
+ tasks_data: dict[str, Any] = {
563
+ "tasks": [],
564
+ "phases": [],
565
+ }
566
+
567
+ # Extract tasks (format: - [ ] [TaskID] [P?] [Story?] Description)
568
+ task_pattern = r"- \[([ x])\] \[?([T\d]+)\]?\s*\[?([P])?\]?\s*\[?([US\d]+)?\]?\s*(.+?)(?=\n-|\n##|$)"
569
+ tasks = re.finditer(task_pattern, content, re.MULTILINE | re.DOTALL)
570
+
571
+ for task_match in tasks:
572
+ checked = task_match.group(1) == "x"
573
+ task_id = task_match.group(2)
574
+ is_parallel = task_match.group(3) == "P"
575
+ story_ref = task_match.group(4)
576
+ description = task_match.group(5).strip()
577
+
578
+ tasks_data["tasks"].append(
579
+ {
580
+ "id": task_id,
581
+ "description": description,
582
+ "checked": checked,
583
+ "parallel": is_parallel,
584
+ "story_ref": story_ref,
585
+ }
586
+ )
587
+
588
+ # Extract phase sections and map tasks to phases
589
+ phase_pattern = r"^## Phase (\d+): (.+?)\n(.*?)(?=\n## Phase|$)"
590
+ phases = re.finditer(phase_pattern, content, re.MULTILINE | re.DOTALL)
591
+
592
+ for phase_match in phases:
593
+ phase_num = phase_match.group(1)
594
+ phase_name = phase_match.group(2).strip()
595
+ phase_content = phase_match.group(3)
596
+
597
+ # Find tasks in this phase
598
+ phase_tasks: list[dict[str, Any]] = []
599
+ phase_task_pattern = (
600
+ r"- \[([ x])\] \[?([T\d]+)\]?\s*\[?([P])?\]?\s*\[?([US\d]+)?\]?\s*(.+?)(?=\n-|\n##|$)"
601
+ )
602
+ phase_task_matches = re.finditer(phase_task_pattern, phase_content, re.MULTILINE | re.DOTALL)
603
+
604
+ for task_match in phase_task_matches:
605
+ checked = task_match.group(1) == "x"
606
+ task_id = task_match.group(2)
607
+ is_parallel = task_match.group(3) == "P"
608
+ story_ref = task_match.group(4)
609
+ description = task_match.group(5).strip()
610
+
611
+ phase_tasks.append(
612
+ {
613
+ "id": task_id,
614
+ "description": description,
615
+ "checked": checked,
616
+ "parallel": is_parallel,
617
+ "story_ref": story_ref,
618
+ "phase": phase_num,
619
+ "phase_name": phase_name,
620
+ }
621
+ )
622
+
623
+ tasks_data["phases"].append(
624
+ {
625
+ "number": phase_num,
626
+ "name": phase_name,
627
+ "content": phase_content,
628
+ "tasks": phase_tasks,
629
+ }
630
+ )
631
+
632
+ return tasks_data
633
+
634
+ except Exception as e:
635
+ raise ValueError(f"Failed to parse tasks.md: {e}") from e
636
+
637
+ def parse_memory_files(self, memory_dir: Path) -> dict[str, Any]:
638
+ """
639
+ Parse Spec-Kit memory files (constitution.md, etc.).
640
+
641
+ Args:
642
+ memory_dir: Path to memory directory
643
+
644
+ Returns:
645
+ Dictionary with extracted memory information
646
+ """
647
+ memory_data: dict[str, Any] = {
648
+ "constitution": None,
649
+ "principles": [],
650
+ "constraints": [],
651
+ "version": None,
652
+ }
653
+
654
+ if not memory_dir.exists():
655
+ return memory_data
656
+
657
+ # Parse constitution.md
658
+ constitution_file = memory_dir / "constitution.md"
659
+ if constitution_file.exists():
660
+ try:
661
+ content = constitution_file.read_text(encoding="utf-8")
662
+ memory_data["constitution"] = content
663
+
664
+ # Extract version
665
+ version_match = re.search(r"\*\*Version\*\*:\s*(\d+\.\d+\.\d+)", content, re.MULTILINE)
666
+ if version_match:
667
+ memory_data["version"] = version_match.group(1)
668
+
669
+ # Extract principles (from "### I. Principle Name" or "### Principle Name" sections)
670
+ principle_pattern = r"###\s+(?:[IVX]+\.\s*)?(.+?)(?:\s*\(NON-NEGOTIABLE\))?\n\n(.*?)(?=\n###|\n##|$)"
671
+ principles = re.finditer(principle_pattern, content, re.MULTILINE | re.DOTALL)
672
+
673
+ for prin_match in principles:
674
+ principle_name = prin_match.group(1).strip()
675
+ principle_content = prin_match.group(2).strip() if prin_match.group(2) else ""
676
+ # Skip placeholder principles
677
+ if not principle_name.startswith("["):
678
+ # Extract rationale if present
679
+ rationale_match = re.search(
680
+ r"\*\*Rationale\*\*:\s*(.+?)(?=\n\n|\n###|\n##|$)", principle_content, re.DOTALL
681
+ )
682
+ rationale = rationale_match.group(1).strip() if rationale_match else ""
683
+
684
+ memory_data["principles"].append(
685
+ {
686
+ "name": principle_name,
687
+ "description": (
688
+ principle_content.split("**Rationale**")[0].strip()
689
+ if "**Rationale**" in principle_content
690
+ else principle_content
691
+ ),
692
+ "rationale": rationale,
693
+ }
694
+ )
695
+
696
+ # Extract constraints from Governance section
697
+ governance_section = re.search(r"## Governance\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL)
698
+ if governance_section:
699
+ # Look for constraint patterns
700
+ constraint_pattern = r"- (.+?)(?=\n-|\n|$)"
701
+ constraints = re.finditer(constraint_pattern, governance_section.group(1), re.MULTILINE)
702
+ for const_match in constraints:
703
+ const_text = const_match.group(1).strip()
704
+ if const_text and not const_text.startswith("["):
705
+ memory_data["constraints"].append(const_text)
706
+
707
+ except Exception:
708
+ # Non-fatal error - log but continue
709
+ pass
710
+
711
+ return memory_data