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 }
}