specfact-cli 0.4.2__py3-none-any.whl → 0.6.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. specfact_cli/__init__.py +1 -1
  2. specfact_cli/agents/analyze_agent.py +2 -3
  3. specfact_cli/analyzers/__init__.py +2 -1
  4. specfact_cli/analyzers/ambiguity_scanner.py +601 -0
  5. specfact_cli/analyzers/code_analyzer.py +462 -30
  6. specfact_cli/analyzers/constitution_evidence_extractor.py +491 -0
  7. specfact_cli/analyzers/contract_extractor.py +419 -0
  8. specfact_cli/analyzers/control_flow_analyzer.py +281 -0
  9. specfact_cli/analyzers/requirement_extractor.py +337 -0
  10. specfact_cli/analyzers/test_pattern_extractor.py +330 -0
  11. specfact_cli/cli.py +151 -206
  12. specfact_cli/commands/constitution.py +281 -0
  13. specfact_cli/commands/enforce.py +42 -34
  14. specfact_cli/commands/import_cmd.py +481 -152
  15. specfact_cli/commands/init.py +224 -55
  16. specfact_cli/commands/plan.py +2133 -547
  17. specfact_cli/commands/repro.py +100 -78
  18. specfact_cli/commands/sync.py +701 -186
  19. specfact_cli/enrichers/constitution_enricher.py +765 -0
  20. specfact_cli/enrichers/plan_enricher.py +294 -0
  21. specfact_cli/importers/speckit_converter.py +364 -48
  22. specfact_cli/importers/speckit_scanner.py +65 -0
  23. specfact_cli/models/plan.py +42 -0
  24. specfact_cli/resources/mappings/node-async.yaml +49 -0
  25. specfact_cli/resources/mappings/python-async.yaml +47 -0
  26. specfact_cli/resources/mappings/speckit-default.yaml +82 -0
  27. specfact_cli/resources/prompts/specfact-enforce.md +185 -0
  28. specfact_cli/resources/prompts/specfact-import-from-code.md +626 -0
  29. specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
  30. specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
  31. specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
  32. specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
  33. specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
  34. specfact_cli/resources/prompts/specfact-plan-review.md +1276 -0
  35. specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
  36. specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -0
  37. specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
  38. specfact_cli/resources/prompts/specfact-repro.md +268 -0
  39. specfact_cli/resources/prompts/specfact-sync.md +497 -0
  40. specfact_cli/resources/schemas/deviation.schema.json +61 -0
  41. specfact_cli/resources/schemas/plan.schema.json +204 -0
  42. specfact_cli/resources/schemas/protocol.schema.json +53 -0
  43. specfact_cli/resources/templates/github-action.yml.j2 +140 -0
  44. specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
  45. specfact_cli/resources/templates/pr-template.md.j2 +58 -0
  46. specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
  47. specfact_cli/resources/templates/telemetry.yaml.example +35 -0
  48. specfact_cli/sync/__init__.py +10 -1
  49. specfact_cli/sync/watcher.py +268 -0
  50. specfact_cli/telemetry.py +440 -0
  51. specfact_cli/utils/acceptance_criteria.py +127 -0
  52. specfact_cli/utils/enrichment_parser.py +445 -0
  53. specfact_cli/utils/feature_keys.py +12 -3
  54. specfact_cli/utils/ide_setup.py +170 -0
  55. specfact_cli/utils/structure.py +179 -2
  56. specfact_cli/utils/yaml_utils.py +33 -0
  57. specfact_cli/validators/repro_checker.py +22 -1
  58. specfact_cli/validators/schema.py +15 -4
  59. specfact_cli-0.6.8.dist-info/METADATA +456 -0
  60. specfact_cli-0.6.8.dist-info/RECORD +99 -0
  61. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
  62. specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
  63. specfact_cli-0.4.2.dist-info/METADATA +0 -370
  64. specfact_cli-0.4.2.dist-info/RECORD +0 -62
  65. specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
  66. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/WHEEL +0 -0
@@ -7,136 +7,66 @@ repository changes, and SpecFact plans.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import os
10
11
  import shutil
11
12
  from pathlib import Path
12
13
  from typing import Any
13
14
 
14
15
  import typer
16
+ from beartype import beartype
17
+ from icontract import ensure, require
15
18
  from rich.console import Console
16
19
  from rich.progress import Progress, SpinnerColumn, TextColumn
17
20
 
18
- from specfact_cli.models.plan import PlanBundle
21
+ from specfact_cli.models.plan import Feature, PlanBundle
19
22
  from specfact_cli.sync.speckit_sync import SpecKitSync
23
+ from specfact_cli.telemetry import telemetry
20
24
 
21
25
 
22
26
  app = typer.Typer(help="Synchronize Spec-Kit artifacts and repository changes")
23
27
  console = Console()
24
28
 
25
29
 
26
- def _sync_speckit_to_specfact(repo: Path, converter: Any, scanner: Any, progress: Any) -> tuple[PlanBundle, int, int]:
27
- """
28
- Sync Spec-Kit artifacts to SpecFact format.
29
-
30
- Returns:
31
- Tuple of (merged_bundle, features_updated, features_added)
32
- """
33
- from specfact_cli.generators.plan_generator import PlanGenerator
34
- from specfact_cli.utils.structure import SpecFactStructure
35
- from specfact_cli.validators.schema import validate_plan_bundle
36
-
37
- plan_path = repo / SpecFactStructure.DEFAULT_PLAN
38
- existing_bundle: PlanBundle | None = None
39
-
40
- if plan_path.exists():
41
- validation_result = validate_plan_bundle(plan_path)
42
- if isinstance(validation_result, tuple):
43
- is_valid, _error, bundle = validation_result
44
- if is_valid and bundle:
45
- existing_bundle = bundle
46
-
47
- # Convert Spec-Kit to SpecFact
48
- converted_bundle = converter.convert_plan(None if not existing_bundle else plan_path)
49
-
50
- # Merge with existing plan if it exists
51
- features_updated = 0
52
- features_added = 0
53
-
54
- if existing_bundle:
55
- feature_keys_existing = {f.key for f in existing_bundle.features}
56
-
57
- for feature in converted_bundle.features:
58
- if feature.key in feature_keys_existing:
59
- existing_idx = next(i for i, f in enumerate(existing_bundle.features) if f.key == feature.key)
60
- existing_bundle.features[existing_idx] = feature
61
- features_updated += 1
62
- else:
63
- existing_bundle.features.append(feature)
64
- features_added += 1
65
-
66
- # Update product themes
67
- themes_existing = set(existing_bundle.product.themes)
68
- themes_new = set(converted_bundle.product.themes)
69
- existing_bundle.product.themes = list(themes_existing | themes_new)
70
-
71
- # Write merged bundle
72
- generator = PlanGenerator()
73
- generator.generate(existing_bundle, plan_path)
74
- return existing_bundle, features_updated, features_added
75
- # Write new bundle
76
- generator = PlanGenerator()
77
- generator.generate(converted_bundle, plan_path)
78
- return converted_bundle, 0, len(converted_bundle.features)
79
-
80
-
81
- @app.command("spec-kit")
82
- def sync_spec_kit(
83
- repo: Path = typer.Option(
84
- Path("."),
85
- "--repo",
86
- help="Path to repository",
87
- exists=True,
88
- file_okay=False,
89
- dir_okay=True,
90
- ),
91
- bidirectional: bool = typer.Option(
92
- False,
93
- "--bidirectional",
94
- help="Enable bidirectional sync (Spec-Kit ↔ SpecFact)",
95
- ),
96
- plan: Path | None = typer.Option(
97
- None,
98
- "--plan",
99
- help="Path to SpecFact plan bundle for SpecFact → Spec-Kit conversion (default: .specfact/plans/main.bundle.yaml)",
100
- ),
101
- overwrite: bool = typer.Option(
102
- False,
103
- "--overwrite",
104
- help="Overwrite existing Spec-Kit artifacts (delete all existing before sync)",
105
- ),
106
- watch: bool = typer.Option(
107
- False,
108
- "--watch",
109
- help="Watch mode for continuous sync",
110
- ),
111
- interval: int = typer.Option(
112
- 5,
113
- "--interval",
114
- help="Watch interval in seconds (default: 5)",
115
- min=1,
116
- ),
30
+ def _is_test_mode() -> bool:
31
+ """Check if running in test mode."""
32
+ # Check for TEST_MODE environment variable
33
+ if os.environ.get("TEST_MODE") == "true":
34
+ return True
35
+ # Check if running under pytest (common patterns)
36
+ import sys
37
+
38
+ return any("pytest" in arg or "test" in arg.lower() for arg in sys.argv) or "pytest" in sys.modules
39
+
40
+
41
+ @beartype
42
+ @require(lambda repo: repo.exists(), "Repository path must exist")
43
+ @require(lambda repo: repo.is_dir(), "Repository path must be a directory")
44
+ @require(lambda bidirectional: isinstance(bidirectional, bool), "Bidirectional must be bool")
45
+ @require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
46
+ @require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool")
47
+ @ensure(lambda result: result is None, "Must return None")
48
+ def _perform_sync_operation(
49
+ repo: Path,
50
+ bidirectional: bool,
51
+ plan: Path | None,
52
+ overwrite: bool,
117
53
  ) -> None:
118
54
  """
119
- Sync changes between Spec-Kit artifacts and SpecFact.
55
+ Perform sync operation without watch mode.
120
56
 
121
- Synchronizes markdown artifacts generated by Spec-Kit slash commands
122
- with SpecFact plan bundles and protocols.
57
+ This is extracted to avoid recursion when called from watch mode callback.
123
58
 
124
- Example:
125
- specfact sync spec-kit --repo . --bidirectional
59
+ Args:
60
+ repo: Path to repository
61
+ bidirectional: Enable bidirectional sync
62
+ plan: Path to SpecFact plan bundle
63
+ overwrite: Overwrite existing Spec-Kit artifacts
126
64
  """
127
65
  from specfact_cli.importers.speckit_converter import SpecKitConverter
128
66
  from specfact_cli.importers.speckit_scanner import SpecKitScanner
129
67
  from specfact_cli.utils.structure import SpecFactStructure
130
68
  from specfact_cli.validators.schema import validate_plan_bundle
131
69
 
132
- console.print(f"[bold cyan]Syncing Spec-Kit artifacts from:[/bold cyan] {repo}")
133
-
134
- # Watch mode (not implemented yet)
135
- if watch:
136
- console.print("[yellow]→ Watch mode enabled (not implemented yet)[/yellow]")
137
- console.print(f"[dim]Would watch for changes every {interval} seconds[/dim]")
138
- raise typer.Exit(0)
139
-
140
70
  # Step 1: Detect Spec-Kit repository
141
71
  scanner = SpecKitScanner(repo)
142
72
  if not scanner.is_speckit_repo():
@@ -146,6 +76,64 @@ def sync_spec_kit(
146
76
 
147
77
  console.print("[bold green]✓[/bold green] Detected Spec-Kit repository")
148
78
 
79
+ # Step 1.5: Validate constitution exists and is not empty
80
+ has_constitution, constitution_error = scanner.has_constitution()
81
+ if not has_constitution:
82
+ console.print("[bold red]✗[/bold red] Constitution required")
83
+ console.print(f"[red]{constitution_error}[/red]")
84
+ console.print("\n[bold yellow]Next Steps:[/bold yellow]")
85
+ console.print("1. Run 'specfact constitution bootstrap --repo .' to auto-generate constitution")
86
+ console.print("2. Or run '/speckit.constitution' command in your AI assistant")
87
+ console.print("3. Then run 'specfact sync spec-kit' again")
88
+ raise typer.Exit(1)
89
+
90
+ # Check if constitution is minimal and suggest bootstrap
91
+ constitution_path = repo / ".specify" / "memory" / "constitution.md"
92
+ if constitution_path.exists():
93
+ from specfact_cli.commands.constitution import is_constitution_minimal
94
+
95
+ if is_constitution_minimal(constitution_path):
96
+ # Auto-generate in test mode, prompt in interactive mode
97
+ # Check for test environment (TEST_MODE or PYTEST_CURRENT_TEST)
98
+ is_test_env = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None
99
+ if is_test_env:
100
+ # Auto-generate bootstrap constitution in test mode
101
+ from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher
102
+
103
+ enricher = ConstitutionEnricher()
104
+ enriched_content = enricher.bootstrap(repo, constitution_path)
105
+ constitution_path.write_text(enriched_content, encoding="utf-8")
106
+ else:
107
+ # Check if we're in an interactive environment
108
+ import sys
109
+
110
+ is_interactive = (hasattr(sys.stdin, "isatty") and sys.stdin.isatty()) and sys.stdin.isatty()
111
+ if is_interactive:
112
+ console.print("[yellow]⚠[/yellow] Constitution is minimal (essentially empty)")
113
+ suggest_bootstrap = typer.confirm(
114
+ "Generate bootstrap constitution from repository analysis?",
115
+ default=True,
116
+ )
117
+ if suggest_bootstrap:
118
+ from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher
119
+
120
+ console.print("[dim]Generating bootstrap constitution...[/dim]")
121
+ enricher = ConstitutionEnricher()
122
+ enriched_content = enricher.bootstrap(repo, constitution_path)
123
+ constitution_path.write_text(enriched_content, encoding="utf-8")
124
+ console.print("[bold green]✓[/bold green] Bootstrap constitution generated")
125
+ console.print("[dim]Review and adjust as needed before syncing[/dim]")
126
+ else:
127
+ console.print(
128
+ "[dim]Skipping bootstrap. Run 'specfact constitution bootstrap' manually if needed[/dim]"
129
+ )
130
+ else:
131
+ # Non-interactive mode: skip prompt
132
+ console.print("[yellow]⚠[/yellow] Constitution is minimal (essentially empty)")
133
+ console.print("[dim]Run 'specfact constitution bootstrap --repo .' to generate constitution[/dim]")
134
+
135
+ console.print("[bold green]✓[/bold green] Constitution found and validated")
136
+
149
137
  # Step 2: Detect SpecFact structure
150
138
  specfact_exists = (repo / SpecFactStructure.ROOT).exists()
151
139
 
@@ -168,10 +156,28 @@ def sync_spec_kit(
168
156
  console=console,
169
157
  ) as progress:
170
158
  # Step 3: Scan Spec-Kit artifacts
171
- task = progress.add_task("[cyan]📦[/cyan] Scanning Spec-Kit artifacts...", total=None)
159
+ task = progress.add_task("[cyan]Scanning Spec-Kit artifacts...[/cyan]", total=None)
160
+ # Keep description showing current activity (spinner will show automatically)
161
+ progress.update(task, description="[cyan]Scanning Spec-Kit artifacts...[/cyan]")
172
162
  features = scanner.discover_features()
163
+ # Update with final status after completion
173
164
  progress.update(task, description=f"[green]✓[/green] Found {len(features)} features in specs/")
174
165
 
166
+ # Step 3.5: Validate Spec-Kit artifacts for unidirectional sync
167
+ if not bidirectional and len(features) == 0:
168
+ console.print("[bold red]✗[/bold red] No Spec-Kit features found")
169
+ console.print(
170
+ "[red]Unidirectional sync (Spec-Kit → SpecFact) requires at least one feature specification.[/red]"
171
+ )
172
+ console.print("\n[bold yellow]Next Steps:[/bold yellow]")
173
+ console.print("1. Run '/speckit.specify' command in your AI assistant to create feature specifications")
174
+ console.print("2. Optionally run '/speckit.plan' and '/speckit.tasks' to create complete artifacts")
175
+ console.print("3. Then run 'specfact sync spec-kit' again")
176
+ console.print(
177
+ "\n[dim]Note: For bidirectional sync, Spec-Kit artifacts are optional if syncing from SpecFact → Spec-Kit[/dim]"
178
+ )
179
+ raise typer.Exit(1)
180
+
175
181
  # Step 4: Sync based on mode
176
182
  specfact_changes: dict[str, Any] = {}
177
183
  conflicts: list[dict[str, Any]] = []
@@ -180,10 +186,55 @@ def sync_spec_kit(
180
186
  if bidirectional:
181
187
  # Bidirectional sync: Spec-Kit → SpecFact and SpecFact → Spec-Kit
182
188
  # Step 5.1: Spec-Kit → SpecFact (unidirectional sync)
183
- task = progress.add_task("[cyan]📝[/cyan] Converting Spec-Kit SpecFact...", total=None)
184
- merged_bundle, features_updated, features_added = _sync_speckit_to_specfact(
185
- repo, converter, scanner, progress
186
- )
189
+ # Skip expensive conversion if no Spec-Kit features found (optimization)
190
+ if len(features) == 0:
191
+ task = progress.add_task("[cyan]📝[/cyan] Converting Spec-Kit → SpecFact...", total=None)
192
+ progress.update(
193
+ task,
194
+ description="[green]✓[/green] Skipped (no Spec-Kit features found)",
195
+ )
196
+ console.print("[dim] - Skipped Spec-Kit → SpecFact (no features in specs/)[/dim]")
197
+ # Use existing plan bundle if available, otherwise create minimal empty one
198
+ from specfact_cli.utils.structure import SpecFactStructure
199
+ from specfact_cli.validators.schema import validate_plan_bundle
200
+
201
+ # Use get_default_plan_path() to find the active plan (checks config or falls back to main.bundle.yaml)
202
+ plan_path = SpecFactStructure.get_default_plan_path(repo)
203
+ if plan_path.exists():
204
+ # Show progress while loading plan bundle
205
+ progress.update(task, description="[cyan]Parsing plan bundle YAML...[/cyan]")
206
+ validation_result = validate_plan_bundle(plan_path)
207
+ if isinstance(validation_result, tuple):
208
+ is_valid, _error, bundle = validation_result
209
+ if is_valid and bundle:
210
+ # Show progress during validation (Pydantic validation can be slow for large bundles)
211
+ progress.update(
212
+ task, description=f"[cyan]Validating {len(bundle.features)} features...[/cyan]"
213
+ )
214
+ merged_bundle = bundle
215
+ progress.update(
216
+ task,
217
+ description=f"[green]✓[/green] Loaded plan bundle ({len(bundle.features)} features)",
218
+ )
219
+ else:
220
+ # Fallback: create minimal bundle via converter (but skip expensive parsing)
221
+ progress.update(task, description="[cyan]Creating plan bundle from Spec-Kit...[/cyan]")
222
+ merged_bundle = _sync_speckit_to_specfact(repo, converter, scanner, progress, task)[0]
223
+ else:
224
+ progress.update(task, description="[cyan]Creating plan bundle from Spec-Kit...[/cyan]")
225
+ merged_bundle = _sync_speckit_to_specfact(repo, converter, scanner, progress, task)[0]
226
+ else:
227
+ progress.update(task, description="[cyan]Creating plan bundle from Spec-Kit...[/cyan]")
228
+ merged_bundle = _sync_speckit_to_specfact(repo, converter, scanner, progress, task)[0]
229
+ features_updated = 0
230
+ features_added = 0
231
+ else:
232
+ task = progress.add_task("[cyan]Converting Spec-Kit → SpecFact...[/cyan]", total=None)
233
+ # Show current activity (spinner will show automatically)
234
+ progress.update(task, description="[cyan]Converting Spec-Kit → SpecFact...[/cyan]")
235
+ merged_bundle, features_updated, features_added = _sync_speckit_to_specfact(
236
+ repo, converter, scanner, progress
237
+ )
187
238
 
188
239
  if features_updated > 0 or features_added > 0:
189
240
  progress.update(
@@ -199,55 +250,79 @@ def sync_spec_kit(
199
250
  )
200
251
 
201
252
  # Step 5.2: SpecFact → Spec-Kit (reverse conversion)
202
- task = progress.add_task("[cyan]🔄[/cyan] Converting SpecFact → Spec-Kit...", total=None)
253
+ task = progress.add_task("[cyan]Converting SpecFact → Spec-Kit...[/cyan]", total=None)
254
+ # Show current activity (spinner will show automatically)
255
+ progress.update(task, description="[cyan]Detecting SpecFact changes...[/cyan]")
203
256
 
204
- # Detect SpecFact changes
257
+ # Detect SpecFact changes (for tracking/incremental sync, but don't block conversion)
205
258
  specfact_changes = sync.detect_specfact_changes(repo)
206
259
 
207
- if specfact_changes:
208
- # Load plan bundle and convert to Spec-Kit
209
- # Use provided plan path, or default to main plan
260
+ # Use the merged_bundle we already loaded, or load it if not available
261
+ # We convert even if no "changes" detected, as long as plan bundle exists and has features
262
+ plan_bundle_to_convert: PlanBundle | None = None
263
+
264
+ # Prefer using merged_bundle if it has features (already loaded above)
265
+ if merged_bundle and len(merged_bundle.features) > 0:
266
+ plan_bundle_to_convert = merged_bundle
267
+ else:
268
+ # Fallback: load plan bundle from file if merged_bundle is empty or None
210
269
  if plan:
211
270
  plan_path = plan if plan.is_absolute() else repo / plan
212
271
  else:
213
- plan_path = repo / SpecFactStructure.DEFAULT_PLAN
272
+ # Use get_default_plan_path() to find the active plan (checks config or falls back to main.bundle.yaml)
273
+ plan_path = SpecFactStructure.get_default_plan_path(repo)
214
274
 
215
275
  if plan_path.exists():
276
+ progress.update(task, description="[cyan]Loading plan bundle...[/cyan]")
216
277
  validation_result = validate_plan_bundle(plan_path)
217
278
  if isinstance(validation_result, tuple):
218
279
  is_valid, _error, plan_bundle = validation_result
219
- if is_valid and plan_bundle:
220
- # Handle overwrite mode
221
- if overwrite:
222
- # Delete existing Spec-Kit artifacts before conversion
223
- specs_dir = repo / "specs"
224
- if specs_dir.exists():
225
- console.print(
226
- "[yellow]⚠[/yellow] Overwrite mode: Removing existing Spec-Kit artifacts..."
227
- )
228
- shutil.rmtree(specs_dir)
229
- specs_dir.mkdir(parents=True, exist_ok=True)
230
- console.print("[green][/green] Existing artifacts removed")
280
+ if is_valid and plan_bundle and len(plan_bundle.features) > 0:
281
+ plan_bundle_to_convert = plan_bundle
282
+
283
+ # Convert if we have a plan bundle with features
284
+ if plan_bundle_to_convert and len(plan_bundle_to_convert.features) > 0:
285
+ # Handle overwrite mode
286
+ if overwrite:
287
+ progress.update(task, description="[cyan]Removing existing artifacts...[/cyan]")
288
+ # Delete existing Spec-Kit artifacts before conversion
289
+ specs_dir = repo / "specs"
290
+ if specs_dir.exists():
291
+ console.print("[yellow][/yellow] Overwrite mode: Removing existing Spec-Kit artifacts...")
292
+ shutil.rmtree(specs_dir)
293
+ specs_dir.mkdir(parents=True, exist_ok=True)
294
+ console.print("[green]✓[/green] Existing artifacts removed")
295
+
296
+ # Convert SpecFact plan bundle to Spec-Kit markdown
297
+ total_features = len(plan_bundle_to_convert.features)
298
+ progress.update(
299
+ task,
300
+ description=f"[cyan]Converting plan bundle to Spec-Kit format (0 of {total_features})...[/cyan]",
301
+ )
231
302
 
232
- # Convert SpecFact plan bundle to Spec-Kit markdown
233
- features_converted_speckit = converter.convert_to_speckit(plan_bundle)
234
- progress.update(
235
- task,
236
- description=f"[green]✓[/green] Converted {features_converted_speckit} features to Spec-Kit",
237
- )
238
- mode_text = "overwritten" if overwrite else "generated"
239
- console.print(
240
- f"[dim] - {mode_text.capitalize()} spec.md, plan.md, tasks.md for {features_converted_speckit} features[/dim]"
241
- )
242
- else:
243
- progress.update(task, description="[yellow]⚠[/yellow] Plan bundle validation failed")
244
- console.print("[yellow]⚠[/yellow] Could not load plan bundle for conversion")
245
- else:
246
- progress.update(task, description="[yellow]⚠[/yellow] Plan bundle not found")
247
- else:
248
- progress.update(task, description="[green]✓[/green] No SpecFact plan to sync")
303
+ # Progress callback to update during conversion
304
+ def update_progress(current: int, total: int) -> None:
305
+ progress.update(
306
+ task,
307
+ description=f"[cyan]Converting plan bundle to Spec-Kit format ({current} of {total})...[/cyan]",
308
+ )
309
+
310
+ features_converted_speckit = converter.convert_to_speckit(plan_bundle_to_convert, update_progress)
311
+ progress.update(
312
+ task,
313
+ description=f"[green]✓[/green] Converted {features_converted_speckit} features to Spec-Kit",
314
+ )
315
+ mode_text = "overwritten" if overwrite else "generated"
316
+ console.print(
317
+ f"[dim] - {mode_text.capitalize()} spec.md, plan.md, tasks.md for {features_converted_speckit} features[/dim]"
318
+ )
319
+ # Warning about Constitution Check gates
320
+ console.print(
321
+ "[yellow]⚠[/yellow] [dim]Note: Constitution Check gates in plan.md are set to PENDING - review and check gates based on your project's actual state[/dim]"
322
+ )
249
323
  else:
250
- progress.update(task, description="[green]✓[/green] No SpecFact changes to sync")
324
+ progress.update(task, description="[green]✓[/green] No features to convert to Spec-Kit")
325
+ features_converted_speckit = 0
251
326
 
252
327
  # Detect conflicts between both directions
253
328
  speckit_changes = sync.detect_speckit_changes(repo)
@@ -260,7 +335,9 @@ def sync_spec_kit(
260
335
  console.print("[bold green]✓[/bold green] No conflicts detected")
261
336
  else:
262
337
  # Unidirectional sync: Spec-Kit → SpecFact
263
- task = progress.add_task("[cyan]📝[/cyan] Converting to SpecFact format...", total=None)
338
+ task = progress.add_task("[cyan]Converting to SpecFact format...[/cyan]", total=None)
339
+ # Show current activity (spinner will show automatically)
340
+ progress.update(task, description="[cyan]Converting to SpecFact format...[/cyan]")
264
341
 
265
342
  merged_bundle, features_updated, features_added = _sync_speckit_to_specfact(
266
343
  repo, converter, scanner, progress
@@ -294,16 +371,24 @@ def sync_spec_kit(
294
371
  if bidirectional:
295
372
  console.print("[bold cyan]Sync Summary (Bidirectional):[/bold cyan]")
296
373
  console.print(f" - Spec-Kit → SpecFact: Updated {features_updated}, Added {features_added} features")
297
- if specfact_changes:
374
+ # Always show conversion result (we convert if plan bundle exists, not just when changes detected)
375
+ if features_converted_speckit > 0:
298
376
  console.print(
299
377
  f" - SpecFact → Spec-Kit: {features_converted_speckit} features converted to Spec-Kit markdown"
300
378
  )
301
379
  else:
302
- console.print(" - SpecFact → Spec-Kit: No changes detected")
380
+ console.print(" - SpecFact → Spec-Kit: No features to convert")
303
381
  if conflicts:
304
382
  console.print(f" - Conflicts: {len(conflicts)} detected and resolved")
305
383
  else:
306
384
  console.print(" - Conflicts: None detected")
385
+
386
+ # Post-sync validation suggestion
387
+ if features_converted_speckit > 0:
388
+ console.print()
389
+ console.print("[bold cyan]Next Steps:[/bold cyan]")
390
+ console.print(" Run '/speckit.analyze' to validate artifact consistency and quality")
391
+ console.print(" This will check for ambiguities, duplications, and constitution alignment")
307
392
  else:
308
393
  console.print("[bold cyan]Sync Summary (Unidirectional):[/bold cyan]")
309
394
  if features:
@@ -313,10 +398,368 @@ def sync_spec_kit(
313
398
  console.print(f" - Added: {features_added} new features")
314
399
  console.print(" - Direction: Spec-Kit → SpecFact")
315
400
 
401
+ # Post-sync validation suggestion
402
+ console.print()
403
+ console.print("[bold cyan]Next Steps:[/bold cyan]")
404
+ console.print(" Run '/speckit.analyze' to validate artifact consistency and quality")
405
+ console.print(" This will check for ambiguities, duplications, and constitution alignment")
406
+
316
407
  console.print()
317
408
  console.print("[bold green]✓[/bold green] Sync complete!")
318
409
 
319
410
 
411
+ def _sync_speckit_to_specfact(
412
+ repo: Path, converter: Any, scanner: Any, progress: Any, task: int | None = None
413
+ ) -> tuple[PlanBundle, int, int]:
414
+ """
415
+ Sync Spec-Kit artifacts to SpecFact format.
416
+
417
+ Args:
418
+ repo: Repository path
419
+ converter: SpecKitConverter instance
420
+ scanner: SpecKitScanner instance
421
+ progress: Rich Progress instance
422
+ task: Optional progress task ID to update
423
+
424
+ Returns:
425
+ Tuple of (merged_bundle, features_updated, features_added)
426
+ """
427
+ from specfact_cli.generators.plan_generator import PlanGenerator
428
+ from specfact_cli.utils.structure import SpecFactStructure
429
+ from specfact_cli.validators.schema import validate_plan_bundle
430
+
431
+ plan_path = SpecFactStructure.get_default_plan_path(repo)
432
+ existing_bundle: PlanBundle | None = None
433
+
434
+ if plan_path.exists():
435
+ if task is not None:
436
+ progress.update(task, description="[cyan]Validating existing plan bundle...[/cyan]")
437
+ validation_result = validate_plan_bundle(plan_path)
438
+ if isinstance(validation_result, tuple):
439
+ is_valid, _error, bundle = validation_result
440
+ if is_valid and bundle:
441
+ existing_bundle = bundle
442
+ # Deduplicate existing features by normalized key (clean up duplicates from previous syncs)
443
+ from specfact_cli.utils.feature_keys import normalize_feature_key
444
+
445
+ seen_normalized_keys: set[str] = set()
446
+ deduplicated_features: list[Feature] = []
447
+ for existing_feature in existing_bundle.features:
448
+ normalized_key = normalize_feature_key(existing_feature.key)
449
+ if normalized_key not in seen_normalized_keys:
450
+ seen_normalized_keys.add(normalized_key)
451
+ deduplicated_features.append(existing_feature)
452
+
453
+ duplicates_removed = len(existing_bundle.features) - len(deduplicated_features)
454
+ if duplicates_removed > 0:
455
+ existing_bundle.features = deduplicated_features
456
+ # Write back deduplicated bundle immediately to clean up the plan file
457
+ from specfact_cli.generators.plan_generator import PlanGenerator
458
+
459
+ if task is not None:
460
+ progress.update(
461
+ task,
462
+ description=f"[cyan]Deduplicating {duplicates_removed} duplicate features and writing cleaned plan...[/cyan]",
463
+ )
464
+ generator = PlanGenerator()
465
+ generator.generate(existing_bundle, plan_path)
466
+ if task is not None:
467
+ progress.update(
468
+ task,
469
+ description=f"[green]✓[/green] Removed {duplicates_removed} duplicates, cleaned plan saved",
470
+ )
471
+
472
+ # Convert Spec-Kit to SpecFact
473
+ if task is not None:
474
+ progress.update(task, description="[cyan]Converting Spec-Kit artifacts to SpecFact format...[/cyan]")
475
+ converted_bundle = converter.convert_plan(None if not existing_bundle else plan_path)
476
+
477
+ # Merge with existing plan if it exists
478
+ features_updated = 0
479
+ features_added = 0
480
+
481
+ if existing_bundle:
482
+ if task is not None:
483
+ progress.update(task, description="[cyan]Merging with existing plan bundle...[/cyan]")
484
+ # Use normalized keys for matching to handle different key formats (e.g., FEATURE-001 vs 001_FEATURE_NAME)
485
+ from specfact_cli.utils.feature_keys import normalize_feature_key
486
+
487
+ # Build a map of normalized_key -> (index, original_key) for existing features
488
+ normalized_key_map: dict[str, tuple[int, str]] = {}
489
+ for idx, existing_feature in enumerate(existing_bundle.features):
490
+ normalized_key = normalize_feature_key(existing_feature.key)
491
+ # If multiple features have the same normalized key, keep the first one
492
+ if normalized_key not in normalized_key_map:
493
+ normalized_key_map[normalized_key] = (idx, existing_feature.key)
494
+
495
+ for feature in converted_bundle.features:
496
+ normalized_key = normalize_feature_key(feature.key)
497
+ matched = False
498
+
499
+ # Try exact match first
500
+ if normalized_key in normalized_key_map:
501
+ existing_idx, original_key = normalized_key_map[normalized_key]
502
+ # Preserve the original key format from existing bundle
503
+ feature.key = original_key
504
+ existing_bundle.features[existing_idx] = feature
505
+ features_updated += 1
506
+ matched = True
507
+ else:
508
+ # Try prefix match for abbreviated vs full names
509
+ # (e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM)
510
+ # Only match if shorter is a PREFIX of longer with significant length difference
511
+ # AND at least one key has a numbered prefix (041_, 042-, etc.) indicating Spec-Kit origin
512
+ # This avoids false positives like SMARTCOVERAGE vs SMARTCOVERAGEMANAGER (both from code analysis)
513
+ for existing_norm_key, (existing_idx, original_key) in normalized_key_map.items():
514
+ shorter = min(normalized_key, existing_norm_key, key=len)
515
+ longer = max(normalized_key, existing_norm_key, key=len)
516
+
517
+ # Check if at least one key has a numbered prefix (Spec-Kit format)
518
+ import re
519
+
520
+ has_speckit_key = bool(
521
+ re.match(r"^\d{3}[_-]", feature.key) or re.match(r"^\d{3}[_-]", original_key)
522
+ )
523
+
524
+ # More conservative matching:
525
+ # 1. At least one key must have numbered prefix (Spec-Kit origin)
526
+ # 2. Shorter must be at least 10 chars
527
+ # 3. Longer must start with shorter (prefix match)
528
+ # 4. Length difference must be at least 6 chars
529
+ # 5. Shorter must be < 75% of longer (to ensure significant difference)
530
+ length_diff = len(longer) - len(shorter)
531
+ length_ratio = len(shorter) / len(longer) if len(longer) > 0 else 1.0
532
+
533
+ if (
534
+ has_speckit_key
535
+ and len(shorter) >= 10
536
+ and longer.startswith(shorter)
537
+ and length_diff >= 6
538
+ and length_ratio < 0.75
539
+ ):
540
+ # Match found - use the existing key format (prefer full name if available)
541
+ if len(existing_norm_key) >= len(normalized_key):
542
+ # Existing key is longer (full name) - keep it
543
+ feature.key = original_key
544
+ else:
545
+ # New key is longer (full name) - use it but update existing
546
+ existing_bundle.features[existing_idx].key = feature.key
547
+ existing_bundle.features[existing_idx] = feature
548
+ features_updated += 1
549
+ matched = True
550
+ break
551
+
552
+ if not matched:
553
+ # New feature - add it
554
+ existing_bundle.features.append(feature)
555
+ features_added += 1
556
+
557
+ # Update product themes
558
+ themes_existing = set(existing_bundle.product.themes)
559
+ themes_new = set(converted_bundle.product.themes)
560
+ existing_bundle.product.themes = list(themes_existing | themes_new)
561
+
562
+ # Write merged bundle
563
+ if task is not None:
564
+ progress.update(task, description="[cyan]Writing plan bundle to disk...[/cyan]")
565
+ generator = PlanGenerator()
566
+ generator.generate(existing_bundle, plan_path)
567
+ return existing_bundle, features_updated, features_added
568
+ # Write new bundle
569
+ generator = PlanGenerator()
570
+ generator.generate(converted_bundle, plan_path)
571
+ return converted_bundle, 0, len(converted_bundle.features)
572
+
573
+
574
+ @app.command("spec-kit")
575
+ def sync_spec_kit(
576
+ repo: Path = typer.Option(
577
+ Path("."),
578
+ "--repo",
579
+ help="Path to repository",
580
+ exists=True,
581
+ file_okay=False,
582
+ dir_okay=True,
583
+ ),
584
+ bidirectional: bool = typer.Option(
585
+ False,
586
+ "--bidirectional",
587
+ help="Enable bidirectional sync (Spec-Kit ↔ SpecFact)",
588
+ ),
589
+ plan: Path | None = typer.Option(
590
+ None,
591
+ "--plan",
592
+ help="Path to SpecFact plan bundle for SpecFact → Spec-Kit conversion (default: .specfact/plans/main.bundle.yaml)",
593
+ ),
594
+ overwrite: bool = typer.Option(
595
+ False,
596
+ "--overwrite",
597
+ help="Overwrite existing Spec-Kit artifacts (delete all existing before sync)",
598
+ ),
599
+ watch: bool = typer.Option(
600
+ False,
601
+ "--watch",
602
+ help="Watch mode for continuous sync",
603
+ ),
604
+ interval: int = typer.Option(
605
+ 5,
606
+ "--interval",
607
+ help="Watch interval in seconds (default: 5)",
608
+ min=1,
609
+ ),
610
+ ensure_speckit_compliance: bool = typer.Option(
611
+ False,
612
+ "--ensure-speckit-compliance",
613
+ help="Validate and auto-enrich plan bundle for Spec-Kit compliance before sync (ensures technology stack, testable acceptance criteria, comprehensive scenarios)",
614
+ ),
615
+ ) -> None:
616
+ """
617
+ Sync changes between Spec-Kit artifacts and SpecFact.
618
+
619
+ Synchronizes markdown artifacts generated by Spec-Kit slash commands
620
+ with SpecFact plan bundles and protocols.
621
+
622
+ Example:
623
+ specfact sync spec-kit --repo . --bidirectional
624
+ """
625
+ telemetry_metadata = {
626
+ "bidirectional": bidirectional,
627
+ "watch": watch,
628
+ "overwrite": overwrite,
629
+ "interval": interval,
630
+ }
631
+
632
+ with telemetry.track_command("sync.spec_kit", telemetry_metadata) as record:
633
+ console.print(f"[bold cyan]Syncing Spec-Kit artifacts from:[/bold cyan] {repo}")
634
+
635
+ # Ensure Spec-Kit compliance if requested
636
+ if ensure_speckit_compliance:
637
+ console.print("\n[cyan]🔍 Validating plan bundle for Spec-Kit compliance...[/cyan]")
638
+ from specfact_cli.utils.structure import SpecFactStructure
639
+ from specfact_cli.validators.schema import validate_plan_bundle
640
+
641
+ # Use provided plan path or default
642
+ plan_path = plan if plan else SpecFactStructure.get_default_plan_path(repo)
643
+ if not plan_path.is_absolute():
644
+ plan_path = repo / plan_path
645
+
646
+ if plan_path.exists():
647
+ validation_result = validate_plan_bundle(plan_path)
648
+ if isinstance(validation_result, tuple):
649
+ is_valid, _error, plan_bundle = validation_result
650
+ if is_valid and plan_bundle:
651
+ # Check for technology stack in constraints
652
+ has_tech_stack = bool(
653
+ plan_bundle.idea
654
+ and plan_bundle.idea.constraints
655
+ and any(
656
+ "Python" in c or "framework" in c.lower() or "database" in c.lower()
657
+ for c in plan_bundle.idea.constraints
658
+ )
659
+ )
660
+
661
+ if not has_tech_stack:
662
+ console.print("[yellow]⚠ Technology stack not found in constraints[/yellow]")
663
+ console.print("[dim]Technology stack will be extracted from constraints during sync[/dim]")
664
+
665
+ # Check for testable acceptance criteria
666
+ features_with_non_testable = []
667
+ for feature in plan_bundle.features:
668
+ for story in feature.stories:
669
+ testable_count = sum(
670
+ 1
671
+ for acc in story.acceptance
672
+ if any(
673
+ keyword in acc.lower()
674
+ for keyword in ["must", "should", "verify", "validate", "ensure"]
675
+ )
676
+ )
677
+ if testable_count < len(story.acceptance) and len(story.acceptance) > 0:
678
+ features_with_non_testable.append((feature.key, story.key))
679
+
680
+ if features_with_non_testable:
681
+ console.print(
682
+ f"[yellow]⚠ Found {len(features_with_non_testable)} stories with non-testable acceptance criteria[/yellow]"
683
+ )
684
+ console.print("[dim]Acceptance criteria will be enhanced during sync[/dim]")
685
+
686
+ console.print("[green]✓ Plan bundle validation complete[/green]")
687
+ else:
688
+ console.print("[yellow]⚠ Plan bundle validation failed, but continuing with sync[/yellow]")
689
+ else:
690
+ console.print("[yellow]⚠ Could not validate plan bundle, but continuing with sync[/yellow]")
691
+ else:
692
+ console.print("[yellow]⚠ Plan bundle not found, skipping compliance check[/yellow]")
693
+
694
+ # Resolve repo path to ensure it's absolute and valid (do this once at the start)
695
+ resolved_repo = repo.resolve()
696
+ if not resolved_repo.exists():
697
+ console.print(f"[red]Error:[/red] Repository path does not exist: {resolved_repo}")
698
+ raise typer.Exit(1)
699
+ if not resolved_repo.is_dir():
700
+ console.print(f"[red]Error:[/red] Repository path is not a directory: {resolved_repo}")
701
+ raise typer.Exit(1)
702
+
703
+ # Watch mode implementation
704
+ if watch:
705
+ from specfact_cli.sync.watcher import FileChange, SyncWatcher
706
+
707
+ console.print("[bold cyan]Watch mode enabled[/bold cyan]")
708
+ console.print(f"[dim]Watching for changes every {interval} seconds[/dim]\n")
709
+
710
+ @beartype
711
+ @require(lambda changes: isinstance(changes, list), "Changes must be a list")
712
+ @require(
713
+ lambda changes: all(hasattr(c, "change_type") for c in changes),
714
+ "All changes must have change_type attribute",
715
+ )
716
+ @ensure(lambda result: result is None, "Must return None")
717
+ def sync_callback(changes: list[FileChange]) -> None:
718
+ """Handle file changes and trigger sync."""
719
+ spec_kit_changes = [c for c in changes if c.change_type == "spec_kit"]
720
+ specfact_changes = [c for c in changes if c.change_type == "specfact"]
721
+
722
+ if spec_kit_changes or specfact_changes:
723
+ console.print(f"[cyan]Detected {len(changes)} change(s), syncing...[/cyan]")
724
+ # Perform one-time sync (bidirectional if enabled)
725
+ try:
726
+ # Re-validate resolved_repo before use (may have been cleaned up)
727
+ if not resolved_repo.exists():
728
+ console.print(f"[yellow]⚠[/yellow] Repository path no longer exists: {resolved_repo}\n")
729
+ return
730
+ if not resolved_repo.is_dir():
731
+ console.print(
732
+ f"[yellow]⚠[/yellow] Repository path is no longer a directory: {resolved_repo}\n"
733
+ )
734
+ return
735
+ # Use resolved_repo from outer scope (already resolved and validated)
736
+ _perform_sync_operation(
737
+ repo=resolved_repo,
738
+ bidirectional=bidirectional,
739
+ plan=plan,
740
+ overwrite=overwrite,
741
+ )
742
+ console.print("[green]✓[/green] Sync complete\n")
743
+ except Exception as e:
744
+ console.print(f"[red]✗[/red] Sync failed: {e}\n")
745
+
746
+ # Use resolved_repo for watcher (already resolved and validated)
747
+ watcher = SyncWatcher(resolved_repo, sync_callback, interval=interval)
748
+ watcher.watch()
749
+ record({"watch_mode": True})
750
+ return
751
+
752
+ # Perform sync operation (extracted to avoid recursion in watch mode)
753
+ # Use resolved_repo (already resolved and validated above)
754
+ _perform_sync_operation(
755
+ repo=resolved_repo,
756
+ bidirectional=bidirectional,
757
+ plan=plan,
758
+ overwrite=overwrite,
759
+ )
760
+ record({"sync_completed": True})
761
+
762
+
320
763
  @app.command("repository")
321
764
  def sync_repository(
322
765
  repo: Path = typer.Option(
@@ -362,47 +805,119 @@ def sync_repository(
362
805
  """
363
806
  from specfact_cli.sync.repository_sync import RepositorySync
364
807
 
365
- console.print(f"[bold cyan]Syncing repository changes from:[/bold cyan] {repo}")
808
+ telemetry_metadata = {
809
+ "watch": watch,
810
+ "interval": interval,
811
+ "confidence": confidence,
812
+ }
366
813
 
367
- if target is None:
368
- target = repo / ".specfact"
814
+ with telemetry.track_command("sync.repository", telemetry_metadata) as record:
815
+ console.print(f"[bold cyan]Syncing repository changes from:[/bold cyan] {repo}")
369
816
 
370
- sync = RepositorySync(repo, target, confidence_threshold=confidence)
817
+ # Resolve repo path to ensure it's absolute and valid (do this once at the start)
818
+ resolved_repo = repo.resolve()
819
+ if not resolved_repo.exists():
820
+ console.print(f"[red]Error:[/red] Repository path does not exist: {resolved_repo}")
821
+ raise typer.Exit(1)
822
+ if not resolved_repo.is_dir():
823
+ console.print(f"[red]Error:[/red] Repository path is not a directory: {resolved_repo}")
824
+ raise typer.Exit(1)
371
825
 
372
- if watch:
373
- console.print("[yellow]→ Watch mode enabled (not implemented yet)[/yellow]")
374
- console.print(f"[dim]Would watch for changes every {interval} seconds[/dim]")
375
- raise typer.Exit(0)
826
+ if target is None:
827
+ target = resolved_repo / ".specfact"
376
828
 
377
- with Progress(
378
- SpinnerColumn(),
379
- TextColumn("[progress.description]{task.description}"),
380
- console=console,
381
- ) as progress:
382
- # Step 1: Detect code changes
383
- task = progress.add_task("Detecting code changes...", total=None)
384
- result = sync.sync_repository_changes(repo)
385
- progress.update(task, description=f"✓ Detected {len(result.code_changes)} code changes")
829
+ sync = RepositorySync(resolved_repo, target, confidence_threshold=confidence)
386
830
 
387
- # Step 2: Show plan updates
388
- if result.plan_updates:
389
- task = progress.add_task("Updating plan artifacts...", total=None)
390
- total_features = sum(update.get("features", 0) for update in result.plan_updates)
391
- progress.update(task, description=f"✓ Updated plan artifacts ({total_features} features)")
831
+ if watch:
832
+ from specfact_cli.sync.watcher import FileChange, SyncWatcher
392
833
 
393
- # Step 3: Show deviations
834
+ console.print("[bold cyan]Watch mode enabled[/bold cyan]")
835
+ console.print(f"[dim]Watching for changes every {interval} seconds[/dim]\n")
836
+
837
+ @beartype
838
+ @require(lambda changes: isinstance(changes, list), "Changes must be a list")
839
+ @require(
840
+ lambda changes: all(hasattr(c, "change_type") for c in changes),
841
+ "All changes must have change_type attribute",
842
+ )
843
+ @ensure(lambda result: result is None, "Must return None")
844
+ def sync_callback(changes: list[FileChange]) -> None:
845
+ """Handle file changes and trigger sync."""
846
+ code_changes = [c for c in changes if c.change_type == "code"]
847
+
848
+ if code_changes:
849
+ console.print(f"[cyan]Detected {len(code_changes)} code change(s), syncing...[/cyan]")
850
+ # Perform repository sync
851
+ try:
852
+ # Re-validate resolved_repo before use (may have been cleaned up)
853
+ if not resolved_repo.exists():
854
+ console.print(f"[yellow]⚠[/yellow] Repository path no longer exists: {resolved_repo}\n")
855
+ return
856
+ if not resolved_repo.is_dir():
857
+ console.print(
858
+ f"[yellow]⚠[/yellow] Repository path is no longer a directory: {resolved_repo}\n"
859
+ )
860
+ return
861
+ # Use resolved_repo from outer scope (already resolved and validated)
862
+ result = sync.sync_repository_changes(resolved_repo)
863
+ if result.status == "success":
864
+ console.print("[green]✓[/green] Repository sync complete\n")
865
+ elif result.status == "deviation_detected":
866
+ console.print(f"[yellow]⚠[/yellow] Deviations detected: {len(result.deviations)}\n")
867
+ else:
868
+ console.print(f"[red]✗[/red] Sync failed: {result.status}\n")
869
+ except Exception as e:
870
+ console.print(f"[red]✗[/red] Sync failed: {e}\n")
871
+
872
+ # Use resolved_repo for watcher (already resolved and validated)
873
+ watcher = SyncWatcher(resolved_repo, sync_callback, interval=interval)
874
+ watcher.watch()
875
+ record({"watch_mode": True})
876
+ return
877
+
878
+ # Use resolved_repo (already resolved and validated above)
879
+ # Disable Progress in test mode to avoid LiveError conflicts
880
+ if _is_test_mode():
881
+ # In test mode, just run the sync without Progress
882
+ result = sync.sync_repository_changes(resolved_repo)
883
+ else:
884
+ with Progress(
885
+ SpinnerColumn(),
886
+ TextColumn("[progress.description]{task.description}"),
887
+ console=console,
888
+ ) as progress:
889
+ # Step 1: Detect code changes
890
+ task = progress.add_task("Detecting code changes...", total=None)
891
+ result = sync.sync_repository_changes(resolved_repo)
892
+ progress.update(task, description=f"✓ Detected {len(result.code_changes)} code changes")
893
+
894
+ # Step 2: Show plan updates
895
+ if result.plan_updates:
896
+ task = progress.add_task("Updating plan artifacts...", total=None)
897
+ total_features = sum(update.get("features", 0) for update in result.plan_updates)
898
+ progress.update(task, description=f"✓ Updated plan artifacts ({total_features} features)")
899
+
900
+ # Step 3: Show deviations
901
+ if result.deviations:
902
+ task = progress.add_task("Tracking deviations...", total=None)
903
+ progress.update(task, description=f"✓ Found {len(result.deviations)} deviations")
904
+
905
+ # Record sync results
906
+ record(
907
+ {
908
+ "code_changes": len(result.code_changes),
909
+ "plan_updates": len(result.plan_updates) if result.plan_updates else 0,
910
+ "deviations": len(result.deviations) if result.deviations else 0,
911
+ }
912
+ )
913
+
914
+ # Report results
915
+ console.print(f"[bold cyan]Code Changes:[/bold cyan] {len(result.code_changes)}")
916
+ if result.plan_updates:
917
+ console.print(f"[bold cyan]Plan Updates:[/bold cyan] {len(result.plan_updates)}")
394
918
  if result.deviations:
395
- task = progress.add_task("Tracking deviations...", total=None)
396
- progress.update(task, description=f" Found {len(result.deviations)} deviations")
397
-
398
- # Report results
399
- console.print(f"[bold cyan]Code Changes:[/bold cyan] {len(result.code_changes)}")
400
- if result.plan_updates:
401
- console.print(f"[bold cyan]Plan Updates:[/bold cyan] {len(result.plan_updates)}")
402
- if result.deviations:
403
- console.print(f"[yellow]⚠[/yellow] Found {len(result.deviations)} deviations from manual plan")
404
- console.print("[dim]Run 'specfact plan compare' for detailed deviation report[/dim]")
405
- else:
406
- console.print("[bold green]✓[/bold green] No deviations detected")
407
-
408
- console.print("[bold green]✓[/bold green] Repository sync complete!")
919
+ console.print(f"[yellow]⚠[/yellow] Found {len(result.deviations)} deviations from manual plan")
920
+ console.print("[dim]Run 'specfact plan compare' for detailed deviation report[/dim]")
921
+ else:
922
+ console.print("[bold green]✓[/bold green] No deviations detected")
923
+ console.print("[bold green][/bold green] Repository sync complete!")