Files
Claude-Code-Workflow/.claude/skills/team-lifecycle-v2/roles/reviewer/commands/spec-quality.md
catlog22 f0dda075f0 feat: Add templates for epics, product brief, and requirements PRD
- Introduced a comprehensive template for generating epics and stories, including an index and individual epic files.
- Created a product brief template to outline product vision, problem statements, and target users.
- Developed a requirements PRD template to structure functional and non-functional requirements, including traceability and prioritization.
- Implemented ast-grep processors for JavaScript and TypeScript to extract relationships such as imports and inheritance.
- Added corresponding patterns for JavaScript and TypeScript to support relationship extraction.
- Established comparison tests to validate the accuracy of relationship extraction between tree-sitter and ast-grep methods.
2026-02-18 12:02:02 +08:00

25 KiB

Spec Quality Command

Purpose

5-dimension spec quality check with readiness report generation and quality gate determination.

Quality Dimensions

1. Completeness (Weight: 25%)

function scoreCompleteness(specDocs) {
  const requiredSections = {
    "product-brief": [
      "Vision Statement",
      "Problem Statement",
      "Target Audience",
      "Success Metrics",
      "Constraints"
    ],
    "prd": [
      "Goals",
      "Requirements",
      "User Stories",
      "Acceptance Criteria",
      "Non-Functional Requirements"
    ],
    "architecture": [
      "System Overview",
      "Component Design",
      "Data Models",
      "API Specifications",
      "Technology Stack"
    ],
    "user-stories": [
      "Story List",
      "Acceptance Criteria",
      "Priority",
      "Estimation"
    ],
    "implementation-plan": [
      "Task Breakdown",
      "Dependencies",
      "Timeline",
      "Resource Allocation"
    ],
    "test-strategy": [
      "Test Scope",
      "Test Cases",
      "Coverage Goals",
      "Test Environment"
    ]
  }

  let totalScore = 0
  let totalWeight = 0
  const details = []

  for (const doc of specDocs) {
    const phase = doc.phase
    const expectedSections = requiredSections[phase] || []

    if (expectedSections.length === 0) continue

    let presentCount = 0
    let substantialCount = 0

    for (const section of expectedSections) {
      const sectionRegex = new RegExp(`##\\s+${section}`, "i")
      const sectionMatch = doc.content.match(sectionRegex)

      if (sectionMatch) {
        presentCount++

        // Check if section has substantial content (not just header)
        const sectionIndex = doc.content.indexOf(sectionMatch[0])
        const nextSectionIndex = doc.content.indexOf("\n##", sectionIndex + 1)
        const sectionContent = nextSectionIndex > -1
          ? doc.content.substring(sectionIndex, nextSectionIndex)
          : doc.content.substring(sectionIndex)

        // Substantial = more than 100 chars excluding header
        const contentWithoutHeader = sectionContent.replace(sectionRegex, "").trim()
        if (contentWithoutHeader.length > 100) {
          substantialCount++
        }
      }
    }

    const presentRatio = presentCount / expectedSections.length
    const substantialRatio = substantialCount / expectedSections.length

    // Score: 50% for presence, 50% for substance
    const docScore = (presentRatio * 50) + (substantialRatio * 50)

    totalScore += docScore
    totalWeight += 100

    details.push({
      phase: phase,
      score: docScore,
      present: presentCount,
      substantial: substantialCount,
      expected: expectedSections.length,
      missing: expectedSections.filter(s => !doc.content.match(new RegExp(`##\\s+${s}`, "i")))
    })
  }

  const overallScore = totalWeight > 0 ? (totalScore / totalWeight) * 100 : 0

  return {
    score: overallScore,
    weight: 25,
    weighted_score: overallScore * 0.25,
    details: details
  }
}

2. Consistency (Weight: 20%)

function scoreConsistency(specDocs) {
  const issues = []

  // 1. Terminology consistency
  const terminologyMap = new Map()

  for (const doc of specDocs) {
    // Extract key terms (capitalized phrases, technical terms)
    const terms = doc.content.match(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b/g) || []

    terms.forEach(term => {
      const normalized = term.toLowerCase()
      if (!terminologyMap.has(normalized)) {
        terminologyMap.set(normalized, new Set())
      }
      terminologyMap.get(normalized).add(term)
    })
  }

  // Find inconsistent terminology (same concept, different casing/spelling)
  terminologyMap.forEach((variants, normalized) => {
    if (variants.size > 1) {
      issues.push({
        type: "terminology",
        severity: "medium",
        message: `Inconsistent terminology: ${[...variants].join(", ")}`,
        suggestion: `Standardize to one variant`
      })
    }
  })

  // 2. Format consistency
  const headerStyles = new Map()
  for (const doc of specDocs) {
    const headers = doc.content.match(/^#{1,6}\s+.+$/gm) || []
    headers.forEach(header => {
      const level = header.match(/^#+/)[0].length
      const style = header.includes("**") ? "bold" : "plain"
      const key = `level-${level}`

      if (!headerStyles.has(key)) {
        headerStyles.set(key, new Set())
      }
      headerStyles.get(key).add(style)
    })
  }

  headerStyles.forEach((styles, level) => {
    if (styles.size > 1) {
      issues.push({
        type: "format",
        severity: "low",
        message: `Inconsistent header style at ${level}: ${[...styles].join(", ")}`,
        suggestion: "Use consistent header formatting"
      })
    }
  })

  // 3. Reference consistency
  const references = new Map()
  for (const doc of specDocs) {
    // Extract references to other documents/sections
    const refs = doc.content.match(/\[.*?\]\(.*?\)/g) || []
    refs.forEach(ref => {
      const linkMatch = ref.match(/\((.*?)\)/)
      if (linkMatch) {
        const link = linkMatch[1]
        if (!references.has(link)) {
          references.set(link, [])
        }
        references.get(link).push(doc.phase)
      }
    })
  }

  // Check for broken references
  references.forEach((sources, link) => {
    if (link.startsWith("./") || link.startsWith("../")) {
      // Check if file exists
      const exists = Bash(`test -f ${link}`).exitCode === 0
      if (!exists) {
        issues.push({
          type: "reference",
          severity: "high",
          message: `Broken reference: ${link} (referenced in ${sources.join(", ")})`,
          suggestion: "Fix or remove broken reference"
        })
      }
    }
  })

  // 4. Naming convention consistency
  const namingPatterns = {
    camelCase: /\b[a-z]+(?:[A-Z][a-z]+)+\b/g,
    PascalCase: /\b[A-Z][a-z]+(?:[A-Z][a-z]+)+\b/g,
    snake_case: /\b[a-z]+(?:_[a-z]+)+\b/g,
    kebab_case: /\b[a-z]+(?:-[a-z]+)+\b/g
  }

  const namingCounts = {}
  for (const doc of specDocs) {
    Object.entries(namingPatterns).forEach(([pattern, regex]) => {
      const matches = doc.content.match(regex) || []
      namingCounts[pattern] = (namingCounts[pattern] || 0) + matches.length
    })
  }

  const dominantPattern = Object.entries(namingCounts)
    .sort((a, b) => b[1] - a[1])[0]?.[0]

  Object.entries(namingCounts).forEach(([pattern, count]) => {
    if (pattern !== dominantPattern && count > 10) {
      issues.push({
        type: "naming",
        severity: "low",
        message: `Mixed naming conventions: ${pattern} (${count} occurrences) vs ${dominantPattern}`,
        suggestion: `Standardize to ${dominantPattern}`
      })
    }
  })

  // Calculate score based on issues
  const severityWeights = { high: 10, medium: 5, low: 2 }
  const totalPenalty = issues.reduce((sum, issue) => sum + severityWeights[issue.severity], 0)
  const maxPenalty = 100 // Arbitrary max for normalization

  const score = Math.max(0, 100 - (totalPenalty / maxPenalty) * 100)

  return {
    score: score,
    weight: 20,
    weighted_score: score * 0.20,
    issues: issues,
    details: {
      terminology_issues: issues.filter(i => i.type === "terminology").length,
      format_issues: issues.filter(i => i.type === "format").length,
      reference_issues: issues.filter(i => i.type === "reference").length,
      naming_issues: issues.filter(i => i.type === "naming").length
    }
  }
}

3. Traceability (Weight: 25%)

function scoreTraceability(specDocs) {
  const chains = []

  // Extract traceability elements
  const goals = extractElements(specDocs, "product-brief", /^[-*]\s+Goal:\s*(.+)$/gm)
  const requirements = extractElements(specDocs, "prd", /^[-*]\s+(?:REQ-\d+|Requirement):\s*(.+)$/gm)
  const components = extractElements(specDocs, "architecture", /^[-*]\s+(?:Component|Module):\s*(.+)$/gm)
  const stories = extractElements(specDocs, "user-stories", /^[-*]\s+(?:US-\d+|Story):\s*(.+)$/gm)

  // Build traceability chains: Goals → Requirements → Components → Stories
  for (const goal of goals) {
    const chain = {
      goal: goal.text,
      requirements: [],
      components: [],
      stories: [],
      complete: false
    }

    // Find requirements that reference this goal
    const goalKeywords = extractKeywords(goal.text)
    for (const req of requirements) {
      if (hasKeywordOverlap(req.text, goalKeywords, 0.3)) {
        chain.requirements.push(req.text)

        // Find components that implement this requirement
        const reqKeywords = extractKeywords(req.text)
        for (const comp of components) {
          if (hasKeywordOverlap(comp.text, reqKeywords, 0.3)) {
            chain.components.push(comp.text)
          }
        }

        // Find stories that implement this requirement
        for (const story of stories) {
          if (hasKeywordOverlap(story.text, reqKeywords, 0.3)) {
            chain.stories.push(story.text)
          }
        }
      }
    }

    // Check if chain is complete
    chain.complete = chain.requirements.length > 0 &&
                     chain.components.length > 0 &&
                     chain.stories.length > 0

    chains.push(chain)
  }

  // Calculate score
  const completeChains = chains.filter(c => c.complete).length
  const totalChains = chains.length
  const score = totalChains > 0 ? (completeChains / totalChains) * 100 : 0

  // Identify weak links
  const weakLinks = []
  chains.forEach((chain, idx) => {
    if (!chain.complete) {
      if (chain.requirements.length === 0) {
        weakLinks.push(`Goal ${idx + 1} has no linked requirements`)
      }
      if (chain.components.length === 0) {
        weakLinks.push(`Goal ${idx + 1} has no linked components`)
      }
      if (chain.stories.length === 0) {
        weakLinks.push(`Goal ${idx + 1} has no linked stories`)
      }
    }
  })

  return {
    score: score,
    weight: 25,
    weighted_score: score * 0.25,
    details: {
      total_chains: totalChains,
      complete_chains: completeChains,
      weak_links: weakLinks
    },
    chains: chains
  }
}

function extractElements(specDocs, phase, regex) {
  const elements = []
  const doc = specDocs.find(d => d.phase === phase)

  if (doc) {
    let match
    while ((match = regex.exec(doc.content)) !== null) {
      elements.push({
        text: match[1].trim(),
        phase: phase
      })
    }
  }

  return elements
}

function extractKeywords(text) {
  // Extract meaningful words (4+ chars, not common words)
  const commonWords = new Set(["that", "this", "with", "from", "have", "will", "should", "must", "can"])
  const words = text.toLowerCase().match(/\b\w{4,}\b/g) || []
  return words.filter(w => !commonWords.has(w))
}

function hasKeywordOverlap(text, keywords, threshold) {
  const textLower = text.toLowerCase()
  const matchCount = keywords.filter(kw => textLower.includes(kw)).length
  return matchCount / keywords.length >= threshold
}

4. Depth (Weight: 20%)

function scoreDepth(specDocs) {
  const dimensions = []

  // 1. Acceptance Criteria Testability
  const acDoc = specDocs.find(d => d.phase === "prd" || d.phase === "user-stories")
  if (acDoc) {
    const acMatches = acDoc.content.match(/Acceptance Criteria:[\s\S]*?(?=\n##|\n\n[-*]|$)/gi) || []
    let testableCount = 0
    let totalCount = 0

    acMatches.forEach(section => {
      const criteria = section.match(/^[-*]\s+(.+)$/gm) || []
      totalCount += criteria.length

      criteria.forEach(criterion => {
        // Testable if contains measurable verbs or specific conditions
        const testablePatterns = [
          /\b(should|must|will)\s+(display|show|return|validate|check|verify|calculate|send|receive)\b/i,
          /\b(when|if|given)\b.*\b(then|should|must)\b/i,
          /\b\d+\b/, // Contains numbers (measurable)
          /\b(success|error|fail|pass)\b/i
        ]

        const isTestable = testablePatterns.some(pattern => pattern.test(criterion))
        if (isTestable) testableCount++
      })
    })

    const acScore = totalCount > 0 ? (testableCount / totalCount) * 100 : 0
    dimensions.push({
      name: "Acceptance Criteria Testability",
      score: acScore,
      testable: testableCount,
      total: totalCount
    })
  }

  // 2. ADR Justification
  const archDoc = specDocs.find(d => d.phase === "architecture")
  if (archDoc) {
    const adrMatches = archDoc.content.match(/##\s+(?:ADR|Decision)[\s\S]*?(?=\n##|$)/gi) || []
    let justifiedCount = 0
    let totalCount = adrMatches.length

    adrMatches.forEach(adr => {
      // Justified if contains rationale, alternatives, or consequences
      const hasJustification = adr.match(/\b(rationale|reason|because|alternative|consequence|trade-?off)\b/i)
      if (hasJustification) justifiedCount++
    })

    const adrScore = totalCount > 0 ? (justifiedCount / totalCount) * 100 : 100 // Default 100 if no ADRs
    dimensions.push({
      name: "ADR Justification",
      score: adrScore,
      justified: justifiedCount,
      total: totalCount
    })
  }

  // 3. User Stories Estimability
  const storiesDoc = specDocs.find(d => d.phase === "user-stories")
  if (storiesDoc) {
    const storyMatches = storiesDoc.content.match(/^[-*]\s+(?:US-\d+|Story)[\s\S]*?(?=\n[-*]|$)/gim) || []
    let estimableCount = 0
    let totalCount = storyMatches.length

    storyMatches.forEach(story => {
      // Estimable if has clear scope, AC, and no ambiguity
      const hasScope = story.match(/\b(as a|I want|so that)\b/i)
      const hasAC = story.match(/acceptance criteria/i)
      const hasEstimate = story.match(/\b(points?|hours?|days?|estimate)\b/i)

      if ((hasScope && hasAC) || hasEstimate) estimableCount++
    })

    const storiesScore = totalCount > 0 ? (estimableCount / totalCount) * 100 : 0
    dimensions.push({
      name: "User Stories Estimability",
      score: storiesScore,
      estimable: estimableCount,
      total: totalCount
    })
  }

  // 4. Technical Detail Sufficiency
  const techDocs = specDocs.filter(d => d.phase === "architecture" || d.phase === "implementation-plan")
  let detailScore = 0

  if (techDocs.length > 0) {
    const detailIndicators = [
      /```[\s\S]*?```/, // Code blocks
      /\b(API|endpoint|schema|model|interface|class|function)\b/i,
      /\b(GET|POST|PUT|DELETE|PATCH)\b/, // HTTP methods
      /\b(database|table|collection|index)\b/i,
      /\b(authentication|authorization|security)\b/i
    ]

    let indicatorCount = 0
    techDocs.forEach(doc => {
      detailIndicators.forEach(pattern => {
        if (pattern.test(doc.content)) indicatorCount++
      })
    })

    detailScore = Math.min(100, (indicatorCount / (detailIndicators.length * techDocs.length)) * 100)
    dimensions.push({
      name: "Technical Detail Sufficiency",
      score: detailScore,
      indicators_found: indicatorCount,
      indicators_expected: detailIndicators.length * techDocs.length
    })
  }

  // Calculate overall depth score
  const overallScore = dimensions.reduce((sum, d) => sum + d.score, 0) / dimensions.length

  return {
    score: overallScore,
    weight: 20,
    weighted_score: overallScore * 0.20,
    dimensions: dimensions
  }
}

5. Requirement Coverage (Weight: 10%)

function scoreRequirementCoverage(specDocs, originalRequirements) {
  // Extract original requirements from task description or initial brief
  const originalReqs = originalRequirements || extractOriginalRequirements(specDocs)

  if (originalReqs.length === 0) {
    return {
      score: 100, // No requirements to cover
      weight: 10,
      weighted_score: 10,
      details: {
        total: 0,
        covered: 0,
        uncovered: []
      }
    }
  }

  // Extract all requirements from spec documents
  const specReqs = []
  for (const doc of specDocs) {
    const reqMatches = doc.content.match(/^[-*]\s+(?:REQ-\d+|Requirement|Feature):\s*(.+)$/gm) || []
    reqMatches.forEach(match => {
      specReqs.push(match.replace(/^[-*]\s+(?:REQ-\d+|Requirement|Feature):\s*/, "").trim())
    })
  }

  // Map original requirements to spec requirements
  const coverage = []
  for (const origReq of originalReqs) {
    const keywords = extractKeywords(origReq)
    const covered = specReqs.some(specReq => hasKeywordOverlap(specReq, keywords, 0.4))

    coverage.push({
      requirement: origReq,
      covered: covered
    })
  }

  const coveredCount = coverage.filter(c => c.covered).length
  const score = (coveredCount / originalReqs.length) * 100

  return {
    score: score,
    weight: 10,
    weighted_score: score * 0.10,
    details: {
      total: originalReqs.length,
      covered: coveredCount,
      uncovered: coverage.filter(c => !c.covered).map(c => c.requirement)
    }
  }
}

function extractOriginalRequirements(specDocs) {
  // Try to find original requirements in product brief
  const briefDoc = specDocs.find(d => d.phase === "product-brief")
  if (!briefDoc) return []

  const reqSection = briefDoc.content.match(/##\s+(?:Requirements|Objectives)[\s\S]*?(?=\n##|$)/i)
  if (!reqSection) return []

  const reqs = reqSection[0].match(/^[-*]\s+(.+)$/gm) || []
  return reqs.map(r => r.replace(/^[-*]\s+/, "").trim())
}

Quality Gate Determination

function determineQualityGate(overallScore, coverageScore) {
  // PASS: Score ≥80% AND coverage ≥70%
  if (overallScore >= 80 && coverageScore >= 70) {
    return {
      gate: "PASS",
      message: "Specification meets quality standards and is ready for implementation",
      action: "Proceed to implementation phase"
    }
  }

  // FAIL: Score <60% OR coverage <50%
  if (overallScore < 60 || coverageScore < 50) {
    return {
      gate: "FAIL",
      message: "Specification requires major revisions before implementation",
      action: "Address critical gaps and resubmit for review"
    }
  }

  // REVIEW: Between PASS and FAIL
  return {
    gate: "REVIEW",
    message: "Specification needs improvements but may proceed with caution",
    action: "Address recommendations and consider re-review"
  }
}

Readiness Report Generation

function formatReadinessReport(report, specDocs) {
  const { overall_score, quality_gate, dimensions, phase_gates } = report

  let markdown = `# Specification Readiness Report\n\n`
  markdown += `**Generated**: ${new Date().toISOString()}\n\n`
  markdown += `**Overall Score**: ${overall_score.toFixed(1)}%\n\n`
  markdown += `**Quality Gate**: ${quality_gate.gate} - ${quality_gate.message}\n\n`
  markdown += `**Recommended Action**: ${quality_gate.action}\n\n`

  markdown += `---\n\n`

  markdown += `## Dimension Scores\n\n`
  markdown += `| Dimension | Score | Weight | Weighted Score |\n`
  markdown += `|-----------|-------|--------|----------------|\n`

  Object.entries(dimensions).forEach(([name, data]) => {
    markdown += `| ${name} | ${data.score.toFixed(1)}% | ${data.weight}% | ${data.weighted_score.toFixed(1)}% |\n`
  })

  markdown += `\n---\n\n`

  // Completeness Details
  markdown += `## Completeness Analysis\n\n`
  dimensions.completeness.details.forEach(detail => {
    markdown += `### ${detail.phase}\n`
    markdown += `- Score: ${detail.score.toFixed(1)}%\n`
    markdown += `- Sections Present: ${detail.present}/${detail.expected}\n`
    markdown += `- Substantial Content: ${detail.substantial}/${detail.expected}\n`
    if (detail.missing.length > 0) {
      markdown += `- Missing: ${detail.missing.join(", ")}\n`
    }
    markdown += `\n`
  })

  // Consistency Details
  markdown += `## Consistency Analysis\n\n`
  if (dimensions.consistency.issues.length > 0) {
    markdown += `**Issues Found**: ${dimensions.consistency.issues.length}\n\n`
    dimensions.consistency.issues.forEach(issue => {
      markdown += `- **${issue.severity.toUpperCase()}**: ${issue.message}\n`
      markdown += `  *Suggestion*: ${issue.suggestion}\n\n`
    })
  } else {
    markdown += `No consistency issues found.\n\n`
  }

  // Traceability Details
  markdown += `## Traceability Analysis\n\n`
  markdown += `- Complete Chains: ${dimensions.traceability.details.complete_chains}/${dimensions.traceability.details.total_chains}\n\n`
  if (dimensions.traceability.details.weak_links.length > 0) {
    markdown += `**Weak Links**:\n`
    dimensions.traceability.details.weak_links.forEach(link => {
      markdown += `- ${link}\n`
    })
    markdown += `\n`
  }

  // Depth Details
  markdown += `## Depth Analysis\n\n`
  dimensions.depth.dimensions.forEach(dim => {
    markdown += `### ${dim.name}\n`
    markdown += `- Score: ${dim.score.toFixed(1)}%\n`
    if (dim.testable !== undefined) {
      markdown += `- Testable: ${dim.testable}/${dim.total}\n`
    }
    if (dim.justified !== undefined) {
      markdown += `- Justified: ${dim.justified}/${dim.total}\n`
    }
    if (dim.estimable !== undefined) {
      markdown += `- Estimable: ${dim.estimable}/${dim.total}\n`
    }
    markdown += `\n`
  })

  // Coverage Details
  markdown += `## Requirement Coverage\n\n`
  markdown += `- Covered: ${dimensions.coverage.details.covered}/${dimensions.coverage.details.total}\n`
  if (dimensions.coverage.details.uncovered.length > 0) {
    markdown += `\n**Uncovered Requirements**:\n`
    dimensions.coverage.details.uncovered.forEach(req => {
      markdown += `- ${req}\n`
    })
  }
  markdown += `\n`

  // Phase Gates
  if (phase_gates) {
    markdown += `---\n\n`
    markdown += `## Phase-Level Quality Gates\n\n`
    Object.entries(phase_gates).forEach(([phase, gate]) => {
      markdown += `### ${phase}\n`
      markdown += `- Gate: ${gate.status}\n`
      markdown += `- Score: ${gate.score.toFixed(1)}%\n`
      if (gate.issues.length > 0) {
        markdown += `- Issues: ${gate.issues.join(", ")}\n`
      }
      markdown += `\n`
    })
  }

  return markdown
}

Spec Summary Generation

function formatSpecSummary(specDocs, report) {
  let markdown = `# Specification Summary\n\n`

  markdown += `**Overall Quality Score**: ${report.overall_score.toFixed(1)}%\n`
  markdown += `**Quality Gate**: ${report.quality_gate.gate}\n\n`

  markdown += `---\n\n`

  // Document Overview
  markdown += `## Documents Reviewed\n\n`
  specDocs.forEach(doc => {
    markdown += `### ${doc.phase}\n`
    markdown += `- Path: ${doc.path}\n`
    markdown += `- Size: ${doc.content.length} characters\n`

    // Extract key sections
    const sections = doc.content.match(/^##\s+(.+)$/gm) || []
    if (sections.length > 0) {
      markdown += `- Sections: ${sections.map(s => s.replace(/^##\s+/, "")).join(", ")}\n`
    }
    markdown += `\n`
  })

  markdown += `---\n\n`

  // Key Findings
  markdown += `## Key Findings\n\n`

  // Strengths
  const strengths = []
  Object.entries(report.dimensions).forEach(([name, data]) => {
    if (data.score >= 80) {
      strengths.push(`${name}: ${data.score.toFixed(1)}%`)
    }
  })

  if (strengths.length > 0) {
    markdown += `### Strengths\n`
    strengths.forEach(s => markdown += `- ${s}\n`)
    markdown += `\n`
  }

  // Areas for Improvement
  const improvements = []
  Object.entries(report.dimensions).forEach(([name, data]) => {
    if (data.score < 70) {
      improvements.push(`${name}: ${data.score.toFixed(1)}%`)
    }
  })

  if (improvements.length > 0) {
    markdown += `### Areas for Improvement\n`
    improvements.forEach(i => markdown += `- ${i}\n`)
    markdown += `\n`
  }

  // Recommendations
  if (report.recommendations && report.recommendations.length > 0) {
    markdown += `### Recommendations\n`
    report.recommendations.forEach((rec, i) => {
      markdown += `${i + 1}. ${rec}\n`
    })
    markdown += `\n`
  }

  return markdown
}

Phase-Level Quality Gates

function calculatePhaseGates(specDocs) {
  const gates = {}

  for (const doc of specDocs) {
    const phase = doc.phase
    const issues = []
    let score = 100

    // Check minimum content threshold
    if (doc.content.length < 500) {
      issues.push("Insufficient content")
      score -= 30
    }

    // Check for required sections (phase-specific)
    const requiredSections = getRequiredSections(phase)
    const missingSections = requiredSections.filter(section =>
      !doc.content.match(new RegExp(`##\\s+${section}`, "i"))
    )

    if (missingSections.length > 0) {
      issues.push(`Missing sections: ${missingSections.join(", ")}`)
      score -= missingSections.length * 15
    }

    // Determine gate status
    let status = "PASS"
    if (score < 60) status = "FAIL"
    else if (score < 80) status = "REVIEW"

    gates[phase] = {
      status: status,
      score: Math.max(0, score),
      issues: issues
    }
  }

  return gates
}

function getRequiredSections(phase) {
  const sectionMap = {
    "product-brief": ["Vision", "Problem", "Target Audience"],
    "prd": ["Goals", "Requirements", "User Stories"],
    "architecture": ["Overview", "Components", "Data Models"],
    "user-stories": ["Stories", "Acceptance Criteria"],
    "implementation-plan": ["Tasks", "Dependencies"],
    "test-strategy": ["Test Cases", "Coverage"]
  }

  return sectionMap[phase] || []
}