scripts/internal/continuous-co-review/worktree-navigator.ps1
|
$ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest # iter-008 — the worktree-engine navigator: a FAST Stop-trigger. It REUSES the legacy navigator's reap + # stage-gate + dedup-state (the survive-half) and the host-neutral co-review SERVICE for the fire # (Start-ContinuousCoReviewServiceRun -Detached). It does NO heavy work on the Stop budget — identity is the fast # HEAD-subtree tree-id and the materialize + review run in the detached orchestrator. The legacy navigator is # UNTOUCHED; the provider selects this engine by config (co_review_engine=worktree). The legacy path is deleted at # cutover (the tracked must-happen final step), so the two paths do not ossify. . (Join-Path $PSScriptRoot 'co-review-service.ps1') # brings the legacy navigator (reap/stage/dedup) + the service (fire/identity) function Test-ReviewCampaignBoundaryRequiresIteration { # Feature-level intake legitimately has an active lifecycle cursor before any iteration exists. # Only a cursor at plan or later proves that a missing iteration is suspicious. Unknown or # malformed non-empty cursor values fail closed by returning true. param( [AllowNull()] $BoundaryCursor ) if ($null -eq $BoundaryCursor) { return $false } $candidates = [System.Collections.Generic.List[string]]::new() if ($BoundaryCursor -is [string]) { if ([string]::IsNullOrWhiteSpace([string]$BoundaryCursor)) { return $false } $candidates.Add([string]$BoundaryCursor) | Out-Null } else { # pending_crossing is an object. Its destination/working boundary determines whether # iteration state is expected; from_boundary may still be the pre-feature 'intake'. foreach ($propertyName in @('working_boundary', 'to_boundary')) { if (($BoundaryCursor.PSObject.Properties.Name -contains $propertyName) -and -not [string]::IsNullOrWhiteSpace([string]$BoundaryCursor.$propertyName)) { $candidates.Add([string]$BoundaryCursor.$propertyName) | Out-Null } } if ($candidates.Count -eq 0) { return $true } } foreach ($candidate in $candidates) { $normalized = $candidate.Trim().ToLowerInvariant() if (Get-Command -Name 'Normalize-SpecrewCanonicalBoundaryType' -ErrorAction SilentlyContinue) { try { $normalized = Normalize-SpecrewCanonicalBoundaryType -Boundary $candidate } catch { return $true } } if ($normalized -notin @('intake', 'specify', 'clarify')) { return $true } } return $false } function Get-ReviewCampaignNavigatorScopeApplicability { # Campaign authority is installed before a greenfield project has an active feature or iteration. Those # intake states are expected no-ops, not authority failures. Once any active-feature signal exists, malformed # or missing state remains applicable so the packet gate below fails closed with the authoritative reason. param([Parameter(Mandatory)][string]$RepoRoot) $featureRoot = $null $activeFeatureSignal = $false $activeIterationSignal = $false $sessionBoundaryCursor = $null $crossingWorkingBoundary = $null $authorizedBoundaryCursor = $null $featureJsonPath = Join-Path $RepoRoot '.specify/feature.json' if (Test-Path -LiteralPath $featureJsonPath -PathType Leaf) { $activeFeatureSignal = $true try { $featureJson = Get-Content -LiteralPath $featureJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json if (($featureJson.PSObject.Properties.Name -contains 'feature_directory') -and -not [string]::IsNullOrWhiteSpace([string]$featureJson.feature_directory)) { $candidate = Join-Path $RepoRoot ([string]$featureJson.feature_directory) if (Test-Path -LiteralPath $candidate -PathType Container) { $featureRoot = $candidate } } } catch { return [pscustomobject]@{ applicable = $true; reason = 'active-feature-state-invalid' } } } $startContextPath = Join-Path $RepoRoot '.specrew/start-context.json' if (Test-Path -LiteralPath $startContextPath -PathType Leaf) { try { $startContext = Get-Content -LiteralPath $startContextPath -Raw -Encoding UTF8 | ConvertFrom-Json $featurePath = $null if ($startContext.PSObject.Properties['session_state'] -and $null -ne $startContext.session_state) { if ($startContext.session_state.PSObject.Properties['feature_path']) { $featurePath = [string]$startContext.session_state.feature_path } if ($startContext.session_state.PSObject.Properties['iteration_number'] -and -not [string]::IsNullOrWhiteSpace([string]$startContext.session_state.iteration_number)) { $activeFeatureSignal = $true $activeIterationSignal = $true } if ($startContext.session_state.PSObject.Properties['boundary_type'] -and -not [string]::IsNullOrWhiteSpace([string]$startContext.session_state.boundary_type)) { $activeFeatureSignal = $true $sessionBoundaryCursor = [string]$startContext.session_state.boundary_type if (Test-ReviewCampaignBoundaryRequiresIteration -BoundaryCursor $startContext.session_state.boundary_type) { $activeIterationSignal = $true } } } elseif ($startContext.PSObject.Properties['feature_path']) { $featurePath = [string]$startContext.feature_path } if ($startContext.PSObject.Properties['boundary_enforcement'] -and $null -ne $startContext.boundary_enforcement) { foreach ($cursorName in @('last_authorized_boundary', 'pending_next_boundary', 'pending_crossing')) { if ($startContext.boundary_enforcement.PSObject.Properties[$cursorName] -and $null -ne $startContext.boundary_enforcement.$cursorName -and -not [string]::IsNullOrWhiteSpace([string]$startContext.boundary_enforcement.$cursorName)) { $activeFeatureSignal = $true if ($cursorName -eq 'last_authorized_boundary' -and [string]::IsNullOrWhiteSpace($authorizedBoundaryCursor)) { $authorizedBoundaryCursor = [string]$startContext.boundary_enforcement.$cursorName } if ($cursorName -eq 'pending_crossing' -and $startContext.boundary_enforcement.pending_crossing -is [object] -and $startContext.boundary_enforcement.pending_crossing.PSObject.Properties['working_boundary'] -and -not [string]::IsNullOrWhiteSpace([string]$startContext.boundary_enforcement.pending_crossing.working_boundary)) { $crossingWorkingBoundary = [string]$startContext.boundary_enforcement.pending_crossing.working_boundary } if (Test-ReviewCampaignBoundaryRequiresIteration -BoundaryCursor $startContext.boundary_enforcement.$cursorName) { $activeIterationSignal = $true } } } } if (-not [string]::IsNullOrWhiteSpace($featurePath)) { $activeFeatureSignal = $true $candidate = if ([IO.Path]::IsPathRooted($featurePath)) { $featurePath } else { Join-Path $RepoRoot $featurePath } if (Test-Path -LiteralPath $candidate -PathType Container) { $featureRoot = $candidate } } } catch { return [pscustomobject]@{ applicable = $true; reason = 'active-session-state-invalid' } } } if ($null -eq $featureRoot) { $branch = @(& git -C $RepoRoot branch --show-current 2>$null) if ($LASTEXITCODE -eq 0 -and $branch.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$branch[0])) { $candidate = Join-Path $RepoRoot ('specs/' + [string]$branch[0]) if (Test-Path -LiteralPath $candidate -PathType Container) { $featureRoot = $candidate $activeFeatureSignal = $true } } } if ($null -eq $featureRoot) { if ($activeFeatureSignal) { return [pscustomobject]@{ applicable = $true; reason = 'active-feature-unresolved' } } return [pscustomobject]@{ applicable = $false; reason = 'campaign-not-applicable:no-active-feature' } } $iterationsRoot = Join-Path $featureRoot 'iterations' $iterations = @(if (Test-Path -LiteralPath $iterationsRoot -PathType Container) { Get-ChildItem -LiteralPath $iterationsRoot -Directory | Where-Object { $_.Name -match '^\d{3,}$' } }) if ($iterations.Count -eq 0) { if ($activeIterationSignal) { return [pscustomobject]@{ applicable = $true; reason = 'active-iteration-unresolved' } } return [pscustomobject]@{ applicable = $false; reason = 'campaign-not-applicable:no-active-iteration' } } # DRIFT pre-tag slice #2 (testbeta3, journal 2026-08-08T01:13:28Z; dry-run correction # 2026-08-08): applicability turned ON at iteration-directory existence while the auto-fire # path stays implement-only, so a consumer at design-analysis received a standing # review-required block nothing could ever satisfy — the i009 quiet-no-op family's missing # edge. The first fix quieted only plan/tasks — the PATTERN, not the INSTANCE: testbeta3's # actual cursor at the flip was 'clarify', because before-plan scaffolds the iteration BEFORE # any plan sync advances the cursor. Stated as the INVERSION so no future cursor rejoins the # gap: with an iteration present, the campaign surface is LIVE from 'before-implement' onward # (plus the legacy 'implement' alias) — there is implementation to review and the block is # satisfiable by a human CLI review; every EARLIER resolved canonical cursor is quiet # not-applicable (nothing reviewable exists yet; auto-fire stays implement-only; pre-code # reviews remain human-CLI-initiated). The WORKING position decides, in precedence order: # session_state.boundary_type, then pending_crossing.working_boundary, then # last_authorized_boundary — a pending crossing INTO before-implement is the implement # window's edge (the v2 missing-iteration fail-closed case pins this). Unresolved or # non-canonical cursors stay applicable so the packet gate fails closed with the # authoritative reason (this function's standing philosophy). $effectiveCursor = $sessionBoundaryCursor if ([string]::IsNullOrWhiteSpace($effectiveCursor)) { $effectiveCursor = $crossingWorkingBoundary } if ([string]::IsNullOrWhiteSpace($effectiveCursor)) { $effectiveCursor = $authorizedBoundaryCursor } if (-not [string]::IsNullOrWhiteSpace($effectiveCursor)) { $cursorNorm = $effectiveCursor.Trim().ToLowerInvariant() if (Get-Command -Name 'Normalize-SpecrewCanonicalBoundaryType' -ErrorAction SilentlyContinue) { try { $cursorNorm = Normalize-SpecrewCanonicalBoundaryType -Boundary $effectiveCursor } catch { $cursorNorm = $null } } if (-not [string]::IsNullOrWhiteSpace($cursorNorm) -and $cursorNorm -ne 'implement') { $boundaryOrder = @('specify', 'clarify', 'plan', 'tasks', 'before-implement', 'review-signoff', 'retro', 'iteration-closeout', 'feature-closeout') if (Get-Command -Name 'Get-SpecrewBoundaryOrder' -ErrorAction SilentlyContinue) { try { $boundaryOrder = @(Get-SpecrewBoundaryOrder) } catch { $null = $_ } } $cursorIdx = [Array]::IndexOf($boundaryOrder, $cursorNorm) $liveIdx = [Array]::IndexOf($boundaryOrder, 'before-implement') if ($cursorIdx -ge 0 -and $liveIdx -ge 0 -and $cursorIdx -lt $liveIdx) { return [pscustomobject]@{ applicable = $false; reason = ('campaign-not-applicable:pre-implement-stage ({0})' -f $cursorNorm) } } } } return [pscustomobject]@{ applicable = $true; reason = 'campaign-applicable' } } function Invoke-ContinuousCoReviewWorktreeNavigator { # Param shape MATCHES the legacy Invoke-ContinuousCoReviewNavigator so the provider config-selects between # the two by name with the SAME @navParams. -SessionStart = the cross-session sweep. -CodeWriterHost threads # through the service to the orchestrator's reviewer-host SELECTION (independent + authorized). param( [Parameter(Mandatory)][string]$RepoRoot, [AllowEmptyString()][string]$TrunkName = '', [switch]$SessionStart, [string]$CodeWriterHost, # T106/N4: the host transcript path (optional) - threads to the reap so the escalation-latch # can read REAL user turns to detect human closure. [string]$TranscriptPath, [int]$TimeoutSeconds = 900 ) $resolved = (Resolve-Path -LiteralPath $RepoRoot).Path $decision = [pscustomobject]@{ action = 'no-op'; reason = ''; engine = 'worktree'; fired_run_id = $null; fired_tree_id = $null; stop_block = $null; inject_notes = @() } $authority = Get-ContinuousCoReviewAuthorityDecision if (-not $authority.valid -or [string]$authority.mode -eq 'disabled') { $decision.reason = ('review-authority-disabled:' + [string]$authority.reason) return $decision } if ([bool]$authority.campaign_authority_enabled) { if ($SessionStart) { $decision.reason = 'campaign-cross-session-no-legacy-reap'; return $decision } $scope = Get-ReviewCampaignNavigatorScopeApplicability -RepoRoot $resolved if (-not [bool]$scope.applicable) { $decision.reason = [string]$scope.reason return $decision } try { $packet = Get-ReviewCampaignVerdictPacketDecision -RepoRoot $resolved } catch { $decision.reason = 'campaign-packet-gate-failed' $decision.stop_block = "Campaign review authority could not be read safely: $($_.Exception.Message)`n(Campaign review block, not a lifecycle verdict - do NOT emit a SPECREW-VERDICT-BOUNDARY marker.)" return $decision } $decision.reason = [string]$packet.reason if ([bool]$packet.render_boundary_packet) { $decision.inject_notes = @(("[co-review] campaign run {0} authorizes the exact current digest; the lifecycle boundary packet may now be rendered." -f $packet.run_id)) } elseif ([string]$packet.route -eq 'review-running') { $decision.inject_notes = @(("[co-review] campaign run {0} is still reviewing the current digest; no decision is required." -f $packet.run_id)) } else { $decision.stop_block = Build-ReviewCampaignNavigatorStopBlock -PacketDecision $packet } return $decision } # REAP (reuse) — surfaces any completed verdict (incl. the worktree engine's result.out) + cleans orphans. $reap = Invoke-ContinuousCoReviewNavigatorReap -RepoRoot $resolved -TrunkName $TrunkName -CrossSession:$SessionStart -TranscriptPath $TranscriptPath $decision.stop_block = $reap.stop_block $decision.inject_notes = @($reap.inject_notes) if ($SessionStart) { $decision.reason = 'cross-session-sweep'; return $decision } # IMPLEMENT-stage gate (reuse). $stage = Get-ContinuousCoReviewNavigatorImplementStage -RepoRoot $resolved if ($stage -ne 'implement') { $decision.reason = "not-implement-stage ($stage)"; return $decision } # Identity + dedup: the CERTIFIED digest identity (working tree), so a dirty increment CHANGES the # key and fires a new review - HEAD-tree keying deduped uncommitted edits as already-reviewed # (codex finding, run 20260708T225439577; the D-197-I010-004 follow-on). Digest failure falls back # to the HEAD subtree inside the helper (the navigator never breaks on a digest error). $treeId = Get-ContinuousCoReviewCheckpointIdentity -RepoRoot $resolved if ([string]::IsNullOrWhiteSpace($treeId)) { $decision.reason = 'identity-unresolved'; return $decision } $decision.fired_tree_id = $treeId if ($treeId -eq (Get-ContinuousCoReviewNavigatorLastFiredTreeId -RepoRoot $resolved)) { $decision.reason = 'deduped (already reviewed this tree)'; return $decision } # FIRE via the host-neutral service (detached; all heavy work off the Stop budget). T019 piece 3: the service # acquires the per-lineage LEASE atomically BEFORE spawning any reviewer (piece 2), so a concurrent DUPLICATE # (e.g. a manual --live already reviewing this lineage - the DRIFT-198-I003-002 collision class that # last_fired_tree_id misses because other drivers never set it) or a NEWER tree queued behind a live owner is # SUPPRESSED there rather than spawning a second reviewer. The LEASE is the SINGLE in-flight dedup source: we # consume its suppression here rather than add a second competing mechanism. last_fired_tree_id stays purely the # CHANGED-tree trigger above (don't re-review an unchanged, already-reviewed tree) and is advanced ONLY on a run # that actually fired - a suppressed acquire must NOT advance it (else the queued newer tree would be treated as # already-fired and never reviewed). try { $run = Start-ContinuousCoReviewServiceRun -RepoRoot $resolved -TreeId $treeId -CodeWriterHost $CodeWriterHost -TimeoutSeconds $TimeoutSeconds -Detached if (($run.PSObject.Properties['status']) -and ([string]$run.status -eq 'suppressed')) { $supReason = if ($run.PSObject.Properties['suppressed_reason']) { [string]$run.suppressed_reason } else { 'lease-not-acquired' } $decision.reason = ('deduped-by-lease ({0})' -f $supReason) # NOT fired: no spawn, no spend/round, last_fired_tree_id UNCHANGED } else { Set-ContinuousCoReviewNavigatorLastFiredTreeId -RepoRoot $resolved -TreeId $treeId -RunId $run.run_id $decision.action = 'fired'; $decision.reason = 'registered-checkpoint'; $decision.fired_run_id = $run.run_id } } catch { $decision.reason = ('fire-failed: ' + $_.Exception.Message) } return $decision } |