extensions/specrew-speckit/scripts/declare-turn-end.ps1
|
#!/usr/bin/env pwsh <# .SYNOPSIS Record this turn and verify the agent-authored message. Call as the turn's last tool. .DESCRIPTION Returns information to the agent, never a replacement reply. Ordinary turns emit no text. Boundary drafts are verified; only missing machine-derived approval/marker lines are supplied. Token ownership, missing-artifact withholding and the pending crossing remain authoritative. #> [CmdletBinding()] param( [Parameter(Mandatory)][ValidateSet('boundary', 'in-flight', 'conversational')][string] $Kind, # What this turn actually did, in the agent's own words. A FACT supplied as a parameter - the script # never inspects prose to find it, and never scores it. [AllowNull()][AllowEmptyString()][string] $Summary, # What is pending: the named background work for an in-flight turn, or what the human is being asked to # decide at a boundary. [AllowNull()][AllowEmptyString()][string] $Pending, # Artifacts this stage owes and has not produced. Approval and marker are withheld; # the agent names the missing artifacts and next step in the drafted packet. [AllowNull()][string[]] $Owed, # THE TOKEN THE HOOK HANDED YOU AT THE START OF THIS TURN, in a line beginning `[specrew-turn]`. It is # how this declaration is placed under the session that is actually making it - the hook that issued # the token is the hook that will judge the record. With more than one session live in this project, # a declaration without it cannot be placed and is refused; with exactly one, it is optional. [AllowNull()][AllowEmptyString()][string] $Token, [AllowNull()][string] $ProjectRoot, # Draft of the human-facing reply; the script verifies it but never renders it. [AllowNull()][string] $MessagePath, # Emit the verification and any missing machine lines as JSON for tests and tooling. [switch] $AsJson ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $storePath = Join-Path $PSScriptRoot 'turn-end-store.ps1' if (-not (Test-Path -LiteralPath $storePath -PathType Leaf)) { throw "The turn-end store is missing next to this script: '$storePath'." } . $storePath $root = if ([string]::IsNullOrWhiteSpace($ProjectRoot)) { (Get-Location).Path } else { $ProjectRoot } $root = [System.IO.Path]::GetFullPath($root) # IDENTITY COMES FROM THE TOKEN THE HOOK HANDED THE AGENT, never from a project-wide file and never from a # ranking of what is on disk. Two designs stood here and the independent review broke both the same way - # a session credited for a declaration another session made. This one refuses rather than guesses: the # four outcomes below are the ruling, and the two refusals each say exactly what would resolve them. $holder = Resolve-SpecrewTurnTokenHolder -ProjectRoot $root -Token $Token $scriptRelativePath = '.specify/extensions/specrew-speckit/scripts/declare-turn-end.ps1' switch ([string]$holder.outcome) { 'unknown-token' { # FAILS CLOSED, NAMING THE VALUE. A token no live session holds is either a stale line from an # earlier turn (the hook consumed that one at its Stop) or a typo; either way writing under some # other session would be the false credit this exists to prevent. $liveCount = @($holder.live).Count $lines = @( ("Turn-end declaration refused: the token '{0}' matches no live session in this project ({1} live token{2})." -f $Token.Trim(), $liveCount, $(if ($liveCount -eq 1) { '' } else { 's' })), "Use the token from the MOST RECENT line beginning '[specrew-turn]' in this turn - the hook hands one out at each turn start and consumes it at Stop, so an earlier turn's token is no longer live.", ("Then run again: pwsh -File {0} -Kind {1} -Token <that token>" -f $scriptRelativePath, $Kind) ) [Console]::Error.WriteLine(($lines -join [Environment]::NewLine)) exit 2 } 'ambiguous' { # REFUSED, NAMING BOTH SESSIONS AND THE PARAMETER. This is the only case the fallback decides, and # it decides by not deciding. A leftover from a crashed session lands here too, and the refusal # names each token's path and issue time so a human who knows a session is gone can remove its # token; nothing here ages one out on its own. $named = @($holder.live | ForEach-Object { ' - ' + (Get-SpecrewTurnTokenSessionLabel -LiveToken $_) }) $lines = @( ("Turn-end declaration refused: {0} sessions are live in this project and no -Token was given, so this declaration cannot be placed under the right one:" -f @($holder.live).Count) ) + $named + @( "Pass -Token with the token the hook handed you at the start of this turn (the most recent line beginning '[specrew-turn]'):", (" pwsh -File {0} -Kind {1} -Token <that token>" -f $scriptRelativePath, $Kind), "A token listed above that belongs to a session you know has ended can be removed at the path shown; it is a leftover, not a live turn." ) [Console]::Error.WriteLine(($lines -join [Environment]::NewLine)) exit 2 } } $paths = if ([string]$holder.outcome -in @('matched', 'single')) { Get-SpecrewTurnEndPathsForStateRoot -ProjectRoot $root -StateRoot $holder.state_root -OwnerHash $holder.owner_hash } else { # Absent: this host delivered no turn-start event, so there is nothing to echo. The hook will have # none either, and the two degrade together - absence is not mismatch. Get-SpecrewTurnEndPaths -ProjectRoot $root -HostKind '' -SessionId '' } function Read-SpecrewPendingVerdictStop { # Parses the artifact sync-boundary-state.ps1 writes. THE VALUES COME FROM THE FILE, never from a guess # about which phase comes next - inferring the marker from the intended next phase is the specific # mistake this artifact exists to prevent, and it is worth restating at the one place that reads it. param([string] $Path) $result = [pscustomobject]@{ present = $false; boundary = ''; approval_phrase = ''; marker = '' working_boundary = ''; last_authorized = ''; feature = ''; coverage = '' } try { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $result } $lines = @(Get-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop) for ($i = 0; $i -lt $lines.Count; $i++) { $line = [string]$lines[$i] if ($line -cmatch '^Boundary to ask for:\s*(.+)$') { $result.boundary = $Matches[1].Trim() } elseif ($line -cmatch '^Human approval phrase:\s*(.+)$') { $result.approval_phrase = $Matches[1].Trim() } elseif ($line -cmatch '^Working boundary:\s*(.+)$') { $result.working_boundary = $Matches[1].Trim() } elseif ($line -cmatch '^Last authorized boundary:\s*(.+)$') { $result.last_authorized = $Matches[1].Trim() } elseif ($line -cmatch '^Feature:\s*(.+)$') { $result.feature = $Matches[1].Trim() } elseif ($line -cmatch '^Coverage line[^:]*:\s*(.+)$') { $result.coverage = $Matches[1].Trim() } elseif ($line -cmatch '^Marker last line exactly:') { if (($i + 1) -lt $lines.Count) { $result.marker = ([string]$lines[$i + 1]).Trim() } } } $result.present = -not [string]::IsNullOrWhiteSpace($result.boundary) return $result } catch { return $result } } # The agent authors the reply. This declaration verifies the draft and records facts for Stop. $message = '' if (-not [string]::IsNullOrWhiteSpace($MessagePath)) { $message = Get-Content -LiteralPath $MessagePath -Raw -Encoding UTF8 -ErrorAction Stop if ([Text.Encoding]::UTF8.GetByteCount($message) -gt 131072) { throw 'The authored message exceeds 128 KiB.' } } $owedItems = @(@($Owed) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $declaredBoundary = '' $declaredMarker = '' $approvalPhrase = '' $machineLines = [Collections.Generic.List[string]]::new() $packetCheck = [pscustomobject]@{ valid = $false; missing = @() } $inFlightRun = 0 $inFlightExhausted = $false if ($Kind -eq 'in-flight') { if ([string]::IsNullOrWhiteSpace($Pending)) { throw 'An in-flight turn must name background work with -Pending.' } $inFlightRun = Get-SpecrewInFlightRepeatCount -TurnEndRoot $paths.TurnEndRoot -PendingText $Pending.Trim() -ExcludePath $paths.RecordPath $inFlightExhausted = $inFlightRun -gt $script:SpecrewTurnEndInFlightBound } if ($Kind -eq 'boundary') { $packetCheck = Test-SpecrewAuthoredPacket -Message $message $stop = Read-SpecrewPendingVerdictStop -Path (Join-Path $root '.specrew/runtime/pending-verdict-stop.md') if ($owedItems.Count -gt 0) { # Missing artifacts cannot acquire approval text or a marker through a drafted packet either. if ($message -match 'SPECREW-VERDICT-BOUNDARY|(?im)^\s*approved for ') { $packetCheck = [pscustomobject]@{ valid = $false; missing = @('withhold approval and marker while artifacts are owed') } } } elseif ($stop.present) { $declaredBoundary = [string]$stop.boundary $declaredMarker = [string]$stop.marker $approvalPhrase = [string]$stop.approval_phrase $draftMarkers = @([regex]::Matches($message, '<!--\s*SPECREW-VERDICT-BOUNDARY:[^>]*-->') | ForEach-Object { $_.Value }) if (@($draftMarkers | Where-Object { $_ -cne $declaredMarker }).Count -gt 0) { $packetCheck = [pscustomobject]@{ valid = $false; missing = @('replace the stale crossing marker with the pending marker') } } if ($packetCheck.valid) { if (-not (Test-SpecrewTurnMessageVisible -Message $message -Expected $approvalPhrase)) { $machineLines.Add($approvalPhrase) } if (-not (Test-SpecrewTurnMessageVisible -Message $message -Expected $declaredMarker)) { $machineLines.Add($declaredMarker) } } } } $record = [pscustomobject][ordered]@{ schema_version = '1.0' kind = $Kind turn_id = [string]$paths.TurnId turn_token = [string]$holder.token owner_hash = [string]$paths.OwnerHash rendered = $false render_reason = 'agent-authored-message' content_hash = Get-SpecrewTurnEndContentHash -Text $message message = $message packet_valid = [bool]$packetCheck.valid packet_missing = @($packetCheck.missing) boundary = $declaredBoundary marker = $declaredMarker approval_phrase = $approvalPhrase in_flight_run = $inFlightRun in_flight_exhausted = $inFlightExhausted owed = @($owedItems) pending = $(if ([string]::IsNullOrWhiteSpace($Pending)) { '' } else { $Pending.Trim() }) summary = [string]$Summary orientation = $false rendered_at = [DateTimeOffset]::UtcNow.ToString('o') rendered_ms = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() } $written = Write-SpecrewTurnEndRecord -Path $paths.RecordPath -Record $record $machineText = $machineLines -join [Environment]::NewLine if ($AsJson) { [pscustomobject][ordered]@{ record_path = [string]$paths.RecordPath turn_id = [string]$paths.TurnId anchored = [bool]$paths.Anchored record_written = [bool]$written identity = [string]$holder.outcome rendered = $false render_reason = 'agent-authored-message' orientation = $false packet_valid = [bool]$packetCheck.valid packet_missing = @($packetCheck.missing) owed = @($owedItems) text = $machineText } | ConvertTo-Json -Depth 6 return } if ($Kind -eq 'boundary' -and -not $packetCheck.valid) { [Console]::Error.WriteLine(('Packet not verified: {0}; pass the agent-authored packet with -MessagePath, then include it in your reply.' -f ($packetCheck.missing -join ', '))) } if (-not [string]::IsNullOrWhiteSpace($machineText)) { Write-Output $machineText } |