specfact-cli 0.4.2__py3-none-any.whl → 0.6.8__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 (66) hide show
  1. specfact_cli/__init__.py +1 -1
  2. specfact_cli/agents/analyze_agent.py +2 -3
  3. specfact_cli/analyzers/__init__.py +2 -1
  4. specfact_cli/analyzers/ambiguity_scanner.py +601 -0
  5. specfact_cli/analyzers/code_analyzer.py +462 -30
  6. specfact_cli/analyzers/constitution_evidence_extractor.py +491 -0
  7. specfact_cli/analyzers/contract_extractor.py +419 -0
  8. specfact_cli/analyzers/control_flow_analyzer.py +281 -0
  9. specfact_cli/analyzers/requirement_extractor.py +337 -0
  10. specfact_cli/analyzers/test_pattern_extractor.py +330 -0
  11. specfact_cli/cli.py +151 -206
  12. specfact_cli/commands/constitution.py +281 -0
  13. specfact_cli/commands/enforce.py +42 -34
  14. specfact_cli/commands/import_cmd.py +481 -152
  15. specfact_cli/commands/init.py +224 -55
  16. specfact_cli/commands/plan.py +2133 -547
  17. specfact_cli/commands/repro.py +100 -78
  18. specfact_cli/commands/sync.py +701 -186
  19. specfact_cli/enrichers/constitution_enricher.py +765 -0
  20. specfact_cli/enrichers/plan_enricher.py +294 -0
  21. specfact_cli/importers/speckit_converter.py +364 -48
  22. specfact_cli/importers/speckit_scanner.py +65 -0
  23. specfact_cli/models/plan.py +42 -0
  24. specfact_cli/resources/mappings/node-async.yaml +49 -0
  25. specfact_cli/resources/mappings/python-async.yaml +47 -0
  26. specfact_cli/resources/mappings/speckit-default.yaml +82 -0
  27. specfact_cli/resources/prompts/specfact-enforce.md +185 -0
  28. specfact_cli/resources/prompts/specfact-import-from-code.md +626 -0
  29. specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
  30. specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
  31. specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
  32. specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
  33. specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
  34. specfact_cli/resources/prompts/specfact-plan-review.md +1276 -0
  35. specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
  36. specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -0
  37. specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
  38. specfact_cli/resources/prompts/specfact-repro.md +268 -0
  39. specfact_cli/resources/prompts/specfact-sync.md +497 -0
  40. specfact_cli/resources/schemas/deviation.schema.json +61 -0
  41. specfact_cli/resources/schemas/plan.schema.json +204 -0
  42. specfact_cli/resources/schemas/protocol.schema.json +53 -0
  43. specfact_cli/resources/templates/github-action.yml.j2 +140 -0
  44. specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
  45. specfact_cli/resources/templates/pr-template.md.j2 +58 -0
  46. specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
  47. specfact_cli/resources/templates/telemetry.yaml.example +35 -0
  48. specfact_cli/sync/__init__.py +10 -1
  49. specfact_cli/sync/watcher.py +268 -0
  50. specfact_cli/telemetry.py +440 -0
  51. specfact_cli/utils/acceptance_criteria.py +127 -0
  52. specfact_cli/utils/enrichment_parser.py +445 -0
  53. specfact_cli/utils/feature_keys.py +12 -3
  54. specfact_cli/utils/ide_setup.py +170 -0
  55. specfact_cli/utils/structure.py +179 -2
  56. specfact_cli/utils/yaml_utils.py +33 -0
  57. specfact_cli/validators/repro_checker.py +22 -1
  58. specfact_cli/validators/schema.py +15 -4
  59. specfact_cli-0.6.8.dist-info/METADATA +456 -0
  60. specfact_cli-0.6.8.dist-info/RECORD +99 -0
  61. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
  62. specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
  63. specfact_cli-0.4.2.dist-info/METADATA +0 -370
  64. specfact_cli-0.4.2.dist-info/RECORD +0 -62
  65. specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
  66. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/WHEEL +0 -0
@@ -7,12 +7,15 @@ to SpecFact format (plans, protocols).
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import re
11
+ from collections.abc import Callable
10
12
  from pathlib import Path
11
13
  from typing import Any
12
14
 
13
15
  from beartype import beartype
14
16
  from icontract import ensure, require
15
17
 
18
+ from specfact_cli.analyzers.constitution_evidence_extractor import ConstitutionEvidenceExtractor
16
19
  from specfact_cli.generators.plan_generator import PlanGenerator
17
20
  from specfact_cli.generators.protocol_generator import ProtocolGenerator
18
21
  from specfact_cli.generators.workflow_generator import WorkflowGenerator
@@ -43,6 +46,7 @@ class SpecKitConverter:
43
46
  self.protocol_generator = ProtocolGenerator()
44
47
  self.plan_generator = PlanGenerator()
45
48
  self.workflow_generator = WorkflowGenerator()
49
+ self.constitution_extractor = ConstitutionEvidenceExtractor(repo_path)
46
50
  self.mapping_file = mapping_file
47
51
 
48
52
  @beartype
@@ -111,10 +115,10 @@ class SpecKitConverter:
111
115
  # Discover features from markdown artifacts
112
116
  discovered_features = self.scanner.discover_features()
113
117
 
114
- # Extract features from markdown data
115
- features = self._extract_features_from_markdown(discovered_features)
118
+ # Extract features from markdown data (empty list if no features found)
119
+ features = self._extract_features_from_markdown(discovered_features) if discovered_features else []
116
120
 
117
- # Parse constitution for constraints
121
+ # Parse constitution for constraints (only if needed for idea creation)
118
122
  structure = self.scanner.scan_structure()
119
123
  memory_dir = Path(structure.get("specify_memory_dir", "")) if structure.get("specify_memory_dir") else None
120
124
  constraints: list[str] = []
@@ -155,6 +159,7 @@ class SpecKitConverter:
155
159
  product=product,
156
160
  features=features,
157
161
  metadata=None,
162
+ clarifications=None,
158
163
  )
159
164
 
160
165
  # Write to file if output path provided
@@ -259,6 +264,16 @@ class SpecKitConverter:
259
264
  if (story_ref and story_ref in story_key) or not story_ref:
260
265
  tasks.append(task.get("description", ""))
261
266
 
267
+ # Extract scenarios from Spec-Kit format (Primary, Alternate, Exception, Recovery)
268
+ scenarios = story_data.get("scenarios")
269
+ # Ensure scenarios dict has correct format (filter out empty lists)
270
+ if scenarios and isinstance(scenarios, dict):
271
+ # Filter out empty scenario lists
272
+ filtered_scenarios = {k: v for k, v in scenarios.items() if v and isinstance(v, list) and len(v) > 0}
273
+ scenarios = filtered_scenarios if filtered_scenarios else None
274
+ else:
275
+ scenarios = None
276
+
262
277
  story = Story(
263
278
  key=story_key,
264
279
  title=story_title,
@@ -269,6 +284,8 @@ class SpecKitConverter:
269
284
  tasks=tasks,
270
285
  confidence=0.8, # High confidence from spec
271
286
  draft=False,
287
+ scenarios=scenarios,
288
+ contracts=None,
272
289
  )
273
290
  stories.append(story)
274
291
 
@@ -357,7 +374,9 @@ class SpecKitConverter:
357
374
  @require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Must be PlanBundle instance")
358
375
  @ensure(lambda result: isinstance(result, int), "Must return int (number of features converted)")
359
376
  @ensure(lambda result: result >= 0, "Result must be non-negative")
360
- def convert_to_speckit(self, plan_bundle: PlanBundle) -> int:
377
+ def convert_to_speckit(
378
+ self, plan_bundle: PlanBundle, progress_callback: Callable[[int, int], None] | None = None
379
+ ) -> int:
361
380
  """
362
381
  Convert SpecFact plan bundle to Spec-Kit markdown artifacts.
363
382
 
@@ -365,27 +384,44 @@ class SpecKitConverter:
365
384
 
366
385
  Args:
367
386
  plan_bundle: SpecFact plan bundle to convert
387
+ progress_callback: Optional callback function(current, total) to report progress
368
388
 
369
389
  Returns:
370
390
  Number of features converted
371
391
  """
372
392
  features_converted = 0
373
-
374
- for feature in plan_bundle.features:
393
+ total_features = len(plan_bundle.features)
394
+ # Track used feature numbers to avoid duplicates
395
+ used_feature_nums: set[int] = set()
396
+
397
+ for idx, feature in enumerate(plan_bundle.features, start=1):
398
+ # Report progress if callback provided
399
+ if progress_callback:
400
+ progress_callback(idx, total_features)
375
401
  # Generate feature directory name from key (FEATURE-001 -> 001-feature-name)
376
- feature_num = self._extract_feature_number(feature.key)
402
+ # Use number from key if available and not already used, otherwise use sequential index
403
+ extracted_num = self._extract_feature_number(feature.key)
404
+ if extracted_num == 0 or extracted_num in used_feature_nums:
405
+ # No number found in key, or number already used - use sequential numbering
406
+ # Find next available sequential number starting from idx
407
+ feature_num = idx
408
+ while feature_num in used_feature_nums:
409
+ feature_num += 1
410
+ else:
411
+ feature_num = extracted_num
412
+ used_feature_nums.add(feature_num)
377
413
  feature_name = self._to_feature_dir_name(feature.title)
378
414
 
379
415
  # Create feature directory
380
416
  feature_dir = self.repo_path / "specs" / f"{feature_num:03d}-{feature_name}"
381
417
  feature_dir.mkdir(parents=True, exist_ok=True)
382
418
 
383
- # Generate spec.md
384
- spec_content = self._generate_spec_markdown(feature)
419
+ # Generate spec.md (pass calculated feature_num to avoid recalculation)
420
+ spec_content = self._generate_spec_markdown(feature, feature_num=feature_num)
385
421
  (feature_dir / "spec.md").write_text(spec_content, encoding="utf-8")
386
422
 
387
423
  # Generate plan.md
388
- plan_content = self._generate_plan_markdown(feature)
424
+ plan_content = self._generate_plan_markdown(feature, plan_bundle)
389
425
  (feature_dir / "plan.md").write_text(plan_content, encoding="utf-8")
390
426
 
391
427
  # Generate tasks.md
@@ -398,14 +434,29 @@ class SpecKitConverter:
398
434
 
399
435
  @beartype
400
436
  @require(lambda feature: isinstance(feature, Feature), "Must be Feature instance")
437
+ @require(
438
+ lambda feature_num: feature_num is None or feature_num > 0,
439
+ "Feature number must be None or positive",
440
+ )
401
441
  @ensure(lambda result: isinstance(result, str), "Must return string")
402
442
  @ensure(lambda result: len(result) > 0, "Result must be non-empty")
403
- def _generate_spec_markdown(self, feature: Feature) -> str:
404
- """Generate Spec-Kit spec.md content from SpecFact feature."""
443
+ def _generate_spec_markdown(self, feature: Feature, feature_num: int | None = None) -> str:
444
+ """
445
+ Generate Spec-Kit spec.md content from SpecFact feature.
446
+
447
+ Args:
448
+ feature: Feature to generate spec for
449
+ feature_num: Optional pre-calculated feature number (avoids recalculation with fallback)
450
+ """
405
451
  from datetime import datetime
406
452
 
407
453
  # Extract feature branch from feature key (FEATURE-001 -> 001-feature-name)
408
- feature_num = self._extract_feature_number(feature.key)
454
+ # Use provided feature_num if available, otherwise extract from key (with fallback to 1)
455
+ if feature_num is None:
456
+ feature_num = self._extract_feature_number(feature.key)
457
+ if feature_num == 0:
458
+ # Fallback: use 1 if no number found (shouldn't happen if called from convert_to_speckit)
459
+ feature_num = 1
409
460
  feature_name = self._to_feature_dir_name(feature.title)
410
461
  feature_branch = f"{feature_num:03d}-{feature_name}"
411
462
 
@@ -438,7 +489,17 @@ class SpecKitConverter:
438
489
  lines.append(f"### User Story {idx} - {story.title} (Priority: {priority})")
439
490
  lines.append(f"Users can {story.title}")
440
491
  lines.append("")
441
- lines.append("**Why this priority**: Core functionality")
492
+ # Extract priority rationale from story tags, feature outcomes, or use default
493
+ priority_rationale = "Core functionality"
494
+ if story.tags:
495
+ for tag in story.tags:
496
+ if tag.startswith(("priority:", "rationale:")):
497
+ priority_rationale = tag.split(":", 1)[1].strip()
498
+ break
499
+ if (not priority_rationale or priority_rationale == "Core functionality") and feature.outcomes:
500
+ # Try to extract from feature outcomes
501
+ priority_rationale = feature.outcomes[0] if len(feature.outcomes[0]) < 100 else "Core functionality"
502
+ lines.append(f"**Why this priority**: {priority_rationale}")
442
503
  lines.append("")
443
504
 
444
505
  # INVSEST criteria (CRITICAL for /speckit.analyze and /speckit.checklist)
@@ -461,26 +522,100 @@ class SpecKitConverter:
461
522
  for acc_idx, acc in enumerate(story.acceptance, start=1):
462
523
  # Parse Given/When/Then if available
463
524
  if "Given" in acc and "When" in acc and "Then" in acc:
464
- parts = acc.split(", ")
465
- given = parts[0].replace("Given ", "").strip()
466
- when = parts[1].replace("When ", "").strip()
467
- then = parts[2].replace("Then ", "").strip()
525
+ # Use regex to properly extract Given/When/Then parts
526
+ # This handles commas inside type hints (e.g., "dict[str, Any]")
527
+ gwt_pattern = r"Given\s+(.+?),\s*When\s+(.+?),\s*Then\s+(.+?)(?:$|,)"
528
+ match = re.search(gwt_pattern, acc, re.IGNORECASE | re.DOTALL)
529
+ if match:
530
+ given = match.group(1).strip()
531
+ when = match.group(2).strip()
532
+ then = match.group(3).strip()
533
+ else:
534
+ # Fallback to simple split if regex fails
535
+ parts = acc.split(", ")
536
+ given = parts[0].replace("Given ", "").strip() if len(parts) > 0 else ""
537
+ when = parts[1].replace("When ", "").strip() if len(parts) > 1 else ""
538
+ then = parts[2].replace("Then ", "").strip() if len(parts) > 2 else ""
468
539
  lines.append(f"{acc_idx}. **Given** {given}, **When** {when}, **Then** {then}")
469
540
 
470
541
  # Categorize scenarios based on keywords
471
542
  scenario_text = f"{given}, {when}, {then}"
472
543
  acc_lower = acc.lower()
473
- if any(keyword in acc_lower for keyword in ["error", "exception", "fail", "invalid"]):
544
+ if any(keyword in acc_lower for keyword in ["error", "exception", "fail", "invalid", "reject"]):
474
545
  scenarios_exception.append(scenario_text)
475
- elif any(keyword in acc_lower for keyword in ["recover", "retry", "fallback"]):
546
+ elif any(keyword in acc_lower for keyword in ["recover", "retry", "fallback", "retry"]):
476
547
  scenarios_recovery.append(scenario_text)
477
- elif any(keyword in acc_lower for keyword in ["alternate", "alternative", "different"]):
548
+ elif any(
549
+ keyword in acc_lower for keyword in ["alternate", "alternative", "different", "optional"]
550
+ ):
478
551
  scenarios_alternate.append(scenario_text)
479
552
  else:
480
553
  scenarios_primary.append(scenario_text)
481
554
  else:
482
- lines.append(f"{acc_idx}. {acc}")
483
- scenarios_primary.append(acc)
555
+ # Convert simple acceptance to Given/When/Then format for better scenario extraction
556
+ acc_lower = acc.lower()
557
+
558
+ # Generate Given/When/Then from simple acceptance
559
+ if "must" in acc_lower or "should" in acc_lower or "will" in acc_lower:
560
+ # Extract action and outcome
561
+ if "verify" in acc_lower or "validate" in acc_lower:
562
+ action = (
563
+ acc.replace("Must verify", "")
564
+ .replace("Must validate", "")
565
+ .replace("Should verify", "")
566
+ .replace("Should validate", "")
567
+ .strip()
568
+ )
569
+ given = "user performs action"
570
+ when = f"system {action}"
571
+ then = f"{action} succeeds"
572
+ elif "handle" in acc_lower or "display" in acc_lower:
573
+ action = (
574
+ acc.replace("Must handle", "")
575
+ .replace("Must display", "")
576
+ .replace("Should handle", "")
577
+ .replace("Should display", "")
578
+ .strip()
579
+ )
580
+ given = "error condition occurs"
581
+ when = "system processes error"
582
+ then = f"system {action}"
583
+ else:
584
+ # Generic conversion
585
+ given = "user interacts with system"
586
+ when = "action is performed"
587
+ then = acc.replace("Must", "").replace("Should", "").replace("Will", "").strip()
588
+
589
+ lines.append(f"{acc_idx}. **Given** {given}, **When** {when}, **Then** {then}")
590
+
591
+ # Categorize based on keywords
592
+ scenario_text = f"{given}, {when}, {then}"
593
+ if any(
594
+ keyword in acc_lower
595
+ for keyword in ["error", "exception", "fail", "invalid", "reject", "handle error"]
596
+ ):
597
+ scenarios_exception.append(scenario_text)
598
+ elif any(keyword in acc_lower for keyword in ["recover", "retry", "fallback"]):
599
+ scenarios_recovery.append(scenario_text)
600
+ elif any(
601
+ keyword in acc_lower
602
+ for keyword in ["alternate", "alternative", "different", "optional"]
603
+ ):
604
+ scenarios_alternate.append(scenario_text)
605
+ else:
606
+ scenarios_primary.append(scenario_text)
607
+ else:
608
+ # Keep original format but still categorize
609
+ lines.append(f"{acc_idx}. {acc}")
610
+ acc_lower = acc.lower()
611
+ if any(keyword in acc_lower for keyword in ["error", "exception", "fail", "invalid"]):
612
+ scenarios_exception.append(acc)
613
+ elif any(keyword in acc_lower for keyword in ["recover", "retry", "fallback"]):
614
+ scenarios_recovery.append(acc)
615
+ elif any(keyword in acc_lower for keyword in ["alternate", "alternative", "different"]):
616
+ scenarios_alternate.append(acc)
617
+ else:
618
+ scenarios_primary.append(acc)
484
619
 
485
620
  lines.append("")
486
621
 
@@ -546,9 +681,12 @@ class SpecKitConverter:
546
681
  return "\n".join(lines)
547
682
 
548
683
  @beartype
549
- @require(lambda feature: isinstance(feature, Feature), "Must be Feature instance")
684
+ @require(
685
+ lambda feature, plan_bundle: isinstance(feature, Feature) and isinstance(plan_bundle, PlanBundle),
686
+ "Must be Feature and PlanBundle instances",
687
+ )
550
688
  @ensure(lambda result: isinstance(result, str), "Must return string")
551
- def _generate_plan_markdown(self, feature: Feature) -> str:
689
+ def _generate_plan_markdown(self, feature: Feature, plan_bundle: PlanBundle) -> str:
552
690
  """Generate Spec-Kit plan.md content from SpecFact feature."""
553
691
  lines = [f"# Implementation Plan: {feature.title}", ""]
554
692
  lines.append("## Summary")
@@ -557,21 +695,49 @@ class SpecKitConverter:
557
695
 
558
696
  lines.append("## Technical Context")
559
697
  lines.append("")
560
- lines.append("**Language/Version**: Python 3.11+")
698
+
699
+ # Extract technology stack from constraints
700
+ technology_stack = self._extract_technology_stack(feature, plan_bundle)
701
+ language_version = next((s for s in technology_stack if "Python" in s), "Python 3.11+")
702
+
703
+ lines.append(f"**Language/Version**: {language_version}")
561
704
  lines.append("")
562
705
 
563
706
  lines.append("**Primary Dependencies:**")
564
707
  lines.append("")
565
- # Could extract from feature context if available
566
- lines.append("- `typer` - CLI framework")
567
- lines.append("- `pydantic` - Data validation")
708
+ # Extract dependencies from technology stack
709
+ dependencies = [
710
+ s
711
+ for s in technology_stack
712
+ if any(fw in s.lower() for fw in ["typer", "fastapi", "django", "flask", "pydantic", "sqlalchemy"])
713
+ ]
714
+ if dependencies:
715
+ for dep in dependencies[:5]: # Limit to top 5
716
+ # Format: "FastAPI framework" -> "fastapi - Web framework"
717
+ dep_lower = dep.lower()
718
+ if "fastapi" in dep_lower:
719
+ lines.append("- `fastapi` - Web framework")
720
+ elif "django" in dep_lower:
721
+ lines.append("- `django` - Web framework")
722
+ elif "flask" in dep_lower:
723
+ lines.append("- `flask` - Web framework")
724
+ elif "typer" in dep_lower:
725
+ lines.append("- `typer` - CLI framework")
726
+ elif "pydantic" in dep_lower:
727
+ lines.append("- `pydantic` - Data validation")
728
+ elif "sqlalchemy" in dep_lower:
729
+ lines.append("- `sqlalchemy` - ORM")
730
+ else:
731
+ lines.append(f"- {dep}")
732
+ else:
733
+ lines.append("- `typer` - CLI framework")
734
+ lines.append("- `pydantic` - Data validation")
568
735
  lines.append("")
569
736
 
570
737
  lines.append("**Technology Stack:**")
571
738
  lines.append("")
572
- lines.append("- Python 3.11+")
573
- lines.append("- Typer for CLI")
574
- lines.append("- Pydantic for data validation")
739
+ for stack_item in technology_stack:
740
+ lines.append(f"- {stack_item}")
575
741
  lines.append("")
576
742
 
577
743
  lines.append("**Constraints:**")
@@ -588,23 +754,87 @@ class SpecKitConverter:
588
754
  lines.append("- None at this time")
589
755
  lines.append("")
590
756
 
757
+ # Check if contracts are defined in stories (for Article IX and contract definitions section)
758
+ contracts_defined = any(story.contracts for story in feature.stories if story.contracts)
759
+
591
760
  # Constitution Check section (CRITICAL for /speckit.analyze)
592
- lines.append("## Constitution Check")
593
- lines.append("")
594
- lines.append("**Article VII (Simplicity)**:")
595
- lines.append("- [ ] Using ≤3 projects?")
596
- lines.append("- [ ] No future-proofing?")
597
- lines.append("")
598
- lines.append("**Article VIII (Anti-Abstraction)**:")
599
- lines.append("- [ ] Using framework directly?")
600
- lines.append("- [ ] Single model representation?")
601
- lines.append("")
602
- lines.append("**Article IX (Integration-First)**:")
603
- lines.append("- [ ] Contracts defined?")
604
- lines.append("- [ ] Contract tests written?")
605
- lines.append("")
606
- lines.append("**Status**: PASS")
607
- lines.append("")
761
+ # Extract evidence-based constitution status (Step 2.2)
762
+ try:
763
+ constitution_evidence = self.constitution_extractor.extract_all_evidence(self.repo_path)
764
+ constitution_section = self.constitution_extractor.generate_constitution_check_section(
765
+ constitution_evidence
766
+ )
767
+ lines.append(constitution_section)
768
+ except Exception:
769
+ # Fallback to basic constitution check if extraction fails
770
+ lines.append("## Constitution Check")
771
+ lines.append("")
772
+ lines.append("**Article VII (Simplicity)**:")
773
+ lines.append("- [ ] Evidence extraction pending")
774
+ lines.append("")
775
+ lines.append("**Article VIII (Anti-Abstraction)**:")
776
+ lines.append("- [ ] Evidence extraction pending")
777
+ lines.append("")
778
+ lines.append("**Article IX (Integration-First)**:")
779
+ if contracts_defined:
780
+ lines.append("- [x] Contracts defined?")
781
+ lines.append("- [ ] Contract tests written?")
782
+ else:
783
+ lines.append("- [ ] Contracts defined?")
784
+ lines.append("- [ ] Contract tests written?")
785
+ lines.append("")
786
+ lines.append("**Status**: PENDING")
787
+ lines.append("")
788
+
789
+ # Add contract definitions section if contracts exist (Step 2.1)
790
+ if contracts_defined:
791
+ lines.append("### Contract Definitions")
792
+ lines.append("")
793
+ for story in feature.stories:
794
+ if story.contracts:
795
+ lines.append(f"#### {story.title}")
796
+ lines.append("")
797
+ contracts = story.contracts
798
+
799
+ # Parameters
800
+ if contracts.get("parameters"):
801
+ lines.append("**Parameters:**")
802
+ for param in contracts["parameters"]:
803
+ param_type = param.get("type", "Any")
804
+ required = "required" if param.get("required", True) else "optional"
805
+ default = f" (default: {param.get('default')})" if param.get("default") is not None else ""
806
+ lines.append(f"- `{param['name']}`: {param_type} ({required}){default}")
807
+ lines.append("")
808
+
809
+ # Return type
810
+ if contracts.get("return_type"):
811
+ return_type = contracts["return_type"].get("type", "Any")
812
+ lines.append(f"**Return Type**: `{return_type}`")
813
+ lines.append("")
814
+
815
+ # Preconditions
816
+ if contracts.get("preconditions"):
817
+ lines.append("**Preconditions:**")
818
+ for precondition in contracts["preconditions"]:
819
+ lines.append(f"- {precondition}")
820
+ lines.append("")
821
+
822
+ # Postconditions
823
+ if contracts.get("postconditions"):
824
+ lines.append("**Postconditions:**")
825
+ for postcondition in contracts["postconditions"]:
826
+ lines.append(f"- {postcondition}")
827
+ lines.append("")
828
+
829
+ # Error contracts
830
+ if contracts.get("error_contracts"):
831
+ lines.append("**Error Contracts:**")
832
+ for error_contract in contracts["error_contracts"]:
833
+ exc_type = error_contract.get("exception_type", "Exception")
834
+ condition = error_contract.get("condition", "Error condition")
835
+ lines.append(f"- `{exc_type}`: {condition}")
836
+ lines.append("")
837
+ lines.append("")
608
838
 
609
839
  # Phases section
610
840
  lines.append("## Phase 0: Research")
@@ -724,6 +954,92 @@ class SpecKitConverter:
724
954
 
725
955
  return "\n".join(lines)
726
956
 
957
+ @beartype
958
+ @require(lambda feature: isinstance(feature, Feature), "Must be Feature instance")
959
+ @require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Must be PlanBundle instance")
960
+ @ensure(lambda result: isinstance(result, list), "Must return list")
961
+ @ensure(lambda result: len(result) > 0, "Must have at least one stack item")
962
+ def _extract_technology_stack(self, feature: Feature, plan_bundle: PlanBundle) -> list[str]:
963
+ """
964
+ Extract technology stack from feature and plan bundle constraints.
965
+
966
+ Args:
967
+ feature: Feature to extract stack from
968
+ plan_bundle: Plan bundle containing idea-level constraints
969
+
970
+ Returns:
971
+ List of technology stack items
972
+ """
973
+ stack: list[str] = []
974
+ seen: set[str] = set()
975
+
976
+ # Extract from idea-level constraints (project-wide)
977
+ if plan_bundle.idea and plan_bundle.idea.constraints:
978
+ for constraint in plan_bundle.idea.constraints:
979
+ constraint_lower = constraint.lower()
980
+
981
+ # Extract Python version
982
+ if "python" in constraint_lower and constraint not in seen:
983
+ stack.append(constraint)
984
+ seen.add(constraint)
985
+
986
+ # Extract frameworks
987
+ for fw in ["fastapi", "django", "flask", "typer", "tornado", "bottle"]:
988
+ if fw in constraint_lower and constraint not in seen:
989
+ stack.append(constraint)
990
+ seen.add(constraint)
991
+ break
992
+
993
+ # Extract databases
994
+ for db in ["postgres", "postgresql", "mysql", "sqlite", "redis", "mongodb", "cassandra"]:
995
+ if db in constraint_lower and constraint not in seen:
996
+ stack.append(constraint)
997
+ seen.add(constraint)
998
+ break
999
+
1000
+ # Extract from feature-level constraints (feature-specific)
1001
+ if feature.constraints:
1002
+ for constraint in feature.constraints:
1003
+ constraint_lower = constraint.lower()
1004
+
1005
+ # Skip if already added from idea constraints
1006
+ if constraint in seen:
1007
+ continue
1008
+
1009
+ # Extract frameworks
1010
+ for fw in ["fastapi", "django", "flask", "typer", "tornado", "bottle"]:
1011
+ if fw in constraint_lower:
1012
+ stack.append(constraint)
1013
+ seen.add(constraint)
1014
+ break
1015
+
1016
+ # Extract databases
1017
+ for db in ["postgres", "postgresql", "mysql", "sqlite", "redis", "mongodb", "cassandra"]:
1018
+ if db in constraint_lower:
1019
+ stack.append(constraint)
1020
+ seen.add(constraint)
1021
+ break
1022
+
1023
+ # Extract testing tools
1024
+ for test in ["pytest", "unittest", "nose", "tox"]:
1025
+ if test in constraint_lower:
1026
+ stack.append(constraint)
1027
+ seen.add(constraint)
1028
+ break
1029
+
1030
+ # Extract deployment tools
1031
+ for deploy in ["docker", "kubernetes", "aws", "gcp", "azure"]:
1032
+ if deploy in constraint_lower:
1033
+ stack.append(constraint)
1034
+ seen.add(constraint)
1035
+ break
1036
+
1037
+ # Default fallback if nothing extracted
1038
+ if not stack:
1039
+ stack = ["Python 3.11+", "Typer for CLI", "Pydantic for data validation"]
1040
+
1041
+ return stack
1042
+
727
1043
  @beartype
728
1044
  @require(lambda feature_key: isinstance(feature_key, str), "Must be string")
729
1045
  @ensure(lambda result: isinstance(result, int), "Must return int")
@@ -55,6 +55,71 @@ class SpecKitScanner:
55
55
  specify_dir = self.repo_path / self.SPECIFY_DIR
56
56
  return specify_dir.exists() and specify_dir.is_dir()
57
57
 
58
+ @beartype
59
+ @ensure(lambda result: isinstance(result, tuple), "Must return tuple")
60
+ @ensure(lambda result: len(result) == 2, "Must return (bool, str) tuple")
61
+ def has_constitution(self) -> tuple[bool, str]:
62
+ """
63
+ Check if constitution.md exists and is not empty.
64
+
65
+ Returns:
66
+ Tuple of (exists_and_valid, error_message)
67
+ - exists_and_valid: True if constitution exists and has content
68
+ - error_message: Empty string if valid, otherwise error description
69
+ """
70
+ memory_dir = self.repo_path / self.SPECIFY_MEMORY_DIR
71
+ constitution_file = memory_dir / "constitution.md"
72
+
73
+ if not memory_dir.exists():
74
+ return (
75
+ False,
76
+ f"Spec-Kit memory directory not found: {memory_dir}\n"
77
+ "The constitution must be created before syncing.\n"
78
+ "Run '/speckit.constitution' command first to create the project constitution.",
79
+ )
80
+
81
+ if not constitution_file.exists():
82
+ return (
83
+ False,
84
+ f"Constitution file not found: {constitution_file}\n"
85
+ "The constitution is required before syncing Spec-Kit artifacts.\n"
86
+ "Run '/speckit.constitution' command first to create the project constitution.",
87
+ )
88
+
89
+ # Check if file is empty or only contains whitespace/placeholders
90
+ try:
91
+ content = constitution_file.read_text(encoding="utf-8").strip()
92
+ if not content:
93
+ return (
94
+ False,
95
+ f"Constitution file is empty: {constitution_file}\n"
96
+ "The constitution must be populated before syncing.\n"
97
+ "Run '/speckit.constitution' command to fill in the constitution template.",
98
+ )
99
+
100
+ # Check if file only contains template placeholders (no actual content)
101
+ # Look for patterns like [PROJECT_NAME], [PRINCIPLE_1_NAME], etc.
102
+ placeholder_pattern = r"\[[A-Z_0-9]+\]"
103
+ placeholder_count = len(re.findall(placeholder_pattern, content))
104
+ # If more than 50% of lines contain placeholders, consider it a template
105
+ lines = [line.strip() for line in content.split("\n") if line.strip()]
106
+ if lines and placeholder_count > len(lines) * 0.5:
107
+ return (
108
+ False,
109
+ f"Constitution file contains only template placeholders: {constitution_file}\n"
110
+ "The constitution must be filled in before syncing.\n"
111
+ "Run '/speckit.constitution' command to replace placeholders with actual project principles.",
112
+ )
113
+
114
+ return (True, "")
115
+ except Exception as e:
116
+ return (
117
+ False,
118
+ f"Error reading constitution file: {constitution_file}\n"
119
+ f"Error: {e!s}\n"
120
+ "Please ensure the constitution file is readable and try again.",
121
+ )
122
+
58
123
  @beartype
59
124
  @ensure(lambda result: isinstance(result, dict), "Must return dictionary")
60
125
  @ensure(lambda result: "is_speckit" in result, "Must include is_speckit key")