scripts/internal/continuous-co-review/continuous-co-review-navigator.ps1
|
$ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest # T078 / T079 (FR-026 / FR-030 / FR-031): the async co-review NAVIGATOR + the pending registry & reaper. # # This is the in-glob LOGIC of the always-on co-review navigator (the pair-programming navigator that # auto-fires a fresh-context co-review at every real implement checkpoint, host-neutral, non-blocking). # A THIN entry-point at extensions/specrew-speckit/scripts/specrew-co-review-navigator-provider.ps1 is # what the F-185 hook dispatcher invokes on Stop/SessionStart (it locates + dot-sources THIS file via # the module-base ladder, then calls Invoke-ContinuousCoReviewNavigator). Keeping the logic here makes # it unit-testable by direct dot-source (like every other CCR module) and keeps the dispatcher-facing # surface a one-file loader. # # THE WHOLE-PIPELINE SHAPE (per the iteration-005 design): # On each Stop, FAST (well inside the ~20s provider budget; #2885), it NEVER waits for a review: # 1. REAP first (T079): scan .specrew/review/pending/. A done entry -> surface its verdict (a # blocking verdict emits the 185 <<<SPECREW-STOP-BLOCK>>> sentinel + a directive; else a brief # inject note), then retire it. A past-deadline-but-supervisor-alive entry -> Stop the task # (kill + cleanup). A supervisor-gone-with-no-terminal-status entry -> mark crashed + clean the # orphaned worktree. (Backstops the supervisor's own finally-dispose for the DEAD-launcher case.) # 2. If this Stop is a real implement CHECKPOINT (reuse the Phase A # Invoke-ContinuousCoReviewGateDispatch detection - do NOT re-derive) AND the current reviewed # tree-id differs from the last-FIRED tree-id (dedup via the Iteration-004 digest), FIRE # Start-SpecrewIsolatedTask {read-only, discard, code-review} with a reviewer -Command that # emits a verdict JSON on stdout (captured to the run's result_path). Record the fired tree-id. # Return immediately. # 3. Otherwise no-op, emitting NOTHING (a no-op stop must not perturb the dispatcher's merged result). # On SessionStart, SWEEP: reap cross-session orphans (pending entries from a prior session - kill any # live supervisor, clean orphaned worktrees) so a session that died mid-review never leaks. # # CONCURRENCY: one pending review at a time for the navigator. A new checkpoint SUPERSEDES an # un-reaped prior (Stop the prior, then fire the replacement). # # F-184 footprint: NONE. Non-protected script under the CCR internal location. PowerShell 7.x. # --- shared launcher (single source) ------------------------------------------------------------- # The navigator FIRES + REAPS through the general isolated-task launcher (T077). Dot-source it if its # functions are not already present (the dispatcher-facing loader resolves the path; tests dot-source # the launcher themselves). Resolution is best-effort: a miss leaves Start/Stop-SpecrewIsolatedTask # undefined and the navigator degrades to its fail-open no-op (the caller WARNs once). if (-not (Get-Command -Name 'Start-SpecrewIsolatedTask' -ErrorAction SilentlyContinue)) { # This file lives at scripts/internal/continuous-co-review/; the launcher is its SIBLING-DIR file # scripts/internal/agent-tasks/isolated-task-launcher.ps1. So one parent (scripts/internal) + the # agent-tasks leaf - NOT two parents (which would land at scripts/agent-tasks and miss). $script:NavigatorLauncherCandidates = @( (Join-Path (Split-Path -Parent $PSScriptRoot) 'agent-tasks/isolated-task-launcher.ps1') ) foreach ($cand in $script:NavigatorLauncherCandidates) { if (Test-Path -LiteralPath $cand -PathType Leaf) { . $cand; break } } } # --- the CCR engine (single source) -------------------------------------------------------------- # FIRST-LIVE-RUN FIX (iter-006 e2e): the navigator's CHECKPOINT DETECTION (the Phase A gate-dispatch # reuse + Get-...MergeBaseAnchor + Get-...CheckpointDiff) and the reviewer plan / promotion / blackboard # all need the CCR engine (_load.ps1: checkpoint-diff-provider, gate-review-dispatcher, the request # builder, the catalog/selection/authorization, the blackboard writer). The dispatcher-facing provider # dot-sources ONLY this file, and the checkpoint detection runs BEFORE New-...ReviewerPlan's own # lazy-load - so without _load here the navigator NO-OPS on EVERY live Stop (the checkpoint functions are # undefined). Dot-source it if absent (a _load function gates it; _load does NOT load this navigator, so # no circular). Best-effort: a miss leaves the navigator at its fail-open no-op. if (-not (Get-Command -Name 'Get-ContinuousCoReviewCheckpointDiff' -ErrorAction SilentlyContinue)) { $script:NavigatorEngineLoad = Join-Path $PSScriptRoot '_load.ps1' if (Test-Path -LiteralPath $script:NavigatorEngineLoad -PathType Leaf) { . $script:NavigatorEngineLoad } } function Get-ContinuousCoReviewNavigatorModuleBase { # T082 HAZARD A: the detached reviewer pwsh runs with cwd = the materialized read-only WORKTREE, # which contains NO Specrew scripts (it is a `git archive` content export of the reviewed project). # So the fired -Command cannot dot-source the execution engine / adapters / contracts relative to # its cwd. We resolve the Specrew module base HERE (in-repo, at fire) from THIS file's own location # and thread the ABSOLUTE path into the -Command. This file lives at # scripts/internal/continuous-co-review/, so the module base (where scripts/, extensions/ live) is # three parents up. SPECREW_MODULE_PATH is the fallback (it is what the provider + the test harness # set). The returned base is the dir that CONTAINS scripts/internal/continuous-co-review/_load.ps1. $candidates = New-Object System.Collections.Generic.List[string] try { $fromHere = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '../../..')).Path if (-not [string]::IsNullOrWhiteSpace($fromHere)) { [void]$candidates.Add($fromHere) } } catch { $null = $_ } if (-not [string]::IsNullOrWhiteSpace($env:SPECREW_MODULE_PATH)) { [void]$candidates.Add($env:SPECREW_MODULE_PATH) } foreach ($base in $candidates.ToArray()) { $probe = Join-Path $base 'scripts/internal/continuous-co-review/_load.ps1' if (Test-Path -LiteralPath $probe -PathType Leaf) { try { return (Resolve-Path -LiteralPath $base).Path } catch { return $base } } } return $null } function Get-ContinuousCoReviewNavigatorTimeoutSeconds { # T082 / condition-c: the co-review timeout config scalar. iteration 002 proved a real codex # full-iteration review needs ~300s (a 120s rerun timed out, so no findings landed). This raises the # navigator default OFF 120 to a value that clears a real codex run, and makes it project-overridable # via .specrew/config.yml `co_review_timeout_seconds` (mirroring Get-ContinuousCoReviewGateEnforcementEnabled's # quote-strip + inline-comment-tolerant grammar). This is the ADAPTER/host-call budget (how long the # reviewer process may run); the launcher's own supervisor TimeoutSec is kept ABOVE it (see # Invoke-ContinuousCoReviewNavigator) so the adapter times out gracefully before the supervisor hard-kills. param([Parameter(Mandatory)][string]$RepoRoot, [int]$Default = 300) $configPath = Join-Path $RepoRoot '.specrew/config.yml' if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { return $Default } try { foreach ($line in Get-Content -LiteralPath $configPath -Encoding UTF8) { if ($line -match '^\s*co_review_timeout_seconds:\s*[''"]?(?<value>[^''"#]+?)[''"]?\s*(?:#.*)?$') { $parsed = 0 if ([int]::TryParse(($Matches['value'].Trim()), [ref]$parsed) -and $parsed -gt 0) { return $parsed } } } } catch { $null = $_ } return $Default } function Get-ContinuousCoReviewNavigatorFeatureRoot { # Resolve the ACTIVE feature directory (repo-relative, e.g. 'specs/197-continuous-co-review') so the # reviewer gets the right design context. The navigator is GENERIC (it must work in any governed # project), so it never hardcodes a feature path. Source of truth = .specify/feature.json # feature_directory (the canonical resolver every other Specrew script uses - task-progress.ps1, # worktree-awareness.ps1); fallback = .specrew/start-context.json session_state.feature_path. Returns # a repo-relative path, or $null when no feature is resolvable (the caller then fires no real review - # fail-open, never a stub). param([Parameter(Mandatory)][string]$RepoRoot) $featureJsonPath = Join-Path $RepoRoot '.specify/feature.json' if (Test-Path -LiteralPath $featureJsonPath -PathType Leaf) { try { $fj = Get-Content -LiteralPath $featureJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json if (($fj.PSObject.Properties.Name -contains 'feature_directory') -and -not [string]::IsNullOrWhiteSpace([string]$fj.feature_directory)) { $rel = ([string]$fj.feature_directory).Replace('\', '/').TrimEnd('/') if (Test-Path -LiteralPath (Join-Path $RepoRoot $rel) -PathType Container) { return $rel } } } catch { $null = $_ } } $scPath = Join-Path $RepoRoot '.specrew/start-context.json' if (Test-Path -LiteralPath $scPath -PathType Leaf) { try { $sc = Get-Content -LiteralPath $scPath -Raw -Encoding UTF8 | ConvertFrom-Json $fp = $null if ($sc.PSObject.Properties['session_state'] -and $null -ne $sc.session_state -and $sc.session_state.PSObject.Properties['feature_path']) { $fp = [string]$sc.session_state.feature_path } elseif ($sc.PSObject.Properties['feature_path']) { $fp = [string]$sc.feature_path } if (-not [string]::IsNullOrWhiteSpace($fp)) { $full = $fp if (-not [System.IO.Path]::IsPathRooted($fp)) { $full = Join-Path $RepoRoot $fp } if (Test-Path -LiteralPath $full -PathType Container) { try { return ([System.IO.Path]::GetRelativePath((Resolve-Path -LiteralPath $RepoRoot).Path, (Resolve-Path -LiteralPath $full).Path)).Replace('\', '/') } catch { $null = $_ } } } } catch { $null = $_ } } return $null } function Get-ContinuousCoReviewNavigatorDesignContextRefs { # Resolve the design-context refs the reviewer needs (ReviewRequest.v2 REQUIRES at least one, else # New-ContinuousCoReviewRequest throws and the fire fail-opens to no-op - no real review). Generic: # probe the active feature dir for spec.md + the LATEST iteration's design-analysis.md + workshop/. # Returns repo-relative refs (the request builder reads them under RepoRoot). Empty when nothing # resolves (caller fires no real review). param([Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$FeatureRoot) $refs = New-Object System.Collections.Generic.List[string] $featureFull = Join-Path $RepoRoot $FeatureRoot $specPath = Join-Path $featureFull 'spec.md' if (Test-Path -LiteralPath $specPath -PathType Leaf) { [void]$refs.Add(("$FeatureRoot/spec.md")) } # The latest iteration's design-analysis.md (highest-numbered iterations/NNN/), so the review reflects # the iteration actually being implemented - not a hardcoded iterations/001. $iterationsRoot = Join-Path $featureFull 'iterations' if (Test-Path -LiteralPath $iterationsRoot -PathType Container) { $iterDirs = @(Get-ChildItem -LiteralPath $iterationsRoot -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^\d+$' } | Sort-Object -Property @{ Expression = { [int]$_.Name } } -Descending) foreach ($iter in $iterDirs) { $da = Join-Path $iter.FullName 'design-analysis.md' if (Test-Path -LiteralPath $da -PathType Leaf) { [void]$refs.Add(("$FeatureRoot/iterations/$($iter.Name)/design-analysis.md")); break } } } # spec.md is the minimum; if neither spec nor design-analysis exists, return empty (no real review). return @($refs.ToArray() | Select-Object -Unique) } function Get-ContinuousCoReviewNavigatorPendingDir { # The pending-task registry dir (launcher<->reaper signaling). Stable, in-repo, gitignored + # digest-stripped (.specrew/** is out of the reviewed tree-id), so it survives the fire->reap gap # ACROSS a session boundary for the SessionStart sweep. Mirrors Get-SpecrewIsolatedTaskPendingDir; # redefined here so the navigator does not require the launcher to be loaded just to find the dir # during a reap-only path. param([Parameter(Mandatory)][string]$RepoRoot) return (Join-Path $RepoRoot '.specrew/review/pending') } function Get-ContinuousCoReviewNavigatorRunDir { # Per-run scratch dir under the pending registry (status.json + result.out + job/harness). Lives # beside the registry so a single sweep over .specrew/review/pending/ finds both the registry # entry (<run-id>.json) and its run dir (<run-id>/). param([Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$RunId) return (Join-Path (Get-ContinuousCoReviewNavigatorPendingDir -RepoRoot $RepoRoot) $RunId) } function Get-ContinuousCoReviewNavigatorStatePath { # The navigator's own dedup state (the last-FIRED reviewed tree-id). Under .specrew/runtime/ # (gitignored, regenerated per machine). A read/write miss disables dedup (re-fire is safe), never # blocks. param([Parameter(Mandatory)][string]$RepoRoot) return (Join-Path $RepoRoot '.specrew/runtime/co-review-navigator-state.json') } function Get-ContinuousCoReviewNavigatorLastFiredTreeId { param([Parameter(Mandatory)][string]$RepoRoot) $path = Get-ContinuousCoReviewNavigatorStatePath -RepoRoot $RepoRoot try { if (Test-Path -LiteralPath $path -PathType Leaf) { $rec = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($rec.PSObject.Properties.Name -contains 'last_fired_tree_id') { return [string]$rec.last_fired_tree_id } } } catch { $null = $_ } return $null } function Set-ContinuousCoReviewNavigatorLastFiredTreeId { param([Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$TreeId, [AllowNull()][string]$RunId) $path = Get-ContinuousCoReviewNavigatorStatePath -RepoRoot $RepoRoot try { $dir = Split-Path -Parent $path if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } ([pscustomobject]@{ last_fired_tree_id = $TreeId last_fired_run_id = $RunId fired_at = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress) | Set-Content -LiteralPath $path -Encoding UTF8 -ErrorAction Stop } catch { $null = $_ } } function Get-ContinuousCoReviewNavigatorPendingEntries { # Every registry entry (<run-id>.json directly under the pending dir). Each is the launcher's # registry object plus its on-disk path. Unreadable/partial files are skipped (fail-open). param([Parameter(Mandatory)][string]$RepoRoot) $pendingDir = Get-ContinuousCoReviewNavigatorPendingDir -RepoRoot $RepoRoot if (-not (Test-Path -LiteralPath $pendingDir -PathType Container)) { return @() } $entries = New-Object System.Collections.Generic.List[object] foreach ($file in @(Get-ChildItem -LiteralPath $pendingDir -Filter '*.json' -File -ErrorAction SilentlyContinue)) { try { $reg = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop $entries.Add([pscustomobject]@{ registry_path = $file.FullName; registry = $reg }) | Out-Null } catch { $null = $_ } } return $entries.ToArray() } function Get-ContinuousCoReviewNavigatorSupervisorPresence { # TRI-STATE supervisor liveness for the reap (dogfood finding 2: do not treat a transient # Get-Process FAILURE as crashed-and-kill). Returns one of: # 'present' - the supervisor pid is a live process (leave the entry running). # 'absent' - Get-Process UNAMBIGUOUSLY reports no such process (a dead pid throws # NoProcessFoundForGivenId / category ObjectNotFound) OR the pid is missing/zero # (nothing to be alive). This is the ONLY signal that licenses an orphan reap. # 'unknown' - Get-Process threw something OTHER than not-found (e.g. transient/permission). # A genuinely-running review must NOT be reaped on a transient error; the caller # leaves it pending for the next reap. param([AllowNull()]$Registry) if ($null -eq $Registry -or -not ($Registry.PSObject.Properties.Name -contains 'supervisor_pid')) { return 'absent' } $supPid = $Registry.supervisor_pid if (-not $supPid) { return 'absent' } try { $null = Get-Process -Id ([int]$supPid) -ErrorAction Stop return 'present' } catch { # Discriminate: "no such process" is a DEFINITE absence; anything else is transient/unknown. # Verified empirically (pwsh 7.x): a dead pid throws FullyQualifiedErrorId # 'NoProcessFoundForGivenId,...' with CategoryInfo.Category 'ObjectNotFound'. $fqid = [string]$_.FullyQualifiedErrorId $isNotFound = ($fqid -like 'NoProcessFoundForGivenId*') -or ($null -ne $_.CategoryInfo -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) if ($isNotFound) { return 'absent' } return 'unknown' } } function Test-ContinuousCoReviewNavigatorSupervisorAlive { # BACK-COMPAT boolean wrapper: $true only when the supervisor is definitively 'present'. The reap # uses the tri-state directly (it must distinguish 'absent' from 'unknown'); other callers that # only ask "is it running" keep the simple boolean. param([AllowNull()]$Registry) return ((Get-ContinuousCoReviewNavigatorSupervisorPresence -Registry $Registry) -eq 'present') } function Test-ContinuousCoReviewNavigatorPastDeadline { # Is the registry entry past its supervisor-recorded deadline (UTC ISO-8601)? A missing/garbage # deadline -> NOT past-deadline (do not reap a still-running task on an unparseable timestamp). param([AllowNull()]$Registry, [datetime]$Now = [datetime]::UtcNow) if ($null -eq $Registry -or -not ($Registry.PSObject.Properties.Name -contains 'deadline')) { return $false } $raw = [string]$Registry.deadline if ([string]::IsNullOrWhiteSpace($raw)) { return $false } try { $deadline = [datetime]::Parse($raw, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal) return ($Now -gt $deadline) } catch { return $false } } function ConvertFrom-ContinuousCoReviewNavigatorVerdict { # Parse a reviewer verdict from a completed run's result file. The reviewer harness emits a verdict # JSON on stdout (captured to result.out by the supervisor's stdio redirect). The canonical shape is # { disposition, blocking, findings } - but a FindingsResult.v1 ({ status, findings }) is also # accepted (the real reviewer emits that), with blocking inferred from a blocking finding. Returns # a normalized @{ ok; blocking; disposition; summary; raw } or ok=$false if nothing parseable. param([AllowNull()][string]$ResultPath) $out = [pscustomobject]@{ ok = $false; blocking = $false; disposition = $null; summary = $null; raw = $null } if ([string]::IsNullOrWhiteSpace($ResultPath) -or -not (Test-Path -LiteralPath $ResultPath -PathType Leaf)) { return $out } $text = $null try { $text = Get-Content -LiteralPath $ResultPath -Raw -Encoding UTF8 } catch { return $out } if ([string]::IsNullOrWhiteSpace($text)) { return $out } $verdict = $null try { $verdict = $text | ConvertFrom-Json -ErrorAction Stop } catch { # Tolerate prose around the JSON: take the outermost {...} span. $first = $text.IndexOf('{'); $last = $text.LastIndexOf('}') if ($first -ge 0 -and $last -gt $first) { try { $verdict = $text.Substring($first, $last - $first + 1) | ConvertFrom-Json -ErrorAction Stop } catch { $verdict = $null } } } if ($null -eq $verdict) { return $out } $out.ok = $true $out.raw = $verdict # disposition (canonical verdict shape) OR status (FindingsResult.v1). if ($verdict.PSObject.Properties.Name -contains 'disposition' -and -not [string]::IsNullOrWhiteSpace([string]$verdict.disposition)) { $out.disposition = [string]$verdict.disposition } elseif ($verdict.PSObject.Properties.Name -contains 'status' -and -not [string]::IsNullOrWhiteSpace([string]$verdict.status)) { $out.disposition = [string]$verdict.status } # blocking: an explicit boolean wins; else infer from a blocking-severity finding or a # block/reject/fail disposition. $blocking = $false if ($verdict.PSObject.Properties.Name -contains 'blocking') { try { $blocking = [bool]$verdict.blocking } catch { $blocking = $false } } if (-not $blocking -and ($verdict.PSObject.Properties.Name -contains 'findings') -and $null -ne $verdict.findings) { foreach ($f in @($verdict.findings)) { $sev = if ($null -ne $f -and ($f.PSObject.Properties.Name -contains 'severity')) { [string]$f.severity } else { '' } $disp = if ($null -ne $f -and ($f.PSObject.Properties.Name -contains 'disposition')) { [string]$f.disposition } else { '' } if ($sev -match '(?i)^(blocking|block|critical|high)$' -or $disp -match '(?i)^block') { $blocking = $true; break } } } if (-not $blocking -and -not [string]::IsNullOrWhiteSpace([string]$out.disposition) -and ([string]$out.disposition) -match '(?i)\b(block|reject|fail)') { $blocking = $true } $out.blocking = $blocking # is_stub: the default placeholder reviewer (Build-...ReviewerCommand) marks itself reviewer='stub'. # It ALWAYS emits pass without actually reviewing, so it must never become gate evidence (else the # signoff gate is auto-satisfiable by plumbing). A real reviewer omits the marker. (closeout / flag 2) $isStub = ($verdict.PSObject.Properties.Name -contains 'reviewer') -and (([string]$verdict.reviewer).Trim() -eq 'stub') $out | Add-Member -NotePropertyName is_stub -NotePropertyValue ([bool]$isStub) -Force # A short human summary line (finding count + first comment), for the inject/STOP-BLOCK directive. $findingCount = 0 $firstComment = $null if (($verdict.PSObject.Properties.Name -contains 'findings') -and $null -ne $verdict.findings) { $arr = @($verdict.findings) $findingCount = $arr.Count foreach ($f in $arr) { if ($null -ne $f -and ($f.PSObject.Properties.Name -contains 'comment') -and -not [string]::IsNullOrWhiteSpace([string]$f.comment)) { $firstComment = [string]$f.comment; break } } } $out.summary = ("{0} finding(s){1}" -f $findingCount, ($(if ($firstComment) { ": $firstComment" } else { '' }))) return $out } function Clear-ContinuousCoReviewNavigatorEntry { # Retire a fully-processed registry entry (move it out of the active pending dir so a later reap # does not re-surface it). We DELETE both the registry json and its run dir - the durable PASS # record the gate enforces lives separately in .specrew/review/inline/ (written by the promotion above). param([Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$RegistryPath, [AllowNull()]$Registry) try { $runDir = $null if ($null -ne $Registry -and ($Registry.PSObject.Properties.Name -contains 'run_dir')) { $runDir = [string]$Registry.run_dir } if (Test-Path -LiteralPath $RegistryPath -PathType Leaf) { Remove-Item -LiteralPath $RegistryPath -Force -ErrorAction SilentlyContinue } if (-not [string]::IsNullOrWhiteSpace($runDir) -and (Test-Path -LiteralPath $runDir)) { Remove-Item -LiteralPath $runDir -Recurse -Force -ErrorAction SilentlyContinue } } catch { $null = $_ } } function Write-ContinuousCoReviewNavigatorBlackboard { # T083: route a REAL reviewer's COMPLETE FindingsResult (all severities) to the durable blackboard # thread under .specrew/review/inline/<run-id>/ (findings-result.json + review-thread.json), run_id # NORMALIZED to the registry run-id so the full findings co-locate with the gate record. The reap's # Clear-...Entry deletes only pending/<run-id>/ (a SEPARATE dir); inline/ survives -> NO reap-ordering # change. EXCLUDES the stub (no real findings). FAIL-OPEN: any error/miss returns $null and the caller # degrades to the one-line summary note; the navigator never throws to the dispatcher. (T083) param( [Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)]$Verdict, [datetime]$Now = [datetime]::UtcNow ) if (($Verdict.PSObject.Properties.Name -contains 'is_stub') -and $Verdict.is_stub) { return $null } if ($null -eq $Verdict.raw) { return $null } # The blackboard writer lives in review-blackboard-writer.ps1; the provider path dot-sources only the # navigator + launcher, so lazy-load _load (the same in-scope pattern as Add-...PassRunRecord). if (-not (Get-Command -Name 'Write-ContinuousCoReviewBlackboardThread' -ErrorAction SilentlyContinue)) { try { $loadPath = Join-Path $PSScriptRoot '_load.ps1'; if (Test-Path -LiteralPath $loadPath -PathType Leaf) { . $loadPath } } catch { $null = $_ } if (-not (Get-Command -Name 'Write-ContinuousCoReviewBlackboardThread' -ErrorAction SilentlyContinue)) { return $null } } try { $findings = $Verdict.raw # Option A (park-and-inform): an escalation IS, by definition, escalated to a human. Record that disposition # on the finding so the signoff gate treats it as not-unresolved (Get-...UnresolvedBlockingFindingInfo skips # disposition_state='escalated_to_human') instead of deadlocking on an 'open' blocking escalation. Scoped to # kind='escalation' so a REAL bug (any other kind) stays 'open' and keeps blocking — the latch never silences # a bug, only the human-decision escalation it is scoped to. Does NOT suppress the stop-block surfacing # (that is severity-based; the round-ceiling bounds it). (F-197 iter-009) if ($null -ne $findings -and ($findings.PSObject.Properties.Name -contains 'findings')) { foreach ($f in @($findings.findings)) { if ($null -eq $f) { continue } $kindProp = $f.PSObject.Properties['kind'] if ($null -eq $kindProp -or ([string]$kindProp.Value) -ne 'escalation') { continue } if ($f.PSObject.Properties.Name -contains 'disposition') { $f.disposition = 'escalated_to_human' } else { $f | Add-Member -NotePropertyName disposition -NotePropertyValue 'escalated_to_human' -Force } } } # NORMALIZE run_id to the registry run-id (co-locate with the gate record under inline/<run-id>/). if ($findings.PSObject.Properties.Name -contains 'run_id') { $findings.run_id = $RunId } else { $findings | Add-Member -NotePropertyName run_id -NotePropertyValue $RunId -Force } Write-ContinuousCoReviewBlackboardThread -RepoRoot $RepoRoot -CheckpointId ("nav-$RunId") -FindingsResult $findings -CreatedAt $Now | Out-Null return ".specrew/review/inline/$RunId/" } catch { return $null } } function Get-ContinuousCoReviewNavigatorFailureReason { # Read the detached reviewer's SAFE failure sidecar (review-failure.json, written by the reviewer # -Command on a non-findings-result) and format a one-line reason for the reap's advisory note, so a # checkpoint that produced no verdict SAYS WHY (input-too-large / timeout / schema-mismatch / nonzero-exit # / ...) instead of a bare "no parseable verdict". The sidecar carries only the contract-scrubbed # category + message (no stdout/stderr/prompt content). Missing/unreadable -> $null (the caller falls back # to the generic note). Read BEFORE Clear-...Entry retires the run dir. param([Parameter(Mandatory)][string]$RepoRoot, [AllowNull()]$Registry) try { $runDir = if ($null -ne $Registry -and ($Registry.PSObject.Properties.Name -contains 'run_dir')) { [string]$Registry.run_dir } else { $null } if ([string]::IsNullOrWhiteSpace($runDir)) { return $null } $sidecar = Join-Path $runDir 'review-failure.json' if (-not (Test-Path -LiteralPath $sidecar -PathType Leaf)) { return $null } $rec = Get-Content -LiteralPath $sidecar -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop $cat = if ($rec.PSObject.Properties.Name -contains 'category') { [string]$rec.category } else { '' } $msg = if ($rec.PSObject.Properties.Name -contains 'message') { [string]$rec.message } else { '' } if ([string]::IsNullOrWhiteSpace($cat) -and [string]::IsNullOrWhiteSpace($msg)) { return $null } if ([string]::IsNullOrWhiteSpace($msg)) { return $cat } if ([string]::IsNullOrWhiteSpace($cat)) { return $msg } return ("{0} - {1}" -f $cat, $msg) } catch { return $null } } function Invoke-ContinuousCoReviewNavigatorReap { # T079 REAP (runs at the top of every navigator Stop, AND - via -CrossSession - as the SessionStart # sweep). Walks every pending registry entry and classifies it: # - terminal status (done|timed-out|failed|reaped|crashed): surface a verdict if a result exists, # then retire the entry. A blocking done-verdict produces a STOP-BLOCK directive; a clean one a # brief inject note; a non-done terminal an inject note (the run did not produce a verdict). # - running + past-deadline + supervisor PRESENT: the supervisor overran its own kill loop (or is # wedged) -> Stop-SpecrewIsolatedTask (kill + worktree cleanup + mark reaped). # - running + supervisor DEFINITIVELY ABSENT + no terminal status: a DEAD launcher/supervisor # orphan -> Stop-SpecrewIsolatedTask marks it crashed + cleans the orphaned worktree (the # backstop the launcher's own finally-dispose cannot cover when the supervisor itself was # killed). # - running + supervisor present + within deadline: leave it (still working). # - running + supervisor presence UNKNOWN (a transient Get-Process error, NOT not-found) + within # deadline: leave it PENDING (dogfood finding 2: a transient probe failure must not prematurely # reap a genuinely-running review; the next reap re-checks). Past-deadline still reaps it # regardless (the deadline is an independent terminal signal). # A reaped NON-BLOCKING PASS (disposition pass / no blocking findings) is PROMOTED to durable gate # evidence (.specrew/review/inline/<run-id>/review-run.json) so an auto-fired checkpoint PASS becomes # fresh evidence the signoff freshness+coverage gate accepts; a blocking/failed verdict is NOT. # Returns @{ stop_block; inject_notes[]; reaped_run_ids[]; promoted_run_ids[] }. stop_block is the # FIRST blocking verdict's directive (one STOP-BLOCK per stop). CrossSession skips verdict surfacing # AND promotion (a prior session's verdict is stale for THIS turn) and only kills+cleans orphans. param( [Parameter(Mandatory)][string]$RepoRoot, [switch]$CrossSession, [string]$TrunkName = 'main', # T106/N4: the host transcript path (optional) - the escalation-latch reads REAL user turns # from it to decide human closure. Absent -> the latch default-denies (keeps state, no close). [AllowNull()][string]$TranscriptPath, [datetime]$Now = [datetime]::UtcNow ) $result = [pscustomobject]@{ stop_block = $null; inject_notes = (New-Object System.Collections.Generic.List[string]); reaped_run_ids = (New-Object System.Collections.Generic.List[string]); promoted_run_ids = (New-Object System.Collections.Generic.List[string]) } $terminalStatuses = @('done', 'timed-out', 'failed', 'reaped', 'crashed') foreach ($entry in (Get-ContinuousCoReviewNavigatorPendingEntries -RepoRoot $RepoRoot)) { $reg = $entry.registry $regPath = $entry.registry_path $status = if ($null -ne $reg -and ($reg.PSObject.Properties.Name -contains 'status')) { [string]$reg.status } else { '' } $runId = if ($null -ne $reg -and ($reg.PSObject.Properties.Name -contains 'run_id')) { [string]$reg.run_id } else { '' } $resultPath = if ($null -ne $reg -and ($reg.PSObject.Properties.Name -contains 'result_path')) { [string]$reg.result_path } else { $null } # Promote the reviewed-state DIGEST (the gate's identity, computed off the Stop budget by the orchestrator and # propagated to the registry), falling back to the HEAD-tree only for older records. Promoting the HEAD-tree # never matched the gate's working-tree digest -> every promoted pass read 'stale' (P-145 identity divergence). $treeId = if ($null -ne $reg -and ($reg.PSObject.Properties.Name -contains 'reviewed_digest_tree_id') -and -not [string]::IsNullOrWhiteSpace([string]$reg.reviewed_digest_tree_id)) { [string]$reg.reviewed_digest_tree_id } elseif ($null -ne $reg -and ($reg.PSObject.Properties.Name -contains 'tree_id')) { [string]$reg.tree_id } else { $null } $isTerminal = ($status -in $terminalStatuses) # TRI-STATE presence (finding 2): 'present' / 'absent' (definite) / 'unknown' (transient error). $presence = Get-ContinuousCoReviewNavigatorSupervisorPresence -Registry $reg $pastDeadline = Test-ContinuousCoReviewNavigatorPastDeadline -Registry $reg -Now $Now if ($isTerminal) { if (-not $CrossSession) { # Surface the verdict (done runs carry one at result_path; others did not finish cleanly). $verdict = ConvertFrom-ContinuousCoReviewNavigatorVerdict -ResultPath $resultPath # T092/R2 (FR-034): if this run reached its time budget (a PARTIAL review), offer the human-gated # "more time" choice. The findings are real (T090 harvest) but incomplete; a fresh run at a larger # budget completes it. Additive ("here are partial findings; extend to finish"), not "retry a fail". $moreTimeNote = '' $stObj = $null try { $stPath = Join-Path (Split-Path -Parent $resultPath) 'status.json' if (Test-Path -LiteralPath $stPath -PathType Leaf) { $stObj = Get-Content -LiteralPath $stPath -Raw -Encoding UTF8 | ConvertFrom-Json } } catch { $stObj = $null } try { if (($null -ne $stObj) -and ($stObj.PSObject.Properties.Name -contains 'completeness') -and ([string]$stObj.completeness -eq 'partial')) { $sugg = 1800; try { $cur = [int]$stObj.timeout_seconds; if ($cur -gt 0) { $sugg = [int]($cur * 2) } } catch { $null = $_ } $moreTimeNote = (" NOTE: this was a PARTIAL review - it reached its time budget, so the findings above may be incomplete. To complete it, give it MORE TIME: re-run with a larger budget, e.g. ``specrew review --live --timeout-seconds {0}``." -f $sugg) } } catch { $null = $_ } # T093/FR-035: a same-host fallback review is surfaced AS same-host with the one-time # upgrade ask - authorizing an independent host upgrades the NEXT run (this one never # blocked; the labelled fallback fired immediately, per iter-009 D1). $independenceNote = '' $regIndependence = if ($null -ne $reg -and ($reg.PSObject.Properties.Name -contains 'reviewer_independence')) { [string]$reg.reviewer_independence } else { '' } if ($regIndependence -eq 'same-host') { $independenceNote = ' NOTE: this was a SAME-HOST fallback review (reviewer = code-writer host; labelled, not silently substituted). Authorize an INDEPENDENT reviewer once: ``specrew review --host <other-host> --authorization-ref <ref>`` - the next run upgrades automatically.' } # T094/FR-036 (iter-009 D4): the 3-dimension evidence labels, derived from the terminal # status + registry and promoted onto the durable record - the tiered gate's assurance # input. Defaults are the CONSERVATIVE reading (unverified independence needs an ack). $runLabels = [pscustomobject]@{ completeness = 'full'; independence = 'unverified'; budget = 'normal' } try { if (($null -ne $stObj) -and ($stObj.PSObject.Properties.Name -contains 'completeness') -and -not [string]::IsNullOrWhiteSpace([string]$stObj.completeness)) { $runLabels.completeness = [string]$stObj.completeness } if (-not [string]::IsNullOrWhiteSpace($regIndependence)) { $runLabels.independence = $regIndependence } elseif (($null -ne $stObj) -and ($stObj.PSObject.Properties.Name -contains 'reviewer_independence') -and -not [string]::IsNullOrWhiteSpace([string]$stObj.reviewer_independence)) { $runLabels.independence = [string]$stObj.reviewer_independence } if (($null -ne $stObj) -and ($stObj.PSObject.Properties.Name -contains 'budget_bumped')) { try { if ([bool]$stObj.budget_bumped) { $runLabels.budget = 'time-extended' } } catch { $null = $_ } } } catch { $null = $_ } if ($status -eq 'done' -and $verdict.ok) { # T083: route the REAL reviewer's full findings (all severities) to the durable # blackboard (fail-open -> $null; the stub is excluded inside). T084: surface the thread. $threadRef = Write-ContinuousCoReviewNavigatorBlackboard -RepoRoot $RepoRoot -RunId $runId -Verdict $verdict -Now $Now $threadSuffix = if ($threadRef) { " Full findings (all severities): $threadRef" } else { '' } $latchPath = Join-Path $RepoRoot '.specrew/runtime/co-review-escalation-latch.json' if (-not $verdict.blocking) { # T106/N4: a CONVERGED (non-blocking) verdict on the lineage clears any open latch. try { if (Test-Path -LiteralPath $latchPath -PathType Leaf) { Remove-Item -LiteralPath $latchPath -Force -ErrorAction SilentlyContinue } } catch { $null = $_ } } if ($verdict.blocking) { # T106/N4 (D-197-I009-010): the escalation-latch. A verdict whose blocking findings # are ALL loop-state escalations (kind='escalation') stop-blocks ONCE, then latches # QUIET (a brief note each stop) until the human closes it (suppress + reset the # sticky round-state) or the lineage converges. ANY non-escalation blocking finding # (a real bug) never latches - it blocks every time. Default-deny on every failure. $latchHandled = $false try { $rawFindings = if ($null -ne $verdict.raw -and ($verdict.raw.PSObject.Properties.Name -contains 'findings') -and $null -ne $verdict.raw.findings) { @($verdict.raw.findings) } else { @() } $blockingF = @($rawFindings | Where-Object { $null -ne $_ -and ($_.PSObject.Properties.Name -contains 'severity') -and ([string]$_.severity -eq 'blocking') }) $allEscalation = ($blockingF.Count -gt 0) foreach ($bf in $blockingF) { if (-not (($bf.PSObject.Properties.Name -contains 'kind') -and ([string]$bf.kind -eq 'escalation'))) { $allEscalation = $false; break } } if ($allEscalation) { if (-not (Get-Command -Name 'Test-ContinuousCoReviewEscalationStopBlockClosed' -ErrorAction SilentlyContinue)) { $lp = Join-Path $PSScriptRoot 'escalation-latch.ps1' if (Test-Path -LiteralPath $lp -PathType Leaf) { try { . $lp } catch { $null = $_ } } } $latchKey = ((@($blockingF | ForEach-Object { if ($_.PSObject.Properties.Name -contains 'finding_id') { [string]$_.finding_id } }) | Sort-Object) -join '+') $latch = $null if (Test-Path -LiteralPath $latchPath -PathType Leaf) { try { $latch = Get-Content -LiteralPath $latchPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $latch = $null } } $latchMatches = ($null -ne $latch -and ($latch.PSObject.Properties.Name -contains 'key') -and ([string]$latch.key -eq $latchKey)) if ($latchMatches -and (Get-Command -Name 'Test-ContinuousCoReviewEscalationStopBlockClosed' -ErrorAction SilentlyContinue)) { $turns = @() if (-not [string]::IsNullOrWhiteSpace($TranscriptPath) -and (Get-Command -Name 'Get-ContinuousCoReviewTranscriptTurns' -ErrorAction SilentlyContinue)) { try { $turns = @(Get-ContinuousCoReviewTranscriptTurns -TranscriptPath $TranscriptPath) } catch { $turns = @() } } if (Test-ContinuousCoReviewEscalationStopBlockClosed -BlockingFindings $blockingF -SurfacedAtUtc ([string]$latch.surfaced_at) -ConversationTurns $turns) { # HUMAN-CLOSED: suppress the block, clear the latch, reset the sticky round-state. try { Remove-Item -LiteralPath $latchPath -Force -ErrorAction SilentlyContinue } catch { $null = $_ } try { $rsPath = Join-Path $RepoRoot '.specrew/runtime/co-review-round-state.json' if (Test-Path -LiteralPath $rsPath -PathType Leaf) { $rs = Get-Content -LiteralPath $rsPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($rs.PSObject.Properties.Name -contains 'blocking') { $rs.blocking = $false } if ($rs.PSObject.Properties.Name -contains 'round') { $rs.round = 0 } ($rs | ConvertTo-Json -Depth 8 -Compress) | Set-Content -LiteralPath $rsPath -Encoding UTF8 } } catch { $null = $_ } $result.inject_notes.Add(("[co-review] ceiling escalation CLOSED by your authorization (run {0}) - the sticky round-state was reset; co-review resumes fresh at the next changed checkpoint." -f $runId)) | Out-Null $latchHandled = $true } else { # LATCHED QUIET: surfaced before, not yet closed - a brief note, not another stop-block. $result.inject_notes.Add(("[co-review] ceiling escalation still OPEN (latched quiet; already surfaced). Reply to authorize/close it, or use the remediation menu for run {0}." -f $runId)) | Out-Null $latchHandled = $true } } elseif (-not $latchMatches) { # FIRST surface for this escalation: stop-block below + record the latch. try { $ldir = Split-Path -Parent $latchPath if ($ldir -and -not (Test-Path -LiteralPath $ldir)) { New-Item -ItemType Directory -Path $ldir -Force | Out-Null } ([pscustomobject]@{ key = $latchKey; surfaced_at = $Now.ToUniversalTime().ToString('o'); run_id = $runId } | ConvertTo-Json -Compress) | Set-Content -LiteralPath $latchPath -Encoding UTF8 } catch { $null = $_ } } } } catch { $null = $_ } if (-not $latchHandled) { if ($null -eq $result.stop_block) { $result.stop_block = (Build-ContinuousCoReviewNavigatorStopBlock -Verdict $verdict -RunId $runId -BlackboardRef $threadRef) } if (-not [string]::IsNullOrWhiteSpace($moreTimeNote)) { $result.inject_notes.Add(("[co-review] run {0}:{1}" -f $runId, $moreTimeNote)) | Out-Null } if (-not [string]::IsNullOrWhiteSpace($independenceNote)) { $result.inject_notes.Add(("[co-review] run {0}:{1}" -f $runId, $independenceNote)) | Out-Null } } } elseif (($verdict.PSObject.Properties.Name -contains 'is_stub') -and $verdict.is_stub) { # The default PLACEHOLDER stub always emits pass without reviewing. Surface it as # advisory feedback ONLY; it must NOT promote to durable gate evidence (that would # make the signoff gate auto-satisfiable by plumbing). The gate stays unsatisfied # until the real reviewer is wired (the post-closeout fast-follow). (closeout / flag 2) $result.inject_notes.Add(("[co-review] checkpoint navigator fired (run {0}) - plumbing OK, but the real reviewer is not wired yet, so this is NOT counted as gate evidence." -f $runId)) | Out-Null } elseif ((-not [string]::IsNullOrWhiteSpace([string]$verdict.disposition)) -and ([string]$verdict.disposition -match '(?i)^\s*(pass|approved|clean|no.?findings)\s*$')) { $result.inject_notes.Add(("[co-review] checkpoint review PASSED (run {0}): {1}.{2}{3}" -f $runId, $verdict.summary, $threadSuffix, $independenceNote)) | Out-Null # PROMOTE only on an AFFIRMATIVE pass disposition (pass/approved/clean/no-findings) - # NEVER on mere absence-of-blocking, else a 'needs-work'/'partial'/unparseable verdict # would launder to a gate 'pass'. The stub is excluded above; this makes the promotion # adversarially sound for the real reviewer too. (145 G-197-I005-01) $promotedId = Add-ContinuousCoReviewNavigatorPassRunRecord -RepoRoot $RepoRoot -RunId $runId -TreeId $treeId -TrunkName $TrunkName -EvidenceLabels $runLabels -Now $Now if (-not [string]::IsNullOrWhiteSpace($promotedId)) { $result.promoted_run_ids.Add($promotedId) | Out-Null } } else { # Non-blocking but NOT an affirmative pass (needs-work / partial / no parseable pass # disposition): advisory only, NEVER gate evidence. (145 G-197-I005-01) $result.inject_notes.Add(("[co-review] checkpoint review run {0} returned a non-blocking, non-pass verdict ('{1}') - advisory only, NOT counted as gate evidence.{2}{3}{4}" -f $runId, ([string]$verdict.disposition), $threadSuffix, $moreTimeNote, $independenceNote)) | Out-Null } } elseif ($status -eq 'done' -and -not $verdict.ok) { # STATE THE REASON: a done run with no parseable verdict used to be a bare "no verdict" # note (the EnglishIntake unparseable case - the human never learned it was an oversize # input). Surface the SAFE failure category/message from the sidecar so the checkpoint # says WHY; advisory only, never gate evidence. $failReason = Get-ContinuousCoReviewNavigatorFailureReason -RepoRoot $RepoRoot -Registry $reg $reasonSuffix = if (-not [string]::IsNullOrWhiteSpace($failReason)) { (" Reason: {0}." -f $failReason) } else { '' } $result.inject_notes.Add(("[co-review] checkpoint review run {0} completed but produced no parseable verdict (advisory only, NOT gate evidence).{1}{2}" -f $runId, $reasonSuffix, $moreTimeNote)) | Out-Null } else { # Co-review setup clarity (2026-07-01 / auto-select-no-authorization finding): a FAILED run whose # reason is no-authorized-reviewer-host means the checkpoint FIRED but no reviewer host was # enabled+authorized, so NO review ran. The recommended 'auto-select' preference does NOT # auto-authorize (authorization is a human cost/independence consent), so on a fresh project the # first checkpoint fires into what was a SILENT dead-end. Make it ACTIONABLE: tell the human # exactly how to authorize an independent reviewer ONCE, so the next checkpoint produces a review. # The failure_reason (e.g. no-authorized-reviewer-host, written to status.json by # worktree-review-orchestrator.ps1:259 and copied onto the pending registry by the detached # entry) lives on the registry entry $reg; fall back to the run's status.json via run_dir # (result_path can be null on a host-selection failure, so do NOT derive the path from it). # Do NOT use Get-ContinuousCoReviewNavigatorFailureReason here - it reads the review-failure.json # SIDECAR, which a host-selection failure never writes, so it returns null and this branch stays # INERT. (That inert first version was caught by the co-review run on this very fix - D-014.) $navFailReason = if ($null -ne $reg -and ($reg.PSObject.Properties.Name -contains 'failure_reason')) { [string]$reg.failure_reason } else { '' } if ([string]::IsNullOrWhiteSpace($navFailReason)) { try { $navRunDir = if ($null -ne $reg -and ($reg.PSObject.Properties.Name -contains 'run_dir')) { [string]$reg.run_dir } else { '' } if (-not [string]::IsNullOrWhiteSpace($navRunDir)) { $navStatusPath = Join-Path $navRunDir 'status.json' if (Test-Path -LiteralPath $navStatusPath -PathType Leaf) { $navStatusObj = Get-Content -LiteralPath $navStatusPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($navStatusObj.PSObject.Properties.Name -contains 'failure_reason') { $navFailReason = [string]$navStatusObj.failure_reason } } } } catch { $navFailReason = '' } } if ($navFailReason -match '(?i)no-authorized-reviewer-host') { $result.inject_notes.Add(("[co-review] checkpoint FIRED (run {0}) but NO reviewer host is authorized, so NO review ran. The 'auto-select' default does not auto-authorize - authorize an INDEPENDENT reviewer ONCE: ``specrew review --host <an-installed-harness-other-than-the-code-writer> --authorization-ref <ref>``. It then reviews automatically at the next changed checkpoint." -f $runId)) | Out-Null } else { $result.inject_notes.Add(("[co-review] checkpoint review run {0} ended '{1}' without a verdict (no blocking signal); a re-review fires on the next changed checkpoint." -f $runId, $status)) | Out-Null } } # T096/FR-038 (iter-009 D6/R6): ONE remediation menu on any review PROBLEM - the human's # choice rides co-review-round-state.json and shapes the next run. The no-authorized-host # failure keeps its own authorize note above (authorization IS its remediation). $menuFailReason = if ($null -ne $reg -and ($reg.PSObject.Properties.Name -contains 'failure_reason')) { [string]$reg.failure_reason } else { '' } $problem = $null if ($status -ne 'done') { if ($menuFailReason -notmatch '(?i)no-authorized-reviewer-host') { $problem = ("ended '{0}'" -f $status) } } elseif ($verdict.ok -and $verdict.blocking) { $problem = 'a BLOCKING finding' } elseif ($runLabels.completeness -eq 'partial') { $problem = 'PARTIAL completeness (time budget reached)' } elseif ($runLabels.independence -eq 'same-host') { $problem = 'a SAME-HOST (degraded) review' } if ($null -ne $problem) { $menu = ("[co-review] REMEDIATION MENU for run {0} ({1}): " -f $runId, $problem) + '1) more time -> `specrew review --remediate more-time --timeout-seconds <n>`; ' + '2) different host -> `specrew review --remediate different-host --host <name>`; ' + '3) narrow the scope -> `specrew review --remediate narrow-scope --scope <code|process|path:<p>|function:<name>>`; ' + ('4) accept partial -> `specrew review --remediate accept-partial --run-id {0} --ack-reason "<why>"`; ' -f $runId) + ('5) override the block (degraded only) -> `specrew review --remediate override-block --run-id {0} --ack-reason "<why>"`. ' -f $runId) + 'The choice is carried in the round-state and applied to the next run.' $result.inject_notes.Add($menu) | Out-Null } } # Retire the terminal entry (its worktree was already disposed by the supervisor's finally). Clear-ContinuousCoReviewNavigatorEntry -RepoRoot $RepoRoot -RegistryPath $regPath -Registry $reg $result.reaped_run_ids.Add($runId) | Out-Null continue } # Non-terminal (running). Decide whether it is an orphan to kill/clean. $shouldStop = $false $reason = 'reaped' if ($pastDeadline) { # Past its deadline: reap regardless of presence ('present' = wedged/overran its own kill # loop; 'unknown' = we cannot prove it alive AND it is overdue). The deadline is an # independent terminal signal, so a transient probe error does not save an overdue entry. $shouldStop = $true; $reason = 'reaped' } elseif ($presence -eq 'absent') { # Supervisor DEFINITIVELY gone with no terminal status: a DEAD-launcher orphan (worktree may # have leaked). Only a not-found result reaches here - a transient 'unknown' does NOT (it # falls through to "leave pending" below), so a genuinely-running review is never reaped on a # transient Get-Process failure (finding 2). $shouldStop = $true; $reason = 'crashed' } elseif ($CrossSession -and $presence -eq 'present') { # Cross-session sweep: a still-"running" entry from a PRIOR session whose supervisor is # somehow still alive is a cross-session leak -> kill + clean (a new session must not inherit # a prior session's live review). $shouldStop = $true; $reason = 'reaped' } if ($shouldStop) { if (Get-Command -Name 'Stop-SpecrewIsolatedTask' -ErrorAction SilentlyContinue) { try { $null = Stop-SpecrewIsolatedTask -RegistryPath $regPath -Reason $reason } catch { $null = $_ } } else { # Launcher not loaded (degraded): best-effort inline cleanup so an orphan still gets # reaped (kill supervisor + remove worktree + mark terminal). Invoke-ContinuousCoReviewNavigatorInlineReap -RegistryPath $regPath -Registry $reg -Reason $reason } Clear-ContinuousCoReviewNavigatorEntry -RepoRoot $RepoRoot -RegistryPath $regPath -Registry $reg $result.reaped_run_ids.Add($runId) | Out-Null # HUMAN-GATED STATUS - ONLY on a normal Stop reap, NEVER on the cross-session SessionStart sweep # (that cleans PRIOR-session orphans and must stay silent - it is cleanup, not current status). if ($reason -eq 'crashed' -and -not $CrossSession) { # A DEAD reviewer (supervisor gone, no terminal verdict) is INCONCLUSIVE. Say so rather than # reaping it silently, so the human reruns instead of assuming a pass - the gate never # advances on an inconclusive run (no passing evidence was collected). $result.inject_notes.Add(("[co-review] run {0} did not finish - the reviewer process is gone with no verdict, so this checkpoint is INCONCLUSIVE (not a pass). A fresh review fires on the next changed checkpoint; rerun if you need it now." -f $runId)) | Out-Null } } elseif (-not $CrossSession) { # HUMAN-GATED STATUS: a genuinely-running review is left PENDING (correct) - but SAY SO. It was # silent before, so 'continue' was a blind guess. Now each Stop/continue reports it is still in # flight; the verdict is surfaced the moment it finishes. The human drives the poll by continuing # (the host-neutral "wake" - every host has a human + a Stop, none has a portable auto-wake). $result.inject_notes.Add(("[co-review] run {0} is still reviewing in the background - say 'continue' to check again, or keep working; its verdict is surfaced here as soon as it finishes." -f $runId)) | Out-Null } } return $result } function Invoke-ContinuousCoReviewNavigatorInlineReap { # Degraded backstop for the orphan-kill path when Stop-SpecrewIsolatedTask (the launcher) could not # be loaded. Mirrors its three steps: kill the supervisor pid, remove the worktree, mark the # registry terminal. Idempotent + fail-open. param([Parameter(Mandatory)][string]$RegistryPath, [AllowNull()]$Registry, [string]$Reason = 'reaped') try { $supPid = if ($null -ne $Registry -and ($Registry.PSObject.Properties.Name -contains 'supervisor_pid')) { $Registry.supervisor_pid } else { $null } if ($supPid) { try { $null = Get-Process -Id ([int]$supPid) -ErrorAction Stop; Stop-Process -Id ([int]$supPid) -Force -ErrorAction SilentlyContinue } catch { $null = $_ } } # T100 parity: best-effort kill of the recorded CHILD tree (the dead-supervisor orphan class). # Unix: one group signal via the recorded pgid; anywhere: the direct child pid as fallback. # (On Windows the supervisor's closed job handle already reaped the tree - KILL_ON_JOB_CLOSE.) $childPgid = if ($null -ne $Registry -and ($Registry.PSObject.Properties.Name -contains 'child_pgid')) { $Registry.child_pgid } else { $null } $childPid = if ($null -ne $Registry -and ($Registry.PSObject.Properties.Name -contains 'child_pid')) { $Registry.child_pid } else { $null } if (-not $childPid -and -not $childPgid) { # T091: detached-ENTRY runs record the reviewer's pids via heartbeat telemetry in the run # dir's status.json (the registry carries no child_*). Probe it (same as the launcher reaper). try { $rd = if ($null -ne $Registry -and ($Registry.PSObject.Properties.Name -contains 'run_dir')) { [string]$Registry.run_dir } else { '' } if (-not [string]::IsNullOrWhiteSpace($rd)) { $stp = Join-Path $rd 'status.json' if (Test-Path -LiteralPath $stp -PathType Leaf) { $sto = Get-Content -LiteralPath $stp -Raw -Encoding UTF8 | ConvertFrom-Json $tel = if ($sto.PSObject.Properties.Name -contains 'reviewer_telemetry') { $sto.reviewer_telemetry } else { $null } if ($null -ne $tel) { if ($tel.PSObject.Properties.Name -contains 'child_pid') { $childPid = $tel.child_pid } if ($tel.PSObject.Properties.Name -contains 'child_pgid') { $childPgid = $tel.child_pgid } } } } } catch { $null = $_ } } if ($childPgid -and -not $IsWindows) { $killBin = Get-Command -Name 'kill' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($killBin) { try { & $killBin.Source -KILL -- ("-{0}" -f [int]$childPgid) 2>$null } catch { $null = $_ } } } if ($childPid) { try { Stop-Process -Id ([int]$childPid) -Force -ErrorAction SilentlyContinue } catch { $null = $_ } } $wt = if ($null -ne $Registry -and ($Registry.PSObject.Properties.Name -contains 'worktree_path')) { [string]$Registry.worktree_path } else { $null } # The worktree is a `git archive | tar` EXPORT into a plain temp dir (see # New-SpecrewIsolatedTaskWorktree: RO path = `git archive --output <tar>` + `tar -xf`, NOT # `git worktree add`), so there is NO `.git/worktrees/<id>` admin metadata to prune. # Remove-Item -Recurse -Force is therefore the COMPLETE + correct cleanup (no `git worktree # remove`/`prune` needed) - finding 4. if ($wt -and (Test-Path -LiteralPath $wt)) { Remove-Item -LiteralPath $wt -Recurse -Force -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $RegistryPath -PathType Leaf) { try { $reg = Get-Content -LiteralPath $RegistryPath -Raw -Encoding UTF8 | ConvertFrom-Json $reg | Add-Member -NotePropertyName 'status' -NotePropertyValue $Reason -Force $reg | Add-Member -NotePropertyName 'reaped_at' -NotePropertyValue ((Get-Date).ToUniversalTime().ToString('o')) -Force ($reg | ConvertTo-Json -Depth 8) | Set-Content -LiteralPath $RegistryPath -Encoding UTF8 } catch { $null = $_ } } } catch { $null = $_ } } function Test-ContinuousCoReviewVerdictIsPromotablePass { # The CANONICAL "is this an affirmative pass that may promote to durable gate evidence?" decision, used by BOTH # producers: the navigator reap (detached path) and the /specrew-review inline door (the host-neutral F3 # checkpoint, for a straight-through host whose Stop hook never fires the reap). Promote ONLY on an affirmative # disposition (pass/approved/clean/no-findings) - NEVER on mere absence-of-blocking (a needs-work / partial / # unparseable verdict must not launder to a gate pass), and NEVER on the placeholder stub. Mirrors the reap's # inline elseif-chain so the two producers agree by construction. param([AllowNull()]$Verdict) if ($null -eq $Verdict) { return $false } $ok = ($Verdict.PSObject.Properties['ok']) -and [bool]$Verdict.ok if (-not $ok) { return $false } if (($Verdict.PSObject.Properties['blocking']) -and [bool]$Verdict.blocking) { return $false } if (($Verdict.PSObject.Properties['is_stub']) -and [bool]$Verdict.is_stub) { return $false } $disp = if ($Verdict.PSObject.Properties['disposition']) { [string]$Verdict.disposition } else { '' } return ($disp -match '(?i)^\s*(pass|approved|clean|no.?findings)\s*$') } function Add-ContinuousCoReviewNavigatorPassRunRecord { # PART 2 (FR-024 gate wiring): promote a reaped NON-BLOCKING PASS to a DURABLE passing-run record # the signoff gate (Get-ContinuousCoReviewSignoffGateDecision) accepts. The gate checks THREE # things, not just freshness, so a record carrying only the tree-id would still be REJECTED: # 1. FRESHNESS - a passing run's reviewed_tree_id == the current reviewed-state digest. We record # the tree-id the navigator actually FIRED on (the registry's tree_id). # 2. LINEAGE - reviewed_ref must be a real commit that is an ancestor-of-or-equal-to HEAD. We # record HEAD-at-reap (equal-to-itself satisfies the ancestor test). # 3. COVERAGE - the chain's baseline_ref must be ancestor-of-or-equal-to the merge-base anchor. # We record baseline_ref = the merge-base-with-trunk anchor itself, so the # single-link chain reaches the anchor immediately (no gap). # status MUST be 'pass' (the writer maps GateVerdict.state -> status; the gate only accepts # pass|escalated). Writes via the EXISTING writer Write-ContinuousCoReviewRunIndex, which lands the # record at .specrew/review/inline/<run-id>/review-run.json - the path the gate reader actually # walks (NOT .specrew/review/runs/, which the design comments name but no shipped gate code reads; # see the navigator-hardening report). Fail-open: ANY failure (missing dep, no anchor, unresolvable # HEAD, writer throw) returns $null and the reap proceeds without promotion (a blocking gate at # signoff is the safe outcome of a missing record, never a false pass). # # LAZY DEP-LOAD, INLINE BY DESIGN (do NOT extract to a helper): on the PRODUCTION path the provider # dot-sources only THIS navigator (which loads the launcher); the run-index writer + its deps live in # _load.ps1, NOT loaded there. We load _load.ps1 HERE, in THIS function's scope, so the dot-sourced # Write-ContinuousCoReviewRunIndex (+ its transitive deps) resolve via the call-stack walk from the # Write-... call BELOW. A separate "Initialize-deps" function CANNOT work: PowerShell dot-sources into # the CALLEE's scope, which dies on return, so the writer would vanish before this function used it # (verified). Loading here is paid only on an actual PASS promotion (rare), never on the hot Stop reap. param( [Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$RunId, [AllowNull()][string]$TreeId, [string]$TrunkName = 'main', # T094/FR-036: the run's 3-dimension evidence labels (completeness/independence/budget), # recorded onto the durable review-run.json so the tiered signoff gate can read assurance. [AllowNull()]$EvidenceLabels, [datetime]$Now = [datetime]::UtcNow ) try { if ([string]::IsNullOrWhiteSpace($RunId) -or [string]::IsNullOrWhiteSpace($TreeId)) { return $null } # Lazily bring in the writer + its deps INTO THIS SCOPE (see the header note). Idempotent: if # _load already ran (e.g. the in-process test path), Get-Command short-circuits the dot-source. if (-not (Get-Command -Name 'Write-ContinuousCoReviewRunIndex' -ErrorAction SilentlyContinue)) { try { $loadPath = Join-Path $PSScriptRoot '_load.ps1' if (Test-Path -LiteralPath $loadPath -PathType Leaf) { . $loadPath } } catch { $null = $_ } } if (-not (Get-Command -Name 'Write-ContinuousCoReviewRunIndex' -ErrorAction SilentlyContinue)) { return $null } # Idempotence: if a durable record for this run already exists, do not re-promote (the writer # would throw on a content mismatch; a second reap of the same run is a no-op). $existing = Join-Path $RepoRoot (".specrew/review/inline/$RunId/review-run.json") if (Test-Path -LiteralPath $existing -PathType Leaf) { return $RunId } # COVERAGE anchor = merge-base with trunk. No anchor -> cannot prove coverage -> skip promotion # (the gate would block at signoff; a missing record is the safe outcome). if (-not (Get-Command -Name 'Get-ContinuousCoReviewMergeBaseAnchor' -ErrorAction SilentlyContinue)) { return $null } $anchor = Get-ContinuousCoReviewMergeBaseAnchor -RepoRoot $RepoRoot -TrunkName $TrunkName if ([string]::IsNullOrWhiteSpace([string]$anchor)) { return $null } # LINEAGE ref = current HEAD (via the encoding-immune git helper - raw `& git` throws the # StandardOutputEncoding error in the hook provider context). $reviewedRef = $null if (Get-Command -Name 'Invoke-ContinuousCoReviewGit' -ErrorAction SilentlyContinue) { $headResult = Invoke-ContinuousCoReviewGit -RepoRoot $RepoRoot -Arguments @('rev-parse', 'HEAD') if ($headResult.ExitCode -eq 0 -and @($headResult.Output).Count -gt 0) { $headCandidate = ([string]$headResult.Output[0]).Trim() if ($headCandidate -match '^[0-9a-f]{40}$') { $reviewedRef = $headCandidate } } } if ([string]::IsNullOrWhiteSpace($reviewedRef)) { return $null } # A pass GateVerdict so the writer records status='pass' (the gate's accepted set). $checkpointId = "nav-$RunId" if (-not (Get-Command -Name 'New-ContinuousCoReviewGateVerdict' -ErrorAction SilentlyContinue)) { return $null } $verdict = New-ContinuousCoReviewGateVerdict -RunId $RunId -CheckpointId $checkpointId -State 'pass' -RoundCount 1 -CreatedAt $Now $null = Write-ContinuousCoReviewRunIndex -RepoRoot $RepoRoot -RunId $RunId -CheckpointId $checkpointId ` -BaselineRef ([string]$anchor) -ReviewedRef $reviewedRef -ReviewedTreeId $TreeId ` -GateVerdict $verdict -EvidenceLabels $EvidenceLabels -CreatedAt $Now return $RunId } catch { $null = $_; return $null } } function Build-ContinuousCoReviewNavigatorStopBlock { # The directive body a blocking co-review verdict force-continues the turn with (the dispatcher # wraps it in the host's stop-block envelope). Names the finding so the human/agent acts on it. param([Parameter(Mandatory)]$Verdict, [AllowNull()][string]$RunId, [AllowNull()][string]$BlackboardRef) # Collect the blocking findings ONCE and render them cleanly. The old format dumped the full finding text # TWICE (the summary line + the BLOCKING line) and stringified location as "@{path=...}" - an unreadable wall # for the human (maintainer feedback 2026-06-28). Render: one header, a run+count line, then each finding as # a bullet with a clean [path:line] location and its comment, indented, exactly once. $blocking = New-Object System.Collections.Generic.List[object] if ($null -ne $Verdict.raw -and ($Verdict.raw.PSObject.Properties.Name -contains 'findings') -and $null -ne $Verdict.raw.findings) { foreach ($f in @($Verdict.raw.findings)) { $sev = if ($null -ne $f -and ($f.PSObject.Properties.Name -contains 'severity')) { [string]$f.severity } else { '' } $disp = if ($null -ne $f -and ($f.PSObject.Properties.Name -contains 'disposition')) { [string]$f.disposition } else { '' } if ($sev -match '(?i)^(blocking|block|critical|high)$' -or $disp -match '(?i)^block') { [void]$blocking.Add($f) } } } $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine('Specrew co-review — BLOCKING. The fresh-context review of your latest increment found an issue to address before you continue. Fix it, then re-stop so co-review can re-check.') [void]$sb.AppendLine('') [void]$sb.AppendLine(("Run {0} - {1} blocking finding(s):" -f $RunId, $blocking.Count)) foreach ($f in $blocking) { $loc = $null if (($f.PSObject.Properties.Name -contains 'location') -and $null -ne $f.location) { $p = if ($f.location.PSObject.Properties.Name -contains 'path') { [string]$f.location.path } else { '' } $ls = if ($f.location.PSObject.Properties.Name -contains 'line_start') { $f.location.line_start } else { $null } if (-not [string]::IsNullOrWhiteSpace($p)) { $loc = if ($null -ne $ls) { ("{0}:{1}" -f $p, $ls) } else { $p } } } $cmt = if ($f.PSObject.Properties.Name -contains 'comment') { [string]$f.comment } else { '' } [void]$sb.AppendLine('') [void]$sb.AppendLine((" - {0}" -f ($(if ($loc) { "[$loc]" } else { '(no location)' })))) [void]$sb.AppendLine((" {0}" -f $cmt)) } [void]$sb.AppendLine('') if (-not [string]::IsNullOrWhiteSpace($BlackboardRef)) { [void]$sb.AppendLine(("Full findings (all severities): {0}" -f $BlackboardRef)) } [void]$sb.AppendLine('(Co-review navigator block, not a boundary verdict - do NOT emit a SPECREW-VERDICT-BOUNDARY marker. Reviewed in an isolated read-only worktree; your tree is unchanged.)') return $sb.ToString().TrimEnd() } function Get-ContinuousCoReviewNavigatorImplementStage { # Map the active boundary cursor (start-context.json session_state.boundary_type) to the gate-review # registry stage. Implementation work happens AFTER the before-implement verdict is authorized and # BEFORE review-signoff, so the cursor reads 'before-implement' during active implementation. The # registry routes the stage 'implement'. So: a cursor that normalizes to 'before-implement' IS the # implementation window -> return 'implement' (the registered stage). Anything else -> $null (the # navigator only auto-fires the implement-stage code reviewer; other stages are unregistered no-ops). param([Parameter(Mandatory)][string]$RepoRoot) $scPath = Join-Path $RepoRoot '.specrew/start-context.json' if (-not (Test-Path -LiteralPath $scPath -PathType Leaf)) { return $null } try { $sc = Get-Content -LiteralPath $scPath -Raw -Encoding UTF8 | ConvertFrom-Json $boundary = $null if ($sc.PSObject.Properties['session_state'] -and $null -ne $sc.session_state -and $sc.session_state.PSObject.Properties['boundary_type']) { $boundary = [string]$sc.session_state.boundary_type } # start-context schema v2 has NO session_state cursor; the boundary lives under boundary_enforcement. # last_authorized_boundary normalizes to 'before-implement' during active implementation - exactly the # value the old session_state.boundary_type cursor carried at that point (see the comment above and the # firing window: the navigator only fires DURING implement) - so the implement-stage check below is # behavior-preserving; this only reads the migrated field. The iter-007 real-host dogfood proved the # navigator was reading the dead old field on a v2 project and silently no-opping every checkpoint. if ([string]::IsNullOrWhiteSpace($boundary) -and $sc.PSObject.Properties['boundary_enforcement'] -and $null -ne $sc.boundary_enforcement -and $sc.boundary_enforcement.PSObject.Properties['last_authorized_boundary']) { $boundary = [string]$sc.boundary_enforcement.last_authorized_boundary } if ([string]::IsNullOrWhiteSpace($boundary)) { return $null } if (Get-Command -Name 'Normalize-SpecrewCanonicalBoundaryType' -ErrorAction SilentlyContinue) { $norm = Normalize-SpecrewCanonicalBoundaryType -Boundary $boundary } else { $norm = $boundary.Trim().ToLowerInvariant() } if ($norm -eq 'before-implement' -or $norm -eq 'implement') { return 'implement' } return $null } catch { return $null } } |