extensions/specrew-speckit/scripts/scaffold-retro-artifact.ps1

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$IterationDirectory,

    [string]$RetroDate = (Get-Date -Format 'yyyy-MM-dd'),
    [switch]$DryRun,
    [switch]$PassThru
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$allowedReviewTaskVerdicts = @('pass', 'needs-work', 'blocked')

function Add-ScaffoldAction {
    param(
        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [System.Collections.ArrayList]$Actions,

        [Parameter(Mandatory = $true)]
        [string]$Action,

        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    $null = $Actions.Add([pscustomobject]@{
            Action = $Action
            Path   = $Path
        })
}

function Write-MissingFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$TargetPath,

        [Parameter(Mandatory = $true)]
        [string]$Content,

        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [System.Collections.ArrayList]$Actions
    )

    if (Test-Path -LiteralPath $TargetPath) {
        Add-ScaffoldAction -Actions $Actions -Action 'preserved' -Path $TargetPath
        return
    }

    Add-ScaffoldAction -Actions $Actions -Action $(if ($DryRun) { 'would-create' } else { 'created' }) -Path $TargetPath
    if (-not $DryRun) {
        $parent = Split-Path -Parent $TargetPath
        if (-not (Test-Path -LiteralPath $parent)) {
            New-Item -ItemType Directory -Path $parent -Force | Out-Null
        }

        [System.IO.File]::WriteAllText($TargetPath, $Content, [System.Text.UTF8Encoding]::new($false))
    }
}

function Get-MarkdownContent {
    param([string]$Path)

    return @(Get-Content -LiteralPath $Path -Encoding UTF8)
}

function Get-MarkdownSectionTable {
    param(
        [AllowEmptyString()]
        [string[]]$Lines,
        [string]$Heading
    )

    $headingPattern = '^##\s+' + [regex]::Escape($Heading) + '\b'
    $startIndex = -1

    for ($index = 0; $index -lt $Lines.Count; $index++) {
        if ($Lines[$index] -match $headingPattern) {
            $startIndex = $index
            break
        }
    }

    if ($startIndex -lt 0) {
        return @()
    }

    $tableLines = New-Object System.Collections.Generic.List[string]
    for ($index = $startIndex + 1; $index -lt $Lines.Count; $index++) {
        $currentLine = $Lines[$index]
        if ($currentLine -match '^##\s+') {
            break
        }

        if ($currentLine.Trim().StartsWith('|')) {
            $null = $tableLines.Add($currentLine)
        }
    }

    if ($tableLines.Count -lt 2) {
        return @()
    }

    $headers = ($tableLines[0].Trim('|') -split '\|') | ForEach-Object { $_.Trim() }
    $rows = New-Object System.Collections.Generic.List[object]

    for ($rowIndex = 1; $rowIndex -lt $tableLines.Count; $rowIndex++) {
        $cells = ($tableLines[$rowIndex].Trim('|') -split '\|') | ForEach-Object { $_.Trim() }
        $isSeparator = $true
        foreach ($cell in $cells) {
            if ($cell -notmatch '^:?-{3,}:?$') {
                $isSeparator = $false
                break
            }
        }

        if ($isSeparator) {
            continue
        }

        $row = [ordered]@{}
        for ($cellIndex = 0; $cellIndex -lt $headers.Count; $cellIndex++) {
            $value = if ($cellIndex -lt $cells.Count) { $cells[$cellIndex] } else { '' }
            $row[$headers[$cellIndex]] = $value
        }

        $rows.Add([pscustomobject]$row)
    }

    return $rows.ToArray()
}

function Get-IterationLabel {
    param(
        [AllowEmptyString()]
        [string[]]$PlanLines,
        [string]$Fallback
    )

    $titleLine = @($PlanLines | Select-Object -First 1)[0]
    if (-not [string]::IsNullOrWhiteSpace($titleLine) -and $titleLine -match '^#\s+Iteration Plan:\s+(.+?)(?:\s+\(stub\))?\s*$') {
        return $Matches[1].Trim()
    }

    return $Fallback
}

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

    if ([string]::IsNullOrWhiteSpace($Value)) {
        return $true
    }

    return $Value.Trim() -match '^(?:—|-|none|null|n/a|\(none\)|blank)$'
}

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

    if (Test-IsNullish $Value) {
        return $false
    }

    return $Value.Trim() -match '^(?:Review delivered output against .+ and adjust verdict if needed\.|Execution reported blocked; confirm blocker status and escalation path\.|Deferred work; confirm the deferral is still acceptable for this iteration\.|Task already marked needs-rework during execution; confirm re-entry scope\.|Populate verdict after reviewing the delivered evidence\.)$'
}

function Get-NormalizedReviewTaskVerdict {
    param([AllowNull()][string]$Value)

    if (Test-IsNullish $Value) {
        return $null
    }

    $normalized = $Value.Trim().ToLowerInvariant()
    if ($normalized -match '\bneeds[- ]work\b') {
        return 'needs-work'
    }

    if ($normalized -match '\bblocked\b') {
        return 'blocked'
    }

    if ($normalized -match '\bpass(?:ed)?\b') {
        return 'pass'
    }

    return $normalized
}

function ConvertTo-NullableDecimal {
    param([string]$Value)

    if ([string]::IsNullOrWhiteSpace($Value)) {
        return $null
    }

    $trimmed = $Value.Trim()
    if ($trimmed -notmatch '^-?\d+(?:\.\d+)?$') {
        return $null
    }

    return [decimal]$trimmed
}

function Format-Delta {
    param(
        [AllowNull()]
        [Nullable[decimal]]$Estimated,

        [AllowNull()]
        [Nullable[decimal]]$Actual
    )

    if ($null -eq $Estimated -or $null -eq $Actual) {
        return 'TBD'
    }

    $delta = $Actual - $Estimated
    if ($delta -gt 0) {
        return ('+{0}' -f $delta.ToString('0.##'))
    }

    return $delta.ToString('0.##')
}

function Get-PhaseVarianceNote {
    param([string]$PhaseName)

    switch ($PhaseName.Trim().ToLowerInvariant()) {
        'planning' { return 'Capture approval, clarification, and task-decomposition variance.' }
        'discovery/spikes' { return 'Record any preflight or research effort that changed execution certainty.' }
        'implementation' { return 'Note whether reuse, blockers, or rework changed delivery effort.' }
        'review' { return 'Capture late-found gaps, batch drift checks, or demo overhead.' }
        'rework' { return 'Record whether needs-work loops were avoided, deferred, or underestimated.' }
        default { return 'Document why actual effort differed from the planned baseline.' }
    }
}

function Get-ReviewOverallVerdict {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string[]]$ReviewLines
    )

    foreach ($line in $ReviewLines) {
        if ($line -match '^\*\*Overall Verdict\*\*:\s*(.+?)\s*$') {
            $verdict = $Matches[1].Trim()
            if ($verdict -in @('accepted', 'needs-rework', 'blocked')) {
                return $verdict
            }

            throw "review.md contains an invalid overall verdict: '$verdict'."
        }
    }

    throw 'review.md must record an overall verdict before retrospective scaffolding can run.'
}

function Assert-ReviewArtifactReadyForRetro {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string[]]$ReviewLines
    )

    $overallVerdict = Get-ReviewOverallVerdict -ReviewLines $ReviewLines
    if ($overallVerdict -ne 'accepted') {
        throw "review.md overall verdict must be 'accepted' before retrospective scaffolding can run (found '$overallVerdict')."
    }

    foreach ($line in $ReviewLines) {
        if ($line.Trim() -eq '- Replace default verdicts with the actual per-task review outcome before closing the review phase.') {
            throw 'review.md still contains scaffold reminder text and is not ready for retrospective scaffolding.'
        }
    }

    $taskVerdicts = @(Get-MarkdownSectionTable -Lines $ReviewLines -Heading 'Task Verdicts')
    if ($taskVerdicts.Count -eq 0) {
        throw 'review.md must contain a populated Task Verdicts table before retrospective scaffolding can run.'
    }

    foreach ($row in $taskVerdicts) {
        $taskId = [string]$row.Task
        if (Test-IsNullish $taskId) {
            throw 'review.md contains a verdict row without a task identifier.'
        }

        $verdict = Get-NormalizedReviewTaskVerdict -Value ([string]$row.Verdict)
        if ($null -eq $verdict) {
            throw "review.md is missing a verdict for task '$taskId'."
        }

        if ($verdict -notin $allowedReviewTaskVerdicts) {
            throw "review.md contains invalid verdict '$($row.Verdict)' for task '$taskId'."
        }

        if ($verdict -ne 'pass') {
            throw "review.md task '$taskId' is still marked '$verdict'; resolve review findings before retrospective scaffolding."
        }

        $notes = if ($null -ne $row.PSObject.Properties['Notes']) { [string]$row.Notes } else { $null }
        if (Test-IsPlaceholderReviewNote -Value $notes) {
            throw "review.md still contains scaffold placeholder notes for task '$taskId'."
        }
    }

    return $overallVerdict
}

function Get-DriftSummary {
    param(
        [AllowEmptyString()]
        [string[]]$DriftLines
    )

    $summary = [ordered]@{
        Total              = 0
        SpecUpdated        = 0
        ImplementationRevert = 0
        Deferred           = 0
        HumanDecision      = 0
    }

    foreach ($line in $DriftLines) {
        if ($line -match '^\*\*Total drift events\*\*:\s*(\d+)\s*$') {
            $summary.Total = [int]$Matches[1]
            continue
        }

        if ($line -match '^\s*-\s+\*\*Resolution\*\*:\s*(spec-updated|implementation-reverted|deferred|human-decision)\s*$') {
            switch ($Matches[1]) {
                'spec-updated' { $summary.SpecUpdated++ }
                'implementation-reverted' { $summary.ImplementationRevert++ }
                'deferred' { $summary.Deferred++ }
                'human-decision' { $summary.HumanDecision++ }
            }
        }
    }

    return [pscustomobject]$summary
}

$resolvedIterationDirectory = [System.IO.Path]::GetFullPath($IterationDirectory)
$planPath = Join-Path $resolvedIterationDirectory 'plan.md'
$statePath = Join-Path $resolvedIterationDirectory 'state.md'
$driftLogPath = Join-Path $resolvedIterationDirectory 'drift-log.md'
$reviewPath = Join-Path $resolvedIterationDirectory 'review.md'
$retroPath = Join-Path $resolvedIterationDirectory 'retro.md'
$reviewerArtifactScriptPath = Join-Path $PSScriptRoot 'scaffold-reviewer-artifacts.ps1'
$actions = [System.Collections.ArrayList]::new()

foreach ($requiredPath in @($planPath, $statePath, $driftLogPath, $reviewPath)) {
    if (-not (Test-Path -LiteralPath $requiredPath)) {
        throw "Required retrospective input '$requiredPath' does not exist."
    }
}

$planLines = @(Get-MarkdownContent -Path $planPath)
$reviewLines = @(Get-MarkdownContent -Path $reviewPath)
$driftLines = @(Get-MarkdownContent -Path $driftLogPath)
$tasks = @(Get-MarkdownSectionTable -Lines $planLines -Heading 'Tasks')
$phaseRows = @(Get-MarkdownSectionTable -Lines $planLines -Heading 'Phase Baseline')

if ($tasks.Count -eq 0) {
    throw "Plan '$planPath' does not contain a populated Tasks table."
}

if ($phaseRows.Count -eq 0) {
    throw "Plan '$planPath' does not contain a populated Phase Baseline table."
}

$iterationLabel = Get-IterationLabel -PlanLines $planLines -Fallback (Split-Path -Leaf $resolvedIterationDirectory)
$reviewOverallVerdict = Assert-ReviewArtifactReadyForRetro -ReviewLines $reviewLines
$driftSummary = Get-DriftSummary -DriftLines $driftLines

if (-not (Test-Path -LiteralPath $reviewerArtifactScriptPath -PathType Leaf)) {
    throw "Reviewer artifact helper '$reviewerArtifactScriptPath' does not exist."
}

$taskVarianceRows = @(
    '| Task | Estimated | Actual | Delta |'
    '| ---- | --------- | ------ | ----- |'
)

$absoluteDeltas = New-Object System.Collections.Generic.List[decimal]
foreach ($task in $tasks) {
    $taskId = [string]$task.Task
    if ([string]::IsNullOrWhiteSpace($taskId)) {
        continue
    }

    $estimatedValue = ConvertTo-NullableDecimal -Value ([string]$task.Effort)
    $actualValue = ConvertTo-NullableDecimal -Value ([string]$task.Actual)
    if ($null -ne $estimatedValue -and $null -ne $actualValue) {
        $null = $absoluteDeltas.Add([math]::Abs($actualValue - $estimatedValue))
    }

    $taskVarianceRows += ('| {0} | {1} | {2} | {3} |' -f
        $taskId.Trim(),
        $(if ($null -eq $estimatedValue) { 'TBD' } else { $estimatedValue.ToString('0.##') }),
        $(if ($null -eq $actualValue) { 'TBD' } else { $actualValue.ToString('0.##') }),
        (Format-Delta -Estimated $estimatedValue -Actual $actualValue))
}

$averageVariance = if ($absoluteDeltas.Count -eq 0) {
    'TBD'
}
else {
    $sum = [decimal]0
    foreach ($delta in $absoluteDeltas) {
        $sum += $delta
    }

    ('+/- {0}' -f (($sum / $absoluteDeltas.Count).ToString('0.##')))
}

$phaseVarianceRows = @(
    '| Phase | Estimated | Actual | Delta | Notes |'
    '| ----- | --------- | ------ | ----- | ----- |'
)

foreach ($phaseRow in $phaseRows) {
    $phaseName = [string]$phaseRow.Phase
    if ([string]::IsNullOrWhiteSpace($phaseName)) {
        continue
    }

    $estimated = ConvertTo-NullableDecimal -Value ([string]$phaseRow.'Estimated Effort')
    $phaseVarianceRows += ('| {0} | {1} | {2} | {3} | {4} |' -f
        $phaseName.Trim(),
        $(if ($null -eq $estimated) { 'TBD' } else { $estimated.ToString('0.##') }),
        'TBD',
        'TBD',
        ((Get-PhaseVarianceNote -PhaseName $phaseName) -replace '\|', '\|'))
}

$retroContent = @"
# Retrospective: Iteration $iterationLabel
 
**Schema**: v1
**Date**: $RetroDate
 
## Estimation Accuracy
 
$($taskVarianceRows -join [Environment]::NewLine)
 
**Average variance**: $averageVariance
 
## Phase Variance
 
$($phaseVarianceRows -join [Environment]::NewLine)
 
## Drift Summary
 
- Total drift events: $($driftSummary.Total)
- Resolved via spec update: $($driftSummary.SpecUpdated)
- Resolved via revert: $($driftSummary.ImplementationRevert)
- Deferred: $($driftSummary.Deferred)
- Escalated to human decision: $($driftSummary.HumanDecision)
 
## What Went Well
 
- Review verdict recorded as **$reviewOverallVerdict** before retrospective started.
- Replace with the concrete practices that improved planning accuracy, execution flow, or governance quality.
 
## What Didn't Go Well
 
- Replace with the concrete friction, missed gates, or late-found drift that hurt this iteration.
- Call out any repeatable failure pattern that should be prevented in the next planning ceremony.
 
## Improvement Actions
 
1. Owner: TBD | Phase: next planning | Type: process | Expected effect: tighten a measurable workflow weakness.
2. Owner: TBD | Phase: next iteration | Type: implementation | Expected effect: remove one repeated source of friction.
 
## Calibration Suggestion
 
- Suggested capacity adjustment: current baseline -> TBD
- Rationale: Replace with evidence from task variance, phase variance, and drift timing.
 
## Notes
 
- This artifact was scaffolded from plan.md, state.md, drift-log.md, and review.md for Squad's built-in Retrospective ceremony.
- Replace all TBD placeholders with evidence from the completed iteration before marking the retro phase complete.
"@


Write-MissingFile -TargetPath $retroPath -Content $retroContent -Actions $actions

$reviewerArtifactActions = @(
    & $reviewerArtifactScriptPath `
        -IterationDirectory $resolvedIterationDirectory `
        -PassThru `
        -DryRun:$DryRun
)

foreach ($action in $reviewerArtifactActions) {
    if ($null -eq $action) {
        continue
    }

    Add-ScaffoldAction -Actions $actions -Action ([string]$action.Action) -Path ([string]$action.Path)
}

if ($PassThru) {
    $actions
    return
}

$actions | Select-Object Action, Path | Format-Table -AutoSize
Write-Host ("Retro artifact scaffold {0} for {1}" -f ($(if ($DryRun) { 'previewed' } else { 'completed' }), $retroPath)) -ForegroundColor Green
& $reviewerArtifactScriptPath `
    -IterationDirectory $resolvedIterationDirectory `
    -SummaryOnly `
    -DryRun:$DryRun
exit 0