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