scripts/internal/continuous-co-review/co-review-service.ps1

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

# iter-008 — the HOST-NEUTRAL co-review SERVICE. The single API surface for the co-review capabilities, consumed
# by TWO peers: the Claude Stop-hook navigator (today) AND a future MCP server (any MCP host, tomorrow). No
# host-specific surfacing here — each function returns structured data; the host integration (stop-block,
# inject-note, an MCP tool result) is the CONSUMER's job. The MCP server is a THIN wrapper:
# trigger_review -> Start-ContinuousCoReviewServiceRun (fire a review of the committed state; detached)
# get_review_status-> Get-ContinuousCoReviewServiceStatus (a run's lifecycle status, or all pending)
# get_review_findings-> Get-ContinuousCoReviewServiceFindings(a run's FindingsResult — the durable thread)
# ask_reviewer -> Invoke-ContinuousCoReviewServiceAsk (a follow-up question about a run's findings)
# This is the "small refactor now" so the MCP is easy later. See iter-008 design-analysis (MCP-readiness).

. (Join-Path $PSScriptRoot 'continuous-co-review-navigator.ps1')   # pending-dir / run-dir / pending-entries helpers
. (Join-Path $PSScriptRoot 'worktree-review-orchestrator.ps1')     # the trigger pipeline + auto-resolution

function New-ContinuousCoReviewServiceRunId {
    if (Get-Command -Name 'New-SpecrewIsolatedTaskRunId' -ErrorAction SilentlyContinue) { return (New-SpecrewIsolatedTaskRunId) }
    return ('ccr-{0}' -f ([guid]::NewGuid().ToString('N')))
}

function Get-ContinuousCoReviewCheckpointIdentity {
    # The CHECKPOINT identity for auto-fire dedup and pending-entry registration = the CERTIFIED
    # reviewed-state digest (working tree), falling back to the HEAD subtree only when the digest
    # cannot be computed. Fixes the codex finding of run 20260708T225439577 (the D-197-I010-004
    # follow-on): keying dedup on HEAD^{tree} meant an UNCOMMITTED edit after a fired commit kept the
    # old key and was deduped as already-reviewed, so the auto path never reviewed dirty increments -
    # the one identity that materialization, the gate, and now the dedup share is the digest. The
    # original HEAD-tree choice guarded a 50s digest cost that no longer exists (batched git calls
    # brought it to ~0.2-2s), and this only runs on implement-stage material stops.
    param([Parameter(Mandatory)][string]$RepoRoot)
    try {
        if (-not (Get-Command -Name 'Get-ContinuousCoReviewReviewedStateDigest' -ErrorAction SilentlyContinue)) {
            $lp = Join-Path $PSScriptRoot '_load.ps1'
            if (Test-Path -LiteralPath $lp -PathType Leaf) { . $lp }
        }
        $dg = Get-ContinuousCoReviewReviewedStateDigest -RepoRoot $RepoRoot
        if ($null -ne $dg -and [bool]$dg.ok -and -not [string]::IsNullOrWhiteSpace([string]$dg.tree_id)) {
            return ([string]$dg.tree_id).Trim()
        }
    }
    catch { $null = $_ }
    return (Get-ContinuousCoReviewWorktreeIdentity -RepoRoot $RepoRoot)
}

function Get-ContinuousCoReviewWorktreeIdentity {
    # The FAST identity = HEAD's reviewed SUBTREE tree-id (nested-project aware). Retained as the
    # digest-failure FALLBACK for Get-ContinuousCoReviewCheckpointIdentity (it misses dirty
    # working-tree changes by construction - do not key dedup on it directly).
    param([Parameter(Mandatory)][string]$RepoRoot)
    $resolved = (Resolve-Path -LiteralPath $RepoRoot).Path
    $prefixRaw = (& git -C $resolved rev-parse --show-prefix 2>$null)
    $prefix = if ($null -ne $prefixRaw) { ([string]$prefixRaw).Trim().TrimEnd('/') } else { '' }
    $treeId = if ([string]::IsNullOrWhiteSpace($prefix)) {
        (& git -C $resolved rev-parse 'HEAD^{tree}' 2>$null)
    }
    else {
        $gitRoot = (& git -C $resolved rev-parse --show-toplevel 2>$null).Trim()
        (& git -C $gitRoot rev-parse "HEAD:$prefix" 2>$null)
    }
    if ([string]::IsNullOrWhiteSpace($treeId)) { return $null }
    return ([string]$treeId).Trim()
}

function Start-ContinuousCoReviewServiceRun {
    # TRIGGER. Fire a review of the committed state. -Detached spawns the detached orchestrator NON-BLOCKING and
    # registers a reap-compatible pending entry (the navigator/MCP fire-and-forget path); otherwise runs inline
    # (blocking). Returns @{ run_id; run_dir; status; supervisor_pid?; tree_id; detached }.
    param(
        [Parameter(Mandatory)][string]$RepoRoot,
        [string]$RunId,
        [string]$TreeId,
        [string]$BaselineRef,
        [string]$CodeWriterHost,
        # T093/FR-035: an explicit reviewer-host request (the --live door's --host). Honoured or
        # surfaced by the orchestrator's selection, never silently substituted.
        [string]$RequestedHost,
        [int]$TimeoutSeconds = 900,
        [switch]$Detached
    )
    $resolved = (Resolve-Path -LiteralPath $RepoRoot).Path
    if ([string]::IsNullOrWhiteSpace($RunId)) { $RunId = New-ContinuousCoReviewServiceRunId }
    $runDir = Get-ContinuousCoReviewNavigatorRunDir -RepoRoot $resolved -RunId $RunId
    if (-not (Test-Path -LiteralPath $runDir)) { New-Item -ItemType Directory -Path $runDir -Force | Out-Null }

    if (-not $Detached) {
        $st = Invoke-ContinuousCoReviewWorktreeReviewRun -RepoRoot $resolved -RunDir $runDir -RunId $RunId -BaselineRef $BaselineRef -CodeWriterHost $CodeWriterHost -RequestedHost $RequestedHost -TimeoutSeconds $TimeoutSeconds
        # Pass the failure_reason through, and read tree_id null-safely - a FAILED run (e.g. no-authorized-reviewer-host)
        # writes no tree_id, and a bare `.Value` on the missing property would crash the caller into a messy stack
        # instead of a clean, actionable status (the door relies on this to fail loud rather than silently).
        $failReason = if ($st.PSObject.Properties['failure_reason']) { [string]$st.failure_reason } else { '' }
        $treeIdVal = if ($st.PSObject.Properties['tree_id']) { $st.PSObject.Properties['tree_id'].Value } else { $null }
        $digestVal = if ($st.PSObject.Properties['reviewed_digest_tree_id']) { [string]$st.reviewed_digest_tree_id } else { '' }
        $elapsedVal = if ($st.PSObject.Properties['elapsed_seconds']) { $st.PSObject.Properties['elapsed_seconds'].Value } else { $null }
        $timeoutVal = if ($st.PSObject.Properties['timeout_seconds']) { $st.PSObject.Properties['timeout_seconds'].Value } else { $TimeoutSeconds }
        return [pscustomobject]@{ run_id = $RunId; run_dir = $runDir; status = ([string]$st.status); failure_reason = $failReason; reviewed_digest_tree_id = $digestVal; detached = $false; tree_id = $treeIdVal; elapsed_seconds = $elapsedVal; timeout_seconds = $timeoutVal }
    }

    if ([string]::IsNullOrWhiteSpace($TreeId)) { $TreeId = Get-ContinuousCoReviewCheckpointIdentity -RepoRoot $resolved }
    $regPath = Join-Path (Get-ContinuousCoReviewNavigatorPendingDir -RepoRoot $resolved) "$RunId.json"
    $resultPath = Join-Path $runDir 'result.out'
    $now = [datetime]::UtcNow
    $deadline = $now.AddSeconds($TimeoutSeconds + 120).ToString('o')
    $writeReg = {
        param([AllowNull()]$SupPid, [string]$Status, [hashtable]$Extra)
        $record = [ordered]@{
                schema_version = '1.0'; run_id = $RunId; engine = 'worktree'; status = $Status
                run_dir = $runDir; result_path = $resultPath; tree_id = $TreeId
                supervisor_pid = $SupPid; created_at = $now.ToString('o'); deadline = $deadline
            }
        if ($Extra) {
            foreach ($key in $Extra.Keys) { $record[$key] = $Extra[$key] }
        }
        $json = ([pscustomobject]$record | ConvertTo-Json -Depth 8)
        if (Get-Command -Name 'Write-SpecrewFileAtomic' -ErrorAction SilentlyContinue) { Write-SpecrewFileAtomic -Path $regPath -Content $json } else { [System.IO.File]::WriteAllText($regPath, $json) }
    }
    & $writeReg $null 'running' $null
    # Spawn detached, stdio redirected to files. An un-redirected child inherits our pipes and BLOCKS the parent.
    $entry = Join-Path $PSScriptRoot 'worktree-review-detached-entry.ps1'
    $spawnArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $entry, '-RepoRoot', $resolved, '-RunDir', $runDir, '-RunId', $RunId, '-RegistryPath', $regPath, '-TimeoutSeconds', $TimeoutSeconds)
    if (-not [string]::IsNullOrWhiteSpace($BaselineRef)) { $spawnArgs += @('-BaselineRef', $BaselineRef) }
    if (-not [string]::IsNullOrWhiteSpace($CodeWriterHost)) { $spawnArgs += @('-CodeWriterHost', $CodeWriterHost) }
    if (-not [string]::IsNullOrWhiteSpace($RequestedHost)) { $spawnArgs += @('-RequestedHost', $RequestedHost) }
    # ISSUE-1 ROOT FIX (the 20-minute Stop): spawn the detached review inheriting NOTHING, so it cannot hold the
    # dispatcher's - and TRANSITIVELY the HOST's - stdout pipe open. The host (Claude Code) launches the dispatcher
    # and reads its stdout to EOF with NO drain cap (it is the host, not our code), so any inherited pipe blocks the
    # host until the review exits (~the whole budget = the 20-30 min hang). Start-Process forces bInheritHandles=TRUE
    # (inherits EVERY inheritable handle, not just stdout/stderr), so neither -Redirect* nor clearing -11/-12 is
    # enough - a 4-level host->dispatcher->provider->review harness proved Start-Process+handle-clear = 11.1s
    # host-read vs Win32_Process.Create = 1.8s. On WINDOWS use Win32_Process.Create (CreateProcess with
    # bInheritHandles=FALSE + no parent stdio); on UNIX Start-Process -Redirect* already detaches cleanly (verified
    # 2.8s baseline on WSL). The detached-entry self-redirects its own stdio (CreateProcess has no console/shell
    # redirection, so the entry.out.log is written from inside the entry, not by the parent).
    $supPid = $null
    if ($IsWindows) {
        try {
            $quoted = @('"' + (Get-Command pwsh).Source + '"') + @($spawnArgs | ForEach-Object { '"' + (([string]$_) -replace '"', '\"') + '"' })
            # ShowWindow=SW_HIDE(0): Win32_Process.Create has no -WindowStyle Hidden equivalent, so without this the
            # detached pwsh pops a blank console window at EVERY checkpoint (Start-Process used -WindowStyle Hidden).
            $startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 }
            $spawn = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ($quoted -join ' '); ProcessStartupInformation = $startup } -ErrorAction Stop
        }
        catch {
            & $writeReg $null 'failed' @{ failure_reason = ('detached-spawn-failed: ' + $_.Exception.Message) }
            throw
        }
        if ($null -eq $spawn -or [int]$spawn.ReturnValue -ne 0 -or -not $spawn.ProcessId) {
            $rc = if ($null -ne $spawn) { [string]$spawn.ReturnValue } else { 'null' }
            & $writeReg $null 'failed' @{ failure_reason = ("detached-spawn-failed: Win32_Process.Create rc=$rc") }
            throw "Win32_Process.Create failed (rc=$rc)"
        }
        $supPid = [int]$spawn.ProcessId
    }
    else {
        try {
            # NO -WindowStyle here: this IS the non-Windows branch (Windows uses Win32_Process.Create
            # above) and -WindowStyle throws NotSupported on Unix pwsh - it silently broke the WSL/Linux
            # detached fire before the reviewer ever started (co-review finding f2, run 20260708T112353271).
            $proc = Start-Process -FilePath (Get-Command pwsh).Source -ArgumentList $spawnArgs -PassThru `
                -RedirectStandardOutput (Join-Path $runDir 'entry.out.log') -RedirectStandardError (Join-Path $runDir 'entry.err.log')
        }
        catch {
            & $writeReg $null 'failed' @{ failure_reason = ('detached-spawn-failed: ' + $_.Exception.Message) }
            throw
        }
        $supPid = [int]$proc.Id
    }
    & $writeReg $supPid 'running' $null
    return [pscustomobject]@{ run_id = $RunId; run_dir = $runDir; status = 'running'; supervisor_pid = $supPid; tree_id = $TreeId; detached = $true }
}

function Get-ContinuousCoReviewServiceStatus {
    # STATUS. A run's lifecycle status (pending registry -> running/done/failed; else inline -> reaped); or, with
    # no RunId, every pending run. Pure read.
    param([Parameter(Mandatory)][string]$RepoRoot, [string]$RunId)
    $resolved = (Resolve-Path -LiteralPath $RepoRoot).Path
    if ([string]::IsNullOrWhiteSpace($RunId)) {
        return @(Get-ContinuousCoReviewNavigatorPendingEntries -RepoRoot $resolved | ForEach-Object { $_.registry })
    }
    $regPath = Join-Path (Get-ContinuousCoReviewNavigatorPendingDir -RepoRoot $resolved) "$RunId.json"
    if (Test-Path -LiteralPath $regPath -PathType Leaf) { return (Get-Content -LiteralPath $regPath -Raw -Encoding UTF8 | ConvertFrom-Json) }
    # Reaped: the durable inline thread survives (findings-result.json for ANY real verdict; review-run.json only
    # when an affirmative pass was PROMOTED to gate evidence). Either means the run was reviewed.
    $inlineDir = Join-Path $resolved ".specrew/review/inline/$RunId"
    if ((Test-Path -LiteralPath (Join-Path $inlineDir 'findings-result.json') -PathType Leaf) -or (Test-Path -LiteralPath (Join-Path $inlineDir 'review-run.json') -PathType Leaf)) {
        $promoted = Test-Path -LiteralPath (Join-Path $inlineDir 'review-run.json') -PathType Leaf
        return [pscustomobject]@{ run_id = $RunId; status = 'reviewed'; reaped = $true; promoted_gate_evidence = $promoted }
    }
    return [pscustomobject]@{ run_id = $RunId; status = 'unknown' }
}

function Get-ContinuousCoReviewServiceFindings {
    # FINDINGS — the reviewer data. The durable promoted thread (inline/<run-id>/findings-result.json) if present,
    # else the pending result.out. Returns the parsed FindingsResult (or $null). The MCP `get_review_findings`
    # tool returns this verbatim; inline/<run-id>/ is the MCP resource.
    param([Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$RunId)
    $resolved = (Resolve-Path -LiteralPath $RepoRoot).Path
    foreach ($p in @((Join-Path $resolved ".specrew/review/inline/$RunId/findings-result.json"),
            (Join-Path (Get-ContinuousCoReviewNavigatorRunDir -RepoRoot $resolved -RunId $RunId) 'result.out'))) {
        if (Test-Path -LiteralPath $p -PathType Leaf) {
            try { $raw = Get-Content -LiteralPath $p -Raw -Encoding UTF8; if (-not [string]::IsNullOrWhiteSpace($raw)) { return ($raw | ConvertFrom-Json -Depth 100) } } catch { $null = $_ }
        }
    }
    return $null
}

function Get-ContinuousCoReviewAskPrompt {
    param([Parameter(Mandatory)][string]$Question, [AllowNull()]$PriorFindings)
    $priorJson = if ($null -ne $PriorFindings) { ($PriorFindings | ConvertTo-Json -Depth 100) } else { '(no prior findings on record)' }
    return @"
You are the Specrew co-reviewer answering a FOLLOW-UP QUESTION about a review you produced for this project.
Your current working directory IS the reviewed project. You are TRUSTED: READ any file and RUN any command to
verify your answer, but you are READ-ONLY on the source — do NOT modify any file.
 
Prior findings you produced (JSON):
$priorJson
 
The user's question:
$Question
 
Answer concisely and specifically, citing exact files/lines and the design reference where relevant. Plain text
only (no JSON wrapper).
"@

}

function Invoke-ContinuousCoReviewServiceAsk {
    # ASK — a follow-up question about a run's findings. Re-materializes the reviewed state in a fresh read-only
    # worktree and re-invokes the agentic host with the prior findings + the question (the same trusted
    # agent-in-worktree the review uses). Returns @{ answer; ok }. The MCP `ask_reviewer` tool wraps this.
    param(
        [Parameter(Mandatory)][string]$RepoRoot,
        [Parameter(Mandatory)][string]$RunId,
        [Parameter(Mandatory)][string]$Question,
        [string]$BaselineRef,
        [string]$CodeWriterHost,
        [int]$TimeoutSeconds = 300
    )
    $resolved = (Resolve-Path -LiteralPath $RepoRoot).Path
    $prior = Get-ContinuousCoReviewServiceFindings -RepoRoot $resolved -RunId $RunId
    if ([string]::IsNullOrWhiteSpace($BaselineRef)) { $BaselineRef = Resolve-ContinuousCoReviewWorktreeBaseline -RepoRoot $resolved }
    $design = @(Resolve-ContinuousCoReviewWorktreeDesignContext -RepoRoot $resolved)
    # D-197-I010-002 (host-neutral core): no named-harness fallback - an unresolved reviewer host
    # fails soft with the stated reason instead of silently asking a hardcoded host.
    $reviewerHost = Resolve-ContinuousCoReviewReviewerHost -RepoRoot $resolved -CodeWriterHost $CodeWriterHost
    if ($null -eq $reviewerHost) {
        return [pscustomobject]@{ ok = $false; answer = $null; failure_reason = 'no-authorized-reviewer-host'; reviewer_host = $null }
    }
    $askHost = [string]$reviewerHost.host
    $wt = New-ContinuousCoReviewStrippedWorktree -RepoRoot $resolved -BaselineRef $BaselineRef -DesignContextFiles $design
    try {
        $prompt = Get-ContinuousCoReviewAskPrompt -Question $Question -PriorFindings $prior
        $r = Invoke-ContinuousCoReviewAgentInWorktree -WorktreePath $wt.worktree_path -Prompt $prompt -HostName $askHost -TimeoutSeconds $TimeoutSeconds
        return [pscustomobject]@{ ok = ($r.exit_code -eq 0); answer = ([string]$r.stdout).Trim(); reviewer_host = $askHost }
    }
    finally {
        Remove-Item -LiteralPath $wt.worktree_path -Recurse -Force -ErrorAction SilentlyContinue
    }
}