scripts/specrew-review.ps1
|
[CmdletBinding()] param( [string]$ProjectPath = '.', [string]$FeatureId, [string]$IterationNumber, [switch]$Quiet, [switch]$Json, [switch]$Open, [switch]$Live, [Alias('baseline-ref')] [string]$BaselineRef, [string]$Trunk, [Alias('checkpoint-id')] [string]$CheckpointId, [Alias('run-id')] [string]$RunId, [string]$HostName, [Alias('host')] [string]$ReviewerHost, [string]$Model, [Alias('authorization-ref')] [string]$AuthorizationRef, [Alias('code-writer-host')] [string]$CodeWriterHost, [Alias('fallback-policy')] [string]$FallbackPolicy, [Alias('reviewer-config')] [string]$ReviewerConfigPath, [Alias('schema-root')] [string]$SchemaRoot, [Alias('run-root')] [string]$RunRoot, [Alias('timeout-seconds')] [int]$TimeoutSeconds = 0, [Alias('design-context-ref')] [string[]]$DesignContextRef, [Alias('allowed-path')] [string[]]$AllowedPath, [Alias('forbidden-path')] [string[]]$ForbiddenPath, [Alias('exclude-path')] [string[]]$ExcludePath, [Alias('preserve-debug')] [switch]$PreserveDebug, [Alias('list-hosts')] [switch]$ListHosts, [switch]$Help, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$CliArgs ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sharedGovernancePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'extensions\specrew-speckit\scripts\shared-governance.ps1' if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) { throw "Missing shared governance helper '$sharedGovernancePath'." } . $sharedGovernancePath $boundaryStateHelperPath = Join-Path $PSScriptRoot 'internal\sync-boundary-state.ps1' if (-not (Test-Path -LiteralPath $boundaryStateHelperPath -PathType Leaf)) { throw "Missing boundary-state helper '$boundaryStateHelperPath'." } . $boundaryStateHelperPath # INT-006 (iter-007): `specrew review --list-hosts` DISCOVERS + PRESENTS the available reviewer hosts (with # the recommended independent default) and exits - lightweight, no review, no project-setup gate. This is # the deterministic list the code-implementation lens renders so the human chooses from real options # instead of being asked blind. Best-effort PATH detection; reflects this shell's env. if ($ListHosts) { $ccrLoadPath = Join-Path $PSScriptRoot 'internal\continuous-co-review\_load.ps1' if (Test-Path -LiteralPath $ccrLoadPath -PathType Leaf) { . $ccrLoadPath $cwHostForList = if (-not [string]::IsNullOrWhiteSpace($CodeWriterHost)) { $CodeWriterHost } elseif (-not [string]::IsNullOrWhiteSpace($env:SPECREW_HOST)) { $env:SPECREW_HOST } else { $env:SPECREW_ACTIVE_HOST } Write-Host (Format-ContinuousCoReviewReviewerHostChoices -CodeWriterHost $cwHostForList).text } else { Write-Host 'Reviewer-host discovery is unavailable (continuous-co-review module not found under this Specrew install).' } return } function Show-Usage { @' specrew review - run live continuous co-review or replay persisted reviewer evidence Usage: specrew review [<iteration>] [--project-path <path>] [--feature <id>] [--quiet | --json] [--open] specrew review --live --baseline-ref <ref> [--checkpoint-id <id>] [--run-id <id>] [--host <host>] [--model <model>] [--effort <effort>] [--authorization-ref <ref>] [--code-writer-host <host>] [--design-context-ref <path>] [--allowed-path <path>] [--forbidden-path <path>] [--exclude-path <pattern>] [--reviewer-config <path>] [--schema-root <path>] [--run-root <path>] [--timeout-seconds <seconds>] [--quiet | --json] specrew review --reconcile-run <run-id> [--feature <id>] [--iteration <NNN>] [--json] Options: --project-path <path> Target Specrew project (default: current directory) --feature <id> Restrict lookup to one feature directory under specs\ --iteration <NNN> Replay a specific iteration directory --live Run the continuous co-review runtime and write .specrew\review\inline evidence --ack-degraded <run-id> Record a first-class human ack of DEGRADED review evidence (with --ack-reason) --ack-reason <text> Why the degraded assurance level (partial/same-host) is acceptable for signoff --remediate <choice> Record a review-problem remediation: more-time | different-host | narrow-scope | accept-partial | override-block | resolved-against-disk | allowance-reset (resolved-against-disk clears a fixed finding but PRESERVES spent rounds; allowance-reset is the separate human-approved replenish of the round allowance) --scope <spec> Human-directed scope for narrow-scope: code | process | path:<p> | function:<name> --fix-evidence-ref <c> Commit that resolves the held finding (required by --remediate resolved-against-disk) --baseline-ref <ref> Optional git ref/SHA baseline. Omit for a signoff run (auto-anchors to the last pass or the merge-base with the trunk); supplying it makes the run exploratory (it does not auto-anchor). --trunk <name> Trunk branch the coverage anchor is the merge-base of (default: main) --checkpoint-id <id> Stable checkpoint id for live evidence (default: manual-live-review) --run-id <id> Stable run id for live evidence (default: run-<checkpoint-id>) --host <host> Requested reviewer host, such as claude, codex, copilot, cursor-agent, or antigravity --model <model> Requested reviewer model id for the host --effort <effort> Optional host-specific reviewer reasoning/effort setting to persist in evidence --authorization-ref Human-approved authorization reference for the requested reviewer --code-writer-host Host that produced the implementation, used to prefer an independent reviewer --design-context-ref Design/spec artifact to include in the request bundle; repeatable --allowed-path Path scope the reviewer may inspect; repeatable --forbidden-path Path scope the reviewer must not inspect; repeatable --exclude-path Diff path pattern to exclude; repeatable --reviewer-config JSON host catalog override for live review --schema-root Reviewer contract schema directory override --run-root External temporary workspace-root override (campaign snapshots must stay outside the repository) --timeout-seconds Reviewer host timeout in seconds (default: 120) --preserve-debug Keep temporary request-bundle workspaces after live review --reconcile-run Resume one interrupted campaign run without invoking a provider --quiet Emit only the stable machine-parseable digest line --json Emit JSON summary instead of the visual reviewer summary --open Open reviewer-index.md and review-diagrams.md when present --help Show this help message '@ | Write-Host } function Convert-UnixStyleArguments { param( [string]$ProjectPath, [string]$FeatureId, [string]$IterationNumber, [bool]$Quiet, [bool]$Json, [bool]$Open, [bool]$Help, [string[]]$CliArgs ) $result = [ordered]@{ ProjectPath = $ProjectPath FeatureId = $FeatureId IterationNumber = $IterationNumber Quiet = $Quiet Json = $Json Open = $Open Live = $Live Help = $Help BaselineRef = $null TrunkName = '' # '' -> the shared trunk resolver auto-detects (config/origin-HEAD/upstream/conventional); --trunk overrides CheckpointId = 'manual-live-review' RunId = $null Host = $null Model = $null Effort = $null AuthorizationRef = $null CodeWriterHost = $null TimeoutSeconds = 0 FallbackPolicy = 'none' ReviewerConfigPath = $null SchemaRoot = $null RunRoot = $null PreserveDebug = $false DesignContextRefs = @() AllowedPaths = @() ForbiddenPaths = @() ExcludedPathPatterns = @() AckDegradedRunId = $null ReconcileRunId = $null AckReason = $null Remediate = $null Scope = $null FixEvidenceRef = $null TimeoutSecondsExplicit = $false } $CliArgs = @($CliArgs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) for ($index = 0; $index -lt $CliArgs.Count; $index++) { $argument = $CliArgs[$index] switch -Regex ($argument) { '^--(?<name>baseline-ref|trunk|checkpoint-id|run-id|reconcile-run|host|model|effort|authorization-ref|code-writer-host|fallback-policy|reviewer-config|schema-root|run-root|timeout-seconds|design-context-ref|allowed-path|forbidden-path|exclude-path|remediate|scope|fix-evidence-ref)(?:=(?<value>.+))?$' { $name = $Matches['name'] $value = $Matches['value'] if ([string]::IsNullOrWhiteSpace($value)) { $index++ if ($index -ge $CliArgs.Count) { throw "--$name requires a value." } $value = $CliArgs[$index] } switch ($name) { 'baseline-ref' { $result.BaselineRef = $value } 'trunk' { $result.TrunkName = $value } 'checkpoint-id' { $result.CheckpointId = $value } 'run-id' { $result.RunId = $value } 'reconcile-run' { $result.ReconcileRunId = $value } 'host' { $result.Host = $value } 'model' { $result.Model = $value } 'effort' { $result.Effort = $value } 'authorization-ref' { $result.AuthorizationRef = $value } 'code-writer-host' { $result.CodeWriterHost = $value } 'fallback-policy' { $result.FallbackPolicy = $value } 'reviewer-config' { $result.ReviewerConfigPath = $value } 'schema-root' { $result.SchemaRoot = $value } 'run-root' { $result.RunRoot = $value } 'timeout-seconds' { $result.TimeoutSeconds = [int]$value; $result.TimeoutSecondsExplicit = $true } 'remediate' { $result.Remediate = $value } 'scope' { $result.Scope = $value } 'fix-evidence-ref' { $result.FixEvidenceRef = $value } 'design-context-ref' { $result.DesignContextRefs = @($result.DesignContextRefs) + @($value) } 'allowed-path' { $result.AllowedPaths = @($result.AllowedPaths) + @($value) } 'forbidden-path' { $result.ForbiddenPaths = @($result.ForbiddenPaths) + @($value) } 'exclude-path' { $result.ExcludedPathPatterns = @($result.ExcludedPathPatterns) + @($value) } } } '^--project-path(?:=(.+))?$' { if ($Matches[1]) { $result.ProjectPath = $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--project-path requires a value.' } $result.ProjectPath = $CliArgs[$index] } } '^--feature(?:=(.+))?$' { if ($Matches[1]) { $result.FeatureId = $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--feature requires a value.' } $result.FeatureId = $CliArgs[$index] } } '^--iteration(?:=(.+))?$' { if ($Matches[1]) { $result.IterationNumber = $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--iteration requires a value.' } $result.IterationNumber = $CliArgs[$index] } } '^--quiet$' { $result.Quiet = $true } '^--json$' { $result.Json = $true } '^--open$' { $result.Open = $true } '^--live$' { $result.Live = $true } '^--ack-degraded$' { $index++ if ($index -ge $CliArgs.Count) { throw '--ack-degraded requires a run-id value.' } # Downstream field bug (2026-07-09): `--ack-degraded --ack-reason "..."` bound the NEXT FLAG # as the run-id and the orphaned reason text then read as an unknown argument - the agent # concluded the flag was "unsupported by the binary". A flag-shaped run-id is always a # missing-value mistake: say so precisely. if (([string]$CliArgs[$index]).StartsWith('--')) { throw ('--ack-degraded requires a run-id BEFORE other flags (got ''{0}''). Usage: specrew review --ack-degraded <run-id> --ack-reason "<why>"' -f $CliArgs[$index]) } $result.AckDegradedRunId = $CliArgs[$index] } '^--ack-reason$' { $index++ if ($index -ge $CliArgs.Count) { throw '--ack-reason requires a value.' } $result.AckReason = $CliArgs[$index] } '^--preserve-debug$' { $result.PreserveDebug = $true } '^(?:-h|--help)$' { $result.Help = $true } '^\d{3,}$' { if ([string]::IsNullOrWhiteSpace($result.IterationNumber)) { $result.IterationNumber = $argument } else { throw ("Unknown argument for specrew review: {0}" -f $argument) } } default { throw ("Unknown argument for specrew review: {0}" -f $argument) } } } return [pscustomobject]$result } function Get-LiveReviewConfiguration { param( [AllowNull()][string]$ReviewerConfigPath, [AllowNull()][string]$HostName, [AllowNull()][string]$Model, [AllowNull()][string]$AuthorizationRef, [int]$TimeoutSeconds, [string]$FallbackPolicy ) if (-not [string]::IsNullOrWhiteSpace($ReviewerConfigPath)) { $resolvedPath = (Resolve-Path -LiteralPath $ReviewerConfigPath).Path return (Get-Content -LiteralPath $resolvedPath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 100) } if ($HostName -eq 'fixture') { return [pscustomobject][ordered]@{ schema_version = '1.0' hosts = @( [pscustomobject][ordered]@{ host = 'fixture' model = if ([string]::IsNullOrWhiteSpace($Model)) { 'fixture-reviewer' } else { $Model } adapter_id = 'reviewer-host-adapter-fixture' allowed = $true installed = $true review_class_rank = 100 model_source = 'fixture' cost_class = 'free-local-fixture' authorization_ref = if ([string]::IsNullOrWhiteSpace($AuthorizationRef)) { 'local-fixture-reviewer' } else { $AuthorizationRef } fallback_allowed = $false timeout_seconds = $TimeoutSeconds } ) } } if ([string]::IsNullOrWhiteSpace($HostName) -and [string]::IsNullOrWhiteSpace($AuthorizationRef) -and [string]::IsNullOrWhiteSpace($Model)) { return $null } $configuration = New-ContinuousCoReviewDefaultReviewerHostConfig $hosts = @( foreach ($entry in @($configuration.hosts)) { $hostMatches = [string]::IsNullOrWhiteSpace($HostName) -or $entry.host -eq $HostName [pscustomobject][ordered]@{ host = $entry.host model = if ($hostMatches -and -not [string]::IsNullOrWhiteSpace($Model)) { $Model } else { $entry.model } adapter_id = $entry.adapter_id allowed = if ($hostMatches -and -not [string]::IsNullOrWhiteSpace($AuthorizationRef)) { $true } else { [bool]$entry.allowed } installed = [bool]$entry.installed review_class_rank = [int]$entry.review_class_rank model_source = $entry.model_source cost_class = $entry.cost_class authorization_ref = if ($hostMatches -and -not [string]::IsNullOrWhiteSpace($AuthorizationRef)) { $AuthorizationRef } else { $entry.authorization_ref } fallback_allowed = if ($hostMatches -and $FallbackPolicy -ne 'none') { $true } else { [bool]$entry.fallback_allowed } timeout_seconds = $TimeoutSeconds } } ) return [pscustomobject][ordered]@{ schema_version = '1.0' hosts = @($hosts) } } function Get-MetadataValue { param( [string]$Path, [string]$Label ) $pattern = '(?m)^\*\*' + [regex]::Escape($Label) + '\*\*:\s*(?<value>.+?)\s*$' $match = [regex]::Match((Get-Content -LiteralPath $Path -Raw -Encoding UTF8), $pattern) if ($match.Success) { return $match.Groups['value'].Value.Trim() } return $null } function Get-MarkdownContent { param([string]$Path) return @(Get-Content -LiteralPath $Path -Encoding UTF8) } function Get-MarkdownSectionLines { param( [AllowEmptyString()] [string[]]$Lines, [string]$Heading ) $headingPattern = '^##\s+' + [regex]::Escape($Heading) + '\b' $startIndex = -1 for ($index = 0; $index -lt $Lines.Count; $index++) { if ($Lines[$index] -match $headingPattern) { $startIndex = $index break } } if ($startIndex -lt 0) { return @() } $sectionLines = New-Object System.Collections.Generic.List[string] for ($index = $startIndex + 1; $index -lt $Lines.Count; $index++) { $currentLine = $Lines[$index] if ($currentLine -match '^##\s+') { break } $null = $sectionLines.Add($currentLine) } return $sectionLines.ToArray() } function Resolve-IterationDirectory { param( [string]$ProjectRoot, [AllowNull()][string]$FeatureId, [AllowNull()][string]$IterationNumber ) $specsRoot = Join-Path $ProjectRoot 'specs' if (-not (Test-Path -LiteralPath $specsRoot -PathType Container)) { throw "Project does not contain a specs directory: $specsRoot" } $featureDirectories = @( if ($FeatureId) { Get-ChildItem -LiteralPath $specsRoot -Directory | Where-Object { $_.Name -eq $FeatureId } } else { Get-ChildItem -LiteralPath $specsRoot -Directory } ) if ($featureDirectories.Count -eq 0) { throw 'No matching feature directories were found.' } $candidateIterations = New-Object System.Collections.Generic.List[object] foreach ($featureDirectory in $featureDirectories) { $iterationsRoot = Join-Path $featureDirectory.FullName 'iterations' if (-not (Test-Path -LiteralPath $iterationsRoot -PathType Container)) { continue } foreach ($iterationDirectory in @(Get-ChildItem -LiteralPath $iterationsRoot -Directory)) { if ($IterationNumber -and $iterationDirectory.Name -ne $IterationNumber) { continue } $reviewerIndexPath = Join-Path $iterationDirectory.FullName 'reviewer-index.md' $reviewPath = Join-Path $iterationDirectory.FullName 'review.md' if (-not (Test-Path -LiteralPath $reviewerIndexPath -PathType Leaf) -or -not (Test-Path -LiteralPath $reviewPath -PathType Leaf)) { continue } $reviewed = Get-MetadataValue -Path $reviewPath -Label 'Reviewed' $candidateIterations.Add([pscustomobject]@{ Feature = $featureDirectory.Name Iteration = $iterationDirectory.Name Path = $iterationDirectory.FullName Reviewed = $reviewed }) } } if ($candidateIterations.Count -eq 0) { throw 'No completed iteration with reviewer artifacts was found.' } return @( $candidateIterations | Sort-Object -Property @( @{ Expression = { if ([string]::IsNullOrWhiteSpace($_.Reviewed)) { '0000-00-00' } else { $_.Reviewed } }; Descending = $true }, @{ Expression = { $_.Iteration }; Descending = $true } ) | Select-Object -First 1 )[0] } function Get-RelativePath { param( [Parameter(Mandatory = $true)] [string]$FromDirectory, [Parameter(Mandatory = $true)] [string]$ToPath ) # System.IO.Path.GetRelativePath is cross-platform safe and uses the platform's # native separator. The previous [System.Uri] MakeRelativeUri approach failed on # Linux because bare absolute paths like "/home/user/foo" are not auto-recognized # as absolute URIs without a "file://" scheme. $fromFull = [System.IO.Path]::GetFullPath($FromDirectory) $toFull = [System.IO.Path]::GetFullPath($ToPath) return [System.IO.Path]::GetRelativePath($fromFull, $toFull) } function Try-OpenPath { param([string]$Path) try { Start-Process -FilePath $Path | Out-Null return $true } catch { return $false } } function Get-ReviewBoundarySyncWarning { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$ReviewPath ) $warnings = [System.Collections.Generic.List[string]]::new() $reviewVerdict = Get-MetadataValue -Path $ReviewPath -Label 'Overall Verdict' $latestBoundary = Get-LatestSpecrewBoundarySyncState -ProjectRoot $ProjectRoot if ($reviewVerdict -match '^(?i)accepted$') { if ($null -eq $latestBoundary -or [string]$latestBoundary.boundary_type -notin @('review-signoff', 'retro', 'iteration-closeout', 'feature-closeout')) { $warnings.Add('WARN: Accepted review artifacts exist, but lifecycle state is not synced to review-signoff or a later boundary.') | Out-Null } } $requireStateFile = $null -eq $latestBoundary -or [string]$latestBoundary.boundary_type -notin @('retro', 'iteration-closeout', 'feature-closeout') $iterationDirectory = Split-Path -Parent $ReviewPath $iterationNumber = Split-Path -Leaf $iterationDirectory $featurePath = Split-Path -Parent (Split-Path -Parent $iterationDirectory) foreach ($issue in @(Get-SpecrewIterationStateTruthIssues -ProjectRoot $ProjectRoot -FeaturePath $featurePath -IterationNumber $iterationNumber -RequireStateFile:$requireStateFile)) { $warnings.Add(("WARN: {0}" -f $issue)) | Out-Null } if ($warnings.Count -eq 0) { return $null } return ($warnings.ToArray() -join [Environment]::NewLine) } # iter-008: ENGINE- and FEATURE-independent host AUTHORIZATION. `specrew review --host X --authorization-ref Y` # persists the HUMAN authorization to .specrew/reviewer-hosts.json as a PROJECT-level operation - authorizing a # reviewer is setup (often before the first feature), so it must NOT require a resolvable feature/checkpoint, and # it must run regardless of the review engine. Done HERE, before the review arg + feature resolution, so it # survives both the worktree-engine cutover (the write used to live in the now-bypassed legacy --live) and a # no-feature project. ONLY on explicit --host + --authorization-ref (the human-provenance anchor). $authHostName = if (-not [string]::IsNullOrWhiteSpace($ReviewerHost)) { $ReviewerHost } elseif (-not [string]::IsNullOrWhiteSpace($HostName)) { $HostName } else { '' } $authRefValue = $AuthorizationRef; $authModelValue = $Model $cliArgList = @($CliArgs) for ($ai = 0; $ai -lt $cliArgList.Count; $ai++) { switch ([string]$cliArgList[$ai]) { '--host' { if (($ai + 1) -lt $cliArgList.Count) { $authHostName = [string]$cliArgList[$ai + 1] } } '--authorization-ref' { if (($ai + 1) -lt $cliArgList.Count) { $authRefValue = [string]$cliArgList[$ai + 1] } } '--model' { if (($ai + 1) -lt $cliArgList.Count) { $authModelValue = [string]$cliArgList[$ai + 1] } } } } function Set-ReviewerHostRowField { # Assign one field of one reviewer-host row. Adds the property when the existing document predates # it, because a bare `$row.name = value` throws on a PSCustomObject that lacks the property and the # surrounding catch would then fall back to rewriting the whole file - reintroducing exactly the # clobber this is here to prevent (DRIFT-198-I009-028). param( [Parameter(Mandatory = $true)]$Row, [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][AllowNull()]$Value ) if ($Row.PSObject.Properties[$Name]) { $Row.$Name = $Value } else { $Row | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force } } if ((-not [string]::IsNullOrWhiteSpace($authHostName)) -and (-not [string]::IsNullOrWhiteSpace($authRefValue))) { $authWritten = $false $authError = $null $reviewerHostsPath = $null try { $authProjectPath = if ([string]::IsNullOrWhiteSpace($ProjectPath)) { (Get-Location).Path } else { (Resolve-Path -LiteralPath $ProjectPath -ErrorAction Stop).Path } . (Join-Path $PSScriptRoot 'internal/continuous-co-review/_load.ps1') # for New-ContinuousCoReviewDefaultReviewerHostConfig $authConfig = Get-LiveReviewConfiguration -HostName $authHostName -Model $authModelValue -AuthorizationRef $authRefValue -TimeoutSeconds 0 -FallbackPolicy 'none' if ($null -ne $authConfig) { $reviewerHostsPath = Join-Path $authProjectPath '.specrew/reviewer-hosts.json' $rhDir = Split-Path -Parent $reviewerHostsPath if (-not (Test-Path -LiteralPath $rhDir)) { New-Item -ItemType Directory -Path $rhDir -Force | Out-Null } # Update ONE FIELD of ONE ROW, and leave the rest of the file exactly as it was. # # The earlier implementation serialized a FRESH DEFAULT catalog and copied only `allowed` / # `authorization_ref` / `model` back from prior rows that were BOTH allowed and authorized. # Three things followed. (1) Re-authorizing a host RESET its pinned model whenever --model # was omitted, destroying the reviewer-of-record provenance that review evidence cites. # (2) Every other field of every row - adapter_id, review_class_rank, model_source, # cost_class, installed, fallback_allowed, timeout_seconds - reverted to its default. # (3) Worst: a SUSPENDED row is `allowed:false`, so it was not preserved at all, and a # deliberate reviewer-INDEPENDENCE suspension recorded in its authorization_ref was # silently nulled - which could make a suspended host selectable again, the exact # violation the suspension existed to prevent. That is DRIFT-198-I009-028; it happened on # 2026-07-27, had to be repaired by hand, and made this very re-certification's # authorization reference unsafe to record through this path. # # Correct scope: start from the EXISTING document, set the addressed row's # authorization_ref, set its model ONLY when one was explicitly supplied, and touch # nothing else. Fail-safe unchanged: with no prior file (or an unreadable one) the fresh # catalog is the right content. $rhWroteInPlace = $false if (Test-Path -LiteralPath $reviewerHostsPath -PathType Leaf) { try { $existingRh = Get-Content -LiteralPath $reviewerHostsPath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 100 $targetRow = $null foreach ($eh in @($existingRh.hosts)) { if ([string]$eh.host -ceq [string]$authHostName) { $targetRow = $eh; break } } if ($null -eq $targetRow) { foreach ($eh in @($existingRh.hosts)) { if ([string]::Equals([string]$eh.host, [string]$authHostName, [System.StringComparison]::OrdinalIgnoreCase)) { $targetRow = $eh; break } } } if ($null -ne $targetRow) { Set-ReviewerHostRowField -Row $targetRow -Name 'authorization_ref' -Value ([string]$authRefValue) Set-ReviewerHostRowField -Row $targetRow -Name 'allowed' -Value $true if (-not [string]::IsNullOrWhiteSpace($authModelValue)) { Set-ReviewerHostRowField -Row $targetRow -Name 'model' -Value ([string]$authModelValue) } ($existingRh | ConvertTo-Json -Depth 100) | Set-Content -LiteralPath $reviewerHostsPath -Encoding UTF8 $rhWroteInPlace = $true } else { # The addressed host has no row yet. APPEND the fresh catalog's row for it and # keep every existing row as-is - never rebuild the file around it. $freshRow = $null foreach ($nh in @($authConfig.hosts)) { if ([string]::Equals([string]$nh.host, [string]$authHostName, [System.StringComparison]::OrdinalIgnoreCase)) { $freshRow = $nh; break } } if ($null -ne $freshRow) { $existingRh.hosts = @(@($existingRh.hosts) + @($freshRow)) ($existingRh | ConvertTo-Json -Depth 100) | Set-Content -LiteralPath $reviewerHostsPath -Encoding UTF8 $rhWroteInPlace = $true } } } catch { $rhWroteInPlace = $false } } if (-not $rhWroteInPlace) { ($authConfig | ConvertTo-Json -Depth 100) | Set-Content -LiteralPath $reviewerHostsPath -Encoding UTF8 } $authWritten = $true } else { $authError = "Get-LiveReviewConfiguration returned no config for host '$authHostName'." } } catch { $authError = $_.Exception.Message } # A PURE authorize (--host + --authorization-ref, no --live review) is a project-SETUP op (often pre-first-feature, # at the code-implementation lens). Report the outcome HONESTLY and EXIT here - do NOT fall through to the replay # path (Resolve-IterationDirectory below), which throws at workshop time when no iteration folder exists yet (the # "wrote the selection, then looked for the iteration folder" failure). Detect --live from BOTH the -Live switch # AND --live in $CliArgs (this block runs BEFORE arg-parse), so an authorize+review combo still runs the review. # Honest exit code: never report success on a write that did not happen (else a failed authorize reads as authorized # and the navigator silently finds no host). $liveRequested = $Live.IsPresent -or (@($cliArgList) -contains '--live') if (-not $liveRequested) { if ($authWritten) { Write-Host ("Authorized reviewer host '{0}' (ref: {1}) -> {2}" -f $authHostName, $authRefValue, $reviewerHostsPath) exit 0 } $authReason = if ([string]::IsNullOrWhiteSpace($authError)) { 'unknown error' } else { $authError } Write-Error ("Reviewer host authorization FAILED for '{0}': {1}" -f $authHostName, $authReason) exit 1 } } $parsedArgs = Convert-UnixStyleArguments ` -ProjectPath $ProjectPath ` -FeatureId $FeatureId ` -IterationNumber $IterationNumber ` -Quiet $Quiet.IsPresent ` -Json $Json.IsPresent ` -Open $Open.IsPresent ` -Live $Live.IsPresent ` -Help $Help.IsPresent ` -CliArgs $CliArgs if (-not [string]::IsNullOrWhiteSpace($BaselineRef)) { $parsedArgs.BaselineRef = $BaselineRef } if (-not [string]::IsNullOrWhiteSpace($Trunk)) { $parsedArgs.TrunkName = $Trunk } if (-not [string]::IsNullOrWhiteSpace($CheckpointId)) { $parsedArgs.CheckpointId = $CheckpointId } if (-not [string]::IsNullOrWhiteSpace($RunId)) { $parsedArgs.RunId = $RunId } $boundHost = if (-not [string]::IsNullOrWhiteSpace($ReviewerHost)) { $ReviewerHost } else { $HostName } if (-not [string]::IsNullOrWhiteSpace($boundHost)) { $parsedArgs.Host = $boundHost } if (-not [string]::IsNullOrWhiteSpace($Model)) { $parsedArgs.Model = $Model } if (-not [string]::IsNullOrWhiteSpace($AuthorizationRef)) { $parsedArgs.AuthorizationRef = $AuthorizationRef } if (-not [string]::IsNullOrWhiteSpace($CodeWriterHost)) { $parsedArgs.CodeWriterHost = $CodeWriterHost } if (-not [string]::IsNullOrWhiteSpace($FallbackPolicy)) { $parsedArgs.FallbackPolicy = $FallbackPolicy } if (-not [string]::IsNullOrWhiteSpace($ReviewerConfigPath)) { $parsedArgs.ReviewerConfigPath = $ReviewerConfigPath } if (-not [string]::IsNullOrWhiteSpace($SchemaRoot)) { $parsedArgs.SchemaRoot = $SchemaRoot } if (-not [string]::IsNullOrWhiteSpace($RunRoot)) { $parsedArgs.RunRoot = $RunRoot } if ($TimeoutSeconds -gt 0) { $parsedArgs.TimeoutSeconds = $TimeoutSeconds } $boundDesignContextRefs = @($DesignContextRef | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) $boundAllowedPaths = @($AllowedPath | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) $boundForbiddenPaths = @($ForbiddenPath | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) $boundExcludedPaths = @($ExcludePath | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) if ($boundDesignContextRefs.Count -gt 0) { $parsedArgs.DesignContextRefs = @($parsedArgs.DesignContextRefs) + $boundDesignContextRefs } if ($boundAllowedPaths.Count -gt 0) { $parsedArgs.AllowedPaths = @($parsedArgs.AllowedPaths) + $boundAllowedPaths } if ($boundForbiddenPaths.Count -gt 0) { $parsedArgs.ForbiddenPaths = @($parsedArgs.ForbiddenPaths) + $boundForbiddenPaths } if ($boundExcludedPaths.Count -gt 0) { $parsedArgs.ExcludedPathPatterns = @($parsedArgs.ExcludedPathPatterns) + $boundExcludedPaths } if ($PreserveDebug.IsPresent) { $parsedArgs.PreserveDebug = $true } $ProjectPath = $parsedArgs.ProjectPath $FeatureId = $parsedArgs.FeatureId $IterationNumber = $parsedArgs.IterationNumber $Quiet = [bool]$parsedArgs.Quiet $Json = [bool]$parsedArgs.Json $Open = [bool]$parsedArgs.Open $Live = [bool]$parsedArgs.Live $Help = [bool]$parsedArgs.Help if ($Help) { Show-Usage exit 0 } if ($Quiet -and $Json) { Write-Error 'Choose either --quiet or --json, not both.' exit 1 } $resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath if (-not (Test-Path -LiteralPath $resolvedProjectPath -PathType Container)) { Write-Error ("Project path does not exist: {0}" -f $resolvedProjectPath) exit 1 } $installedReviewEngineRoot = Join-Path $PSScriptRoot 'internal/continuous-co-review' $reviewEngineRoot = $installedReviewEngineRoot $requiresReviewEngine = $Live -or (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.ReconcileRunId)) -or (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.AckDegradedRunId)) -or (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.Remediate)) if ($requiresReviewEngine) { $engineResolverPath = Join-Path $PSScriptRoot 'internal/review-engine-resolution.ps1' if (-not (Test-Path -LiteralPath $engineResolverPath -PathType Leaf)) { Write-Error "Review engine resolver is missing: $engineResolverPath" exit 1 } . $engineResolverPath try { $reviewEngineSelection = Resolve-SpecrewReviewEngineRoot -ProjectRoot $resolvedProjectPath -InstalledRuntimeRoot $installedReviewEngineRoot $reviewEngineRoot = [string]$reviewEngineSelection.runtime_root } catch { Write-Error $_.Exception.Message exit 1 } } # FR-062: restart reconciliation is a first-class public operation. It never grants or # invokes a provider; it only executes the immutable plan for one already-recorded run. if (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.ReconcileRunId)) { try { . (Join-Path $reviewEngineRoot '_load.ps1') $authority = Get-ContinuousCoReviewAuthorityDecision if (-not $authority.valid -or -not [bool]$authority.campaign_authority_enabled) { throw ('review-reconciliation-requires-campaign-authority:' + $authority.reason) } $identity = Resolve-ReviewCampaignPublicIdentity -RepoRoot $resolvedProjectPath -FeatureId ([string]$FeatureId) -IterationNumber ([string]$IterationNumber) -RunId ([string]$parsedArgs.ReconcileRunId) $timeout = if ([int]$parsedArgs.TimeoutSeconds -gt 0) { [int]$parsedArgs.TimeoutSeconds } else { 900 } $store = Join-Path $resolvedProjectPath '.specrew/review/authority' $target = New-ReviewCampaignTargetPort -RepoRoot $resolvedProjectPath -RequestedRoot ([string]$parsedArgs.RunRoot) $runtime = New-ReviewProductionRuntimePort -TimeoutSeconds $timeout $reconciled = Invoke-ReviewRunReconciliation -StoreRoot $store -CampaignId $identity.campaign_id -RunId $identity.run_id ` -TargetLineage $identity.target_lineage -TargetPort $target -RuntimePort $runtime -ClockPort (New-ReviewSystemClockPort) if ($Json) { $reconciled | ConvertTo-Json -Depth 30 } else { Write-Host ("review-reconciliation campaign={0} run_id={1} status={2} reason={3}" -f $identity.campaign_id, $identity.run_id, $reconciled.status, $reconciled.reason) if ($null -ne $reconciled.result) { Write-Host ("verdict={0} completion={1} runtime_outcome={2}" -f $reconciled.result.verdict, $reconciled.result.completion, $reconciled.result.runtime_outcome) } } if ([string]$reconciled.status -notin @('complete', 'terminal')) { exit 1 } exit 0 } catch { Write-Error $_.Exception.Message; exit 1 } } # T094/FR-036: record the FIRST-CLASS human acknowledgement of degraded review evidence, then exit # (a standalone op, like authorize). This human-typed command IS the trust boundary the gate's ack # reader relies on - the recorded verdict lets a partial/same-host review satisfy signoff consciously. if (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.AckDegradedRunId)) { try { . (Join-Path $reviewEngineRoot '_load.ps1') if ([string]::IsNullOrWhiteSpace([string]$parsedArgs.AckReason)) { throw '--ack-degraded needs --ack-reason "<why this assurance level is acceptable>" (an ack is never implicit).' } $ackBy = (& git -C $resolvedProjectPath config user.name 2>$null) if ([string]::IsNullOrWhiteSpace([string]$ackBy)) { $ackBy = [string]$env:USERNAME } if ([string]::IsNullOrWhiteSpace([string]$ackBy)) { $ackBy = 'human' } $ack = Add-ContinuousCoReviewDegradedAck -RepoRoot $resolvedProjectPath -RunId ([string]$parsedArgs.AckDegradedRunId) -AuthorizedBy ([string]$ackBy).Trim() -Rationale ([string]$parsedArgs.AckReason) if ($Json) { $ack | ConvertTo-Json -Depth 6 } else { Write-Host ("degraded-evidence acknowledgement recorded for run {0} by {1}" -f $ack.run_id, $ack.authorized_by) -ForegroundColor Green } exit 0 } catch { Write-Error $_.Exception.Message; exit 1 } } # T096/FR-038: record the human's remediation choice (the menu's carrier), then exit. The choice # rides co-review-round-state.json and shapes the NEXT run (more-time/different-host/narrow-scope); # accept-partial/override-block act immediately. This human-typed command is the trust boundary. if (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.Remediate)) { try { . (Join-Path $reviewEngineRoot '_load.ps1') $remediationAuthority = Get-ContinuousCoReviewAuthorityDecision if (-not $remediationAuthority.valid -or [string]$remediationAuthority.mode -eq 'disabled') { throw ("Review authority is unavailable ({0}); neither legacy nor campaign remediation may mutate review state." -f $remediationAuthority.reason) } if ([bool]$remediationAuthority.campaign_authority_enabled) { if ([string]$parsedArgs.Remediate -cne 'override-block') { throw "Campaign remediation '$($parsedArgs.Remediate)' does not create signoff authority; use a new explicitly authorized run." } if ([string]::IsNullOrWhiteSpace([string]$parsedArgs.RunId) -or [string]::IsNullOrWhiteSpace([string]$parsedArgs.AckReason)) { throw 'Campaign override-block requires --run-id and --ack-reason; the disposition is never implicit.' } $identity = Resolve-ReviewCampaignPublicIdentity -RepoRoot $resolvedProjectPath -FeatureId ([string]$FeatureId) -IterationNumber ([string]$IterationNumber) -RunId ([string]$parsedArgs.RunId) $actor = (& git -C $resolvedProjectPath config user.name 2>$null) if ([string]::IsNullOrWhiteSpace([string]$actor)) { $actor = [string]$env:USERNAME } if ([string]::IsNullOrWhiteSpace([string]$actor)) { $actor = 'human' } $store = Join-Path $resolvedProjectPath '.specrew/review/authority' $disposition = Add-ReviewCampaignHumanDisposition -StoreRoot $store -CampaignId $identity.campaign_id -RunId ([string]$parsedArgs.RunId) -Decision accept-current -AuthorizedBy ([string]$actor).Trim() -AuthorizationRef ("public-cli:override-block:{0}" -f $parsedArgs.RunId) -Rationale ([string]$parsedArgs.AckReason) if ($Json) { $disposition | ConvertTo-Json -Depth 10 } else { Write-Host ("campaign finding disposition recorded for run {0} by {1}" -f $parsedArgs.RunId, $actor) -ForegroundColor Green } exit 0 } if (-not [bool]$remediationAuthority.legacy_promotion_enabled) { throw 'Review authority is not available for legacy remediation.' } . (Join-Path $reviewEngineRoot 'worktree-review-orchestrator.ps1') $remParams = @{ RepoRoot = $resolvedProjectPath; Choice = [string]$parsedArgs.Remediate } if ($parsedArgs.TimeoutSecondsExplicit) { $remParams.TimeoutSeconds = [int]$parsedArgs.TimeoutSeconds } if (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.Host)) { $remParams.HostName = [string]$parsedArgs.Host } if (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.Scope)) { $remParams.Scope = [string]$parsedArgs.Scope } if (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.RunId)) { $remParams.RunId = [string]$parsedArgs.RunId } if (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.AckReason)) { $remParams.Reason = [string]$parsedArgs.AckReason } if (-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.FixEvidenceRef)) { $remParams.FixEvidenceRef = [string]$parsedArgs.FixEvidenceRef } $rem = Set-ContinuousCoReviewRemediationChoice @remParams if ($Json) { $rem | ConvertTo-Json -Depth 6 } else { $applied = if ([string]$rem.choice -in @('accept-partial', 'override-block', 'resolved-against-disk', 'allowance-reset')) { 'recorded and applied immediately' } else { 'recorded - it shapes the NEXT review run' } Write-Host ("remediation '{0}' {1} (by {2})" -f $rem.choice, $applied, $rem.authorized_by) -ForegroundColor Green } exit 0 } catch { Write-Error $_.Exception.Message; exit 1 } } if ($Live) { # Resolve the singular authority seam without loading campaign/runtime modules into the # legacy command scope. The legacy timeout resolver depends on its historical load order. . (Join-Path $reviewEngineRoot 'review-authority-cutover.ps1') $authorityDecision = Get-ContinuousCoReviewAuthorityDecision if (-not $authorityDecision.valid -or [string]$authorityDecision.mode -eq 'disabled') { Write-Error ("Review authority is unavailable ({0}); neither legacy nor campaign review may run." -f $authorityDecision.reason) exit 1 } # T051 / FR-057 / FR-065: the existing public surface delegates to exactly one authority path. # Campaign failure never falls back to the historical service, and the historical service never # promotes after campaign cutover. T060 persisted the disabled barrier and then activated the # checked-in campaign mode; the legacy branch below remains only for historical configurations. if ([bool]$authorityDecision.campaign_authority_enabled) { try { . (Join-Path $reviewEngineRoot '_load.ps1') # A project-level `specrew review --host ... --authorization-ref ...` records the # human grant in reviewer-hosts.json. Normal campaign runs must reload that exact # selected entry; otherwise the public command drops the reference and reaches the # authority store with no allowance. An explicit per-run reference remains highest # precedence. Reusing a persisted reference is safe: campaign authority derives one # immutable one-slot grant id from it and never mints another slot for a later run. $campaignHost = [string]$parsedArgs.Host $campaignModel = [string]$parsedArgs.Model $campaignGrantAuthorizationRef = [string]$parsedArgs.AuthorizationRef if ([string]::IsNullOrWhiteSpace($campaignGrantAuthorizationRef)) { $configuredReviewer = Resolve-ContinuousCoReviewConfiguredReviewerCandidate -RepoRoot $resolvedProjectPath ` -ReviewerConfigPath ([string]$parsedArgs.ReviewerConfigPath) -RequestedHost $campaignHost ` -RequestedModel $campaignModel -CodeWriterHost ([string]$parsedArgs.CodeWriterHost) if ($null -ne $configuredReviewer) { if ([string]::IsNullOrWhiteSpace($campaignHost)) { $campaignHost = [string]$configuredReviewer.host } # The catalog/config `model` is a DECLARATION of what the selected host runs # (rows legitimately read 'configured-by-user' or 'gpt-5.5-or-claude-4.8'), never a # per-run CLI override. Promoting it into the harness override made every host whose # file-primary constructor takes no -Model fail at preflight with # production-harness-model-override-unsupported, so a project that recorded a # reviewer model - the state `--host X --authorization-ref Y` itself writes - could # not run a campaign review at all. Only an explicit --model request is an override. $campaignGrantAuthorizationRef = [string]$configuredReviewer.authorization_ref } } $resolvedBudget = if (Get-Command -Name 'Get-ContinuousCoReviewNavigatorTimeoutSeconds' -ErrorAction SilentlyContinue) { [int](Get-ContinuousCoReviewNavigatorTimeoutSeconds -RepoRoot $resolvedProjectPath -HostName $campaignHost) } else { 600 } $tos = if ([int]$parsedArgs.TimeoutSeconds -gt 0) { [int]$parsedArgs.TimeoutSeconds } else { $resolvedBudget } $progressSink = $null if (-not $Json -and -not $Quiet) { $formatProgressCommand = Get-Command -Name 'Format-ReviewProgressEvent' -CommandType Function $progressSink = { param($event) $color = if ([string]$event.stage -in @('duplicate-warning', 'failed')) { 'Yellow' } elseif ([string]$event.stage -ceq 'terminal') { 'Cyan' } else { 'DarkGray' } Write-Host (& $formatProgressCommand -Event $event) -ForegroundColor $color }.GetNewClosure() } $campaignRun = Invoke-ReviewCampaignCommand -RepoRoot $resolvedProjectPath -FeatureId ([string]$FeatureId) -IterationNumber ([string]$IterationNumber) ` -RunId ([string]$parsedArgs.RunId) -ReviewerHost $campaignHost -GrantAuthorizationRef $campaignGrantAuthorizationRef ` -ReviewerHostExplicit:(-not [string]::IsNullOrWhiteSpace([string]$parsedArgs.Host)) ` -DesignContextRefs @($parsedArgs.DesignContextRefs) -ExcludedPathPatterns @($parsedArgs.ExcludedPathPatterns) ` -Model $campaignModel -TargetRoot ([string]$parsedArgs.RunRoot) -TimeoutSeconds $tos -ProgressSink $progressSink if ($Json) { $campaignRun | ConvertTo-Json -Depth 30 } elseif ($Quiet) { $verdict = if ($null -ne $campaignRun.result) { [string]$campaignRun.result.verdict } else { 'none' } Write-Host ("review-run campaign={0} run_id={1} status={2} verdict={3} invoked={4} elapsed_ms={5} usage={6}" -f $campaignRun.campaign_id, $campaignRun.run_id, $campaignRun.status, $verdict, ([bool]$campaignRun.invoked).ToString().ToLowerInvariant(), $campaignRun.diagnostics.elapsed_ms, $campaignRun.diagnostics.usage.status) } else { $border = ('=' * 60) $color = if ($null -ne $campaignRun.result -and [bool]$campaignRun.result.can_approve_current) { 'Green' } else { 'Yellow' } Write-Host $border -ForegroundColor $color $campaignHeading = if (-not [bool]$campaignRun.invoked -and [string]$campaignRun.status -cne 'terminal') { 'SPECREW CO-REVIEW DID NOT RUN' } else { 'SPECREW CAMPAIGN REVIEW' } Write-Host $campaignHeading -ForegroundColor $color Write-Host $border -ForegroundColor $color Write-Host ("Campaign: {0}" -f $campaignRun.campaign_id) Write-Host ("Run: {0} Status: {1} Invoked: {2}" -f $campaignRun.run_id, $campaignRun.status, $campaignRun.invoked) if ($null -ne $campaignRun.result) { Write-Host ("Verdict: {0} Completion: {1} Currentness: {2} Can approve current: {3}" -f $campaignRun.result.verdict, $campaignRun.result.completion, $campaignRun.result.currentness, $campaignRun.result.can_approve_current) if (-not [string]::IsNullOrWhiteSpace([string]$campaignRun.result.failure_reason)) { Write-Host ("Failure: {0}" -f $campaignRun.result.failure_reason) -ForegroundColor Yellow } foreach ($finding in @($campaignRun.result.findings)) { Write-Host (" [{0}] {1}: {2}" -f $finding.severity, $finding.title, $finding.description) } } else { Write-Host ("Reason: {0}" -f $campaignRun.reason) -ForegroundColor Yellow } if ($campaignRun.PSObject.Properties['resolved_timeout_seconds']) { Write-Host ("Resolved timeout: {0} seconds" -f $campaignRun.resolved_timeout_seconds) } $usage = $campaignRun.diagnostics.usage Write-Host ("Observed elapsed: {0:n1}s Heartbeats: {1} Usage: {2}" -f ([long]$campaignRun.diagnostics.elapsed_ms / 1000), $campaignRun.diagnostics.heartbeat_count, $usage.status) if ([string]$usage.status -ceq 'available') { Write-Host ("Usage detail: input={0} output={1} total={2} cost_usd={3}" -f $usage.input_tokens, $usage.output_tokens, $usage.total_tokens, $usage.cost_usd) } $authorityStoreText = if ($campaignRun.PSObject.Properties['store_root'] -and -not [string]::IsNullOrWhiteSpace([string]$campaignRun.store_root)) { [string]$campaignRun.store_root } else { 'unavailable (run ended before authority-store creation)' } Write-Host ("Authority store: {0}" -f $authorityStoreText) } if ([string]$campaignRun.status -cne 'terminal') { exit 1 } exit 0 } catch { Write-Error $_.Exception.Message; exit 1 } } # Legacy diagnostic path retained only while the singular cutover seam says mode=legacy. $coReviewEngine = 'worktree' if ($coReviewEngine -eq 'worktree') { try { . (Join-Path $reviewEngineRoot 'co-review-service.ps1') # Budget resolution (F-198 FR-021/FR-022, supersedes the D-197-I010-006 flat default): # explicit --timeout-seconds wins (explicit-beats-config) -> project config -> catalog # per-host default -> the 600-second floor. When an explicit value UNDERCUTS what the # chain would resolve, warn AT RESOLUTION TIME so the operator sees the downgrade # before losing a review cycle to it. $resolvedBudget = if (Get-Command -Name 'Get-ContinuousCoReviewNavigatorTimeoutSeconds' -ErrorAction SilentlyContinue) { [int](Get-ContinuousCoReviewNavigatorTimeoutSeconds -RepoRoot $resolvedProjectPath -HostName ([string]$parsedArgs.Host)) } else { 600 } $tos = if ([int]$parsedArgs.TimeoutSeconds -gt 0) { [int]$parsedArgs.TimeoutSeconds } else { $resolvedBudget } if ([int]$parsedArgs.TimeoutSeconds -gt 0 -and [int]$parsedArgs.TimeoutSeconds -lt $resolvedBudget) { Write-Host ("[co-review] NOTE: your explicit budget ({0}s) is below the resolved budget for this setup ({1}s). Reviews here typically need the larger budget - a too-small one can end the review before it produces anything. If it gets cut short, ask me and I'll request your approval to re-run with the larger budget." -f [int]$parsedArgs.TimeoutSeconds, $resolvedBudget) -ForegroundColor Yellow } # T093/FR-035: an explicit `--host X --live` is a reviewer-host REQUEST for this run - # honoured (even same-host, labelled) or surfaced, never silently substituted. $run = Start-ContinuousCoReviewServiceRun -RepoRoot $resolvedProjectPath -RunId ([string]$parsedArgs.RunId) -BaselineRef ([string]$parsedArgs.BaselineRef) -CodeWriterHost ([string]$parsedArgs.CodeWriterHost) -RequestedHost ([string]$parsedArgs.Host) -TimeoutSeconds $tos $findings = Get-ContinuousCoReviewServiceFindings -RepoRoot $resolvedProjectPath -RunId $run.run_id $fc = if ($findings) { @($findings.findings).Count } else { 0 } $fstatus = if ($findings) { [string]$findings.status } else { '' } # FAIL LOUD: the inline run returns status='done' ONLY when the co-review actually ran. Any other status # (notably 'failed' / no-authorized-reviewer-host) is NOT a clean review - surface the reason + remediation # and exit NON-ZERO, so a caller cannot read an empty result as "reviewed, no findings" and substitute its # own review (the failure mode that let an unauthorized run get accepted on the Copilot dogfood). if ([string]$run.status -ne 'done') { $reason = if (($run.PSObject.Properties['failure_reason']) -and (-not [string]::IsNullOrWhiteSpace([string]$run.failure_reason))) { [string]$run.failure_reason } else { [string]$run.status } if ($Json) { [pscustomobject]@{ run_id = $run.run_id; engine = 'worktree'; status = $run.status; failure_reason = $reason; ok = $false; run_dir = $run.run_dir } | ConvertTo-Json -Depth 8 } else { $rb = ('=' * 60) Write-Host $rb -ForegroundColor Red Write-Host 'SPECREW CO-REVIEW DID NOT RUN' -ForegroundColor Red Write-Host $rb -ForegroundColor Red Write-Host ("Run: {0} Reason: {1}" -f $run.run_id, $reason) if ($reason -match 'no-authorized-reviewer-host') { Write-Host 'No reviewer host is authorized. Authorize one (independent of the code-writer):' Write-Host ' specrew review --host <claude|codex|...> --authorization-ref <ref>' } elseif ($reason -match 'timeout|budget') { # F-198 FR-022 teaching (consumer-legible, amended approval UX): the sanctioned # next step is a bigger budget approved by the human - the assistant asks, the # human approves, the assistant re-runs. Never runtime-state surgery. Write-Host ("Inspect: {0}" -f $run.run_dir) Write-Host ("This looks like a review budget kill ({0}s was not enough). Ask your assistant to request your approval for a longer budget and re-run - or raise co_review_timeout_seconds in .specrew/config.yml yourself. A plain re-run with the same budget will likely die the same way." -f $tos) -ForegroundColor Yellow } else { Write-Host ("Inspect: {0}" -f $run.run_dir) } Write-Host 'Do NOT substitute another review for this - the co-review must run to produce gate evidence.' -ForegroundColor Yellow } exit 1 } if ($Json) { [pscustomobject]@{ run_id = $run.run_id; engine = 'worktree'; status = $run.status; findings_status = $fstatus; findings_count = $fc; run_dir = $run.run_dir } | ConvertTo-Json -Depth 8 } elseif ($Quiet) { Write-Host ("review-run run_id={0} engine=worktree status={1} findings={2}" -f $run.run_id, $run.status, $fc) } else { $border = ('=' * 60) Write-Host $border -ForegroundColor Green Write-Host 'SPECREW LIVE REVIEW (worktree engine)' -ForegroundColor Green Write-Host $border -ForegroundColor Green Write-Host ("Run: {0}" -f $run.run_id) Write-Host ("Status: {0} Findings: {1} ({2})" -f $run.status, $fc, $fstatus) if ($run.PSObject.Properties['elapsed_seconds'] -and $null -ne $run.elapsed_seconds) { Write-Host ("Elapsed: {0}s Timeout: {1}s" -f $run.elapsed_seconds, $run.timeout_seconds) } if ($findings -and $fc -gt 0) { foreach ($f in @($findings.findings)) { # location.path is OPTIONAL per the FindingsResult schema (a salvage/harvest # finding may be path-less) - render null-safe (StrictMode). $floc = if (($null -ne $f.PSObject.Properties['location']) -and $null -ne $f.location -and ($null -ne $f.location.PSObject.Properties['path']) -and -not [string]::IsNullOrWhiteSpace([string]$f.location.path)) { [string]$f.location.path } else { '(no path)' } Write-Host (" [{0}] {1} - {2}" -f $f.severity, $floc, ([string]$f.comment)) } } } # HOST-NEUTRAL gate evidence: the detached reap promotes on a host whose Stop hook fires, but a # straight-through host (Copilot) never fires it - so THIS inline door (the F3 checkpoint) promotes through # the SAME canonical producer (Add-...PassRunRecord with the DIGEST), gated on the SAME affirmative-pass # decision the reap uses. Idempotent + fail-open: a later reap promotion of the same run is a no-op, and any # failure leaves the gate to block safely. Advisory-only (no promotion) on a non-affirmative verdict. try { # P1 (Codex review): a SCOPED live review (explicit --baseline-ref) is exploratory and must NOT # auto-anchor signoff evidence - the --live help says an explicit baseline does not auto-anchor. # Promoting it records the merge-base digest as if the whole feature were reviewed, letting a narrow # `--baseline-ref HEAD~1` satisfy review-signoff for earlier changes that were never co-reviewed. # Promote ONLY a signoff run (baseline OMITTED -> auto-anchored to the feature merge-base). $scopedExploratoryReview = -not [string]::IsNullOrWhiteSpace([string]$parsedArgs.BaselineRef) $verdict = ConvertFrom-ContinuousCoReviewNavigatorVerdict -ResultPath (Join-Path $run.run_dir 'result.out') $isPromotablePass = Test-ContinuousCoReviewVerdictIsPromotablePass -Verdict $verdict if ($isPromotablePass -and (-not $scopedExploratoryReview)) { $digestId = if ($run.PSObject.Properties['reviewed_digest_tree_id']) { [string]$run.reviewed_digest_tree_id } else { '' } if (-not [string]::IsNullOrWhiteSpace($digestId)) { # T094/FR-036: carry the run's 3-dimension evidence labels onto the promoted record. $doorLabels = [pscustomobject]@{ completeness = 'full'; independence = 'unverified'; budget = 'normal' } try { $doorStatusPath = Join-Path $run.run_dir 'status.json' if (Test-Path -LiteralPath $doorStatusPath -PathType Leaf) { $doorStatus = Get-Content -LiteralPath $doorStatusPath -Raw -Encoding UTF8 | ConvertFrom-Json if (($doorStatus.PSObject.Properties.Name -contains 'completeness') -and -not [string]::IsNullOrWhiteSpace([string]$doorStatus.completeness)) { $doorLabels.completeness = [string]$doorStatus.completeness } if (($doorStatus.PSObject.Properties.Name -contains 'reviewer_independence') -and -not [string]::IsNullOrWhiteSpace([string]$doorStatus.reviewer_independence)) { $doorLabels.independence = [string]$doorStatus.reviewer_independence } if (($doorStatus.PSObject.Properties.Name -contains 'budget_bumped')) { try { if ([bool]$doorStatus.budget_bumped) { $doorLabels.budget = 'time-extended' } } catch { $null = $_ } } } } catch { $null = $_ } $promoted = Add-ContinuousCoReviewNavigatorPassRunRecord -RepoRoot $resolvedProjectPath -RunId $run.run_id -TreeId $digestId -EvidenceLabels $doorLabels -Now ([datetime]::UtcNow) if ((-not [string]::IsNullOrWhiteSpace([string]$promoted)) -and (-not $Quiet) -and (-not $Json)) { Write-Host (" promoted as co-review gate evidence (run {0})" -f $run.run_id) -ForegroundColor Green } } } elseif ($isPromotablePass -and $scopedExploratoryReview -and (-not $Quiet) -and (-not $Json)) { Write-Host (" scoped review (--baseline-ref {0}) is exploratory - NOT promoted as signoff gate evidence." -f $parsedArgs.BaselineRef) -ForegroundColor Yellow } } catch { $null = $_ } } catch { Write-Error $_.Exception.Message; exit 1 } exit 0 } } try { $selection = Resolve-IterationDirectory -ProjectRoot $resolvedProjectPath -FeatureId $FeatureId -IterationNumber $IterationNumber } catch { Write-Error $_.Exception.Message exit 1 } $iterationDirectory = $selection.Path $reviewPath = Join-Path $iterationDirectory 'review.md' $reviewerIndexPath = Join-Path $iterationDirectory 'reviewer-index.md' $reviewDiagramsPath = Join-Path $iterationDirectory 'review-diagrams.md' $indexLines = @(Get-MarkdownContent -Path $reviewerIndexPath) $summaryLines = @(Get-MarkdownSectionLines -Lines $indexLines -Heading 'Summary' | Where-Object { $_.Trim().StartsWith('- ') } | ForEach-Object { $_.Trim().Substring(2) }) $digestLines = @(Get-MarkdownSectionLines -Lines $indexLines -Heading 'Replay Digest' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $digestLine = if ($digestLines.Count -gt 0) { ($digestLines[0] -replace '^`|`$', '').Trim() } else { '' } $summary = [pscustomobject]@{ feature = $selection.Feature iteration = $selection.Iteration reviewed = Get-MetadataValue -Path $reviewPath -Label 'Reviewed' overall_verdict = Get-MetadataValue -Path $reviewPath -Label 'Overall Verdict' reviewer_index = Get-RelativePath -FromDirectory $resolvedProjectPath -ToPath $reviewerIndexPath review_diagrams = if (Test-Path -LiteralPath $reviewDiagramsPath -PathType Leaf) { Get-RelativePath -FromDirectory $resolvedProjectPath -ToPath $reviewDiagramsPath } else { $null } summary_lines = $summaryLines digest = $digestLine cap_active = if ($digestLine -match 'cap=active') { $true } else { $false } cap_chain = if ($digestLine -match 'cap_chain=(\d+)/(\d+)') { "$($Matches[1])/$($Matches[2])" } else { $null } boundary_sync_warning = Get-ReviewBoundarySyncWarning -ProjectRoot $resolvedProjectPath -ReviewPath $reviewPath } if ($Json) { $summary | ConvertTo-Json -Depth 4 } elseif ($Quiet) { if ([string]::IsNullOrWhiteSpace($digestLine)) { Write-Error 'reviewer-index.md does not contain a replay digest.' exit 1 } Write-Host $digestLine } else { $border = ('=' * 60) Write-Host $border -ForegroundColor Green Write-Host 'SPECREW REVIEWER SUMMARY' -ForegroundColor Green Write-Host $border -ForegroundColor Green foreach ($line in $summaryLines) { Write-Host $line } if (-not [string]::IsNullOrWhiteSpace($digestLine)) { Write-Host '' Write-Host $digestLine } if (-not [string]::IsNullOrWhiteSpace([string]$summary.boundary_sync_warning)) { Write-Output $summary.boundary_sync_warning } } if ($Open) { $openedAny = $false foreach ($path in @($reviewerIndexPath, $reviewDiagramsPath)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } if (Try-OpenPath -Path $path) { $openedAny = $true } else { Write-Host ("Open manually: {0}" -f $path) } } if (-not $openedAny -and -not (Test-Path -LiteralPath $reviewerIndexPath -PathType Leaf)) { Write-Host ("Open manually: {0}" -f $reviewerIndexPath) if (Test-Path -LiteralPath $reviewDiagramsPath -PathType Leaf) { Write-Host ("Open manually: {0}" -f $reviewDiagramsPath) } } } exit 0 |