extensions/specrew-speckit/scripts/specrew-co-review-navigator-provider.ps1
|
# T078 / T079 (FR-026 / FR-030 / FR-031): the co-review NAVIGATOR provider - the dispatcher-facing # THIN loader for the always-on async co-review navigator. # # This is a CONSUMER of the EXISTING F-185 hook dispatcher + provider catalog (refocus-scopes.json), # registered as kind=inject events=[Stop,agentStop,stop,SessionStart] order=50 - it runs AFTER the # conformance provider (order 40). # # KNOWN STOP-BLOCK COLLISION (flagged for Planner, NOT fixable in-glob): the dispatcher's # $stopBlockReason is LAST-WRITER-WINS with no merge/priority logic. Because the navigator runs LAST # (order 50 > conformance's 40), a navigator stop-block OVERWRITES a conformance stop-block emitted on # the SAME Stop. Each provider is a separate child process and cannot see another's stdout, so the # navigator CANNOT coordinate ("emit only if conformance did not") from here. In the implement window a # blocking co-review verdict can co-occur with a conformance MATERIAL-work block (changed files, no # packet); today the navigator's block would be the only one delivered, dropping the conformance packet # directive that turn. Containing this needs the DISPATCHER to merge/prioritize competing stop-block # reasons (an out-of-glob / F-184 change) - routed to Planner as a design question. Mitigation today: # the navigator blocks ONLY on its own distinct blocking-co-review verdict (a narrow, infrequent event), # and a dropped conformance block re-fires on the next Stop (the gate STATE remains the authority). # # The provider itself does almost NOTHING - it locates + dot-sources the in-glob navigator LOGIC # (scripts/internal/continuous-co-review/continuous-co-review-navigator.ps1, which dot-sources the # launcher + _load.ps1) via the module-base candidate ladder, then calls Invoke-ContinuousCoReviewWorktreeNavigator # and translates the returned decision into the dispatcher's stdout contract: # - decision.stop_block set -> print '<<<SPECREW-STOP-BLOCK>>>' + the directive (the dispatcher # force-continues the turn via the host's stop-block envelope). # - decision.inject_notes present -> print them (a plain inject the dispatcher merges as a fragment). # - no-op -> print NOTHING (a no-op stop must not perturb the merged result). # It is FAST + non-blocking by construction (the navigator reaps cheaply and FIRES a DETACHED launcher # that the provider never waits for) and FULLY FAIL-OPEN: any error/uncertainty -> no output, exit 0. # # ARG CONTRACT (the dispatcher's double-dash convention, like conformance): the dispatcher invokes # inject providers with --host-kind / --source-event / --transcript-path via ProcessStartInfo.ArgumentList. # A PowerShell param()/[CmdletBinding()] block REJECTS a '--flag' token (binds as '-flag' and exits 1 at # the binding boundary BEFORE the body runs), so parse $args MANUALLY. NO param(). # # F-184 footprint: NONE. Non-protected script in the deployed extension scripts tree (the registration # side; the navigator logic is non-protected too). PowerShell 7.x. exit 0 ALWAYS (fail-open). $ErrorActionPreference = 'Stop' try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { $null = $_ } function Resolve-CoReviewNavigatorLogicPath { # Locate scripts/internal/continuous-co-review/continuous-co-review-navigator.ps1. Clone the # Resolve-SpecrewBootstrapDir candidate ladder: project tree (the deployed extension's own root, or # the self-host repo) -> $env:SPECREW_MODULE_PATH -> the installed Specrew ModuleBase. $null if none # resolves (the caller WARNs once + no-ops; downstream this is the FileList-gap symptom - the CCR # tree is currently NOT in the .psd1 FileList, a feature-wide packaging gap flagged for closeout). param([string]$ProjectRoot, [string]$Rel = 'scripts/internal/continuous-co-review/continuous-co-review-navigator.ps1') $rel = $Rel # When this provider is the DEPLOYED copy under <project>/.specify/extensions/specrew-speckit/scripts, # the CCR logic is NOT beside it - it ships (once the FileList is fixed) under the installed # ModuleBase. When it is the in-repo SOURCE copy (extensions/specrew-speckit/scripts), the repo root # two levels up holds scripts/internal/... Probe the source-repo layout first (self-host + tests), # then the env override, then the installed module. $candidateBases = New-Object System.Collections.Generic.List[string] # source-repo: <repo>/extensions/specrew-speckit/scripts -> <repo> $maybeRepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) if (-not [string]::IsNullOrWhiteSpace($maybeRepoRoot)) { [void]$candidateBases.Add($maybeRepoRoot) } foreach ($b in @($ProjectRoot, $env:SPECREW_MODULE_PATH)) { if (-not [string]::IsNullOrWhiteSpace($b)) { [void]$candidateBases.Add($b) } } foreach ($base in $candidateBases.ToArray()) { $probe = Join-Path $base $rel if (Test-Path -LiteralPath $probe -PathType Leaf) { return $probe } } try { $mod = Get-Module -ListAvailable Specrew | Sort-Object Version -Descending | Where-Object { Test-Path -LiteralPath (Join-Path $_.ModuleBase $rel) -PathType Leaf } | Select-Object -First 1 if ($mod) { return (Join-Path $mod.ModuleBase $rel) } } catch { $null = $_ } return $null } function Write-CoReviewNavigatorTrace { # D-197-I009-001 observability: the navigator was DARK + SILENT for days (stderr WARN only, masked by # manual --live). Record EVERY outcome (fired | deduped | not-implement-stage | identity-unresolved | # dark | failed | no-op) to a bounded journal so a non-fire is OBSERVABLE, not invisible. Fail-safe - # the provider is fail-open, so a journal error must never throw. param([string]$ProjectRoot, [string]$Action, [string]$Reason) try { $dir = Join-Path $ProjectRoot '.specrew/runtime' if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return } $path = Join-Path $dir 'co-review-navigator-journal.jsonl' $entry = ([pscustomobject]@{ recorded_at = ([datetime]::UtcNow.ToString('o')); action = [string]$Action; reason = [string]$Reason } | ConvertTo-Json -Compress) $existing = if (Test-Path -LiteralPath $path -PathType Leaf) { @(Get-Content -LiteralPath $path -ErrorAction SilentlyContinue) } else { @() } Set-Content -LiteralPath $path -Value ((@($existing | Select-Object -Last 49) + $entry) -join "`n") -Encoding UTF8 } catch { $null = $_ } } # --- manual $args parse (the double-dash contract; NO param()) --- $sourceEventArg = $null $hostKindArg = $null $transcriptPathArg = $null for ($i = 0; $i -lt $args.Count; $i++) { if ($args[$i] -eq '--source-event' -and ($i + 1) -lt $args.Count) { $sourceEventArg = [string]$args[$i + 1] } # M1 fix (145 iter-006): --host-kind IS the code-writer host - thread it to the navigator so reviewer # selection is code-writer-INDEPENDENT by logic (a different harness than the code-writer, resolved # from the catalog), not merely by which hosts the catalog authorizes. --transcript-path threads to # the reap (T106/N4) so the escalation-latch can read REAL user turns for human closure. elseif ($args[$i] -eq '--host-kind' -and ($i + 1) -lt $args.Count) { $hostKindArg = [string]$args[$i + 1] } elseif ($args[$i] -eq '--transcript-path' -and ($i + 1) -lt $args.Count) { $transcriptPathArg = [string]$args[$i + 1] } } try { $projectRoot = (Get-Location).Path if ([string]::IsNullOrWhiteSpace($projectRoot) -or -not (Test-Path -LiteralPath (Join-Path $projectRoot '.specrew'))) { exit 0 # not a governed project root - nothing to do. } # Classify the event: SessionStart -> cross-session SWEEP; Stop-class -> reap+fire. The registration # binds exactly these; anything else is a defensive no-op. $evt = if ([string]::IsNullOrWhiteSpace($sourceEventArg)) { '' } else { $sourceEventArg.ToLowerInvariant() } $isSessionStart = ($evt -eq 'sessionstart') $isStop = ($evt -in @('stop', 'agentstop')) if (-not $isSessionStart -and -not $isStop) { exit 0 } # iter-008: the worktree co-review engine is the ONE method. The diff-cramming first cut of this (never-released) # feature is being removed - there is no "legacy" to select. Always load the worktree navigator. $rel = 'scripts/internal/continuous-co-review/worktree-navigator.ps1' $navFn = 'Invoke-ContinuousCoReviewWorktreeNavigator' $logicPath = Resolve-CoReviewNavigatorLogicPath -ProjectRoot $projectRoot -Rel $rel if ([string]::IsNullOrWhiteSpace($logicPath)) { # Diagnosable degrade (NOT a silent dead provider): WARN (diagnostic) + a VISIBLE note + a durable # trace, so a dark navigator is OBSERVABLE this time (D-197-I009-001: it was stderr-silent for days). Write-CoReviewNavigatorTrace -ProjectRoot $projectRoot -Action 'dark' -Reason 'navigator-logic-unresolved' [Console]::Error.WriteLine("[specrew-co-review-navigator] WARN CO_REVIEW_NAVIGATOR_UNAVAILABLE the in-glob navigator logic ($rel) did not resolve under the project tree, SPECREW_MODULE_PATH, or the installed Specrew module; the co-review navigator is dark this event (the deterministic signoff gate floor remains the authority).") Write-Output '[co-review] navigator is DARK — continuous co-review is NOT running (navigator logic did not load). The signoff-gate floor still applies; deploy/re-sync the co-review runtime to restore auto-review.' exit 0 } . $logicPath if (-not (Get-Command -Name $navFn -ErrorAction SilentlyContinue)) { # This is exactly the D-197-I009-001 failure mode (deployed provider drifted to a legacy fn name). Write-CoReviewNavigatorTrace -ProjectRoot $projectRoot -Action 'dark' -Reason "$navFn-undefined" [Console]::Error.WriteLine("[specrew-co-review-navigator] WARN CO_REVIEW_NAVIGATOR_UNAVAILABLE the navigator logic loaded but $navFn is undefined; co-review navigator dark this event.") Write-Output "[co-review] navigator is DARK — continuous co-review is NOT running ($navFn undefined; the deployed provider likely drifted from source). The signoff-gate floor still applies; re-sync the deployed mirror to restore auto-review." exit 0 } # Optional config scalar: the co-review timeout (mirrors co_review_gate_enforcement). Read best-effort # from .specrew config if present; else the navigator's safe default applies. $navParams = @{ RepoRoot = $projectRoot } if ($isSessionStart) { $navParams['SessionStart'] = $true } # M1 fix (145 iter-006): thread the code-writer host (the dispatcher's --host-kind) so reviewer # selection is independent-by-logic, not config-incidental. if (-not [string]::IsNullOrWhiteSpace($hostKindArg)) { $navParams['CodeWriterHost'] = $hostKindArg } # T106/N4: thread the transcript so the escalation-latch can detect a human closure. if (-not [string]::IsNullOrWhiteSpace($transcriptPathArg)) { $navParams['TranscriptPath'] = $transcriptPathArg } # Honor the project's configured co_review_timeout_seconds on the AUTO path (Codex review P2): without this the # navigator always fell back to its default, ignoring an explicit per-project policy. Best-effort inline read # (same grammar as Get-ContinuousCoReviewNavigatorTimeoutSeconds); left UNSET when the key is absent so the # navigator's own default still applies. try { $ccrCfgPath = Join-Path $projectRoot '.specrew/config.yml' if (Test-Path -LiteralPath $ccrCfgPath -PathType Leaf) { foreach ($cfgLine in Get-Content -LiteralPath $ccrCfgPath -Encoding UTF8) { if ($cfgLine -match '^\s*co_review_timeout_seconds:\s*[''"]?(?<value>[^''"#]+?)[''"]?\s*(?:#.*)?$') { $ccrTimeout = 0 if ([int]::TryParse(($Matches['value'].Trim()), [ref]$ccrTimeout) -and $ccrTimeout -gt 0) { $navParams['TimeoutSeconds'] = $ccrTimeout } break } } } } catch { $null = $_ } $decision = $null try { $decision = & $navFn @navParams } catch { Write-CoReviewNavigatorTrace -ProjectRoot $projectRoot -Action 'failed' -Reason ([string]$_.Exception.Message) [Console]::Error.WriteLine("[specrew-co-review-navigator] WARN CO_REVIEW_NAVIGATOR_FAILED $($_.Exception.Message)") exit 0 } if ($null -eq $decision) { Write-CoReviewNavigatorTrace -ProjectRoot $projectRoot -Action 'no-op' -Reason 'null-decision'; exit 0 } # Durable trace of the REAL outcome (fired | deduped | not-implement-stage | identity-unresolved | cross-session-sweep). Write-CoReviewNavigatorTrace -ProjectRoot $projectRoot -Action ([string]$decision.action) -Reason ([string]$decision.reason) # Translate the decision into the dispatcher's stdout contract. STOP-BLOCK wins (force-continue); # else any inject notes; else NOTHING. if (-not [string]::IsNullOrWhiteSpace([string]$decision.stop_block)) { Write-Output ("<<<SPECREW-STOP-BLOCK>>>`n" + [string]$decision.stop_block) exit 0 } $notes = @($decision.inject_notes | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) if ($notes.Count -gt 0) { Write-Output ($notes -join "`n") } # no-op (action no-op/fired/swept with no surfaced verdict) -> emit nothing. exit 0 } catch { [Console]::Error.WriteLine("[specrew-co-review-navigator] WARN CO_REVIEW_NAVIGATOR_FAILED $($_.Exception.Message)") exit 0 } |