extensions/specrew-speckit/scripts/turn-end-store.ps1
|
# THE TURN-END DECLARATION STORE - the one place the writer and the reader agree on. # # WHY THIS FILE EXISTS AT ALL, and it is the whole design in one sentence: the hook verifies artifacts the # agent's SCRIPTS wrote, never the agent's PROSE. What went before scored the agent's text - four of six # header phrases in the flattened last message, a 200-line transcript scan for banner wording, an HTML # comment marker the agent had to remember to type. Every one of those punished compliant output at least # once, because a detector reading prose is guessing at intent from its shadow. Here the agent supplies # FACTS as parameters, a script decides what to render, and the hook asks one question with a yes/no answer: # did the script run, for this session, for this turn? # # AND THE HANDSHAKE IS THE FAILURE MODE. If the writer and the reader ever compute a different path, the # hook finds no record and refuses every compliant turn - strictly worse than what it replaces. So the path # is computed HERE, once, and both sides dot-source this file. A second copy of this arithmetic anywhere is # the bug, not a convenience. # # HOST-NEUTRAL BY CONSTRUCTION: nothing below names a host, branches on one, or asks one anything. The host # name is data the dispatcher passes to the hook and is hashed into a directory name. The script never reads # it at all - it takes identity from the token the hook issued, for the reasons under THE IDENTITY HANDSHAKE. # # CHEAP BY CONSTRUCTION: no git, no transcript, no module load. The whole path is small JSON reads on files # the session already wrote, because this runs at the end of every turn and a per-turn cost is paid forever. $script:SpecrewTurnEndSchemaVersion = '1.0' $script:SpecrewTurnEndKinds = @('boundary', 'in-flight', 'conversational') # EVERY TIMESTAMP IS READ THROUGH ONE FUNCTION. Three sites in this batch were bitten by ConvertFrom-Json # coercing an ISO string into a [datetime] - the design-decision read-back, the token ordering, and the # render cooldown - each wrong in a different way. The reader lives beside this file and is loaded here, so # the writer and the reader of every record below agree on what a timestamp is the same way they agree on # where a record lives: by sharing the code rather than re-deriving it. $script:SpecrewTimestampReadPath = Join-Path $PSScriptRoot 'timestamp-read.ps1' if (-not (Get-Command ConvertTo-SpecrewUtcTimestamp -ErrorAction SilentlyContinue) -and (Test-Path -LiteralPath $script:SpecrewTimestampReadPath -PathType Leaf)) { . $script:SpecrewTimestampReadPath } # IN-FLIGHT IS THE ONE KIND THE HOOK CANNOT VERIFY, so it is the one that needs a bound. # # A boundary declaration is checked against the pending crossing; a conversational one claims nothing. But # "work is in flight, continuing when it lands" is an assertion about the world that no artifact confirms - # and an unbounded assertion of that shape is a way to never stop. FR-045a already met this problem from the # other direction and answered it the same way: a `continue` needs intervening progress, and repeated # continues with none trip a bounded guard rather than looping forever. That bound is 3, and it is carried # here rather than re-chosen, because two different numbers for the same idea is how a guard rots. $script:SpecrewTurnEndInFlightBound = 3 function Get-SpecrewTurnEndOwnerHash { # The SAME arithmetic Get-SpecrewMaterialRuntimeState uses for its per-session state root, kept # deliberately identical so a session's turn-end records sit beside its other per-session records # instead of in a parallel namespace with its own drift. [OutputType([string])] param([AllowNull()][string] $HostKind, [AllowNull()][string] $SessionId) if ([string]::IsNullOrWhiteSpace($SessionId)) { return '' } $safeHost = if ([string]::IsNullOrWhiteSpace($HostKind)) { 'unknown' } else { (($HostKind -replace '[^a-zA-Z0-9-]+', '-').Trim('-').ToLowerInvariant()) } $safeSession = (($SessionId -replace '[^a-zA-Z0-9-]+', '-').Trim('-')) if ([string]::IsNullOrWhiteSpace($safeSession)) { return '' } try { $bytes = [System.Text.Encoding]::UTF8.GetBytes(('{0}|{1}' -f $safeHost, $safeSession)) return (-join ([System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') })) } catch { return '' } } # THE IDENTITY HANDSHAKE: THE HOOK HANDS THE TOKEN TO THE AGENT, THE AGENT HANDS IT BACK, AND NOTHING # IN BETWEEN GUESSES. # # What stood here first read `.specrew/runtime/session-marker.json` for the declaring session's identity. # The independent review broke it in one probe: the marker is PROJECT-WIDE and stamped by whichever session # started last, so with two sessions open, A declared and the record landed under B's path - A refused for a # declaration it made, B credited with one it did not. **A project-scoped file cannot answer a # session-scoped question**, so the read went, not repaired. # # What replaced it resolved to the NEWEST token across the project's session directories, and the # confirmatory review broke that too, the same way: B started its turn last, A's declaration landed under # B, and B's Stop CREDITED it. Newest-wins is the same inference from shared state with a different tiebreak. # The ruling on it: crediting the wrong session is worse than refusing, so newest-wins is gone. # # What stands now has no inference in it at all: # # - The hook writes a token into its OWN session directory when the turn starts, and HANDS IT TO THE # AGENT in its turn-start output - one line. The party that knows tells the party that acts. # - declare-turn-end takes -Token and writes under the session holding it. A token no live session holds # is refused, naming the value. # - With NO -Token: exactly one live token is unambiguous and is accepted; more than one is REFUSED, # naming both sessions and the parameter - that is the ambiguous case, and it is the only case where the # fallback decides anything. No tokens at all is absence: a host with no turn-start event, and both # sides degrade together. # - LIVE MEANS UNCONSUMED. The hook deletes its session's token at Stop, after judging the declaration # and only when the turn actually ended (a block force-continues the same turn and keeps it). A session # that crashed leaves its token behind, and that leftover costs the next token-less declarer one # refusal - which names the leftover's path, so a human who knows the session is gone can remove it. # Nothing here ages a token out, because "it is old so it must be dead" is inference again. # # ABSENCE IS STILL NOT MISMATCH: the hook that issued no token accepts a record carrying none. What fails # closed is a token PRESENT on the hook side and different in the record. function Get-SpecrewTurnTokenPath { [OutputType([string])] param([Parameter(Mandatory)][string] $StateRoot) return (Join-Path $StateRoot 'turn-token.json') } function Write-SpecrewTurnToken { # THE HOOK'S WRITE, at turn start. A fresh random token per turn: it is an identity, not a secret, and # it only has to be different from the one another session is holding. # # -ReuseUnconsumed is how the hook calls it. A token still on disk at a turn start means this session's # own Stop has not consumed it: the turn is still open (a SessionStart fired mid-turn on compaction, or a # host delivered two turn-start events for one prompt), or the previous Stop never ran. Either way the # open turn keeps its token, and the line the agent was already handed stays true. Overwriting it here # would make the token in the agent's context unknown to the hook that issued it - one refusal per # session opening, on every host that fires SessionStart and then the first prompt event. [OutputType([string])] param([Parameter(Mandatory)][string] $StateRoot, [AllowNull()][string] $TurnId, [switch] $ReuseUnconsumed) $temp = $null try { if ($ReuseUnconsumed) { $existing = Read-SpecrewTurnToken -StateRoot $StateRoot if (-not [string]::IsNullOrWhiteSpace($existing)) { return $existing } } if (-not (Test-Path -LiteralPath $StateRoot -PathType Container)) { New-Item -ItemType Directory -Path $StateRoot -Force | Out-Null } $token = [guid]::NewGuid().ToString('N') $path = Get-SpecrewTurnTokenPath -StateRoot $StateRoot $temp = $path + '.tmp-' + [guid]::NewGuid().ToString('N') # issued_ms is a NUMBER on purpose, and it is what the ordering reads. ConvertFrom-Json coerces an # ISO-8601 string into a [datetime] on the way back, and re-parsing that lost the sub-second part - # so two tokens issued in the same second compared EQUAL and the newest-wins sort became arbitrary. # Measured: session B won over a token issued 30 ms later, and the losing session could not recover. # This is the second time in this batch that date coercion has broken a round-trip; a number cannot # be coerced into something else, so the ordering no longer depends on a type surviving JSON. $record = [ordered]@{ schema_version = '1.0'; token = $token; turn_id = [string]$TurnId; issued_at = [DateTimeOffset]::UtcNow.ToString('o'); issued_ms = (Get-SpecrewUtcNowMilliseconds) } [System.IO.File]::WriteAllText($temp, ($record | ConvertTo-Json -Compress), [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::Move($temp, $path, $true) return $token } catch { return '' } finally { if (-not [string]::IsNullOrWhiteSpace($temp) -and (Test-Path -LiteralPath $temp -PathType Leaf)) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } function Read-SpecrewTurnToken { [OutputType([string])] param([Parameter(Mandatory)][string] $StateRoot) try { $path = Get-SpecrewTurnTokenPath -StateRoot $StateRoot if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return '' } $record = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($null -eq $record -or -not $record.PSObject.Properties['token']) { return '' } return ([string]$record.token) } catch { return '' } } function Remove-SpecrewTurnToken { # THE HOOK'S CONSUMPTION, at Stop, after the declaration has been judged and only when the turn actually # ended. This is what "live" means: a token that is still on disk belongs to a turn that has not been # closed by its own hook. Returns whether a token was there to consume, so a hook can tell "consumed" # from "there was nothing" without a second read. [OutputType([bool])] param([Parameter(Mandatory)][string] $StateRoot) try { $path = Get-SpecrewTurnTokenPath -StateRoot $StateRoot if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $false } Remove-Item -LiteralPath $path -Force -ErrorAction Stop return $true } catch { return $false } } function Get-SpecrewLiveTurnTokens { # Every UNCONSUMED token in this project, one per session directory that holds one. This is the whole # of what the script can see, and it is listed rather than ranked: the ranking was the bug. # # THE LEGACY ROOT IS A CANDIDATE TOO. A host that passes no session id keeps its state at the runtime # root itself, and the hook writes its token THERE. The first version scanned only the per-session # directories, so on such a host the hook held a token, the script found none, the record carried # none - and "absence is not mismatch" became a mismatch after all, failing closed on every # declaration. The independent review saw exactly that and withheld it as out of scope; the fix-2 tail # hit it as PH-e. Absence means neither side has a token, not that the script did not look. [OutputType([object[]])] param([Parameter(Mandatory)][string] $ProjectRoot) $live = New-Object System.Collections.Generic.List[object] try { $runtimeRoot = Join-Path $ProjectRoot '.specrew/runtime' $roots = @() $roots += [pscustomobject]@{ path = $runtimeRoot; owner = '' } $sessionsRoot = Join-Path $runtimeRoot 'conformance-sessions' if (Test-Path -LiteralPath $sessionsRoot -PathType Container) { foreach ($dir in @(Get-ChildItem -LiteralPath $sessionsRoot -Directory -ErrorAction Stop)) { $roots += [pscustomobject]@{ path = $dir.FullName; owner = $dir.Name } } } foreach ($rootEntry in $roots) { $tokenPath = Get-SpecrewTurnTokenPath -StateRoot $rootEntry.path if (-not (Test-Path -LiteralPath $tokenPath -PathType Leaf)) { continue } try { $record = Get-Content -LiteralPath $tokenPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($null -eq $record -or [string]::IsNullOrWhiteSpace([string]$record.token)) { continue } # The issue time is reported in the refusal so a human can tell a leftover from a live turn. It # is READ THROUGH THE ONE HELPER, whichever shape the field survived JSON in. $issued = $null if ($record.PSObject.Properties['issued_ms'] -and $null -ne $record.issued_ms) { $issued = ConvertTo-SpecrewUtcTimestamp -Value $record.issued_ms } if ($null -eq $issued -and $record.PSObject.Properties['issued_at']) { $issued = ConvertTo-SpecrewUtcTimestamp -Value $record.issued_at } $live.Add([pscustomobject]@{ token = [string]$record.token state_root = [string]$rootEntry.path owner_hash = [string]$rootEntry.owner turn_id = $(if ($record.PSObject.Properties['turn_id']) { [string]$record.turn_id } else { '' }) issued = $issued path = [string]$tokenPath }) | Out-Null } catch { continue } } } catch { $null = $_ } return @($live.ToArray()) } function Resolve-SpecrewTurnTokenHolder { # THE SCRIPT'S SIDE, and the four outcomes are the ruling, in order: # # matched -Token names a live token: write under the session holding it. # unknown-token -Token names no live token: refused, naming the value. Fails CLOSED. # single no -Token, exactly one live token: unambiguous, accepted. # ambiguous no -Token, more than one live token: refused, naming every session and -Token. # absent no tokens at all: a host with no turn-start event; both sides degrade together. # # Nothing here picks. A caller that gets 'ambiguous' or 'unknown-token' has been told exactly what would # resolve it, and the resolution is information only the agent has. [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $ProjectRoot, [AllowNull()][AllowEmptyString()][string] $Token ) $live = @(Get-SpecrewLiveTurnTokens -ProjectRoot $ProjectRoot) $result = [pscustomobject]@{ outcome = 'absent'; token = ''; state_root = ''; owner_hash = ''; live = $live } $wanted = if ([string]::IsNullOrWhiteSpace($Token)) { '' } else { $Token.Trim() } if (-not [string]::IsNullOrWhiteSpace($wanted)) { $match = @($live | Where-Object { [string]$_.token -ceq $wanted }) if ($match.Count -ge 1) { $result.outcome = 'matched' $result.token = [string]$match[0].token $result.state_root = [string]$match[0].state_root $result.owner_hash = [string]$match[0].owner_hash } else { $result.outcome = 'unknown-token' } return $result } if ($live.Count -eq 1) { $result.outcome = 'single' $result.token = [string]$live[0].token $result.state_root = [string]$live[0].state_root $result.owner_hash = [string]$live[0].owner_hash } elseif ($live.Count -gt 1) { $result.outcome = 'ambiguous' } return $result } function Get-SpecrewTurnTokenSessionLabel { # How a session is NAMED in a refusal. The per-session directory is a hash of host|session-id - opaque, # but it is the directory the human can go and look at, and the issue time beside it is what tells a # live turn from a leftover. [OutputType([string])] param([Parameter(Mandatory)]$LiveToken) $where = if ([string]::IsNullOrWhiteSpace([string]$LiveToken.owner_hash)) { 'the runtime root (a host that passes no session id)' } else { ('session ' + ([string]$LiveToken.owner_hash).Substring(0, [Math]::Min(12, ([string]$LiveToken.owner_hash).Length)) + '...') } $when = if ($null -ne $LiveToken.issued) { ([DateTimeOffset]$LiveToken.issued).ToString('yyyy-MM-dd HH:mm:ss', [System.Globalization.CultureInfo]::InvariantCulture) + ' UTC' } else { 'an unknown time' } return ('{0}, token issued {1}, at {2}' -f $where, $when, [string]$LiveToken.path) } function Get-SpecrewTurnCounterPath { [OutputType([string])] param([Parameter(Mandatory)][string] $StateRoot) return (Join-Path $StateRoot 'turn-counter.json') } function Get-SpecrewTurnId { # WHAT A "TURN" IS: a counter THE HOOK INCREMENTS AT STOP. Nothing else advances it. # # The first design derived the id from the conformance turn baseline, which the provider writes at the # host's genuine prompt boundary. It reads well and it is wrong in a way that only shows on the hosts # this most needs to work on: a host that never delivers a prompt event never writes a baseline, so every # turn in that session resolves to the SAME id - and a declaration made once, in turn 1, would satisfy # the hook at turn 50 forever after. The check would be perfectly green and measuring nothing. # # A counter the hook itself advances cannot have that failure. It needs no host cooperation, it advances # exactly once per Stop the hook actually processed, and "did the agent declare THIS turn" becomes a # question about a number rather than about whether a host emitted an event. # # It is READ here and never written: the writer of the counter is the hook, at Stop, and a reader that # also increments would race the very thing it is trying to measure. [OutputType([string])] param([Parameter(Mandatory)][string] $StateRoot) try { $counterPath = Get-SpecrewTurnCounterPath -StateRoot $StateRoot if (-not (Test-Path -LiteralPath $counterPath -PathType Leaf)) { return 'turn-1' } $counter = Get-Content -LiteralPath $counterPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($null -eq $counter -or -not $counter.PSObject.Properties['turn']) { return 'turn-1' } $turn = [int]$counter.turn if ($turn -lt 1) { $turn = 1 } return ('turn-{0}' -f $turn) } catch { return 'turn-1' } } function Step-SpecrewTurnCounter { # THE HOOK'S WRITE, and the only one. Called at Stop AFTER the declaration for the current turn has been # read and judged, so a declaration written earlier in the same turn - which is where it will be written, # since the agent runs the script as its last ACTION and the Stop fires after the message - still matches # when the hook looks for it. # # Only a fire the provider actually processed advances it. A duplicate hook delivery for the same # message must not, or the turn would move underneath a declaration that is still current. [OutputType([int])] param([Parameter(Mandatory)][string] $StateRoot) $temp = $null try { $counterPath = Get-SpecrewTurnCounterPath -StateRoot $StateRoot $current = 1 if (Test-Path -LiteralPath $counterPath -PathType Leaf) { try { $existing = Get-Content -LiteralPath $counterPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($null -ne $existing -and $existing.PSObject.Properties['turn']) { $current = [int]$existing.turn } } catch { $current = 1 } } if ($current -lt 1) { $current = 1 } $next = $current + 1 if (-not (Test-Path -LiteralPath $StateRoot -PathType Container)) { New-Item -ItemType Directory -Path $StateRoot -Force | Out-Null } $temp = $counterPath + '.tmp-' + [guid]::NewGuid().ToString('N') [System.IO.File]::WriteAllText($temp, (([ordered]@{ schema_version = '1.0'; turn = $next; stepped_at = [DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Compress)), [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::Move($temp, $counterPath, $true) return $next } catch { return -1 } finally { if (-not [string]::IsNullOrWhiteSpace($temp) -and (Test-Path -LiteralPath $temp -PathType Leaf)) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } # THE JUDGMENT, LEFT BESIDE THE COUNTER (fix 2 item (c), PRED-BETA4-024). The conformance provider judges # this session's declaration at Stop and steps the counter afterwards. Everything that runs AFTER it in the # same Stop - the co-review navigator, by the dispatcher's order - used to know nothing about the session # and blocked every Stop in the project while the tree was unreviewed: a read-only reviewer session was told # seventeen times in a row that "its files" had not been reviewed. The judgment is written here once, by # the one provider that made it, and read by whoever comes after. Nothing infers it a second time. function Get-SpecrewTurnMaterialVerdictPath { [OutputType([string])] param([Parameter(Mandatory)][string] $StateRoot) return (Join-Path $StateRoot 'turn-material.json') } function Write-SpecrewTurnMaterialVerdict { # Written BEFORE the counter steps, so `turn_id` names the turn that was judged. Fail-open: a missing # judgment reads as "no judgment", and the reader keeps today's behavior. [OutputType([bool])] param( [Parameter(Mandatory)][string] $StateRoot, [Parameter(Mandatory)][string] $TurnId, [Parameter()][AllowNull()][AllowEmptyString()][string] $DeclarationKind, [Parameter()][bool] $Material = $false ) $temp = $null try { $kind = if ([string]::IsNullOrWhiteSpace($DeclarationKind)) { 'absent' } else { [string]$DeclarationKind } if (-not (Test-Path -LiteralPath $StateRoot -PathType Container)) { New-Item -ItemType Directory -Path $StateRoot -Force | Out-Null } $path = Get-SpecrewTurnMaterialVerdictPath -StateRoot $StateRoot $temp = $path + '.tmp-' + [guid]::NewGuid().ToString('N') $record = [ordered]@{ schema_version = '1.0' turn_id = [string]$TurnId declaration_kind = $kind material = [bool]$Material judged_at = [DateTimeOffset]::UtcNow.ToString('o') } [System.IO.File]::WriteAllText($temp, ($record | ConvertTo-Json -Compress), [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::Move($temp, $path, $true) return $true } catch { return $false } finally { if (-not [string]::IsNullOrWhiteSpace($temp) -and (Test-Path -LiteralPath $temp -PathType Leaf)) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } function Read-SpecrewTurnMaterialVerdict { [OutputType([pscustomobject])] param([Parameter(Mandatory)][string] $StateRoot) try { $path = Get-SpecrewTurnMaterialVerdictPath -StateRoot $StateRoot if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } $record = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($null -eq $record -or [string]$record.schema_version -cne '1.0') { return $null } return $record } catch { return $null } } function Test-SpecrewTurnMaterialVerdictQuiet { # THE ONE QUESTION A LATER PROVIDER ASKS: did the provider that judged this session's turn find it # material? Quiet when the session DECLARED conversational, or declared nothing and was judged not # material. Not quiet - today's behavior - for in-flight, boundary, absent-with-material, no judgment # at all, or a judgment that cannot be THIS Stop's: its turn id is neither the current one (a block # left the counter alone) nor the one just stepped past, or it is older than -MaxAgeSeconds. Freshness # is the one clock in this, and it fails toward the advisory, never toward silence. [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $StateRoot, [int] $MaxAgeSeconds = 120 ) $r = [pscustomobject]@{ Quiet = $false; Reason = 'no-judgment'; DeclarationKind = ''; TurnId = '' } $verdict = Read-SpecrewTurnMaterialVerdict -StateRoot $StateRoot if ($null -eq $verdict) { return $r } $r.DeclarationKind = [string]$verdict.declaration_kind $r.TurnId = [string]$verdict.turn_id $current = Get-SpecrewTurnId -StateRoot $StateRoot $currentNumber = 0; $verdictNumber = 0 if ($current -match '^turn-(\d+)$') { $currentNumber = [int]$Matches[1] } if ([string]$verdict.turn_id -match '^turn-(\d+)$') { $verdictNumber = [int]$Matches[1] } if ($verdictNumber -lt 1 -or ($verdictNumber -ne $currentNumber -and $verdictNumber -ne ($currentNumber - 1))) { $r.Reason = 'judgment-not-this-turn'; return $r } $age = [double]::MaxValue try { # THE ONE TIMESTAMP READER (timestamp-read.ps1): a [datetime] ConvertFrom-Json coerced, a string, or a # number all become one UTC instant there, never here - census 34533964445 caught a parse of this # store's own, which is exactly what the helper's class guard exists to catch. $judged = ConvertTo-SpecrewUtcTimestamp -Value $verdict.judged_at $age = if ($null -eq $judged) { [double]::MaxValue } else { ([DateTimeOffset]::UtcNow - $judged).TotalSeconds } } catch { $age = [double]::MaxValue } if ($age -lt 0 -or $age -gt $MaxAgeSeconds) { $r.Reason = 'judgment-stale'; return $r } $material = $false try { $material = [bool]$verdict.material } catch { $material = $true } switch ([string]$verdict.declaration_kind) { 'conversational' { $r.Quiet = $true; $r.Reason = 'session declared conversational'; return $r } 'absent' { if (-not $material) { $r.Quiet = $true; $r.Reason = 'session declared nothing and was judged not material'; return $r } $r.Reason = 'absent-with-material'; return $r } default { $r.Reason = ('declared ' + [string]$verdict.declaration_kind); return $r } } return $r } function Get-SpecrewTurnEndPaths { # THE ONE RESOLVER. Everything that reads or writes a turn-end record comes through here. [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $ProjectRoot, [AllowNull()][string] $HostKind, [AllowNull()][string] $SessionId ) $runtimeRoot = Join-Path $ProjectRoot '.specrew/runtime' $ownerHash = Get-SpecrewTurnEndOwnerHash -HostKind $HostKind -SessionId $SessionId $anchored = -not [string]::IsNullOrWhiteSpace($ownerHash) $stateRoot = if ($anchored) { Join-Path (Join-Path $runtimeRoot 'conformance-sessions') $ownerHash } else { $runtimeRoot } $turnEndRoot = if ($anchored) { Join-Path (Join-Path $runtimeRoot 'turn-end') $ownerHash } else { Join-Path $runtimeRoot 'turn-end' } $turnId = Get-SpecrewTurnId -StateRoot $stateRoot return [pscustomobject]@{ Anchored = $anchored OwnerHash = $ownerHash StateRoot = $stateRoot TurnEndRoot = $turnEndRoot TurnId = $turnId RecordPath = Join-Path $turnEndRoot ($turnId + '.json') OrientationPath = Join-Path $stateRoot 'orientation-rendered.json' } } function Get-SpecrewTurnEndPathsForStateRoot { # The same shape Get-SpecrewTurnEndPaths returns, but resolved from a state root the caller already # found rather than from a host/session pair it would have to guess. This is the script's route: it # located the session by the token the hook issued, so it must write into THAT session's directories, # not into ones derived from anything it inferred for itself. [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $ProjectRoot, [Parameter(Mandatory)][string] $StateRoot, [AllowNull()][string] $OwnerHash ) $runtimeRoot = Join-Path $ProjectRoot '.specrew/runtime' $anchored = -not [string]::IsNullOrWhiteSpace($OwnerHash) $turnEndRoot = if ($anchored) { Join-Path (Join-Path $runtimeRoot 'turn-end') $OwnerHash } else { Join-Path $runtimeRoot 'turn-end' } $turnId = Get-SpecrewTurnId -StateRoot $StateRoot return [pscustomobject]@{ Anchored = $anchored OwnerHash = [string]$OwnerHash StateRoot = $StateRoot TurnEndRoot = $turnEndRoot TurnId = $turnId RecordPath = Join-Path $turnEndRoot ($turnId + '.json') OrientationPath = Join-Path $StateRoot 'orientation-rendered.json' } } function Read-SpecrewTurnEndRecord { [OutputType([pscustomobject])] param([Parameter(Mandatory)][string] $Path) try { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } $item = Get-Item -LiteralPath $Path -ErrorAction Stop if ($item.Length -le 0 -or $item.Length -gt 262144) { return $null } $record = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($null -eq $record -or $record -is [System.Array]) { return $null } if ([string]$record.schema_version -cne $script:SpecrewTurnEndSchemaVersion) { return $null } if ([string]$record.kind -cnotin $script:SpecrewTurnEndKinds) { return $null } return $record } catch { return $null } } function Write-SpecrewTurnEndRecord { # Atomic temp-and-move, UTF-8 without BOM, and it READS THE RECORD BACK before reporting success. A # write this whole contract rests on must not be reported on the strength of no exception. [OutputType([bool])] param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)]$Record) $temp = $null try { $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-' + [guid]::NewGuid().ToString('N') [System.IO.File]::WriteAllText($temp, ($Record | ConvertTo-Json -Depth 8 -Compress), [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::Move($temp, $Path, $true) $back = Read-SpecrewTurnEndRecord -Path $Path return ($null -ne $back -and [string]$back.kind -ceq [string]$Record.kind -and [string]$back.turn_id -ceq [string]$Record.turn_id) } catch { return $false } finally { if (-not [string]::IsNullOrWhiteSpace($temp) -and (Test-Path -LiteralPath $temp -PathType Leaf)) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } function Test-SpecrewTurnMessageVisible { param([AllowNull()][string]$Message, [AllowNull()][string]$Expected) if ([string]::IsNullOrWhiteSpace($Expected)) { return $false } # Host accessors normalize whitespace. Content and order, rather than line endings, bind presentation. $actualText = ([string]$Message -replace '\s+', ' ').Trim() $expectedText = ($Expected -replace '\s+', ' ').Trim() return $actualText.Contains($expectedText, [StringComparison]::Ordinal) } function Test-SpecrewAuthoredPacket { param([AllowNull()][string]$Message) $missing = [Collections.Generic.List[string]]::new() $sections = @{} $headings = @('What I Just Did', 'Why I Stopped', 'What Needs Your Review', 'What Happens Next', 'Discussion Prompts', 'What I Need From You') foreach ($heading in $headings) { $match = [regex]::Match([string]$Message, '(?ims)^##\s+' + [regex]::Escape($heading) + '\s*\r?\n(?<body>.*?)(?=^##\s|\z)') $sections[$heading] = $match.Groups['body'].Value.Trim() if ([string]::IsNullOrWhiteSpace($sections[$heading])) { $missing.Add($heading) } } if ($sections['What Needs Your Review'] -notmatch '(?i)file:///\S+|\]\((?:<?[A-Z]:[/\\]|<?/[A-Z]:/|<?/[^)]+)') { $missing.Add('specific review targets with file links') } if ([string]$Message -notmatch '(?i)\brecommend(?:ation|ed)?\b') { $missing.Add('recommendation') } if ($sections['What Happens Next'] -match '(?i)^On .+ the next stage starts\.|^Your reply decides\.?$') { $missing.Add('specific next step') } if ($sections['Discussion Prompts'] -match '(?i)^1\. Anything above you want changed, questioned, or done differently\.?$') { $missing.Add('specific discussion prompt') } return [pscustomobject]@{ valid = ($missing.Count -eq 0); missing = @($missing) } } function Get-SpecrewOrientationDialsLine { param([string]$ProfilePath = (Join-Path ([Environment]::GetFolderPath('UserProfile')) '.specrew/user-profile.yml')) if (-not (Test-Path -LiteralPath $ProfilePath -PathType Leaf)) { return '' } $dials = [Collections.Generic.List[string]]::new() $inExpertise = $false foreach ($line in Get-Content -LiteralPath $ProfilePath -Encoding UTF8) { if ($line -cmatch '^expertise:\s*$') { $inExpertise = $true; continue } if (-not $inExpertise) { continue } if ($line -cmatch '^\s{2,}([a-z_]+):\s*(\S+)\s*$') { $dials.Add(('{0}={1}' -f $Matches[1], $Matches[2])) } elseif ($line -cmatch '^\S') { break } } if ($dials.Count -eq 0) { return '' } return ('How I am adapting to you: {0}; correct me if that is wrong.' -f ($dials -join ', ')) } function Get-SpecrewTurnEndRenderDecision { # THE THREE GATES, AND THEY LIVE HERE RATHER THAN IN THE AGENT'S HEAD. # # This is the half that used to be instruction text: render a packet when there was actual work, not too # soon after the last one, and not the same thing twice. Instructions asking an agent to judge those # three things produced the measured failure this replaces - eight stops in one session, none about the # code. As a function it is decidable, testable, and identical on every host. # # Returning a NO-OP is a first-class outcome, not a failure: the declaration is still recorded, so the # hook can tell "the agent declared and nothing was earned" from "the agent declared nothing at all". # Those two look the same from the outside and mean opposite things. [OutputType([pscustomobject])] param( [Parameter(Mandatory)][ValidateSet('boundary', 'in-flight', 'conversational')][string] $Kind, [AllowNull()]$PreviousRecord, [AllowNull()][string] $ContentHash, [int] $MinimumSecondsBetweenRenders = 45, [AllowNull()][datetime] $NowUtc ) $now = if ($null -eq $NowUtc) { [datetime]::UtcNow } else { $NowUtc.ToUniversalTime() } # Gate 1 - actual work. A conversational turn earns nothing by definition; that is what the kind means. if ($Kind -eq 'conversational') { return [pscustomobject]@{ render = $false; reason = 'conversational-turn-renders-nothing' } } if ($null -eq $PreviousRecord) { return [pscustomobject]@{ render = $true; reason = 'first-render-of-this-kind' } } # Gate 2 - unchanged content. Checked BEFORE elapsed time on purpose: re-rendering an identical packet # is noise no matter how long ago the last one was, and saying so names the real reason. if (-not [string]::IsNullOrWhiteSpace($ContentHash) -and [string]$PreviousRecord.content_hash -ceq $ContentHash -and [string]$PreviousRecord.kind -ceq $Kind) { return [pscustomobject]@{ render = $false; reason = 'identical-to-the-last-render-of-this-kind' } } # Gate 3 - elapsed time since the last render OF THIS KIND. Scoped to the kind so an in-flight line # never suppresses a boundary packet, which is the case that must never be rate-limited away. if ([string]$PreviousRecord.kind -ceq $Kind) { try { # THE THIRD INSTANCE OF ONE ROOT. ConvertFrom-Json coerces the ISO `rendered_at` into a # [datetime]; the first version cast that back to a string, which dropped the UTC designator and # the fraction, and re-parsed it as LOCAL time. The confirmatory review measured it on this # machine: a render 0.5 s old looked ~3 hours old, and the 45-second in-flight cooldown was # bypassed. Three instances made it a pattern, and the pattern's fix is one reader: `rendered_ms` # first (a number survives JSON), the coerced `rendered_at` as the fallback for an older record, # both through ConvertTo-SpecrewUtcTimestamp, which never goes through a string it was not given. $previousAt = $null if ($PreviousRecord.PSObject.Properties['rendered_ms'] -and $null -ne $PreviousRecord.rendered_ms) { $previousAt = ConvertTo-SpecrewUtcTimestamp -Value $PreviousRecord.rendered_ms } if ($null -eq $previousAt -and $PreviousRecord.PSObject.Properties['rendered_at']) { $previousAt = ConvertTo-SpecrewUtcTimestamp -Value $PreviousRecord.rendered_at } if ($null -ne $previousAt) { $elapsed = ([DateTimeOffset]::new($now, [TimeSpan]::Zero) - [DateTimeOffset]$previousAt).TotalSeconds if ($elapsed -ge 0 -and $elapsed -lt $MinimumSecondsBetweenRenders -and $Kind -eq 'in-flight') { return [pscustomobject]@{ render = $false; reason = 'in-flight-line-already-shown-moments-ago' } } } } catch { $null = $_ } } return [pscustomobject]@{ render = $true; reason = 'render-earned' } } function Get-SpecrewTurnEndContentHash { [OutputType([string])] param([AllowNull()][string] $Text) if ([string]::IsNullOrWhiteSpace($Text)) { return '' } try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) return (-join ([System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') })) } catch { return '' } } function Get-SpecrewInFlightRepeatCount { # How many times IN A ROW this session has declared in-flight against the SAME pending text, counting the # declaration about to be written. The run must be unbroken: any other kind, or a different pending text, # resets it - because the thing being bounded is "nothing is changing", not "in-flight was used a lot". # A session that alternates real work with in-flight waits is doing exactly what in-flight is for. # # IT COUNTS SUPPRESSED NO-OPS TOO, and that is the opposite of what Get-SpecrewTurnEndPreviousRecord does # with -RenderedOnly. The asymmetry is deliberate and load-bearing. The render gates ask *what did the # human last SEE*, so a no-op must not suppress against itself. This bound asks *how long has nothing # changed*, and a suppressed no-op is the strongest possible evidence for that - it is a turn that # produced nothing new, by the gates' own judgement. Filtering it out here would mean the quieter a # session got, the longer it could go on saying nothing, which is exactly backwards. # Measured: runs 1-5 against one pending item give run counts 1,2,3,4,5 with renders at 1, 4 and 5 - the # two suppressed turns still carry the run forward and the bound trips on schedule. [OutputType([int])] param( [Parameter(Mandatory)][string] $TurnEndRoot, [AllowNull()][AllowEmptyString()][string] $PendingText, [AllowNull()][string] $ExcludePath ) if ([string]::IsNullOrWhiteSpace($PendingText)) { return 1 } $needle = $PendingText.Trim() $run = 1 try { if (-not (Test-Path -LiteralPath $TurnEndRoot -PathType Container)) { return $run } $files = @(Get-ChildItem -LiteralPath $TurnEndRoot -Filter '*.json' -File -ErrorAction Stop | Where-Object { [string]::IsNullOrWhiteSpace($ExcludePath) -or $_.FullName -ne $ExcludePath } | Sort-Object LastWriteTimeUtc -Descending) foreach ($file in $files) { $record = Read-SpecrewTurnEndRecord -Path $file.FullName if ($null -eq $record) { continue } # A CONVERSATIONAL TURN DOES NOT BREAK THE RUN, and the first version of this let it. # # Found by the independent review, which ran the sequence rather than reading it: in-flight, # conversational, in-flight, conversational... reset the count to 1 every time and the bound # NEVER TRIPPED. An agent could wait forever on one item by saying "nothing to report" between # waits - and "nothing to report" is, by its own declaration, nothing happening. Treating it as # progress meant the cheapest possible turn laundered the guard. # # So the walk SKIPS conversational records. What breaks a run is something that actually # happened: a boundary declaration (the human was asked something) or a wait on a DIFFERENT # item. Both are real events; a no-op is the absence of one. if ([string]$record.kind -ceq 'conversational') { continue } if ([string]$record.kind -cne 'in-flight') { break } $recordPending = if ($record.PSObject.Properties['pending']) { ([string]$record.pending).Trim() } else { '' } if ($recordPending -cne $needle) { break } $run++ } return $run } catch { return $run } } function Get-SpecrewTurnEndPreviousRecord { # The most recent record for this session, whatever turn it belonged to - the gates compare across # turns, because "already shown moments ago" is a fact about the session and not about one turn. # # -RenderedOnly is what keeps the gates from suppressing against something the human never saw. A no-op # record is still a record, and comparing new content against a no-op would let one suppressed render # suppress the next one for the same reason, forever. The gates ask "what did the human last SEE". [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $TurnEndRoot, [AllowNull()][string] $ExcludePath, [switch] $RenderedOnly ) try { if (-not (Test-Path -LiteralPath $TurnEndRoot -PathType Container)) { return $null } $files = @(Get-ChildItem -LiteralPath $TurnEndRoot -Filter '*.json' -File -ErrorAction Stop | Where-Object { [string]::IsNullOrWhiteSpace($ExcludePath) -or $_.FullName -ne $ExcludePath } | Sort-Object LastWriteTimeUtc -Descending) foreach ($file in $files) { $record = Read-SpecrewTurnEndRecord -Path $file.FullName if ($null -eq $record) { continue } if ($RenderedOnly -and -not [bool]$record.rendered) { continue } return $record } return $null } catch { return $null } } |