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

# Feature 202 T014 (FR-021, DA-4): THE TURN DRAFT ARRIVES ON STDIN, never in a file the agent chose.
#
# B4F-101: agents wrote their drafts to paths of their own choosing, inside the repository too, so drafts rode into
# commits, and two sessions in one project wrote the same file. The agent now pipes the draft into the turn-end script.
# The bytes are read raw and decoded as strict UTF-8: the child's console input encoding is ibm437 under some agent
# shells (research.md, DA-4), so any console decoder is the B4F-065 class.
# Iteration 002, T023 (FR-034): the message is kept in the turn record only; the separate draft copy, read by nothing,
# is no longer written.
$script:SpecrewTurnDraftMaxBytes = 131072
$script:SpecrewTurnDraftFirstByteWaitMs = 1500
$script:SpecrewTurnDraftCompleteWaitMs = 10000

function Read-SpecrewTurnDraftFromStdin {
    # The draft's raw bytes, or $null when none was sent. A shell that leaves stdin open and sends nothing must not hang
    # the turn end, so the first bytes are awaited for a short while only; a draft that has started must finish.
    [OutputType([byte[]])]
    param()
    if (-not [Console]::IsInputRedirected) { return $null }
    $stdin = [Console]::OpenStandardInput()
    $buffer = [byte[]]::new(65536)
    $collected = [IO.MemoryStream]::new()
    $read = $stdin.ReadAsync($buffer, 0, $buffer.Length)
    if (-not $read.Wait($script:SpecrewTurnDraftFirstByteWaitMs)) { return $null }
    $sw = [Diagnostics.Stopwatch]::StartNew()
    while ($read.Result -gt 0) {
        $collected.Write($buffer, 0, $read.Result)
        if ($collected.Length -gt $script:SpecrewTurnDraftMaxBytes) { throw 'The draft on stdin exceeds 128 KiB.' }
        $read = $stdin.ReadAsync($buffer, 0, $buffer.Length)
        $left = [Math]::Max(0, $script:SpecrewTurnDraftCompleteWaitMs - [int]$sw.ElapsedMilliseconds)
        if (-not $read.Wait($left)) { throw ("The draft on stdin did not finish within {0} s; close stdin after the draft." -f ($script:SpecrewTurnDraftCompleteWaitMs / 1000)) }
    }
    if ($collected.Length -eq 0) { return $null }
    return , $collected.ToArray()
}

function ConvertFrom-SpecrewTurnDraftBytes {
    # Strict UTF-8: invalid bytes are refused, never replaced, because a replaced character is the human's text lost.
    # A leading byte-order mark is not part of the text.
    [OutputType([string])]
    param([Parameter(Mandatory)][AllowEmptyCollection()][byte[]] $Bytes)
    $offset = if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { 3 } else { 0 }
    try { return [Text.UTF8Encoding]::new($false, $true).GetString($Bytes, $offset, $Bytes.Length - $offset) }
    catch [Text.DecoderFallbackException] { throw 'The draft is not valid UTF-8. Send it as UTF-8 text (PowerShell 7 pipes UTF-8; Windows PowerShell 5.1 does not).' }
}

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 Get-SpecrewReviewTargetLinks {
    # Every review link a message carries, as written (text) and as a local path (path; $null when it cannot be
    # resolved). ONE reader for the three places that need it - the packet validator, the meaning lines and the
    # reviewed-artifact memory (co-review round 3, 202): the validator accepted Markdown links to absolute paths while
    # the other two read only file:/// URLs, so such a link could be dropped from the reply unnoticed and a change to
    # its file never lifted suppression. Accepted: a bare file:/// URL (Rule 52's form), and a Markdown link whose
    # target is an absolute path - `C:\...` or `C:/...`, `/C:/...`, or `/...` - optionally in angle brackets.
    [OutputType([object[]])]
    param([AllowNull()][string] $Message)

    $links = [System.Collections.Generic.List[object]]::new()
    if ([string]::IsNullOrWhiteSpace($Message)) { return @() }
    # Co-review round 6 (202, FR-011): a path with parentheses ("app (draft)") was cut at the first ')', so the link
    # named a folder that does not exist, whose "missing" fingerprint never changes - an edit to the real file never
    # lifted suppression. Balanced parentheses belong to the path; an unbalanced ')' still ends it (a sentence's or a
    # Markdown link's closing parenthesis).
    foreach ($match in [regex]::Matches($Message, 'file:///(?:[^\s<>"''`\[\](){}|]|\([^\s<>"''`\[\](){}|]*\))+')) {
        $url = ([string]$match.Value).TrimEnd('.', ',', ';', ':', '!', '?', '*')
        $path = $null
        try { $path = [System.IO.Path]::GetFullPath([System.Uri]::new($url).LocalPath) } catch { $path = $null }
        $links.Add([pscustomobject]@{ text = $url; path = $path })
    }
    # An angle-wrapped destination runs to its '>' whatever it holds; a bare one keeps balanced parentheses.
    foreach ($match in [regex]::Matches($Message, '(?i)\]\(\s*(?:<(?<t>(?:[A-Z]:[/\\]|/[A-Z]:/|/)[^<>\r\n]*)>|(?<t>(?:[A-Z]:[/\\]|/[A-Z]:/|/)(?:[^()<>\r\n]|\([^()<>\r\n]*\))*?))\s*\)')) {
        $target = [string]$match.Groups['t'].Value
        $local = if ($target -match '^/[A-Za-z]:/') { $target.Substring(1) } else { $target }
        $path = $null
        try { $path = [System.IO.Path]::GetFullPath([System.Uri]::UnescapeDataString($local)) } catch { $path = $null }
        $links.Add([pscustomobject]@{ text = $target; path = $path })
    }
    return $links.ToArray()
}

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 (@(Get-SpecrewReviewTargetLinks -Message $sections['What Needs Your Review']).Count -eq 0) {
        $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) }
}

# FEATURE 202 T009 (FR-016, SC-005, B4F-107): ONE ADAPTATION SENTENCE ON EVERY HOST. What stood here rendered the
# profile's raw keys and numbers ("software_architecture=10, ...") and reached Codex that way, while Claude read
# the prose sentence the session orientation uses. The prose renderer in user-profile.ps1 is now the only one;
# the host adapter contributes only the correction reference (Get-SpecrewProfileCorrectionReference, beside it).
function Resolve-SpecrewModuleFile {
    # A file of the Specrew module tree, from wherever this script runs: the source tree or an installed module
    # (three levels above extensions/specrew-speckit/scripts), SPECREW_MODULE_PATH, a self-hosting project root,
    # or the newest installed module. $null when none has it.
    param([Parameter(Mandatory)][string] $RelativePath, [AllowNull()][string] $ProjectRoot)
    $roots = [System.Collections.Generic.List[string]]::new()
    try { $roots.Add([IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../..'))) } catch { $null = $_ }
    if (-not [string]::IsNullOrWhiteSpace($env:SPECREW_MODULE_PATH)) { $roots.Add($env:SPECREW_MODULE_PATH) }
    if (-not [string]::IsNullOrWhiteSpace($ProjectRoot)) { $roots.Add($ProjectRoot) }
    foreach ($candidateRoot in $roots) {
        $candidate = Join-Path $candidateRoot $RelativePath
        if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate }
    }
    try {
        $module = Get-Module -ListAvailable Specrew | Sort-Object Version -Descending |
            Where-Object { Test-Path -LiteralPath (Join-Path $_.ModuleBase $RelativePath) -PathType Leaf } | Select-Object -First 1
        if ($module) { return (Join-Path $module.ModuleBase $RelativePath) }
    }
    catch { $null = $_ }
    return $null
}

function Get-SpecrewOrientationAdaptationLine {
    # The shared sentence, or '' when no profile is set. Read through user-profile.ps1's own reader, which honours
    # SPECREW_USER_PROFILE_PATH, so every surface reads the one profile the human configured.
    param([AllowNull()][string] $HostKind, [AllowNull()][string] $ProjectRoot)
    try {
        if (-not (Get-Command Get-SpecrewProfileCorrectionReference -ErrorAction SilentlyContinue)) {
            $profileScript = Resolve-SpecrewModuleFile -RelativePath 'scripts/internal/user-profile.ps1' -ProjectRoot $ProjectRoot
            if (-not $profileScript) { return '' }
            . $profileScript
        }
        $line = Get-SpecrewProfileOrientationLine -CorrectionReference (Get-SpecrewProfileCorrectionReference -HostKind $HostKind)
        if ([string]::IsNullOrWhiteSpace([string]$line)) { return '' }
        return [string]$line
    }
    catch { return '' }
}

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

# PRESENTED-CROSSING MEMORY (Feature 202 T006, FR-011, DA-2). An unchanged pending crossing is owed in full
# ONCE per session, and again only after a context gap. What stood before keyed the obligation on the turn:
# every non-verdict turn of a session with a pending crossing re-armed the packet and ended in a Stop block -
# a side question, a wrong verdict and an orientation request each repainted it (B4F-104).
#
# THE RECORD IS PER SESSION AND PER CROSSING. It lives in the session's own turn-end directory, so another
# session never inherits it - a new session owes its own presentation. It names the crossing it was written
# for, so a different crossing finds nothing to suppress against.
#
# THE CONTEXT GAPS are exactly the agreed four, and HEAD movement is deliberately not one of them: the
# agent's own bookkeeping commits (drift entries, findings) are what repainted the packet in 202's own
# sessions. A gap is:
# - a changed crossing id (the record names another crossing);
# - a change to the artifacts under review (the files the presented packet linked);
# - SessionStart new/resume/compact (the session-start lane clears the record);
# - the long gap ($script:SpecrewPresentedCrossingLongGapHours).
#
# THE RECOVERY PATH STAYS OPEN (DRIFT-008, B4F-112). A typed reply that carried this crossing's approval
# phrase while it was pending, and after which the crossing is STILL pending, is a capture miss: the human
# answered and the answer did not record. Suppression would leave that human with no packet to answer, so a
# capture miss lifts it whatever the gap state.
$script:SpecrewPresentedCrossingLongGapHours = 8
$script:SpecrewPresentedCrossingFileName = 'presented-crossings.json'
$script:SpecrewSessionStartFileName = 'session-start.json'
$script:SpecrewPresentedArtifactLimit = 32
$script:SpecrewPresentedArtifactMaxBytes = 8MB

function Get-SpecrewPresentedCrossingPath {
    [OutputType([string])]
    param([Parameter(Mandatory)][string] $TurnEndRoot)
    return (Join-Path $TurnEndRoot $script:SpecrewPresentedCrossingFileName)
}

function Write-SpecrewTurnEndJsonFile {
    # The same atomic temp-and-move the turn records use, for the two small session records that are not
    # turn records (Read-SpecrewTurnEndRecord rightly refuses them: they carry no turn kind).
    [OutputType([bool])]
    param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)]$Value)

    $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, ($Value | ConvertTo-Json -Depth 8 -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-SpecrewTurnEndJsonFile {
    [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 }
        $value = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
        if ($null -eq $value -or $value -is [System.Array]) { return $null }
        return $value
    }
    catch { return $null }
}

function Clear-SpecrewPresentedCrossing {
    [OutputType([bool])]
    param([Parameter(Mandatory)][string] $TurnEndRoot)
    $path = Get-SpecrewPresentedCrossingPath -TurnEndRoot $TurnEndRoot
    try {
        if (Test-Path -LiteralPath $path -PathType Leaf) { Remove-Item -LiteralPath $path -Force -ErrorAction Stop }
        return $true
    }
    catch { return $false }
}

function Write-SpecrewSessionStart {
    # Called on every SessionStart this session receives. EVERY SessionStart is a context gap, so the
    # presented record is cleared whatever its kind; the kind (startup, resume, clear, compact) is kept only
    # so the next presented record can say which gap it followed.
    [OutputType([bool])]
    param([Parameter(Mandatory)][string] $TurnEndRoot, [AllowNull()][string] $Source)

    $kind = if ([string]::IsNullOrWhiteSpace($Source)) { 'unknown' } else { $Source.Trim().ToLowerInvariant() }
    $null = Clear-SpecrewPresentedCrossing -TurnEndRoot $TurnEndRoot
    return (Write-SpecrewTurnEndJsonFile -Path (Join-Path $TurnEndRoot $script:SpecrewSessionStartFileName) -Value ([pscustomobject][ordered]@{
                kind  = $kind
                at    = [DateTimeOffset]::UtcNow.ToString('o')
                at_ms = Get-SpecrewUtcNowMilliseconds
            }))
}

function Get-SpecrewReviewedArtifactState {
    # The artifacts under review are the files the presented packet LINKED (Rule 52's bare file:/// URLs):
    # the packet is where the human was told what to review. Each linked file is content-hashed. A linked
    # directory is a pointer to browse, not an artifact, so it is recorded but not hashed - hashing it would
    # turn every drift entry written inside an iteration folder into a "changed review target", which is the
    # bookkeeping-commit repaint by another route.
    [OutputType([object[]])]
    param([AllowNull()][string] $Message)

    $state = [System.Collections.Generic.List[object]]::new()
    if ([string]::IsNullOrWhiteSpace($Message)) { return @() }
    # Co-review round 6 (202): exact paths. Merging by case hid the second of two files differing only by case on a
    # case-sensitive filesystem; where case does not matter, both entries fingerprint the same file and change together.
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    foreach ($link in @(Get-SpecrewReviewTargetLinks -Message $Message)) {
        if ($state.Count -ge $script:SpecrewPresentedArtifactLimit) { break }
        $localPath = [string]$link.path
        if ([string]::IsNullOrWhiteSpace($localPath)) { continue }
        if (-not $seen.Add($localPath)) { continue }
        $state.Add((Get-SpecrewArtifactFingerprint -Path $localPath))
    }
    return $state.ToArray()
}

function Get-SpecrewArtifactFingerprint {
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][string] $Path)

    $entry = [ordered]@{ path = $Path; kind = 'missing'; sha256 = '' }
    try {
        if (Test-Path -LiteralPath $Path -PathType Container) { $entry.kind = 'directory' }
        elseif (Test-Path -LiteralPath $Path -PathType Leaf) {
            $item = Get-Item -LiteralPath $Path -ErrorAction Stop
            if ($item.Length -gt $script:SpecrewPresentedArtifactMaxBytes) {
                $entry.kind = 'file-large'
                $entry.sha256 = ('{0}:{1}' -f $item.Length, $item.LastWriteTimeUtc.Ticks)
            }
            else {
                $entry.kind = 'file'
                $entry.sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
            }
        }
    }
    catch { $entry.kind = 'unreadable' }
    return [pscustomobject]$entry
}

function Write-SpecrewPresentedCrossing {
    # Written only by the Stop that VERIFIED the presentation: a boundary declaration for this crossing whose
    # packet, approval line and marker all reached the visible reply. A declaration alone is not a
    # presentation, so nothing upstream of that check writes here.
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string] $TurnEndRoot,
        [Parameter(Mandatory)][string] $CrossingId,
        [AllowNull()][string] $ApprovalPhrase,
        [AllowNull()][string] $Marker,
        [AllowNull()][string] $Message
    )

    $sessionStart = Read-SpecrewTurnEndJsonFile -Path (Join-Path $TurnEndRoot $script:SpecrewSessionStartFileName)
    $sessionStartKind = if ($null -ne $sessionStart -and $sessionStart.PSObject.Properties['kind']) { [string]$sessionStart.kind } else { 'none-observed' }
    return (Write-SpecrewTurnEndJsonFile -Path (Get-SpecrewPresentedCrossingPath -TurnEndRoot $TurnEndRoot) -Value ([pscustomobject][ordered]@{
                schema_version          = '1.0'
                crossing_id             = $CrossingId
                approval_phrase         = [string]$ApprovalPhrase
                marker                  = [string]$Marker
                reviewed_artifact_state = @(Get-SpecrewReviewedArtifactState -Message $Message)
                presented_at            = [DateTimeOffset]::UtcNow.ToString('o')
                presented_ms            = Get-SpecrewUtcNowMilliseconds
                session_start_kind      = $sessionStartKind
                verdict_reply_at        = $null
            }))
}

function Test-SpecrewReplyCarriesApprovalPhrase {
    # Was this typed reply an ATTEMPT to approve the presented crossing? Case- and whitespace-insensitive, on any line:
    # a reply whose phrase sits on its own line after a pasted block is exactly the DRIFT-008 shape this has to see.
    #
    # Co-review round 3 (202): an attempt, not a mention. The phrase anywhere in the reply also matched "What does
    # 'approved for before-implement' mean?" and "I have not approved for before-implement yet", and the crossing,
    # rightly still pending, was then read as a missed capture: a repaint, and a false "your approval did not
    # register". So the phrase must LEAD its line (after an optional "yes," / "I"), as the verdict recognizer
    # anchors it, and the clause it opens must not be a question; instructions after a delimiter are still fine.
    [OutputType([bool])]
    param([AllowNull()][string] $Reply, [AllowNull()][string] $ApprovalPhrase)
    if ([string]::IsNullOrWhiteSpace($Reply) -or [string]::IsNullOrWhiteSpace($ApprovalPhrase)) { return $false }
    $words = @((($ApprovalPhrase -replace '\s+', ' ').Trim()) -split ' ' | ForEach-Object { [regex]::Escape($_) })
    $pattern = '(?i)^\s*(?:(?:yes|confirmed)\s*[,;:\-–—]\s*)?(?:(?:i|we)\s+)?' + ($words -join '\s+') + '(?![\w-])'
    # Co-review round 5 (202, FR-011): a quoted example is a mention too. A side question with the phrase inside a fenced
    # code block ("Explain this example:" then the block) or an indented one was read as an attempt, and the next Stop
    # said the approval did not register and repainted. Lines inside a CLOSED fence, and lines of an indented code block
    # (4+ spaces or a tab, after a blank line), are skipped. An unclosed fence hides nothing, and a line typed after a
    # closed block still counts - the DRIFT-008 / B4F-125 shape.
    $lines = @($Reply -split "`r?`n")
    $quoted = New-Object bool[] $lines.Count
    $open = -1; $marker = ''
    for ($i = 0; $i -lt $lines.Count; $i++) {
        $fence = [regex]::Match($lines[$i], '^\s{0,3}(```+|~~~+)')
        if (-not $fence.Success) { continue }
        if ($open -lt 0) { $open = $i; $marker = $fence.Groups[1].Value.Substring(0, 3) }
        elseif ($fence.Groups[1].Value.StartsWith($marker) -and $lines[$i].Trim() -match '^(```+|~~~+)$') {
            for ($j = $open; $j -le $i; $j++) { $quoted[$j] = $true }
            $open = -1; $marker = ''
        }
    }
    $inIndented = $false
    for ($i = 0; $i -lt $lines.Count; $i++) {
        if ([string]::IsNullOrWhiteSpace($lines[$i])) { $inIndented = $false; continue }
        $indented = $lines[$i] -match '^(?: {4}|\t)'
        $afterBlank = $i -eq 0 -or [string]::IsNullOrWhiteSpace($lines[$i - 1])
        if ($indented -and ($afterBlank -or $inIndented)) { $inIndented = $true; $quoted[$i] = $true } else { $inIndented = $false }
    }
    for ($i = 0; $i -lt $lines.Count; $i++) {
        if ($quoted[$i]) { continue }
        $line = $lines[$i]
        $match = [regex]::Match($line, $pattern)
        if (-not $match.Success) { continue }
        $clause = ([regex]::Split($line.Substring($match.Length), '[,.;:]|\s[-–—]\s', 2))[0]
        if (-not $clause.Contains('?')) { return $true }
    }
    return $false
}

function Register-SpecrewPresentedCrossingReply {
    # Prompt time, typed replies only. Records THAT a reply carried the presented crossing's approval phrase.
    # Whether it was a miss is decided at Stop, where the crossing is either still pending (the capture did
    # not record it) or gone (it did).
    [OutputType([bool])]
    param([Parameter(Mandatory)][string] $TurnEndRoot, [AllowNull()][string] $Reply)

    $path = Get-SpecrewPresentedCrossingPath -TurnEndRoot $TurnEndRoot
    $record = Read-SpecrewTurnEndJsonFile -Path $path
    if ($null -eq $record -or -not $record.PSObject.Properties['approval_phrase']) { return $false }
    if (-not (Test-SpecrewReplyCarriesApprovalPhrase -Reply $Reply -ApprovalPhrase ([string]$record.approval_phrase))) { return $false }
    $record.verdict_reply_at = [DateTimeOffset]::UtcNow.ToString('o')
    return (Write-SpecrewTurnEndJsonFile -Path $path -Value $record)
}

function Get-SpecrewPresentedCrossingDecision {
    # Does this session's earlier presentation still cover the pending crossing? suppress is $true only for a
    # crossing that was presented, is unchanged and had no capture miss. Otherwise the reason names the gap,
    # so the journal says WHY the packet was owed again.
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string] $TurnEndRoot,
        [Parameter(Mandatory)][string] $CrossingId,
        [Nullable[DateTimeOffset]] $Now
    )

    $nowValue = if ($null -ne $Now) { [DateTimeOffset]$Now } else { [DateTimeOffset]::UtcNow }
    $record = Read-SpecrewTurnEndJsonFile -Path (Get-SpecrewPresentedCrossingPath -TurnEndRoot $TurnEndRoot)
    if ($null -eq $record -or -not $record.PSObject.Properties['crossing_id']) { return [pscustomobject]@{ suppress = $false; reason = 'not-presented' } }
    if ([string]$record.crossing_id -cne $CrossingId) { return [pscustomobject]@{ suppress = $false; reason = 'crossing-changed' } }
    if ($record.PSObject.Properties['verdict_reply_at'] -and -not [string]::IsNullOrWhiteSpace([string]$record.verdict_reply_at)) {
        return [pscustomobject]@{ suppress = $false; reason = 'capture-miss' }
    }
    $presentedAt = if ($record.PSObject.Properties['presented_ms']) { ConvertTo-SpecrewUtcTimestamp -Value $record.presented_ms } else { $null }
    if ($null -eq $presentedAt -and $record.PSObject.Properties['presented_at']) { $presentedAt = ConvertTo-SpecrewUtcTimestamp -Value $record.presented_at }
    if ($null -eq $presentedAt) { return [pscustomobject]@{ suppress = $false; reason = 'record-unreadable' } }
    if (($nowValue - [DateTimeOffset]$presentedAt).TotalHours -ge $script:SpecrewPresentedCrossingLongGapHours) {
        return [pscustomobject]@{ suppress = $false; reason = 'long-gap' }
    }
    foreach ($entry in @($record.reviewed_artifact_state)) {
        if ($null -eq $entry -or -not $entry.PSObject.Properties['path']) { continue }
        if ([string]$entry.kind -eq 'directory') { continue }
        $current = Get-SpecrewArtifactFingerprint -Path ([string]$entry.path)
        if ([string]$current.kind -cne [string]$entry.kind -or [string]$current.sha256 -cne [string]$entry.sha256) {
            return [pscustomobject]@{ suppress = $false; reason = 'reviewed-artifacts-changed' }
        }
    }
    return [pscustomobject]@{ suppress = $true; reason = 'presented-unchanged' }
}

# THE MEANING-LINE SET (Feature 202 T007, FR-012, FR-015, B4F-100). "The draft reached the reply" is checked over the
# lines that carry meaning, declared once here and used by both declare-turn-end.ps1 and the conformance Stop:
# - headings;
# - review-target links (each file:/// URL);
# - the question line (a line that asks the human something);
# - the approval line and the verdict options (the typed responses offered);
# - the marker.
# What stood before required the WHOLE draft, whitespace-normalized, inside the reply. Tightening one sentence after
# the draft was saved then blocked the Stop and repainted the whole message (B4F-100, twice in 202's own intake).
# Wording between the meaning lines is the author's to change; the lines themselves are what the human must see.
function Get-SpecrewTurnMeaningLines {
    [OutputType([string[]])]
    param([AllowNull()][string] $Message)

    $lines = [System.Collections.Generic.List[string]]::new()
    if ([string]::IsNullOrWhiteSpace($Message)) { return @() }
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    $add = {
        param([string]$Text)
        $normalized = ($Text -replace '\s+', ' ').Trim()
        if ($normalized.Length -gt 0 -and $seen.Add($normalized)) { $lines.Add($normalized) }
    }
    foreach ($raw in ($Message -split "`r?`n")) {
        $line = $raw.Trim()
        if ($line.Length -eq 0) { continue }
        if ($line -match '^#{1,6}\s+\S') { & $add $line; continue }                                             # heading
        if ($line -match '^<!--\s*SPECREW-VERDICT-BOUNDARY:.*-->$') { & $add $line; continue }                  # marker
        # Approval line and verdict options. Co-review round 6 (202, FR-015): an option written as a bullet, a quote, a
        # numbered item, inline code or a bold label was not a meaning line, so a reply could drop it. The wrapper is
        # formatting: the option inside it is the meaning line, which a reply that keeps the option carries either way.
        $core = $line
        do { $before = $core; $core = $core -replace '^(?:>\s*|[-*+]\s+|\d+[.)]\s+)', '' } while ($core -ne $before)
        $core = $core -replace '^(?:`+|\*\*|__|\*|_)', ''
        if ($core -match '(?i)^(?:approved for\b|changes needed\s*:|discuss prompt\b)') { & $add ($core -replace '(?:`+|\*\*|__)\s*$', ''); continue }
        # The question line: any line that asks something - a '?' ending a sentence, even when an instruction follows
        # it on the same line ("Does this match? Type move on."). A '?' inside a URL query does not count.
        if ($line -match '\?(?:["''\)\]\*_]*)(?:\s|$)') { & $add $line }
        foreach ($link in @(Get-SpecrewReviewTargetLinks -Message $line)) { & $add ([string]$link.text) }      # review-target links
    }
    return $lines.ToArray()
}

# A HEADING IS PRESENT WHEN ITS MEANING IS (202 DRIFT-030). The first live run of this check with stdin drafts blocked a
# reply whose two headings differed from the draft by a function word ("now as one budget" / "now one budget"), and the
# whole message was rendered again - the B4F-104 repaint class, which FR-012 removed. A heading's meaning is its numbers
# and identifiers, its negations, and its content words: all three must hold, so "Round 3" never answers for "Round 4",
# and "does not hold" never answers for "holds". Content words may differ by small wording (Jaccard 0.75 or more).
# Links, questions, options and the marker stay exact: they are what the human acts on.
$script:SpecrewHeadingFunctionWords = [System.Collections.Generic.HashSet[string]]::new([string[]]@(
        'a', 'an', 'the', 'and', 'or', 'as', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'now', 'of', 'to', 'in',
        'on', 'at', 'for', 'by', 'with', 'from', 'into', 'it', 'its', 'this', 'that', 'these', 'those', 'so', 'then',
        'than', 'also', 'just', 'both', 'all', 'here', 'there'), [System.StringComparer]::OrdinalIgnoreCase)
$script:SpecrewHeadingNegations = [System.Collections.Generic.HashSet[string]]::new([string[]]@(
        'not', 'no', 'never', 'without', 'nothing', 'none', 'cannot', 'neither', 'nor', 'unless', 'dont', 'doesnt', 'isnt',
        'arent', 'wont', 'cant', 'didnt', 'wasnt', 'werent', 'hasnt', 'havent'), [System.StringComparer]::OrdinalIgnoreCase)

function Get-SpecrewHeadingMeaning {
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][string] $Heading)
    $text = ($Heading -replace '^\s*#{1,6}\s+', '').ToLowerInvariant() -replace "['’]", ''
    $anchors = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    $negations = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    $words = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    foreach ($m in [regex]::Matches($text, '[\p{L}\p{N}]+(?:[-./][\p{L}\p{N}]+)*')) {
        $token = $m.Value
        if ($token -match '\d') { [void]$anchors.Add($token) }
        elseif ($script:SpecrewHeadingNegations.Contains($token)) { [void]$negations.Add($token) }
        elseif (-not $script:SpecrewHeadingFunctionWords.Contains($token)) { [void]$words.Add($token) }
    }
    return [pscustomobject]@{ Anchors = $anchors; Negations = $negations; Words = $words }
}

function Test-SpecrewHeadingMeaningMatch {
    [OutputType([bool])]
    param([Parameter(Mandatory)][string] $Declared, [Parameter(Mandatory)][string] $Replied)
    $d = Get-SpecrewHeadingMeaning -Heading $Declared
    $r = Get-SpecrewHeadingMeaning -Heading $Replied
    if (-not $d.Anchors.SetEquals($r.Anchors)) { return $false }
    if (-not $d.Negations.SetEquals($r.Negations)) { return $false }
    if ($d.Words.Count -eq 0 -and $r.Words.Count -eq 0) { return $true }
    $union = [System.Collections.Generic.HashSet[string]]::new($d.Words, [System.StringComparer]::Ordinal)
    $union.UnionWith($r.Words)
    $common = [System.Collections.Generic.HashSet[string]]::new($d.Words, [System.StringComparer]::Ordinal)
    $common.IntersectWith($r.Words)
    return (($common.Count / [double]$union.Count) -ge 0.75)
}

# A DISCUSSION QUESTION IS PRESENT WHEN ITS MEANING IS (202 iteration 002, T019, FR-030; B4F-128). The review sign-off
# packet of 2026-10-16: the reply reworded discussion prompt 1 ("Does closing with B4F-127 open fit the consequence
# grading, ..." became "Is closing with B4F-127 open consistent with how you grade defects by consequence, ..."), the
# Stop counted it missing, and the whole packet was rendered again - the FR-012 repaint class on a line kind the heading
# match did not reach. The same three tests as a heading, tuned for a sentence: its numbers and identifiers, and its
# negations, must be identical; its content words, lightly stemmed ("grading" and "grade" are one word) and without the
# words any question carries, must largely overlap - nearly all of the shorter question's words in the longer one
# (0.8), and half of all the words in common (0.5). Options, links and the marker stay exact.
$script:SpecrewQuestionFunctionWords = [System.Collections.Generic.HashSet[string]]::new([string[]]@(
        'do', 'does', 'did', 'how', 'what', 'which', 'who', 'whom', 'whose', 'when', 'where', 'why', 'whether', 'you', 'your',
        'we', 'our', 'i', 'my', 'me', 'us', 'should', 'would', 'could', 'can', 'will', 'shall', 'may', 'might', 'must',
        'given', 'since', 'because', 'before', 'after', 'while', 'if', 'about', 'any', 'some', 'there'), [System.StringComparer]::OrdinalIgnoreCase)
# Co-review round 6 (iteration 002): the order words are carried by any question, but not interchangeably. 'before' and
# 'after' were both dropped, so 'Should the migration run before deployment?' and '... after deployment?' read as one
# question. A rewording may add or drop one ("the interim launch check" became "the interim check before launches" in
# B4F-128's reply); a question that has one where the other has its opposite is a changed question.
$script:SpecrewQuestionOrderOpposites = @(@('before', 'after'), @('earlier', 'later'), @('first', 'last'))

function Get-SpecrewQuestionMeaning {
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][string] $Question)
    $text = ($Question -replace '^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+', '').ToLowerInvariant() -replace "['’]", ''
    $last = $text.LastIndexOf('?')
    if ($last -ge 0) { $text = $text.Substring(0, $last) }
    $anchors = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    $negations = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    $words = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    $order = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    foreach ($m in [regex]::Matches($text, '[\p{L}\p{N}]+(?:[-./][\p{L}\p{N}]+)*')) {
        $token = $m.Value
        foreach ($pair in $script:SpecrewQuestionOrderOpposites) { if ($pair -contains $token) { [void]$order.Add($token) } }
        if ($token -match '\d') { [void]$anchors.Add($token); continue }
        if ($script:SpecrewHeadingNegations.Contains($token)) { [void]$negations.Add($token); continue }
        if ($script:SpecrewHeadingFunctionWords.Contains($token) -or $script:SpecrewQuestionFunctionWords.Contains($token)) { continue }
        $stem = $token
        foreach ($suffix in @('ing', 'es', 'ed', 's')) {
            if ($stem.Length -gt ($suffix.Length + 3) -and $stem.EndsWith($suffix)) { $stem = $stem.Substring(0, $stem.Length - $suffix.Length); break }
        }
        if ($stem.Length -gt 4 -and $stem.EndsWith('e')) { $stem = $stem.Substring(0, $stem.Length - 1) }
        [void]$words.Add($stem)
    }
    return [pscustomobject]@{ Anchors = $anchors; Negations = $negations; Words = $words; Order = $order }
}

function Test-SpecrewQuestionMeaningMatch {
    [OutputType([bool])]
    param([Parameter(Mandatory)][string] $Declared, [Parameter(Mandatory)][string] $Replied)
    $d = Get-SpecrewQuestionMeaning -Question $Declared
    $r = Get-SpecrewQuestionMeaning -Question $Replied
    if (-not $d.Anchors.SetEquals($r.Anchors)) { return $false }
    if (-not $d.Negations.SetEquals($r.Negations)) { return $false }
    foreach ($pair in $script:SpecrewQuestionOrderOpposites) {
        $dOrder = @($pair | Where-Object { $d.Order.Contains($_) })
        $rOrder = @($pair | Where-Object { $r.Order.Contains($_) })
        if ($dOrder.Count -gt 0 -and $rOrder.Count -gt 0 -and (($dOrder -join ' ') -cne ($rOrder -join ' '))) { return $false }
    }
    if ($d.Words.Count -eq 0 -or $r.Words.Count -eq 0) { return $false }
    $common = [System.Collections.Generic.HashSet[string]]::new($d.Words, [System.StringComparer]::Ordinal)
    $common.IntersectWith($r.Words)
    $union = [System.Collections.Generic.HashSet[string]]::new($d.Words, [System.StringComparer]::Ordinal)
    $union.UnionWith($r.Words)
    $smaller = [Math]::Min($d.Words.Count, $r.Words.Count)
    return ((($common.Count / [double]$smaller) -ge 0.8) -and (($common.Count / [double]$union.Count) -ge 0.5))
}

function Get-SpecrewTurnMeaningLinesMissing {
    # The declared meaning lines the reply does not carry, in draft order. Empty when every one is present - the
    # wording around them may differ freely. Whitespace is normalized on both sides, as the host accessors do. A heading
    # absent verbatim is still present when a reply heading carries its meaning (above); each reply heading answers for
    # one declared heading at most.
    [OutputType([string[]])]
    param([AllowNull()][string] $Reply, [AllowNull()][string[]] $MeaningLines)

    $replyText = ([string]$Reply -replace '\s+', ' ').Trim()
    # The host accessors hand the Stop a FLATTENED reply (whitespace normalized to single spaces), so reply headings
    # are found by their '#' markers, not by line: each marker starts a slot holding the words up to the next marker.
    $slots = [System.Collections.Generic.List[object]]::new()
    $markers = @([regex]::Matches($replyText, '(?:^|(?<=\s))(#{1,6})\s+(?=\S)'))
    for ($i = 0; $i -lt $markers.Count; $i++) {
        $start = $markers[$i].Index + $markers[$i].Length
        $end = if ($i + 1 -lt $markers.Count) { $markers[$i + 1].Index } else { $replyText.Length }
        $slots.Add([pscustomobject]@{ Hashes = $markers[$i].Groups[1].Value; Words = @($replyText.Substring($start, $end - $start).Trim() -split ' ' | Where-Object { $_ }) ; Used = $false })
    }
    $missing = [System.Collections.Generic.List[string]]::new()
    $pendingHeadings = [System.Collections.Generic.List[string]]::new()
    $pendingQuestions = [System.Collections.Generic.List[string]]::new()
    # T019: the reply's questions, from the flattened text - each sentence that ends in '?', without its list marker.
    $replyQuestions = [System.Collections.Generic.List[object]]::new()
    # Co-review round 5 (iteration 002): a period inside a version or a file name ('1.2', 'plan.md') is not the end of a
    # sentence, so the question keeps its identifier and its anchor can match.
    foreach ($q in [regex]::Matches($replyText, '(?:[^?!.#]|\.(?=\S))+\?')) {
        $qText = ($q.Value -replace '^\s*(?:>\s*|[-*+]\s+|\d+[.)]\s+)+', '').Trim()
        if ($qText.Length -gt 0) { $replyQuestions.Add([pscustomobject]@{ Text = $qText; Used = $false }) }
    }
    foreach ($line in @($MeaningLines)) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        if ($line -match '^#{1,6}\s+\S') {
            if ($replyText.Contains($line, [StringComparison]::Ordinal)) {
                # Claim the slot this heading occupies verbatim, so no reworded match can take it.
                $words = @(($line -replace '^#{1,6}\s+', '') -split ' ')
                foreach ($slot in $slots) {
                    if (-not $slot.Used -and $slot.Words.Count -ge $words.Count -and (($slot.Words[0..($words.Count - 1)]) -join ' ') -ceq ($words -join ' ')) { $slot.Used = $true; break }
                }
                continue
            }
            $pendingHeadings.Add($line)
            continue
        }
        if ($replyText.Contains($line, [StringComparison]::Ordinal)) {
            # A question present verbatim claims its reply question, so no reworded match can take it.
            foreach ($rq in $replyQuestions) { if (-not $rq.Used -and $line.Contains($rq.Text, [StringComparison]::Ordinal)) { $rq.Used = $true; break } }
            continue
        }
        $optionCore = $line -replace '^(?:>\s*|[-*+]\s+|\d+[.)]\s+)+', '' -replace '^(?:`+|\*\*|__|\*|_)', ''
        $isQuestion = ($line -match '\?(?:["''\)\]\*_]*)(?:\s|$)') -and ($line -notmatch '^<!--') -and ($optionCore -notmatch '(?i)^(?:approved for\b|changes needed\s*:|discuss prompt\b)')
        if ($isQuestion) { $pendingQuestions.Add($line) } else { $missing.Add($line) }
    }
    foreach ($line in $pendingQuestions) {
        $hitQ = $null
        foreach ($rq in $replyQuestions) {
            if ($rq.Used) { continue }
            if (Test-SpecrewQuestionMeaningMatch -Declared $line -Replied $rq.Text) { $hitQ = $rq; break }
        }
        if ($null -ne $hitQ) { $hitQ.Used = $true } else { $missing.Add($line) }
    }
    # A reworded heading matches a slot when the slot's first k words carry its meaning, for k within a few words of the
    # declared heading's length (the slot also holds the prose that follows the heading).
    $reworded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    foreach ($line in $pendingHeadings) {
        $n = @(($line -replace '^#{1,6}\s+', '') -split ' ' | Where-Object { $_ }).Count
        $hit = $null
        foreach ($slot in $slots) {
            if ($slot.Used) { continue }
            for ($k = [Math]::Max(1, $n - 3); $k -le [Math]::Min($slot.Words.Count, $n + 3); $k++) {
                if (Test-SpecrewHeadingMeaningMatch -Declared $line -Replied (($slot.Words[0..($k - 1)]) -join ' ')) { $hit = $slot; break }
            }
            if ($null -ne $hit) { break }
        }
        if ($null -ne $hit) { $hit.Used = $true } else { [void]$reworded.Add($line) }
    }
    # Draft order: rebuild the list with the unmatched headings in their places.
    $ordered = [System.Collections.Generic.List[string]]::new()
    foreach ($line in @($MeaningLines)) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        if ($reworded.Contains($line) -or $missing.Contains($line)) { $ordered.Add($line) }
    }
    return $ordered.ToArray()
}