Public/Invoke-AITDebate.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

<#
.SYNOPSIS
    Runs a structured multi-perspective AI debate using the shared debate library.
.DESCRIPTION
    Orchestrates a full debate with Accelerationist (accelerationist), Safetyist (safetyist),
    and Skeptic (skeptic) POVers. Produces debate transcript, diagnostics, and harvest
    output files. Uses the same prompts, logic, and argumentation framework as the
    Taxonomy Editor's debate tool.
.PARAMETER FeatureFlags
    Hashtable of feature flags applied for the run. Known keys (case-insensitive):
    Clarification, Probing, SalienceBeacon, ExploreFirst, AdaptiveStaging,
    EarlyTermination, TurnValidation. Unknown keys throw an ActionableError.
    Explicit switches (-Clarify, -Probe, -AdaptiveStaging, -DisableTurnValidation)
    take precedence over the hashtable when both are provided.
.PARAMETER ConfrontationRounds
    Max rounds for the confrontation phase (1-10). When specified without
    -AdaptiveStaging, disables adaptive staging automatically so these counts
    are treated as hard limits.
.PARAMETER ArgumentationRounds
    Max rounds for the argumentation phase (1-10). When specified without
    -AdaptiveStaging, disables adaptive staging automatically so these counts
    are treated as hard limits.
.PARAMETER ConcludingRounds
    Max rounds for the concluding phase (1-10). When specified without
    -AdaptiveStaging, disables adaptive staging automatically so these counts
    are treated as hard limits.
.EXAMPLE
    Invoke-AITDebate -Topic "Should the US impose AI licensing?" -Rounds 3
.EXAMPLE
    Invoke-AITDebate -Topic "Scaling limits" -Name "Scaling Debate" -Rounds 4 -Model gemini-3.5-flash-lite
.EXAMPLE
    Invoke-AITDebate -DocPath ../ai-triad-data/sources/my-doc/snapshot.md -Name "My Doc Debate"
.EXAMPLE
    Invoke-AITDebate -CrossCuttingNodeId sit-005 -Clarify -Probe
.EXAMPLE
    Invoke-AITDebate -Topic "AI liability" -FeatureFlags @{ Clarification = $true; Probing = $true; SalienceBeacon = $true }
.EXAMPLE
    Invoke-AITDebate -Topic "AI liability" -ConfrontationRounds 2 -ArgumentationRounds 3 -ConcludingRounds 1
.LINK
    Show-AITriadHelp
.LINK
    Show-TriadDialogue
.LINK
    Get-AITDebate
.LINK
    Resume-AITDebate
.LINK
    Repair-DebateOutput
.LINK
    Watch-DebateProgress
.LINK
    Invoke-DebateBatch
#>

function Invoke-AITDebate {
    [CmdletBinding(DefaultParameterSetName = 'Topic')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Topic', Position = 0)]
        [string]$Topic,

        [Parameter(Mandatory, ParameterSetName = 'Document')]
        [ValidateScript({ Test-Path $_ })]
        [Alias('DocumentPath', 'Path')]
        [string]$DocPath,

        [Parameter(Mandatory, ParameterSetName = 'Url')]
        [string]$Url,

        [Parameter(Mandatory, ParameterSetName = 'CrossCutting')]
        [string]$CrossCuttingNodeId,

        [Parameter()]
        [string]$Name,

        [Parameter()]
        [ValidateSet('Accelerationist', 'Safetyist', 'Skeptic')]
        [string[]]$Debaters = @('Accelerationist', 'Safetyist', 'Skeptic'),

        [Parameter()]
        [ValidateScript({ Test-AIModelId $_ })]
        [ArgumentCompleter({ param($cmd, $param, $word) $script:ValidModelIds | Where-Object { $_ -like "$word*" } })]
        [string]$Model,

        [Parameter()]
        [ValidateRange(1, 20)]
        [int]$Rounds = 3,

        [Parameter()]
        [ValidateSet('brief', 'medium', 'detailed')]
        [string]$ResponseLength = 'medium',

        [Parameter()]
        [ValidateSet('structured', 'socratic', 'deliberation')]
        [string]$Protocol = 'structured',

        [Parameter()]
        [switch]$Clarify,

        [Parameter()]
        [switch]$Probe,

        [Parameter()]
        [int]$ProbeEvery = 2,

        [Parameter()]
        [Alias('OutputPath')]
        [string]$OutputDirectory,

        [Parameter()]
        [ValidateSet('json', 'markdown')]
        [string]$OutputFormat = 'json',

        [Parameter()]
        [string]$ApiKey,

        [Parameter()]
        [double]$Temperature = 0.3,

        [Parameter()]
        [switch]$DisableTurnValidation,

        [Parameter()]
        [ValidateSet(0, 1, 2)]
        [int]$MaxTurnRetries = 2,

        [Parameter()]
        [switch]$AdaptiveStaging,

        [Parameter()]
        [hashtable]$StageModels,

        # Path to a shared debate-progress.json file. When set, this run writes
        # status + per-turn updates to that file (see Update-DebateProgress).
        # Watch with: Watch-DebateProgress -Path <path>
        [Parameter()]
        [string]$ProgressFile,

        # Debate name to use when writing progress. Defaults to the slug.
        [Parameter()]
        [string]$ProgressDebateName,

        # Batch name to set on the progress file on first write.
        [Parameter()]
        [string]$ProgressBatchName,

        # Per-phase round caps. Each phase is "rounds" where every active debater gets one turn,
        # so total LLM calls per phase ≈ rounds × debater count. Only applied when -AdaptiveStaging
        # is on (phaseBoundsOverride is an adaptive-staging concept).
        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$ConfrontationRounds,

        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$ArgumentationRounds,

        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$ConcludingRounds,

        # Feature flags as a hashtable. Known keys (case-insensitive):
        # Clarification, Probing, SalienceBeacon, ExploreFirst,
        # AdaptiveStaging, EarlyTermination, TurnValidation
        # Values are coerced to bool. Unknown keys throw an ActionableError so typos surface
        # rather than silently no-op (the same class of bug as the claude-sonnet-4-5 silent
        # failure in New-SyntheticCorpus).
        [Parameter()]
        [hashtable]$FeatureFlags
    )

    Set-StrictMode -Version Latest

    # ── Resolve FeatureFlags onto known switches ────────────
    # Cmdlet-level switches (-Clarify, -Probe, -AdaptiveStaging) take precedence when
    # set, so callers can override the hashtable without removing keys from it.
    $KnownFlagMap = @{
        clarification    = 'Clarify'
        probing          = 'Probe'
        saliencebeacon   = 'SalienceBeacon'
        explorefirst     = 'ExploreFirst'
        adaptivestaging  = 'AdaptiveStaging'
        earlytermination = 'AllowEarlyTermination'
        turnvalidation   = 'TurnValidation'  # inverted into DisableTurnValidation below
    }
    $FlagSalienceBeacon = $false
    $FlagExploreFirst = $false
    $FlagAllowEarlyTermination = $false
    $FlagTurnValidationExplicit = $false
    $FlagTurnValidation = $true
    if ($FeatureFlags) {
        foreach ($Key in $FeatureFlags.Keys) {
            $LowerKey = $Key.ToString().ToLower()
            if (-not $KnownFlagMap.ContainsKey($LowerKey)) {
                New-ActionableError `
                    -Goal     'Apply feature flags to debate run' `
                    -Problem  "Unknown feature flag '$Key'" `
                    -Location 'Invoke-AITDebate -FeatureFlags' `
                    -NextSteps "Use one of: $(($KnownFlagMap.Values | Sort-Object) -join ', '). Keys are case-insensitive." `
                    -Throw
            }
            $Value = [bool]$FeatureFlags[$Key]
            switch ($LowerKey) {
                'clarification'    { if (-not $PSBoundParameters.ContainsKey('Clarify'))         { $Clarify        = $Value } }
                'probing'          { if (-not $PSBoundParameters.ContainsKey('Probe'))           { $Probe          = $Value } }
                'adaptivestaging'  { if (-not $PSBoundParameters.ContainsKey('AdaptiveStaging')) { $AdaptiveStaging = $Value } }
                'saliencebeacon'   { $FlagSalienceBeacon = $Value }
                'explorefirst'     { $FlagExploreFirst = $Value }
                'earlytermination' { $FlagAllowEarlyTermination = $Value }
                'turnvalidation'   { $FlagTurnValidation = $Value; $FlagTurnValidationExplicit = $true }
            }
        }
    }
    # -DisableTurnValidation switch wins if explicitly set; otherwise honor flag
    if (-not $PSBoundParameters.ContainsKey('DisableTurnValidation') -and $FlagTurnValidationExplicit) {
        $DisableTurnValidation = -not $FlagTurnValidation
    }

    # ── Validate prerequisites ────────────────────────────
    if ($Debaters.Count -lt 2) {
        throw "At least 2 debaters are required. Got: $($Debaters -join ', ')"
    }

    # Verify npx is available (prefer .cmd on Windows — .ps1 can't be launched via Process.Start)
    $NpxCmd = Get-Command npx.cmd -ErrorAction SilentlyContinue
    if (-not $NpxCmd) { $NpxCmd = Get-Command npx -ErrorAction SilentlyContinue }
    if (-not $NpxCmd) {
        throw @"
npx (Node.js package runner) is not installed.
Required to run the debate CLI engine.
Install Node.js from https://nodejs.org (v18+), then verify: npx --version
"@

    }

    # Resolve model
    if ($Model) { $ResolvedModel = $Model }
    elseif ($env:AI_MODEL) { $ResolvedModel = $env:AI_MODEL }
    else { $ResolvedModel = 'gemini-3.5-flash-lite' }
    Write-Verbose "Model resolved: $ResolvedModel (source: $(if ($Model) {'parameter'} elseif ($env:AI_MODEL) {'env:AI_MODEL'} else {'default'}))"

    # ── Resolve output directory ──────────────────────────
    if (-not $OutputDirectory) {
        try {
            $DebatesDir = Get-DebatesDir
            $OutputDirectory = Join-Path $DebatesDir 'cli-runs'
        } catch {
            $OutputDirectory = Join-Path $PWD 'debates'
        }
    }
    Write-Verbose "Output directory: $OutputDirectory"
    if (-not (Test-Path $OutputDirectory)) {
        try {
            $null = New-Item -Path $OutputDirectory -ItemType Directory -Force -ErrorAction Stop
        } catch {
            throw "Failed to create output directory '$OutputDirectory': $_`nCheck that the parent directory exists and you have write permissions."
        }
    }

    # ── Generate slug ─────────────────────────────────────
    $DebateTopic = switch ($PSCmdlet.ParameterSetName) {
        'Topic'        { $Topic }
        'Document'     { "Document debate: $(Split-Path $DocPath -Leaf)" }
        'Url'          { "URL debate: $Url" }
        'CrossCutting' { "Cross-cutting: $CrossCuttingNodeId" }
    }

    if ($Name) { $SlugSource = $Name } else { $SlugSource = $DebateTopic }
    $Slug = New-Slug -Text $SlugSource

    # ── Build config JSON ─────────────────────────────────
    $Config = @{
        activePovers       = @($Debaters | ForEach-Object { $_.ToLower() })
        model              = $ResolvedModel
        rounds             = $Rounds
        responseLength     = $ResponseLength
        protocolId         = $Protocol
        enableClarification = [bool]$Clarify
        enableProbing      = [bool]$Probe
        probingInterval    = $ProbeEvery
        outputDir          = (Resolve-Path $OutputDirectory).Path
        outputFormat       = $OutputFormat
        slug               = $Slug
        temperature        = $Temperature
    }

    if ($Name) { $Config.name = $Name }

    switch ($PSCmdlet.ParameterSetName) {
        'Topic'        { $Config.topic = $Topic }
        'Document'     { $Config.docPath = (Resolve-Path $DocPath).Path }
        'Url'          { $Config.url = $Url }
        'CrossCutting' { $Config.crossCuttingId = $CrossCuttingNodeId }
    }

    if ($ApiKey) { $Config.apiKey = $ApiKey }
    if ($AdaptiveStaging) { $Config.useAdaptiveStaging = $true }
    if ($StageModels) { $Config.stageModels = $StageModels }
    if ($FlagSalienceBeacon) { $Config.salienceBeacon = $true }
    if ($FlagExploreFirst) { $Config.exploreFirst = $true }
    if ($FlagAllowEarlyTermination) { $Config.allowEarlyTermination = $true }

    # Per-phase round caps. Two paths:
    # - With -AdaptiveStaging: pass phaseBoundsOverride; engine treats caps as
    # hard upper bounds that adaptive signals can exit early but never exceed.
    # - Without -AdaptiveStaging (default): caps are treated as fixed-round
    # hard limits. We sum them into -Rounds and disable adaptive staging,
    # matching the docstring 'these counts are treated as hard limits'.
    # Loses per-phase granularity (engine has no fixed-mode phase concept),
    # but the total round count is honored exactly.
    $AnyPhaseCapSet = $PSBoundParameters.ContainsKey('ConfrontationRounds') -or
                      $PSBoundParameters.ContainsKey('ArgumentationRounds') -or
                      $PSBoundParameters.ContainsKey('ConcludingRounds')
    if ($AnyPhaseCapSet) {
        if ($AdaptiveStaging) {
            $PhaseBoundsOverride = @{}
            if ($PSBoundParameters.ContainsKey('ConfrontationRounds')) { $PhaseBoundsOverride.maxConfrontationRounds = $ConfrontationRounds }
            if ($PSBoundParameters.ContainsKey('ArgumentationRounds')) { $PhaseBoundsOverride.maxArgumentationRounds = $ArgumentationRounds }
            if ($PSBoundParameters.ContainsKey('ConcludingRounds'))    { $PhaseBoundsOverride.maxConcludingRounds    = $ConcludingRounds }
            $Config.phaseBoundsOverride = $PhaseBoundsOverride
        } else {
            $PhaseSum = 0
            if ($PSBoundParameters.ContainsKey('ConfrontationRounds')) { $PhaseSum += $ConfrontationRounds }
            if ($PSBoundParameters.ContainsKey('ArgumentationRounds')) { $PhaseSum += $ArgumentationRounds }
            if ($PSBoundParameters.ContainsKey('ConcludingRounds'))    { $PhaseSum += $ConcludingRounds }
            if ($PSBoundParameters.ContainsKey('Rounds') -and $Rounds -ne $PhaseSum) {
                Write-Warning ("-Rounds={0} overridden by phase round caps (sum={1}). Use -AdaptiveStaging if you want phase bounds with a separate -Rounds ceiling." -f $Rounds, $PhaseSum)
            }
            Write-Verbose ("Per-phase caps sum to {0} rounds; adaptive staging stays off (hard-limit semantics)." -f $PhaseSum)
            $Config.rounds = $PhaseSum
        }
    }

    Write-Verbose "Config: topic='$DebateTopic' | slug=$Slug | rounds=$Rounds | protocol=$Protocol"
    Write-Verbose "Config: debaters=$($Debaters -join ',') | responseLength=$ResponseLength | temp=$Temperature"
    Write-Verbose "Config: clarify=$Clarify | probe=$Probe (every $ProbeEvery) | adaptiveStaging=$AdaptiveStaging"
    if ($StageModels) { Write-Verbose "Config: stageModels=$($StageModels | ConvertTo-Json -Compress)" }
    if ($DisableTurnValidation) { Write-Verbose "Config: turn validation DISABLED" }
    if ($MaxTurnRetries -ne 2) { Write-Verbose "Config: maxTurnRetries=$MaxTurnRetries" }

    # ── Write config temp file ────────────────────────────
    try {
        $ConfigPath = [System.IO.Path]::GetTempFileName()
        $Json = $Config | ConvertTo-Json -Depth 10
        Set-Content -Path $ConfigPath -Value $Json -Encoding utf8NoBOM -NoNewline
    } catch {
        throw "Failed to write debate config to temp file: $_`nCheck that $([System.IO.Path]::GetTempPath()) is writable and has free space."
    }

    try {
        # ── Locate CLI ────────────────────────────────────
        $RepoRoot = Get-CodeRoot
        $CliPath  = Join-Path (Join-Path (Join-Path $RepoRoot 'lib') 'debate') 'cli.ts'

        # t/1163: optional test seam — when AITRIAD_DEBATE_CLI_OVERRIDE is set,
        # bypass npx/tsx and shell out to that file via `pwsh -File ...` instead.
        # Lets PsBoundaryFaults tests inject canned stderr/exit codes without
        # running the real TypeScript engine.
        $UseOverride = -not [string]::IsNullOrWhiteSpace($env:AITRIAD_DEBATE_CLI_OVERRIDE)
        if ($UseOverride) {
            $CliPath = $env:AITRIAD_DEBATE_CLI_OVERRIDE
            Write-Verbose "CLI override active: $CliPath"
        }

        if (-not (Test-Path $CliPath)) {
            throw @"
Debate CLI not found at: $CliPath
 
Expected repo structure: lib/debate/cli.ts
Computed repo root: $RepoRoot
 
This usually means: (1) repo not checked out correctly, (2) lib/debate was not built, or (3) running from a non-standard location.
Verify the file exists: Get-Item '$CliPath'
"@

        }

        # ── Run the Node.js CLI ───────────────────────────
        $StdOut  = [System.Collections.Generic.List[string]]::new()
        $StdErr  = [System.Collections.Generic.List[string]]::new()

        $Psi = [System.Diagnostics.ProcessStartInfo]::new()
        $TvArgs = ''
        if ($DisableTurnValidation) { $TvArgs += ' --no-turn-validation' }
        if ($PSBoundParameters.ContainsKey('MaxTurnRetries')) { $TvArgs += " --max-turn-retries $MaxTurnRetries" }

        if ($UseOverride) {
            $PwshCmd = Get-Command pwsh -ErrorAction Stop
            $Psi.FileName = $PwshCmd.Source
            $Psi.Arguments = "-NoProfile -File `"$CliPath`" --config `"$ConfigPath`"$TvArgs"
        } else {
            $Psi.FileName = $NpxCmd.Source
            $Psi.Arguments = "tsx `"$CliPath`" --config `"$ConfigPath`"$TvArgs"
        }

        Write-Verbose "CLI: $($Psi.FileName) $($Psi.Arguments)"
        Write-Verbose "Working directory: $RepoRoot"
        $Psi.WorkingDirectory = $RepoRoot
        $Psi.RedirectStandardOutput = $true
        $Psi.RedirectStandardError  = $true
        $Psi.UseShellExecute = $false
        $Psi.CreateNoWindow = $true

        try {
            $Proc = [System.Diagnostics.Process]::Start($Psi)
        } catch {
            throw "Failed to start debate CLI process (npx tsx): $_`nVerify Node.js is installed and npx is in your PATH: npx --version"
        }

        # t/1170: drain stdout asynchronously so the OS pipe buffer never fills
        # while we're reading stderr line-by-line. Without this, a multi-MB
        # session JSON on stdout blocks the CLI write — main thread spins
        # forever in StandardError.ReadLine() waiting for stderr that never
        # comes (classic .NET Process stdout/stderr deadlock).
        $StdOutTask = $Proc.StandardOutput.ReadToEndAsync()

        # ── Progress file setup (t/1095) ──────────────────
        # When -ProgressFile is set, write per-turn updates so Watch-DebateProgress
        # (and any operator tailing the file) can see live state — fixes the 3h
        # silent-hang class of bug from exp-1069.
        $ProgressActive = -not [string]::IsNullOrWhiteSpace($ProgressFile)
        $EffectiveDebateName = if ($ProgressDebateName) { $ProgressDebateName } else { $Slug }
        $CurrentTurn = 0
        $TotalTurnsExpected = if ($AdaptiveStaging) { 0 } else { [int]$Rounds * @($Debaters).Count }
        if ($ProgressActive) {
            Write-Verbose "Progress file: $ProgressFile (debate=$EffectiveDebateName)"
            Update-DebateProgress -Path $ProgressFile -DebateName $EffectiveDebateName `
                -BatchName $ProgressBatchName -Fields @{
                    status               = 'running'
                    started_at           = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
                    current_turn         = 0
                    total_turns_expected = $TotalTurnsExpected
                    current_stage        = 'starting'
                    current_debater      = $null
                }
        }

        # Stream stderr for progress — parse per-round/phase info for -Verbose
        $CurrentRound = 0
        $RoundStartTime = [DateTime]::UtcNow
        while (-not $Proc.StandardError.EndOfStream) {
            $Line = $Proc.StandardError.ReadLine()
            if (-not $Line) { continue }
            $StdErr.Add($Line)

            # Parse CLI progress lines for verbose reporting
            if ($Line -match '\[debate-cli\]\s*\[(\w+)\]\s*(\w+)?:?\s*(.*)') {
                $Phase = $Matches[1]
                $Speaker = $Matches[2]
                $Message = $Matches[3].Trim()

                # Detect round transitions
                if ($Phase -match 'round_(\d+)' -or $Message -match 'Round\s+(\d+)') {
                    $NewRound = [int]($Matches[1])
                    if ($NewRound -ne $CurrentRound) {
                        if ($CurrentRound -gt 0) {
                            $RoundElapsed = [Math]::Round(([DateTime]::UtcNow - $RoundStartTime).TotalSeconds, 1)
                            Write-Verbose " Round $CurrentRound completed in ${RoundElapsed}s"
                        }
                        $CurrentRound = $NewRound
                        $RoundStartTime = [DateTime]::UtcNow
                        Write-Verbose "Round $CurrentRound starting..."
                    }
                }

                # Per-speaker turn info
                if ($Speaker -and $Message) {
                    $Color = switch ($Speaker.ToLower()) {
                        'Accelerationist' { 'Green' }
                        'Safetyist'  { 'Red' }
                        'Skeptic' { 'Yellow' }
                        default     { 'DarkGray' }
                    }
                    Write-Host " [$Phase] $Speaker`: $Message" -ForegroundColor $Color

                    # t/1095: each speaker line is a turn. Write progress so
                    # Watch-DebateProgress (and hung-detection) see liveness.
                    if ($ProgressActive) {
                        $CurrentTurn++
                        Update-DebateProgress -Path $ProgressFile -DebateName $EffectiveDebateName -Fields @{
                            status          = 'running'
                            current_turn    = $CurrentTurn
                            current_stage   = $Phase
                            current_debater = $Speaker
                        }
                    }
                } else {
                    Write-Host " $Line" -ForegroundColor DarkGray
                }
            }
            elseif ($Line -match '\[debate-cli\]\s*(.+)') {
                Write-Verbose "CLI: $($Matches[1])"
            }
            else {
                Write-Host $Line -ForegroundColor DarkGray
            }
        }
        # Final round timing
        if ($CurrentRound -gt 0) {
            $RoundElapsed = [Math]::Round(([DateTime]::UtcNow - $RoundStartTime).TotalSeconds, 1)
            Write-Verbose " Round $CurrentRound completed in ${RoundElapsed}s"
        }

        # t/1170: await the async stdout drain kicked off after Process.Start.
        # The task has been pulling stdout into a buffer for the entire duration
        # of the stderr loop, so this either returns immediately (stdout already
        # fully read) or completes within a few ms of the CLI exiting.
        $StdOutText = $StdOutTask.GetAwaiter().GetResult()

        # Wait with timeout (10 minutes max for a full debate)
        if (-not $Proc.WaitForExit(600000)) {
            try { $Proc.Kill() } catch { }
            throw @"
Debate CLI process timed out after 10 minutes.
This may indicate: the AI API is unresponsive, the model is overloaded, or the debate has too many rounds.
Try: reduce -Rounds, use a faster -Model, or check your API key and network connectivity.
Stderr output:
$($StdErr -join "`n" | Select-Object -Last 20)
"@

        }

        if ($StdOutText) { $StdOut.Add($StdOutText) }

        # ── Parse result ──────────────────────────────────
        $ResultJson = $StdOut -join "`n"

        # t/1123: non-zero exit is always a CLI failure — surface the original error
        # immediately, don't fall through to the JSON parser (which would throw a
        # confusing "Failed to parse JSON" error that masks the real cause).
        if ($Proc.ExitCode -ne 0) {
            # t/1163: if the CLI emitted an ActionableError JSON line on stderr,
            # render its fields directly instead of dumping a raw stderr tail.
            $Structured = Get-StructuredErrorFromStderr -StderrLines (@($StdErr))
            # Defensive: structured errors should be on stderr, but if a CLI
            # writes them to stdout instead, still surface them.
            if (-not $Structured -and $ResultJson) {
                $Structured = Get-StructuredErrorFromStderr -StderrLines @($ResultJson -split "`n")
            }
            if ($Structured) {
                $StepLines = (@($Structured.NextSteps) | ForEach-Object { " - $_" }) -join "`n"
                throw @"
[Debate CLI ActionableError] (exit code $($Proc.ExitCode))
Goal: $($Structured.Goal)
Problem: $($Structured.Problem)
Location: $($Structured.Location)
Next Steps:
$StepLines
"@

            }

            $StderrTail = (@($StdErr) | Select-Object -Last 20) -join "`n"
            $StdoutPreview = if ($ResultJson) {
                "Stdout preview (first 500 chars):`n$($ResultJson.Substring(0, [Math]::Min(500, $ResultJson.Length)))"
            } else { '(stdout was empty)' }
            throw @"
Debate CLI failed with exit code $($Proc.ExitCode).
Stderr (last 20 lines):
$StderrTail
 
$StdoutPreview
 
Troubleshooting:
  1. Check API key: ensure GEMINI_API_KEY (or ANTHROPIC_API_KEY/GROQ_API_KEY) is set
  2. Check model: verify '$ResolvedModel' is a valid model in ai-models.json
  3. Run with -Verbose for more detail
"@

        }

        if (-not $ResultJson) {
            throw @"
Debate CLI produced no output (exit code: $($Proc.ExitCode)).
Stderr:
$($StdErr -join "`n" | Select-Object -Last 20)
 
This usually means the CLI crashed before producing results. Run with -Verbose for debugging.
"@

        }

        try {
            $Result = $ResultJson | ConvertFrom-Json -ErrorAction Stop
        } catch {
            throw @"
Failed to parse debate CLI response as JSON: $_
First 300 chars of output: $($ResultJson.Substring(0, [Math]::Min(300, $ResultJson.Length)))
 
This usually means the CLI produced non-JSON output. Check stderr above for errors.
"@

        }

        if (-not $Result.success) {
            throw "Debate failed: $($Result.error)"
        }

        Write-Verbose "Debate complete: $($Result.stats.rounds) rounds, $($Result.stats.entries) entries, $($Result.stats.apiCalls) API calls"
        Write-Verbose " Time: $([Math]::Round($Result.stats.totalTimeMs / 1000, 1))s | Claims: $($Result.stats.claimsAccepted) accepted, $($Result.stats.claimsRejected) rejected"
        Write-Verbose " Files: debate=$($Result.files.debate)"
        Write-Verbose " transcript=$($Result.files.transcript)"
        Write-Verbose " diagnostics=$($Result.files.diagnostics)"
        if ($Result.files.harvest) { Write-Verbose " harvest=$($Result.files.harvest)" }

        # t/1095: mark this debate done in the progress file
        if ($ProgressActive) {
            Update-DebateProgress -Path $ProgressFile -DebateName $EffectiveDebateName -Fields @{
                status = 'done'
            }
        }

        # ── Return structured result ──────────────────────
        [PSCustomObject]@{
            DebateId        = $Result.debateId
            Name            = $Result.name
            Slug            = $Result.slug
            Topic           = $Result.topic
            DebateFile      = $Result.files.debate
            TranscriptFile  = $Result.files.transcript
            DiagnosticsFile = $Result.files.diagnostics
            HarvestFile     = $Result.files.harvest
            MarkdownFile    = $Result.files.markdown
            Rounds          = $Result.stats.rounds
            Entries         = $Result.stats.entries
            ApiCalls        = $Result.stats.apiCalls
            TotalTimeMs     = $Result.stats.totalTimeMs
            ClaimsAccepted  = $Result.stats.claimsAccepted
            ClaimsRejected  = $Result.stats.claimsRejected
            Success         = $true
        }
    }
    catch {
        # t/1095: mark debate failed before rethrowing so Watch-DebateProgress sees it
        if ($ProgressActive) {
            Update-DebateProgress -Path $ProgressFile -DebateName $EffectiveDebateName -Fields @{
                status = 'failed'
                error  = $_.Exception.Message
            }
        }
        throw
    }
    finally {
        Remove-Item -Path $ConfigPath -ErrorAction SilentlyContinue
    }
}