extensions/specrew-speckit/scripts/confirm-workshop-lens.ps1
|
#!/usr/bin/env pwsh <# .SYNOPSIS The governed LENS CHECKPOINT writer (FR-027, iteration 002, T018). .DESCRIPTION F-1, measured on the WinUI walk: the human confirms a lens, the agent writes the artifacts, and the controller stays on the same phase because NOTHING runs the transition. `moved_on` was written by the agent hand-editing lens-applicability.json per skill instruction. Later, outside work plus an open topic triggers a re-ask of a lens the human already answered - and to a newcomer that reads as "the system lost my answer". Roughly nine of those per full workshop. Inert-control family: the state exists, a writer exists for the AGENDA (confirm-workshop-agenda.ps1), and nothing connected them at the moment a LENS is confirmed. This is that missing writer, and it is the only supported way `moved_on` becomes true. B-6 rides with it, scoped as the maintainer ruled: the lens's ALREADY-EXISTING validator runs HERE, at the checkpoint, with the human still in the room and the context still loaded - instead of at the specify boundary, lenses later, in reverse order of creation. No new validators are written; the boundary gate keeps its own calls as defense in depth. Refusals route through the workshop refusal contract: they name what is missing, keep the human's answers, and give one action - never machinery vocabulary, never blaming Specrew. #> # PositionalBinding = $false: EVERY parameter must be named. # # Measured in the field (ConsoleFractal, 2026-08-30): a six-element `-Agenda` array was absorbed across the # positional parameters and one of its strings landed in `-Confirmation`. The ValidateSet caught it loudly, # which is correct - but the message named `Confirmation`, and the cause was `Agenda`. Accurate about what # it checked, wrong about what went wrong; the same family as the binding refusal above. With positional # binding refused, a mis-shaped call fails as "a positional parameter cannot be found" instead of # reappearing as a different parameter's validation error. [CmdletBinding(PositionalBinding = $false)] param( [Parameter(Mandatory)][string] $ProjectRoot, [Parameter(Mandatory)][string] $FeatureRef, [Parameter(Mandatory)][string] $Lens, [Parameter(Mandatory)][string] $Decision, [Parameter(Mandatory)][string] $Depth, [Parameter(Mandatory)][string[]] $Agenda, [AllowNull()][string] $Diagram, [AllowNull()][string] $BindingsJson, [ValidateSet('human-confirmed', 'human-skipped', 'human-delegated')][string] $Confirmation = 'human-confirmed', [switch] $PassThru ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $here = $PSScriptRoot foreach ($dependency in @('workshop-authority-store.ps1', 'shared-governance.ps1')) { $path = Join-Path $here $dependency if (Test-Path -LiteralPath $path -PathType Leaf) { . $path } } function Write-SpecrewLensAtomicUtf8NoBom { param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][string] $Content) $directory = Split-Path -Parent $Path if ($directory -and -not (Test-Path -LiteralPath $directory -PathType Container)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $temp = "$Path.tmp" [System.IO.File]::WriteAllText($temp, $Content, [System.Text.UTF8Encoding]::new($false)) Move-Item -LiteralPath $temp -Destination $Path -Force } function New-SpecrewLensCheckpointRefusal { # Same contract as the agenda writer's refusals: a summary the human can act on, one action, and the # standing reassurance that their answers are safe. No machinery nouns. param([Parameter(Mandatory)][string] $Summary, [Parameter(Mandatory)][string] $Action) $contract = if (Get-Command -Name 'Get-SpecrewWorkshopRefusalContractText' -ErrorAction SilentlyContinue) { Get-SpecrewWorkshopRefusalContractText -AnswerState 'preserved' } else { 'Your answers so far are safe and unchanged.' } return ("{0} {1} {2}" -f $Summary, $Action, $contract) } $resolvedProjectRoot = [System.IO.Path]::GetFullPath($ProjectRoot) $featurePath = Join-Path (Join-Path $resolvedProjectRoot 'specs') (Split-Path -Leaf $FeatureRef) $controllerPath = Join-Path $featurePath 'lens-applicability.json' $lensRecordPath = Join-Path (Join-Path $featurePath 'workshop') ($Lens + '.md') if (-not (Test-Path -LiteralPath $controllerPath -PathType Leaf)) { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("This feature has no recorded workshop plan yet, so the '{0}' discussion cannot be closed." -f $Lens) ` -Action 'Start the workshop for this feature and confirm its agenda first, then close this lens again.') } $controller = $null try { $controller = Get-Content -LiteralPath $controllerPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("The recorded workshop plan for this feature could not be read, so the '{0}' discussion cannot be closed." -f $Lens) ` -Action 'Ask for the workshop plan to be repaired, then close this lens again.') } # THE INTAKE LENS IS NOT AN AGENDA TOPIC, and treating it as one deadlocked every greenfield workshop. # # `product-domain` runs BEFORE the agenda and is what produces it, so it is never in `selected` - the # agenda catalog excludes it by construction. This writer required membership in `selected` and a # CONFIRMED agenda, so the first lens of every new feature could not be closed: closing it demanded a # confirmed agenda, and confirming the agenda demanded the product-domain records this step persists. # Two refusals that are each correct in isolation and jointly a trap - the pause-recovery shape again. $isIntakeLens = Test-SpecrewWorkshopIntakeLens -Lens $Lens # This is the finite transition contract: a TECHNICAL lens closes only from a # confirmed agenda; the INTAKE lens closes from the pre-agenda state, which is how the controller reaches # `pending-product-projection`. $transitionOperation = if ($isIntakeLens) { 'confirm-intake-lens' } else { 'confirm-lens' } $transition = Resolve-SpecrewWorkshopStateTransition -Controller $controller -Operation $transitionOperation if (-not [bool]$transition.allowed) { # A REFUSAL MUST LEAVE A LEGAL NEXT MOVE (method rule 12, applied to writers rather than checks). The # intake and technical cases fail for opposite reasons, so one shared sentence cannot name a move that # is actually available from where the reader is. $transitionAction = if ($isIntakeLens) { "This topic runs before the agenda, so there is no agenda to confirm first. Ask for the workshop plan to be repaired, then close this topic again." } else { "Confirm the workshop agenda first (or ask for the plan to be repaired), then close this topic again." } throw (New-SpecrewLensCheckpointRefusal ` -Summary ("The workshop plan is not in a state where the '{0}' discussion can be closed." -f $Lens) ` -Action $transitionAction) } if (-not $isIntakeLens) { $selected = @() if ($controller.PSObject.Properties['selected'] -and $null -ne $controller.selected) { $selected = @($controller.selected | ForEach-Object { [string]$_ }) } if ($selected -notcontains $Lens) { # Naming the agreed topics is what keeps this refusal actionable; with an empty agenda it would # otherwise say "close one of: " and name nothing, which is a refusal with no legal move in it. $agreedList = if ($selected.Count -gt 0) { ($selected -join ', ') } else { '(none yet - the agenda has not been confirmed)' } throw (New-SpecrewLensCheckpointRefusal ` -Summary ("'{0}' is not one of the topics this workshop agreed to cover." -f $Lens) ` -Action ("Close one of the agreed topics instead: {0}." -f $agreedList)) } } # The typed-turn receipt: the human's own reply, minted by the prompt-submit hook. No receipt, no close - # a lens cannot be closed on the agent's say-so. # THE PHASE MUST MATCH THE ONE THE TURN WAS MINTED UNDER. Intake answers are recorded with # `phase: 'product-domain'` - measured in the field: HelloWinUIReactive's store holds 12 of them - while # this lookup asked for `phase: 'lens'`. Fixing only the membership check above would have moved the # deadlock one line down and reported "no typed reply from you is on record" for a reply that was. # A fact that fails two contracts reports only one of them. $receiptPhase = if ($isIntakeLens) { $Lens } else { 'lens' } $receipt = Get-SpecrewWorkshopAuthorityReceipt -ProjectRoot $resolvedProjectRoot -FeatureRef $FeatureRef -Phase $receiptPhase -Lens $Lens if ($null -eq $receipt) { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("No typed reply from you is on record for the '{0}' question, so the topic was not closed." -f $Lens) ` -Action ('Reply to the open question (or type "skip" to skip the topic), and the checkpoint will run on that reply.')) } $receiptConfirmation = [string]$receipt.confirmation # FR-027 review finding `workshop-receipt-contract`: the scope written here is the scope the RECEIPT # carries, never one this script derives. A local table is a second opinion about what the human typed, # and Test-SpecrewWorkshopAuthorityReceipt compares the record's scope against the receipt's - so a # derived scope that disagrees makes the entry unreadable at the next workshop-state read, which is the # precise failure this whole checkpoint exists to prevent. $receiptScope = [string]$receipt.confirmation_scope if ($receiptConfirmation -cne $Confirmation) { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("Your recorded reply for '{0}' does not match the way this topic is being closed." -f $Lens) ` -Action 'Close the topic the way you answered it, or reply again to the open question.') } if ([string]::IsNullOrWhiteSpace($receiptScope)) { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("Your recorded reply for '{0}' is missing the detail the checkpoint needs to close the topic safely, so nothing was changed." -f $Lens) ` -Action 'Reply to the open question once more, and the checkpoint will run on that reply.') } if (-not (Test-Path -LiteralPath $lensRecordPath -PathType Leaf)) { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("The '{0}' discussion has no written record yet, so it cannot be closed." -f $Lens) ` -Action ("Write the discussion record at workshop/{0}.md, then close this topic again." -f $Lens)) } $recordText = Get-Content -LiteralPath $lensRecordPath -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace($recordText)) { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("The '{0}' discussion record is empty, so the topic cannot be closed." -f $Lens) ` -Action ("Write what was decided into workshop/{0}.md, then close this topic again." -f $Lens)) } # B-6: the lens's OWN validator, at ITS checkpoint. Only the two lenses that have one; no new validators. # # The resolution below is deliberately explicit, and it REFUSES rather than skipping when it cannot find the # validator it knows should exist. A silent skip here would be the inert-control shape this whole batch is # about: a check that is called, cannot load, and reports nothing - green because it never ran. function Resolve-SpecrewLensScriptPath { param([Parameter(Mandatory)][string] $FileName, [Parameter(Mandatory)][string] $StartDirectory, [AllowNull()][string] $ProjectRootPath) $candidates = New-Object System.Collections.Generic.List[string] $walk = $StartDirectory while (-not [string]::IsNullOrWhiteSpace($walk)) { $candidates.Add((Join-Path $walk (Join-Path 'scripts' (Join-Path 'internal' $FileName)))) | Out-Null $parent = Split-Path -Parent $walk if ($parent -eq $walk) { break } $walk = $parent } if (-not [string]::IsNullOrWhiteSpace($env:SPECREW_MODULE_PATH)) { $candidates.Add((Join-Path $env:SPECREW_MODULE_PATH (Join-Path 'scripts' (Join-Path 'internal' $FileName)))) | Out-Null } if (-not [string]::IsNullOrWhiteSpace($ProjectRootPath)) { $candidates.Add((Join-Path $ProjectRootPath (Join-Path 'scripts' (Join-Path 'internal' $FileName)))) | Out-Null } try { $module = Get-Module -ListAvailable Specrew -ErrorAction SilentlyContinue | Sort-Object Version -Descending | Select-Object -First 1 if ($null -ne $module) { $candidates.Add((Join-Path $module.ModuleBase (Join-Path 'scripts' (Join-Path 'internal' $FileName)))) | Out-Null } } catch { $null = $_ } foreach ($candidate in $candidates) { if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { return $candidate } } return $null } $lensValidators = @{ 'product-domain' = @{ Script = 'product-domain-lens.ps1'; Function = 'Test-SpecrewProductDomainRecord' } 'code-implementation' = @{ Script = 'code-implementation-lens.ps1'; Function = 'Test-SpecrewImplementationRulesManifest' } } $lensValidatorErrors = @() if ($lensValidators.ContainsKey($Lens)) { $spec = $lensValidators[$Lens] if (-not (Get-Command -Name ([string]$spec.Function) -ErrorAction SilentlyContinue)) { $scriptPath = Resolve-SpecrewLensScriptPath -FileName ([string]$spec.Script) -StartDirectory $here -ProjectRootPath $resolvedProjectRoot if ($null -ne $scriptPath) { . $scriptPath } } if (-not (Get-Command -Name ([string]$spec.Function) -ErrorAction SilentlyContinue)) { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("The '{0}' record cannot be checked here because this installation's checker for it could not be loaded, and closing the topic without checking it would hide a problem until much later." -f $Lens) ` -Action 'Ask for the installation to be repaired or updated, then close this topic again.') } if ($Lens -ceq 'product-domain') { $recordPath = Get-SpecrewProductDomainRecordPath -FeatureDir $featurePath $schemaPath = if (Get-Command -Name 'Get-SpecrewProductDomainSchemaPath' -ErrorAction SilentlyContinue) { Get-SpecrewProductDomainSchemaPath -FeatureDir $featurePath } else { $null } $lensValidatorErrors = @(Test-SpecrewProductDomainRecord -Path $recordPath -SchemaPath $schemaPath) } else { $manifestPath = Get-SpecrewCodeManifestPath -FeatureDir $featurePath $lensValidatorErrors = @(Test-SpecrewImplementationRulesManifest -Path $manifestPath -SchemaPath $null -CatalogPath $null -OverlayPath $null) } } if ($lensValidatorErrors.Count -gt 0) { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("The '{0}' record did not pass its own checks, so the topic stays open: {1}" -f $Lens, ($lensValidatorErrors -join ' ')) ` -Action 'Fix the record, then close this topic again.') } $entry = [ordered]@{ agenda = @($Agenda) decision = $Decision depth = $Depth moved_on = $true confirmation = $Confirmation confirmation_scope = $receiptScope } if (-not [string]::IsNullOrWhiteSpace($Diagram)) { $entry['diagram'] = $Diagram } if (-not [string]::IsNullOrWhiteSpace($BindingsJson)) { try { $entry['bindings'] = ($BindingsJson | ConvertFrom-Json) } catch { throw (New-SpecrewLensCheckpointRefusal ` -Summary ("The cross-topic names recorded for '{0}' could not be read." -f $Lens) ` -Action 'Record them as a simple name-to-value list, then close this topic again.') } } # `human_turn_receipt` is the name ProjectMetadataAccessor reads. It was written as `turn_receipt`, so # every entry this checkpoint wrote was invisible to the reader and the next state read returned # `workshop-completed-human-turn-receipt-invalid`. if ($receipt.PSObject.Properties['receipt_id']) { $entry['human_turn_receipt'] = [string]$receipt.receipt_id } $workshop = if ($controller.PSObject.Properties['workshop'] -and $null -ne $controller.workshop) { $controller.workshop } else { [pscustomobject]@{} } $workshopMap = [ordered]@{} foreach ($property in @($workshop.PSObject.Properties)) { $workshopMap[[string]$property.Name] = $property.Value } $workshopMap[$Lens] = $entry $updated = [ordered]@{} foreach ($property in @($controller.PSObject.Properties)) { if ([string]$property.Name -ceq 'workshop') { continue } $updated[[string]$property.Name] = $property.Value } $updated['workshop'] = $workshopMap Write-SpecrewLensAtomicUtf8NoBom -Path $controllerPath -Content (($updated | ConvertTo-Json -Depth 12) + [Environment]::NewLine) # The handover refresh the skill's step 7(c) used to ask the agent to remember. $handoverProvider = Join-Path $here 'specrew-handover-provider.ps1' if (Test-Path -LiteralPath $handoverProvider -PathType Leaf) { try { & pwsh -NoProfile -File $handoverProvider --project-root $resolvedProjectRoot --source workshop 2>&1 | Out-Null } catch { $null = $_ } } if ($PassThru) { [pscustomobject]@{ lens = $Lens moved_on = $true confirmation = $Confirmation confirmation_scope = $receiptScope controller_path = $controllerPath validated = ($Lens -cin @('product-domain', 'code-implementation')) } } |