extensions/specrew-speckit/scripts/confirm-workshop-agenda.ps1

#!/usr/bin/env pwsh
[CmdletBinding(DefaultParameterSetName = 'Json')]
param(
    [Parameter(Mandatory)][string] $ProjectRoot,
    [Parameter(Mandatory)][string] $FeatureRef,
    [Parameter(Mandatory, ParameterSetName = 'Json')][string] $AgendaJson,
    [Parameter(Mandatory, ParameterSetName = 'Fields')][string[]] $SelectedLens,
    [Parameter(Mandatory, ParameterSetName = 'Fields')][string[]] $SelectedDepth,
    [Parameter(Mandatory, ParameterSetName = 'Fields')][string[]] $SelectedDecision,
    [Parameter(Mandatory, ParameterSetName = 'Fields')][AllowEmptyCollection()][string[]] $SkippedLens,
    [Parameter(Mandatory, ParameterSetName = 'Fields')][AllowEmptyCollection()][string[]] $SkippedReason,
    [ValidateSet('human-confirmed')][string] $Confirmation = 'human-confirmed',
    [switch] $RenderOnly,
    [switch] $PassThru
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Write-AtomicUtf8NoBom {
    param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][string] $Content)
    $tempPath = '{0}.{1}.tmp' -f $Path, ([Guid]::NewGuid().ToString('N'))
    try {
        [IO.File]::WriteAllText($tempPath, $Content, [Text.UTF8Encoding]::new($false))
        Move-Item -LiteralPath $tempPath -Destination $Path -Force
    }
    finally {
        if (Test-Path -LiteralPath $tempPath -PathType Leaf) {
            Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
        }
    }
}

function New-SpecrewWorkshopAgendaRefusal {
    param(
        [Parameter(Mandatory)][string]$Summary,
        [AllowEmptyString()][string]$Action
    )
    $parts = [Collections.Generic.List[string]]::new()
    $parts.Add($Summary.Trim()) | Out-Null
    if (-not [string]::IsNullOrWhiteSpace($Action)) { $parts.Add($Action.Trim()) | Out-Null }
    $parts.Add((Get-SpecrewWorkshopRefusalContractText)) | Out-Null
    return ($parts -join ' ')
}

$authorityStorePath = Join-Path $PSScriptRoot 'workshop-authority-store.ps1'
if (-not (Test-Path -LiteralPath $authorityStorePath -PathType Leaf)) {
    throw "Workshop typed-turn authority helper is missing: '$authorityStorePath'."
}
. $authorityStorePath

function Get-CanonicalWorkshopAgendaText {
    param(
        [Parameter(Mandatory)][Collections.IDictionary]$Agenda,
        [Parameter(Mandatory)][Collections.IDictionary]$Skipped
    )
    $lines = [Collections.Generic.List[string]]::new()
    $lines.Add('Workshop agenda') | Out-Null
    $lines.Add('') | Out-Null
    $lines.Add('Selected lenses:') | Out-Null
    foreach ($entry in $Agenda.GetEnumerator()) {
        $lines.Add(('- {0} ({1}): {2}' -f $entry.Key, $entry.Value.depth, $entry.Value.decision)) | Out-Null
    }
    $lines.Add('') | Out-Null
    $lines.Add('Skipped lenses:') | Out-Null
    if ($Skipped.Count -eq 0) { $lines.Add('- none') | Out-Null }
    else { foreach ($entry in $Skipped.GetEnumerator()) { $lines.Add(('- {0}: {1}' -f $entry.Key, $entry.Value)) | Out-Null } }
    $lines.Add('') | Out-Null
    $lines.Add('Does this complete selected + skipped agenda look right? Reply with confirm or tell me what to change.') | Out-Null
    return ($lines -join [Environment]::NewLine)
}

$resolvedProjectRoot = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd(
    [IO.Path]::DirectorySeparatorChar,
    [IO.Path]::AltDirectorySeparatorChar
)
if (-not (Test-Path -LiteralPath (Join-Path $resolvedProjectRoot '.specrew/config.yml') -PathType Leaf)) {
    throw (New-SpecrewWorkshopAgendaRefusal -Summary 'Workshop agenda confirmation requires a Specrew-governed project.' `
        -Action 'Run the agenda command from the project root that contains .specrew/config.yml.')
}
if ($FeatureRef -cnotmatch '^[0-9]{3}-[a-z0-9][a-z0-9-]{0,63}$') {
    throw "FeatureRef must be an exact Specrew feature reference such as '001-article-amplifier'."
}
if ($PSCmdlet.ParameterSetName -eq 'Json') {
    try { $proposal = $AgendaJson | ConvertFrom-Json -AsHashtable -Depth 10 -ErrorAction Stop }
    catch { throw "AgendaJson is not valid JSON: $($_.Exception.Message)" }
    if ($proposal -isnot [Collections.IDictionary] -or -not $proposal.Contains('selected') -or -not $proposal.Contains('skipped') -or
        $proposal.selected -isnot [System.Array] -or $proposal.skipped -isnot [System.Array]) {
        throw 'AgendaJson must contain selected and skipped arrays.'
    }
    $SelectedLens = @($proposal.selected | ForEach-Object { if ($_ -is [Collections.IDictionary] -and $_.Contains('lens')) { [string]$_['lens'] } else { '' } })
    $SelectedDepth = @($proposal.selected | ForEach-Object { if ($_ -is [Collections.IDictionary] -and $_.Contains('depth')) { [string]$_['depth'] } else { '' } })
    $SelectedDecision = @($proposal.selected | ForEach-Object { if ($_ -is [Collections.IDictionary] -and $_.Contains('decision')) { [string]$_['decision'] } else { '' } })
    $SkippedLens = @($proposal.skipped | ForEach-Object { if ($_ -is [Collections.IDictionary] -and $_.Contains('lens')) { [string]$_['lens'] } else { '' } })
    $SkippedReason = @($proposal.skipped | ForEach-Object { if ($_ -is [Collections.IDictionary] -and $_.Contains('reason')) { [string]$_['reason'] } else { '' } })
}

if (@($SelectedLens).Count -eq 0) { throw 'At least one technical lens must be selected.' }
if ($SelectedLens.Count -ne $SelectedDepth.Count -or $SelectedLens.Count -ne $SelectedDecision.Count) {
    throw 'SelectedLens, SelectedDepth, and SelectedDecision must have the same number of entries.'
}
if ($SkippedLens.Count -ne $SkippedReason.Count) {
    throw 'SkippedLens and SkippedReason must have the same number of entries.'
}

$featureRoot = Join-Path (Join-Path $resolvedProjectRoot 'specs') $FeatureRef
$statePath = Join-Path $featureRoot 'lens-applicability.json'
if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) {
    throw "The feature's workshop progress record is missing: '$statePath'."
}
$state = Get-Content -LiteralPath $statePath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -Depth 20 -ErrorAction Stop
if ([string]$state.agenda_contract -cne 'complete-coverage-v1' -or [string]$state.agenda_status -cne 'pending-confirmation') {
    throw (New-SpecrewWorkshopAgendaRefusal -Summary 'The technical agenda can only be confirmed while this feature is awaiting agenda confirmation.' `
        -Action 'Resume the feature from its saved workshop progress. Do not replace saved decisions.')
}
$transitionOperation = if ($RenderOnly) { 'render-agenda' } else { 'confirm-agenda' }
$transition = Resolve-SpecrewWorkshopStateTransition -Controller $state -Operation $transitionOperation
if (-not $transition.allowed) {
    throw (New-SpecrewWorkshopAgendaRefusal -Summary 'This feature''s saved workshop progress includes technical decisions before the agenda was confirmed, so the agenda cannot be replaced safely.' `
        -Action 'Propose the governed workshop repair described in the design-workshop skill. It preserves saved product answers and requires typed human approval before changing the unfinished agenda.')
}
if ([string]$state.human_turn_contract -cne 'typed-turns-v1') {
    throw 'The feature workshop cannot prove a typed human reply for this agenda.'
}
$productReceipt = Get-SpecrewWorkshopAuthorityReceipt -ProjectRoot $resolvedProjectRoot -FeatureRef $FeatureRef -Phase 'product-domain'
if ($null -eq $productReceipt -or [string]$productReceipt.confirmation -eq 'invalid') {
    throw (New-SpecrewWorkshopAgendaRefusal -Summary 'Product-domain has no typed human reply receipt.' `
        -Action 'Re-render its question as prose once and wait for a typed answer.')
}
$missingProductRecords = [Collections.Generic.List[string]]::new()
foreach ($relativeProductRecord in @('workshop/product-domain.md', 'workshop/product-domain.yml')) {
    if (-not (Test-Path -LiteralPath (Join-Path $featureRoot $relativeProductRecord) -PathType Leaf)) {
        $missingProductRecords.Add($relativeProductRecord) | Out-Null
    }
}
if ($missingProductRecords.Count -gt 0) {
    throw (New-SpecrewWorkshopAgendaRefusal `
        -Summary ('The product-domain records must be persisted before the technical agenda can be rendered or confirmed. Missing: {0}.' -f ($missingProductRecords -join ', ')) `
        -Action 'Persist both product-domain records from the typed answer already on record, validate them, then run this same agenda command with -RenderOnly once.')
}
$catalogPath = Join-Path $resolvedProjectRoot '.specify/extensions/specrew-speckit/knowledge/design-lenses/index.yml'
if (-not (Test-Path -LiteralPath $catalogPath -PathType Leaf)) {
    throw "Design-lens catalog is missing: '$catalogPath'."
}
$yamlReader = Join-Path $PSScriptRoot 'intake/helpers/Read-IntakeYaml.ps1'
if (-not (Test-Path -LiteralPath $yamlReader -PathType Leaf)) { throw "Design-lens catalog parser is missing: '$yamlReader'." }
. $yamlReader
$catalog = @(Read-IntakeYamlDocument -Path $catalogPath -Kind lenses | ForEach-Object { [string]$_.id } | Where-Object { $_ -and -not (Test-SpecrewWorkshopIntakeLens -Lens $_) })
if ($catalog.Count -eq 0) { throw 'The design-lens catalog contains no technical lenses.' }

$selectedSet = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
$skippedSet = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
$agenda = [ordered]@{}
$skipped = [ordered]@{}
for ($i = 0; $i -lt $SelectedLens.Count; $i++) {
    $lens = [string]$SelectedLens[$i]
    $depth = [string]$SelectedDepth[$i]
    $decision = [string]$SelectedDecision[$i]
    if ($lens -cnotmatch '^[a-z][a-z0-9-]{1,63}$' -or -not $selectedSet.Add($lens)) {
        throw "Selected lens '$lens' is malformed or duplicated."
    }
    if ($depth -cnotin @('full', 'medium', 'light')) { throw "Selected lens '$lens' has invalid depth '$depth'." }
    if ([string]::IsNullOrWhiteSpace($decision)) { throw "Selected lens '$lens' needs the concrete decision it raises." }
    $agenda[$lens] = [ordered]@{ depth = $depth; decision = $decision.Trim() }
}
for ($i = 0; $i -lt $SkippedLens.Count; $i++) {
    $lens = [string]$SkippedLens[$i]
    $reason = [string]$SkippedReason[$i]
    if ($lens -cnotmatch '^[a-z][a-z0-9-]{1,63}$' -or -not $skippedSet.Add($lens)) {
        throw "Skipped lens '$lens' is malformed or duplicated."
    }
    if ($selectedSet.Contains($lens)) { throw "Lens '$lens' cannot be both selected and skipped." }
    if ([string]::IsNullOrWhiteSpace($reason)) { throw "Skipped lens '$lens' needs a visible reason." }
    $skipped[$lens] = $reason.Trim()
}

$catalogSet = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
foreach ($lens in $catalog) { [void]$catalogSet.Add($lens) }
$unknown = @(($SelectedLens + $SkippedLens) | Where-Object { -not $catalogSet.Contains([string]$_) })
if ($unknown.Count -gt 0) { throw ('Unknown technical lens(es): {0}.' -f ($unknown -join ', ')) }
$missing = @($catalog | Where-Object { -not $selectedSet.Contains($_) -and -not $skippedSet.Contains($_) })
if ($missing.Count -gt 0) {
    throw ('Every technical lens must be selected or visibly skipped with a reason. Missing: {0}.' -f ($missing -join ', '))
}

$canonicalAgendaText = Get-CanonicalWorkshopAgendaText -Agenda $agenda -Skipped $skipped
$agendaBinding = ConvertTo-SpecrewWorkshopAgendaBinding -Agenda $agenda -Skipped $skipped
$agendaDigest = Get-SpecrewWorkshopAgendaDigest -Binding $agendaBinding
$proposalPath = Join-Path $resolvedProjectRoot '.specrew/handover/workshop-agenda-proposal.json'
if ($RenderOnly) {
    $proposal = [ordered]@{
        schema_version = '1.0'
        feature_ref = $FeatureRef
        lens = 'product-domain'
        agenda_digest = $agendaDigest
        agenda_binding = $agendaBinding
        canonical_text = $canonicalAgendaText
        recorded_at = [DateTimeOffset]::UtcNow.ToString('o')
    }
    $proposalDir = Split-Path -Parent $proposalPath
    if (-not (Test-Path -LiteralPath $proposalDir -PathType Container)) {
        New-Item -ItemType Directory -Path $proposalDir -Force | Out-Null
    }
    Write-AtomicUtf8NoBom -Path $proposalPath -Content (($proposal | ConvertTo-Json -Depth 10) + [Environment]::NewLine)
    Write-Output $canonicalAgendaText
    exit 0
}
$agendaReceipt = Get-SpecrewWorkshopAuthorityReceipt -ProjectRoot $resolvedProjectRoot -FeatureRef $FeatureRef -Phase 'agenda'
if ($null -eq $agendaReceipt -or [string]$agendaReceipt.confirmation -cne 'human-confirmed' -or
    [string]$agendaReceipt.confirmation_scope -cne 'lens-selection') {
    throw (New-SpecrewWorkshopAgendaRefusal -Summary 'The complete selected + skipped agenda has no typed human confirmation receipt.' `
        -Action 'Run this command with -RenderOnly once, paste its complete output into your own message (a tool result is not a message), and wait for one typed reply - confirm, yes, ok, looks right, or a change.')
}
# SPECREW-AUTHORITY-CONSUMER: workshop-agenda-question-identity
$receiptDigestProperty = $agendaReceipt.PSObject.Properties['agenda_digest']
$receiptBindingProperty = $agendaReceipt.PSObject.Properties['agenda_binding']
if (-not $receiptDigestProperty -or -not $receiptBindingProperty -or $null -eq $receiptBindingProperty.Value) {
    throw (New-SpecrewWorkshopAgendaRefusal -Summary 'The typed agenda confirmation is not bound to agenda content.' `
        -Action 'Run this command with -RenderOnly once, paste its complete output into your own message (a tool result is not a message), and wait for one new typed reply - confirm, yes, ok, looks right, or a change.')
}
if ([string]$receiptDigestProperty.Value -cne $agendaDigest) {
    $changedLenses = @(Get-SpecrewWorkshopAgendaChangedLenses -ExpectedBinding $receiptBindingProperty.Value -ActualBinding $agendaBinding)
    $changedLabel = if ($changedLenses.Count -gt 0) { $changedLenses -join ', ' } else { '(content digest changed)' }
    throw (New-SpecrewWorkshopAgendaRefusal -Summary ("The agenda decisions changed after the human reply. Changed lenses: {0}." -f $changedLabel) `
        -Action 'Render the current complete agenda once with -RenderOnly, paste it into your own message, and wait for one new typed reply - confirm, yes, ok, looks right, or a change.')
}

$confirmed = [ordered]@{
    schema_version            = '1.1'
    workshop_intake           = $true
    confirmation_required     = $true
    agenda_contract           = 'complete-coverage-v1'
    human_turn_contract       = 'typed-turns-v1'
    agenda_status             = 'confirmed'
    selected                  = @($SelectedLens)
    agenda                    = $agenda
    skipped                   = $skipped
    agenda_confirmation       = $Confirmation
    agenda_confirmation_scope = 'lens-selection'
    agenda_turn_receipt       = [string]$agendaReceipt.receipt_id
    agenda_digest             = $agendaDigest
    workshop                  = [ordered]@{}
}
Write-AtomicUtf8NoBom -Path $statePath -Content (($confirmed | ConvertTo-Json -Depth 10) + [Environment]::NewLine)
if (Test-Path -LiteralPath $proposalPath -PathType Leaf) {
    Remove-Item -LiteralPath $proposalPath -Force -ErrorAction SilentlyContinue
}

if ($PassThru) {
    [pscustomobject]@{
        feature_ref = $FeatureRef
        state = 'confirmed'
        selected_count = $SelectedLens.Count
        skipped_count = $SkippedLens.Count
        artifact_path = $statePath
    }
}