scripts/internal/session-recovery.ps1

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

# Session-state + recovery functions extracted from specrew-start.ps1 (Feature 141
# iteration 002, FR-024) so they are dot-sourceable and unit-testable. specrew-start.ps1
# dot-sources this helper as a compatibility wrapper; tests dot-source it standalone.
$srSharedGovernance = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'extensions\specrew-speckit\scripts\shared-governance.ps1'
if (Test-Path -LiteralPath $srSharedGovernance -PathType Leaf) { . $srSharedGovernance }
$srSyncBoundary = Join-Path $PSScriptRoot 'sync-boundary-state.ps1'
if (Test-Path -LiteralPath $srSyncBoundary -PathType Leaf) { . $srSyncBoundary }

function Get-SpecrewConfigValue {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProjectRoot,

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

    $configPath = Join-Path $ProjectRoot '.specrew\config.yml'
    if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) {
        return $null
    }

    foreach ($line in Get-Content -LiteralPath $configPath -Encoding UTF8) {
        if ($line -match ('^\s*{0}:\s*"?(?<value>[^"#]+?)"?\s*$' -f [regex]::Escape($Key))) {
            return $Matches['value'].Trim()
        }
    }

    return $null
}

function Get-SpecrewPromptSessionState {
    param([Parameter(Mandatory = $true)][string]$ProjectRoot)

    $paths = Get-SpecrewSessionStatePaths -ProjectRoot $ProjectRoot
    if (-not (Test-Path -LiteralPath $paths.PromptPath -PathType Leaf)) {
        return $null
    }

    $parsed = ConvertFrom-SpecrewFrontmatter -Content (Get-Content -LiteralPath $paths.PromptPath -Raw -Encoding UTF8)
    return Get-SpecrewSessionStateFromFrontmatter -Frontmatter $parsed.Frontmatter
}

function Get-SpecrewIdentitySessionState {
    param([Parameter(Mandatory = $true)][string]$ProjectRoot)

    $paths = Get-SpecrewSessionStatePaths -ProjectRoot $ProjectRoot
    if (-not (Test-Path -LiteralPath $paths.IdentityPath -PathType Leaf)) {
        return $null
    }

    $parsed = ConvertFrom-SpecrewFrontmatter -Content (Get-Content -LiteralPath $paths.IdentityPath -Raw -Encoding UTF8)
    return Get-SpecrewSessionStateFromFrontmatter -Frontmatter $parsed.Frontmatter
}

function Get-SpecrewStartContextSessionState {
    param([Parameter(Mandatory = $true)][string]$ProjectRoot)

    $paths = Get-SpecrewSessionStatePaths -ProjectRoot $ProjectRoot
    if (-not (Test-Path -LiteralPath $paths.ContextPath -PathType Leaf)) {
        return $null
    }

    # -AsHashtable is critical here: legacy start-context.json files from
    # pre-F-020 projects (initialized at 0.19.0 or earlier) do NOT have the
    # session_state field. With ConvertFrom-Json producing PSCustomObject,
    # Set-StrictMode -Version Latest throws on the missing-property access.
    # Hashtable indexer returns $null for missing keys without throwing,
    # which is the migration-tolerant semantics we want here.
    try {
        $context = Get-Content -LiteralPath $paths.ContextPath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 12 -AsHashtable
    }
    catch {
        if (Test-IsUnsupportedSpecrewSchemaError -ErrorRecord $_) {
            throw
        }
        return $null
    }

    $schema = Get-SpecrewStateSchemaVersion -State $context -Path $paths.ContextPath
    # v0/v1 behavior: session_state payload remains optional for legacy workspaces

    if ($null -eq $context -or $null -eq $context['session_state']) {
        return $null
    }

    $sessionState = $context['session_state']
    return [pscustomobject]@{
        active           = if ($sessionState['active']) { 'true' } else { 'false' }
        boundary_type    = [string]$sessionState['boundary_type']
        feature_ref      = [string]$sessionState['feature_ref']
        feature_path     = [string]$sessionState['feature_path']
        iteration_number = [string]$sessionState['iteration_number']
        task_id          = [string]$sessionState['task_id']
        auth_commit_hash = [string]$sessionState['auth_commit_hash']
        recorded_at      = [string]$sessionState['recorded_at']
    }
}

function Get-SpecrewSessionStateSnapshot {
    param([Parameter(Mandatory = $true)][string]$ProjectRoot)

    $promptState = Get-SpecrewPromptSessionState -ProjectRoot $ProjectRoot
    $contextState = Get-SpecrewStartContextSessionState -ProjectRoot $ProjectRoot
    $identityState = Get-SpecrewIdentitySessionState -ProjectRoot $ProjectRoot
    $decisionsState = Get-LatestSpecrewBoundarySyncState -ProjectRoot $ProjectRoot
    $states = @(
        foreach ($candidate in @($promptState, $contextState, $identityState, $decisionsState)) {
            if ($null -ne $candidate) {
                $candidate
            }
        }
    )

    # File paths surfaced so Test-SpecrewSessionStateConsistency can distinguish
    # "file absent on disk" from "file present but stale/unparseable" — fixes the
    # misleading "missing or unreadable" message from tip-calc-v2 dogfooding 2026-05-23.
    $resolvedProjectRoot = Resolve-ProjectPath -Path $ProjectRoot

    return [pscustomobject]@{
        prompt         = $promptState
        prompt_path    = Join-Path $resolvedProjectRoot '.specrew\last-start-prompt.md'
        context        = $contextState
        context_path   = Join-Path $resolvedProjectRoot '.specrew\start-context.json'
        identity       = $identityState
        identity_path  = Join-Path $resolvedProjectRoot '.squad\identity\now.md'
        decisions      = $decisionsState
        session_state  = if ($states.Count -gt 0) { $states[0] } else { $null }
    }
}

function Test-SpecrewFeatureMergedToMain {
    param(
        [Parameter(Mandatory = $true)][string]$ProjectRoot,
        [AllowNull()][string]$FeatureRef
    )

    # Strict merge detection (Feature 141 FR-024). Match the FULL feature ref slug
    # (e.g. "141-design-gate-runtime-hardening", which appears in PR-merge bodies as
    # "...from alonf/141-design-gate-runtime-hardening"), NEVER the bare numeric id.
    # Grepping the bare number falsely classified Feature 141 as merged because an
    # unrelated Feature 049 merge body said "Proposal 120 + 141" — proposal 141 is not
    # feature 141. --fixed-strings makes this an exact substring match (no regex), and
    # the Get-SpecrewFeatureNumber guard still rejects refs that lack the NNN- shape.
    $featureNumber = Get-SpecrewFeatureNumber -FeatureRef $FeatureRef
    if ([string]::IsNullOrWhiteSpace($featureNumber)) {
        return [pscustomobject]@{ IsMerged = $false; Detail = $null }
    }

    $bootstrapDate = Get-SpecrewConfigValue -ProjectRoot $ProjectRoot -Key 'bootstrap_date'
    if ([string]::IsNullOrWhiteSpace($bootstrapDate)) {
        $bootstrapDate = '90 days ago'
    }

    $logOutput = @(& git -C $ProjectRoot log main --since="$bootstrapDate" --merges --oneline --fixed-strings --grep="$FeatureRef" 2>&1)
    if ($LASTEXITCODE -ne 0) {
        return [pscustomobject]@{ IsMerged = $false; Detail = $null }
    }

    if ($logOutput.Count -gt 0) {
        return [pscustomobject]@{
            IsMerged = $true
            Detail   = ('Feature {0} appears in merge history on main: {1}' -f $FeatureRef, ($logOutput[0].ToString().Trim()))
        }
    }

    return [pscustomobject]@{ IsMerged = $false; Detail = $null }
}

function Test-SpecrewFeatureBranchExists {
    param(
        [Parameter(Mandatory = $true)][string]$ProjectRoot,
        [AllowNull()][string]$FeatureRef
    )

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

    # 2>$null: in a non-repo (or when the ref is absent) git writes "fatal: not a git
    # repository" / "fatal: bad ref" to stderr. The decision is taken purely from
    # $LASTEXITCODE, so silence the stderr to keep test transcripts clean (it otherwise
    # leaks through to the FR-024 unit test's non-repo temp fixtures).
    & git -C $ProjectRoot show-ref --verify --quiet ("refs/heads/{0}" -f $FeatureRef) 2>$null
    if ($LASTEXITCODE -eq 0) {
        return $true
    }

    & git -C $ProjectRoot show-ref --verify --quiet ("refs/remotes/origin/{0}" -f $FeatureRef) 2>$null
    return ($LASTEXITCODE -eq 0)
}

function Test-SpecrewAuthorizationRecord {
    param(
        [Parameter(Mandatory = $true)][string]$ProjectRoot,
        [pscustomobject]$SessionState
    )

    if ($null -eq $SessionState -or [string]::IsNullOrWhiteSpace([string]$SessionState.feature_ref)) {
        return $true
    }

    $paths = Get-SpecrewSessionStatePaths -ProjectRoot $ProjectRoot
    if (-not (Test-Path -LiteralPath $paths.DecisionsPath -PathType Leaf)) {
        return $false
    }

    $content = Get-Content -LiteralPath $paths.DecisionsPath -Raw -Encoding UTF8
    if (-not [string]::IsNullOrWhiteSpace([string]$SessionState.auth_commit_hash) -and $content -match [regex]::Escape([string]$SessionState.auth_commit_hash)) {
        return $true
    }

    $featureNumber = Get-SpecrewFeatureNumber -FeatureRef $SessionState.feature_ref
    if ([string]::IsNullOrWhiteSpace($featureNumber)) {
        return $false
    }

    return ($content -match ('Feature\s+{0}' -f [regex]::Escape($featureNumber)) -and $content -match 'authorization')
}

function Test-SpecrewSessionStateConsistency {
    param([Parameter(Mandatory = $true)][pscustomobject]$Snapshot)

    $issues = New-Object System.Collections.Generic.List[string]
    # Each entry now optionally carries a Path so we can distinguish "file absent on disk"
    # from "file present but unparseable / stale frontmatter". Wording fix following
    # tip-calc-v2 dogfooding 2026-05-23/24: the prior "missing or unreadable" message
    # fired even when the file was present and readable, just stale relative to the git
    # log — that misled the human into thinking the file had been deleted.
    $namedStates = @(
        @{ Name = 'last-start-prompt.md'; State = $Snapshot.prompt;   Path = $Snapshot.prompt_path }
        @{ Name = 'start-context.json';   State = $Snapshot.context;  Path = $Snapshot.context_path }
        @{ Name = 'identity/now.md';      State = $Snapshot.identity; Path = $Snapshot.identity_path }
    )

    $existingCount = @($namedStates | Where-Object { $null -ne $_.State }).Count
    if ($existingCount -gt 0) {
        foreach ($entry in $namedStates) {
            if ($null -eq $entry.State) {
                $fileOnDisk = $false
                if (-not [string]::IsNullOrWhiteSpace([string]$entry.Path)) {
                    $fileOnDisk = Test-Path -LiteralPath ([string]$entry.Path) -PathType Leaf
                }
                if ($fileOnDisk) {
                    $issues.Add(("Session-state file is present but stale or unparseable: {0} (file is on disk but its frontmatter / JSON could not be loaded; re-anchor or recreate to refresh)" -f $entry.Name)) | Out-Null
                }
                else {
                    $issues.Add(("Session-state file missing on disk: {0} (re-anchor will recreate it from the current spec)" -f $entry.Name)) | Out-Null
                }
            }
        }
    }

    $activeStates = @(
        foreach ($entry in $namedStates) {
            if ($null -ne $entry.State) {
                $entry.State
            }
        }
        if ($null -ne $Snapshot.decisions) {
            $Snapshot.decisions
        }
    )
    $featureRefs = @($activeStates | ForEach-Object { [string]$_.feature_ref } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
    if ($featureRefs.Count -gt 1) {
        $issues.Add(("Session-state feature mismatch detected: {0}" -f ($featureRefs -join ', '))) | Out-Null
    }

    $boundaries = @($activeStates | ForEach-Object { [string]$_.boundary_type } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
    if ($boundaries.Count -gt 1) {
        $issues.Add(("Session-state boundary mismatch detected: {0}" -f ($boundaries -join ', '))) | Out-Null
    }

    return $issues.ToArray()
}

function Get-SpecrewLatestIterationDirectory {
    param(
        [Parameter(Mandatory = $true)][string]$FeaturePath
    )

    $iterationsRoot = Join-Path $FeaturePath 'iterations'
    if (-not (Test-Path -LiteralPath $iterationsRoot -PathType Container)) {
        return $null
    }

    return @(
        Get-ChildItem -LiteralPath $iterationsRoot -Directory |
            Sort-Object Name -Descending |
            Select-Object -First 1
    )[0]
}

function Get-SpecrewMetadataValueFromFile {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][string]$Label
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        return $null
    }

    $pattern = '(?m)^\*\*' + [regex]::Escape($Label) + '\*\*:\s*(?<value>.+?)\s*$'
    $match = [regex]::Match((Get-Content -LiteralPath $Path -Raw -Encoding UTF8), $pattern)
    if ($match.Success) {
        return $match.Groups['value'].Value.Trim()
    }

    return $null
}

function Get-SpecrewLateBoundaryIssues {
    param(
        [Parameter(Mandatory = $true)][string]$ProjectRoot,
        [AllowNull()][pscustomobject]$SessionState
    )

    if ($null -eq $SessionState) {
        return @()
    }

    $issues = New-Object System.Collections.Generic.List[string]
    $featurePath = if (-not [string]::IsNullOrWhiteSpace([string]$SessionState.feature_path)) {
        [string]$SessionState.feature_path
    }
    elseif (-not [string]::IsNullOrWhiteSpace([string]$SessionState.feature_ref)) {
        Join-Path $ProjectRoot ('specs\' + [string]$SessionState.feature_ref)
    }
    else {
        $null
    }

    if ([string]::IsNullOrWhiteSpace($featurePath) -or -not (Test-Path -LiteralPath $featurePath -PathType Container)) {
        return @()
    }

    $latestIterationDirectory = Get-SpecrewLatestIterationDirectory -FeaturePath $featurePath
    if ($null -ne $latestIterationDirectory) {
        $reviewPath = Join-Path $latestIterationDirectory.FullName 'review.md'
        $reviewVerdict = Get-SpecrewMetadataValueFromFile -Path $reviewPath -Label 'Overall Verdict'
        if ($reviewVerdict -match '^(?i)accepted$' -and [string]$SessionState.boundary_type -notin @('review-signoff', 'retro', 'iteration-closeout', 'feature-closeout')) {
            $issues.Add(("Late boundary sync mismatch: review.md is accepted in iteration {0}, but the recorded boundary is '{1}' instead of review-signoff or later." -f $latestIterationDirectory.Name, $SessionState.boundary_type)) | Out-Null
        }

        $requireStateFile = [string]$SessionState.boundary_type -notin @('retro', 'iteration-closeout', 'feature-closeout')
        foreach ($stateTruthIssue in @(Get-SpecrewIterationStateTruthIssues -ProjectRoot $ProjectRoot -FeaturePath $featurePath -IterationNumber $latestIterationDirectory.Name -RequireStateFile:$requireStateFile)) {
            $issues.Add($stateTruthIssue) | Out-Null
        }
    }

    $closeoutDashboardPath = Join-Path $featurePath 'closeout-dashboard.md'
    if ((Test-Path -LiteralPath $closeoutDashboardPath -PathType Leaf) -and [string]$SessionState.boundary_type -ne 'feature-closeout') {
        $issues.Add(("Late boundary sync mismatch: closeout-dashboard.md exists for '{0}', but the recorded boundary is '{1}' instead of feature-closeout." -f (Split-Path -Leaf $featurePath), $SessionState.boundary_type)) | Out-Null
    }

    return $issues.ToArray()
}

function Test-SpecrewStaleSessionState {
    param([Parameter(Mandatory = $true)][string]$ProjectRoot)

    $snapshot = Get-SpecrewSessionStateSnapshot -ProjectRoot $ProjectRoot
    $sessionState = $snapshot.session_state
    if ($null -eq $sessionState) {
        return [pscustomobject]@{
            IsStale = $false
            Issues = @()
            SessionState = $null
        }
    }

    $issues = New-Object System.Collections.Generic.List[string]
    foreach ($issue in (Test-SpecrewSessionStateConsistency -Snapshot $snapshot)) {
        $issues.Add($issue) | Out-Null
    }

    foreach ($issue in (Get-SpecrewLateBoundaryIssues -ProjectRoot $ProjectRoot -SessionState $sessionState)) {
        $issues.Add($issue) | Out-Null
    }

    if ([string]$sessionState.active -eq 'false') {
        return [pscustomobject]@{
            IsStale      = ($issues.Count -gt 0)
            Issues       = $issues.ToArray()
            SessionState = $sessionState
        }
    }

    $mergeCheck = Test-SpecrewFeatureMergedToMain -ProjectRoot $ProjectRoot -FeatureRef $sessionState.feature_ref
    if ($mergeCheck.IsMerged) {
        $issues.Add($mergeCheck.Detail) | Out-Null
    }

    if (-not (Test-SpecrewFeatureBranchExists -ProjectRoot $ProjectRoot -FeatureRef $sessionState.feature_ref)) {
        $issues.Add(("Feature branch is missing: {0}" -f $sessionState.feature_ref)) | Out-Null
    }

    # FR-024 (2026-06-02 Linux smoke): the saved session feature path no longer exists
    # on disk, or it points outside the current worktree to a deleted/external worktree
    # (e.g., a completed/merged feature whose old worktree was removed). This is stale
    # runtime state — recovery must NOT re-anchor to this deleted external path.
    $savedFeaturePath = [string]$sessionState.feature_path
    if (-not [string]::IsNullOrWhiteSpace($savedFeaturePath) -and -not (Test-Path -LiteralPath $savedFeaturePath -PathType Container)) {
        $resolvedRoot = Resolve-ProjectPath -Path $ProjectRoot
        $isOutsideWorktree = -not ($savedFeaturePath -like (Join-Path $resolvedRoot '*'))
        $detail = if ($isOutsideWorktree) {
            "Saved session feature path no longer exists and is outside the current worktree: {0} (stale runtime state; do not re-anchor to this deleted/external worktree — clear the stale session reference instead)." -f $savedFeaturePath
        }
        else {
            "Saved session feature path no longer exists: {0} (stale runtime state; do not re-anchor to a missing path — clear the stale session reference instead)." -f $savedFeaturePath
        }
        $issues.Add($detail) | Out-Null
    }

    if (-not (Test-SpecrewAuthorizationRecord -ProjectRoot $ProjectRoot -SessionState $sessionState)) {
        $issues.Add(("Authorization record missing for {0}." -f $sessionState.feature_ref)) | Out-Null
    }

    return [pscustomobject]@{
        IsStale = ($issues.Count -gt 0)
        Issues = $issues.ToArray()
        SessionState = $sessionState
    }
}

function Read-SpecrewRecoveryChoice {
    param([AllowNull()][string]$PreferredChoice)

    if (-not [string]::IsNullOrWhiteSpace($PreferredChoice)) {
        return $PreferredChoice.Trim().ToUpperInvariant()
    }

    while ($true) {
        $selection = Read-Host 'Choose recovery path [A/B/C]'
        if (-not [string]::IsNullOrWhiteSpace($selection)) {
            $normalizedSelection = $selection.Trim().ToUpperInvariant()
            if ($normalizedSelection -in @('A', 'B', 'C')) {
                return $normalizedSelection
            }
        }

        Write-Output "WARN: Invalid recovery choice. Enter A, B, or C." | Out-Host
    }
}

function New-SpecrewRecoverySession {
    param(
        [Parameter(Mandatory = $true)][string]$EntryMode,
        [Parameter(Mandatory = $true)][string[]]$StaleReasons,
        [Parameter(Mandatory = $true)][bool]$BypassGate,
        [AllowNull()][string]$SelectedChoice,
        [Parameter(Mandatory = $true)][string]$NextActionMessage
    )

    return [pscustomobject]@{
        entry_mode              = $EntryMode
        stale_reasons           = @($StaleReasons)
        choice_set              = if ($EntryMode -eq 'detected-stale-state') { @('A', 'B', 'C') } else { @('recover') }
        selected_choice         = $SelectedChoice
        bypass_gate             = $BypassGate
        approval_mode_changed   = $false
        next_action_message     = $NextActionMessage
    }
}

function Resolve-SpecrewRecoverySelection {
    param(
        [Parameter(Mandatory = $true)][string]$Choice,
        [AllowNull()][pscustomobject]$SessionState
    )

    $recoveryFeaturePath = if ($null -ne $SessionState -and -not [string]::IsNullOrWhiteSpace([string]$SessionState.feature_path)) {
        [string]$SessionState.feature_path
    }
    else {
        $null
    }

    switch ($Choice) {
        'A' {
            # FR-024 (2026-06-02 Linux smoke): never re-anchor to a saved feature path
            # that no longer exists (a deleted/external worktree such as C:\Dev\Specrew-051).
            # Route to confirm-gated safe cleanup of the stale runtime references instead.
            $featurePathMissing = (-not [string]::IsNullOrWhiteSpace($recoveryFeaturePath)) -and (-not (Test-Path -LiteralPath $recoveryFeaturePath -PathType Container))
            if ($featurePathMissing) {
                return [pscustomobject]@{
                    ResumeFeatureOverride            = $null
                    SkipAutoResume                   = $true
                    ForceNoLaunch                    = $false
                    NextActionMessage                = "Recovery will NOT re-anchor to '$recoveryFeaturePath' because that worktree no longer exists. With your confirmation it will clear only the stale active-session/start-context references — no feature artifacts are touched and no lifecycle commits are made."
                    Directive                        = "Recovery choice A on a missing feature path: do NOT re-anchor to the deleted/external worktree '$recoveryFeaturePath'. Report the current branch, the stale feature refs, and the selected active-feature candidate, then require explicit human confirmation before clearing the stale active-sessions/start-context references. Do not touch feature artifacts and do not make lifecycle commits."
                    RequiresStaleCleanupConfirmation = $true
                    StaleFeaturePath                 = $recoveryFeaturePath
                }
            }
            return [pscustomobject]@{
                ResumeFeatureOverride = if (-not [string]::IsNullOrWhiteSpace($recoveryFeaturePath)) { $recoveryFeaturePath } else { 'auto' }
                SkipAutoResume        = $false
                ForceNoLaunch         = $false
                NextActionMessage     = if (-not [string]::IsNullOrWhiteSpace($recoveryFeaturePath)) {
                    "Recovery will re-anchor to '$recoveryFeaturePath' so you can repair or continue the last known feature state."
                }
                else {
                    'Recovery will try to re-anchor to the last known feature automatically so you can repair or continue.'
                }
                Directive             = 'Recovery choice A selected: re-anchor to the last known feature, inspect the stale-state evidence, and continue with an explicit repair or resume plan.'
            }
        }
        'B' {
            return [pscustomobject]@{
                ResumeFeatureOverride = $null
                SkipAutoResume        = $true
                ForceNoLaunch         = $false
                NextActionMessage     = 'Recovery will bypass the stale feature state and return you to fresh feature intake.'
                Directive             = 'Recovery choice B selected: do not resume the stale feature automatically. Start fresh intake for a new feature after acknowledging the stale-state evidence.'
            }
        }
        default {
            return [pscustomobject]@{
                ResumeFeatureOverride = $null
                SkipAutoResume        = $true
                ForceNoLaunch         = $true
                NextActionMessage     = 'Recovery will stop after writing diagnostics so you can manually fix or document the stale state before restarting.'
                Directive             = 'Recovery choice C selected: do not launch the host CLI automatically. Review the recorded stale-state evidence, repair the session-state artifacts manually, then rerun specrew start.'
            }
        }
    }
}

function Clear-SpecrewStaleSessionReference {
    <#
    .SYNOPSIS
    FR-024 confirm-gated cleanup of stale runtime session references.
 
    .DESCRIPTION
    When a cross-worktree session resumes against a completed/merged/deleted feature
    whose feature_path no longer exists (see Test-SpecrewStaleSessionState and the
    Resolve-SpecrewRecoverySelection choice-A guard), the human is asked to confirm
    cleanup. On confirmation this function clears ONLY the runtime session references
    that would otherwise re-anchor the next start:
      - start-context.json -> session_state (active=false, feature_ref/feature_path/iteration/task cleared)
      - active-sessions.yml -> the matching feature's session entry (removed)
    It NEVER touches feature artifacts under specs/** and NEVER makes git/lifecycle
    commits. Without -Confirmed it is a no-op (returns Cleared=$false, Reason=confirmation-required).
    #>

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

        [AllowNull()]
        [AllowEmptyString()]
        [string]$StaleFeatureRef,

        [switch]$Confirmed
    )

    if (-not $Confirmed) {
        return [pscustomobject]@{
            Cleared          = $false
            Reason           = 'confirmation-required'
            ClearedRefs      = @()
            TouchedArtifacts = $false
            MadeCommits      = $false
        }
    }

    if (Get-Command -Name 'Resolve-ProjectPath' -ErrorAction SilentlyContinue) {
        $resolvedRoot = Resolve-ProjectPath -Path $ProjectRoot
    } else {
        $resolvedRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path
    }

    $clearedRefs = New-Object System.Collections.Generic.List[string]

    # 1. start-context.json: clear the stale session_state so the next start does not re-anchor.
    $contextPath = Join-Path $resolvedRoot '.specrew/start-context.json'
    if (Test-Path -LiteralPath $contextPath -PathType Leaf) {
        try {
            $context = Get-Content -LiteralPath $contextPath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 25 -AsHashtable
            if ($null -ne $context -and $context.ContainsKey('session_state') -and $null -ne $context['session_state']) {
                $context['session_state']['active'] = $false
                foreach ($key in 'feature_ref', 'feature_path', 'iteration_number', 'task_id') {
                    if ($context['session_state'].ContainsKey($key)) { $context['session_state'][$key] = '' }
                }
                $json = ConvertTo-Json -InputObject $context -Depth 25
                [System.IO.File]::WriteAllText($contextPath, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
                $clearedRefs.Add('start-context.json:session_state') | Out-Null
            }
        } catch {
            # Leave start-context untouched on parse failure rather than corrupt it.
        }
    }

    # 2. active-sessions.yml: remove the stale feature's session entry (line-based block removal).
    $activePath = Join-Path $resolvedRoot '.specrew/active-sessions.yml'
    if ((Test-Path -LiteralPath $activePath -PathType Leaf) -and -not [string]::IsNullOrWhiteSpace($StaleFeatureRef)) {
        $sourceLines = @(Get-Content -LiteralPath $activePath -Encoding UTF8)
        $out = New-Object System.Collections.Generic.List[string]
        $removing = $false
        $entryIndent = -1
        $headerPattern = '^-\s+feature_id:\s*"?' + [regex]::Escape($StaleFeatureRef) + '"?\s*$'
        foreach ($line in $sourceLines) {
            $trimmed = $line.TrimStart()
            $indent = $line.Length - $trimmed.Length
            if ($removing) {
                $isSiblingDash = $trimmed.StartsWith('- ') -and $indent -le $entryIndent
                $isDedentKey = ($indent -le $entryIndent) -and ($trimmed.Length -gt 0) -and (-not $trimmed.StartsWith('-'))
                if ($isSiblingDash -or $isDedentKey) {
                    $removing = $false
                }
            }
            if (-not $removing -and $trimmed -match $headerPattern) {
                $removing = $true
                $entryIndent = $indent
                continue
            }
            if ($removing) { continue }
            $out.Add($line)
        }
        if ($out.Count -ne $sourceLines.Count) {
            [System.IO.File]::WriteAllText($activePath, ($out -join [Environment]::NewLine) + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
            $clearedRefs.Add('active-sessions.yml:' + $StaleFeatureRef) | Out-Null
        }
    }

    return [pscustomobject]@{
        Cleared          = ($clearedRefs.Count -gt 0)
        Reason           = if ($clearedRefs.Count -gt 0) { 'cleared' } else { 'no-matching-references' }
        ClearedRefs      = $clearedRefs.ToArray()
        TouchedArtifacts = $false
        MadeCommits      = $false
    }
}

function Invoke-SpecrewStaleSessionCleanupDecision {
    <#
    .SYNOPSIS
    FR-024 enforcement bridge: act on a recovery plan that requested confirm-gated cleanup.
 
    .DESCRIPTION
    Pure decision function (no I/O of its own) so the start flow's enforcement is unit-testable.
    Only acts when the recovery plan set RequiresStaleCleanupConfirmation. The caller is
    responsible for collecting the human confirmation (e.g. Read-SpecrewYesNo) and passing it
    as -Confirmed. When confirmed, delegates to Clear-SpecrewStaleSessionReference. Returns a
    record describing whether cleanup was attempted, confirmed, and the cleanup result.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject]$RecoveryPlan,

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

        [AllowNull()]
        [pscustomobject]$SessionState,

        [Parameter(Mandatory = $true)]
        [bool]$Confirmed
    )

    $requiresCleanup = ($RecoveryPlan.PSObject.Properties.Name -contains 'RequiresStaleCleanupConfirmation') -and `
        [bool]$RecoveryPlan.RequiresStaleCleanupConfirmation
    if (-not $requiresCleanup) {
        return [pscustomobject]@{ Attempted = $false; Confirmed = $false; Result = $null }
    }

    if (-not $Confirmed) {
        return [pscustomobject]@{ Attempted = $true; Confirmed = $false; Result = $null }
    }

    $staleRef = if ($null -ne $SessionState -and -not [string]::IsNullOrWhiteSpace([string]$SessionState.feature_ref)) {
        [string]$SessionState.feature_ref
    }
    else {
        $null
    }
    $result = Clear-SpecrewStaleSessionReference -ProjectRoot $ProjectRoot -StaleFeatureRef $staleRef -Confirmed
    return [pscustomobject]@{ Attempted = $true; Confirmed = $true; Result = $result }
}