workflows/default/systems/runtime/modules/MergeConflictEscalation.psm1

<#
.SYNOPSIS
    Shared helper for escalating merge-conflict failures to needs-input and
    notifying external stakeholders (see issue #224).
#>


function Move-TaskToMergeConflictNeedsInput {
    <#
    .SYNOPSIS
    Move a task from done/ to needs-input/ with a merge-conflict pending_question
    and dispatch an external notification when configured.

    .PARAMETER BotRoot
    The `.bot` root directory (matches the convention used by WorktreeManager,
    Get-NotificationSettings, and the runtime process types). Defaults to
    `$global:DotbotProjectRoot/.bot`.

    .OUTPUTS
    @{ success; new_path; notified; notification_silent; notification_reason }
    notification_silent is $true when the project hasn't opted into notifications
    (no NotificationClient module or settings.enabled = $false).
    #>

    param(
        [Parameter(Mandatory)] [string] $TaskId,
        [Parameter(Mandatory)] [string] $TasksBaseDir,
        [Parameter(Mandatory)] [object] $MergeResult,
        [Parameter(Mandatory)] [string] $WorktreePath,
        [string] $BotRoot
    )

    if (-not $BotRoot) {
        if (-not $global:DotbotProjectRoot) {
            throw "Move-TaskToMergeConflictNeedsInput: BotRoot not provided and \$global:DotbotProjectRoot is not set"
        }
        $BotRoot = Join-Path $global:DotbotProjectRoot '.bot'
    }

    $doneDir = Join-Path $TasksBaseDir "done"
    $needsInputDir = Join-Path $TasksBaseDir "needs-input"

    $taskFile = Get-ChildItem -LiteralPath $doneDir -Filter "*.json" -File -ErrorAction SilentlyContinue | Where-Object {
        try {
            $c = Get-Content $_.FullName -Raw | ConvertFrom-Json
            $c.id -eq $TaskId
        } catch { $false }
    } | Select-Object -First 1

    if (-not $taskFile) {
        return @{
            success             = $false
            new_path            = $null
            notified            = $false
            notification_silent = $false
            notification_reason = "Task file not found in done/"
        }
    }

    # TODO(#224): delegate to Move-TaskState once it accepts `done` as a FromState.
    $taskContent = Get-Content $taskFile.FullName -Raw | ConvertFrom-Json
    $taskContent.status = 'needs-input'
    $taskContent.updated_at = (Get-Date).ToUniversalTime().ToString("o")

    if (-not $taskContent.PSObject.Properties['pending_question']) {
        $taskContent | Add-Member -NotePropertyName 'pending_question' -NotePropertyValue $null -Force
    }

    $conflictFiles = @()
    if ($MergeResult -is [hashtable]) {
        if ($MergeResult.ContainsKey('conflict_files') -and $MergeResult['conflict_files']) {
            $conflictFiles = @($MergeResult['conflict_files'])
        }
    } elseif ($MergeResult.PSObject.Properties['conflict_files'] -and $MergeResult.conflict_files) {
        # Defensive only — Complete-TaskWorktree returns a [hashtable] in production.
        $conflictFiles = @($MergeResult.conflict_files)
    }
    $conflictDetail = if ($conflictFiles.Count -gt 0) { $conflictFiles -join '; ' } else { '(none reported)' }

    $taskContent.pending_question = @{
        id             = "merge-conflict"
        question       = "Merge conflict during squash-merge to main"
        context        = "Conflict details: $conflictDetail. Worktree preserved at: $WorktreePath"
        options        = @(
            @{ key = "A"; label = "Resolve manually and retry (recommended)"; rationale = "Inspect the worktree, resolve conflicts, then retry merge" }
            @{ key = "B"; label = "Discard task changes"; rationale = "Remove worktree and abandon this task's changes" }
            @{ key = "C"; label = "Retry with fresh rebase"; rationale = "Reset and attempt rebase again" }
        )
        recommendation = "A"
        asked_at       = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
    }

    # Close the open execution session by walking the task's history. The env var
    # $env:CLAUDE_SESSION_ID is nulled by both runtime workers before the merge,
    # so we cannot rely on it. Best-effort — never block escalation.
    try {
        $sessionModule = Join-Path $BotRoot 'systems' | Join-Path -ChildPath 'mcp' | Join-Path -ChildPath 'modules' | Join-Path -ChildPath 'SessionTracking.psm1'
        if ((Test-Path $sessionModule) -and $taskContent.PSObject.Properties['execution_sessions']) {
            $openSession = @($taskContent.execution_sessions) | Where-Object {
                $_ -and $_.id -and (-not $_.ended_at)
            } | Select-Object -Last 1
            if ($openSession) {
                Import-Module $sessionModule -Force
                if (Get-Command Close-SessionOnTask -ErrorAction SilentlyContinue) {
                    Close-SessionOnTask -TaskContent $taskContent -SessionId $openSession.id -Phase 'execution'
                }
            }
        }
    } catch {
        if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) {
            Write-BotLog -Level Debug -Message "Merge-conflict session close failed" -Exception $_
        }
    }

    if (-not (Test-Path $needsInputDir)) {
        New-Item -ItemType Directory -Force -Path $needsInputDir | Out-Null
    }
    $newPath = Join-Path $needsInputDir $taskFile.Name
    $taskContent | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $newPath -Encoding UTF8
    try {
        Remove-Item -LiteralPath $taskFile.FullName -Force -ErrorAction Stop
    } catch {
        # Rollback: remove the newly written file to avoid split-brain (task in both done/ and needs-input/)
        Remove-Item -LiteralPath $newPath -Force -ErrorAction SilentlyContinue
        throw
    }

    $notified = $false
    $silent = $true
    $reason = "Notifications disabled"
    $notifModule = Join-Path $BotRoot 'systems' | Join-Path -ChildPath 'mcp' | Join-Path -ChildPath 'modules' | Join-Path -ChildPath 'NotificationClient.psm1'
    try {
        if (Test-Path $notifModule) {
            Import-Module $notifModule -Force
            # Module is present — any failure past this point is a real delivery
            # problem, NOT a silent opt-out. Flip $silent here so the catch below
            # surfaces unexpected errors instead of masking them.
            $silent = $false
            $settings = Get-NotificationSettings -BotRoot $BotRoot
            if (-not $settings.enabled) {
                # Explicit opt-out via settings.
                $silent = $true
                $reason = "Notifications disabled"
            } else {
                $sendResult = Send-TaskNotification -TaskContent $taskContent -PendingQuestion $taskContent.pending_question
                if ($sendResult -and $sendResult.success) {
                    $taskContent | Add-Member -NotePropertyName 'notification' -NotePropertyValue @{
                        question_id = $sendResult.question_id
                        instance_id = $sendResult.instance_id
                        channel     = $sendResult.channel
                        project_id  = $sendResult.project_id
                        sent_at     = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
                    } -Force
                    $taskContent | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $newPath -Encoding UTF8
                    $notified = $true
                    $reason = "Notification dispatched"
                } else {
                    $reason = if ($sendResult -and $sendResult.reason) { $sendResult.reason } else { "Send-TaskNotification failed" }
                }
            }
        } else {
            $reason = "NotificationClient module not found"
        }
    } catch {
        $reason = "Notification error: $($_.Exception.Message)"
        if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) {
            Write-BotLog -Level Debug -Message "Merge-conflict notification failed" -Exception $_
        }
    }

    return @{
        success             = $true
        new_path            = $newPath
        notified            = $notified
        notification_silent = $silent
        notification_reason = $reason
    }
}

function Invoke-MergeConflictEscalation {
    <#
    .SYNOPSIS
    Runtime-side wrapper around Move-TaskToMergeConflictNeedsInput. Owns
    Write-Status / Write-ProcessActivity emission so each caller collapses
    to a single call.
    #>

    param(
        [Parameter(Mandatory)] [object] $Task,
        [Parameter(Mandatory)] [string] $TasksBaseDir,
        [Parameter(Mandatory)] [object] $MergeResult,
        [Parameter(Mandatory)] [string] $WorktreePath,
        [string] $ProcId,
        [string] $BotRoot
    )

    if (-not $BotRoot) {
        if (-not $global:DotbotProjectRoot) {
            throw "Invoke-MergeConflictEscalation: BotRoot not provided and \$global:DotbotProjectRoot is not set"
        }
        $BotRoot = Join-Path $global:DotbotProjectRoot '.bot'
    }

    $emitActivity = {
        param($message)
        if ($ProcId -and (Get-Command Write-ProcessActivity -ErrorAction SilentlyContinue)) {
            Write-ProcessActivity -Id $ProcId -ActivityType "text" -Message $message
        }
    }

    $escalation = $null
    try {
        $escalation = Move-TaskToMergeConflictNeedsInput `
            -TaskId $Task.id `
            -TasksBaseDir $TasksBaseDir `
            -MergeResult $MergeResult `
            -WorktreePath $WorktreePath `
            -BotRoot $BotRoot
    } catch {
        $msg = "Merge-conflict escalation helper failed: $($_.Exception.Message)"
        if (Get-Command Write-Status -ErrorAction SilentlyContinue) {
            Write-Status $msg -Type Error
        }
        & $emitActivity "Escalation helper threw for $($Task.name): $($_.Exception.Message)"
        return @{
            success             = $false
            notified            = $false
            notification_silent = $false
            notification_reason = $msg
        }
    }

    if ($escalation -and $escalation.success) {
        $statusMsg = if ($escalation.notified) {
            "Task moved to needs-input for manual conflict resolution (stakeholders notified)"
        } else {
            "Task moved to needs-input for manual conflict resolution"
        }
        if (Get-Command Write-Status -ErrorAction SilentlyContinue) {
            Write-Status $statusMsg -Type Warn
        }
        & $emitActivity "Escalated merge conflict for $($Task.name); notified=$($escalation.notified)"

        # Surface real delivery failures; opt-out states stay quiet.
        if (-not $escalation.notified -and -not $escalation.notification_silent) {
            if (Get-Command Write-Status -ErrorAction SilentlyContinue) {
                Write-Status "Merge-conflict notification not delivered: $($escalation.notification_reason)" -Type Warn
            }
            & $emitActivity "Merge-conflict notification skipped for $($Task.name): $($escalation.notification_reason)"
        }
    } elseif ($escalation) {
        if (Get-Command Write-Status -ErrorAction SilentlyContinue) {
            Write-Status "Failed to escalate merge conflict: $($escalation.notification_reason)" -Type Error
        }
        & $emitActivity "Failed to escalate merge conflict for $($Task.name): $($escalation.notification_reason)"
    } else {
        if (Get-Command Write-Status -ErrorAction SilentlyContinue) {
            Write-Status "Merge-conflict escalation helper returned no result for $($Task.name)" -Type Error
        }
        & $emitActivity "Merge-conflict escalation helper returned null for $($Task.name)"
        $escalation = @{
            success             = $false
            notified            = $false
            notification_silent = $false
            notification_reason = "Helper returned no result"
        }
    }

    return $escalation
}

Export-ModuleMember -Function 'Move-TaskToMergeConflictNeedsInput', 'Invoke-MergeConflictEscalation'