extensions/specrew-speckit/scripts/repair-workshop-controller-state.ps1
|
#!/usr/bin/env pwsh [CmdletBinding(DefaultParameterSetName = 'Request')] param( [Parameter(Mandatory)][string] $ProjectRoot, [Parameter(Mandatory)][string] $FeatureRef, [Parameter(ParameterSetName = 'Request')][switch] $Request, [Parameter(Mandatory, ParameterSetName = 'Apply')][switch] $Apply, [switch] $PassThru ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function New-SpecrewWorkshopRepairRefusal { # The workshop refusal contract: what happened, one action, and the standing reassurance that the # human's answers are safe - never machinery vocabulary, never blaming Specrew. param([Parameter(Mandatory)][string] $Summary, [Parameter(Mandatory)][string] $Action) $contract = if (Get-Command -Name 'Get-SpecrewWorkshopRefusalContractText' -ErrorAction SilentlyContinue) { Get-SpecrewWorkshopRefusalContractText -AnswerState 'preserved' } else { 'Your saved answers remain unchanged.' } return ('{0} {1} {2}' -f $Summary, $Action, $contract) } function Write-AtomicUtf8NoBom { param( [Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][string] $Content ) $temporaryPath = '{0}.{1}.tmp' -f $Path, ([Guid]::NewGuid().ToString('N')) try { [IO.File]::WriteAllText($temporaryPath, $Content, [Text.UTF8Encoding]::new($false)) Move-Item -LiteralPath $temporaryPath -Destination $Path -Force } finally { if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } } function Get-Sha256ForBytes { param([Parameter(Mandatory)][byte[]] $Bytes) return ([Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($Bytes))).ToLowerInvariant() } function Get-FileSha256 { param([Parameter(Mandatory)][string] $Path) return Get-Sha256ForBytes -Bytes ([IO.File]::ReadAllBytes($Path)) } function Get-CanonicalPendingState { return [ordered]@{ schema_version = '1.1' workshop_intake = $true confirmation_required = $true agenda_contract = 'complete-coverage-v1' human_turn_contract = 'typed-turns-v1' agenda_status = 'pending-confirmation' selected = @() agenda = [ordered]@{} skipped = [ordered]@{} agenda_confirmation = 'pending' agenda_confirmation_scope = 'lens-selection' agenda_turn_receipt = 'pending' workshop = [ordered]@{} } } $resolvedProjectRoot = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) if (-not (Test-Path -LiteralPath (Join-Path $resolvedProjectRoot '.specrew/config.yml') -PathType Leaf)) { throw 'workshop-repair-project-not-governed' } if ($FeatureRef -cnotmatch '^[0-9]{3}-[a-z0-9][a-z0-9-]{0,63}$') { throw 'workshop-repair-feature-ref-invalid' } $featureRoot = Join-Path (Join-Path $resolvedProjectRoot 'specs') $FeatureRef $statePath = Join-Path $featureRoot 'lens-applicability.json' if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) { throw 'workshop-repair-state-missing' } $authorityStorePath = Join-Path $resolvedProjectRoot '.specify/extensions/specrew-speckit/scripts/workshop-authority-store.ps1' if (-not (Test-Path -LiteralPath $authorityStorePath -PathType Leaf)) { $authorityStorePath = Join-Path $PSScriptRoot 'workshop-authority-store.ps1' } if (-not (Test-Path -LiteralPath $authorityStorePath -PathType Leaf)) { throw 'workshop-repair-authority-store-missing' } . $authorityStorePath $state = Get-Content -LiteralPath $statePath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -Depth 20 -ErrorAction Stop if ([string]$state.schema_version -cne '1.1' -or [string]$state.agenda_contract -cne 'complete-coverage-v1' -or [string]$state.human_turn_contract -cne 'typed-turns-v1') { throw 'workshop-repair-state-contract-invalid' } if ([string]$state.agenda_status -cne 'pending-confirmation') { throw 'workshop-repair-only-pending-state-supported' } $transitionOperation = if ($PSCmdlet.ParameterSetName -eq 'Request') { 'request-repair' } else { 'apply-repair' } $transition = Resolve-SpecrewWorkshopStateTransition -Controller $state -Operation $transitionOperation if (-not $transition.allowed) { if ($transition.state_class -in @('pending-empty', 'pending-product-projection')) { throw 'workshop-repair-not-required' } throw 'workshop-repair-transition-not-allowed' } $selected = @($state.selected) $workshopKeys = if ($null -ne $state.workshop) { @($state.workshop.PSObject.Properties | ForEach-Object { [string]$_.Name }) } else { @() } $technicalWorkshopKeys = @($workshopKeys | Where-Object { $_ -cne 'product-domain' }) $agendaKeys = if ($null -ne $state.agenda) { @($state.agenda.PSObject.Properties).Count } else { 0 } $skippedKeys = if ($null -ne $state.skipped) { @($state.skipped.PSObject.Properties).Count } else { 0 } $hasInconsistentProjection = ( $selected.Count -gt 0 -or $technicalWorkshopKeys.Count -gt 0 -or $agendaKeys -gt 0 -or $skippedKeys -gt 0 -or [string]$state.agenda_confirmation -cne 'pending' -or [string]$state.agenda_turn_receipt -cne 'pending' ) if (-not $hasInconsistentProjection) { throw 'workshop-repair-not-required' } $preservedRecords = @() $workshopDirectory = Join-Path $featureRoot 'workshop' if (Test-Path -LiteralPath $workshopDirectory -PathType Container) { $featurePrefix = $featureRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar foreach ($recordFile in @(Get-ChildItem -LiteralPath $workshopDirectory -Recurse -File | Sort-Object FullName)) { $recordPath = [IO.Path]::GetFullPath($recordFile.FullName) if (-not $recordPath.StartsWith($featurePrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'workshop-repair-record-containment-invalid' } $preservedRecords += [ordered]@{ path = $recordPath.Substring($featurePrefix.Length).Replace([IO.Path]::DirectorySeparatorChar, '/') sha256 = Get-FileSha256 -Path $recordPath } } } $controllerSha256 = Get-FileSha256 -Path $statePath $discardedSelected = @($selected | ForEach-Object { [string]$_ }) $discardedWorkshop = @($workshopKeys) $proposalPath = Get-SpecrewWorkshopRepairProposalPath -ProjectRoot $resolvedProjectRoot if ($PSCmdlet.ParameterSetName -eq 'Request') { $proposalDirectory = Split-Path -Parent $proposalPath if (-not (Test-Path -LiteralPath $proposalDirectory -PathType Container)) { New-Item -ItemType Directory -Path $proposalDirectory -Force | Out-Null } $proposal = $null if (Test-Path -LiteralPath $proposalPath -PathType Leaf) { $existingProposal = Get-Content -LiteralPath $proposalPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -Depth 8 -ErrorAction Stop $existingRecords = @($existingProposal.preserved_records | ForEach-Object { [ordered]@{ path = [string]$_.path; sha256 = [string]$_.sha256 } }) if ([string]$existingProposal.feature_ref -ceq $FeatureRef -and [string]$existingProposal.controller_sha256 -ceq $controllerSha256 -and (($existingRecords | ConvertTo-Json -Depth 5 -Compress) -ceq (@($preservedRecords) | ConvertTo-Json -Depth 5 -Compress))) { $proposal = $existingProposal } } if ($null -eq $proposal) { $proposalNonce = [Guid]::NewGuid().ToString('N') $binding = [ordered]@{ schema_version = '1.0' feature_ref = $FeatureRef controller_sha256 = $controllerSha256 target_state = 'pending-confirmation' preserved_records = @($preservedRecords) discarded_selected = @($discardedSelected) discarded_workshop = @($discardedWorkshop) proposal_nonce = $proposalNonce } $bindingJson = $binding | ConvertTo-Json -Depth 8 -Compress $proposalId = Get-Sha256ForBytes -Bytes ([Text.Encoding]::UTF8.GetBytes($bindingJson)) $proposal = [ordered]@{ schema_version = '1.0' proposal_id = $proposalId feature_ref = $FeatureRef controller_sha256 = $controllerSha256 target_state = 'pending-confirmation' preserved_records = @($preservedRecords) discarded_selected = @($discardedSelected) discarded_workshop = @($discardedWorkshop) proposal_nonce = $proposalNonce requested_at = [DateTimeOffset]::UtcNow.ToString('o') } Write-AtomicUtf8NoBom -Path $proposalPath -Content ((($proposal | ConvertTo-Json -Depth 8)) + [Environment]::NewLine) } $proposalId = [string]$proposal.proposal_id $preservedLabel = if ($preservedRecords.Count -gt 0) { (@($preservedRecords | ForEach-Object { [string]$_.path }) -join ', ') } else { 'none were present' } $message = @( 'This feature''s workshop progress is out of step with the unfinished technical agenda.' ('Your saved product answers remain unchanged ({0}).' -f $preservedLabel) 'I can return only the unfinished technical agenda to its pre-confirmation state, then show that agenda again.' 'To authorize that exact repair, type: approved for workshop repair' ) -join [Environment]::NewLine if ($PassThru) { [pscustomobject]@{ state = 'awaiting-human-authorization' proposal_id = $proposalId controller_sha256 = $controllerSha256 preserved_records = @($preservedRecords) message = $message } } else { Write-Output $message } return } $proposal = Get-Content -LiteralPath $proposalPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -Depth 8 -ErrorAction Stop $proposalId = [string]$proposal.proposal_id $proposalBinding = [ordered]@{ schema_version = [string]$proposal.schema_version feature_ref = [string]$proposal.feature_ref controller_sha256 = [string]$proposal.controller_sha256 target_state = [string]$proposal.target_state preserved_records = @($proposal.preserved_records | ForEach-Object { [ordered]@{ path = [string]$_.path; sha256 = [string]$_.sha256 } }) discarded_selected = @($proposal.discarded_selected | ForEach-Object { [string]$_ }) discarded_workshop = @($proposal.discarded_workshop | ForEach-Object { [string]$_ }) proposal_nonce = [string]$proposal.proposal_nonce } $expectedProposalId = Get-Sha256ForBytes -Bytes ([Text.Encoding]::UTF8.GetBytes(($proposalBinding | ConvertTo-Json -Depth 8 -Compress))) if ($proposalId -cne $expectedProposalId -or $proposalId -cnotmatch '^[a-f0-9]{64}$' -or [string]$proposal.proposal_nonce -cnotmatch '^[a-f0-9]{32}$' -or [string]$proposal.controller_sha256 -cne $controllerSha256 -or [string]$proposal.feature_ref -cne $FeatureRef) { # FR-028 (T019): the sibling bare token, given the same treatment. throw (New-SpecrewWorkshopRepairRefusal -Summary 'The workshop records changed after this repair was proposed, so the proposal no longer matches what is on disk and was not applied.' ` -Action 'Ask for the repair to be proposed again, read the fresh proposal, and authorize that one.') } # SPECREW-AUTHORITY-CONSUMER: workshop-repair-human-authorization $authorization = Get-SpecrewWorkshopRepairAuthorization -ProjectRoot $resolvedProjectRoot -ProposalId $proposalId if ($null -eq $authorization -or [string]$authorization.controller_sha256 -cne $controllerSha256 -or [string]$authorization.feature_ref -cne $FeatureRef) { # FR-028 (iteration 002, T019): this used to throw the bare token # 'workshop-repair-human-authorization-missing' - a machine label with no reader. The recognizer is # unchanged (still the exact typed phrase, still case-sensitive); only the SILENCE around it is fixed. # This script never receives the human's reply text - the authorization is looked up by proposal id - # so the refusal says exactly what it can know: no authorization is on record for THIS proposal, and # the phrase that creates one. Quoting a reply it does not have would be an invented instance. throw (New-SpecrewWorkshopRepairRefusal -Summary ("No typed authorization is on record for the repair proposed for '{0}', so nothing was changed." -f $FeatureRef) ` -Action 'Read the repair proposal above, and if you want exactly that repair, type: approved for workshop repair') } foreach ($record in @($proposal.preserved_records)) { $recordPath = Join-Path $featureRoot ([string]$record.path) if (-not (Test-Path -LiteralPath $recordPath -PathType Leaf) -or (Get-FileSha256 -Path $recordPath) -cne [string]$record.sha256) { throw 'workshop-repair-preserved-record-changed' } } $canonicalState = Get-CanonicalPendingState $canonicalJson = ($canonicalState | ConvertTo-Json -Depth 8) + [Environment]::NewLine Write-AtomicUtf8NoBom -Path $statePath -Content $canonicalJson $afterSha256 = Get-FileSha256 -Path $statePath $historyPath = Join-Path $resolvedProjectRoot '.specrew/runtime/workshop-controller-repair-history.jsonl' $historyDirectory = Split-Path -Parent $historyPath if (-not (Test-Path -LiteralPath $historyDirectory -PathType Container)) { New-Item -ItemType Directory -Path $historyDirectory -Force | Out-Null } $historyRecord = [ordered]@{ schema_version = '1.0' proposal_id = $proposalId feature_ref = $FeatureRef before_sha256 = $controllerSha256 after_sha256 = $afterSha256 preserved_records = @($proposal.preserved_records) applied_at = [DateTimeOffset]::UtcNow.ToString('o') } [IO.File]::AppendAllText( $historyPath, (($historyRecord | ConvertTo-Json -Depth 8 -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false) ) Remove-Item -LiteralPath $proposalPath -Force -ErrorAction Stop if ($PassThru) { [pscustomobject]@{ state = 'repaired' proposal_id = $proposalId before_sha256 = $controllerSha256 after_sha256 = $afterSha256 preserved_records = @($proposal.preserved_records) } } else { Write-Output 'The saved product answers are unchanged. The unfinished technical agenda is ready to be shown again.' } |