scripts/internal/coordinator-resume.ps1
|
Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sharedGovernancePath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'extensions\specrew-speckit\scripts\shared-governance.ps1' if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) { throw "Missing shared governance helper '$sharedGovernancePath'." } . $sharedGovernancePath $taskProgressHelperPath = Join-Path $PSScriptRoot 'task-progress.ps1' if (-not (Test-Path -LiteralPath $taskProgressHelperPath -PathType Leaf)) { throw "Missing task-progress helper '$taskProgressHelperPath'." } . $taskProgressHelperPath $worktreeHelperPath = Join-Path $PSScriptRoot 'worktree-awareness.ps1' if (-not (Test-Path -LiteralPath $worktreeHelperPath -PathType Leaf)) { throw "Missing worktree-awareness helper '$worktreeHelperPath'." } . $worktreeHelperPath $boundaryStateHelperPath = Join-Path $PSScriptRoot 'sync-boundary-state.ps1' if (-not (Test-Path -LiteralPath $boundaryStateHelperPath -PathType Leaf)) { throw "Missing boundary-state helper '$boundaryStateHelperPath'." } . $boundaryStateHelperPath # F-174 iter-10 (T008): the rolling-handover read + the SHARED resume reconciliation. `specrew start` is the # ONLY recovery path for antigravity (no hooks) and for any non-hook launch, so it must surface the same # git-delta resume context the SessionStart hook does. Dot-source the two bootstrap components that own those # functions; fail-open so a missing/broken component degrades the snapshot rather than failing the launcher. foreach ($bootstrapDep in @('bootstrap\HandoverStore.ps1', 'bootstrap\ProjectMetadataAccessor.ps1')) { $bootstrapDepPath = Join-Path $PSScriptRoot $bootstrapDep if (Test-Path -LiteralPath $bootstrapDepPath -PathType Leaf) { try { . $bootstrapDepPath } catch { $null = $_ } } } function Get-ResumeSessionStateProp { # StrictMode-safe property read: returns the property value if present on the object, else $null. # Get-SpecrewProp (the project-wide equivalent) lives in SessionStateAccessor.ps1, which is NOT in # this file's dot-source chain - so this local reader keeps coordinator-resume dependency-free. param([AllowNull()]$Object, [Parameter(Mandatory = $true)][string]$Name) if ($null -eq $Object) { return $null } $match = $Object.PSObject.Properties.Match($Name) if ($match.Count -gt 0) { return $match[0].Value } return $null } function ConvertTo-NormalizedResumeSessionState { # F-174 iter-10 (T008 hardening): the resume snapshot is now the load-bearing recovery path for # `specrew start` (the ONLY recovery seam for antigravity, which has no hooks). Callers pass EITHER the # raw session anchor (Get-SpecrewSessionAnchor emits `boundary`/`iteration` and NO `task_id`) OR the # already-mapped generator shape (`boundary_type`/`iteration_number`/`task_id`). Under # Set-StrictMode -Version Latest (set at this file's scope) a direct `.iteration_number` read on the raw # anchor THROWS "property cannot be found" -> a HARD throw inside Get-StartPrompt (the call at # launch-contract.ps1 is NOT wrapped) -> crashed `specrew start` / silent provider fail-open: the same # D-009 trap class that already bit this feature. Both real callers map first, so production is safe # TODAY; this normalizes BOTH shapes to the generator shape ONCE so the snapshot body never reads an # absent property regardless of which caller (or any FUTURE caller) hands it which shape. Defense-in- # depth on the function T008 made load-bearing - it must never depend on the caller remembering to map. param([AllowNull()][pscustomobject]$SessionState) if ($null -eq $SessionState) { return $null } $iteration = Get-ResumeSessionStateProp $SessionState 'iteration_number' if ([string]::IsNullOrWhiteSpace([string]$iteration)) { $iteration = Get-ResumeSessionStateProp $SessionState 'iteration' } $boundary = Get-ResumeSessionStateProp $SessionState 'boundary_type' if ([string]::IsNullOrWhiteSpace([string]$boundary)) { $boundary = Get-ResumeSessionStateProp $SessionState 'boundary' } return [pscustomobject]@{ feature_ref = Get-ResumeSessionStateProp $SessionState 'feature_ref' feature_path = Get-ResumeSessionStateProp $SessionState 'feature_path' boundary_type = $boundary iteration_number = $iteration task_id = Get-ResumeSessionStateProp $SessionState 'task_id' } } function Get-ValidatorSummaryPath { param([Parameter(Mandatory = $true)][string]$ProjectRoot) return Get-SpecrewValidatorSummaryPath -ProjectRoot $ProjectRoot } function Get-ValidatorWarningSummary { param([Parameter(Mandatory = $true)][string]$ProjectRoot) $summaryPath = Get-ValidatorSummaryPath -ProjectRoot $ProjectRoot if (-not (Test-Path -LiteralPath $summaryPath -PathType Leaf)) { return $null } try { # F-023: Use -AsHashtable for StrictMode compatibility; hashtable indexer tolerates missing fields $summary = Get-Content -LiteralPath $summaryPath -Raw -Encoding UTF8 | ConvertFrom-Json -AsHashtable -Depth 6 # F-023: Legacy schema handling - missing 'schema' field implies v0 $schema = Get-SpecrewStateSchemaVersion -State $summary -Path $summaryPath # v0/v1 behavior: warnings field structure is required in both schemas # v1+ behavior: same as v0 for this summary (no behavioral divergence yet) return [pscustomobject]@{ total = [int]$summary['warnings']['total'] soft = [int]$summary['warnings']['soft'] medium = [int]$summary['warnings']['medium'] hard = [int]$summary['warnings']['hard'] command = [string]$summary['command'] recorded_at = [string]$summary['recorded_at'] } } catch { if (Test-IsUnsupportedSpecrewSchemaError -ErrorRecord $_) { throw } return $null } } function Resolve-ResumeIterationNumber { param( [AllowNull()][string]$ResolvedFeaturePath, [AllowNull()][pscustomobject]$SessionState ) if ($null -ne $SessionState -and -not [string]::IsNullOrWhiteSpace([string]$SessionState.iteration_number)) { return [string]$SessionState.iteration_number } if ([string]::IsNullOrWhiteSpace($ResolvedFeaturePath)) { return $null } $iterationsRoot = Join-Path $ResolvedFeaturePath 'iterations' if (-not (Test-Path -LiteralPath $iterationsRoot -PathType Container)) { return $null } $latestIteration = Get-ChildItem -LiteralPath $iterationsRoot -Directory | Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'plan.md') -PathType Leaf } | Sort-Object Name -Descending | Select-Object -First 1 if ($null -eq $latestIteration) { return $null } return [string]$latestIteration.Name } function Get-CoordinatorResumeSnapshot { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$ResolvedFeaturePath, [AllowNull()][pscustomobject]$SessionState ) # F-174 iter-10 (T008 hardening): normalize the session-state shape ONCE (raw anchor OR mapped generator # shape) so every read below - and Resolve-ResumeIterationNumber, which receives it - is StrictMode-safe # and never throws on an absent property. See ConvertTo-NormalizedResumeSessionState for the why. $SessionState = ConvertTo-NormalizedResumeSessionState -SessionState $SessionState $resolvedProjectRoot = Resolve-ProjectPath -Path $ProjectRoot $effectiveFeaturePath = if (-not [string]::IsNullOrWhiteSpace($ResolvedFeaturePath)) { $ResolvedFeaturePath } elseif ($null -ne $SessionState -and -not [string]::IsNullOrWhiteSpace([string]$SessionState.feature_path)) { [string]$SessionState.feature_path } else { $null } $featureRef = if ($null -ne $SessionState -and -not [string]::IsNullOrWhiteSpace([string]$SessionState.feature_ref)) { [string]$SessionState.feature_ref } elseif (-not [string]::IsNullOrWhiteSpace($effectiveFeaturePath)) { Split-Path -Leaf $effectiveFeaturePath } else { $null } $iterationNumber = Resolve-ResumeIterationNumber -ResolvedFeaturePath $effectiveFeaturePath -SessionState $SessionState $taskSummary = if (-not [string]::IsNullOrWhiteSpace($featureRef) -and -not [string]::IsNullOrWhiteSpace($iterationNumber)) { Get-TaskProgressSummary -ProjectRoot $resolvedProjectRoot -FeatureRef $featureRef -IterationNumber $iterationNumber -ResolvedFeaturePath $effectiveFeaturePath } else { $null } $latestBoundary = Get-LatestSpecrewBoundarySyncState -ProjectRoot $resolvedProjectRoot $validatorSummary = Get-ValidatorWarningSummary -ProjectRoot $resolvedProjectRoot # F-174 iter-10 (T008): read the rolling handover + run the SHARED reconciliation (re-compute the CURRENT # cheap delta), so a `specrew start` launch recovers the same "changed since the last stop -> read + # continue" context the hook surfaces. Fail-open: any failure leaves both $null and the snapshot still # carries the lifecycle state above. $resumeHandover = $null $resumeReconciliation = $null try { if (Get-Command Get-SpecrewRollingHandover -ErrorAction SilentlyContinue) { $resumeHandover = Get-SpecrewRollingHandover -HandoverDir (Join-Path $resolvedProjectRoot '.specrew/handover') -NowUtc ((Get-Date).ToUniversalTime().ToString('o')) } if (Get-Command Get-SpecrewResumeReconciliation -ErrorAction SilentlyContinue) { $resumeReconciliation = Get-SpecrewResumeReconciliation -ProjectRoot $resolvedProjectRoot -Handover $resumeHandover } } catch { $resumeHandover = $null; $resumeReconciliation = $null } $suggestedActions = New-Object System.Collections.Generic.List[string] if ($null -ne $taskSummary -and $taskSummary.InProgress.Count -gt 0) { $task = $taskSummary.InProgress[0] $suggestedActions.Add(("Resume {0} — {1}" -f $task.id, $task.title)) | Out-Null } elseif ($null -ne $taskSummary -and $taskSummary.Pending.Count -gt 0) { $task = $taskSummary.Pending[0] $suggestedActions.Add(("Start {0} — {1}" -f $task.id, $task.title)) | Out-Null } if ($null -ne $validatorSummary -and $validatorSummary.total -gt 0) { $validatorCommand = if (-not [string]::IsNullOrWhiteSpace($validatorSummary.command)) { $validatorSummary.command } elseif (-not [string]::IsNullOrWhiteSpace($featureRef) -and -not [string]::IsNullOrWhiteSpace($iterationNumber)) { 'pwsh -NoProfile -ExecutionPolicy Bypass -File .\extensions\specrew-speckit\scripts\validate-governance.ps1 -ProjectPath . -IterationPath .\specs\' + $featureRef + '\iterations\' + $iterationNumber } else { 'pwsh -NoProfile -ExecutionPolicy Bypass -File .\extensions\specrew-speckit\scripts\validate-governance.ps1 -ProjectPath .' } $suggestedActions.Add(("Review validator warnings with: {0}" -f $validatorCommand)) | Out-Null } return [pscustomobject]@{ feature_ref = $featureRef feature_path = $effectiveFeaturePath worktree_path = $resolvedProjectRoot iteration_number = $iterationNumber current_boundary = if ($null -ne $SessionState) { [string]$SessionState.boundary_type } else { $null } current_task = if ($null -ne $SessionState) { [string]$SessionState.task_id } else { $null } last_boundary_commit = if ($null -ne $latestBoundary) { [string]$latestBoundary.auth_commit_hash } else { $null } last_boundary_at = if ($null -ne $latestBoundary) { [string]$latestBoundary.recorded_at } else { $null } task_summary = $taskSummary validator_summary = $validatorSummary handover = $resumeHandover reconciliation = $resumeReconciliation suggested_actions = $suggestedActions.ToArray() } } function Get-CoordinatorResumePromptBlock { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$ResolvedFeaturePath, [AllowNull()][pscustomobject]$SessionState ) $snapshot = Get-CoordinatorResumeSnapshot -ProjectRoot $ProjectRoot -ResolvedFeaturePath $ResolvedFeaturePath -SessionState $SessionState if ([string]::IsNullOrWhiteSpace($snapshot.feature_ref)) { return $null } $taskSummary = $snapshot.task_summary $taskStatusLine = if ($null -eq $taskSummary) { 'Task progress: (not available)' } else { 'Task progress: {0} complete, {1} in-progress, {2} pending, {3} blocked' -f $taskSummary.Complete.Count, $taskSummary.InProgress.Count, $taskSummary.Pending.Count, $taskSummary.Blocked.Count } $taskDetails = @() if ($null -ne $taskSummary -and $taskSummary.Complete.Count -gt 0) { $taskDetails += '- Complete: ' + (($taskSummary.Complete | ForEach-Object { $_.id }) -join ', ') } if ($null -ne $taskSummary -and $taskSummary.InProgress.Count -gt 0) { $taskDetails += '- In progress: ' + (($taskSummary.InProgress | ForEach-Object { '{0} ({1})' -f $_.id, $_.title }) -join '; ') } if ($null -ne $taskSummary -and $taskSummary.Pending.Count -gt 0) { $taskDetails += '- Pending: ' + (($taskSummary.Pending | Select-Object -First 3 | ForEach-Object { $_.id }) -join ', ') } if ($null -ne $taskSummary -and $taskSummary.Blocked.Count -gt 0) { $taskDetails += '- Blocked: ' + (($taskSummary.Blocked | ForEach-Object { '{0} ({1})' -f $_.id, $_.blocked_reason }) -join '; ') } $validatorLine = if ($null -eq $snapshot.validator_summary -or $snapshot.validator_summary.total -le 0) { 'Validator state: no recorded warnings' } else { 'Validator state: {0} warnings: {1} soft, {2} medium, {3} hard' -f $snapshot.validator_summary.total, $snapshot.validator_summary.soft, $snapshot.validator_summary.medium, $snapshot.validator_summary.hard } $lastCompletedTaskLine = if ($null -ne $taskSummary -and $null -ne $taskSummary.LatestCompleted) { '- Last completed task: {0} at {1}' -f $taskSummary.LatestCompleted.id, $taskSummary.LatestCompleted.timestamp } else { '- Last completed task: (none)' } $suggestedActionsBlock = if ($snapshot.suggested_actions.Count -gt 0) { ($snapshot.suggested_actions | ForEach-Object { '- ' + $_ }) -join [Environment]::NewLine } else { '- Continue from the current feature boundary' } $detailBlock = if ($taskDetails.Count -gt 0) { ($taskDetails -join [Environment]::NewLine) + [Environment]::NewLine } else { '' } # F-174 iter-10 (T008): the resume reconciliation directive - re-computed CURRENT delta vs the last stop, # so a `specrew start` launch (incl. antigravity) reads what changed since and continues from the real state. $reconciliationBlock = if ($null -ne $snapshot.reconciliation -and -not [string]::IsNullOrWhiteSpace([string]$snapshot.reconciliation.directive_text)) { [Environment]::NewLine + [Environment]::NewLine + '## Resume Reconciliation (current tree, re-computed now)' + [Environment]::NewLine + [Environment]::NewLine + [string]$snapshot.reconciliation.directive_text } else { '' } return @" ## Welcome Back Snapshot - Active feature: $($snapshot.feature_ref) - Feature path: $(if ($snapshot.feature_path) { $snapshot.feature_path } else { '(none)' }) - Worktree: $($snapshot.worktree_path) - Current boundary: $(if ($snapshot.current_boundary) { $snapshot.current_boundary } else { '(none)' }) - Current task: $(if ($snapshot.current_task) { $snapshot.current_task } else { '(none)' }) $lastCompletedTaskLine - Last completed boundary: $(if ($snapshot.last_boundary_commit) { "$($snapshot.last_boundary_commit) at $($snapshot.last_boundary_at)" } else { '(none)' }) - $taskStatusLine $detailBlock- $validatorLine ### Suggested Next Actions $suggestedActionsBlock$reconciliationBlock "@ } function Get-CoordinatorRecoveryPromptBlock { param( [AllowNull()][pscustomobject]$RecoverySession ) if ($null -eq $RecoverySession) { return $null } $reasonLines = if (@($RecoverySession.stale_reasons).Count -gt 0) { (@($RecoverySession.stale_reasons) | ForEach-Object { '- ' + [string]$_ }) -join [Environment]::NewLine } else { '- Recovery was requested explicitly.' } $choiceLines = if (@($RecoverySession.choice_set).Count -gt 0) { (@($RecoverySession.choice_set) | ForEach-Object { '- ' + [string]$_ }) -join [Environment]::NewLine } else { '- Recovery is active without an interactive choice requirement.' } return @" ## Recovery Mode - Entry mode: $($RecoverySession.entry_mode) - Selected choice: $(if ($RecoverySession.selected_choice) { $RecoverySession.selected_choice } else { '(none)' }) - Bypass stale-state gate: $($RecoverySession.bypass_gate) - Approval behavior changed: $($RecoverySession.approval_mode_changed) - Next action: $($RecoverySession.next_action_message) ### Recovery Reasons $reasonLines ### Available Recovery Choices $choiceLines "@ } |