scripts/internal/continuous-co-review/escalation-latch.ps1

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

# F-197 co-review ESCALATION-LATCH closure predicate.
#
# THE TRUST MODEL IS THE LATCH. A co-review escalation may be closed ONLY by the human, never by the agent. This
# predicate answers "has a human authorized closing THIS escalation?" by reading ONLY user-role transcript turns
# recorded AFTER the escalation was surfaced. The agent authors assistant turns and files — it cannot author a
# user turn — so it structurally cannot forge a closure (the adversarial (b) safety gate in
# tests/continuous-co-review/unit/escalation-latch.Tests.ps1). Every ambiguity (non-user role, missing/older/
# unparseable timestamp, any negation/hesitation, empty text) resolves to KEEP BLOCKING: a false-block is the
# harmless spin; a false-close would be a co-review silencer — the one failure that must never happen.

function Get-ContinuousCoReviewTurnField {
    param([AllowNull()]$Turn, [Parameter(Mandatory)][string]$Name)
    if ($null -eq $Turn) { return $null }
    if ($Turn -is [System.Collections.IDictionary]) { if ($Turn.Contains($Name)) { return $Turn[$Name] } return $null }
    $prop = $Turn.PSObject.Properties[$Name]
    if ($null -ne $prop) { return $prop.Value }
    return $null
}

function ConvertTo-ContinuousCoReviewUtc {
    param([AllowNull()][string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return $null }
    try {
        return [datetimeoffset]::Parse(
            $Value, [cultureinfo]::InvariantCulture,
            [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal)
    }
    catch { return $null }
}

function Test-ContinuousCoReviewEscalationHumanClosed {
    [CmdletBinding()]
    param(
        # When the escalation was surfaced to the human (ISO-8601 UTC). A closing decision must come AFTER this.
        [Parameter(Mandatory)][string]$SurfacedAtUtc,
        # Parsed transcript turns: objects/hashtables with role ('user'|'assistant'), text, timestamp (ISO UTC).
        # Supplied by the hook from the REAL transcript; the agent cannot inject a user-role turn here.
        [AllowNull()]$ConversationTurns
    )

    if ($null -eq $ConversationTurns) { return $false }
    $surfacedAt = ConvertTo-ContinuousCoReviewUtc -Value $SurfacedAtUtc
    if ($null -eq $surfacedAt) { return $false }   # unparseable surfacing time -> keep blocking

    # CONSERVATIVE matching, safety-biased.
    $authPattern = '(?i)\b(authoris|authoriz|approv|accept|defer|proceed|go ahead|sounds good|do it)'
    $negPattern = "(?i)(\bdo not\b|\bdon'?t\b|\bcannot\b|\bcan'?t\b|\bnot\b|\bno\b|\breject|\brefus|\bdeny\b|\bdisapprov|\bhold off\b|\bwait\b|\bstop\b)"
    # Machine-injected turns (Stop-hook feedback, system-reminders, tool results, interrupts) carry role='user'
    # but are NOT human decisions — and they routinely QUOTE the co-review block text (which contains
    # 'authorization'/'escalation'). Excluding them is a hard safety requirement: otherwise the hook's OWN block
    # text, echoed back as a 'user' turn, could self-close the escalation. A genuine human authorization never
    # contains these markers.
    $machinePattern = '(?i)(stop hook feedback:|<system-reminder>|\[request interrupted|this Stop followed material work|SPECREW-(VERDICT|STOP-BLOCK)|Specrew co-review|co-review navigator block)'

    foreach ($turn in @($ConversationTurns)) {
        if ($null -eq $turn) { continue }
        $role = ([string](Get-ContinuousCoReviewTurnField -Turn $turn -Name 'role')).Trim().ToLowerInvariant()
        if ($role -ne 'user') { continue }   # ONLY the human; the agent cannot author a user turn

        $ts = ConvertTo-ContinuousCoReviewUtc -Value ([string](Get-ContinuousCoReviewTurnField -Turn $turn -Name 'timestamp'))
        if ($null -eq $ts -or $ts -le $surfacedAt) { continue }   # must be a FRESH decision about THIS escalation

        $text = [string](Get-ContinuousCoReviewTurnField -Turn $turn -Name 'text')
        if ([string]::IsNullOrWhiteSpace($text)) { continue }
        if ($text -match $machinePattern) { continue }   # machine-injected user turn (hook feedback / reminder), NOT a human decision
        if ($text -match $negPattern) { continue }   # any refusal/hesitation -> keep blocking
        if ($text -match $authPattern) { return $true }
    }
    return $false
}

function Test-ContinuousCoReviewEscalationStopBlockClosed {
    # The WIRING-SAFE wrapper the navigator calls. Suppresses a co-review stop-block ONLY when it is safe to:
    # 1. there is at least one blocking finding, AND
    # 2. EVERY blocking finding is a loop-state escalation (kind='escalation') — a single non-escalation blocking
    # finding (a real bug) keeps the block, so the latch can NEVER silence a bug, only the human-decision
    # escalation it is scoped to (the advisor's case (c)), AND
    # 3. a real human user-turn closed it (Test-...HumanClosed — the (b) forgery gate).
    # Any failure of any clause -> KEEP BLOCKING. Default-deny.
    [CmdletBinding()]
    param(
        [AllowNull()]$BlockingFindings,
        [Parameter(Mandatory)][string]$SurfacedAtUtc,
        [AllowNull()]$ConversationTurns
    )
    $blocking = @(@($BlockingFindings) | Where-Object {
            $null -ne $_ -and ([string](Get-ContinuousCoReviewTurnField -Turn $_ -Name 'severity')) -eq 'blocking'
        })
    if ($blocking.Count -eq 0) { return $false }
    foreach ($f in $blocking) {
        if (([string](Get-ContinuousCoReviewTurnField -Turn $f -Name 'kind')) -ne 'escalation') { return $false }
    }
    return (Test-ContinuousCoReviewEscalationHumanClosed -SurfacedAtUtc $SurfacedAtUtc -ConversationTurns $ConversationTurns)
}

function Get-ContinuousCoReviewTranscriptTurns {
    # Parse the transcript tail into {role, text, timestamp} turns for the latch. Reuses the TESTED
    # ConversationCaptureAccessor helpers (schema variance across claude/codex/cursor/copilot), lazy-loading them
    # if the navigator's context did not. Fail-open to @() -> the latch then default-denies (keeps blocking), so a
    # transcript-format surprise can only ever leave the block intact, never silence one.
    param([string]$TranscriptPath, [int]$MaxLines = 400)
    if ([string]::IsNullOrWhiteSpace($TranscriptPath)) { return @() }
    if (-not (Get-Command -Name 'Get-SpecrewTranscriptTailLines' -ErrorAction SilentlyContinue)) {
        try {
            $acc = Join-Path (Split-Path $PSScriptRoot -Parent) (Join-Path 'bootstrap' 'ConversationCaptureAccessor.ps1')
            if (Test-Path -LiteralPath $acc -PathType Leaf) { . $acc }
        }
        catch { $null = $_ }
    }
    if (-not (Get-Command -Name 'Get-SpecrewTranscriptTailLines' -ErrorAction SilentlyContinue)) { return @() }
    $lines = @()
    try { $lines = @(Get-SpecrewTranscriptTailLines -Path $TranscriptPath -MaxLines $MaxLines) } catch { return @() }
    $out = [System.Collections.ArrayList]::new()
    foreach ($line in $lines) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        try {
            $obj = $line | ConvertFrom-Json -ErrorAction Stop
            $msg = Get-SpecrewConversationProp -Object $obj -Name 'message'
            $role = [string](Get-SpecrewConversationProp -Object $obj -Name 'role')
            if ([string]::IsNullOrWhiteSpace($role)) { $role = [string](Get-SpecrewConversationProp -Object $obj -Name 'type') }
            if ([string]::IsNullOrWhiteSpace($role) -and $null -ne $msg) { $role = [string](Get-SpecrewConversationProp -Object $msg -Name 'role') }
            $content = Get-SpecrewConversationProp -Object $obj -Name 'content'
            if ($null -eq $content -and $null -ne $msg) { $content = Get-SpecrewConversationProp -Object $msg -Name 'content' }
            $text = ''
            if ($content -is [string]) { $text = $content }
            elseif ($null -ne $content) {
                $text = (@(foreach ($blk in @($content)) {
                            if ($blk -is [string]) { $blk }
                            elseif ($null -ne $blk) { $bt = [string](Get-SpecrewConversationProp -Object $blk -Name 'text'); if (-not [string]::IsNullOrWhiteSpace($bt)) { $bt } }
                        }) -join ' ')
            }
            $text = $text.Trim()
            $tsRaw = Get-SpecrewConversationProp -Object $obj -Name 'timestamp'
            $ts = if ($tsRaw -is [datetime]) { ([datetimeoffset]$tsRaw).ToUniversalTime().ToString('o') } elseif ($tsRaw -is [datetimeoffset]) { $tsRaw.ToUniversalTime().ToString('o') } else { [string]$tsRaw }
            [void]$out.Add([pscustomobject]@{ role = $role; text = $text; timestamp = $ts })
        }
        catch { continue }
    }
    return @($out)
}