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,1089 @@
1
+ """
2
+ Plan command - Manage greenfield development plans.
3
+
4
+ This module provides commands for creating and managing development plans,
5
+ features, and stories.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from contextlib import suppress
11
+ from datetime import UTC
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import typer
16
+ from beartype import beartype
17
+ from icontract import require
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from specfact_cli.comparators.plan_comparator import PlanComparator
22
+ from specfact_cli.generators.plan_generator import PlanGenerator
23
+ from specfact_cli.generators.report_generator import ReportFormat, ReportGenerator
24
+ from specfact_cli.models.deviation import Deviation, ValidationReport
25
+ from specfact_cli.models.enforcement import EnforcementConfig
26
+ from specfact_cli.models.plan import Business, Feature, Idea, Metadata, PlanBundle, Product, Release, Story
27
+ from specfact_cli.utils import (
28
+ display_summary,
29
+ print_error,
30
+ print_info,
31
+ print_section,
32
+ print_success,
33
+ print_warning,
34
+ prompt_confirm,
35
+ prompt_dict,
36
+ prompt_list,
37
+ prompt_text,
38
+ )
39
+ from specfact_cli.validators.schema import validate_plan_bundle
40
+
41
+
42
+ app = typer.Typer(help="Manage development plans, features, and stories")
43
+ console = Console()
44
+
45
+
46
+ @app.command("init")
47
+ @beartype
48
+ @require(lambda out: out is None or isinstance(out, Path), "Output must be None or Path")
49
+ def init(
50
+ interactive: bool = typer.Option(
51
+ True,
52
+ "--interactive/--no-interactive",
53
+ help="Interactive mode with prompts",
54
+ ),
55
+ out: Path | None = typer.Option(
56
+ None,
57
+ "--out",
58
+ help="Output plan bundle path (default: .specfact/plans/main.bundle.yaml)",
59
+ ),
60
+ scaffold: bool = typer.Option(
61
+ True,
62
+ "--scaffold/--no-scaffold",
63
+ help="Create complete .specfact directory structure",
64
+ ),
65
+ ) -> None:
66
+ """
67
+ Initialize a new development plan.
68
+
69
+ Creates a new plan bundle with idea, product, and features structure.
70
+ Optionally scaffolds the complete .specfact/ directory structure.
71
+
72
+ Example:
73
+ specfact plan init # Interactive with scaffold
74
+ specfact plan init --no-interactive # Minimal plan
75
+ specfact plan init --out .specfact/plans/feature-auth.bundle.yaml
76
+ """
77
+ from specfact_cli.utils.structure import SpecFactStructure
78
+
79
+ print_section("SpecFact CLI - Plan Builder")
80
+
81
+ # Create .specfact structure if requested
82
+ if scaffold:
83
+ print_info("Creating .specfact/ directory structure...")
84
+ SpecFactStructure.scaffold_project()
85
+ print_success("Directory structure created")
86
+ else:
87
+ # Ensure minimum structure exists
88
+ SpecFactStructure.ensure_structure()
89
+
90
+ # Use default path if not specified
91
+ if out is None:
92
+ out = SpecFactStructure.get_default_plan_path()
93
+
94
+ if not interactive:
95
+ # Non-interactive mode: create minimal plan
96
+ _create_minimal_plan(out)
97
+ return
98
+
99
+ # Interactive mode: guided plan creation
100
+ try:
101
+ plan = _build_plan_interactively()
102
+
103
+ # Generate plan file
104
+ out.parent.mkdir(parents=True, exist_ok=True)
105
+ generator = PlanGenerator()
106
+ generator.generate(plan, out)
107
+
108
+ print_success(f"Plan created successfully: {out}")
109
+
110
+ # Validate
111
+ is_valid, error, _ = validate_plan_bundle(out)
112
+ if is_valid:
113
+ print_success("Plan validation passed")
114
+ else:
115
+ print_warning(f"Plan has validation issues: {error}")
116
+
117
+ except KeyboardInterrupt:
118
+ print_warning("\nPlan creation cancelled")
119
+ raise typer.Exit(1) from None
120
+ except Exception as e:
121
+ print_error(f"Failed to create plan: {e}")
122
+ raise typer.Exit(1) from e
123
+
124
+
125
+ def _create_minimal_plan(out: Path) -> None:
126
+ """Create a minimal plan bundle."""
127
+ plan = PlanBundle(
128
+ version="1.0",
129
+ idea=None,
130
+ business=None,
131
+ product=Product(themes=[], releases=[]),
132
+ features=[],
133
+ metadata=None,
134
+ )
135
+
136
+ generator = PlanGenerator()
137
+ generator.generate(plan, out)
138
+ print_success(f"Minimal plan created: {out}")
139
+
140
+
141
+ def _build_plan_interactively() -> PlanBundle:
142
+ """Build a plan bundle through interactive prompts."""
143
+ # Section 1: Idea
144
+ print_section("1. Idea - What are you building?")
145
+
146
+ idea_title = prompt_text("Project title", required=True)
147
+ idea_narrative = prompt_text("Project narrative (brief description)", required=True)
148
+
149
+ add_idea_details = prompt_confirm("Add optional idea details? (target users, metrics)", default=False)
150
+
151
+ idea_data: dict[str, Any] = {"title": idea_title, "narrative": idea_narrative}
152
+
153
+ if add_idea_details:
154
+ target_users = prompt_list("Target users")
155
+ value_hypothesis = prompt_text("Value hypothesis", required=False)
156
+
157
+ if target_users:
158
+ idea_data["target_users"] = target_users
159
+ if value_hypothesis:
160
+ idea_data["value_hypothesis"] = value_hypothesis
161
+
162
+ if prompt_confirm("Add success metrics?", default=False):
163
+ metrics = prompt_dict("Success Metrics")
164
+ if metrics:
165
+ idea_data["metrics"] = metrics
166
+
167
+ idea = Idea(**idea_data)
168
+ display_summary("Idea Summary", idea_data)
169
+
170
+ # Section 2: Business (optional)
171
+ print_section("2. Business Context (optional)")
172
+
173
+ business = None
174
+ if prompt_confirm("Add business context?", default=False):
175
+ segments = prompt_list("Market segments")
176
+ problems = prompt_list("Problems you're solving")
177
+ solutions = prompt_list("Your solutions")
178
+ differentiation = prompt_list("How you differentiate")
179
+ risks = prompt_list("Business risks")
180
+
181
+ business = Business(
182
+ segments=segments if segments else [],
183
+ problems=problems if problems else [],
184
+ solutions=solutions if solutions else [],
185
+ differentiation=differentiation if differentiation else [],
186
+ risks=risks if risks else [],
187
+ )
188
+
189
+ # Section 3: Product
190
+ print_section("3. Product - Themes and Releases")
191
+
192
+ themes = prompt_list("Product themes (e.g., AI/ML, Security)")
193
+ releases: list[Release] = []
194
+
195
+ if prompt_confirm("Define releases?", default=True):
196
+ while True:
197
+ release_name = prompt_text("Release name (e.g., v1.0 - MVP)", required=False)
198
+ if not release_name:
199
+ break
200
+
201
+ objectives = prompt_list("Release objectives")
202
+ scope = prompt_list("Feature keys in scope (e.g., FEATURE-001)")
203
+ risks = prompt_list("Release risks")
204
+
205
+ releases.append(
206
+ Release(
207
+ name=release_name,
208
+ objectives=objectives if objectives else [],
209
+ scope=scope if scope else [],
210
+ risks=risks if risks else [],
211
+ )
212
+ )
213
+
214
+ if not prompt_confirm("Add another release?", default=False):
215
+ break
216
+
217
+ product = Product(themes=themes if themes else [], releases=releases)
218
+
219
+ # Section 4: Features
220
+ print_section("4. Features - What will you build?")
221
+
222
+ features: list[Feature] = []
223
+ while prompt_confirm("Add a feature?", default=True):
224
+ feature = _prompt_feature()
225
+ features.append(feature)
226
+
227
+ if not prompt_confirm("Add another feature?", default=False):
228
+ break
229
+
230
+ # Create plan bundle
231
+ plan = PlanBundle(
232
+ version="1.0",
233
+ idea=idea,
234
+ business=business,
235
+ product=product,
236
+ features=features,
237
+ metadata=None,
238
+ )
239
+
240
+ # Final summary
241
+ print_section("Plan Summary")
242
+ console.print(f"[cyan]Title:[/cyan] {idea.title}")
243
+ console.print(f"[cyan]Themes:[/cyan] {', '.join(product.themes)}")
244
+ console.print(f"[cyan]Features:[/cyan] {len(features)}")
245
+ console.print(f"[cyan]Releases:[/cyan] {len(product.releases)}")
246
+
247
+ return plan
248
+
249
+
250
+ def _prompt_feature() -> Feature:
251
+ """Prompt for feature details."""
252
+ print_info("\nNew Feature")
253
+
254
+ key = prompt_text("Feature key (e.g., FEATURE-001)", required=True)
255
+ title = prompt_text("Feature title", required=True)
256
+ outcomes = prompt_list("Expected outcomes")
257
+ acceptance = prompt_list("Acceptance criteria")
258
+
259
+ add_details = prompt_confirm("Add optional details?", default=False)
260
+
261
+ feature_data = {
262
+ "key": key,
263
+ "title": title,
264
+ "outcomes": outcomes if outcomes else [],
265
+ "acceptance": acceptance if acceptance else [],
266
+ }
267
+
268
+ if add_details:
269
+ constraints = prompt_list("Constraints")
270
+ if constraints:
271
+ feature_data["constraints"] = constraints
272
+
273
+ confidence = prompt_text("Confidence (0.0-1.0)", required=False)
274
+ if confidence:
275
+ with suppress(ValueError):
276
+ feature_data["confidence"] = float(confidence)
277
+
278
+ draft = prompt_confirm("Mark as draft?", default=False)
279
+ feature_data["draft"] = draft
280
+
281
+ # Add stories
282
+ stories: list[Story] = []
283
+ if prompt_confirm("Add stories to this feature?", default=True):
284
+ while True:
285
+ story = _prompt_story()
286
+ stories.append(story)
287
+
288
+ if not prompt_confirm("Add another story?", default=False):
289
+ break
290
+
291
+ feature_data["stories"] = stories
292
+
293
+ return Feature(**feature_data)
294
+
295
+
296
+ def _prompt_story() -> Story:
297
+ """Prompt for story details."""
298
+ print_info(" New Story")
299
+
300
+ key = prompt_text(" Story key (e.g., STORY-001)", required=True)
301
+ title = prompt_text(" Story title", required=True)
302
+ acceptance = prompt_list(" Acceptance criteria")
303
+
304
+ story_data = {
305
+ "key": key,
306
+ "title": title,
307
+ "acceptance": acceptance if acceptance else [],
308
+ }
309
+
310
+ if prompt_confirm(" Add optional details?", default=False):
311
+ tags = prompt_list(" Tags (e.g., critical, backend)")
312
+ if tags:
313
+ story_data["tags"] = tags
314
+
315
+ confidence = prompt_text(" Confidence (0.0-1.0)", required=False)
316
+ if confidence:
317
+ with suppress(ValueError):
318
+ story_data["confidence"] = float(confidence)
319
+
320
+ draft = prompt_confirm(" Mark as draft?", default=False)
321
+ story_data["draft"] = draft
322
+
323
+ return Story(**story_data)
324
+
325
+
326
+ @app.command("add-feature")
327
+ @beartype
328
+ @require(lambda key: isinstance(key, str) and len(key) > 0, "Key must be non-empty string")
329
+ @require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string")
330
+ @require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
331
+ def add_feature(
332
+ key: str = typer.Option(..., "--key", help="Feature key (e.g., FEATURE-001)"),
333
+ title: str = typer.Option(..., "--title", help="Feature title"),
334
+ outcomes: str | None = typer.Option(None, "--outcomes", help="Expected outcomes (comma-separated)"),
335
+ acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"),
336
+ plan: Path | None = typer.Option(
337
+ None,
338
+ "--plan",
339
+ help="Path to plan bundle (default: .specfact/plans/main.bundle.yaml)",
340
+ ),
341
+ ) -> None:
342
+ """
343
+ Add a new feature to an existing plan.
344
+
345
+ Example:
346
+ specfact plan add-feature --key FEATURE-001 --title "User Auth" --outcomes "Secure login" --acceptance "Login works"
347
+ """
348
+ from specfact_cli.utils.structure import SpecFactStructure
349
+
350
+ # Use default path if not specified
351
+ if plan is None:
352
+ plan = SpecFactStructure.get_default_plan_path()
353
+ if not plan.exists():
354
+ print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
355
+ raise typer.Exit(1)
356
+ print_info(f"Using default plan: {plan}")
357
+
358
+ if not plan.exists():
359
+ print_error(f"Plan bundle not found: {plan}")
360
+ raise typer.Exit(1)
361
+
362
+ print_section("SpecFact CLI - Add Feature")
363
+
364
+ try:
365
+ # Load existing plan
366
+ print_info(f"Loading plan: {plan}")
367
+ validation_result = validate_plan_bundle(plan)
368
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
369
+ is_valid, error, existing_plan = validation_result
370
+
371
+ if not is_valid or existing_plan is None:
372
+ print_error(f"Plan validation failed: {error}")
373
+ raise typer.Exit(1)
374
+
375
+ # Check if feature key already exists
376
+ existing_keys = {f.key for f in existing_plan.features}
377
+ if key in existing_keys:
378
+ print_error(f"Feature '{key}' already exists in plan")
379
+ raise typer.Exit(1)
380
+
381
+ # Parse outcomes and acceptance (comma-separated strings)
382
+ outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else []
383
+ acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else []
384
+
385
+ # Create new feature
386
+ new_feature = Feature(
387
+ key=key,
388
+ title=title,
389
+ outcomes=outcomes_list,
390
+ acceptance=acceptance_list,
391
+ constraints=[],
392
+ stories=[],
393
+ confidence=1.0,
394
+ draft=False,
395
+ )
396
+
397
+ # Add feature to plan
398
+ existing_plan.features.append(new_feature)
399
+
400
+ # Validate updated plan (always passes for PlanBundle model)
401
+ print_info("Validating updated plan...")
402
+
403
+ # Save updated plan
404
+ print_info(f"Saving plan to: {plan}")
405
+ generator = PlanGenerator()
406
+ generator.generate(existing_plan, plan)
407
+
408
+ print_success(f"Feature '{key}' added successfully")
409
+ console.print(f"[dim]Feature: {title}[/dim]")
410
+ if outcomes_list:
411
+ console.print(f"[dim]Outcomes: {', '.join(outcomes_list)}[/dim]")
412
+ if acceptance_list:
413
+ console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]")
414
+
415
+ except typer.Exit:
416
+ raise
417
+ except Exception as e:
418
+ print_error(f"Failed to add feature: {e}")
419
+ raise typer.Exit(1) from e
420
+
421
+
422
+ @app.command("add-story")
423
+ @beartype
424
+ @require(lambda feature: isinstance(feature, str) and len(feature) > 0, "Feature must be non-empty string")
425
+ @require(lambda key: isinstance(key, str) and len(key) > 0, "Key must be non-empty string")
426
+ @require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string")
427
+ @require(
428
+ lambda story_points: story_points is None or (story_points >= 0 and story_points <= 100),
429
+ "Story points must be 0-100 if provided",
430
+ )
431
+ @require(
432
+ lambda value_points: value_points is None or (value_points >= 0 and value_points <= 100),
433
+ "Value points must be 0-100 if provided",
434
+ )
435
+ @require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
436
+ def add_story(
437
+ feature: str = typer.Option(..., "--feature", help="Parent feature key"),
438
+ key: str = typer.Option(..., "--key", help="Story key (e.g., STORY-001)"),
439
+ title: str = typer.Option(..., "--title", help="Story title"),
440
+ acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"),
441
+ story_points: int | None = typer.Option(None, "--story-points", help="Story points (complexity)"),
442
+ value_points: int | None = typer.Option(None, "--value-points", help="Value points (business value)"),
443
+ draft: bool = typer.Option(False, "--draft", help="Mark story as draft"),
444
+ plan: Path | None = typer.Option(
445
+ None,
446
+ "--plan",
447
+ help="Path to plan bundle (default: .specfact/plans/main.bundle.yaml)",
448
+ ),
449
+ ) -> None:
450
+ """
451
+ Add a new story to a feature.
452
+
453
+ Example:
454
+ specfact plan add-story --feature FEATURE-001 --key STORY-001 --title "Login API" --acceptance "API works" --story-points 5
455
+ """
456
+ from specfact_cli.utils.structure import SpecFactStructure
457
+
458
+ # Use default path if not specified
459
+ if plan is None:
460
+ plan = SpecFactStructure.get_default_plan_path()
461
+ if not plan.exists():
462
+ print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
463
+ raise typer.Exit(1)
464
+ print_info(f"Using default plan: {plan}")
465
+
466
+ if not plan.exists():
467
+ print_error(f"Plan bundle not found: {plan}")
468
+ raise typer.Exit(1)
469
+
470
+ print_section("SpecFact CLI - Add Story")
471
+
472
+ try:
473
+ # Load existing plan
474
+ print_info(f"Loading plan: {plan}")
475
+ validation_result = validate_plan_bundle(plan)
476
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
477
+ is_valid, error, existing_plan = validation_result
478
+
479
+ if not is_valid or existing_plan is None:
480
+ print_error(f"Plan validation failed: {error}")
481
+ raise typer.Exit(1)
482
+
483
+ # Find parent feature
484
+ parent_feature = None
485
+ for f in existing_plan.features:
486
+ if f.key == feature:
487
+ parent_feature = f
488
+ break
489
+
490
+ if parent_feature is None:
491
+ print_error(f"Feature '{feature}' not found in plan")
492
+ console.print(f"[dim]Available features: {', '.join(f.key for f in existing_plan.features)}[/dim]")
493
+ raise typer.Exit(1)
494
+
495
+ # Check if story key already exists in feature
496
+ existing_story_keys = {s.key for s in parent_feature.stories}
497
+ if key in existing_story_keys:
498
+ print_error(f"Story '{key}' already exists in feature '{feature}'")
499
+ raise typer.Exit(1)
500
+
501
+ # Parse acceptance (comma-separated string)
502
+ acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else []
503
+
504
+ # Create new story
505
+ new_story = Story(
506
+ key=key,
507
+ title=title,
508
+ acceptance=acceptance_list,
509
+ tags=[],
510
+ story_points=story_points,
511
+ value_points=value_points,
512
+ tasks=[],
513
+ confidence=1.0,
514
+ draft=draft,
515
+ )
516
+
517
+ # Add story to feature
518
+ parent_feature.stories.append(new_story)
519
+
520
+ # Validate updated plan (always passes for PlanBundle model)
521
+ print_info("Validating updated plan...")
522
+
523
+ # Save updated plan
524
+ print_info(f"Saving plan to: {plan}")
525
+ generator = PlanGenerator()
526
+ generator.generate(existing_plan, plan)
527
+
528
+ print_success(f"Story '{key}' added to feature '{feature}'")
529
+ console.print(f"[dim]Story: {title}[/dim]")
530
+ if acceptance_list:
531
+ console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]")
532
+ if story_points:
533
+ console.print(f"[dim]Story Points: {story_points}[/dim]")
534
+ if value_points:
535
+ console.print(f"[dim]Value Points: {value_points}[/dim]")
536
+
537
+ except typer.Exit:
538
+ raise
539
+ except Exception as e:
540
+ print_error(f"Failed to add story: {e}")
541
+ raise typer.Exit(1) from e
542
+
543
+
544
+ @app.command("compare")
545
+ @beartype
546
+ def compare(
547
+ manual: Path | None = typer.Option(
548
+ None,
549
+ "--manual",
550
+ help="Manual plan bundle path (default: .specfact/plans/main.bundle.yaml)",
551
+ ),
552
+ auto: Path | None = typer.Option(
553
+ None,
554
+ "--auto",
555
+ help="Auto-derived plan bundle path (default: latest in .specfact/plans/)",
556
+ ),
557
+ format: str = typer.Option(
558
+ "markdown",
559
+ "--format",
560
+ help="Output format (markdown, json, yaml)",
561
+ ),
562
+ out: Path | None = typer.Option(
563
+ None,
564
+ "--out",
565
+ help="Output file path (default: .specfact/reports/comparison/deviations-<timestamp>.md)",
566
+ ),
567
+ ) -> None:
568
+ """
569
+ Compare manual and auto-derived plans.
570
+
571
+ Detects deviations between manually created plans and
572
+ reverse-engineered plans from code.
573
+
574
+ Example:
575
+ specfact plan compare --manual .specfact/plans/main.bundle.yaml --auto .specfact/plans/auto-derived-<timestamp>.bundle.yaml
576
+ """
577
+ from specfact_cli.utils.structure import SpecFactStructure
578
+
579
+ # Ensure .specfact structure exists
580
+ SpecFactStructure.ensure_structure()
581
+
582
+ # Use default paths if not specified (smart defaults)
583
+ if manual is None:
584
+ manual = SpecFactStructure.get_default_plan_path()
585
+ if not manual.exists():
586
+ print_error(f"Default manual plan not found: {manual}\nCreate one with: specfact plan init --interactive")
587
+ raise typer.Exit(1)
588
+ print_info(f"Using default manual plan: {manual}")
589
+
590
+ if auto is None:
591
+ # Use smart default: find latest auto-derived plan
592
+ auto = SpecFactStructure.get_latest_brownfield_report()
593
+ if auto is None:
594
+ plans_dir = Path(SpecFactStructure.PLANS)
595
+ print_error(
596
+ f"No auto-derived plans found in {plans_dir}\nGenerate one with: specfact import from-code --repo ."
597
+ )
598
+ raise typer.Exit(1)
599
+ print_info(f"Using latest auto-derived plan: {auto}")
600
+
601
+ if out is None:
602
+ # Use smart default: timestamped comparison report
603
+ extension = {"markdown": "md", "json": "json", "yaml": "yaml"}[format.lower()]
604
+ out = SpecFactStructure.get_comparison_report_path(format=extension)
605
+ print_info(f"Writing comparison report to: {out}")
606
+
607
+ print_section("SpecFact CLI - Plan Comparison")
608
+
609
+ # Validate inputs (after defaults are set)
610
+ if manual is not None and not manual.exists():
611
+ print_error(f"Manual plan not found: {manual}")
612
+ raise typer.Exit(1)
613
+
614
+ if auto is not None and not auto.exists():
615
+ print_error(f"Auto plan not found: {auto}")
616
+ raise typer.Exit(1)
617
+
618
+ # Validate format
619
+ if format.lower() not in ("markdown", "json", "yaml"):
620
+ print_error(f"Invalid format: {format}. Must be markdown, json, or yaml")
621
+ raise typer.Exit(1)
622
+
623
+ try:
624
+ # Load plans
625
+ # Note: validate_plan_bundle returns tuple[bool, str | None, PlanBundle | None] when given a Path
626
+ print_info(f"Loading manual plan: {manual}")
627
+ validation_result = validate_plan_bundle(manual)
628
+ # Type narrowing: when Path is passed, always returns tuple
629
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
630
+ is_valid, error, manual_plan = validation_result
631
+ if not is_valid or manual_plan is None:
632
+ print_error(f"Manual plan validation failed: {error}")
633
+ raise typer.Exit(1)
634
+
635
+ print_info(f"Loading auto plan: {auto}")
636
+ validation_result = validate_plan_bundle(auto)
637
+ # Type narrowing: when Path is passed, always returns tuple
638
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
639
+ is_valid, error, auto_plan = validation_result
640
+ if not is_valid or auto_plan is None:
641
+ print_error(f"Auto plan validation failed: {error}")
642
+ raise typer.Exit(1)
643
+
644
+ # Compare plans
645
+ print_info("Comparing plans...")
646
+ comparator = PlanComparator()
647
+ report = comparator.compare(
648
+ manual_plan,
649
+ auto_plan,
650
+ manual_label=str(manual),
651
+ auto_label=str(auto),
652
+ )
653
+
654
+ # Display results
655
+ print_section("Comparison Results")
656
+
657
+ console.print(f"[cyan]Manual Plan:[/cyan] {manual}")
658
+ console.print(f"[cyan]Auto Plan:[/cyan] {auto}")
659
+ console.print(f"[cyan]Total Deviations:[/cyan] {report.total_deviations}\n")
660
+
661
+ if report.total_deviations == 0:
662
+ print_success("No deviations found! Plans are identical.")
663
+ else:
664
+ # Show severity summary
665
+ console.print("[bold]Deviation Summary:[/bold]")
666
+ console.print(f" 🔴 [bold red]HIGH:[/bold red] {report.high_count}")
667
+ console.print(f" 🟡 [bold yellow]MEDIUM:[/bold yellow] {report.medium_count}")
668
+ console.print(f" 🔵 [bold blue]LOW:[/bold blue] {report.low_count}\n")
669
+
670
+ # Show detailed table
671
+ table = Table(title="Deviations by Type and Severity")
672
+ table.add_column("Severity", style="bold")
673
+ table.add_column("Type", style="cyan")
674
+ table.add_column("Description", style="white", no_wrap=False)
675
+ table.add_column("Location", style="dim")
676
+
677
+ for deviation in report.deviations:
678
+ severity_icon = {"HIGH": "🔴", "MEDIUM": "🟡", "LOW": "🔵"}[deviation.severity.value]
679
+ table.add_row(
680
+ f"{severity_icon} {deviation.severity.value}",
681
+ deviation.type.value.replace("_", " ").title(),
682
+ deviation.description[:80] + "..." if len(deviation.description) > 80 else deviation.description,
683
+ deviation.location,
684
+ )
685
+
686
+ console.print(table)
687
+
688
+ # Generate report file if requested
689
+ if out:
690
+ print_info(f"Generating {format} report...")
691
+ generator = ReportGenerator()
692
+
693
+ # Map format string to enum
694
+ format_map = {
695
+ "markdown": ReportFormat.MARKDOWN,
696
+ "json": ReportFormat.JSON,
697
+ "yaml": ReportFormat.YAML,
698
+ }
699
+
700
+ report_format = format_map.get(format.lower(), ReportFormat.MARKDOWN)
701
+ generator.generate_deviation_report(report, out, report_format)
702
+
703
+ print_success(f"Report written to: {out}")
704
+
705
+ # Apply enforcement rules if config exists
706
+ from specfact_cli.utils.structure import SpecFactStructure
707
+
708
+ config_path = SpecFactStructure.get_enforcement_config_path()
709
+ if config_path.exists():
710
+ try:
711
+ from specfact_cli.utils.yaml_utils import load_yaml
712
+
713
+ config_data = load_yaml(config_path)
714
+ enforcement_config = EnforcementConfig(**config_data)
715
+
716
+ if enforcement_config.enabled and report.total_deviations > 0:
717
+ print_section("Enforcement Rules")
718
+ console.print(f"[dim]Using enforcement config: {config_path}[/dim]\n")
719
+
720
+ # Check for blocking deviations
721
+ blocking_deviations: list[Deviation] = []
722
+ for deviation in report.deviations:
723
+ action = enforcement_config.get_action(deviation.severity.value)
724
+ action_icon = {"BLOCK": "🚫", "WARN": "⚠️", "LOG": "📝"}[action.value]
725
+
726
+ console.print(
727
+ f"{action_icon} [{deviation.severity.value}] {deviation.type.value}: "
728
+ f"[dim]{action.value}[/dim]"
729
+ )
730
+
731
+ if enforcement_config.should_block_deviation(deviation.severity.value):
732
+ blocking_deviations.append(deviation)
733
+
734
+ if blocking_deviations:
735
+ print_error(
736
+ f"\n❌ Enforcement BLOCKED: {len(blocking_deviations)} deviation(s) violate quality gates"
737
+ )
738
+ console.print("[dim]Fix the blocking deviations or adjust enforcement config[/dim]")
739
+ raise typer.Exit(1)
740
+ print_success("\n✅ Enforcement PASSED: No blocking deviations")
741
+
742
+ except typer.Exit:
743
+ # Re-raise typer.Exit (for enforcement blocking)
744
+ raise
745
+ except Exception as e:
746
+ print_warning(f"Could not load enforcement config: {e}")
747
+
748
+ # Note: Finding deviations without enforcement is a successful comparison result
749
+ # Exit code 0 indicates successful execution (even if deviations were found)
750
+ # Use the report file, stdout, or enforcement config to determine if deviations are critical
751
+ if report.total_deviations > 0:
752
+ print_warning(f"\n{report.total_deviations} deviation(s) found")
753
+
754
+ except KeyboardInterrupt:
755
+ print_warning("\nComparison cancelled")
756
+ raise typer.Exit(1) from None
757
+ except Exception as e:
758
+ print_error(f"Comparison failed: {e}")
759
+ raise typer.Exit(1) from e
760
+
761
+
762
+ @app.command("select")
763
+ @beartype
764
+ @require(lambda plan: plan is None or isinstance(plan, str), "Plan must be None or str")
765
+ def select(
766
+ plan: str | None = typer.Argument(
767
+ None,
768
+ help="Plan name or number to select (e.g., 'main.bundle.yaml' or '1')",
769
+ ),
770
+ ) -> None:
771
+ """
772
+ Select active plan from available plan bundles.
773
+
774
+ Displays a numbered list of available plans and allows selection by number or name.
775
+ The selected plan becomes the active plan tracked in `.specfact/plans/config.yaml`.
776
+
777
+ Example:
778
+ specfact plan select # Interactive selection
779
+ specfact plan select 1 # Select by number
780
+ specfact plan select main.bundle.yaml # Select by name
781
+ """
782
+ from specfact_cli.utils.structure import SpecFactStructure
783
+
784
+ print_section("SpecFact CLI - Plan Selection")
785
+
786
+ # List all available plans
787
+ plans = SpecFactStructure.list_plans()
788
+
789
+ if not plans:
790
+ print_warning("No plan bundles found in .specfact/plans/")
791
+ print_info("Create a plan with:")
792
+ print_info(" - specfact plan init")
793
+ print_info(" - specfact import from-code")
794
+ raise typer.Exit(1)
795
+
796
+ # If plan provided, try to resolve it
797
+ if plan is not None:
798
+ # Try as number first
799
+ if isinstance(plan, str) and plan.isdigit():
800
+ plan_num = int(plan)
801
+ if 1 <= plan_num <= len(plans):
802
+ selected_plan = plans[plan_num - 1]
803
+ else:
804
+ print_error(f"Invalid plan number: {plan_num}. Must be between 1 and {len(plans)}")
805
+ raise typer.Exit(1)
806
+ else:
807
+ # Try as name
808
+ plan_name = str(plan)
809
+ # Remove .bundle.yaml suffix if present
810
+ if plan_name.endswith(".bundle.yaml"):
811
+ plan_name = plan_name
812
+ elif not plan_name.endswith(".yaml"):
813
+ plan_name = f"{plan_name}.bundle.yaml"
814
+
815
+ # Find matching plan
816
+ selected_plan = None
817
+ for p in plans:
818
+ if p["name"] == plan_name or p["name"] == plan:
819
+ selected_plan = p
820
+ break
821
+
822
+ if selected_plan is None:
823
+ print_error(f"Plan not found: {plan}")
824
+ print_info("Available plans:")
825
+ for i, p in enumerate(plans, 1):
826
+ print_info(f" {i}. {p['name']}")
827
+ raise typer.Exit(1)
828
+ else:
829
+ # Interactive selection - display numbered list
830
+ console.print("\n[bold]Available Plans:[/bold]\n")
831
+
832
+ table = Table(show_header=True, header_style="bold cyan")
833
+ table.add_column("#", style="dim", width=4)
834
+ table.add_column("Status", style="dim", width=10)
835
+ table.add_column("Plan Name", style="bold", width=50)
836
+ table.add_column("Features", justify="right", width=10)
837
+ table.add_column("Stories", justify="right", width=10)
838
+ table.add_column("Stage", width=12)
839
+ table.add_column("Modified", style="dim", width=20)
840
+
841
+ for i, p in enumerate(plans, 1):
842
+ status = "[ACTIVE]" if p.get("active") else ""
843
+ plan_name = str(p["name"])
844
+ features_count = str(p["features"])
845
+ stories_count = str(p["stories"])
846
+ stage = str(p.get("stage", "unknown"))
847
+ modified = str(p["modified"])
848
+ modified_display = modified[:19] if len(modified) > 19 else modified
849
+ table.add_row(
850
+ str(i),
851
+ status,
852
+ plan_name,
853
+ features_count,
854
+ stories_count,
855
+ stage,
856
+ modified_display,
857
+ )
858
+
859
+ console.print(table)
860
+ console.print()
861
+
862
+ # Prompt for selection
863
+ selection = ""
864
+ try:
865
+ selection = prompt_text(f"Select a plan by number (1-{len(plans)}) or 'q' to quit: ").strip()
866
+
867
+ if selection.lower() in ("q", "quit", ""):
868
+ print_info("Selection cancelled")
869
+ raise typer.Exit(0)
870
+
871
+ plan_num = int(selection)
872
+ if not (1 <= plan_num <= len(plans)):
873
+ print_error(f"Invalid selection: {plan_num}. Must be between 1 and {len(plans)}")
874
+ raise typer.Exit(1)
875
+
876
+ selected_plan = plans[plan_num - 1]
877
+ except ValueError:
878
+ print_error(f"Invalid input: {selection}. Please enter a number.")
879
+ raise typer.Exit(1) from None
880
+ except KeyboardInterrupt:
881
+ print_warning("\nSelection cancelled")
882
+ raise typer.Exit(1) from None
883
+
884
+ # Set as active plan
885
+ plan_name = str(selected_plan["name"])
886
+ SpecFactStructure.set_active_plan(plan_name)
887
+
888
+ print_success(f"Active plan set to: {plan_name}")
889
+ print_info(f" Features: {selected_plan['features']}")
890
+ print_info(f" Stories: {selected_plan['stories']}")
891
+ print_info(f" Stage: {selected_plan.get('stage', 'unknown')}")
892
+
893
+ print_info("\nThis plan will now be used as the default for:")
894
+ print_info(" - specfact plan compare")
895
+ print_info(" - specfact plan promote")
896
+ print_info(" - specfact plan add-feature")
897
+ print_info(" - specfact plan add-story")
898
+ print_info(" - specfact sync spec-kit")
899
+
900
+
901
+ @app.command("promote")
902
+ @beartype
903
+ @require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
904
+ @require(
905
+ lambda stage: stage in ("draft", "review", "approved", "released"),
906
+ "Stage must be draft, review, approved, or released",
907
+ )
908
+ def promote(
909
+ stage: str = typer.Option(..., "--stage", help="Target stage (draft, review, approved, released)"),
910
+ plan: Path | None = typer.Option(
911
+ None,
912
+ "--plan",
913
+ help="Path to plan bundle (default: .specfact/plans/main.bundle.yaml)",
914
+ ),
915
+ validate: bool = typer.Option(
916
+ True,
917
+ "--validate/--no-validate",
918
+ help="Run validation before promotion (default: true)",
919
+ ),
920
+ force: bool = typer.Option(
921
+ False,
922
+ "--force",
923
+ help="Force promotion even if validation fails (default: false)",
924
+ ),
925
+ ) -> None:
926
+ """
927
+ Promote a plan bundle through development stages.
928
+
929
+ Stages: draft → review → approved → released
930
+
931
+ Example:
932
+ specfact plan promote --stage review
933
+ specfact plan promote --stage approved --validate
934
+ specfact plan promote --stage released --force
935
+ """
936
+ import os
937
+ from datetime import datetime
938
+
939
+ from specfact_cli.utils.structure import SpecFactStructure
940
+
941
+ # Use default path if not specified
942
+ if plan is None:
943
+ plan = SpecFactStructure.get_default_plan_path()
944
+ if not plan.exists():
945
+ print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
946
+ raise typer.Exit(1)
947
+ print_info(f"Using default plan: {plan}")
948
+
949
+ if not plan.exists():
950
+ print_error(f"Plan bundle not found: {plan}")
951
+ raise typer.Exit(1)
952
+
953
+ print_section("SpecFact CLI - Plan Promotion")
954
+
955
+ try:
956
+ # Load existing plan
957
+ print_info(f"Loading plan: {plan}")
958
+ validation_result = validate_plan_bundle(plan)
959
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
960
+ is_valid, error, bundle = validation_result
961
+
962
+ if not is_valid or bundle is None:
963
+ print_error(f"Plan validation failed: {error}")
964
+ raise typer.Exit(1)
965
+
966
+ # Check current stage
967
+ current_stage = "draft"
968
+ if bundle.metadata:
969
+ current_stage = bundle.metadata.stage
970
+
971
+ print_info(f"Current stage: {current_stage}")
972
+ print_info(f"Target stage: {stage}")
973
+
974
+ # Validate stage progression
975
+ stage_order = {"draft": 0, "review": 1, "approved": 2, "released": 3}
976
+ current_order = stage_order.get(current_stage, 0)
977
+ target_order = stage_order.get(stage, 0)
978
+
979
+ if target_order < current_order:
980
+ print_error(f"Cannot promote backward: {current_stage} → {stage}")
981
+ print_error("Only forward promotion is allowed (draft → review → approved → released)")
982
+ raise typer.Exit(1)
983
+
984
+ if target_order == current_order:
985
+ print_warning(f"Plan is already at stage: {stage}")
986
+ raise typer.Exit(0)
987
+
988
+ # Validate promotion rules
989
+ print_info("Checking promotion rules...")
990
+
991
+ # Draft → Review: All features must have at least one story
992
+ if current_stage == "draft" and stage == "review":
993
+ features_without_stories = [f for f in bundle.features if len(f.stories) == 0]
994
+ if features_without_stories:
995
+ print_error(f"Cannot promote to review: {len(features_without_stories)} feature(s) without stories")
996
+ console.print("[dim]Features without stories:[/dim]")
997
+ for f in features_without_stories[:5]:
998
+ console.print(f" - {f.key}: {f.title}")
999
+ if len(features_without_stories) > 5:
1000
+ console.print(f" ... and {len(features_without_stories) - 5} more")
1001
+ if not force:
1002
+ raise typer.Exit(1)
1003
+
1004
+ # Review → Approved: All features must pass validation
1005
+ if current_stage == "review" and stage == "approved" and validate:
1006
+ print_info("Validating all features...")
1007
+ incomplete_features: list[Feature] = []
1008
+ for f in bundle.features:
1009
+ if not f.acceptance:
1010
+ incomplete_features.append(f)
1011
+ for s in f.stories:
1012
+ if not s.acceptance:
1013
+ incomplete_features.append(f)
1014
+ break
1015
+
1016
+ if incomplete_features:
1017
+ print_warning(f"{len(incomplete_features)} feature(s) have incomplete acceptance criteria")
1018
+ if not force:
1019
+ console.print("[dim]Use --force to promote anyway[/dim]")
1020
+ raise typer.Exit(1)
1021
+
1022
+ # Approved → Released: All features must be implemented (future check)
1023
+ if current_stage == "approved" and stage == "released":
1024
+ print_warning("Release promotion: Implementation verification not yet implemented")
1025
+ if not force:
1026
+ console.print("[dim]Use --force to promote to released stage[/dim]")
1027
+ raise typer.Exit(1)
1028
+
1029
+ # Run validation if enabled
1030
+ if validate:
1031
+ print_info("Running validation...")
1032
+ validation_result = validate_plan_bundle(bundle)
1033
+ if isinstance(validation_result, ValidationReport):
1034
+ if not validation_result.passed:
1035
+ deviation_count = len(validation_result.deviations)
1036
+ print_warning(f"Validation found {deviation_count} issue(s)")
1037
+ if not force:
1038
+ console.print("[dim]Use --force to promote anyway[/dim]")
1039
+ raise typer.Exit(1)
1040
+ else:
1041
+ print_success("Validation passed")
1042
+ else:
1043
+ print_success("Validation passed")
1044
+
1045
+ # Update metadata
1046
+ print_info(f"Promoting plan: {current_stage} → {stage}")
1047
+
1048
+ # Get user info
1049
+ promoted_by = (
1050
+ os.environ.get("USER") or os.environ.get("USERNAME") or os.environ.get("GIT_AUTHOR_NAME") or "unknown"
1051
+ )
1052
+
1053
+ # Create or update metadata
1054
+ if bundle.metadata is None:
1055
+ bundle.metadata = Metadata(stage=stage, promoted_at=None, promoted_by=None)
1056
+
1057
+ bundle.metadata.stage = stage
1058
+ bundle.metadata.promoted_at = datetime.now(UTC).isoformat()
1059
+ bundle.metadata.promoted_by = promoted_by
1060
+
1061
+ # Write updated plan
1062
+ print_info(f"Saving plan to: {plan}")
1063
+ generator = PlanGenerator()
1064
+ generator.generate(bundle, plan)
1065
+
1066
+ # Display summary
1067
+ print_success(f"Plan promoted: {current_stage} → {stage}")
1068
+ console.print(f"[dim]Promoted at: {bundle.metadata.promoted_at}[/dim]")
1069
+ console.print(f"[dim]Promoted by: {promoted_by}[/dim]")
1070
+
1071
+ # Show next steps
1072
+ console.print("\n[bold]Next Steps:[/bold]")
1073
+ if stage == "review":
1074
+ console.print(" • Review plan bundle for completeness")
1075
+ console.print(" • Add stories to features if missing")
1076
+ console.print(" • Run: specfact plan promote --stage approved")
1077
+ elif stage == "approved":
1078
+ console.print(" • Plan is approved for implementation")
1079
+ console.print(" • Begin feature development")
1080
+ console.print(" • Run: specfact plan promote --stage released (after implementation)")
1081
+ elif stage == "released":
1082
+ console.print(" • Plan is released and should be immutable")
1083
+ console.print(" • Create new plan bundle for future changes")
1084
+
1085
+ except typer.Exit:
1086
+ raise
1087
+ except Exception as e:
1088
+ print_error(f"Failed to promote plan: {e}")
1089
+ raise typer.Exit(1) from e