scripts/internal/lens-applicability.ps1

<#
.SYNOPSIS
  Deterministic design-analysis lens applicability selector (Feature 141 Iteration 4 / FR-025).
 
  Pure functions: given the decoupled sibling applicability-map (always-on foundational lenses +
  per-question gated specialized lenses) and the recorded questionnaire answers, compute the
  selected lens set. Selection is a deterministic function of (map, answers) — identical answers
  always yield the identical ordered set. No network, no LLM; the only judgment input is the
  recorded answers. The Proposal 156 catalog `index.yml` is NOT read or modified here (decoupled).
#>


Set-StrictMode -Version Latest

function Read-SpecrewLensApplicabilityMap {
    param([Parameter(Mandatory = $true)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
    try {
        return (Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json)
    }
    catch {
        return $null
    }
}

function Read-SpecrewLensAnswers {
    # Reads the lens-applicability.json artifact; returns the inner `answers` object (or $null).
    param([Parameter(Mandatory = $true)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
    try {
        $doc = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
        if ($null -ne $doc -and $doc.PSObject.Properties['answers']) { return $doc.answers }
        return $null
    }
    catch {
        return $null
    }
}

function Test-SpecrewLensAnswerYes {
    param([AllowNull()]$Value)

    if ($null -eq $Value) { return $false }
    if ($Value -is [bool]) { return [bool]$Value }
    return (([string]$Value).Trim() -match '^(?i:yes|true|y)$')
}

function Get-SpecrewAnswerValue {
    param([AllowNull()]$Answers, [Parameter(Mandatory = $true)][string]$Key)

    if ($null -eq $Answers) { return $null }
    if ($Answers -is [System.Collections.IDictionary]) {
        if ($Answers.Contains($Key)) { return $Answers[$Key] }
        return $null
    }
    $prop = $Answers.PSObject.Properties[$Key]
    if ($prop) { return $prop.Value }
    return $null
}

function Get-SpecrewApplicableLenses {
    # Pure deterministic selector: always-on (foundational) + specialized lenses gated by a yes answer.
    # Order: always-on first (map order), then gated lenses in question order. Deduplicated.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()]$Map,
        [Parameter(Mandatory = $true)][AllowNull()]$Answers
    )

    $selected = [System.Collections.Generic.List[string]]::new()
    # Graceful degradation (SC-006): no map (catalog absent) OR no answers (questionnaire not
    # answered) -> none available. Foundational always-on lenses apply only once the questionnaire
    # has been answered, so an absent questionnaire does not fabricate a selection.
    if ($null -eq $Map -or $null -eq $Answers) { return @() }

    foreach ($lens in @($Map.always_on)) {
        $id = [string]$lens
        if (-not [string]::IsNullOrWhiteSpace($id) -and -not $selected.Contains($id)) {
            $selected.Add($id) | Out-Null
        }
    }

    foreach ($q in @($Map.questions)) {
        if (Test-SpecrewLensAnswerYes -Value (Get-SpecrewAnswerValue -Answers $Answers -Key ([string]$q.id))) {
            foreach ($g in @($q.gates)) {
                $gid = [string]$g
                if (-not [string]::IsNullOrWhiteSpace($gid) -and -not $selected.Contains($gid)) {
                    $selected.Add($gid) | Out-Null
                }
            }
        }
    }

    return $selected.ToArray()
}

function Get-SpecrewLensSelection {
    # Audit wrapper: selected set + per-lens include/exclude rationale (for the JSON + render).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()]$Map,
        [Parameter(Mandatory = $true)][AllowNull()]$Answers
    )

    $selected = @(Get-SpecrewApplicableLenses -Map $Map -Answers $Answers)
    $included = [System.Collections.Generic.List[object]]::new()
    $excluded = [System.Collections.Generic.List[object]]::new()

    if ($null -ne $Map -and $null -ne $Answers) {
        foreach ($lens in @($Map.always_on)) {
            if (-not [string]::IsNullOrWhiteSpace([string]$lens)) {
                $included.Add([pscustomobject]@{ id = [string]$lens; reason = 'always-on (foundational)' }) | Out-Null
            }
        }
        foreach ($q in @($Map.questions)) {
            $qid = [string]$q.id
            $yes = Test-SpecrewLensAnswerYes -Value (Get-SpecrewAnswerValue -Answers $Answers -Key $qid)
            foreach ($g in @($q.gates)) {
                $entry = [pscustomobject]@{ id = [string]$g; reason = ("gated by '{0}' = {1}" -f $qid, $(if ($yes) { 'yes' } else { 'no' })) }
                if ($yes) { $included.Add($entry) | Out-Null } else { $excluded.Add($entry) | Out-Null }
            }
        }
    }

    return [pscustomobject]@{
        selected = $selected
        included = $included.ToArray()
        excluded = $excluded.ToArray()
    }
}

function New-SpecrewLensApplicabilityTemplate {
    # T002: emit a lens-applicability.json template (questions + empty answers) from the map for the
    # design-analysis questionnaire. Does not overwrite an existing file unless -Force. Returns the path.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()]$Map,
        [Parameter(Mandatory = $true)][string]$OutPath,
        [switch]$Force
    )

    if ($null -eq $Map) { return $null }
    if ((Test-Path -LiteralPath $OutPath -PathType Leaf) -and -not $Force) { return $OutPath }

    $answers = [ordered]@{}
    $questions = [System.Collections.Generic.List[object]]::new()
    foreach ($q in @($Map.questions)) {
        $answers[[string]$q.id] = $false
        $questions.Add([ordered]@{ id = [string]$q.id; prompt = [string]$q.prompt }) | Out-Null
    }

    $doc = [ordered]@{
        schema    = 'v1'
        note      = 'Answer each question true/false (the design-analysis applicability questionnaire). Lens selection is then a deterministic function of these answers + the sibling applicability-map.json.'
        questions = $questions.ToArray()
        answers   = $answers
        selected  = @()
    }

    $parent = Split-Path -Parent $OutPath
    if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent -PathType Container)) {
        $null = New-Item -ItemType Directory -Path $parent -Force
    }
    [System.IO.File]::WriteAllText($OutPath, ($doc | ConvertTo-Json -Depth 6), [System.Text.UTF8Encoding]::new($false))
    return $OutPath
}

function Get-SpecrewLensDecisionPoints {
    # T001 (FR-009): pure extractor of a lens file's "## Design Decision Points" bullets so the
    # design analysis can be genuinely informed by the lens knowledge (not just named). Returns an
    # ordered string[] of decision points with continuation lines folded into their bullet. Graceful
    # @() when the catalog dir, the lens file, or the section is absent. No network/LLM; read-only.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$LensId,
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$CatalogDir
    )

    if ([string]::IsNullOrWhiteSpace($LensId) -or [string]::IsNullOrWhiteSpace($CatalogDir)) { return @() }
    $path = Join-Path $CatalogDir ('{0}.md' -f $LensId)
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return @() }

    $content = ''
    try { $content = Get-Content -LiteralPath $path -Raw -Encoding UTF8 }
    catch { return @() }
    if ([string]::IsNullOrWhiteSpace($content)) { return @() }

    # Isolate the "## Design Decision Points" section body (until the next ## heading or EOF).
    $section = [regex]::Match($content, '(?ims)^##\s+Design\s+Decision\s+Points\s*$\r?\n(?<body>.*?)(?=^##\s+|\z)')
    if (-not $section.Success) { return @() }

    $points = [System.Collections.Generic.List[string]]::new()
    $current = $null
    foreach ($rawLine in ($section.Groups['body'].Value -split '\r?\n')) {
        if ($rawLine -match '^\s*[-*]\s+(.*)$') {
            if ($null -ne $current) { $points.Add(($current -replace '\s+', ' ').Trim()) | Out-Null }
            $current = $Matches[1].Trim()
        }
        elseif ($null -ne $current -and $rawLine -match '^\s+\S') {
            # Indented continuation of the current bullet -> fold it in.
            $current = ('{0} {1}' -f $current, $rawLine.Trim())
        }
        else {
            # Blank line or non-indented prose ends the current bullet.
            if ($null -ne $current) { $points.Add(($current -replace '\s+', ' ').Trim()) | Out-Null }
            $current = $null
        }
    }
    if ($null -ne $current) { $points.Add(($current -replace '\s+', ' ').Trim()) | Out-Null }

    return $points.ToArray()
}

function Get-SpecrewLensQuestionDepth {
    # T001 (FR-025 / SC-018): map a material lens-question area + the user-profile expertise dials to
    # an interaction depth, so the lens intake adapts question depth to the human's expertise (the
    # F-016 interaction model). Returns 'expert-terse' (dial >= 8 — ask a concise expert question and
    # assume the human decides), 'guided-explain' (dial <= 3 — explain the area and recommend a
    # default), or 'moderate' (in between, or as the fail-safe when the dial/profile is absent).
    # Pure + deterministic; no network/LLM.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()]$ExpertiseDials,
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Area
    )

    # Each material lens area maps to the most-relevant persona-lens dial; architect is the technical
    # default for areas without a dedicated dial.
    $areaToPersona = @{
        ui          = 'ux-ui-specialist'
        security    = 'architect'
        data        = 'architect'
        integration = 'architect'
        ops         = 'ai-researcher-project-manager'
        perf        = 'architect'
        architecture = 'architect'
    }

    $key = if ([string]::IsNullOrWhiteSpace($Area)) { '' } else { $Area.Trim().ToLowerInvariant() }
    $persona = if ($areaToPersona.ContainsKey($key)) { $areaToPersona[$key] } else { 'architect' }

    $dial = $null
    if ($null -ne $ExpertiseDials) {
        if ($ExpertiseDials -is [System.Collections.IDictionary]) {
            if ($ExpertiseDials.Contains($persona)) { $dial = $ExpertiseDials[$persona] }
        }
        else {
            $prop = $ExpertiseDials.PSObject.Properties[$persona]
            if ($prop) { $dial = $prop.Value }
        }
    }

    $value = 0
    if ($null -ne $dial -and [int]::TryParse([string]$dial, [ref]$value)) {
        if ($value -ge 8) { return 'expert-terse' }
        if ($value -le 3) { return 'guided-explain' }
        return 'moderate'
    }
    return 'moderate'  # fail-safe: absent/unparseable dial -> moderate depth
}

function Get-SpecrewLensWorkshopAgenda {
    # Iteration 7 T001 (FR-009 / FR-025, Amendment A4): produce the per-lens workshop AGENDA — the
    # ordered design questions the Crew raises for one lens during the facilitated intake. The agenda IS
    # the lens's "## Design Decision Points" (reused via Get-SpecrewLensDecisionPoints) surfaced as the
    # discussion the coordinator runs; the architecture-book phrasing lives in the lens files, so this
    # stays a pure, deterministic, LLM/network-free surfacing — no new parallel question bank. Graceful
    # @() when the lens/section/catalog is absent. This agenda is what the SC-021 per-lens record stores.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$LensId,
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$CatalogDir
    )

    return @(Get-SpecrewLensDecisionPoints -LensId $LensId -CatalogDir $CatalogDir)
}

function Format-SpecrewLensWorkshopAgenda {
    # Iteration 7 T001 (FR-025, Amendment A4): render the human-visible "## Workshop Agenda" the Crew
    # surfaces during the per-lens facilitated intake — for each selected lens, its decision-point
    # questions as a numbered discussion agenda, with a per-lens decision/agreement line to capture and
    # a "move on" marker. Markdownlint-safe (asterisk emphasis; no '+'-at-line-start). Graceful
    # "None available" when nothing is selectable. Pure + deterministic; no network/LLM.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()]$SelectedLenses,
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$CatalogDir
    )

    $lenses = @($SelectedLenses | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('## Workshop Agenda')
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('For each applicable lens the Crew raises these design questions, adapts depth to your expertise, and records your decision and explicit agreement before moving on to the next lens.')
    [void]$sb.AppendLine('')

    if ($lenses.Count -eq 0) {
        [void]$sb.AppendLine('*None available* (no lenses selected, or the catalog is absent).')
        return $sb.ToString().TrimEnd()
    }

    foreach ($lens in $lenses) {
        [void]$sb.AppendLine(('### {0}' -f $lens))
        [void]$sb.AppendLine('')
        $agenda = @(Get-SpecrewLensWorkshopAgenda -LensId ([string]$lens) -CatalogDir $CatalogDir)
        if ($agenda.Count -eq 0) {
            [void]$sb.AppendLine('*No decision points found for this lens* (discuss its scope directly).')
        }
        else {
            $n = 1
            foreach ($item in $agenda) {
                [void]$sb.AppendLine(('{0}. {1}' -f $n, $item))
                $n++
            }
        }
        [void]$sb.AppendLine('')
        [void]$sb.AppendLine('- Decision / agreement: <captured during the workshop>')
        [void]$sb.AppendLine('- Depth used: <expert-terse | moderate | guided-explain>')
        [void]$sb.AppendLine('- Moved on: <yes, on the human''s confirmation>')
        [void]$sb.AppendLine('')
    }

    return $sb.ToString().TrimEnd()
}

function Format-SpecrewLensWorkshopDecisions {
    # Iteration 7 T004 (FR-009, Amendment A4): surface the RECORDED per-lens workshop decisions from a
    # lens-applicability.json so the workshop output concretely flows into the design analysis and plan
    # (not only the decision points). Reads the `workshop` records for the `selected` lenses. Graceful
    # "None recorded" when absent. Pure + deterministic; no network/LLM; markdownlint-safe.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$ArtifactPath
    )

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('## Lens Decisions (recorded in the workshop)')
    [void]$sb.AppendLine('')

    $doc = $null
    if (-not [string]::IsNullOrWhiteSpace($ArtifactPath) -and (Test-Path -LiteralPath $ArtifactPath -PathType Leaf)) {
        try { $doc = Get-Content -LiteralPath $ArtifactPath -Raw -Encoding UTF8 | ConvertFrom-Json }
        catch { $doc = $null }
    }

    $selected = @()
    if ($null -ne $doc -and $doc.PSObject.Properties['selected']) {
        $selected = @($doc.selected | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    }
    $workshop = if ($null -ne $doc -and $doc.PSObject.Properties['workshop']) { $doc.workshop } else { $null }

    if ($selected.Count -eq 0 -or $null -eq $workshop) {
        [void]$sb.AppendLine('*None recorded* (no workshop decisions in the intake artifact).')
        return $sb.ToString().TrimEnd()
    }

    foreach ($id in $selected) {
        $rec = if ($workshop.PSObject.Properties[$id]) { $workshop.$id } else { $null }
        $decision = if ($null -ne $rec -and $rec.PSObject.Properties['decision'] -and -not [string]::IsNullOrWhiteSpace([string]$rec.decision)) { [string]$rec.decision } else { '*not recorded*' }
        $depth = if ($null -ne $rec -and $rec.PSObject.Properties['depth'] -and -not [string]::IsNullOrWhiteSpace([string]$rec.depth)) { [string]$rec.depth } else { 'unknown' }
        [void]$sb.AppendLine(('- **{0}** (depth: {1}): {2}' -f $id, $depth, $decision))
    }

    return $sb.ToString().TrimEnd()
}

function Get-SpecrewLensDiagramType {
    # Iteration 8 T001 (FR-030, Amendment A5): read the per-lens diagram vocabulary
    # (diagram-vocabulary.json, a sibling to applicability-map.json) and return the diagram type + default
    # render form for a lens, so a lens's workshop discussion can be made concrete with its native diagram.
    # Graceful $null when the catalog dir, the file, or the lens entry is absent. Pure + deterministic; no
    # network/LLM. index.yml stays pure (the vocabulary is a decoupled sibling).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$LensId,
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$CatalogDir
    )

    if ([string]::IsNullOrWhiteSpace($LensId) -or [string]::IsNullOrWhiteSpace($CatalogDir)) { return $null }
    $path = Join-Path $CatalogDir 'diagram-vocabulary.json'
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null }

    $doc = $null
    try { $doc = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json }
    catch { return $null }
    if ($null -eq $doc) { return $null }

    $key = $LensId.Trim()
    foreach ($group in @('lenses', 'cross_cutting')) {
        if ($doc.PSObject.Properties[$group] -and $doc.$group.PSObject.Properties[$key]) {
            $entry = $doc.$group.$key
            $dt = if ($entry.PSObject.Properties['diagram_type']) { [string]$entry.diagram_type } else { $null }
            $rf = if ($entry.PSObject.Properties['render_form']) { [string]$entry.render_form } else { $null }
            return [pscustomobject]@{ LensId = $key; DiagramType = $dt; RenderForm = $rf }
        }
    }
    return $null
}

function Format-SpecrewWorkshopVisual {
    # Iteration 8 T002 (FR-031/FR-033, Amendment A5): the deterministic emit half of the visuals whiteboard.
    # The agent authors the diagram CONTENT (behavioral); THIS surfaces it in one of three tiers:
    # inline -> a fenced block for quick console display (mermaid/text fence; a table is emitted raw)
    # temp -> writes Content to DestinationPath (under .specrew/workshop-visuals/) and returns a
    # clickable file:/// reference (FR-028 console form — forward slashes)
    # persisted -> a keeper: mermaid/ascii/table return the inline block to embed in the design doc;
    # svg/html are written to DestinationPath and returned as a markdown link (FR-028 persisted)
    # Pure for inline; writes a file for temp / persisted-svg-html. LLM/network-free. Throws CLEARLY when a
    # destination is required but absent (no silent no-op).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Content,
        [Parameter(Mandatory = $true)][ValidateSet('mermaid', 'ascii', 'table', 'svg', 'html')][string]$RenderForm,
        [Parameter(Mandatory = $true)][ValidateSet('inline', 'temp', 'persisted')][string]$Tier,
        [AllowNull()][string]$DestinationPath
    )

    $fence = {
        param($c, $rf)
        switch ($rf) {
            'mermaid' { "``````mermaid`n$c`n``````" }
            'table' { $c }
            default { "``````text`n$c`n``````" }
        }
    }

    if ($Tier -eq 'inline') { return (& $fence $Content $RenderForm) }

    if ($Tier -eq 'persisted') {
        if ($RenderForm -in @('svg', 'html')) {
            if ([string]::IsNullOrWhiteSpace($DestinationPath)) { throw 'Format-SpecrewWorkshopVisual: persisted svg/html requires -DestinationPath.' }
            $pdir = Split-Path -Parent $DestinationPath
            if (-not [string]::IsNullOrWhiteSpace($pdir) -and -not (Test-Path -LiteralPath $pdir -PathType Container)) { $null = New-Item -ItemType Directory -Path $pdir -Force }
            [System.IO.File]::WriteAllText($DestinationPath, $Content, [System.Text.UTF8Encoding]::new($false))
            return ('[diagram]({0})' -f ($DestinationPath -replace '\\', '/'))
        }
        return (& $fence $Content $RenderForm)
    }

    # temp tier
    if ([string]::IsNullOrWhiteSpace($DestinationPath)) { throw 'Format-SpecrewWorkshopVisual: temp tier requires -DestinationPath (under .specrew/workshop-visuals/).' }
    $tdir = Split-Path -Parent $DestinationPath
    if (-not [string]::IsNullOrWhiteSpace($tdir) -and -not (Test-Path -LiteralPath $tdir -PathType Container)) { $null = New-Item -ItemType Directory -Path $tdir -Force }
    [System.IO.File]::WriteAllText($DestinationPath, $Content, [System.Text.UTF8Encoding]::new($false))
    return ('file:///{0}' -f ($DestinationPath -replace '\\', '/'))
}

function Format-SpecrewVisualIntakeReference {
    # Iteration 8 T003 (FR-032, Amendment A5): the bring-your-own half of the bidirectional intake. Records a
    # human-provided artifact (an existing diagram, doc, Figma export, or whiteboard photo) as a referenced
    # design input — returns a clickable file:/// reference for the path (FR-028 console form), with an
    # optional note. Accept-a-path/image only (no fetch/API). Graceful $null when the path is empty.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$ArtifactPath,
        [AllowNull()][AllowEmptyString()][string]$Note
    )

    if ([string]::IsNullOrWhiteSpace($ArtifactPath)) { return $null }
    $ref = 'file:///{0}' -f ($ArtifactPath.Trim() -replace '\\', '/')
    if (-not [string]::IsNullOrWhiteSpace($Note)) { return ('{0} — {1}' -f $ref, $Note.Trim()) }
    return $ref
}

function Format-SpecrewApplicableLensesSection {
    # Iteration 4 T004 + Iteration 5 T002 (FR-009): render the "## Applicable Lenses" markdown
    # section from the selector. Read-only; graceful degradation to "none available" when the map or
    # answers are absent. When -CatalogDir (absolute path to the lens files) is supplied, ENRICH each
    # selected lens with its Design Decision Points (via Get-SpecrewLensDecisionPoints) plus an
    # "Addressed:" coverage placeholder the author fills by pointing into the option comparison, so
    # the analysis is genuinely lens-informed (FR-009) and the FR-026 gate has a coverage entry to
    # check. With no -CatalogDir the legacy name-list render is preserved (back-compat).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()]$Map,
        [Parameter(Mandatory = $true)][AllowNull()]$Answers,
        [string]$CatalogRelativeDir = 'extensions/specrew-speckit/knowledge/design-lenses',
        [AllowNull()][AllowEmptyString()][string]$CatalogDir
    )

    $sel = Get-SpecrewLensSelection -Map $Map -Answers $Answers
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add('## Applicable Lenses') | Out-Null
    $lines.Add('') | Out-Null

    if (@($sel.selected).Count -eq 0) {
        $lines.Add('None available - no design-lens catalog or no recorded questionnaire answers for this project.') | Out-Null
        return (($lines -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine)
    }

    $lines.Add('Selected by the applicability questionnaire (recorded in `lens-applicability.json`):') | Out-Null
    $lines.Add('') | Out-Null
    $rel = $CatalogRelativeDir.TrimEnd('/')
    $enrich = -not [string]::IsNullOrWhiteSpace($CatalogDir)
    foreach ($id in $sel.selected) {
        $lines.Add(('- **{0}** - `{1}/{2}.md`' -f $id, $rel, $id)) | Out-Null
        if ($enrich) {
            $points = @(Get-SpecrewLensDecisionPoints -LensId $id -CatalogDir $CatalogDir)
            if ($points.Count -gt 0) {
                $lines.Add((' - Decision points: {0}' -f ($points -join '; '))) | Out-Null
            }
            $lines.Add(' - Addressed: <how these decision points shaped the option comparison — name the option(s) and Trade-offs>') | Out-Null
        }
    }

    if (@($sel.excluded).Count -gt 0) {
        $notSel = (@($sel.excluded) | ForEach-Object { '{0} ({1})' -f $_.id, ($_.reason -replace "gated by '", '' -replace "' = ", '=') }) -join ', '
        $lines.Add('') | Out-Null
        $lines.Add(('*Not selected: {0}.*' -f $notSel)) | Out-Null
    }

    return (($lines -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine)
}