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.
- specfact_cli/__init__.py +1 -1
- specfact_cli/agents/analyze_agent.py +2 -3
- specfact_cli/analyzers/__init__.py +2 -1
- specfact_cli/analyzers/ambiguity_scanner.py +601 -0
- specfact_cli/analyzers/code_analyzer.py +462 -30
- specfact_cli/analyzers/constitution_evidence_extractor.py +491 -0
- specfact_cli/analyzers/contract_extractor.py +419 -0
- specfact_cli/analyzers/control_flow_analyzer.py +281 -0
- specfact_cli/analyzers/requirement_extractor.py +337 -0
- specfact_cli/analyzers/test_pattern_extractor.py +330 -0
- specfact_cli/cli.py +151 -206
- specfact_cli/commands/constitution.py +281 -0
- specfact_cli/commands/enforce.py +42 -34
- specfact_cli/commands/import_cmd.py +481 -152
- specfact_cli/commands/init.py +224 -55
- specfact_cli/commands/plan.py +2133 -547
- specfact_cli/commands/repro.py +100 -78
- specfact_cli/commands/sync.py +701 -186
- specfact_cli/enrichers/constitution_enricher.py +765 -0
- specfact_cli/enrichers/plan_enricher.py +294 -0
- specfact_cli/importers/speckit_converter.py +364 -48
- specfact_cli/importers/speckit_scanner.py +65 -0
- specfact_cli/models/plan.py +42 -0
- specfact_cli/resources/mappings/node-async.yaml +49 -0
- specfact_cli/resources/mappings/python-async.yaml +47 -0
- specfact_cli/resources/mappings/speckit-default.yaml +82 -0
- specfact_cli/resources/prompts/specfact-enforce.md +185 -0
- specfact_cli/resources/prompts/specfact-import-from-code.md +626 -0
- specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
- specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
- specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
- specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
- specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
- specfact_cli/resources/prompts/specfact-plan-review.md +1276 -0
- specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
- specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -0
- specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
- specfact_cli/resources/prompts/specfact-repro.md +268 -0
- specfact_cli/resources/prompts/specfact-sync.md +497 -0
- specfact_cli/resources/schemas/deviation.schema.json +61 -0
- specfact_cli/resources/schemas/plan.schema.json +204 -0
- specfact_cli/resources/schemas/protocol.schema.json +53 -0
- specfact_cli/resources/templates/github-action.yml.j2 +140 -0
- specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
- specfact_cli/resources/templates/pr-template.md.j2 +58 -0
- specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
- specfact_cli/resources/templates/telemetry.yaml.example +35 -0
- specfact_cli/sync/__init__.py +10 -1
- specfact_cli/sync/watcher.py +268 -0
- specfact_cli/telemetry.py +440 -0
- specfact_cli/utils/acceptance_criteria.py +127 -0
- specfact_cli/utils/enrichment_parser.py +445 -0
- specfact_cli/utils/feature_keys.py +12 -3
- specfact_cli/utils/ide_setup.py +170 -0
- specfact_cli/utils/structure.py +179 -2
- specfact_cli/utils/yaml_utils.py +33 -0
- specfact_cli/validators/repro_checker.py +22 -1
- specfact_cli/validators/schema.py +15 -4
- specfact_cli-0.6.8.dist-info/METADATA +456 -0
- specfact_cli-0.6.8.dist-info/RECORD +99 -0
- {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
- specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
- specfact_cli-0.4.2.dist-info/METADATA +0 -370
- specfact_cli-0.4.2.dist-info/RECORD +0 -62
- specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
- {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/WHEEL +0 -0
specfact_cli/commands/plan.py
CHANGED
|
@@ -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
|
-
|
|
83
|
+
telemetry_metadata = {
|
|
84
|
+
"interactive": interactive,
|
|
85
|
+
"scaffold": scaffold,
|
|
86
|
+
}
|
|
80
87
|
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return
|
|
100
|
+
# Use default path if not specified
|
|
101
|
+
if out is None:
|
|
102
|
+
out = SpecFactStructure.get_default_plan_path()
|
|
98
103
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
128
|
+
print_success(f"Plan created successfully: {out}")
|
|
109
129
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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"
|
|
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
|
-
|
|
389
|
+
print_section("SpecFact CLI - Add Feature")
|
|
363
390
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
398
|
-
|
|
427
|
+
# Validate updated plan (always passes for PlanBundle model)
|
|
428
|
+
print_info("Validating updated plan...")
|
|
399
429
|
|
|
400
|
-
|
|
401
|
-
|
|
430
|
+
# Save updated plan
|
|
431
|
+
print_info(f"Saving plan to: {plan}")
|
|
432
|
+
generator = PlanGenerator()
|
|
433
|
+
generator.generate(existing_plan, plan)
|
|
402
434
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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"
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
622
|
+
telemetry_metadata = {}
|
|
471
623
|
|
|
472
|
-
|
|
473
|
-
#
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
#
|
|
655
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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
|
-
|
|
1214
|
+
print_section("SpecFact CLI - Plan Comparison")
|
|
704
1215
|
|
|
705
|
-
#
|
|
706
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
732
|
-
|
|
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
|
-
|
|
735
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
1419
|
+
telemetry_metadata = {}
|
|
785
1420
|
|
|
786
|
-
|
|
787
|
-
|
|
1421
|
+
with telemetry.track_command("plan.select", telemetry_metadata) as record:
|
|
1422
|
+
print_section("SpecFact CLI - Plan Selection")
|
|
788
1423
|
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
805
|
-
|
|
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
|
-
#
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
#
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
823
|
-
|
|
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
|
-
|
|
860
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
print_error(f"Invalid
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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"
|
|
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
|
-
|
|
950
|
-
print_error(f"Plan bundle not found: {plan}")
|
|
951
|
-
raise typer.Exit(1)
|
|
1721
|
+
print_section("SpecFact CLI - Plan Promotion")
|
|
952
1722
|
|
|
953
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
raise typer.Exit(1)
|
|
1739
|
+
print_info(f"Current stage: {current_stage}")
|
|
1740
|
+
print_info(f"Target stage: {stage}")
|
|
965
1741
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
972
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
#
|
|
1023
|
-
if
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|