# #region MergeSpec [C:2] [TYPE Module] # @LAYER Infra # #region merge_spec [C:2] [TYPE Module] # @LAYER Infra import sys from datetime import datetime from pathlib import Path REVIEW_PROMPT = ( "Another LLM created this feature package. Your task: conduct an independent " "orthogonal spec review. Evaluate readiness, find contradictions, gaps, " "implementation risks, and prepare a structured report with corrections. " "Focus on spec review, not rewriting the implementation." ) # Canonical artifact order for the merged output. # Each stage: (match_type, value, section_label) # match_type "exact" = exact filename match # match_type "prefix" = directory prefix — matches all files under that dir CANONICAL_MD_STAGES = ( # Layer 1: Requirements & UX narrative ("exact", "spec.md", "SPEC — Feature Specification"), ("exact", "ux_reference.md", "UX REFERENCE — Interaction Narrative"), ("prefix", "checklists/", "CHECKLISTS — Requirements Quality"), # Layer 2: UX Design (from /speckit.ux) ("exact", "contracts/ux/alternatives.md", "UX ALTERNATIVES — Design Space Explored"), ("exact", "contracts/ux/decisions.md", "UX DECISIONS — Final Choices"), ("exact", "contracts/ux/screen-models.md", "UX SCREEN MODELS — Model Inventory"), ("exact", "contracts/ux/api-ux.md", "UX API CONTRACT — Endpoints & Shapes"), ("prefix", "contracts/ux/", "UX DESIGN — Per-Screen Contracts"), ("exact", "contracts/ux/design-tokens.md", None), # force-first within prefix # Layer 3: Implementation Plan ("exact", "plan.md", "PLAN — Implementation Plan"), ("exact", "research.md", "RESEARCH — Technical Decisions"), ("exact", "data-model.md", "DATA MODEL — Entities & Relations"), ("prefix", "contracts/", "CONTRACTS — Module & Function Contracts"), ("exact", "quickstart.md", "QUICKSTART — Dev Onboarding"), # Layer 4: Traceability ("exact", "traceability.md", "TRACEABILITY — Requirements Matrix"), # Layer 5: Execution ("exact", "tasks.md", "TASKS — Implementation Tasks"), ) ORDER_HINT = ( "Artifact order: spec → ux_reference → checklists → " "UX (alternatives → decisions → screen-models → api-ux → per-screen → design-tokens) → " "plan → research → data-model → contracts → quickstart → traceability → tasks → remaining." ) def relative_key(path: Path, root: Path) -> str: return path.relative_to(root).as_posix() def ordered_markdown_files(target_dir: Path) -> list[Path]: """Order markdown files according to CANONICAL_MD_STAGES.""" markdown_files = [path for path in target_dir.rglob("*.md") if path.is_file()] remaining = {relative_key(path, target_dir): path for path in markdown_files} ordered: list[Path] = [] # Track which stages have already been matched to avoid duplicates seen = set() for stage_type, stage_value, _section_label in CANONICAL_MD_STAGES: if stage_type == "exact": # Exact: only match if the file exists and hasn't been collected yet rel = f"contracts/ux/{stage_value}" if stage_value.startswith("contracts/ux/") else stage_value if stage_value.startswith("contracts/ux/"): path = remaining.pop(stage_value, None) else: path = remaining.pop(stage_value, None) if path is not None and path not in seen: ordered.append(path) seen.add(path) continue # Prefix: match all files whose relative path starts with stage_value stage_matches = sorted( [ path for rel_path, path in remaining.items() if rel_path.startswith(stage_value) ], key=lambda p: relative_key(p, target_dir), ) for path in stage_matches: if path not in seen: ordered.append(path) seen.add(path) rel = relative_key(path, target_dir) remaining.pop(rel, None) # Remaining files — append at end remaining_paths = sorted(remaining.values(), key=lambda p: relative_key(p, target_dir)) for path in remaining_paths: if path not in seen: ordered.append(path) return ordered def merge_specs(feature_number: str) -> str | None: """Merge all spec artifacts for a feature into one review file.""" specs_dir = Path("specs") if not specs_dir.exists(): print("Error: 'specs' directory not found.") return None # Find directory starting with feature number target_dir = None for item in specs_dir.iterdir(): if item.is_dir() and item.name.startswith(f"{feature_number}-"): target_dir = item break if not target_dir: print(f"Error: No directory found for feature number '{feature_number}' in 'specs/'.") return None feature_name = target_dir.name now = datetime.now().strftime("%Y%m%d-%H%M%S") output_filename = f"{feature_name}-{now}.md" content_blocks = [ REVIEW_PROMPT, "", "=" * 80, ORDER_HINT, f"Feature: {feature_name}", f"Generated: {datetime.now().isoformat()}", "=" * 80, "", ] files_to_merge = ordered_markdown_files(target_dir) for file_path in files_to_merge: relative_path = file_path.relative_to(target_dir) try: content = file_path.read_text(encoding="utf-8") except Exception as e: print(f"Skipping {file_path}: {e}") continue # Determine section label section_label = str(relative_path) for stage_type, stage_value, label in CANONICAL_MD_STAGES: if label is None: continue if stage_type == "exact" and str(relative_path) == stage_value: section_label = label break if stage_type == "prefix" and str(relative_path).startswith(stage_value): section_label = f"{label} — {relative_path.name}" break content_blocks.append("") content_blocks.append("-" * 60) content_blocks.append(f"## {section_label}") content_blocks.append(f"Source: {relative_path}") content_blocks.append("-" * 60) content_blocks.append("") content_blocks.append(content) content_blocks.append("") # Summary content_blocks.append("") content_blocks.append("=" * 80) content_blocks.append(f"Files merged: {len(files_to_merge)}") content_blocks.append("=" * 80) merged = "\n".join(content_blocks) output_filename = output_filename Path(output_filename).write_text(merged, encoding="utf-8") print(f"Successfully created: {output_filename} ({len(files_to_merge)} files)") return output_filename if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python merge_spec.py ") sys.exit(1) merge_specs(sys.argv[1]) # #endregion merge_spec # #endregion MergeSpec