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,6 +7,7 @@ features, and stories.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import json
10
11
  from contextlib import suppress
11
12
  from datetime import UTC
12
13
  from pathlib import Path
@@ -14,16 +15,19 @@ from typing import Any
14
15
 
15
16
  import typer
16
17
  from beartype import beartype
17
- from icontract import require
18
+ from icontract import ensure, require
18
19
  from rich.console import Console
19
20
  from rich.table import Table
20
21
 
22
+ from specfact_cli.analyzers.ambiguity_scanner import AmbiguityFinding
21
23
  from specfact_cli.comparators.plan_comparator import PlanComparator
22
24
  from specfact_cli.generators.plan_generator import PlanGenerator
23
25
  from specfact_cli.generators.report_generator import ReportFormat, ReportGenerator
24
26
  from specfact_cli.models.deviation import Deviation, ValidationReport
25
27
  from specfact_cli.models.enforcement import EnforcementConfig
26
28
  from specfact_cli.models.plan import Business, Feature, Idea, Metadata, PlanBundle, Product, Release, Story
29
+ from specfact_cli.modes import detect_mode
30
+ from specfact_cli.telemetry import telemetry
27
31
  from specfact_cli.utils import (
28
32
  display_summary,
29
33
  print_error,
@@ -76,50 +80,66 @@ def init(
76
80
  """
77
81
  from specfact_cli.utils.structure import SpecFactStructure
78
82
 
79
- print_section("SpecFact CLI - Plan Builder")
83
+ telemetry_metadata = {
84
+ "interactive": interactive,
85
+ "scaffold": scaffold,
86
+ }
80
87
 
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()
88
+ with telemetry.track_command("plan.init", telemetry_metadata) as record:
89
+ print_section("SpecFact CLI - Plan Builder")
89
90
 
90
- # Use default path if not specified
91
- if out is None:
92
- out = SpecFactStructure.get_default_plan_path()
91
+ # Create .specfact structure if requested
92
+ if scaffold:
93
+ print_info("Creating .specfact/ directory structure...")
94
+ SpecFactStructure.scaffold_project()
95
+ print_success("Directory structure created")
96
+ else:
97
+ # Ensure minimum structure exists
98
+ SpecFactStructure.ensure_structure()
93
99
 
94
- if not interactive:
95
- # Non-interactive mode: create minimal plan
96
- _create_minimal_plan(out)
97
- return
100
+ # Use default path if not specified
101
+ if out is None:
102
+ out = SpecFactStructure.get_default_plan_path()
98
103
 
99
- # Interactive mode: guided plan creation
100
- try:
101
- plan = _build_plan_interactively()
104
+ if not interactive:
105
+ # Non-interactive mode: create minimal plan
106
+ _create_minimal_plan(out)
107
+ record({"plan_type": "minimal"})
108
+ return
102
109
 
103
- # Generate plan file
104
- out.parent.mkdir(parents=True, exist_ok=True)
105
- generator = PlanGenerator()
106
- generator.generate(plan, out)
110
+ # Interactive mode: guided plan creation
111
+ try:
112
+ plan = _build_plan_interactively()
113
+
114
+ # Generate plan file
115
+ out.parent.mkdir(parents=True, exist_ok=True)
116
+ generator = PlanGenerator()
117
+ generator.generate(plan, out)
118
+
119
+ # Record plan statistics
120
+ record(
121
+ {
122
+ "plan_type": "interactive",
123
+ "features_count": len(plan.features) if plan.features else 0,
124
+ "stories_count": sum(len(f.stories) for f in plan.features) if plan.features else 0,
125
+ }
126
+ )
107
127
 
108
- print_success(f"Plan created successfully: {out}")
128
+ print_success(f"Plan created successfully: {out}")
109
129
 
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}")
130
+ # Validate
131
+ is_valid, error, _ = validate_plan_bundle(out)
132
+ if is_valid:
133
+ print_success("Plan validation passed")
134
+ else:
135
+ print_warning(f"Plan has validation issues: {error}")
116
136
 
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
137
+ except KeyboardInterrupt:
138
+ print_warning("\nPlan creation cancelled")
139
+ raise typer.Exit(1) from None
140
+ except Exception as e:
141
+ print_error(f"Failed to create plan: {e}")
142
+ raise typer.Exit(1) from e
123
143
 
124
144
 
125
145
  def _create_minimal_plan(out: Path) -> None:
@@ -131,6 +151,7 @@ def _create_minimal_plan(out: Path) -> None:
131
151
  product=Product(themes=[], releases=[]),
132
152
  features=[],
133
153
  metadata=None,
154
+ clarifications=None,
134
155
  )
135
156
 
136
157
  generator = PlanGenerator()
@@ -235,6 +256,7 @@ def _build_plan_interactively() -> PlanBundle:
235
256
  product=product,
236
257
  features=features,
237
258
  metadata=None,
259
+ clarifications=None,
238
260
  )
239
261
 
240
262
  # Final summary
@@ -347,76 +369,87 @@ def add_feature(
347
369
  """
348
370
  from specfact_cli.utils.structure import SpecFactStructure
349
371
 
350
- # Use default path if not specified
351
- if plan is None:
352
- plan = SpecFactStructure.get_default_plan_path()
372
+ telemetry_metadata = {
373
+ "feature_key": key,
374
+ }
375
+
376
+ with telemetry.track_command("plan.add_feature", telemetry_metadata) as record:
377
+ # Use default path if not specified
378
+ if plan is None:
379
+ plan = SpecFactStructure.get_default_plan_path()
380
+ if not plan.exists():
381
+ print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
382
+ raise typer.Exit(1)
383
+ print_info(f"Using default plan: {plan}")
384
+
353
385
  if not plan.exists():
354
- print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
386
+ print_error(f"Plan bundle not found: {plan}")
355
387
  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
388
 
362
- print_section("SpecFact CLI - Add Feature")
389
+ print_section("SpecFact CLI - Add Feature")
363
390
 
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
391
+ try:
392
+ # Load existing plan
393
+ print_info(f"Loading plan: {plan}")
394
+ validation_result = validate_plan_bundle(plan)
395
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
396
+ is_valid, error, existing_plan = validation_result
397
+
398
+ if not is_valid or existing_plan is None:
399
+ print_error(f"Plan validation failed: {error}")
400
+ raise typer.Exit(1)
370
401
 
371
- if not is_valid or existing_plan is None:
372
- print_error(f"Plan validation failed: {error}")
373
- raise typer.Exit(1)
402
+ # Check if feature key already exists
403
+ existing_keys = {f.key for f in existing_plan.features}
404
+ if key in existing_keys:
405
+ print_error(f"Feature '{key}' already exists in plan")
406
+ raise typer.Exit(1)
374
407
 
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)
408
+ # Parse outcomes and acceptance (comma-separated strings)
409
+ outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else []
410
+ acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else []
411
+
412
+ # Create new feature
413
+ new_feature = Feature(
414
+ key=key,
415
+ title=title,
416
+ outcomes=outcomes_list,
417
+ acceptance=acceptance_list,
418
+ constraints=[],
419
+ stories=[],
420
+ confidence=1.0,
421
+ draft=False,
422
+ )
380
423
 
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
- )
424
+ # Add feature to plan
425
+ existing_plan.features.append(new_feature)
396
426
 
397
- # Add feature to plan
398
- existing_plan.features.append(new_feature)
427
+ # Validate updated plan (always passes for PlanBundle model)
428
+ print_info("Validating updated plan...")
399
429
 
400
- # Validate updated plan (always passes for PlanBundle model)
401
- print_info("Validating updated plan...")
430
+ # Save updated plan
431
+ print_info(f"Saving plan to: {plan}")
432
+ generator = PlanGenerator()
433
+ generator.generate(existing_plan, plan)
402
434
 
403
- # Save updated plan
404
- print_info(f"Saving plan to: {plan}")
405
- generator = PlanGenerator()
406
- generator.generate(existing_plan, plan)
435
+ record(
436
+ {
437
+ "total_features": len(existing_plan.features),
438
+ "outcomes_count": len(outcomes_list),
439
+ "acceptance_count": len(acceptance_list),
440
+ }
441
+ )
407
442
 
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]")
443
+ print_success(f"Feature '{key}' added successfully")
444
+ console.print(f"[dim]Feature: {title}[/dim]")
445
+ if outcomes_list:
446
+ console.print(f"[dim]Outcomes: {', '.join(outcomes_list)}[/dim]")
447
+ if acceptance_list:
448
+ console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]")
414
449
 
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
450
+ except Exception as e:
451
+ print_error(f"Failed to add feature: {e}")
452
+ raise typer.Exit(1) from e
420
453
 
421
454
 
422
455
  @app.command("add-story")
@@ -455,94 +488,621 @@ def add_story(
455
488
  """
456
489
  from specfact_cli.utils.structure import SpecFactStructure
457
490
 
458
- # Use default path if not specified
459
- if plan is None:
460
- plan = SpecFactStructure.get_default_plan_path()
491
+ telemetry_metadata = {
492
+ "feature_key": feature,
493
+ "story_key": key,
494
+ }
495
+
496
+ with telemetry.track_command("plan.add_story", telemetry_metadata) as record:
497
+ # Use default path if not specified
498
+ if plan is None:
499
+ plan = SpecFactStructure.get_default_plan_path()
500
+ if not plan.exists():
501
+ print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
502
+ raise typer.Exit(1)
503
+ print_info(f"Using default plan: {plan}")
504
+
461
505
  if not plan.exists():
462
- print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
506
+ print_error(f"Plan bundle not found: {plan}")
463
507
  raise typer.Exit(1)
464
- print_info(f"Using default plan: {plan}")
465
508
 
466
- if not plan.exists():
467
- print_error(f"Plan bundle not found: {plan}")
468
- raise typer.Exit(1)
509
+ print_section("SpecFact CLI - Add Story")
510
+
511
+ try:
512
+ # Load existing plan
513
+ print_info(f"Loading plan: {plan}")
514
+ validation_result = validate_plan_bundle(plan)
515
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
516
+ is_valid, error, existing_plan = validation_result
517
+
518
+ if not is_valid or existing_plan is None:
519
+ print_error(f"Plan validation failed: {error}")
520
+ raise typer.Exit(1)
521
+
522
+ # Find parent feature
523
+ parent_feature = None
524
+ for f in existing_plan.features:
525
+ if f.key == feature:
526
+ parent_feature = f
527
+ break
528
+
529
+ if parent_feature is None:
530
+ print_error(f"Feature '{feature}' not found in plan")
531
+ console.print(f"[dim]Available features: {', '.join(f.key for f in existing_plan.features)}[/dim]")
532
+ raise typer.Exit(1)
533
+
534
+ # Check if story key already exists in feature
535
+ existing_story_keys = {s.key for s in parent_feature.stories}
536
+ if key in existing_story_keys:
537
+ print_error(f"Story '{key}' already exists in feature '{feature}'")
538
+ raise typer.Exit(1)
539
+
540
+ # Parse acceptance (comma-separated string)
541
+ acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else []
542
+
543
+ # Create new story
544
+ new_story = Story(
545
+ key=key,
546
+ title=title,
547
+ acceptance=acceptance_list,
548
+ tags=[],
549
+ story_points=story_points,
550
+ value_points=value_points,
551
+ tasks=[],
552
+ confidence=1.0,
553
+ draft=draft,
554
+ contracts=None,
555
+ scenarios=None,
556
+ )
557
+
558
+ # Add story to feature
559
+ parent_feature.stories.append(new_story)
560
+
561
+ # Validate updated plan (always passes for PlanBundle model)
562
+ print_info("Validating updated plan...")
563
+
564
+ # Save updated plan
565
+ print_info(f"Saving plan to: {plan}")
566
+ generator = PlanGenerator()
567
+ generator.generate(existing_plan, plan)
568
+
569
+ record(
570
+ {
571
+ "total_stories": len(parent_feature.stories),
572
+ "acceptance_count": len(acceptance_list),
573
+ "story_points": story_points if story_points else 0,
574
+ "value_points": value_points if value_points else 0,
575
+ }
576
+ )
577
+
578
+ print_success(f"Story '{key}' added to feature '{feature}'")
579
+ console.print(f"[dim]Story: {title}[/dim]")
580
+ if acceptance_list:
581
+ console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]")
582
+ if story_points:
583
+ console.print(f"[dim]Story Points: {story_points}[/dim]")
584
+ if value_points:
585
+ console.print(f"[dim]Value Points: {value_points}[/dim]")
586
+
587
+ except Exception as e:
588
+ print_error(f"Failed to add story: {e}")
589
+ raise typer.Exit(1) from e
590
+
591
+
592
+ @app.command("update-idea")
593
+ @beartype
594
+ @require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
595
+ def update_idea(
596
+ title: str | None = typer.Option(None, "--title", help="Idea title"),
597
+ narrative: str | None = typer.Option(None, "--narrative", help="Idea narrative (brief description)"),
598
+ target_users: str | None = typer.Option(None, "--target-users", help="Target user personas (comma-separated)"),
599
+ value_hypothesis: str | None = typer.Option(None, "--value-hypothesis", help="Value hypothesis statement"),
600
+ constraints: str | None = typer.Option(None, "--constraints", help="Idea-level constraints (comma-separated)"),
601
+ plan: Path | None = typer.Option(
602
+ None,
603
+ "--plan",
604
+ help="Path to plan bundle (default: active plan or latest)",
605
+ ),
606
+ ) -> None:
607
+ """
608
+ Update idea section metadata in a plan bundle (optional business context).
609
+
610
+ This command allows updating idea properties (title, narrative, target users,
611
+ value hypothesis, constraints) in non-interactive environments (CI/CD, Copilot).
612
+
613
+ Note: The idea section is OPTIONAL - it provides business context and metadata,
614
+ not technical implementation details. All parameters are optional.
615
+
616
+ Example:
617
+ specfact plan update-idea --target-users "Developers, DevOps" --value-hypothesis "Reduce technical debt"
618
+ specfact plan update-idea --constraints "Python 3.11+, Maintain backward compatibility"
619
+ """
620
+ from specfact_cli.utils.structure import SpecFactStructure
469
621
 
470
- print_section("SpecFact CLI - Add Story")
622
+ telemetry_metadata = {}
471
623
 
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
624
+ with telemetry.track_command("plan.update_idea", telemetry_metadata) as record:
625
+ # Use default path if not specified
626
+ if plan is None:
627
+ default_plan = SpecFactStructure.get_default_plan_path()
628
+ if default_plan.exists():
629
+ plan = default_plan
630
+ print_info(f"Using default plan: {plan}")
631
+ else:
632
+ # Find latest plan bundle
633
+ base_path = Path(".")
634
+ plans_dir = base_path / SpecFactStructure.PLANS
635
+ if plans_dir.exists():
636
+ plan_files = sorted(plans_dir.glob("*.bundle.yaml"), key=lambda p: p.stat().st_mtime, reverse=True)
637
+ if plan_files:
638
+ plan = plan_files[0]
639
+ print_info(f"Using latest plan: {plan}")
640
+ else:
641
+ print_error(f"No plan bundles found in {plans_dir}")
642
+ print_error("Create one with: specfact plan init --interactive")
643
+ raise typer.Exit(1)
644
+ else:
645
+ print_error(f"Plans directory not found: {plans_dir}")
646
+ print_error("Create one with: specfact plan init --interactive")
647
+ raise typer.Exit(1)
478
648
 
479
- if not is_valid or existing_plan is None:
480
- print_error(f"Plan validation failed: {error}")
649
+ # Type guard: ensure plan is not None
650
+ if plan is None:
651
+ print_error("Plan bundle path is required")
481
652
  raise typer.Exit(1)
482
653
 
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
654
+ if not plan.exists():
655
+ print_error(f"Plan bundle not found: {plan}")
656
+ raise typer.Exit(1)
657
+
658
+ print_section("SpecFact CLI - Update Idea")
659
+
660
+ try:
661
+ # Load existing plan
662
+ print_info(f"Loading plan: {plan}")
663
+ validation_result = validate_plan_bundle(plan)
664
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
665
+ is_valid, error, existing_plan = validation_result
666
+
667
+ if not is_valid or existing_plan is None:
668
+ print_error(f"Plan validation failed: {error}")
669
+ raise typer.Exit(1)
670
+
671
+ # Create idea section if it doesn't exist
672
+ if existing_plan.idea is None:
673
+ existing_plan.idea = Idea(
674
+ title=title or "Untitled",
675
+ narrative=narrative or "",
676
+ target_users=[],
677
+ value_hypothesis="",
678
+ constraints=[],
679
+ metrics=None,
680
+ )
681
+ print_info("Created new idea section")
682
+
683
+ # Track what was updated
684
+ updates_made = []
685
+
686
+ # Update title if provided
687
+ if title is not None:
688
+ existing_plan.idea.title = title
689
+ updates_made.append("title")
690
+
691
+ # Update narrative if provided
692
+ if narrative is not None:
693
+ existing_plan.idea.narrative = narrative
694
+ updates_made.append("narrative")
695
+
696
+ # Update target_users if provided
697
+ if target_users is not None:
698
+ target_users_list = [u.strip() for u in target_users.split(",")] if target_users else []
699
+ existing_plan.idea.target_users = target_users_list
700
+ updates_made.append("target_users")
701
+
702
+ # Update value_hypothesis if provided
703
+ if value_hypothesis is not None:
704
+ existing_plan.idea.value_hypothesis = value_hypothesis
705
+ updates_made.append("value_hypothesis")
706
+
707
+ # Update constraints if provided
708
+ if constraints is not None:
709
+ constraints_list = [c.strip() for c in constraints.split(",")] if constraints else []
710
+ existing_plan.idea.constraints = constraints_list
711
+ updates_made.append("constraints")
712
+
713
+ if not updates_made:
714
+ print_warning(
715
+ "No updates specified. Use --title, --narrative, --target-users, --value-hypothesis, or --constraints"
716
+ )
717
+ raise typer.Exit(1)
718
+
719
+ # Validate updated plan (always passes for PlanBundle model)
720
+ print_info("Validating updated plan...")
721
+
722
+ # Save updated plan
723
+ # Type guard: ensure plan is not None (should never happen here, but type checker needs it)
724
+ if plan is None:
725
+ print_error("Plan bundle path is required")
726
+ raise typer.Exit(1)
727
+ print_info(f"Saving plan to: {plan}")
728
+ generator = PlanGenerator()
729
+ generator.generate(existing_plan, plan)
730
+
731
+ record(
732
+ {
733
+ "updates": updates_made,
734
+ "idea_exists": existing_plan.idea is not None,
735
+ }
736
+ )
737
+
738
+ print_success("Idea section updated successfully")
739
+ console.print(f"[dim]Updated fields: {', '.join(updates_made)}[/dim]")
740
+ if title:
741
+ console.print(f"[dim]Title: {title}[/dim]")
742
+ if narrative:
743
+ console.print(
744
+ f"[dim]Narrative: {narrative[:80]}...[/dim]"
745
+ if len(narrative) > 80
746
+ else f"[dim]Narrative: {narrative}[/dim]"
747
+ )
748
+ if target_users:
749
+ target_users_list = [u.strip() for u in target_users.split(",")] if target_users else []
750
+ console.print(f"[dim]Target Users: {', '.join(target_users_list)}[/dim]")
751
+ if value_hypothesis:
752
+ console.print(
753
+ f"[dim]Value Hypothesis: {value_hypothesis[:80]}...[/dim]"
754
+ if len(value_hypothesis) > 80
755
+ else f"[dim]Value Hypothesis: {value_hypothesis}[/dim]"
756
+ )
757
+ if constraints:
758
+ constraints_list = [c.strip() for c in constraints.split(",")] if constraints else []
759
+ console.print(f"[dim]Constraints: {', '.join(constraints_list)}[/dim]")
760
+
761
+ except Exception as e:
762
+ print_error(f"Failed to update idea: {e}")
763
+ raise typer.Exit(1) from e
764
+
765
+
766
+ @app.command("update-feature")
767
+ @beartype
768
+ @require(lambda key: isinstance(key, str) and len(key) > 0, "Key must be non-empty string")
769
+ @require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
770
+ def update_feature(
771
+ key: str = typer.Option(..., "--key", help="Feature key to update (e.g., FEATURE-001)"),
772
+ title: str | None = typer.Option(None, "--title", help="Feature title"),
773
+ outcomes: str | None = typer.Option(None, "--outcomes", help="Expected outcomes (comma-separated)"),
774
+ acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"),
775
+ constraints: str | None = typer.Option(None, "--constraints", help="Constraints (comma-separated)"),
776
+ confidence: float | None = typer.Option(None, "--confidence", help="Confidence score (0.0-1.0)"),
777
+ draft: bool | None = typer.Option(
778
+ None,
779
+ "--draft/--no-draft",
780
+ help="Mark as draft (use --draft to set True, --no-draft to set False, omit to leave unchanged)",
781
+ ),
782
+ plan: Path | None = typer.Option(
783
+ None,
784
+ "--plan",
785
+ help="Path to plan bundle (default: .specfact/plans/main.bundle.yaml)",
786
+ ),
787
+ ) -> None:
788
+ """
789
+ Update an existing feature's metadata in a plan bundle.
790
+
791
+ This command allows updating feature properties (title, outcomes, acceptance criteria,
792
+ constraints, confidence, draft status) in non-interactive environments (CI/CD, Copilot).
793
+
794
+ Example:
795
+ specfact plan update-feature --key FEATURE-001 --title "Updated Title" --outcomes "Outcome 1, Outcome 2"
796
+ specfact plan update-feature --key FEATURE-001 --acceptance "Criterion 1, Criterion 2" --confidence 0.9
797
+ """
798
+ from specfact_cli.utils.structure import SpecFactStructure
799
+
800
+ telemetry_metadata = {
801
+ "feature_key": key,
802
+ }
489
803
 
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]")
804
+ with telemetry.track_command("plan.update_feature", telemetry_metadata) as record:
805
+ # Use default path if not specified
806
+ if plan is None:
807
+ plan = SpecFactStructure.get_default_plan_path()
808
+ if not plan.exists():
809
+ print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
810
+ raise typer.Exit(1)
811
+ print_info(f"Using default plan: {plan}")
812
+
813
+ if not plan.exists():
814
+ print_error(f"Plan bundle not found: {plan}")
493
815
  raise typer.Exit(1)
494
816
 
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}'")
817
+ print_section("SpecFact CLI - Update Feature")
818
+
819
+ try:
820
+ # Load existing plan
821
+ print_info(f"Loading plan: {plan}")
822
+ validation_result = validate_plan_bundle(plan)
823
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
824
+ is_valid, error, existing_plan = validation_result
825
+
826
+ if not is_valid or existing_plan is None:
827
+ print_error(f"Plan validation failed: {error}")
828
+ raise typer.Exit(1)
829
+
830
+ # Find feature to update
831
+ feature_to_update = None
832
+ for f in existing_plan.features:
833
+ if f.key == key:
834
+ feature_to_update = f
835
+ break
836
+
837
+ if feature_to_update is None:
838
+ print_error(f"Feature '{key}' not found in plan")
839
+ console.print(f"[dim]Available features: {', '.join(f.key for f in existing_plan.features)}[/dim]")
840
+ raise typer.Exit(1)
841
+
842
+ # Track what was updated
843
+ updates_made = []
844
+
845
+ # Update title if provided
846
+ if title is not None:
847
+ feature_to_update.title = title
848
+ updates_made.append("title")
849
+
850
+ # Update outcomes if provided
851
+ if outcomes is not None:
852
+ outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else []
853
+ feature_to_update.outcomes = outcomes_list
854
+ updates_made.append("outcomes")
855
+
856
+ # Update acceptance criteria if provided
857
+ if acceptance is not None:
858
+ acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else []
859
+ feature_to_update.acceptance = acceptance_list
860
+ updates_made.append("acceptance")
861
+
862
+ # Update constraints if provided
863
+ if constraints is not None:
864
+ constraints_list = [c.strip() for c in constraints.split(",")] if constraints else []
865
+ feature_to_update.constraints = constraints_list
866
+ updates_made.append("constraints")
867
+
868
+ # Update confidence if provided
869
+ if confidence is not None:
870
+ if not (0.0 <= confidence <= 1.0):
871
+ print_error(f"Confidence must be between 0.0 and 1.0, got: {confidence}")
872
+ raise typer.Exit(1)
873
+ feature_to_update.confidence = confidence
874
+ updates_made.append("confidence")
875
+
876
+ # Update draft status if provided
877
+ if draft is not None:
878
+ feature_to_update.draft = draft
879
+ updates_made.append("draft")
880
+
881
+ if not updates_made:
882
+ print_warning(
883
+ "No updates specified. Use --title, --outcomes, --acceptance, --constraints, --confidence, or --draft"
884
+ )
885
+ raise typer.Exit(1)
886
+
887
+ # Validate updated plan (always passes for PlanBundle model)
888
+ print_info("Validating updated plan...")
889
+
890
+ # Save updated plan
891
+ print_info(f"Saving plan to: {plan}")
892
+ generator = PlanGenerator()
893
+ generator.generate(existing_plan, plan)
894
+
895
+ record(
896
+ {
897
+ "updates": updates_made,
898
+ "total_features": len(existing_plan.features),
899
+ }
900
+ )
901
+
902
+ print_success(f"Feature '{key}' updated successfully")
903
+ console.print(f"[dim]Updated fields: {', '.join(updates_made)}[/dim]")
904
+ if title:
905
+ console.print(f"[dim]Title: {title}[/dim]")
906
+ if outcomes:
907
+ outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else []
908
+ console.print(f"[dim]Outcomes: {', '.join(outcomes_list)}[/dim]")
909
+ if acceptance:
910
+ acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else []
911
+ console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]")
912
+
913
+ except Exception as e:
914
+ print_error(f"Failed to update feature: {e}")
915
+ raise typer.Exit(1) from e
916
+
917
+
918
+ @app.command("update-story")
919
+ @beartype
920
+ @require(lambda feature: isinstance(feature, str) and len(feature) > 0, "Feature must be non-empty string")
921
+ @require(lambda key: isinstance(key, str) and len(key) > 0, "Key must be non-empty string")
922
+ @require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
923
+ @require(
924
+ lambda story_points: story_points is None or (story_points >= 0 and story_points <= 100),
925
+ "Story points must be 0-100 if provided",
926
+ )
927
+ @require(
928
+ lambda value_points: value_points is None or (value_points >= 0 and value_points <= 100),
929
+ "Value points must be 0-100 if provided",
930
+ )
931
+ @require(lambda confidence: confidence is None or (0.0 <= confidence <= 1.0), "Confidence must be 0.0-1.0 if provided")
932
+ def update_story(
933
+ feature: str = typer.Option(..., "--feature", help="Parent feature key (e.g., FEATURE-001)"),
934
+ key: str = typer.Option(..., "--key", help="Story key to update (e.g., STORY-001)"),
935
+ title: str | None = typer.Option(None, "--title", help="Story title"),
936
+ acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"),
937
+ story_points: int | None = typer.Option(None, "--story-points", help="Story points (complexity: 0-100)"),
938
+ value_points: int | None = typer.Option(None, "--value-points", help="Value points (business value: 0-100)"),
939
+ confidence: float | None = typer.Option(None, "--confidence", help="Confidence score (0.0-1.0)"),
940
+ draft: bool | None = typer.Option(
941
+ None,
942
+ "--draft/--no-draft",
943
+ help="Mark as draft (use --draft to set True, --no-draft to set False, omit to leave unchanged)",
944
+ ),
945
+ plan: Path | None = typer.Option(
946
+ None,
947
+ "--plan",
948
+ help="Path to plan bundle (default: .specfact/plans/main.bundle.yaml)",
949
+ ),
950
+ ) -> None:
951
+ """
952
+ Update an existing story's metadata in a plan bundle.
953
+
954
+ This command allows updating story properties (title, acceptance criteria,
955
+ story points, value points, confidence, draft status) in non-interactive
956
+ environments (CI/CD, Copilot).
957
+
958
+ Example:
959
+ specfact plan update-story --feature FEATURE-001 --key STORY-001 --title "Updated Title"
960
+ specfact plan update-story --feature FEATURE-001 --key STORY-001 --acceptance "Criterion 1, Criterion 2" --confidence 0.9
961
+ specfact plan update-story --feature FEATURE-001 --key STORY-001 --acceptance "Given X, When Y, Then Z" --story-points 5
962
+ """
963
+ from specfact_cli.utils.structure import SpecFactStructure
964
+
965
+ telemetry_metadata = {
966
+ "feature_key": feature,
967
+ "story_key": key,
968
+ }
969
+
970
+ with telemetry.track_command("plan.update_story", telemetry_metadata) as record:
971
+ # Use default path if not specified
972
+ if plan is None:
973
+ plan = SpecFactStructure.get_default_plan_path()
974
+ if not plan.exists():
975
+ print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
976
+ raise typer.Exit(1)
977
+ print_info(f"Using default plan: {plan}")
978
+
979
+ if not plan.exists():
980
+ print_error(f"Plan bundle not found: {plan}")
499
981
  raise typer.Exit(1)
500
982
 
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
- )
983
+ print_section("SpecFact CLI - Update Story")
984
+
985
+ try:
986
+ # Load existing plan
987
+ print_info(f"Loading plan: {plan}")
988
+ validation_result = validate_plan_bundle(plan)
989
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
990
+ is_valid, error, existing_plan = validation_result
991
+
992
+ if not is_valid or existing_plan is None:
993
+ print_error(f"Plan validation failed: {error}")
994
+ raise typer.Exit(1)
516
995
 
517
- # Add story to feature
518
- parent_feature.stories.append(new_story)
996
+ # Find parent feature
997
+ parent_feature = None
998
+ for f in existing_plan.features:
999
+ if f.key == feature:
1000
+ parent_feature = f
1001
+ break
519
1002
 
520
- # Validate updated plan (always passes for PlanBundle model)
521
- print_info("Validating updated plan...")
1003
+ if parent_feature is None:
1004
+ print_error(f"Feature '{feature}' not found in plan")
1005
+ console.print(f"[dim]Available features: {', '.join(f.key for f in existing_plan.features)}[/dim]")
1006
+ raise typer.Exit(1)
522
1007
 
523
- # Save updated plan
524
- print_info(f"Saving plan to: {plan}")
525
- generator = PlanGenerator()
526
- generator.generate(existing_plan, plan)
1008
+ # Find story to update
1009
+ story_to_update = None
1010
+ for s in parent_feature.stories:
1011
+ if s.key == key:
1012
+ story_to_update = s
1013
+ break
527
1014
 
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]")
1015
+ if story_to_update is None:
1016
+ print_error(f"Story '{key}' not found in feature '{feature}'")
1017
+ console.print(f"[dim]Available stories: {', '.join(s.key for s in parent_feature.stories)}[/dim]")
1018
+ raise typer.Exit(1)
536
1019
 
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
1020
+ # Track what was updated
1021
+ updates_made = []
1022
+
1023
+ # Update title if provided
1024
+ if title is not None:
1025
+ story_to_update.title = title
1026
+ updates_made.append("title")
1027
+
1028
+ # Update acceptance criteria if provided
1029
+ if acceptance is not None:
1030
+ acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else []
1031
+ story_to_update.acceptance = acceptance_list
1032
+ updates_made.append("acceptance")
1033
+
1034
+ # Update story points if provided
1035
+ if story_points is not None:
1036
+ story_to_update.story_points = story_points
1037
+ updates_made.append("story_points")
1038
+
1039
+ # Update value points if provided
1040
+ if value_points is not None:
1041
+ story_to_update.value_points = value_points
1042
+ updates_made.append("value_points")
1043
+
1044
+ # Update confidence if provided
1045
+ if confidence is not None:
1046
+ if not (0.0 <= confidence <= 1.0):
1047
+ print_error(f"Confidence must be between 0.0 and 1.0, got: {confidence}")
1048
+ raise typer.Exit(1)
1049
+ story_to_update.confidence = confidence
1050
+ updates_made.append("confidence")
1051
+
1052
+ # Update draft status if provided
1053
+ if draft is not None:
1054
+ story_to_update.draft = draft
1055
+ updates_made.append("draft")
1056
+
1057
+ if not updates_made:
1058
+ print_warning(
1059
+ "No updates specified. Use --title, --acceptance, --story-points, --value-points, --confidence, or --draft"
1060
+ )
1061
+ raise typer.Exit(1)
1062
+
1063
+ # Validate updated plan (always passes for PlanBundle model)
1064
+ print_info("Validating updated plan...")
1065
+
1066
+ # Save updated plan
1067
+ print_info(f"Saving plan to: {plan}")
1068
+ generator = PlanGenerator()
1069
+ generator.generate(existing_plan, plan)
1070
+
1071
+ record(
1072
+ {
1073
+ "updates": updates_made,
1074
+ "total_stories": len(parent_feature.stories),
1075
+ }
1076
+ )
1077
+
1078
+ print_success(f"Story '{key}' in feature '{feature}' updated successfully")
1079
+ console.print(f"[dim]Updated fields: {', '.join(updates_made)}[/dim]")
1080
+ if title:
1081
+ console.print(f"[dim]Title: {title}[/dim]")
1082
+ if acceptance:
1083
+ acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else []
1084
+ console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]")
1085
+ if story_points is not None:
1086
+ console.print(f"[dim]Story Points: {story_points}[/dim]")
1087
+ if value_points is not None:
1088
+ console.print(f"[dim]Value Points: {value_points}[/dim]")
1089
+ if confidence is not None:
1090
+ console.print(f"[dim]Confidence: {confidence}[/dim]")
1091
+
1092
+ except Exception as e:
1093
+ print_error(f"Failed to update story: {e}")
1094
+ raise typer.Exit(1) from e
542
1095
 
543
1096
 
544
1097
  @app.command("compare")
545
1098
  @beartype
1099
+ @require(lambda manual: manual is None or isinstance(manual, Path), "Manual must be None or Path")
1100
+ @require(lambda auto: auto is None or isinstance(auto, Path), "Auto must be None or Path")
1101
+ @require(
1102
+ lambda format: isinstance(format, str) and format.lower() in ("markdown", "json", "yaml"),
1103
+ "Format must be markdown, json, or yaml",
1104
+ )
1105
+ @require(lambda out: out is None or isinstance(out, Path), "Out must be None or Path")
546
1106
  def compare(
547
1107
  manual: Path | None = typer.Option(
548
1108
  None,
@@ -554,6 +1114,11 @@ def compare(
554
1114
  "--auto",
555
1115
  help="Auto-derived plan bundle path (default: latest in .specfact/plans/)",
556
1116
  ),
1117
+ code_vs_plan: bool = typer.Option(
1118
+ False,
1119
+ "--code-vs-plan",
1120
+ help="Alias for comparing code-derived plan vs manual plan (auto-detects latest auto plan)",
1121
+ ),
557
1122
  format: str = typer.Option(
558
1123
  "markdown",
559
1124
  "--format",
@@ -566,197 +1131,267 @@ def compare(
566
1131
  ),
567
1132
  ) -> None:
568
1133
  """
569
- Compare manual and auto-derived plans.
1134
+ Compare manual and auto-derived plans to detect code vs plan drift.
1135
+
1136
+ Detects deviations between manually created plans (intended design) and
1137
+ reverse-engineered plans from code (actual implementation). This comparison
1138
+ identifies code vs plan drift automatically.
570
1139
 
571
- Detects deviations between manually created plans and
572
- reverse-engineered plans from code.
1140
+ Use --code-vs-plan for convenience: automatically compares the latest
1141
+ code-derived plan against the manual plan.
573
1142
 
574
1143
  Example:
575
1144
  specfact plan compare --manual .specfact/plans/main.bundle.yaml --auto .specfact/plans/auto-derived-<timestamp>.bundle.yaml
1145
+ specfact plan compare --code-vs-plan # Convenience alias
576
1146
  """
577
1147
  from specfact_cli.utils.structure import SpecFactStructure
578
1148
 
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)
1149
+ telemetry_metadata = {
1150
+ "code_vs_plan": code_vs_plan,
1151
+ "format": format.lower(),
1152
+ }
643
1153
 
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
- )
1154
+ with telemetry.track_command("plan.compare", telemetry_metadata) as record:
1155
+ # Ensure .specfact structure exists
1156
+ SpecFactStructure.ensure_structure()
653
1157
 
654
- # Display results
655
- print_section("Comparison Results")
1158
+ # Handle --code-vs-plan convenience alias
1159
+ if code_vs_plan:
1160
+ # Auto-detect manual plan (default)
1161
+ if manual is None:
1162
+ manual = SpecFactStructure.get_default_plan_path()
1163
+ if not manual.exists():
1164
+ print_error(
1165
+ f"Default manual plan not found: {manual}\nCreate one with: specfact plan init --interactive"
1166
+ )
1167
+ raise typer.Exit(1)
1168
+ print_info(f"Using default manual plan: {manual}")
1169
+
1170
+ # Auto-detect latest code-derived plan
1171
+ if auto is None:
1172
+ auto = SpecFactStructure.get_latest_brownfield_report()
1173
+ if auto is None:
1174
+ plans_dir = Path(SpecFactStructure.PLANS)
1175
+ print_error(
1176
+ f"No code-derived plans found in {plans_dir}\nGenerate one with: specfact import from-code --repo ."
1177
+ )
1178
+ raise typer.Exit(1)
1179
+ print_info(f"Using latest code-derived plan: {auto}")
656
1180
 
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")
1181
+ # Override help text to emphasize code vs plan drift
1182
+ print_section("Code vs Plan Drift Detection")
1183
+ console.print(
1184
+ "[dim]Comparing intended design (manual plan) vs actual implementation (code-derived plan)[/dim]\n"
1185
+ )
660
1186
 
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,
1187
+ # Use default paths if not specified (smart defaults)
1188
+ if manual is None:
1189
+ manual = SpecFactStructure.get_default_plan_path()
1190
+ if not manual.exists():
1191
+ print_error(
1192
+ f"Default manual plan not found: {manual}\nCreate one with: specfact plan init --interactive"
684
1193
  )
1194
+ raise typer.Exit(1)
1195
+ print_info(f"Using default manual plan: {manual}")
685
1196
 
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
- }
1197
+ if auto is None:
1198
+ # Use smart default: find latest auto-derived plan
1199
+ auto = SpecFactStructure.get_latest_brownfield_report()
1200
+ if auto is None:
1201
+ plans_dir = Path(SpecFactStructure.PLANS)
1202
+ print_error(
1203
+ f"No auto-derived plans found in {plans_dir}\nGenerate one with: specfact import from-code --repo ."
1204
+ )
1205
+ raise typer.Exit(1)
1206
+ print_info(f"Using latest auto-derived plan: {auto}")
699
1207
 
700
- report_format = format_map.get(format.lower(), ReportFormat.MARKDOWN)
701
- generator.generate_deviation_report(report, out, report_format)
1208
+ if out is None:
1209
+ # Use smart default: timestamped comparison report
1210
+ extension = {"markdown": "md", "json": "json", "yaml": "yaml"}[format.lower()]
1211
+ out = SpecFactStructure.get_comparison_report_path(format=extension)
1212
+ print_info(f"Writing comparison report to: {out}")
702
1213
 
703
- print_success(f"Report written to: {out}")
1214
+ print_section("SpecFact CLI - Plan Comparison")
704
1215
 
705
- # Apply enforcement rules if config exists
706
- from specfact_cli.utils.structure import SpecFactStructure
1216
+ # Validate inputs (after defaults are set)
1217
+ if manual is not None and not manual.exists():
1218
+ print_error(f"Manual plan not found: {manual}")
1219
+ raise typer.Exit(1)
707
1220
 
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
1221
+ if auto is not None and not auto.exists():
1222
+ print_error(f"Auto plan not found: {auto}")
1223
+ raise typer.Exit(1)
712
1224
 
713
- config_data = load_yaml(config_path)
714
- enforcement_config = EnforcementConfig(**config_data)
1225
+ # Validate format
1226
+ if format.lower() not in ("markdown", "json", "yaml"):
1227
+ print_error(f"Invalid format: {format}. Must be markdown, json, or yaml")
1228
+ raise typer.Exit(1)
715
1229
 
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")
1230
+ try:
1231
+ # Load plans
1232
+ # Note: validate_plan_bundle returns tuple[bool, str | None, PlanBundle | None] when given a Path
1233
+ print_info(f"Loading manual plan: {manual}")
1234
+ validation_result = validate_plan_bundle(manual)
1235
+ # Type narrowing: when Path is passed, always returns tuple
1236
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
1237
+ is_valid, error, manual_plan = validation_result
1238
+ if not is_valid or manual_plan is None:
1239
+ print_error(f"Manual plan validation failed: {error}")
1240
+ raise typer.Exit(1)
719
1241
 
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]
1242
+ print_info(f"Loading auto plan: {auto}")
1243
+ validation_result = validate_plan_bundle(auto)
1244
+ # Type narrowing: when Path is passed, always returns tuple
1245
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
1246
+ is_valid, error, auto_plan = validation_result
1247
+ if not is_valid or auto_plan is None:
1248
+ print_error(f"Auto plan validation failed: {error}")
1249
+ raise typer.Exit(1)
725
1250
 
726
- console.print(
727
- f"{action_icon} [{deviation.severity.value}] {deviation.type.value}: "
728
- f"[dim]{action.value}[/dim]"
729
- )
1251
+ # Compare plans
1252
+ print_info("Comparing plans...")
1253
+ comparator = PlanComparator()
1254
+ report = comparator.compare(
1255
+ manual_plan,
1256
+ auto_plan,
1257
+ manual_label=str(manual),
1258
+ auto_label=str(auto),
1259
+ )
730
1260
 
731
- if enforcement_config.should_block_deviation(deviation.severity.value):
732
- blocking_deviations.append(deviation)
1261
+ # Record comparison results
1262
+ record(
1263
+ {
1264
+ "total_deviations": report.total_deviations,
1265
+ "high_count": report.high_count,
1266
+ "medium_count": report.medium_count,
1267
+ "low_count": report.low_count,
1268
+ "manual_features": len(manual_plan.features) if manual_plan.features else 0,
1269
+ "auto_features": len(auto_plan.features) if auto_plan.features else 0,
1270
+ }
1271
+ )
733
1272
 
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")
1273
+ # Display results
1274
+ print_section("Comparison Results")
741
1275
 
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}")
1276
+ console.print(f"[cyan]Manual Plan:[/cyan] {manual}")
1277
+ console.print(f"[cyan]Auto Plan:[/cyan] {auto}")
1278
+ console.print(f"[cyan]Total Deviations:[/cyan] {report.total_deviations}\n")
747
1279
 
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")
1280
+ if report.total_deviations == 0:
1281
+ print_success("No deviations found! Plans are identical.")
1282
+ else:
1283
+ # Show severity summary
1284
+ console.print("[bold]Deviation Summary:[/bold]")
1285
+ console.print(f" 🔴 [bold red]HIGH:[/bold red] {report.high_count}")
1286
+ console.print(f" 🟡 [bold yellow]MEDIUM:[/bold yellow] {report.medium_count}")
1287
+ console.print(f" 🔵 [bold blue]LOW:[/bold blue] {report.low_count}\n")
1288
+
1289
+ # Show detailed table
1290
+ table = Table(title="Deviations by Type and Severity")
1291
+ table.add_column("Severity", style="bold")
1292
+ table.add_column("Type", style="cyan")
1293
+ table.add_column("Description", style="white", no_wrap=False)
1294
+ table.add_column("Location", style="dim")
1295
+
1296
+ for deviation in report.deviations:
1297
+ severity_icon = {"HIGH": "🔴", "MEDIUM": "🟡", "LOW": "🔵"}[deviation.severity.value]
1298
+ table.add_row(
1299
+ f"{severity_icon} {deviation.severity.value}",
1300
+ deviation.type.value.replace("_", " ").title(),
1301
+ deviation.description[:80] + "..."
1302
+ if len(deviation.description) > 80
1303
+ else deviation.description,
1304
+ deviation.location,
1305
+ )
1306
+
1307
+ console.print(table)
1308
+
1309
+ # Generate report file if requested
1310
+ if out:
1311
+ print_info(f"Generating {format} report...")
1312
+ generator = ReportGenerator()
1313
+
1314
+ # Map format string to enum
1315
+ format_map = {
1316
+ "markdown": ReportFormat.MARKDOWN,
1317
+ "json": ReportFormat.JSON,
1318
+ "yaml": ReportFormat.YAML,
1319
+ }
1320
+
1321
+ report_format = format_map.get(format.lower(), ReportFormat.MARKDOWN)
1322
+ generator.generate_deviation_report(report, out, report_format)
1323
+
1324
+ print_success(f"Report written to: {out}")
1325
+
1326
+ # Apply enforcement rules if config exists
1327
+ from specfact_cli.utils.structure import SpecFactStructure
1328
+
1329
+ # Determine base path from plan paths (use manual plan's parent directory)
1330
+ base_path = manual.parent if manual else None
1331
+ # If base_path is not a repository root, find the repository root
1332
+ if base_path:
1333
+ # Walk up to find repository root (where .specfact would be)
1334
+ current = base_path.resolve()
1335
+ while current != current.parent:
1336
+ if (current / SpecFactStructure.ROOT).exists():
1337
+ base_path = current
1338
+ break
1339
+ current = current.parent
1340
+ else:
1341
+ # If we didn't find .specfact, use the plan's directory
1342
+ # But resolve to absolute path first
1343
+ base_path = manual.parent.resolve()
1344
+
1345
+ config_path = SpecFactStructure.get_enforcement_config_path(base_path)
1346
+ if config_path.exists():
1347
+ try:
1348
+ from specfact_cli.utils.yaml_utils import load_yaml
1349
+
1350
+ config_data = load_yaml(config_path)
1351
+ enforcement_config = EnforcementConfig(**config_data)
1352
+
1353
+ if enforcement_config.enabled and report.total_deviations > 0:
1354
+ print_section("Enforcement Rules")
1355
+ console.print(f"[dim]Using enforcement config: {config_path}[/dim]\n")
1356
+
1357
+ # Check for blocking deviations
1358
+ blocking_deviations: list[Deviation] = []
1359
+ for deviation in report.deviations:
1360
+ action = enforcement_config.get_action(deviation.severity.value)
1361
+ action_icon = {"BLOCK": "🚫", "WARN": "⚠️", "LOG": "📝"}[action.value]
1362
+
1363
+ console.print(
1364
+ f"{action_icon} [{deviation.severity.value}] {deviation.type.value}: "
1365
+ f"[dim]{action.value}[/dim]"
1366
+ )
1367
+
1368
+ if enforcement_config.should_block_deviation(deviation.severity.value):
1369
+ blocking_deviations.append(deviation)
1370
+
1371
+ if blocking_deviations:
1372
+ print_error(
1373
+ f"\n❌ Enforcement BLOCKED: {len(blocking_deviations)} deviation(s) violate quality gates"
1374
+ )
1375
+ console.print("[dim]Fix the blocking deviations or adjust enforcement config[/dim]")
1376
+ raise typer.Exit(1)
1377
+ print_success("\n✅ Enforcement PASSED: No blocking deviations")
1378
+
1379
+ except Exception as e:
1380
+ print_warning(f"Could not load enforcement config: {e}")
1381
+ raise typer.Exit(1) from e
1382
+
1383
+ # Note: Finding deviations without enforcement is a successful comparison result
1384
+ # Exit code 0 indicates successful execution (even if deviations were found)
1385
+ # Use the report file, stdout, or enforcement config to determine if deviations are critical
1386
+ if report.total_deviations > 0:
1387
+ print_warning(f"\n{report.total_deviations} deviation(s) found")
753
1388
 
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
1389
+ except KeyboardInterrupt:
1390
+ print_warning("\nComparison cancelled")
1391
+ raise typer.Exit(1) from None
1392
+ except Exception as e:
1393
+ print_error(f"Comparison failed: {e}")
1394
+ raise typer.Exit(1) from e
760
1395
 
761
1396
 
762
1397
  @app.command("select")
@@ -781,121 +1416,247 @@ def select(
781
1416
  """
782
1417
  from specfact_cli.utils.structure import SpecFactStructure
783
1418
 
784
- print_section("SpecFact CLI - Plan Selection")
1419
+ telemetry_metadata = {}
785
1420
 
786
- # List all available plans
787
- plans = SpecFactStructure.list_plans()
1421
+ with telemetry.track_command("plan.select", telemetry_metadata) as record:
1422
+ print_section("SpecFact CLI - Plan Selection")
788
1423
 
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)
1424
+ # List all available plans
1425
+ plans = SpecFactStructure.list_plans()
795
1426
 
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]
1427
+ if not plans:
1428
+ print_warning("No plan bundles found in .specfact/plans/")
1429
+ print_info("Create a plan with:")
1430
+ print_info(" - specfact plan init")
1431
+ print_info(" - specfact import from-code")
1432
+ raise typer.Exit(1)
1433
+
1434
+ # If plan provided, try to resolve it
1435
+ if plan is not None:
1436
+ # Try as number first
1437
+ if isinstance(plan, str) and plan.isdigit():
1438
+ plan_num = int(plan)
1439
+ if 1 <= plan_num <= len(plans):
1440
+ selected_plan = plans[plan_num - 1]
1441
+ else:
1442
+ print_error(f"Invalid plan number: {plan_num}. Must be between 1 and {len(plans)}")
1443
+ raise typer.Exit(1)
803
1444
  else:
804
- print_error(f"Invalid plan number: {plan_num}. Must be between 1 and {len(plans)}")
805
- raise typer.Exit(1)
1445
+ # Try as name
1446
+ plan_name = str(plan)
1447
+ # Remove .bundle.yaml suffix if present
1448
+ if plan_name.endswith(".bundle.yaml"):
1449
+ plan_name = plan_name
1450
+ elif not plan_name.endswith(".yaml"):
1451
+ plan_name = f"{plan_name}.bundle.yaml"
1452
+
1453
+ # Find matching plan
1454
+ selected_plan = None
1455
+ for p in plans:
1456
+ if p["name"] == plan_name or p["name"] == plan:
1457
+ selected_plan = p
1458
+ break
1459
+
1460
+ if selected_plan is None:
1461
+ print_error(f"Plan not found: {plan}")
1462
+ print_info("Available plans:")
1463
+ for i, p in enumerate(plans, 1):
1464
+ print_info(f" {i}. {p['name']}")
1465
+ raise typer.Exit(1)
806
1466
  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
1467
+ # Interactive selection - display numbered list
1468
+ console.print("\n[bold]Available Plans:[/bold]\n")
1469
+
1470
+ # Create table with optimized column widths
1471
+ # "#" column: fixed at 4 chars (never shrinks)
1472
+ # Features/Stories/Stage: minimal widths to avoid wasting space
1473
+ # Plan Name: flexible to use remaining space (most important)
1474
+ table = Table(show_header=True, header_style="bold cyan", expand=False)
1475
+ table.add_column("#", style="bold yellow", justify="right", width=4, min_width=4, no_wrap=True)
1476
+ table.add_column("Status", style="dim", width=8, min_width=6)
1477
+ table.add_column("Plan Name", style="bold", min_width=30) # Flexible, gets most space
1478
+ table.add_column("Features", justify="right", width=8, min_width=6) # Reduced from 10
1479
+ table.add_column("Stories", justify="right", width=8, min_width=6) # Reduced from 10
1480
+ table.add_column("Stage", width=8, min_width=6) # Reduced from 10 to 8 (draft/review/approved/released fit)
1481
+ table.add_column("Modified", style="dim", width=19, min_width=15) # Slightly reduced
1482
+
1483
+ for i, p in enumerate(plans, 1):
1484
+ status = "[ACTIVE]" if p.get("active") else ""
1485
+ plan_name = str(p["name"])
1486
+ features_count = str(p["features"])
1487
+ stories_count = str(p["stories"])
1488
+ stage = str(p.get("stage", "unknown"))
1489
+ modified = str(p["modified"])
1490
+ modified_display = modified[:19] if len(modified) > 19 else modified
1491
+ table.add_row(
1492
+ f"[bold yellow]{i}[/bold yellow]",
1493
+ status,
1494
+ plan_name,
1495
+ features_count,
1496
+ stories_count,
1497
+ stage,
1498
+ modified_display,
1499
+ )
821
1500
 
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
- )
1501
+ console.print(table)
1502
+ console.print()
858
1503
 
859
- console.print(table)
860
- console.print()
1504
+ # Prompt for selection
1505
+ selection = ""
1506
+ try:
1507
+ selection = prompt_text(f"Select a plan by number (1-{len(plans)}) or 'q' to quit: ").strip()
861
1508
 
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()
1509
+ if selection.lower() in ("q", "quit", ""):
1510
+ print_info("Selection cancelled")
1511
+ raise typer.Exit(0)
866
1512
 
867
- if selection.lower() in ("q", "quit", ""):
868
- print_info("Selection cancelled")
869
- raise typer.Exit(0)
1513
+ plan_num = int(selection)
1514
+ if not (1 <= plan_num <= len(plans)):
1515
+ print_error(f"Invalid selection: {plan_num}. Must be between 1 and {len(plans)}")
1516
+ raise typer.Exit(1)
870
1517
 
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)
1518
+ selected_plan = plans[plan_num - 1]
1519
+ except ValueError:
1520
+ print_error(f"Invalid input: {selection}. Please enter a number.")
1521
+ raise typer.Exit(1) from None
1522
+ except KeyboardInterrupt:
1523
+ print_warning("\nSelection cancelled")
1524
+ raise typer.Exit(1) from None
1525
+
1526
+ # Set as active plan
1527
+ plan_name = str(selected_plan["name"])
1528
+ SpecFactStructure.set_active_plan(plan_name)
1529
+
1530
+ record(
1531
+ {
1532
+ "plans_available": len(plans),
1533
+ "selected_plan": plan_name,
1534
+ "features": selected_plan["features"],
1535
+ "stories": selected_plan["stories"],
1536
+ }
1537
+ )
875
1538
 
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
1539
+ print_success(f"Active plan set to: {plan_name}")
1540
+ print_info(f" Features: {selected_plan['features']}")
1541
+ print_info(f" Stories: {selected_plan['stories']}")
1542
+ print_info(f" Stage: {selected_plan.get('stage', 'unknown')}")
1543
+
1544
+ print_info("\nThis plan will now be used as the default for:")
1545
+ print_info(" - specfact plan compare")
1546
+ print_info(" - specfact plan promote")
1547
+ print_info(" - specfact plan add-feature")
1548
+ print_info(" - specfact plan add-story")
1549
+ print_info(" - specfact plan sync --shared")
1550
+ print_info(" - specfact sync spec-kit")
1551
+
1552
+
1553
+ @app.command("sync")
1554
+ @beartype
1555
+ @require(lambda repo: repo is None or isinstance(repo, Path), "Repo must be None or Path")
1556
+ @require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
1557
+ @require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool")
1558
+ @require(lambda watch: isinstance(watch, bool), "Watch must be bool")
1559
+ @require(lambda interval: isinstance(interval, int) and interval >= 1, "Interval must be int >= 1")
1560
+ def sync(
1561
+ shared: bool = typer.Option(
1562
+ False,
1563
+ "--shared",
1564
+ help="Enable shared plans sync (bidirectional sync with Spec-Kit)",
1565
+ ),
1566
+ repo: Path | None = typer.Option(
1567
+ None,
1568
+ "--repo",
1569
+ help="Path to repository (default: current directory)",
1570
+ ),
1571
+ plan: Path | None = typer.Option(
1572
+ None,
1573
+ "--plan",
1574
+ help="Path to SpecFact plan bundle for SpecFact → Spec-Kit conversion (default: active plan)",
1575
+ ),
1576
+ overwrite: bool = typer.Option(
1577
+ False,
1578
+ "--overwrite",
1579
+ help="Overwrite existing Spec-Kit artifacts (delete all existing before sync)",
1580
+ ),
1581
+ watch: bool = typer.Option(
1582
+ False,
1583
+ "--watch",
1584
+ help="Watch mode for continuous sync",
1585
+ ),
1586
+ interval: int = typer.Option(
1587
+ 5,
1588
+ "--interval",
1589
+ help="Watch interval in seconds (default: 5)",
1590
+ min=1,
1591
+ ),
1592
+ ) -> None:
1593
+ """
1594
+ Sync shared plans between Spec-Kit and SpecFact (bidirectional sync).
883
1595
 
884
- # Set as active plan
885
- plan_name = str(selected_plan["name"])
886
- SpecFactStructure.set_active_plan(plan_name)
1596
+ This is a convenience wrapper around `specfact sync spec-kit --bidirectional`
1597
+ that enables team collaboration through shared structured plans. The bidirectional
1598
+ sync keeps Spec-Kit artifacts and SpecFact plans synchronized automatically.
887
1599
 
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')}")
1600
+ Shared plans enable:
1601
+ - Team collaboration: Multiple developers can work on the same plan
1602
+ - Automated sync: Changes in Spec-Kit automatically sync to SpecFact
1603
+ - Deviation detection: Compare code vs plan drift automatically
1604
+ - Conflict resolution: Automatic conflict detection and resolution
892
1605
 
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")
1606
+ Example:
1607
+ specfact plan sync --shared # One-time sync
1608
+ specfact plan sync --shared --watch # Continuous sync
1609
+ specfact plan sync --shared --repo ./project # Sync specific repo
1610
+ """
1611
+ from specfact_cli.commands.sync import sync_spec_kit
1612
+ from specfact_cli.utils.structure import SpecFactStructure
1613
+
1614
+ telemetry_metadata = {
1615
+ "shared": shared,
1616
+ "watch": watch,
1617
+ "overwrite": overwrite,
1618
+ "interval": interval,
1619
+ }
1620
+
1621
+ with telemetry.track_command("plan.sync", telemetry_metadata) as record:
1622
+ if not shared:
1623
+ print_error("This command requires --shared flag")
1624
+ print_info("Use 'specfact plan sync --shared' to enable shared plans sync")
1625
+ print_info("Or use 'specfact sync spec-kit --bidirectional' for direct sync")
1626
+ raise typer.Exit(1)
1627
+
1628
+ # Use default repo if not specified
1629
+ if repo is None:
1630
+ repo = Path(".").resolve()
1631
+ print_info(f"Using current directory: {repo}")
1632
+
1633
+ # Use default plan if not specified
1634
+ if plan is None:
1635
+ plan = SpecFactStructure.get_default_plan_path()
1636
+ if not plan.exists():
1637
+ print_warning(f"Default plan not found: {plan}")
1638
+ print_info("Using default plan path (will be created if needed)")
1639
+ else:
1640
+ print_info(f"Using active plan: {plan}")
1641
+
1642
+ print_section("Shared Plans Sync")
1643
+ console.print("[dim]Bidirectional sync between Spec-Kit and SpecFact for team collaboration[/dim]\n")
1644
+
1645
+ # Call the underlying sync command
1646
+ try:
1647
+ # Call sync_spec_kit with bidirectional=True
1648
+ sync_spec_kit(
1649
+ repo=repo,
1650
+ bidirectional=True, # Always bidirectional for shared plans
1651
+ plan=plan,
1652
+ overwrite=overwrite,
1653
+ watch=watch,
1654
+ interval=interval,
1655
+ )
1656
+ record({"sync_completed": True})
1657
+ except Exception as e:
1658
+ print_error(f"Shared plans sync failed: {e}")
1659
+ raise typer.Exit(1) from e
899
1660
 
900
1661
 
901
1662
  @app.command("promote")
@@ -938,152 +1699,977 @@ def promote(
938
1699
 
939
1700
  from specfact_cli.utils.structure import SpecFactStructure
940
1701
 
941
- # Use default path if not specified
942
- if plan is None:
943
- plan = SpecFactStructure.get_default_plan_path()
1702
+ telemetry_metadata = {
1703
+ "target_stage": stage,
1704
+ "validate": validate,
1705
+ "force": force,
1706
+ }
1707
+
1708
+ with telemetry.track_command("plan.promote", telemetry_metadata) as record:
1709
+ # Use default path if not specified
1710
+ if plan is None:
1711
+ plan = SpecFactStructure.get_default_plan_path()
1712
+ if not plan.exists():
1713
+ print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
1714
+ raise typer.Exit(1)
1715
+ print_info(f"Using default plan: {plan}")
1716
+
944
1717
  if not plan.exists():
945
- print_error(f"Default plan not found: {plan}\nCreate one with: specfact plan init --interactive")
1718
+ print_error(f"Plan bundle not found: {plan}")
946
1719
  raise typer.Exit(1)
947
- print_info(f"Using default plan: {plan}")
948
1720
 
949
- if not plan.exists():
950
- print_error(f"Plan bundle not found: {plan}")
951
- raise typer.Exit(1)
1721
+ print_section("SpecFact CLI - Plan Promotion")
952
1722
 
953
- print_section("SpecFact CLI - Plan Promotion")
1723
+ try:
1724
+ # Load existing plan
1725
+ print_info(f"Loading plan: {plan}")
1726
+ validation_result = validate_plan_bundle(plan)
1727
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
1728
+ is_valid, error, bundle = validation_result
1729
+
1730
+ if not is_valid or bundle is None:
1731
+ print_error(f"Plan validation failed: {error}")
1732
+ raise typer.Exit(1)
954
1733
 
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
1734
+ # Check current stage
1735
+ current_stage = "draft"
1736
+ if bundle.metadata:
1737
+ current_stage = bundle.metadata.stage
961
1738
 
962
- if not is_valid or bundle is None:
963
- print_error(f"Plan validation failed: {error}")
964
- raise typer.Exit(1)
1739
+ print_info(f"Current stage: {current_stage}")
1740
+ print_info(f"Target stage: {stage}")
965
1741
 
966
- # Check current stage
967
- current_stage = "draft"
968
- if bundle.metadata:
969
- current_stage = bundle.metadata.stage
1742
+ # Validate stage progression
1743
+ stage_order = {"draft": 0, "review": 1, "approved": 2, "released": 3}
1744
+ current_order = stage_order.get(current_stage, 0)
1745
+ target_order = stage_order.get(stage, 0)
970
1746
 
971
- print_info(f"Current stage: {current_stage}")
972
- print_info(f"Target stage: {stage}")
1747
+ if target_order < current_order:
1748
+ print_error(f"Cannot promote backward: {current_stage} → {stage}")
1749
+ print_error("Only forward promotion is allowed (draft → review → approved → released)")
1750
+ raise typer.Exit(1)
973
1751
 
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)
1752
+ if target_order == current_order:
1753
+ print_warning(f"Plan is already at stage: {stage}")
1754
+ raise typer.Exit(0)
978
1755
 
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)
1756
+ # Validate promotion rules
1757
+ print_info("Checking promotion rules...")
1758
+
1759
+ # Draft → Review: All features must have at least one story
1760
+ if current_stage == "draft" and stage == "review":
1761
+ features_without_stories = [f for f in bundle.features if len(f.stories) == 0]
1762
+ if features_without_stories:
1763
+ print_error(f"Cannot promote to review: {len(features_without_stories)} feature(s) without stories")
1764
+ console.print("[dim]Features without stories:[/dim]")
1765
+ for f in features_without_stories[:5]:
1766
+ console.print(f" - {f.key}: {f.title}")
1767
+ if len(features_without_stories) > 5:
1768
+ console.print(f" ... and {len(features_without_stories) - 5} more")
1769
+ if not force:
1770
+ raise typer.Exit(1)
1771
+
1772
+ # Check coverage status for critical categories
1773
+ if validate:
1774
+ from specfact_cli.analyzers.ambiguity_scanner import (
1775
+ AmbiguityScanner,
1776
+ AmbiguityStatus,
1777
+ TaxonomyCategory,
1778
+ )
1779
+
1780
+ print_info("Checking coverage status...")
1781
+ scanner = AmbiguityScanner()
1782
+ report = scanner.scan(bundle)
1783
+
1784
+ # Critical categories that block promotion if Missing
1785
+ critical_categories = [
1786
+ TaxonomyCategory.FUNCTIONAL_SCOPE,
1787
+ TaxonomyCategory.FEATURE_COMPLETENESS,
1788
+ TaxonomyCategory.CONSTRAINTS,
1789
+ ]
1790
+
1791
+ # Important categories that warn if Missing or Partial
1792
+ important_categories = [
1793
+ TaxonomyCategory.DATA_MODEL,
1794
+ TaxonomyCategory.INTEGRATION,
1795
+ TaxonomyCategory.NON_FUNCTIONAL,
1796
+ ]
1797
+
1798
+ missing_critical: list[TaxonomyCategory] = []
1799
+ missing_important: list[TaxonomyCategory] = []
1800
+ partial_important: list[TaxonomyCategory] = []
1801
+
1802
+ if report.coverage:
1803
+ for category, status in report.coverage.items():
1804
+ if category in critical_categories and status == AmbiguityStatus.MISSING:
1805
+ missing_critical.append(category)
1806
+ elif category in important_categories:
1807
+ if status == AmbiguityStatus.MISSING:
1808
+ missing_important.append(category)
1809
+ elif status == AmbiguityStatus.PARTIAL:
1810
+ partial_important.append(category)
1811
+
1812
+ # Block promotion if critical categories are Missing
1813
+ if missing_critical:
1814
+ print_error(
1815
+ f"Cannot promote to review: {len(missing_critical)} critical category(ies) are Missing"
1816
+ )
1817
+ console.print("[dim]Missing critical categories:[/dim]")
1818
+ for cat in missing_critical:
1819
+ console.print(f" - {cat.value}")
1820
+ console.print("\n[dim]Run 'specfact plan review' to resolve these ambiguities[/dim]")
1821
+ if not force:
1822
+ raise typer.Exit(1)
1823
+
1824
+ # Warn if important categories are Missing or Partial
1825
+ if missing_important or partial_important:
1826
+ print_warning(
1827
+ f"Plan has {len(missing_important)} missing and {len(partial_important)} partial important category(ies)"
1828
+ )
1829
+ if missing_important:
1830
+ console.print("[dim]Missing important categories:[/dim]")
1831
+ for cat in missing_important:
1832
+ console.print(f" - {cat.value}")
1833
+ if partial_important:
1834
+ console.print("[dim]Partial important categories:[/dim]")
1835
+ for cat in partial_important:
1836
+ console.print(f" - {cat.value}")
1837
+ if not force:
1838
+ console.print("\n[dim]Consider running 'specfact plan review' to improve coverage[/dim]")
1839
+ console.print("[dim]Use --force to promote anyway[/dim]")
1840
+ if not prompt_confirm(
1841
+ "Continue with promotion despite missing/partial categories?", default=False
1842
+ ):
1843
+ raise typer.Exit(1)
1844
+
1845
+ # Review → Approved: All features must pass validation
1846
+ if current_stage == "review" and stage == "approved" and validate:
1847
+ print_info("Validating all features...")
1848
+ incomplete_features: list[Feature] = []
1849
+ for f in bundle.features:
1850
+ if not f.acceptance:
1851
+ incomplete_features.append(f)
1852
+ for s in f.stories:
1853
+ if not s.acceptance:
1854
+ incomplete_features.append(f)
1855
+ break
1856
+
1857
+ if incomplete_features:
1858
+ print_warning(f"{len(incomplete_features)} feature(s) have incomplete acceptance criteria")
1859
+ if not force:
1860
+ console.print("[dim]Use --force to promote anyway[/dim]")
1861
+ raise typer.Exit(1)
1862
+
1863
+ # Check coverage status for critical categories
1864
+ from specfact_cli.analyzers.ambiguity_scanner import (
1865
+ AmbiguityScanner,
1866
+ AmbiguityStatus,
1867
+ TaxonomyCategory,
1868
+ )
983
1869
 
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")
1870
+ print_info("Checking coverage status...")
1871
+ scanner_approved = AmbiguityScanner()
1872
+ report_approved = scanner_approved.scan(bundle)
1873
+
1874
+ # Critical categories that block promotion if Missing
1875
+ critical_categories_approved = [
1876
+ TaxonomyCategory.FUNCTIONAL_SCOPE,
1877
+ TaxonomyCategory.FEATURE_COMPLETENESS,
1878
+ TaxonomyCategory.CONSTRAINTS,
1879
+ ]
1880
+
1881
+ missing_critical_approved: list[TaxonomyCategory] = []
1882
+
1883
+ if report_approved.coverage:
1884
+ for category, status in report_approved.coverage.items():
1885
+ if category in critical_categories_approved and status == AmbiguityStatus.MISSING:
1886
+ missing_critical_approved.append(category)
1887
+
1888
+ # Block promotion if critical categories are Missing
1889
+ if missing_critical_approved:
1890
+ print_error(
1891
+ f"Cannot promote to approved: {len(missing_critical_approved)} critical category(ies) are Missing"
1892
+ )
1893
+ console.print("[dim]Missing critical categories:[/dim]")
1894
+ for cat in missing_critical_approved:
1895
+ console.print(f" - {cat.value}")
1896
+ console.print("\n[dim]Run 'specfact plan review' to resolve these ambiguities[/dim]")
1897
+ if not force:
1898
+ raise typer.Exit(1)
1899
+
1900
+ # Approved → Released: All features must be implemented (future check)
1901
+ if current_stage == "approved" and stage == "released":
1902
+ print_warning("Release promotion: Implementation verification not yet implemented")
1001
1903
  if not force:
1904
+ console.print("[dim]Use --force to promote to released stage[/dim]")
1002
1905
  raise typer.Exit(1)
1003
1906
 
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
1907
+ # Run validation if enabled
1908
+ if validate:
1909
+ print_info("Running validation...")
1910
+ validation_result = validate_plan_bundle(bundle)
1911
+ if isinstance(validation_result, ValidationReport):
1912
+ if not validation_result.passed:
1913
+ deviation_count = len(validation_result.deviations)
1914
+ print_warning(f"Validation found {deviation_count} issue(s)")
1915
+ if not force:
1916
+ console.print("[dim]Use --force to promote anyway[/dim]")
1917
+ raise typer.Exit(1)
1918
+ else:
1919
+ print_success("Validation passed")
1920
+ else:
1921
+ print_success("Validation passed")
1015
1922
 
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]")
1923
+ # Update metadata
1924
+ print_info(f"Promoting plan: {current_stage} {stage}")
1925
+
1926
+ # Get user info
1927
+ promoted_by = (
1928
+ os.environ.get("USER") or os.environ.get("USERNAME") or os.environ.get("GIT_AUTHOR_NAME") or "unknown"
1929
+ )
1930
+
1931
+ # Create or update metadata
1932
+ if bundle.metadata is None:
1933
+ bundle.metadata = Metadata(
1934
+ stage=stage,
1935
+ promoted_at=None,
1936
+ promoted_by=None,
1937
+ analysis_scope=None,
1938
+ entry_point=None,
1939
+ external_dependencies=[],
1940
+ )
1941
+
1942
+ bundle.metadata.stage = stage
1943
+ bundle.metadata.promoted_at = datetime.now(UTC).isoformat()
1944
+ bundle.metadata.promoted_by = promoted_by
1945
+
1946
+ # Write updated plan
1947
+ print_info(f"Saving plan to: {plan}")
1948
+ generator = PlanGenerator()
1949
+ generator.generate(bundle, plan)
1950
+
1951
+ record(
1952
+ {
1953
+ "current_stage": current_stage,
1954
+ "target_stage": stage,
1955
+ "features_count": len(bundle.features) if bundle.features else 0,
1956
+ }
1957
+ )
1958
+
1959
+ # Display summary
1960
+ print_success(f"Plan promoted: {current_stage} → {stage}")
1961
+ console.print(f"[dim]Promoted at: {bundle.metadata.promoted_at}[/dim]")
1962
+ console.print(f"[dim]Promoted by: {promoted_by}[/dim]")
1963
+
1964
+ # Show next steps
1965
+ console.print("\n[bold]Next Steps:[/bold]")
1966
+ if stage == "review":
1967
+ console.print(" • Review plan bundle for completeness")
1968
+ console.print(" • Add stories to features if missing")
1969
+ console.print(" • Run: specfact plan promote --stage approved")
1970
+ elif stage == "approved":
1971
+ console.print(" • Plan is approved for implementation")
1972
+ console.print(" • Begin feature development")
1973
+ console.print(" • Run: specfact plan promote --stage released (after implementation)")
1974
+ elif stage == "released":
1975
+ console.print(" • Plan is released and should be immutable")
1976
+ console.print(" • Create new plan bundle for future changes")
1977
+
1978
+ except Exception as e:
1979
+ print_error(f"Failed to promote plan: {e}")
1980
+ raise typer.Exit(1) from e
1981
+
1982
+
1983
+ @beartype
1984
+ @require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle")
1985
+ @ensure(lambda result: isinstance(result, int), "Must return int")
1986
+ def _deduplicate_features(bundle: PlanBundle) -> int:
1987
+ """
1988
+ Deduplicate features by normalized key (clean up duplicates from previous syncs).
1989
+
1990
+ Uses prefix matching to handle abbreviated vs full names (e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM).
1991
+
1992
+ Args:
1993
+ bundle: Plan bundle to deduplicate
1994
+
1995
+ Returns:
1996
+ Number of duplicates removed
1997
+ """
1998
+ from specfact_cli.utils.feature_keys import normalize_feature_key
1999
+
2000
+ seen_normalized_keys: set[str] = set()
2001
+ deduplicated_features: list[Feature] = []
2002
+
2003
+ for existing_feature in bundle.features:
2004
+ normalized_key = normalize_feature_key(existing_feature.key)
2005
+
2006
+ # Check for exact match first
2007
+ if normalized_key in seen_normalized_keys:
2008
+ continue
2009
+
2010
+ # Check for prefix match (abbreviated vs full names)
2011
+ # e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM
2012
+ # Only match if shorter is a PREFIX of longer with significant length difference
2013
+ # AND at least one key has a numbered prefix (041_, 042-, etc.) indicating Spec-Kit origin
2014
+ # This avoids false positives like SMARTCOVERAGE vs SMARTCOVERAGEMANAGER (both from code analysis)
2015
+ matched = False
2016
+ for seen_key in seen_normalized_keys:
2017
+ shorter = min(normalized_key, seen_key, key=len)
2018
+ longer = max(normalized_key, seen_key, key=len)
2019
+
2020
+ # Check if at least one of the original keys has a numbered prefix (Spec-Kit format)
2021
+ import re
2022
+
2023
+ has_speckit_key = bool(
2024
+ re.match(r"^\d{3}[_-]", existing_feature.key)
2025
+ or any(
2026
+ re.match(r"^\d{3}[_-]", f.key)
2027
+ for f in deduplicated_features
2028
+ if normalize_feature_key(f.key) == seen_key
2029
+ )
2030
+ )
2031
+
2032
+ # More conservative matching:
2033
+ # 1. At least one key must have numbered prefix (Spec-Kit origin)
2034
+ # 2. Shorter must be at least 10 chars
2035
+ # 3. Longer must start with shorter (prefix match)
2036
+ # 4. Length difference must be at least 6 chars
2037
+ # 5. Shorter must be < 75% of longer (to ensure significant difference)
2038
+ length_diff = len(longer) - len(shorter)
2039
+ length_ratio = len(shorter) / len(longer) if len(longer) > 0 else 1.0
2040
+
2041
+ if (
2042
+ has_speckit_key
2043
+ and len(shorter) >= 10
2044
+ and longer.startswith(shorter)
2045
+ and length_diff >= 6
2046
+ and length_ratio < 0.75
2047
+ ):
2048
+ matched = True
2049
+ # Prefer the longer (full) name - update the existing feature's key if needed
2050
+ if len(normalized_key) > len(seen_key):
2051
+ # Current feature has longer name - update the existing one
2052
+ for dedup_feature in deduplicated_features:
2053
+ if normalize_feature_key(dedup_feature.key) == seen_key:
2054
+ dedup_feature.key = existing_feature.key
2055
+ break
2056
+ break
2057
+
2058
+ if not matched:
2059
+ seen_normalized_keys.add(normalized_key)
2060
+ deduplicated_features.append(existing_feature)
2061
+
2062
+ duplicates_removed = len(bundle.features) - len(deduplicated_features)
2063
+ if duplicates_removed > 0:
2064
+ bundle.features = deduplicated_features
2065
+
2066
+ return duplicates_removed
2067
+
2068
+
2069
+ @app.command("review")
2070
+ @beartype
2071
+ @require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
2072
+ @require(lambda max_questions: max_questions > 0, "Max questions must be positive")
2073
+ def review(
2074
+ plan: Path | None = typer.Option(
2075
+ None,
2076
+ "--plan",
2077
+ help="Path to plan bundle (default: active plan or latest)",
2078
+ ),
2079
+ max_questions: int = typer.Option(
2080
+ 5,
2081
+ "--max-questions",
2082
+ min=1,
2083
+ max=10,
2084
+ help="Maximum questions per session (default: 5)",
2085
+ ),
2086
+ category: str | None = typer.Option(
2087
+ None,
2088
+ "--category",
2089
+ help="Focus on specific taxonomy category (optional)",
2090
+ ),
2091
+ list_questions: bool = typer.Option(
2092
+ False,
2093
+ "--list-questions",
2094
+ help="Output questions in JSON format without asking (for Copilot mode)",
2095
+ ),
2096
+ answers: str | None = typer.Option(
2097
+ None,
2098
+ "--answers",
2099
+ help="JSON object with question_id -> answer mappings (for non-interactive mode). Can be JSON string or path to JSON file.",
2100
+ ),
2101
+ non_interactive: bool = typer.Option(
2102
+ False,
2103
+ "--non-interactive",
2104
+ help="Non-interactive mode (for CI/CD automation)",
2105
+ ),
2106
+ auto_enrich: bool = typer.Option(
2107
+ False,
2108
+ "--auto-enrich",
2109
+ help="Automatically enrich vague acceptance criteria, incomplete requirements, and generic tasks using LLM-enhanced pattern matching",
2110
+ ),
2111
+ ) -> None:
2112
+ """
2113
+ Review plan bundle to identify and resolve ambiguities.
2114
+
2115
+ Analyzes the plan bundle for missing information, unclear requirements,
2116
+ and unknowns. Asks targeted questions to resolve ambiguities and make
2117
+ the plan ready for promotion.
2118
+
2119
+ Example:
2120
+ specfact plan review
2121
+ specfact plan review --plan .specfact/plans/main.bundle.yaml
2122
+ specfact plan review --max-questions 3 --category "Functional Scope"
2123
+ specfact plan review --list-questions # Output questions as JSON
2124
+ specfact plan review --answers '{"Q001": "answer1", "Q002": "answer2"}' # Non-interactive
2125
+ """
2126
+ from datetime import date, datetime
2127
+
2128
+ from specfact_cli.analyzers.ambiguity_scanner import (
2129
+ AmbiguityScanner,
2130
+ AmbiguityStatus,
2131
+ TaxonomyCategory,
2132
+ )
2133
+ from specfact_cli.models.plan import Clarification, Clarifications, ClarificationSession
2134
+ from specfact_cli.utils.structure import SpecFactStructure
2135
+
2136
+ # Detect operational mode
2137
+ mode = detect_mode()
2138
+ is_non_interactive = non_interactive or (answers is not None) or list_questions
2139
+
2140
+ telemetry_metadata = {
2141
+ "max_questions": max_questions,
2142
+ "category": category,
2143
+ "list_questions": list_questions,
2144
+ "non_interactive": is_non_interactive,
2145
+ "mode": mode.value,
2146
+ }
2147
+
2148
+ with telemetry.track_command("plan.review", telemetry_metadata) as record:
2149
+ # Use default path if not specified
2150
+ if plan is None:
2151
+ # Try to find active plan or latest
2152
+ default_plan = SpecFactStructure.get_default_plan_path()
2153
+ if default_plan.exists():
2154
+ plan = default_plan
2155
+ print_info(f"Using default plan: {plan}")
2156
+ else:
2157
+ # Find latest plan bundle
2158
+ base_path = Path(".")
2159
+ plans_dir = base_path / SpecFactStructure.PLANS
2160
+ if plans_dir.exists():
2161
+ plan_files = sorted(plans_dir.glob("*.bundle.yaml"), key=lambda p: p.stat().st_mtime, reverse=True)
2162
+ if plan_files:
2163
+ plan = plan_files[0]
2164
+ print_info(f"Using latest plan: {plan}")
2165
+ else:
2166
+ print_error(f"No plan bundles found in {plans_dir}")
2167
+ print_error("Create one with: specfact plan init --interactive")
2168
+ raise typer.Exit(1)
2169
+ else:
2170
+ print_error(f"Plans directory not found: {plans_dir}")
2171
+ print_error("Create one with: specfact plan init --interactive")
1020
2172
  raise typer.Exit(1)
1021
2173
 
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]")
2174
+ # Type guard: ensure plan is not None
2175
+ if plan is None:
2176
+ print_error("Plan bundle path is required")
2177
+ raise typer.Exit(1)
2178
+
2179
+ if not plan.exists():
2180
+ print_error(f"Plan bundle not found: {plan}")
2181
+ raise typer.Exit(1)
2182
+
2183
+ print_section("SpecFact CLI - Plan Review")
2184
+
2185
+ try:
2186
+ # Load existing plan
2187
+ print_info(f"Loading plan: {plan}")
2188
+ validation_result = validate_plan_bundle(plan)
2189
+ assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path"
2190
+ is_valid, error, bundle = validation_result
2191
+
2192
+ if not is_valid or bundle is None:
2193
+ print_error(f"Plan validation failed: {error}")
1027
2194
  raise typer.Exit(1)
1028
2195
 
1029
- # Run validation if enabled
1030
- if validate:
1031
- print_info("Running validation...")
2196
+ # Deduplicate features by normalized key (clean up duplicates from previous syncs)
2197
+ duplicates_removed = _deduplicate_features(bundle)
2198
+ if duplicates_removed > 0:
2199
+ # Write back deduplicated bundle immediately
2200
+ generator = PlanGenerator()
2201
+ generator.generate(bundle, plan)
2202
+ print_success(f"✓ Removed {duplicates_removed} duplicate features from plan bundle")
2203
+
2204
+ # Check current stage
2205
+ current_stage = "draft"
2206
+ if bundle.metadata:
2207
+ current_stage = bundle.metadata.stage
2208
+
2209
+ print_info(f"Current stage: {current_stage}")
2210
+
2211
+ if current_stage not in ("draft", "review"):
2212
+ print_warning("Review is typically run on 'draft' or 'review' stage plans")
2213
+ if not is_non_interactive and not prompt_confirm("Continue anyway?", default=False):
2214
+ raise typer.Exit(0)
2215
+ if is_non_interactive:
2216
+ print_info("Continuing in non-interactive mode")
2217
+
2218
+ # Initialize clarifications if needed
2219
+ if bundle.clarifications is None:
2220
+ bundle.clarifications = Clarifications(sessions=[])
2221
+
2222
+ # Auto-enrich if requested (before scanning for ambiguities)
2223
+ if auto_enrich:
2224
+ print_info(
2225
+ "Auto-enriching plan bundle (enhancing vague acceptance criteria, incomplete requirements, generic tasks)..."
2226
+ )
2227
+ from specfact_cli.enrichers.plan_enricher import PlanEnricher
2228
+
2229
+ enricher = PlanEnricher()
2230
+ enrichment_summary = enricher.enrich_plan(bundle)
2231
+
2232
+ if enrichment_summary["features_updated"] > 0 or enrichment_summary["stories_updated"] > 0:
2233
+ # Save enriched plan bundle
2234
+ generator = PlanGenerator()
2235
+ generator.generate(bundle, plan)
2236
+ print_success(
2237
+ f"✓ Auto-enriched plan bundle: {enrichment_summary['features_updated']} features, "
2238
+ f"{enrichment_summary['stories_updated']} stories updated"
2239
+ )
2240
+ if enrichment_summary["acceptance_criteria_enhanced"] > 0:
2241
+ console.print(
2242
+ f"[dim] - Enhanced {enrichment_summary['acceptance_criteria_enhanced']} acceptance criteria[/dim]"
2243
+ )
2244
+ if enrichment_summary["requirements_enhanced"] > 0:
2245
+ console.print(
2246
+ f"[dim] - Enhanced {enrichment_summary['requirements_enhanced']} requirements[/dim]"
2247
+ )
2248
+ if enrichment_summary["tasks_enhanced"] > 0:
2249
+ console.print(f"[dim] - Enhanced {enrichment_summary['tasks_enhanced']} tasks[/dim]")
2250
+ if enrichment_summary["changes"]:
2251
+ console.print("\n[bold]Changes made:[/bold]")
2252
+ for change in enrichment_summary["changes"][:10]: # Show first 10 changes
2253
+ console.print(f"[dim] - {change}[/dim]")
2254
+ if len(enrichment_summary["changes"]) > 10:
2255
+ console.print(f"[dim] ... and {len(enrichment_summary['changes']) - 10} more[/dim]")
2256
+ else:
2257
+ print_info("No enrichments needed - plan bundle is already well-specified")
2258
+
2259
+ # Scan for ambiguities
2260
+ print_info("Scanning plan bundle for ambiguities...")
2261
+ scanner = AmbiguityScanner()
2262
+ report = scanner.scan(bundle)
2263
+
2264
+ # Filter by category if specified
2265
+ if category:
2266
+ try:
2267
+ target_category = TaxonomyCategory(category)
2268
+ if report.findings:
2269
+ report.findings = [f for f in report.findings if f.category == target_category]
2270
+ except ValueError:
2271
+ print_warning(f"Unknown category: {category}, ignoring filter")
2272
+ category = None
2273
+
2274
+ # Prioritize questions by (Impact x Uncertainty)
2275
+ findings_list = report.findings or []
2276
+ prioritized_findings = sorted(
2277
+ findings_list,
2278
+ key=lambda f: f.impact * f.uncertainty,
2279
+ reverse=True,
2280
+ )
2281
+
2282
+ # Filter out findings that already have clarifications
2283
+ existing_question_ids = set()
2284
+ for session in bundle.clarifications.sessions:
2285
+ for q in session.questions:
2286
+ existing_question_ids.add(q.id)
2287
+
2288
+ # Generate question IDs and filter
2289
+ question_counter = 1
2290
+ candidate_questions: list[tuple[AmbiguityFinding, str]] = []
2291
+ for finding in prioritized_findings:
2292
+ if finding.question and (question_id := f"Q{question_counter:03d}") not in existing_question_ids:
2293
+ # Generate question ID and add if not already answered
2294
+ question_counter += 1
2295
+ candidate_questions.append((finding, question_id))
2296
+
2297
+ # Limit to max_questions
2298
+ questions_to_ask = candidate_questions[:max_questions]
2299
+
2300
+ if not questions_to_ask:
2301
+ # Check coverage status to determine if plan is truly ready for promotion
2302
+ critical_categories = [
2303
+ TaxonomyCategory.FUNCTIONAL_SCOPE,
2304
+ TaxonomyCategory.FEATURE_COMPLETENESS,
2305
+ TaxonomyCategory.CONSTRAINTS,
2306
+ ]
2307
+
2308
+ missing_critical: list[TaxonomyCategory] = []
2309
+ if report.coverage:
2310
+ for category, status in report.coverage.items():
2311
+ if category in critical_categories and status == AmbiguityStatus.MISSING:
2312
+ missing_critical.append(category)
2313
+
2314
+ if missing_critical:
2315
+ print_warning(
2316
+ f"Plan has {len(missing_critical)} critical category(ies) marked as Missing, but no high-priority questions remain"
2317
+ )
2318
+ console.print("[dim]Missing critical categories:[/dim]")
2319
+ for cat in missing_critical:
2320
+ console.print(f" - {cat.value}")
2321
+ console.print("\n[bold]Coverage Summary:[/bold]")
2322
+ if report.coverage:
2323
+ for cat, status in report.coverage.items():
2324
+ status_icon = (
2325
+ "✅"
2326
+ if status == AmbiguityStatus.CLEAR
2327
+ else "⚠️"
2328
+ if status == AmbiguityStatus.PARTIAL
2329
+ else "❌"
2330
+ )
2331
+ console.print(f" {status_icon} {cat.value}: {status.value}")
2332
+ console.print(
2333
+ "\n[bold]⚠️ Warning:[/bold] Plan may not be ready for promotion due to missing critical categories"
2334
+ )
2335
+ console.print("[dim]Consider addressing these categories before promoting[/dim]")
2336
+ else:
2337
+ print_success("No critical ambiguities detected. Plan is ready for promotion.")
2338
+ console.print("\n[bold]Coverage Summary:[/bold]")
2339
+ if report.coverage:
2340
+ for cat, status in report.coverage.items():
2341
+ status_icon = (
2342
+ "✅"
2343
+ if status == AmbiguityStatus.CLEAR
2344
+ else "⚠️"
2345
+ if status == AmbiguityStatus.PARTIAL
2346
+ else "❌"
2347
+ )
2348
+ console.print(f" {status_icon} {cat.value}: {status.value}")
2349
+ raise typer.Exit(0)
2350
+
2351
+ # Handle --list-questions mode
2352
+ if list_questions:
2353
+ questions_json = []
2354
+ for finding, question_id in questions_to_ask:
2355
+ questions_json.append(
2356
+ {
2357
+ "id": question_id,
2358
+ "category": finding.category.value,
2359
+ "question": finding.question,
2360
+ "impact": finding.impact,
2361
+ "uncertainty": finding.uncertainty,
2362
+ "related_sections": finding.related_sections or [],
2363
+ }
2364
+ )
2365
+ # Output JSON to stdout (for Copilot mode parsing)
2366
+ import sys
2367
+
2368
+ sys.stdout.write(json.dumps({"questions": questions_json, "total": len(questions_json)}, indent=2))
2369
+ sys.stdout.write("\n")
2370
+ sys.stdout.flush()
2371
+ raise typer.Exit(0)
2372
+
2373
+ # Parse answers if provided
2374
+ answers_dict: dict[str, str] = {}
2375
+ if answers:
2376
+ try:
2377
+ # Try to parse as JSON string first
2378
+ try:
2379
+ answers_dict = json.loads(answers)
2380
+ except json.JSONDecodeError:
2381
+ # If JSON parsing fails, try as file path
2382
+ answers_path = Path(answers)
2383
+ if answers_path.exists() and answers_path.is_file():
2384
+ answers_dict = json.loads(answers_path.read_text())
2385
+ else:
2386
+ raise ValueError(f"Invalid JSON string and file not found: {answers}") from None
2387
+
2388
+ if not isinstance(answers_dict, dict):
2389
+ print_error("--answers must be a JSON object with question_id -> answer mappings")
2390
+ raise typer.Exit(1)
2391
+ except (json.JSONDecodeError, ValueError) as e:
2392
+ print_error(f"Invalid JSON in --answers: {e}")
2393
+ raise typer.Exit(1) from e
2394
+
2395
+ print_info(f"Found {len(questions_to_ask)} question(s) to resolve")
2396
+
2397
+ # Create or get today's session
2398
+ today = date.today().isoformat()
2399
+ today_session: ClarificationSession | None = None
2400
+ for session in bundle.clarifications.sessions:
2401
+ if session.date == today:
2402
+ today_session = session
2403
+ break
2404
+
2405
+ if today_session is None:
2406
+ today_session = ClarificationSession(date=today, questions=[])
2407
+ bundle.clarifications.sessions.append(today_session)
2408
+
2409
+ # Ask questions sequentially
2410
+ questions_asked = 0
2411
+ for finding, question_id in questions_to_ask:
2412
+ questions_asked += 1
2413
+
2414
+ # Get answer (interactive or from --answers)
2415
+ if question_id in answers_dict:
2416
+ # Non-interactive: use provided answer
2417
+ answer = answers_dict[question_id]
2418
+ if not isinstance(answer, str) or not answer.strip():
2419
+ print_error(f"Answer for {question_id} must be a non-empty string")
2420
+ raise typer.Exit(1)
2421
+ console.print(f"\n[bold cyan]Question {questions_asked}/{len(questions_to_ask)}[/bold cyan]")
2422
+ console.print(f"[dim]Category: {finding.category.value}[/dim]")
2423
+ console.print(f"[bold]Q: {finding.question}[/bold]")
2424
+ console.print(f"[dim]Answer (from --answers): {answer}[/dim]")
2425
+ else:
2426
+ # Interactive: prompt user
2427
+ if is_non_interactive:
2428
+ # In non-interactive mode without --answers, skip this question
2429
+ print_warning(f"Skipping {question_id}: no answer provided in non-interactive mode")
2430
+ continue
2431
+
2432
+ console.print(f"\n[bold cyan]Question {questions_asked}/{len(questions_to_ask)}[/bold cyan]")
2433
+ console.print(f"[dim]Category: {finding.category.value}[/dim]")
2434
+ console.print(f"[bold]Q: {finding.question}[/bold]")
2435
+
2436
+ # Get answer from user
2437
+ answer = prompt_text("Your answer (<=5 words recommended):", required=True)
2438
+
2439
+ # Validate answer length (warn if too long, but allow)
2440
+ if len(answer.split()) > 5:
2441
+ print_warning("Answer is longer than 5 words. Consider a shorter, more focused answer.")
2442
+
2443
+ # Integrate answer into plan bundle
2444
+ integration_points = _integrate_clarification(bundle, finding, answer)
2445
+
2446
+ # Create clarification record
2447
+ clarification = Clarification(
2448
+ id=question_id,
2449
+ category=finding.category.value,
2450
+ question=finding.question or "",
2451
+ answer=answer,
2452
+ integrated_into=integration_points,
2453
+ timestamp=datetime.now(UTC).isoformat(),
2454
+ )
2455
+
2456
+ today_session.questions.append(clarification)
2457
+
2458
+ # Save plan bundle after each answer (atomic)
2459
+ print_info("Saving plan bundle...")
2460
+ if plan is not None:
2461
+ generator = PlanGenerator()
2462
+ generator.generate(bundle, plan)
2463
+
2464
+ print_success("Answer recorded and integrated into plan bundle")
2465
+
2466
+ # Ask if user wants to continue (only in interactive mode)
2467
+ if (
2468
+ not is_non_interactive
2469
+ and questions_asked < len(questions_to_ask)
2470
+ and not prompt_confirm("Continue to next question?", default=True)
2471
+ ):
2472
+ break
2473
+
2474
+ # Final validation
2475
+ print_info("Validating updated plan bundle...")
1032
2476
  validation_result = validate_plan_bundle(bundle)
1033
2477
  if isinstance(validation_result, ValidationReport):
1034
2478
  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)
2479
+ print_warning(f"Validation found {len(validation_result.deviations)} issue(s)")
1040
2480
  else:
1041
2481
  print_success("Validation passed")
1042
2482
  else:
1043
2483
  print_success("Validation passed")
1044
2484
 
1045
- # Update metadata
1046
- print_info(f"Promoting plan: {current_stage} {stage}")
2485
+ # Display summary
2486
+ print_success(f"Review complete: {questions_asked} question(s) answered")
2487
+ console.print(f"\n[bold]Plan Bundle:[/bold] {plan}")
2488
+ console.print(f"[bold]Questions Asked:[/bold] {questions_asked}")
2489
+
2490
+ if today_session.questions:
2491
+ console.print("\n[bold]Sections Touched:[/bold]")
2492
+ all_sections = set()
2493
+ for q in today_session.questions:
2494
+ all_sections.update(q.integrated_into)
2495
+ for section in sorted(all_sections):
2496
+ console.print(f" • {section}")
2497
+
2498
+ # Coverage summary
2499
+ console.print("\n[bold]Coverage Summary:[/bold]")
2500
+ if report.coverage:
2501
+ for cat, status in report.coverage.items():
2502
+ status_icon = (
2503
+ "✅" if status == AmbiguityStatus.CLEAR else "⚠️" if status == AmbiguityStatus.PARTIAL else "❌"
2504
+ )
2505
+ console.print(f" {status_icon} {cat.value}: {status.value}")
2506
+
2507
+ # Next steps
2508
+ console.print("\n[bold]Next Steps:[/bold]")
2509
+ if current_stage == "draft":
2510
+ console.print(" • Review plan bundle for completeness")
2511
+ console.print(" • Run: specfact plan promote --stage review")
2512
+ elif current_stage == "review":
2513
+ console.print(" • Plan is ready for approval")
2514
+ console.print(" • Run: specfact plan promote --stage approved")
2515
+
2516
+ record(
2517
+ {
2518
+ "questions_asked": questions_asked,
2519
+ "findings_count": len(report.findings) if report.findings else 0,
2520
+ "priority_score": report.priority_score,
2521
+ }
2522
+ )
2523
+
2524
+ except KeyboardInterrupt:
2525
+ print_warning("Review interrupted by user")
2526
+ raise typer.Exit(0) from None
2527
+ except typer.Exit:
2528
+ # Re-raise typer.Exit (used for --list-questions and other early exits)
2529
+ raise
2530
+ except Exception as e:
2531
+ print_error(f"Failed to review plan: {e}")
2532
+ raise typer.Exit(1) from e
1047
2533
 
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
2534
 
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
2535
+ @beartype
2536
+ @require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle")
2537
+ @require(lambda answer: isinstance(answer, str) and bool(answer.strip()), "Answer must be non-empty string")
2538
+ @ensure(lambda result: isinstance(result, list), "Must return list of integration points")
2539
+ def _integrate_clarification(
2540
+ bundle: PlanBundle,
2541
+ finding: AmbiguityFinding,
2542
+ answer: str,
2543
+ ) -> list[str]:
2544
+ """
2545
+ Integrate clarification answer into plan bundle.
2546
+
2547
+ Args:
2548
+ bundle: Plan bundle to update
2549
+ finding: Ambiguity finding with related sections
2550
+ answer: User-provided answer
2551
+
2552
+ Returns:
2553
+ List of integration points (section paths)
2554
+ """
2555
+ from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory
2556
+
2557
+ integration_points: list[str] = []
2558
+
2559
+ category = finding.category
2560
+
2561
+ # Functional Scope → idea.narrative, idea.target_users, features[].outcomes
2562
+ if category == TaxonomyCategory.FUNCTIONAL_SCOPE:
2563
+ related_sections = finding.related_sections or []
2564
+ if (
2565
+ "idea.narrative" in related_sections
2566
+ and bundle.idea
2567
+ and (not bundle.idea.narrative or len(bundle.idea.narrative) < 20)
2568
+ ):
2569
+ bundle.idea.narrative = answer
2570
+ integration_points.append("idea.narrative")
2571
+ elif "idea.target_users" in related_sections and bundle.idea:
2572
+ if bundle.idea.target_users is None:
2573
+ bundle.idea.target_users = []
2574
+ if answer not in bundle.idea.target_users:
2575
+ bundle.idea.target_users.append(answer)
2576
+ integration_points.append("idea.target_users")
2577
+ else:
2578
+ # Try to find feature by related section
2579
+ for section in related_sections:
2580
+ if section.startswith("features.") and ".outcomes" in section:
2581
+ feature_key = section.split(".")[1]
2582
+ for feature in bundle.features:
2583
+ if feature.key == feature_key:
2584
+ if answer not in feature.outcomes:
2585
+ feature.outcomes.append(answer)
2586
+ integration_points.append(section)
2587
+ break
2588
+
2589
+ # Data Model, Integration, Constraints → features[].constraints
2590
+ elif category in (
2591
+ TaxonomyCategory.DATA_MODEL,
2592
+ TaxonomyCategory.INTEGRATION,
2593
+ TaxonomyCategory.CONSTRAINTS,
2594
+ ):
2595
+ related_sections = finding.related_sections or []
2596
+ for section in related_sections:
2597
+ if section.startswith("features.") and ".constraints" in section:
2598
+ feature_key = section.split(".")[1]
2599
+ for feature in bundle.features:
2600
+ if feature.key == feature_key:
2601
+ if answer not in feature.constraints:
2602
+ feature.constraints.append(answer)
2603
+ integration_points.append(section)
2604
+ break
2605
+ elif section == "idea.constraints" and bundle.idea:
2606
+ if bundle.idea.constraints is None:
2607
+ bundle.idea.constraints = []
2608
+ if answer not in bundle.idea.constraints:
2609
+ bundle.idea.constraints.append(answer)
2610
+ integration_points.append(section)
2611
+
2612
+ # Edge Cases, Completion Signals → features[].acceptance, stories[].acceptance
2613
+ elif category in (TaxonomyCategory.EDGE_CASES, TaxonomyCategory.COMPLETION_SIGNALS):
2614
+ related_sections = finding.related_sections or []
2615
+ for section in related_sections:
2616
+ if section.startswith("features."):
2617
+ parts = section.split(".")
2618
+ if len(parts) >= 3:
2619
+ feature_key = parts[1]
2620
+ if parts[2] == "acceptance":
2621
+ for feature in bundle.features:
2622
+ if feature.key == feature_key:
2623
+ if answer not in feature.acceptance:
2624
+ feature.acceptance.append(answer)
2625
+ integration_points.append(section)
2626
+ break
2627
+ elif parts[2] == "stories" and len(parts) >= 5:
2628
+ story_key = parts[3]
2629
+ if parts[4] == "acceptance":
2630
+ for feature in bundle.features:
2631
+ if feature.key == feature_key:
2632
+ for story in feature.stories:
2633
+ if story.key == story_key:
2634
+ if answer not in story.acceptance:
2635
+ story.acceptance.append(answer)
2636
+ integration_points.append(section)
2637
+ break
2638
+ break
2639
+
2640
+ # Feature Completeness → features[].stories, features[].acceptance
2641
+ elif category == TaxonomyCategory.FEATURE_COMPLETENESS:
2642
+ related_sections = finding.related_sections or []
2643
+ for section in related_sections:
2644
+ if section.startswith("features."):
2645
+ parts = section.split(".")
2646
+ if len(parts) >= 3:
2647
+ feature_key = parts[1]
2648
+ if parts[2] == "stories":
2649
+ # This would require creating a new story - skip for now
2650
+ # (stories should be added via add-story command)
2651
+ pass
2652
+ elif parts[2] == "acceptance":
2653
+ for feature in bundle.features:
2654
+ if feature.key == feature_key:
2655
+ if answer not in feature.acceptance:
2656
+ feature.acceptance.append(answer)
2657
+ integration_points.append(section)
2658
+ break
2659
+
2660
+ # Non-Functional → idea.constraints (with quantification)
2661
+ elif (
2662
+ category == TaxonomyCategory.NON_FUNCTIONAL
2663
+ and finding.related_sections
2664
+ and "idea.constraints" in finding.related_sections
2665
+ and bundle.idea
2666
+ ):
2667
+ if bundle.idea.constraints is None:
2668
+ bundle.idea.constraints = []
2669
+ if answer not in bundle.idea.constraints:
2670
+ # Try to quantify vague terms
2671
+ quantified_answer = answer
2672
+ bundle.idea.constraints.append(quantified_answer)
2673
+ integration_points.append("idea.constraints")
2674
+
2675
+ return integration_points