specfact-cli 0.6.3__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 (97) hide show
  1. specfact_cli/__init__.py +14 -0
  2. specfact_cli/agents/__init__.py +24 -0
  3. specfact_cli/agents/analyze_agent.py +391 -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 +12 -0
  9. specfact_cli/analyzers/ambiguity_scanner.py +592 -0
  10. specfact_cli/analyzers/code_analyzer.py +1228 -0
  11. specfact_cli/analyzers/contract_extractor.py +419 -0
  12. specfact_cli/analyzers/control_flow_analyzer.py +281 -0
  13. specfact_cli/analyzers/requirement_extractor.py +337 -0
  14. specfact_cli/analyzers/test_pattern_extractor.py +330 -0
  15. specfact_cli/cli.py +264 -0
  16. specfact_cli/commands/__init__.py +7 -0
  17. specfact_cli/commands/constitution.py +261 -0
  18. specfact_cli/commands/enforce.py +96 -0
  19. specfact_cli/commands/import_cmd.py +694 -0
  20. specfact_cli/commands/init.py +143 -0
  21. specfact_cli/commands/plan.py +2398 -0
  22. specfact_cli/commands/repro.py +214 -0
  23. specfact_cli/commands/sync.py +744 -0
  24. specfact_cli/common/__init__.py +25 -0
  25. specfact_cli/common/logger_setup.py +654 -0
  26. specfact_cli/common/logging_utils.py +41 -0
  27. specfact_cli/common/text_utils.py +52 -0
  28. specfact_cli/common/utils.py +48 -0
  29. specfact_cli/comparators/__init__.py +11 -0
  30. specfact_cli/comparators/plan_comparator.py +391 -0
  31. specfact_cli/enrichers/constitution_enricher.py +765 -0
  32. specfact_cli/enrichers/plan_enricher.py +268 -0
  33. specfact_cli/generators/__init__.py +14 -0
  34. specfact_cli/generators/plan_generator.py +105 -0
  35. specfact_cli/generators/protocol_generator.py +115 -0
  36. specfact_cli/generators/report_generator.py +200 -0
  37. specfact_cli/generators/workflow_generator.py +120 -0
  38. specfact_cli/importers/__init__.py +7 -0
  39. specfact_cli/importers/speckit_converter.py +1051 -0
  40. specfact_cli/importers/speckit_scanner.py +776 -0
  41. specfact_cli/models/__init__.py +33 -0
  42. specfact_cli/models/deviation.py +105 -0
  43. specfact_cli/models/enforcement.py +150 -0
  44. specfact_cli/models/plan.py +139 -0
  45. specfact_cli/models/protocol.py +28 -0
  46. specfact_cli/modes/__init__.py +19 -0
  47. specfact_cli/modes/detector.py +126 -0
  48. specfact_cli/modes/router.py +153 -0
  49. specfact_cli/resources/mappings/node-async.yaml +49 -0
  50. specfact_cli/resources/mappings/python-async.yaml +47 -0
  51. specfact_cli/resources/mappings/speckit-default.yaml +82 -0
  52. specfact_cli/resources/prompts/specfact-enforce.md +185 -0
  53. specfact_cli/resources/prompts/specfact-import-from-code.md +597 -0
  54. specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
  55. specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
  56. specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
  57. specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
  58. specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
  59. specfact_cli/resources/prompts/specfact-plan-review.md +869 -0
  60. specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
  61. specfact_cli/resources/prompts/specfact-plan-update-feature.md +234 -0
  62. specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
  63. specfact_cli/resources/prompts/specfact-repro.md +268 -0
  64. specfact_cli/resources/prompts/specfact-sync.md +457 -0
  65. specfact_cli/resources/schemas/deviation.schema.json +61 -0
  66. specfact_cli/resources/schemas/plan.schema.json +204 -0
  67. specfact_cli/resources/schemas/protocol.schema.json +53 -0
  68. specfact_cli/resources/semgrep/async.yml +285 -0
  69. specfact_cli/resources/templates/github-action.yml.j2 +140 -0
  70. specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
  71. specfact_cli/resources/templates/pr-template.md.j2 +58 -0
  72. specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
  73. specfact_cli/resources/templates/telemetry.yaml.example +35 -0
  74. specfact_cli/sync/__init__.py +21 -0
  75. specfact_cli/sync/repository_sync.py +279 -0
  76. specfact_cli/sync/speckit_sync.py +388 -0
  77. specfact_cli/sync/watcher.py +268 -0
  78. specfact_cli/telemetry.py +440 -0
  79. specfact_cli/utils/__init__.py +58 -0
  80. specfact_cli/utils/console.py +70 -0
  81. specfact_cli/utils/enrichment_parser.py +445 -0
  82. specfact_cli/utils/feature_keys.py +212 -0
  83. specfact_cli/utils/git.py +241 -0
  84. specfact_cli/utils/github_annotations.py +399 -0
  85. specfact_cli/utils/ide_setup.py +389 -0
  86. specfact_cli/utils/prompts.py +180 -0
  87. specfact_cli/utils/structure.py +674 -0
  88. specfact_cli/utils/yaml_utils.py +200 -0
  89. specfact_cli/validators/__init__.py +20 -0
  90. specfact_cli/validators/fsm.py +262 -0
  91. specfact_cli/validators/repro_checker.py +780 -0
  92. specfact_cli/validators/schema.py +196 -0
  93. specfact_cli-0.6.3.dist-info/METADATA +456 -0
  94. specfact_cli-0.6.3.dist-info/RECORD +97 -0
  95. specfact_cli-0.6.3.dist-info/WHEEL +4 -0
  96. specfact_cli-0.6.3.dist-info/entry_points.txt +2 -0
  97. specfact_cli-0.6.3.dist-info/licenses/LICENSE.md +202 -0
@@ -0,0 +1,694 @@
1
+ """
2
+ Import command - Import codebases and Spec-Kit projects to contract-driven format.
3
+
4
+ This module provides commands for importing existing codebases (brownfield) and
5
+ Spec-Kit projects and converting them to SpecFact contract-driven format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from beartype import beartype
14
+ from icontract import ensure, require
15
+ from rich.console import Console
16
+ from rich.progress import Progress, SpinnerColumn, TextColumn
17
+
18
+ from specfact_cli.telemetry import telemetry
19
+
20
+
21
+ app = typer.Typer(help="Import codebases and Spec-Kit projects to contract format")
22
+ console = Console()
23
+
24
+
25
+ def _is_valid_repo_path(path: Path) -> bool:
26
+ """Check if path exists and is a directory."""
27
+ return path.exists() and path.is_dir()
28
+
29
+
30
+ def _is_valid_output_path(path: Path | None) -> bool:
31
+ """Check if output path exists if provided."""
32
+ return path is None or path.exists()
33
+
34
+
35
+ def _count_python_files(repo: Path) -> int:
36
+ """Count Python files for anonymized telemetry metrics."""
37
+ return sum(1 for _ in repo.rglob("*.py"))
38
+
39
+
40
+ @app.command("from-spec-kit")
41
+ def from_spec_kit(
42
+ repo: Path = typer.Option(
43
+ Path("."),
44
+ "--repo",
45
+ help="Path to Spec-Kit repository",
46
+ exists=True,
47
+ file_okay=False,
48
+ dir_okay=True,
49
+ ),
50
+ dry_run: bool = typer.Option(
51
+ False,
52
+ "--dry-run",
53
+ help="Preview changes without writing files",
54
+ ),
55
+ write: bool = typer.Option(
56
+ False,
57
+ "--write",
58
+ help="Write changes to disk",
59
+ ),
60
+ out_branch: str = typer.Option(
61
+ "feat/specfact-migration",
62
+ "--out-branch",
63
+ help="Feature branch name for migration",
64
+ ),
65
+ report: Path | None = typer.Option(
66
+ None,
67
+ "--report",
68
+ help="Path to write import report",
69
+ ),
70
+ force: bool = typer.Option(
71
+ False,
72
+ "--force",
73
+ help="Overwrite existing files",
74
+ ),
75
+ ) -> None:
76
+ """
77
+ Convert Spec-Kit project to SpecFact contract format.
78
+
79
+ This command scans a Spec-Kit repository, parses its structure,
80
+ and generates equivalent SpecFact contracts, protocols, and plans.
81
+
82
+ Example:
83
+ specfact import from-spec-kit --repo ./my-project --write
84
+ """
85
+ from specfact_cli.importers.speckit_converter import SpecKitConverter
86
+ from specfact_cli.importers.speckit_scanner import SpecKitScanner
87
+ from specfact_cli.utils.structure import SpecFactStructure
88
+
89
+ telemetry_metadata = {
90
+ "dry_run": dry_run,
91
+ "write": write,
92
+ "force": force,
93
+ }
94
+
95
+ with telemetry.track_command("import.from_spec_kit", telemetry_metadata) as record:
96
+ console.print(f"[bold cyan]Importing Spec-Kit project from:[/bold cyan] {repo}")
97
+
98
+ # Scan Spec-Kit structure
99
+ scanner = SpecKitScanner(repo)
100
+
101
+ if not scanner.is_speckit_repo():
102
+ console.print("[bold red]✗[/bold red] Not a Spec-Kit repository")
103
+ console.print("[dim]Expected: .specify/ directory[/dim]")
104
+ raise typer.Exit(1)
105
+
106
+ structure = scanner.scan_structure()
107
+
108
+ if dry_run:
109
+ console.print("[yellow]→ Dry run mode - no files will be written[/yellow]")
110
+ console.print("\n[bold]Detected Structure:[/bold]")
111
+ console.print(f" - Specs Directory: {structure.get('specs_dir', 'Not found')}")
112
+ console.print(f" - Memory Directory: {structure.get('specify_memory_dir', 'Not found')}")
113
+ if structure.get("feature_dirs"):
114
+ console.print(f" - Features Found: {len(structure['feature_dirs'])}")
115
+ if structure.get("memory_files"):
116
+ console.print(f" - Memory Files: {len(structure['memory_files'])}")
117
+ record({"dry_run": True, "features_found": len(structure.get("feature_dirs", []))})
118
+ return
119
+
120
+ if not write:
121
+ console.print("[yellow]→ Use --write to actually convert files[/yellow]")
122
+ console.print("[dim]Use --dry-run to preview changes[/dim]")
123
+ return
124
+
125
+ # Ensure SpecFact structure exists
126
+ SpecFactStructure.ensure_structure(repo)
127
+
128
+ with Progress(
129
+ SpinnerColumn(),
130
+ TextColumn("[progress.description]{task.description}"),
131
+ console=console,
132
+ ) as progress:
133
+ # Step 1: Discover features from markdown artifacts
134
+ task = progress.add_task("Discovering Spec-Kit features...", total=None)
135
+ features = scanner.discover_features()
136
+ if not features:
137
+ console.print("[bold red]✗[/bold red] No features found in Spec-Kit repository")
138
+ console.print("[dim]Expected: specs/*/spec.md files[/dim]")
139
+ raise typer.Exit(1)
140
+ progress.update(task, description=f"✓ Discovered {len(features)} features")
141
+
142
+ # Step 2: Convert protocol
143
+ task = progress.add_task("Converting protocol...", total=None)
144
+ converter = SpecKitConverter(repo)
145
+ protocol = None
146
+ plan_bundle = None
147
+ try:
148
+ protocol = converter.convert_protocol()
149
+ progress.update(task, description=f"✓ Protocol converted ({len(protocol.states)} states)")
150
+
151
+ # Step 3: Convert plan
152
+ task = progress.add_task("Converting plan bundle...", total=None)
153
+ plan_bundle = converter.convert_plan()
154
+ progress.update(task, description=f"✓ Plan converted ({len(plan_bundle.features)} features)")
155
+
156
+ # Step 4: Generate Semgrep rules
157
+ task = progress.add_task("Generating Semgrep rules...", total=None)
158
+ _semgrep_path = converter.generate_semgrep_rules() # Not used yet
159
+ progress.update(task, description="✓ Semgrep rules generated")
160
+
161
+ # Step 5: Generate GitHub Action workflow
162
+ task = progress.add_task("Generating GitHub Action workflow...", total=None)
163
+ repo_name = repo.name if isinstance(repo, Path) else None
164
+ _workflow_path = converter.generate_github_action(repo_name=repo_name) # Not used yet
165
+ progress.update(task, description="✓ GitHub Action workflow generated")
166
+
167
+ except Exception as e:
168
+ console.print(f"[bold red]✗[/bold red] Conversion failed: {e}")
169
+ raise typer.Exit(1) from e
170
+
171
+ # Generate report
172
+ if report and protocol and plan_bundle:
173
+ report_content = f"""# Spec-Kit Import Report
174
+
175
+ ## Repository: {repo}
176
+
177
+ ## Summary
178
+ - **States Found**: {len(protocol.states)}
179
+ - **Transitions**: {len(protocol.transitions)}
180
+ - **Features Extracted**: {len(plan_bundle.features)}
181
+ - **Total Stories**: {sum(len(f.stories) for f in plan_bundle.features)}
182
+
183
+ ## Generated Files
184
+ - **Protocol**: `.specfact/protocols/workflow.protocol.yaml`
185
+ - **Plan Bundle**: `.specfact/plans/main.bundle.yaml`
186
+ - **Semgrep Rules**: `.semgrep/async-anti-patterns.yml`
187
+ - **GitHub Action**: `.github/workflows/specfact-gate.yml`
188
+
189
+ ## States
190
+ {chr(10).join(f"- {state}" for state in protocol.states)}
191
+
192
+ ## Features
193
+ {chr(10).join(f"- {f.title} ({f.key})" for f in plan_bundle.features)}
194
+ """
195
+ report.parent.mkdir(parents=True, exist_ok=True)
196
+ report.write_text(report_content, encoding="utf-8")
197
+ console.print(f"[dim]Report written to: {report}[/dim]")
198
+
199
+ console.print("[bold green]✓[/bold green] Import complete!")
200
+ console.print("[dim]Protocol: .specfact/protocols/workflow.protocol.yaml[/dim]")
201
+ console.print("[dim]Plan: .specfact/plans/main.bundle.yaml[/dim]")
202
+ console.print("[dim]Semgrep Rules: .semgrep/async-anti-patterns.yml[/dim]")
203
+ console.print("[dim]GitHub Action: .github/workflows/specfact-gate.yml[/dim]")
204
+
205
+ # Record import results
206
+ if protocol and plan_bundle:
207
+ record(
208
+ {
209
+ "states_found": len(protocol.states),
210
+ "transitions": len(protocol.transitions),
211
+ "features_extracted": len(plan_bundle.features),
212
+ "total_stories": sum(len(f.stories) for f in plan_bundle.features),
213
+ }
214
+ )
215
+
216
+
217
+ @app.command("from-code")
218
+ @require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory")
219
+ @require(lambda confidence: 0.0 <= confidence <= 1.0, "Confidence must be 0.0-1.0")
220
+ @ensure(lambda out: _is_valid_output_path(out), "Output path must exist if provided")
221
+ @beartype
222
+ def from_code(
223
+ repo: Path = typer.Option(
224
+ Path("."),
225
+ "--repo",
226
+ help="Path to repository to import",
227
+ exists=True,
228
+ file_okay=False,
229
+ dir_okay=True,
230
+ ),
231
+ name: str | None = typer.Option(
232
+ None,
233
+ "--name",
234
+ help="Custom plan name (will be sanitized for filesystem, default: 'auto-derived')",
235
+ ),
236
+ out: Path | None = typer.Option(
237
+ None,
238
+ "--out",
239
+ help="Output plan bundle path (default: .specfact/plans/<name>-<timestamp>.bundle.yaml)",
240
+ ),
241
+ shadow_only: bool = typer.Option(
242
+ False,
243
+ "--shadow-only",
244
+ help="Shadow mode - observe without enforcing",
245
+ ),
246
+ report: Path | None = typer.Option(
247
+ None,
248
+ "--report",
249
+ help="Path to write analysis report (default: .specfact/reports/brownfield/analysis-<timestamp>.md)",
250
+ ),
251
+ confidence: float = typer.Option(
252
+ 0.5,
253
+ "--confidence",
254
+ min=0.0,
255
+ max=1.0,
256
+ help="Minimum confidence score for features",
257
+ ),
258
+ key_format: str = typer.Option(
259
+ "classname",
260
+ "--key-format",
261
+ help="Feature key format: 'classname' (FEATURE-CLASSNAME) or 'sequential' (FEATURE-001)",
262
+ ),
263
+ enrichment: Path | None = typer.Option(
264
+ None,
265
+ "--enrichment",
266
+ help="Path to Markdown enrichment report from LLM (applies missing features, confidence adjustments, business context)",
267
+ ),
268
+ enrich_for_speckit: bool = typer.Option(
269
+ False,
270
+ "--enrich-for-speckit",
271
+ help="Automatically enrich plan for Spec-Kit compliance (runs plan review, adds testable acceptance criteria, ensures ≥2 stories per feature)",
272
+ ),
273
+ entry_point: Path | None = typer.Option(
274
+ None,
275
+ "--entry-point",
276
+ help="Subdirectory path for partial analysis (relative to repo root). Analyzes only files within this directory and subdirectories.",
277
+ ),
278
+ ) -> None:
279
+ """
280
+ Import plan bundle from existing codebase (one-way import).
281
+
282
+ Analyzes code structure using AI-first semantic understanding or AST-based fallback
283
+ to generate a plan bundle that represents the current system.
284
+
285
+ Supports dual-stack enrichment workflow: apply LLM-generated enrichment report
286
+ to refine the auto-detected plan bundle (add missing features, adjust confidence scores,
287
+ add business context).
288
+
289
+ Example:
290
+ specfact import from-code --repo . --out brownfield-plan.yaml
291
+ specfact import from-code --repo . --enrichment enrichment-report.md
292
+ """
293
+ from specfact_cli.agents.analyze_agent import AnalyzeAgent
294
+ from specfact_cli.agents.registry import get_agent
295
+ from specfact_cli.cli import get_current_mode
296
+ from specfact_cli.modes import get_router
297
+
298
+ mode = get_current_mode()
299
+
300
+ # Route command based on mode
301
+ router = get_router()
302
+ routing_result = router.route("import from-code", mode, {"repo": str(repo), "confidence": confidence})
303
+
304
+ python_file_count = _count_python_files(repo)
305
+
306
+ from specfact_cli.generators.plan_generator import PlanGenerator
307
+ from specfact_cli.utils.structure import SpecFactStructure
308
+ from specfact_cli.validators.schema import validate_plan_bundle
309
+
310
+ # Ensure .specfact structure exists in the repository being imported
311
+ SpecFactStructure.ensure_structure(repo)
312
+
313
+ # Use default paths if not specified (relative to repo)
314
+ # If enrichment is provided, try to derive original plan path and create enriched copy
315
+ original_plan_path: Path | None = None
316
+ if enrichment and enrichment.exists():
317
+ original_plan_path = SpecFactStructure.get_plan_bundle_from_enrichment(enrichment, base_path=repo)
318
+ if original_plan_path:
319
+ # Create enriched plan path with clear label
320
+ out = SpecFactStructure.get_enriched_plan_path(original_plan_path, base_path=repo)
321
+ else:
322
+ # Enrichment provided but original plan not found, use default naming
323
+ out = SpecFactStructure.get_timestamped_brownfield_report(repo, name=name)
324
+ elif out is None:
325
+ out = SpecFactStructure.get_timestamped_brownfield_report(repo, name=name)
326
+
327
+ if report is None:
328
+ report = SpecFactStructure.get_brownfield_analysis_path(repo)
329
+
330
+ console.print(f"[bold cyan]Importing repository:[/bold cyan] {repo}")
331
+ console.print(f"[dim]Confidence threshold: {confidence}[/dim]")
332
+
333
+ if shadow_only:
334
+ console.print("[yellow]→ Shadow mode - observe without enforcement[/yellow]")
335
+
336
+ telemetry_metadata = {
337
+ "mode": mode.value,
338
+ "execution_mode": routing_result.execution_mode,
339
+ "files_analyzed": python_file_count,
340
+ "shadow_mode": shadow_only,
341
+ }
342
+
343
+ with telemetry.track_command("import.from_code", telemetry_metadata) as record_event:
344
+ try:
345
+ # If enrichment is provided and original plan exists, load it instead of analyzing
346
+ if enrichment and original_plan_path and original_plan_path.exists():
347
+ console.print(f"[dim]Loading original plan for enrichment: {original_plan_path.name}[/dim]")
348
+ import yaml
349
+
350
+ from specfact_cli.models.plan import PlanBundle
351
+
352
+ with original_plan_path.open() as f:
353
+ plan_data = yaml.safe_load(f)
354
+ plan_bundle = PlanBundle.model_validate(plan_data)
355
+ total_stories = sum(len(f.stories) for f in plan_bundle.features)
356
+ console.print(
357
+ f"[green]✓[/green] Loaded original plan: {len(plan_bundle.features)} features, {total_stories} stories"
358
+ )
359
+ else:
360
+ # Use AI-first approach in CoPilot mode, fallback to AST in CI/CD mode
361
+ if routing_result.execution_mode == "agent":
362
+ console.print("[dim]Mode: CoPilot (AI-first import)[/dim]")
363
+ # Get agent for this command
364
+ agent = get_agent("import from-code")
365
+ if agent and isinstance(agent, AnalyzeAgent):
366
+ # Build context for agent
367
+ context = {
368
+ "workspace": str(repo),
369
+ "current_file": None, # TODO: Get from IDE in Phase 4.2+
370
+ "selection": None, # TODO: Get from IDE in Phase 4.2+
371
+ }
372
+ # Inject context (for future LLM integration)
373
+ _enhanced_context = agent.inject_context(context)
374
+ # Use AI-first import
375
+ console.print("\n[cyan]🤖 AI-powered import (semantic understanding)...[/cyan]")
376
+ plan_bundle = agent.analyze_codebase(repo, confidence=confidence, plan_name=name)
377
+ console.print("[green]✓[/green] AI import complete")
378
+ else:
379
+ # Fallback to AST if agent not available
380
+ console.print("[yellow]⚠ Agent not available, falling back to AST-based import[/yellow]")
381
+ from specfact_cli.analyzers.code_analyzer import CodeAnalyzer
382
+
383
+ console.print(
384
+ "\n[yellow]⏱️ Note: This analysis may take 2+ minutes for large codebases[/yellow]"
385
+ )
386
+ if entry_point:
387
+ console.print(f"[cyan]🔍 Analyzing codebase (scoped to {entry_point})...[/cyan]\n")
388
+ else:
389
+ console.print("[cyan]🔍 Analyzing codebase (AST-based fallback)...[/cyan]\n")
390
+ analyzer = CodeAnalyzer(
391
+ repo,
392
+ confidence_threshold=confidence,
393
+ key_format=key_format,
394
+ plan_name=name,
395
+ entry_point=entry_point,
396
+ )
397
+ plan_bundle = analyzer.analyze()
398
+ else:
399
+ # CI/CD mode: use AST-based import (no LLM available)
400
+ console.print("[dim]Mode: CI/CD (AST-based import)[/dim]")
401
+ from specfact_cli.analyzers.code_analyzer import CodeAnalyzer
402
+
403
+ console.print("\n[yellow]⏱️ Note: This analysis may take 2+ minutes for large codebases[/yellow]")
404
+ if entry_point:
405
+ console.print(f"[cyan]🔍 Analyzing codebase (scoped to {entry_point})...[/cyan]\n")
406
+ else:
407
+ console.print("[cyan]🔍 Analyzing codebase...[/cyan]\n")
408
+ analyzer = CodeAnalyzer(
409
+ repo,
410
+ confidence_threshold=confidence,
411
+ key_format=key_format,
412
+ plan_name=name,
413
+ entry_point=entry_point,
414
+ )
415
+ plan_bundle = analyzer.analyze()
416
+
417
+ console.print(f"[green]✓[/green] Found {len(plan_bundle.features)} features")
418
+ console.print(f"[green]✓[/green] Detected themes: {', '.join(plan_bundle.product.themes)}")
419
+
420
+ # Show summary
421
+ total_stories = sum(len(f.stories) for f in plan_bundle.features)
422
+ console.print(f"[green]✓[/green] Total stories: {total_stories}\n")
423
+
424
+ record_event({"features_detected": len(plan_bundle.features), "stories_detected": total_stories})
425
+
426
+ # Apply enrichment if provided
427
+ if enrichment:
428
+ if not enrichment.exists():
429
+ console.print(f"[bold red]✗ Enrichment report not found: {enrichment}[/bold red]")
430
+ raise typer.Exit(1)
431
+
432
+ console.print(f"\n[cyan]📝 Applying enrichment from: {enrichment}[/cyan]")
433
+ from specfact_cli.utils.enrichment_parser import EnrichmentParser, apply_enrichment
434
+
435
+ try:
436
+ parser = EnrichmentParser()
437
+ enrichment_report = parser.parse(enrichment)
438
+ plan_bundle = apply_enrichment(plan_bundle, enrichment_report)
439
+
440
+ # Report enrichment results
441
+ if enrichment_report.missing_features:
442
+ console.print(
443
+ f"[green]✓[/green] Added {len(enrichment_report.missing_features)} missing features"
444
+ )
445
+ if enrichment_report.confidence_adjustments:
446
+ console.print(
447
+ f"[green]✓[/green] Adjusted confidence for {len(enrichment_report.confidence_adjustments)} features"
448
+ )
449
+ if enrichment_report.business_context.get("priorities") or enrichment_report.business_context.get(
450
+ "constraints"
451
+ ):
452
+ console.print("[green]✓[/green] Applied business context")
453
+
454
+ # Update enrichment metrics
455
+ record_event(
456
+ {
457
+ "enrichment_applied": True,
458
+ "features_added": len(enrichment_report.missing_features),
459
+ "confidence_adjusted": len(enrichment_report.confidence_adjustments),
460
+ }
461
+ )
462
+ except Exception as e:
463
+ console.print(f"[bold red]✗ Failed to apply enrichment: {e}[/bold red]")
464
+ raise typer.Exit(1) from e
465
+
466
+ # Generate plan file
467
+ out.parent.mkdir(parents=True, exist_ok=True)
468
+ generator = PlanGenerator()
469
+ generator.generate(plan_bundle, out)
470
+
471
+ console.print("[bold green]✓ Import complete![/bold green]")
472
+ if enrichment and original_plan_path and original_plan_path.exists():
473
+ console.print(f"[dim]Original plan: {original_plan_path.name}[/dim]")
474
+ console.print(f"[dim]Enriched plan: {out.name}[/dim]")
475
+ else:
476
+ console.print(f"[dim]Plan bundle written to: {out}[/dim]")
477
+
478
+ # Suggest constitution bootstrap for brownfield imports
479
+ specify_dir = repo / ".specify" / "memory"
480
+ constitution_path = specify_dir / "constitution.md"
481
+ if not constitution_path.exists() or (
482
+ constitution_path.exists()
483
+ and constitution_path.read_text(encoding="utf-8").strip() in ("", "# Constitution")
484
+ ):
485
+ # Auto-generate in test mode, prompt in interactive mode
486
+ import os
487
+
488
+ # Check for test environment (TEST_MODE or PYTEST_CURRENT_TEST)
489
+ is_test_env = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None
490
+ if is_test_env:
491
+ # Auto-generate bootstrap constitution in test mode
492
+ from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher
493
+
494
+ specify_dir.mkdir(parents=True, exist_ok=True)
495
+ enricher = ConstitutionEnricher()
496
+ enriched_content = enricher.bootstrap(repo, constitution_path)
497
+ constitution_path.write_text(enriched_content, encoding="utf-8")
498
+ else:
499
+ # Check if we're in an interactive environment
500
+ import sys
501
+
502
+ is_interactive = (hasattr(sys.stdin, "isatty") and sys.stdin.isatty()) and sys.stdin.isatty()
503
+ if is_interactive:
504
+ console.print()
505
+ console.print(
506
+ "[bold cyan]💡 Tip:[/bold cyan] Generate project constitution for Spec-Kit integration"
507
+ )
508
+ suggest_constitution = typer.confirm(
509
+ "Generate bootstrap constitution from repository analysis?",
510
+ default=True,
511
+ )
512
+ if suggest_constitution:
513
+ from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher
514
+
515
+ console.print("[dim]Generating bootstrap constitution...[/dim]")
516
+ specify_dir.mkdir(parents=True, exist_ok=True)
517
+ enricher = ConstitutionEnricher()
518
+ enriched_content = enricher.bootstrap(repo, constitution_path)
519
+ constitution_path.write_text(enriched_content, encoding="utf-8")
520
+ console.print("[bold green]✓[/bold green] Bootstrap constitution generated")
521
+ console.print(f"[dim]Review and adjust: {constitution_path}[/dim]")
522
+ console.print(
523
+ "[dim]Then run 'specfact sync spec-kit' to sync with Spec-Kit artifacts[/dim]"
524
+ )
525
+ else:
526
+ # Non-interactive mode: skip prompt
527
+ console.print()
528
+ console.print(
529
+ "[dim]💡 Tip: Run 'specfact constitution bootstrap --repo .' to generate constitution[/dim]"
530
+ )
531
+
532
+ # Enrich for Spec-Kit compliance if requested
533
+ if enrich_for_speckit:
534
+ console.print("\n[cyan]🔧 Enriching plan for Spec-Kit compliance...[/cyan]")
535
+ try:
536
+ from specfact_cli.analyzers.ambiguity_scanner import AmbiguityScanner
537
+
538
+ # Run plan review to identify gaps
539
+ console.print("[dim]Running plan review to identify gaps...[/dim]")
540
+ scanner = AmbiguityScanner()
541
+ _ambiguity_report = scanner.scan(plan_bundle) # Scanned but not used in auto-enrichment
542
+
543
+ # Add missing stories for features with only 1 story
544
+ features_with_one_story = [f for f in plan_bundle.features if len(f.stories) == 1]
545
+ if features_with_one_story:
546
+ console.print(
547
+ f"[yellow]⚠ Found {len(features_with_one_story)} features with only 1 story[/yellow]"
548
+ )
549
+ console.print("[dim]Adding edge case stories for better Spec-Kit compliance...[/dim]")
550
+
551
+ for feature in features_with_one_story:
552
+ # Generate edge case story based on feature title
553
+ edge_case_title = f"As a user, I receive error handling for {feature.title.lower()}"
554
+ edge_case_acceptance = [
555
+ "Must verify error conditions are handled gracefully",
556
+ "Must validate error messages are clear and actionable",
557
+ "Must ensure system recovers from errors",
558
+ ]
559
+
560
+ # Find next story number - extract from existing story keys
561
+ existing_story_nums = []
562
+ for s in feature.stories:
563
+ # Story keys are like STORY-CLASSNAME-001 or STORY-001
564
+ parts = s.key.split("-")
565
+ if len(parts) >= 2:
566
+ # Get the last part which should be the number
567
+ last_part = parts[-1]
568
+ if last_part.isdigit():
569
+ existing_story_nums.append(int(last_part))
570
+
571
+ next_story_num = max(existing_story_nums) + 1 if existing_story_nums else 2
572
+
573
+ # Extract class name from feature key (FEATURE-CLASSNAME -> CLASSNAME)
574
+ feature_key_parts = feature.key.split("-")
575
+ if len(feature_key_parts) >= 2:
576
+ class_name = feature_key_parts[-1] # Get last part (CLASSNAME)
577
+ story_key = f"STORY-{class_name}-{next_story_num:03d}"
578
+ else:
579
+ # Fallback if feature key format is unexpected
580
+ story_key = f"STORY-{next_story_num:03d}"
581
+
582
+ from specfact_cli.models.plan import Story
583
+
584
+ edge_case_story = Story(
585
+ key=story_key,
586
+ title=edge_case_title,
587
+ acceptance=edge_case_acceptance,
588
+ story_points=3,
589
+ value_points=None,
590
+ confidence=0.8,
591
+ scenarios=None,
592
+ contracts=None,
593
+ )
594
+ feature.stories.append(edge_case_story)
595
+
596
+ # Regenerate plan with new stories
597
+ generator = PlanGenerator()
598
+ generator.generate(plan_bundle, out)
599
+ console.print(
600
+ f"[green]✓ Added edge case stories to {len(features_with_one_story)} features[/green]"
601
+ )
602
+
603
+ # Ensure testable acceptance criteria
604
+ features_updated = 0
605
+ for feature in plan_bundle.features:
606
+ for story in feature.stories:
607
+ # Check if acceptance criteria are testable
608
+ testable_count = sum(
609
+ 1
610
+ for acc in story.acceptance
611
+ if any(
612
+ keyword in acc.lower()
613
+ for keyword in ["must", "should", "verify", "validate", "ensure"]
614
+ )
615
+ )
616
+
617
+ if testable_count < len(story.acceptance) and len(story.acceptance) > 0:
618
+ # Enhance acceptance criteria to be more testable
619
+ enhanced_acceptance = []
620
+ for acc in story.acceptance:
621
+ if not any(
622
+ keyword in acc.lower()
623
+ for keyword in ["must", "should", "verify", "validate", "ensure"]
624
+ ):
625
+ # Convert to testable format
626
+ if acc.startswith(("User can", "System can")):
627
+ enhanced_acceptance.append(f"Must verify {acc.lower()}")
628
+ else:
629
+ enhanced_acceptance.append(f"Must verify {acc}")
630
+ else:
631
+ enhanced_acceptance.append(acc)
632
+
633
+ story.acceptance = enhanced_acceptance
634
+ features_updated += 1
635
+
636
+ if features_updated > 0:
637
+ # Regenerate plan with enhanced acceptance criteria
638
+ generator = PlanGenerator()
639
+ generator.generate(plan_bundle, out)
640
+ console.print(f"[green]✓ Enhanced acceptance criteria for {features_updated} stories[/green]")
641
+
642
+ console.print("[green]✓ Spec-Kit enrichment complete[/green]")
643
+
644
+ except Exception as e:
645
+ console.print(f"[yellow]⚠ Spec-Kit enrichment failed: {e}[/yellow]")
646
+ console.print("[dim]Plan is still valid, but may need manual enrichment[/dim]")
647
+
648
+ # Validate generated plan
649
+ is_valid, error, _ = validate_plan_bundle(out)
650
+ if is_valid:
651
+ console.print("[green]✓ Plan validation passed[/green]")
652
+ else:
653
+ console.print(f"[yellow]⚠ Plan validation warning: {error}[/yellow]")
654
+
655
+ # Generate report
656
+ report_content = f"""# Brownfield Import Report
657
+
658
+ ## Repository: {repo}
659
+
660
+ ## Summary
661
+ - **Features Found**: {len(plan_bundle.features)}
662
+ - **Total Stories**: {total_stories}
663
+ - **Detected Themes**: {", ".join(plan_bundle.product.themes)}
664
+ - **Confidence Threshold**: {confidence}
665
+ """
666
+ if enrichment and original_plan_path and original_plan_path.exists():
667
+ report_content += f"""
668
+ ## Enrichment Applied
669
+ - **Original Plan**: `{original_plan_path}`
670
+ - **Enriched Plan**: `{out}`
671
+ - **Enrichment Report**: `{enrichment}`
672
+ """
673
+ report_content += f"""
674
+ ## Output Files
675
+ - **Plan Bundle**: `{out}`
676
+ - **Import Report**: `{report}`
677
+
678
+ ## Features
679
+
680
+ """
681
+ for feature in plan_bundle.features:
682
+ report_content += f"### {feature.title} ({feature.key})\n"
683
+ report_content += f"- **Stories**: {len(feature.stories)}\n"
684
+ report_content += f"- **Confidence**: {feature.confidence}\n"
685
+ report_content += f"- **Outcomes**: {', '.join(feature.outcomes)}\n\n"
686
+
687
+ # Type guard: report is guaranteed to be Path after line 323
688
+ assert report is not None, "Report path must be set"
689
+ report.write_text(report_content)
690
+ console.print(f"[dim]Report written to: {report}[/dim]")
691
+
692
+ except Exception as e:
693
+ console.print(f"[bold red]✗ Import failed:[/bold red] {e}")
694
+ raise typer.Exit(1) from e