scripts/internal/continuous-co-review/worktree-review-orchestrator.ps1
|
$ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest # iter-008 — the detached "prepare+review" orchestrator. Auto-resolves the inputs (merge-base baseline + design # context), runs the worktree-reviewer pipeline, and writes a REAP-CONSUMABLE result (result.out = FindingsResult # JSON, status.json = terminal status) under a run dir, then disposes the ephemeral worktree. BOTH doors — the # navigator's fast Stop-trigger AND /specrew-review — drive THIS one pipeline (G1/G3 close here). All heavy work # lives here, off the 20s Stop budget. See specs/197-continuous-co-review/iterations/008/design-analysis.md. . (Join-Path $PSScriptRoot 'worktree-reviewer.ps1') function ConvertTo-ContinuousCoReviewWorktreeIsoTimestamp { param([datetime]$Timestamp = [datetime]::UtcNow) return $Timestamp.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture) } function Resolve-ContinuousCoReviewTrunkName { param([Parameter(Mandatory)][string]$GitRoot) $head = (& git -C $GitRoot symbolic-ref --quiet refs/remotes/origin/HEAD 2>$null) if ($head) { return ($head.Trim() -replace '^refs/remotes/', '') } foreach ($t in @('origin/main', 'origin/dev', 'main', 'dev', 'master')) { & git -C $GitRoot rev-parse --verify --quiet "$t^{commit}" 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { return $t } } return $null } function Resolve-ContinuousCoReviewWorktreeBaseline { # The review baseline = merge-base with trunk (the user's INCREMENT since branching, not the inception). param([Parameter(Mandatory)][string]$RepoRoot, [string]$Trunk) $gitRoot = (& git -C $RepoRoot rev-parse --show-toplevel 2>$null).Trim() if ([string]::IsNullOrWhiteSpace($gitRoot)) { return $null } if (-not $Trunk) { $Trunk = Resolve-ContinuousCoReviewTrunkName -GitRoot $gitRoot } $mb = $null if ($Trunk) { $mb = (& git -C $gitRoot merge-base HEAD $Trunk 2>$null); if ($LASTEXITCODE -ne 0) { $mb = $null } } if ([string]::IsNullOrWhiteSpace($mb)) { # No trunk (a GREENFIELD: `specrew init` creates ONLY the feature branch - no main/master/remote) OR no # merge-base (unrelated histories): fall back to the EMPTY TREE so the co-review reviews the whole feature's # source instead of failing 'baseline-unresolved' and never running. This was the root cause of the first real # e2e producing zero co-review evidence. The strip/digest list still excludes .specrew/.specify machinery from # what the reviewer sees, so the empty-tree baseline reviews source only, not scaffolding. return '4b825dc642cb6eb9a060e54bf8d69288fbee4904' } return ([string]$mb).Trim() } function Resolve-ContinuousCoReviewWorktreeDesignContext { # Auto-resolve the design context: the feature's spec.md + the latest iteration's design-analysis.md. # Sources, in order (f1 fix, codex finding 2026-07-08): (1) .specify/feature.json (fast, but # GITIGNORED machine-local state - a fresh clone lacks it), (2) .specrew/start-context.json # session_state (the durable lifecycle pointer), (3) a single specs/*/spec.md directory when # unambiguous. Returns project-relative paths (or @() if genuinely unresolved - the CALLER now # records + degrades an empty resolution instead of silently reviewing blind). param([Parameter(Mandatory)][string]$RepoRoot) $out = New-Object System.Collections.Generic.List[string] $featureDir = $null $fj = Join-Path $RepoRoot '.specify/feature.json' if (Test-Path -LiteralPath $fj -PathType Leaf) { try { $featureDir = ([string]((Get-Content $fj -Raw -Encoding UTF8 | ConvertFrom-Json).feature_directory)).Replace('\', '/').TrimEnd('/') } catch { $featureDir = $null } } if ([string]::IsNullOrWhiteSpace($featureDir)) { # Durable fallback: the lifecycle start-context names the active feature. try { $scPath = Join-Path $RepoRoot '.specrew/start-context.json' if (Test-Path -LiteralPath $scPath -PathType Leaf) { $sc = Get-Content -LiteralPath $scPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($sc.PSObject.Properties['session_state'] -and $null -ne $sc.session_state) { $ref = if ($sc.session_state.PSObject.Properties['feature_ref']) { [string]$sc.session_state.feature_ref } else { '' } if (-not [string]::IsNullOrWhiteSpace($ref) -and (Test-Path -LiteralPath (Join-Path $RepoRoot (Join-Path 'specs' $ref)) -PathType Container)) { $featureDir = ('specs/' + $ref) } } } } catch { $null = $_ } } if ([string]::IsNullOrWhiteSpace($featureDir)) { # Last resort: a SINGLE unambiguous specs/*/spec.md (multi-feature repos stay unresolved). try { $specDirs = @(Get-ChildItem -LiteralPath (Join-Path $RepoRoot 'specs') -Directory -ErrorAction Stop | Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'spec.md') -PathType Leaf }) if ($specDirs.Count -eq 1) { $featureDir = ('specs/' + $specDirs[0].Name) } } catch { $null = $_ } } if ([string]::IsNullOrWhiteSpace($featureDir)) { return @() } if (Test-Path -LiteralPath (Join-Path $RepoRoot (Join-Path $featureDir 'spec.md')) -PathType Leaf) { [void]$out.Add("$featureDir/spec.md") } $iterRoot = Join-Path $RepoRoot (Join-Path $featureDir 'iterations') if (Test-Path -LiteralPath $iterRoot -PathType Container) { $latest = @(Get-ChildItem -LiteralPath $iterRoot -Directory -EA SilentlyContinue | Where-Object { $_.Name -match '^\d+$' } | Sort-Object { [int]$_.Name } -Descending | Select-Object -First 1) if ($latest -and (Test-Path -LiteralPath (Join-Path $latest[0].FullName 'design-analysis.md') -PathType Leaf)) { [void]$out.Add(([System.IO.Path]::GetRelativePath($RepoRoot, (Join-Path $latest[0].FullName 'design-analysis.md')).Replace('\', '/'))) } } # Surface the FORMAL contracts (JSON Schema / OpenAPI / proto / Avro / GraphQL) - the AUTHORITY for machine # formats (casing, field names, types, enums). spec.md + design-analysis are PROSE and describe intent # informally; without the contract the reviewer would rule conformance from the narrative and can confidently # contradict the real schema (the curation-steers-the-reviewer failure the worktree pivot was meant to escape). $contractsDir = Join-Path $RepoRoot (Join-Path $featureDir 'contracts') if (Test-Path -LiteralPath $contractsDir -PathType Container) { foreach ($cf in @(Get-ChildItem -LiteralPath $contractsDir -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Extension -match '(?i)^\.(json|ya?ml|proto|graphql|avsc|xsd)$' })) { [void]$out.Add(([System.IO.Path]::GetRelativePath($RepoRoot, $cf.FullName)).Replace('\', '/')) } } return @($out) } function Resolve-ContinuousCoReviewReviewerHost { # Select the reviewer host: code-writer-INDEPENDENT + AUTHORIZED (reviewer-hosts.json), via the legacy policy # (reused, NOT reinvented). Returns @{ host; model } or $null (no authorized host -> the caller fails-soft with # a stated reason). Lazy-loads the CCR engine (_load) if the catalog/policy aren't in scope (the detached path # dot-sources only the orchestrator). This is what makes the worktree reviewer host-NEUTRAL + authorized, not # claude-pinned. param([Parameter(Mandatory)][string]$RepoRoot, [string]$CodeWriterHost, [string]$RequestedHost) if (-not (Get-Command 'Select-ContinuousCoReviewReviewerCandidate' -ErrorAction SilentlyContinue)) { $loadPath = Join-Path $PSScriptRoot '_load.ps1' if (Test-Path -LiteralPath $loadPath -PathType Leaf) { try { . $loadPath } catch { $null = $_ } } } if (-not (Get-Command 'Get-ContinuousCoReviewReviewerHostCatalog' -ErrorAction SilentlyContinue) -or -not (Get-Command 'Select-ContinuousCoReviewReviewerCandidate' -ErrorAction SilentlyContinue)) { return $null } try { # Same load path as the legacy navigator (continuous-co-review-navigator.ps1:849-865): the persisted # human-authorized config -> catalog -> independent+authorized candidate. T093: an explicit # -RequestedHost restricts the selection (honour-or-surface); the returned independence label # ('independent' | 'same-host' | 'unverified') is the D4 gate's evidence dimension. $reviewerConfig = $null $reviewerHostsPath = Join-Path $RepoRoot '.specrew/reviewer-hosts.json' if (Test-Path -LiteralPath $reviewerHostsPath -PathType Leaf) { try { $reviewerConfig = (Get-Content -LiteralPath $reviewerHostsPath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 100) } catch { $reviewerConfig = $null } } $catalog = Get-ContinuousCoReviewReviewerHostCatalog -Configuration $reviewerConfig $cand = Select-ContinuousCoReviewReviewerCandidate -Catalog $catalog -CodeWriterHost $CodeWriterHost -RequestedHost $RequestedHost if ($null -eq $cand -or [string]::IsNullOrWhiteSpace([string]$cand.host)) { return $null } $indep = if ($cand.PSObject.Properties['independence']) { [string]$cand.independence } else { 'unverified' } $selReason = if ($cand.PSObject.Properties['selection_reason']) { [string]$cand.selection_reason } else { '' } return [pscustomobject]@{ host = [string]$cand.host; model = [string]$cand.model; independence = $indep; selection_reason = $selReason } } catch { return $null } } function Get-ContinuousCoReviewMaxRounds { # co_review_max_rounds (config, default 2). The review->fix->re-review ceiling before escalation. param([Parameter(Mandatory)][string]$RepoRoot) $cfg = Join-Path $RepoRoot '.specrew/config.yml' if (Test-Path -LiteralPath $cfg -PathType Leaf) { foreach ($line in (Get-Content -LiteralPath $cfg -Encoding UTF8)) { if ($line -match '^\s*co_review_max_rounds\s*:\s*(\d+)') { $n = [int]$Matches[1]; if ($n -ge 1) { return $n } } } } return 2 } function Get-ContinuousCoReviewRoundStatePath { param([Parameter(Mandatory)][string]$RepoRoot) return (Join-Path $RepoRoot '.specrew/runtime/co-review-round-state.json') } function Get-ContinuousCoReviewRoundState { param([Parameter(Mandatory)][string]$RepoRoot) $p = Get-ContinuousCoReviewRoundStatePath -RepoRoot $RepoRoot if (Test-Path -LiteralPath $p -PathType Leaf) { try { return (Get-Content -LiteralPath $p -Raw -Encoding UTF8 | ConvertFrom-Json) } catch { return $null } } return $null } function Set-ContinuousCoReviewRoundState { param([Parameter(Mandatory)][string]$RepoRoot, [string[]]$ChangedPaths, [int]$Round, [bool]$Blocking, [string]$Findings) $p = Get-ContinuousCoReviewRoundStatePath -RepoRoot $RepoRoot try { $dir = Split-Path -Parent $p; if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } # T096/FR-038: PRESERVE an un-consumed remediation choice across the per-run rewrite (the # human may record it between runs; only the consumer clears it). $remediation = $null try { if (Test-Path -LiteralPath $p -PathType Leaf) { $prior = Get-Content -LiteralPath $p -Raw -Encoding UTF8 | ConvertFrom-Json if ($null -ne $prior -and ($prior.PSObject.Properties.Name -contains 'remediation')) { $remediation = $prior.remediation } } } catch { $remediation = $null } ([pscustomobject]@{ changed_paths = @($ChangedPaths); round = $Round; blocking = $Blocking; findings = $Findings; remediation = $remediation } | ConvertTo-Json -Depth 8 -Compress) | Set-Content -LiteralPath $p -Encoding UTF8 } catch { $null = $_ } } function Set-ContinuousCoReviewRemediationChoice { <# T096/FR-038 (iter-009 D6/R6): record the human's remediation choice onto the round-state so the NEXT run applies it (the menu's carrier). Choices: more-time (+TimeoutSeconds) | different-host (+HostName) | narrow-scope (+Scope) | accept-partial (+RunId +Reason: records the T094 degraded ack immediately) | override-block (+RunId +Reason: D5 - only a DEGRADED run's block is overridable). TRUST BOUNDARY: construct only from a genuinely human-typed command (`specrew review --remediate ...`) or a captured human verdict. #> param( [Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][ValidateSet('more-time', 'different-host', 'narrow-scope', 'accept-partial', 'override-block')][string]$Choice, [int]$TimeoutSeconds = 0, [string]$HostName, [string]$Scope, [string]$RunId, [string]$Reason, [string]$AuthorizedBy, [datetime]$Now = [datetime]::UtcNow ) $resolved = (Resolve-Path -LiteralPath $RepoRoot).Path if ([string]::IsNullOrWhiteSpace($AuthorizedBy)) { $AuthorizedBy = (& git -C $resolved config user.name 2>$null) if ([string]::IsNullOrWhiteSpace([string]$AuthorizedBy)) { $AuthorizedBy = [string]$env:USERNAME } if ([string]::IsNullOrWhiteSpace([string]$AuthorizedBy)) { $AuthorizedBy = 'human' } } $AuthorizedBy = ([string]$AuthorizedBy).Trim() switch ($Choice) { 'different-host' { if ([string]::IsNullOrWhiteSpace($HostName)) { throw "remediation 'different-host' needs --host <name>." } } 'narrow-scope' { if ([string]::IsNullOrWhiteSpace($Scope)) { throw "remediation 'narrow-scope' needs --scope <code|process|path:<p>|function:<name>>." } if ($Scope -notmatch '^(code|process|path:.+|function:.+)$') { throw "remediation scope '$Scope' is not one of code | process | path:<p> | function:<name>." } } 'accept-partial' { if ([string]::IsNullOrWhiteSpace($RunId) -or [string]::IsNullOrWhiteSpace($Reason)) { throw "remediation 'accept-partial' needs --run-id <id> and --ack-reason '<why>'." } # Accepting the partial IS the T094 first-class ack - record it immediately (no rerun). if (-not (Get-Command -Name 'Add-ContinuousCoReviewDegradedAck' -ErrorAction SilentlyContinue)) { $lp = Join-Path $PSScriptRoot '_load.ps1'; if (Test-Path -LiteralPath $lp -PathType Leaf) { . $lp } } $null = Add-ContinuousCoReviewDegradedAck -RepoRoot $resolved -RunId $RunId -AuthorizedBy $AuthorizedBy -Rationale $Reason -Now $Now } 'override-block' { if ([string]::IsNullOrWhiteSpace($RunId) -or [string]::IsNullOrWhiteSpace($Reason)) { throw "remediation 'override-block' needs --run-id <id> and --ack-reason '<why>'." } # D5: ONLY a DEGRADED run's block is overridable - a full+independent review's blocking # finding must be addressed, not waved through. Verify from the run's terminal status. $rdir = Join-Path $resolved ".specrew/review/pending/$RunId" $stPath = Join-Path $rdir 'status.json' $labels = $null if (Test-Path -LiteralPath $stPath -PathType Leaf) { try { $st = Get-Content -LiteralPath $stPath -Raw -Encoding UTF8 | ConvertFrom-Json $comp = if (($st.PSObject.Properties.Name -contains 'completeness') -and -not [string]::IsNullOrWhiteSpace([string]$st.completeness)) { [string]$st.completeness } else { 'full' } $indep = if (($st.PSObject.Properties.Name -contains 'reviewer_independence') -and -not [string]::IsNullOrWhiteSpace([string]$st.reviewer_independence)) { [string]$st.reviewer_independence } else { 'unverified' } $labels = [pscustomobject]@{ completeness = $comp; independence = $indep } } catch { $labels = $null } } if ($null -eq $labels) { throw "remediation 'override-block': run '$RunId' has no readable terminal status - cannot verify the review was degraded, so its block is NOT overridable (address the finding or re-run)." } if ($labels.completeness -eq 'full' -and $labels.independence -eq 'independent') { throw "remediation 'override-block': run '$RunId' was a FULL INDEPENDENT review - its blocking finding must be addressed (or re-reviewed), never overridden (D5 allows overriding DEGRADED blocks only)." } # Record the durable override + clear the sticky blocking round-state so the loop unblocks. $ovDir = Join-Path $resolved ".specrew/review/inline/$RunId" New-Item -ItemType Directory -Path $ovDir -Force | Out-Null ([pscustomobject][ordered]@{ schema_version = '1.0'; run_id = $RunId; authorized_by = $AuthorizedBy; rationale = $Reason evidence_labels = $labels overridden_at = $Now.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture) } | ConvertTo-Json -Depth 8) | Set-Content -LiteralPath (Join-Path $ovDir 'degraded-block-override.json') -Encoding UTF8 -NoNewline $prior = Get-ContinuousCoReviewRoundState -RepoRoot $resolved if ($null -ne $prior) { Set-ContinuousCoReviewRoundState -RepoRoot $resolved -ChangedPaths @($prior.changed_paths) -Round ([int]$prior.round) -Blocking $false -Findings ([string]$prior.findings) } } } $remediation = [pscustomobject][ordered]@{ choice = $Choice timeout_seconds = if ($TimeoutSeconds -gt 0) { $TimeoutSeconds } else { $null } host = if ([string]::IsNullOrWhiteSpace($HostName)) { $null } else { $HostName } scope = if ([string]::IsNullOrWhiteSpace($Scope)) { $null } else { $Scope } run_id = if ([string]::IsNullOrWhiteSpace($RunId)) { $null } else { $RunId } reason = if ([string]::IsNullOrWhiteSpace($Reason)) { $null } else { $Reason } authorized_by = $AuthorizedBy recorded_at = $Now.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture) } # accept-partial / override-block act immediately (above); the rerun-shaping choices ride the # round-state to the next run. if ($Choice -in @('more-time', 'different-host', 'narrow-scope')) { $p = Get-ContinuousCoReviewRoundStatePath -RepoRoot $resolved $dir = Split-Path -Parent $p; if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $state = Get-ContinuousCoReviewRoundState -RepoRoot $resolved if ($null -eq $state) { $state = [pscustomobject]@{ changed_paths = @(); round = 0; blocking = $false; findings = $null } } $state | Add-Member -NotePropertyName 'remediation' -NotePropertyValue $remediation -Force ($state | ConvertTo-Json -Depth 8 -Compress) | Set-Content -LiteralPath $p -Encoding UTF8 } return $remediation } function Read-ContinuousCoReviewRemediationChoice { # ONE-SHOT consumer (T096): return the pending remediation (or $null) and CLEAR it from the # round-state - a human choice applies to exactly one rerun, never silently forever. param([Parameter(Mandatory)][string]$RepoRoot) $state = Get-ContinuousCoReviewRoundState -RepoRoot $RepoRoot if ($null -eq $state -or -not ($state.PSObject.Properties.Name -contains 'remediation') -or $null -eq $state.remediation) { return $null } $choice = $state.remediation try { $state.remediation = $null $p = Get-ContinuousCoReviewRoundStatePath -RepoRoot $RepoRoot ($state | ConvertTo-Json -Depth 8 -Compress) | Set-Content -LiteralPath $p -Encoding UTF8 } catch { $null = $_ } return $choice } function Test-ContinuousCoReviewPathLineageOverlap { # Same review LINEAGE = the current change-set overlaps the prior round's (a fix attempt on the SAME area) - # NOT merely "the prior was blocking" (which conflates unrelated checkpoints -> spurious escalation + irrelevant # prior findings). Overlap -> increment + thread prior findings; no overlap -> a new checkpoint (round 1). param([string[]]$Current, [string[]]$Prior) if (-not $Current -or -not $Prior -or @($Current).Count -eq 0 -or @($Prior).Count -eq 0) { return $false } $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Prior), [System.StringComparer]::OrdinalIgnoreCase) foreach ($c in @($Current)) { if ($set.Contains([string]$c)) { return $true } } return $false } function Get-ContinuousCoReviewFindingsJson { # Robustly extract the FindingsResult JSON from a free-form agentic reviewer's stdout. The reviewer is told to # output ONLY the JSON, but a non-deterministic agent may narrate AROUND it with prose containing braces # (`if (x) { ... }`), so a naive first-brace..last-brace span can capture non-JSON and false-fail the run. Try, # in order: a ```json fence, the whole span, then balanced {...} objects scanned from the END (the prompt asks # for the JSON last). Accept the first candidate that PARSES and carries a `findings` property (the contract # marker). Returns the JSON string, or $null if no valid FindingsResult is present. param([Parameter(Mandatory)][AllowEmptyString()][string]$Raw) if ([string]::IsNullOrWhiteSpace($Raw)) { return $null } $candidates = New-Object System.Collections.Generic.List[string] $fence = [regex]::Match($Raw, '(?s)```(?:json)?\s*(\{.*\})\s*```') if ($fence.Success) { [void]$candidates.Add($fence.Groups[1].Value) } $s = $Raw.IndexOf('{'); $e = $Raw.LastIndexOf('}') if ($s -ge 0 -and $e -gt $s) { [void]$candidates.Add($Raw.Substring($s, $e - $s + 1)) } for ($i = $Raw.Length - 1; $i -ge 0 -and $candidates.Count -lt 8; $i--) { if ($Raw[$i] -ne '}') { continue } $depth = 0 for ($j = $i; $j -ge 0; $j--) { if ($Raw[$j] -eq '}') { $depth++ } elseif ($Raw[$j] -eq '{') { $depth--; if ($depth -eq 0) { [void]$candidates.Add($Raw.Substring($j, $i - $j + 1)); break } } } } foreach ($cand in $candidates) { if ([string]::IsNullOrWhiteSpace($cand)) { continue } try { $o = $cand | ConvertFrom-Json -Depth 100; if ($null -ne $o -and $o.PSObject.Properties['findings']) { return $cand } } catch { $null = $_ } } return $null } function Invoke-ContinuousCoReviewWorktreeReviewRun { # The full detached run: auto-resolve → materialize stripped worktree + .review/ → agentic review → write a # reap-consumable result under $RunDir, then dispose. Returns the terminal status object. param( [Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$RunDir, [Parameter(Mandatory)][string]$RunId, [string]$BaselineRef, [string[]]$DesignContextFiles, [string]$CodeWriterHost, [string]$RequestedHost, [int]$TimeoutSeconds = 900 ) New-Item -ItemType Directory -Path $RunDir -Force | Out-Null $resultOut = Join-Path $RunDir 'result.out' $statusPath = Join-Path $RunDir 'status.json' $startedAt = [datetime]::UtcNow $runTimer = [System.Diagnostics.Stopwatch]::StartNew() # T096/FR-038 (iter-009 D6/R6): consume a pending human remediation choice (ONE-SHOT) and shape # THIS run with it - more time (budget), different host (selection), narrow scope (the reviewer's # human-directed scope). accept-partial/override-block acted immediately at record time. $remediation = Read-ContinuousCoReviewRemediationChoice -RepoRoot $RepoRoot $humanScope = $null $remediationApplied = $null $designContextEmpty = $false # set for real at the design-context-resolution phase (f1) if ($null -ne $remediation) { $remediationApplied = [string]$remediation.choice switch ($remediationApplied) { 'more-time' { $req = 0 try { if (($remediation.PSObject.Properties.Name -contains 'timeout_seconds') -and $null -ne $remediation.timeout_seconds) { $req = [int]$remediation.timeout_seconds } } catch { $req = 0 } if ($req -le 0) { $req = $TimeoutSeconds * 2 } $TimeoutSeconds = [math]::Max($TimeoutSeconds, $req) } 'different-host' { if (($remediation.PSObject.Properties.Name -contains 'host') -and -not [string]::IsNullOrWhiteSpace([string]$remediation.host)) { $RequestedHost = [string]$remediation.host } } 'narrow-scope' { if (($remediation.PSObject.Properties.Name -contains 'scope') -and -not [string]::IsNullOrWhiteSpace([string]$remediation.scope)) { $humanScope = [string]$remediation.scope } } } } $phaseTimings = [ordered]@{} $phaseTimers = @{} $currentPhase = 'initializing' $softBudgetSeconds = [math]::Max(60, [math]::Min([int]($TimeoutSeconds * 0.35), 300)) # T092/R2 (FR-034): the generous-budget bump is computed AFTER worktree materialization (once the diff size is # known). Tracked here so every status write records the default vs the effective (possibly bumped) budget. $budgetDefaultSeconds = $TimeoutSeconds; $budgetBumped = $false $budgetPolicy = 'Use implementer validation evidence first. Spend reviewer runtime where it materially changes confidence; targeted reruns are preferred over broad suites unless broad verification is justified by risk.' $recordPhaseStart = { param([string]$Name) if ([string]::IsNullOrWhiteSpace($Name)) { return } $phaseTimers[$Name] = [System.Diagnostics.Stopwatch]::StartNew() } $recordPhaseEnd = { param([string]$Name) if ([string]::IsNullOrWhiteSpace($Name)) { return } if ($phaseTimers.Contains($Name)) { $phaseTimers[$Name].Stop() $phaseTimings[$Name] = [math]::Round($phaseTimers[$Name].Elapsed.TotalSeconds, 3) $phaseTimers.Remove($Name) } } $writeStatus = { param([string]$St, [hashtable]$Extra) $obj = [ordered]@{ schema_version = '1.0' run_id = $RunId status = $St phase = $currentPhase started_at = ConvertTo-ContinuousCoReviewWorktreeIsoTimestamp -Timestamp $startedAt updated_at = ConvertTo-ContinuousCoReviewWorktreeIsoTimestamp elapsed_seconds = [math]::Round($runTimer.Elapsed.TotalSeconds, 3) timeout_seconds = $TimeoutSeconds soft_budget_seconds = $softBudgetSeconds budget_policy = $budgetPolicy budget_default_seconds = $budgetDefaultSeconds budget_bumped = $budgetBumped artifacts = [ordered]@{ run_dir = $RunDir result_out = $resultOut status_json = $statusPath } remediation_applied = $remediationApplied human_scope = $humanScope design_context = if ($designContextEmpty) { 'empty' } else { 'resolved' } phase_durations_seconds = [pscustomobject]$phaseTimings } if ($Extra) { foreach ($k in $Extra.Keys) { $obj[$k] = $Extra[$k] } } [System.IO.File]::WriteAllText($statusPath, (([pscustomobject]$obj) | ConvertTo-Json -Depth 8)) } try { & $writeStatus 'running' @{ phase = 'initializing' } $currentPhase = 'baseline-resolution' & $recordPhaseStart $currentPhase if ([string]::IsNullOrWhiteSpace($BaselineRef)) { $BaselineRef = Resolve-ContinuousCoReviewWorktreeBaseline -RepoRoot $RepoRoot } & $recordPhaseEnd 'baseline-resolution' if ([string]::IsNullOrWhiteSpace($BaselineRef)) { & $writeStatus 'failed' @{ failure_reason = 'baseline-unresolved' }; return (Get-Content $statusPath -Raw | ConvertFrom-Json) } $currentPhase = 'design-context-resolution' & $recordPhaseStart $currentPhase if (-not $DesignContextFiles -or @($DesignContextFiles).Count -eq 0) { $DesignContextFiles = @(Resolve-ContinuousCoReviewWorktreeDesignContext -RepoRoot $RepoRoot) } # f1 (codex 2026-07-08): an EMPTY design context is RECORDED + DEGRADES the run - never a # silent blind review. The reviewer is told (prompt note), status.json carries it, and the # done-write forces completeness=partial so the T094 tier demands a human ack for the # evidence. Not a terminal failure: a genuinely spec-less repo (greenfield empty-tree # baseline) may legitimately review code-only. $designContextEmpty = (@($DesignContextFiles).Count -eq 0) if ($designContextEmpty) { [Console]::Error.WriteLine('[co-review] WARN DESIGN_CONTEXT_EMPTY no spec/design-analysis resolved (.review/design will be empty); the run is labelled partial and the reviewer is told to flag it.') } & $recordPhaseEnd 'design-context-resolution' # SELECT the reviewer host: independent-preferred + authorized, labelled (T093/FR-035). A # same-host fallback FIRES immediately (never blocks); the label makes it first-class evidence. # Fail-soft (stated reason) if none - and an un-honourable explicit --host is SURFACED, never # silently substituted. $currentPhase = 'reviewer-host-selection' & $recordPhaseStart $currentPhase $reviewerHost = Resolve-ContinuousCoReviewReviewerHost -RepoRoot $RepoRoot -CodeWriterHost $CodeWriterHost -RequestedHost $RequestedHost & $recordPhaseEnd 'reviewer-host-selection' if ($null -eq $reviewerHost) { $selFailure = if (-not [string]::IsNullOrWhiteSpace($RequestedHost)) { "requested-host-not-available: '$RequestedHost' is not installed+authorized (an explicit --host is honoured or surfaced, never silently substituted)" } else { 'no-authorized-reviewer-host' } & $writeStatus 'failed' @{ failure_reason = $selFailure; requested_reviewer_host = $RequestedHost } return (Get-Content $statusPath -Raw | ConvertFrom-Json) } # The reviewed-state DIGEST = the gate's identity. Get-...SignoffGateDecision compares ITS current digest to a # passing run's recorded reviewed_tree_id. Computed BEFORE materialization (identity-unification fix, # escalation 20260708T211331029): the worktree is materialized FROM this digest tree, so the reviewed # content and the certified content are the SAME git tree object by construction - uncommitted changes # are REVIEWED, never silently certified (the FR-025 false-allow the reviewer escalated). Computed over # the MAIN repo (the worktree is a bare git-archive extract, NOT a git repo). _load is dot-sourced only # inside Resolve-...ReviewerHost's scope, so lazy-load it for THIS scope. if (-not (Get-Command -Name 'Get-ContinuousCoReviewReviewedStateDigest' -ErrorAction SilentlyContinue)) { $lp = Join-Path $PSScriptRoot '_load.ps1'; if (Test-Path -LiteralPath $lp -PathType Leaf) { try { . $lp } catch { $null = $_ } } } # SURFACE a digest failure (do not swallow it to ''): an empty digest makes the gate's freshness loop skip the # record -> a genuinely clean review blocks as 'stale' with no visible cause. Carry the reason in the status. # On digest failure the materialization falls back to HEAD (reviewedDigestErr says why the identities differ). $currentPhase = 'reviewed-state-digest' & $recordPhaseStart $currentPhase $reviewedDigestId = ''; $reviewedDigestErr = '' try { $dg = Get-ContinuousCoReviewReviewedStateDigest -RepoRoot $RepoRoot; if ($null -ne $dg -and $dg.ok) { $reviewedDigestId = [string]$dg.tree_id } else { $reviewedDigestErr = if ($null -ne $dg) { [string]$dg.failure_reason } else { 'digest-unavailable' } } } catch { $reviewedDigestErr = $_.Exception.Message } & $recordPhaseEnd 'reviewed-state-digest' $currentPhase = 'worktree-materialization' & $recordPhaseStart $currentPhase & $writeStatus 'running' @{ baseline_ref = $BaselineRef; reviewer_host = $reviewerHost.host; reviewer_independence = $reviewerHost.independence; reviewer_selection_reason = $reviewerHost.selection_reason; requested_reviewer_host = $RequestedHost } $wt = New-ContinuousCoReviewStrippedWorktree -RepoRoot $RepoRoot -BaselineRef $BaselineRef -DesignContextFiles $DesignContextFiles -SourceTreeId $reviewedDigestId & $recordPhaseEnd 'worktree-materialization' # T092 pre-flight generous-budget bump REVERTED (Issue 1): a 30-min AUTO checkpoint review is wrong - the # navigator fires one on every checkpoint, and even fully detached it should be SHORT. The auto path now # uses the passed-in budget (the navigator default). The generous budget belongs to manual # `specrew review --live` (where the human explicitly waits) and, for the auto path, is superseded by the # Phase-2 activity watchdog (kill on inactivity, not a fixed wall). The helpers # (Get-ContinuousCoReviewGenerousBudget / Test-ContinuousCoReviewExplicitTimeoutConfigured) + $wt.diff_bytes # are retained (unit-tested) for manual-path / Phase-2 reuse. # T111 (DEC-197-I010-004): inject the implementer's MACHINE-RECORDED test evidence into the worktree - # ONLY on an exact digest match with the tree under review, so the reviewer can substitute the recorded # suites for broad re-runs (the budget-death fix). A mismatch or absence injects NOTHING (never wrong # evidence); the prompt block is gated on the same flag so the reviewer is never told to trust a file # that is not there. $implementerEvidencePresent = $false try { if (Get-Command -Name 'Copy-ContinuousCoReviewImplementerEvidence' -ErrorAction SilentlyContinue) { $implementerEvidencePresent = [bool](Copy-ContinuousCoReviewImplementerEvidence -RepoRoot $RepoRoot -WorktreePath $wt.worktree_path -DigestTreeId $reviewedDigestId) } } catch { $implementerEvidencePresent = $false } # ROUND: same lineage (change-set overlaps the prior round's) + the prior was blocking -> this is a fix # re-review (round+1, thread the prior findings); else a new checkpoint (round 1, no prior). The reviewer # escalates at the final round (the counter is the safety ceiling). $currentPhase = 'round-state-resolution' & $recordPhaseStart $currentPhase $maxRounds = Get-ContinuousCoReviewMaxRounds -RepoRoot $RepoRoot $prior = Get-ContinuousCoReviewRoundState -RepoRoot $RepoRoot $round = 1; $priorFindings = $null if ($null -ne $prior -and ([bool]$prior.blocking) -and (Test-ContinuousCoReviewPathLineageOverlap -Current @($wt.changed_paths) -Prior @($prior.changed_paths))) { $round = [int]$prior.round + 1 $priorFindings = [string]$prior.findings } & $recordPhaseEnd 'round-state-resolution' try { # T096: a HUMAN-DIRECTED rerun (a consumed remediation) is never auto-halted - the round # ceiling guards the AUTO loop; the menu is its escape hatch. if (($round -gt $maxRounds) -and ($null -eq $remediationApplied)) { # CEILING REACHED — do NOT fire another review round (the deterministic spin-stop / round-9 fix: a # round>maxRounds guard provably halts the monotonic climb while the change-set overlaps). BUT a halt # is NOT a clean pass. The old code wrote an EMPTY result here, so the run read as # 'done / 0 findings / clean' and SILENTLY passed an UNREVIEWED increment — the false-green # (D-197-I009-010) that fooled a dogfood coordinator into signing off code the reviewer never saw. # Instead emit a VISIBLE escalation finding so the run can NEVER be read as clean: kind='escalation' # -> Option A parks it as escalated_to_human (the gate does NOT deadlock); severity 'blocking' -> the # navigator surfaces a NOT-REVIEWED stop-block. Persist a STICKY round-state (blocking=true) so # overlapping checkpoints stay above the ceiling and review stays bounded for THIS path-set (resets # when the change-set no longer overlaps). status carries reviewed=false so any consumer reading # status.json knows the increment was NOT reviewed. (F-197 iter-009 Option A #2 + D-010 hardening.) $currentPhase = 'ceiling-halt' & $recordPhaseStart $currentPhase [System.IO.File]::WriteAllText($resultOut, (New-ContinuousCoReviewCeilingEscalationResult -RunId $RunId -Round $round -MaxRounds $maxRounds)) Set-ContinuousCoReviewRoundState -RepoRoot $RepoRoot -ChangedPaths @($wt.changed_paths) -Round $round -Blocking $true -Findings $priorFindings & $recordPhaseEnd 'ceiling-halt' $runTimer.Stop() & $writeStatus 'done' @{ baseline_ref = $BaselineRef; changed_count = $wt.changed_count; changed_paths = @($wt.changed_paths); tree_id = $wt.tree_id; reviewed_digest_tree_id = $reviewedDigestId; reviewed_digest_error = $reviewedDigestErr; reviewer_host = $reviewerHost.host; reviewer_independence = $reviewerHost.independence; round = $round; max_rounds = $maxRounds; blocking = $false; ceiling_halted = $true; reviewed = $false } return (Get-Content $statusPath -Raw | ConvertFrom-Json) } $currentPhase = 'reviewer-execution' & $writeStatus 'running' @{ baseline_ref = $BaselineRef; changed_count = $wt.changed_count; tree_id = $wt.tree_id; reviewed_digest_tree_id = $reviewedDigestId; reviewed_digest_error = $reviewedDigestErr; reviewer_host = $reviewerHost.host; reviewer_independence = $reviewerHost.independence; round = $round; max_rounds = $maxRounds; blocking = $null; implementer_evidence = $implementerEvidencePresent } & $recordPhaseStart $currentPhase $reviewerHeartbeat = { param($Telemetry) & $writeStatus 'running' @{ baseline_ref = $BaselineRef; changed_count = $wt.changed_count; tree_id = $wt.tree_id; reviewed_digest_tree_id = $reviewedDigestId; reviewed_digest_error = $reviewedDigestErr; reviewer_host = $reviewerHost.host; reviewer_independence = $reviewerHost.independence; round = $round; max_rounds = $maxRounds; blocking = $null; reviewer_telemetry = $Telemetry } } $r = Invoke-ContinuousCoReviewWorktreeReviewer -WorktreePath $wt.worktree_path -RunId $RunId -HostName $reviewerHost.host -RoundNumber $round -MaxRounds $maxRounds -PriorFindings $priorFindings -TimeoutSeconds $TimeoutSeconds -Heartbeat $reviewerHeartbeat -HumanScope $humanScope -DesignContextEmpty:$designContextEmpty -ImplementerEvidencePresent:$implementerEvidencePresent & $recordPhaseEnd 'reviewer-execution' $reviewerTelemetry = if ($r.PSObject.Properties['telemetry']) { $r.telemetry } else { $null } $raw = [string]$r.stdout $json = Get-ContinuousCoReviewFindingsJson -Raw $raw # robust: fence -> span -> balanced-scan, validated $completeness = 'full' if ([string]::IsNullOrWhiteSpace($json)) { # T090/R1: the final blob is empty/unparseable (a timeout / cut-short run). HARVEST the incremental # .review/findings.jsonl prefix (or prose-salvage) so a degraded review still surfaces findings # (any review > nothing), instead of discarding the whole run as 'no-parseable-findings-json'. $json = Get-ContinuousCoReviewHarvestedPartialResult -WorktreePath $wt.worktree_path -RawStdout $raw -RunId $RunId if (-not [string]::IsNullOrWhiteSpace($json)) { $completeness = 'partial' } } # T096: a human-SCOPED review covered a SUBSET of the increment - its evidence is honestly # PARTIAL (the T094 tiered gate then requires the recorded ack, never a silent full pass). if (-not [string]::IsNullOrWhiteSpace([string]$humanScope)) { $completeness = 'partial' } # f1: a review that ran WITHOUT design context could not validate design conformance - # same honest degradation (partial -> the T094 ack tier), never silent full evidence. if ($designContextEmpty) { $completeness = 'partial' } if (-not [string]::IsNullOrWhiteSpace($json)) { $currentPhase = 'write-result' & $recordPhaseStart $currentPhase [System.IO.File]::WriteAllText($resultOut, $json) # detect a blocking finding -> feed the next round's lineage decision $blocking = $false try { foreach ($f in @(($json | ConvertFrom-Json -Depth 100).findings)) { if (([string]$f.severity) -match '(?i)block') { $blocking = $true; break } } } catch { $null = $_ } Set-ContinuousCoReviewRoundState -RepoRoot $RepoRoot -ChangedPaths @($wt.changed_paths) -Round $round -Blocking $blocking -Findings $json & $recordPhaseEnd 'write-result' $currentPhase = 'complete' $runTimer.Stop() & $writeStatus 'done' @{ baseline_ref = $BaselineRef; changed_count = $wt.changed_count; changed_paths = @($wt.changed_paths); tree_id = $wt.tree_id; reviewed_digest_tree_id = $reviewedDigestId; reviewed_digest_error = $reviewedDigestErr; reviewer_host = $reviewerHost.host; reviewer_independence = $reviewerHost.independence; round = $round; max_rounds = $maxRounds; blocking = $blocking; completeness = $completeness; reviewer_telemetry = $reviewerTelemetry } } else { $currentPhase = 'write-failure' & $recordPhaseStart $currentPhase [System.IO.File]::WriteAllText($resultOut, '') # STATE the reason: capture exit code + a stderr tail so an unparseable/empty verdict is diagnosable # (the agent invocation otherwise drops stderr and the failure is invisible). $stderrTail = if (-not [string]::IsNullOrWhiteSpace([string]$r.stderr)) { (([string]$r.stderr) -split "`n" | Where-Object { $_ } | Select-Object -Last 3) -join ' | ' } else { '' } & $recordPhaseEnd 'write-failure' $currentPhase = 'failed' $runTimer.Stop() & $writeStatus 'failed' @{ failure_reason = 'no-parseable-findings-json'; exit_code = $r.exit_code; stderr_tail = $stderrTail; reviewer_independence = $reviewerHost.independence; reviewer_telemetry = $reviewerTelemetry } } } finally { $currentPhase = 'cleanup' & $recordPhaseStart $currentPhase Remove-Item -LiteralPath $wt.worktree_path -Recurse -Force -ErrorAction SilentlyContinue & $recordPhaseEnd 'cleanup' } } catch { $currentPhase = 'failed' $runTimer.Stop() & $writeStatus 'failed' @{ failure_reason = 'orchestrator-exception'; message = ([string]$_.Exception.Message) } } if (Test-Path -LiteralPath $statusPath) { return (Get-Content $statusPath -Raw | ConvertFrom-Json) } } |