extensions/specrew-speckit/scripts/resume-iteration.ps1

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

    [ValidateSet('continue', 'replan', 'abort')]
    [string]$ResumeMode = 'continue',

    [switch]$DryRun,
    [switch]$PassThru
)

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

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

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

function Get-MarkdownMetadataValue {
    param(
        [string[]]$Lines,
        [string]$Label
    )

    $pattern = '^\*\*' + [regex]::Escape($Label) + '\*\*:\s*(.+?)\s*$'
    foreach ($line in $Lines) {
        if ($line -match $pattern) {
            return $Matches[1].Trim()
        }
    }

    return $null
}

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

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

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

function Get-MarkdownSectionTable {
    param(
        [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++) {
        $line = $Lines[$index]
        if ($line -match '^##\s+' -and $tableLines.Count -gt 0) {
            break
        }

        if ($line.TrimStart().StartsWith('|')) {
            $tableLines.Add($line)
        }
        elseif ($tableLines.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($line)) {
            break
        }
    }

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

    $headers = @($tableLines[0].Trim('|').Split('|') | ForEach-Object { $_.Trim() })
    $rows = New-Object System.Collections.Generic.List[object]
    for ($index = 2; $index -lt $tableLines.Count; $index++) {
        $columns = @($tableLines[$index].Trim('|').Split('|') | ForEach-Object { $_.Trim() })
        if ($columns.Count -ne $headers.Count) {
            continue
        }

        $row = [ordered]@{}
        for ($columnIndex = 0; $columnIndex -lt $headers.Count; $columnIndex++) {
            $row[$headers[$columnIndex]] = $columns[$columnIndex]
        }

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

    return $rows.ToArray()
}

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

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

    return @(
        $Value -split ',' |
            ForEach-Object { $_.Trim() } |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
    )
}

function Get-NormalizedTaskStatus {
    param([AllowNull()][string]$Status)

    if ([string]::IsNullOrWhiteSpace($Status)) {
        return ''
    }

    return $Status.Trim().ToLowerInvariant()
}

function Set-MarkdownMetadataValue {
    param(
        [string[]]$Lines,
        [string]$Label,
        [string]$Value
    )

    $pattern = '^\*\*' + [regex]::Escape($Label) + '\*\*:\s*(.+?)\s*$'
    for ($index = 0; $index -lt $Lines.Count; $index++) {
        if ($Lines[$index] -match $pattern) {
            $Lines[$index] = ('**{0}**: {1}' -f $Label, $Value)
            return $Lines
        }
    }

    $insertIndex = -1
    for ($index = 0; $index -lt $Lines.Count; $index++) {
        if ($Lines[$index] -match '^\*\*[^*]+\*\*:\s*') {
            $insertIndex = $index + 1
            continue
        }

        if ($insertIndex -ge 0 -and $Lines[$index] -match '^##\s+') {
            break
        }
    }

    $updatedLines = New-Object System.Collections.Generic.List[string]
    foreach ($line in $Lines) {
        $updatedLines.Add($line)
    }

    if ($insertIndex -lt 0) {
        $updatedLines.Add(('**{0}**: {1}' -f $Label, $Value))
    }
    else {
        $updatedLines.Insert($insertIndex, ('**{0}**: {1}' -f $Label, $Value))
    }

    return $updatedLines.ToArray()
}

function Set-ManagedBlock {
    param(
        [string]$Content,
        [string]$BlockName,
        [string]$BlockContent
    )

    $startMarker = "<!-- >>> specrew-managed $BlockName >>> -->"
    $endMarker = "<!-- <<< specrew-managed $BlockName <<< -->"
    $managedBlock = @(
        $startMarker
        $BlockContent.Trim()
        $endMarker
    ) -join [Environment]::NewLine
    $pattern = '(?ms)\s*' + [regex]::Escape($startMarker) + '.*?' + [regex]::Escape($endMarker) + '\s*'

    if ($Content -match $pattern) {
        $updated = [regex]::Replace($Content, $pattern, ([Environment]::NewLine + [Environment]::NewLine + $managedBlock + [Environment]::NewLine + [Environment]::NewLine))
        return $updated.TrimEnd() + [Environment]::NewLine
    }

    if ([string]::IsNullOrWhiteSpace($Content)) {
        return $managedBlock + [Environment]::NewLine
    }

    return $Content.TrimEnd() + [Environment]::NewLine + [Environment]::NewLine + $managedBlock + [Environment]::NewLine
}

function Get-DefaultEscalationState {
    return [pscustomobject]@{
        status            = 'inactive'
        artifact          = $null
        gate              = $null
        failure_count     = 0
        current_tier      = 'efficiency'
        current_owner     = $null
        locked_out_agents = @()
        last_escalated    = $null
        resolved_at       = $null
        notes             = $null
    }
}

$resolvedIterationDirectory = [System.IO.Path]::GetFullPath($IterationDirectory)
$planPath = Join-Path $resolvedIterationDirectory 'plan.md'
$statePath = Join-Path $resolvedIterationDirectory 'state.md'

if (-not (Test-Path -LiteralPath $planPath -PathType Leaf)) {
    throw "Iteration plan '$planPath' does not exist."
}

$planLines = Get-MarkdownContent -Path $planPath
$planTasks = @(Get-MarkdownSectionTable -Lines $planLines -Heading 'Tasks')
if ($planTasks.Count -eq 0) {
    throw "Iteration plan '$planPath' does not contain a Tasks table."
}

$stateExists = Test-Path -LiteralPath $statePath -PathType Leaf
$stateLines = @(if ($stateExists) { Get-MarkdownContent -Path $statePath } else { @() })
$repairEscalation = Get-DefaultEscalationState
$escalationHelperPath = Join-Path -Path $PSScriptRoot -ChildPath 'manage-escalation-state.ps1'
if ($stateExists -and (Test-Path -LiteralPath $escalationHelperPath -PathType Leaf)) {
    $repairEscalation = & $escalationHelperPath -IterationDirectory $resolvedIterationDirectory -Mode get -PassThru
}

$lastCompletedTask = if ($stateExists) { Get-MarkdownMetadataValue -Lines $stateLines -Label 'Last Completed Task' } else { '(none)' }
$tasksRemainingValue = if ($stateExists) { Get-MarkdownMetadataValue -Lines $stateLines -Label 'Tasks Remaining' } else { $null }
$inProgressValue = if ($stateExists) { Get-MarkdownMetadataValue -Lines $stateLines -Label 'In Progress' } else { $null }

$planTaskLookup = @{}
foreach ($task in $planTasks) {
    $planTaskLookup[$task.Task] = $task
}

$planRemainingTasks = @(
    $planTasks |
        Where-Object { (Get-NormalizedTaskStatus -Status $_.Status) -eq 'planned' } |
        ForEach-Object { $_.Task }
)
$planInProgressTasks = @(
    $planTasks |
        Where-Object { (Get-NormalizedTaskStatus -Status $_.Status) -in @('in-progress', 'needs-rework') } |
        ForEach-Object { $_.Task }
)

$blockers = New-Object System.Collections.Generic.List[object]

if (-not (Test-IsNullish $lastCompletedTask) -and $lastCompletedTask -ne '(none)' -and -not $planTaskLookup.ContainsKey($lastCompletedTask)) {
    $blockers.Add([pscustomobject]@{
            type        = 'resource'
            description = "state.md references unknown last completed task '$lastCompletedTask'."
        })
}

$stateRemainingTasks = @(Get-TaskListFromMetadata -Value $tasksRemainingValue)
$remainingTasks = @($planRemainingTasks)

$stateInProgressTasks = @(Get-TaskListFromMetadata -Value $inProgressValue)
$inProgressTasks = @($stateInProgressTasks)
if ($inProgressTasks.Count -eq 0) {
    $inProgressTasks = @($planInProgressTasks)
}

$invalidInProgressTasks = @(
    $inProgressTasks |
        Where-Object {
            $planTaskLookup.ContainsKey($_) -and
            (Get-NormalizedTaskStatus -Status $planTaskLookup[$_].Status) -in @('done', 'blocked')
        }
)
foreach ($taskId in $invalidInProgressTasks) {
    $blockers.Add([pscustomobject]@{
            type        = 'resource'
            description = "state.md marks task '$taskId' in progress, but plan.md shows it as '$($planTaskLookup[$taskId].Status)'."
        })
}

$inProgressTasks = @(
    $inProgressTasks |
        Where-Object {
            $planTaskLookup.ContainsKey($_) -and
            (Get-NormalizedTaskStatus -Status $planTaskLookup[$_].Status) -notin @('done', 'blocked')
        } |
        Select-Object -Unique
)

if ($inProgressTasks.Count -eq 0 -and $planInProgressTasks.Count -gt 0) {
    $inProgressTasks = @($planInProgressTasks)
}

$remainingTasks = @($remainingTasks | Where-Object { $_ -notin $inProgressTasks })

foreach ($taskId in @($stateRemainingTasks + $stateInProgressTasks) | Select-Object -Unique) {
    if (-not $planTaskLookup.ContainsKey($taskId)) {
        $blockers.Add([pscustomobject]@{
                type        = 'resource'
                description = "state.md references unknown task '$taskId'."
            })
    }
}

$blockedPlanTasks = @(
    $planTasks |
        Where-Object { (Get-NormalizedTaskStatus -Status $_.Status) -eq 'blocked' } |
        ForEach-Object { $_.Task }
)
foreach ($taskId in $blockedPlanTasks) {
    $blockers.Add([pscustomobject]@{
            type        = 'dependency'
            description = "Task '$taskId' is marked blocked in plan.md and must be resolved before execution can continue."
        })
}

$status = 'ready'
$nextSuggestedTask = $null
$salvageableTasks = $null
$nextRecoveryAction = $null
$hasActiveEscalation = $repairEscalation.status -eq 'active'

switch ($ResumeMode) {
    'continue' {
        if ($blockers.Count -gt 0) {
            $status = 'blocked'
        }
        elseif ($hasActiveEscalation) {
            $nextRecoveryAction = 'Resume active escalation for {0} at gate {1} using {2} on the {3} tier.' -f $repairEscalation.artifact, $repairEscalation.gate, $repairEscalation.current_owner, $repairEscalation.current_tier
        }
        elseif ($inProgressTasks.Count -gt 0) {
            $nextSuggestedTask = $inProgressTasks[0]
        }
        elseif ($remainingTasks.Count -gt 0) {
            $nextSuggestedTask = $remainingTasks[0]
        }
    }
    'replan' {
        $status = 'needs-replan'
        if ($blockers.Count -eq 0) {
            $blockers.Add([pscustomobject]@{
                    type        = 'resource'
                    description = 'Resume mode ''replan'' was selected; refresh the remaining task plan before execution resumes.'
                })
        }
    }
    'abort' {
        $status = 'needs-replan'
        $salvageableTasks = @($remainingTasks | Where-Object { $_ -notin $blockedPlanTasks })
        if ($blockers.Count -eq 0) {
            $blockers.Add([pscustomobject]@{
                    type        = 'resource'
                    description = 'Resume mode ''abort'' was selected; carry salvageable tasks into the next iteration or abandonment closeout.'
                })
        }
    }
}

$timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
$normalizedLastCompletedTask = if (Test-IsNullish $lastCompletedTask) { $null } else { $lastCompletedTask }
$normalizedSalvageableTasks = if ($null -eq $salvageableTasks) { $null } else { @($salvageableTasks) }
$blockerItems = [object[]]$blockers.ToArray()
$result = New-Object psobject
$result | Add-Member -NotePropertyName 'status' -NotePropertyValue $status
$result | Add-Member -NotePropertyName 'resume_mode' -NotePropertyValue $ResumeMode
$result | Add-Member -NotePropertyName 'iteration_directory' -NotePropertyValue $resolvedIterationDirectory
$result | Add-Member -NotePropertyName 'last_completed_task' -NotePropertyValue $normalizedLastCompletedTask
$result | Add-Member -NotePropertyName 'in_progress_tasks' -NotePropertyValue @($inProgressTasks)
$result | Add-Member -NotePropertyName 'remaining_tasks' -NotePropertyValue @($remainingTasks)
$result | Add-Member -NotePropertyName 'next_suggested_task' -NotePropertyValue $nextSuggestedTask
$result | Add-Member -NotePropertyName 'next_recovery_action' -NotePropertyValue $nextRecoveryAction
$result | Add-Member -NotePropertyName 'blockers' -NotePropertyValue $blockerItems
$result | Add-Member -NotePropertyName 'salvageable_tasks' -NotePropertyValue $normalizedSalvageableTasks
$result | Add-Member -NotePropertyName 'repair_escalation' -NotePropertyValue $repairEscalation

if ($status -ne 'blocked' -and $stateExists) {
    $updatedStateLines = @($stateLines)
    $updatedInProgressTasks = @()
    $updatedRemainingTasks = @($remainingTasks)

    if ($ResumeMode -eq 'continue' -and $hasActiveEscalation) {
        $updatedInProgressTasks = @($inProgressTasks)
        $updatedRemainingTasks = @($remainingTasks)
    }
    elseif ($ResumeMode -eq 'continue' -and -not [string]::IsNullOrWhiteSpace($nextSuggestedTask)) {
        if ($inProgressTasks.Count -gt 0) {
            $updatedInProgressTasks = @($inProgressTasks)
        }
        else {
            $updatedInProgressTasks = @($nextSuggestedTask)
            $updatedRemainingTasks = @($updatedRemainingTasks | Where-Object { $_ -ne $nextSuggestedTask })
        }
    }
    elseif ($ResumeMode -in @('replan', 'abort')) {
        $updatedRemainingTasks = @($remainingTasks)
    }

    $updatedStateLines = Set-MarkdownMetadataValue -Lines $updatedStateLines -Label 'Tasks Remaining' -Value $(if ($updatedRemainingTasks.Count -gt 0) { $updatedRemainingTasks -join ', ' } else { '(none)' })
    $updatedStateLines = Set-MarkdownMetadataValue -Lines $updatedStateLines -Label 'In Progress' -Value $(if ($updatedInProgressTasks.Count -gt 0) { $updatedInProgressTasks -join ', ' } else { '(none)' })
    $updatedStateLines = Set-MarkdownMetadataValue -Lines $updatedStateLines -Label 'Updated' -Value $timestamp
    $stateContent = ($updatedStateLines -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine

    $resumeReportLines = @(
        '## Resume Report'
        ''
        ('- **Timestamp**: {0}' -f $timestamp)
        ('- **Mode**: {0}' -f $ResumeMode)
        ('- **Status**: {0}' -f $status)
        ('- **Last Completed Task**: {0}' -f $(if (Test-IsNullish $lastCompletedTask) { '(none)' } else { $lastCompletedTask }))
        ('- **Next Suggested Task**: {0}' -f $(if ([string]::IsNullOrWhiteSpace($nextSuggestedTask)) { '(none)' } else { $nextSuggestedTask }))
        ('- **Next Recovery Action**: {0}' -f $(if ([string]::IsNullOrWhiteSpace($nextRecoveryAction)) { '(none)' } else { $nextRecoveryAction }))
        ('- **In-Progress Tasks**: {0}' -f $(if ($inProgressTasks.Count -gt 0) { $inProgressTasks -join ', ' } else { '(none)' }))
        ('- **Remaining Tasks**: {0}' -f $(if ($remainingTasks.Count -gt 0) { $remainingTasks -join ', ' } else { '(none)' }))
        ('- **Repair Escalation**: {0}' -f $(if ($hasActiveEscalation) { '{0} | owner={1} | tier={2} | failures={3} | locked_out={4}' -f $repairEscalation.artifact, $repairEscalation.current_owner, $repairEscalation.current_tier, $repairEscalation.failure_count, $(if ($repairEscalation.locked_out_agents.Count -gt 0) { $repairEscalation.locked_out_agents -join ', ' } else { '(none)' }) } else { 'inactive' }))
        ('- **Blockers**: {0}' -f $(if ($blockers.Count -gt 0) { ($blockers | ForEach-Object { $_.description }) -join ' | ' } else { '(none)' }))
        ('- **Salvageable Tasks**: {0}' -f $(if ($null -ne $salvageableTasks -and $salvageableTasks.Count -gt 0) { $salvageableTasks -join ', ' } elseif ($null -ne $salvageableTasks) { '(none)' } else { 'n/a' }))
    )

    $stateContent = Set-ManagedBlock -Content $stateContent -BlockName 'resume-report' -BlockContent ($resumeReportLines -join [Environment]::NewLine)
    if (-not $DryRun) {
        [System.IO.File]::WriteAllText($statePath, $stateContent, [System.Text.UTF8Encoding]::new($false))
    }
}

if ($PassThru) {
    $result
    return
}

$result | ConvertTo-Json -Depth 5
exit 0