scripts/internal/continuous-co-review/hook-health-receipt.ps1

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

# F-198 Iteration 005 / T038 (FR-053) + T036 (FR-051): minimum hook-health EVIDENCE, the doctor/status
# health renderer, the Codex untrusted-headless governance PREFLIGHT, and the Stop-gate fail-open guard.
#
# WHY THIS FILE EXISTS (FR-053): a DEPLOYED hook configuration is NOT proof the host loaded and fired it. The
# only durable proof is a RECEIPT written from a REAL host-triggered SessionStart/Stop invocation. This module
# is the SINGLE source of truth for writing, reading, and CLASSIFYING that receipt-based health - plus the two
# consumers Beta2 needs: the doctor/status renderer and the Codex headless preflight.
#
# F-184 FOOTPRINT: NONE. This is a NEW, non-protected leaf module under the CCR internal location - the same
# pattern as T035's host-support-tier.ps1: the protected doctor/status surface (scripts/specrew-hooks.ps1) and
# the protected inspector (scripts/internal/specrew-hook-health.ps1) can CALL Format-SpecrewHookHealthReport /
# Resolve-SpecrewHookHealth here without either protected file being edited. Pure I/O + string building.
# No function here collides with the protected specrew-hook-health.ps1 surface (distinct receipt-scoped names).
#
# THE MODEL (Prop-145, maintainer decision 2026-07-14) - TWO INDEPENDENT concepts, never one overloaded status:
# HOOK LIVENESS (hook_status): whether the configured hook path was recently OBSERVED firing. This is MONITORING
# evidence, NOT authenticated - the receipt store is project-writable and the dispatcher can be invoked directly,
# so a same-user process could write a receipt. `healthy` here is OPERATIONAL confidence, never proof of the host
# process; do not describe a receipt as authenticated or as something a PATH shim / same-user process cannot
# produce. Values: healthy (fresh + well-formed lifecycle receipt) | stale | malformed (incl. a pre-v3 receipt
# missing version_source, wrong adapter contract) | conflicting (SessionStart receipts / host field disagree) |
# absent (no receipt).
# VERSION DIAGNOSTIC (version_status + version_source): a NON-AUTHORITATIVE, NON-PROMOTING ambient PATH-resolved
# `--version` reading. It NEVER changes hook_status or readiness. A 'diagnostic-match' means only that the
# SessionStart reading and a later probe resolved EQUIVALENT reported versions through the ambient command
# binding. Values: diagnostic-match | diagnostic-drift | unavailable | untrusted-source; source ambient-path-binding.
# A version probe failure leaves version_status 'unavailable' but does NOT erase valid hook liveness.
# No strong executable identity is available on current host contracts, so none is claimed. The observed version is
# a BOUNDED SessionStart reading (the host CLI's own --version), NEVER an env value.
#
# THE CODEX TRUST GATE (FR-051, maintainer decision 2026-07-14) = NO-PERSISTENT-MUTATION. Codex OWNS its trust
# decision. Specrew NEVER writes ~/.codex, NEVER seeds a trusted_hash, NEVER passes --dangerously-bypass-hook-trust
# as a general solution. The supported flow: Specrew configures the hook -> interactive Codex shows its NATIVE
# trust prompt -> the user approves -> a REAL hook fire records a health RECEIPT -> a HEADLESS `codex exec` proceeds
# as governed ONLY when Test-SpecrewCodexHeadlessGovernanceReady confirms that receipt is CURRENT. No current
# receipt -> NOT ready -> fail/degrade with the actionable instruction; NEVER silently continue as governed.
# (Evidence: iterations/005/evidence/codex-stop-contract-characterization.md - untrusted headless hooks are
# SILENTLY skipped; and malformed hook stdout SILENTLY fails open. Both are the dominant real-world risks.)

# ------------------------------------------------------------------------------------------------------------
# Versioned contract + closed sets
# ------------------------------------------------------------------------------------------------------------

# The Specrew-owned ADAPTER CONTRACT VERSION stamped into every receipt. Bump this when the receipt shape or the
# host adapter's hook contract changes - a receipt written under a DIFFERENT value is contract drift (the reader
# rejects it), so an old receipt can never masquerade as current evidence across a contract change.
# v3 (F-198 iter-005, Prop-145 amendment 2026-07-14): the receipt now records a version_source classification and
# SEPARATES two concepts - HOOK-LIVENESS evidence (host/surface/event/timestamp: the configured hook path was
# OBSERVED firing) from the VERSION DIAGNOSTIC (observed_host_version + version_source='ambient-path-binding': a
# bounded PATH-binding `--version` reading that is NON-AUTHORITATIVE and NEVER promotes health/readiness). Every
# pre-v3 receipt lacks version_source, so it fails the field-set check as malformed and is retired.
$script:SpecrewHookHealthAdapterContractVersion = 3

# Beta2 is CLI-first (FR-050). Receipts describe the CLI surface unless a caller overrides it.
$script:SpecrewHookHealthSurface = 'cli'

# Freshness bound (hours). A receipt older than this is STALE hook-liveness: the configured hook path was observed
# firing THEN, not NOW. This is also how hook-removal / trust-revocation surfaces - no new fire ages the last
# receipt out. Overridable per call so a governed headless run can demand a tighter window.
$script:SpecrewHookHealthDefaultFreshnessHours = 24

# Clock-skew tolerance for FUTURE-dated receipts (review finding f2, run 20260714T172315119): a receipt
# whose timestamp is ahead of now by more than this is NOT plausible clock skew - it is a malformed (or
# tampered, the store is project-writable) record that would otherwise read 'healthy' until its future
# instant plus the freshness window. Beyond the tolerance -> MALFORMED, never healthy/ready.
$script:SpecrewHookHealthClockSkewToleranceMinutes = 5

# The EXACT receipt field set. Sanitized BY CONSTRUCTION: the writer builds a receipt field-by-field from ONLY
# these keys, and the reader REJECTS any receipt whose key-set differs (missing OR extra) as MALFORMED. There is no
# code path by which a prompt, a command argument, an environment value, or a secret can enter a receipt.
# host/surface/event/timestamp = HOOK-LIVENESS evidence; observed_host_version + version_source = VERSION DIAGNOSTIC.
$script:SpecrewHookHealthReceiptFields = @(
    'host'
    'surface'
    'event'
    'observed_host_version'
    'version_source'
    'timestamp'
    'adapter_contract_version'
)

# Closed set: HOOK-LIVENESS status - whether the configured hook path was recently OBSERVED firing. This is
# MONITORING evidence, NOT authenticated: the receipt store is project-writable and the dispatcher can be invoked
# directly, so a same-user process could write a receipt. `healthy` here is operational confidence, never proof of
# the host process. Do not describe a receipt as authenticated or as something a PATH shim/same-user process cannot
# produce.
$script:SpecrewHookLivenessStatusSet = @('healthy', 'stale', 'malformed', 'conflicting', 'absent')

# Closed set: VERSION DIAGNOSTIC status. NEVER promotes hook-liveness or readiness. A 'diagnostic-match' means only
# that SessionStart and the current probe resolved EQUIVALENT reported versions through the AMBIENT command binding.
$script:SpecrewHookVersionStatusSet = @('diagnostic-match', 'diagnostic-drift', 'unavailable', 'untrusted-source')

# The only recognized version-diagnostic source: an ambient PATH-resolved `--version` reading (non-authoritative).
$script:SpecrewHookVersionSource = 'ambient-path-binding'

# Recognized Stop-gate BLOCK envelopes (must mirror the dispatcher's StopBlockShape map + the observed FR-051
# contract): decision:block (claude/codex/copilot), decision:continue (antigravity), followup_message (cursor).
$script:SpecrewHookHealthAcceptedBlockDecisions = @('block', 'continue')

function Get-SpecrewHookHealthAdapterContractVersion {
    # The current Specrew hook-health adapter contract version (the value stamped into new receipts).
    return $script:SpecrewHookHealthAdapterContractVersion
}

function Get-SpecrewHookHealthReceiptFields {
    # The EXACT allowed receipt field set (the sanitization allow-list).
    return @($script:SpecrewHookHealthReceiptFields)
}

function Get-SpecrewHookLivenessStatusSet {
    # The closed HOOK-LIVENESS status set (healthy | stale | malformed | conflicting | absent).
    return @($script:SpecrewHookLivenessStatusSet)
}

function Get-SpecrewHookVersionStatusSet {
    # The closed VERSION-DIAGNOSTIC status set (diagnostic-match | diagnostic-drift | unavailable | untrusted-source).
    return @($script:SpecrewHookVersionStatusSet)
}

# ------------------------------------------------------------------------------------------------------------
# Host CLI version probe (Prop-145 amendment) - a NON-AUTHORITATIVE, NON-PROMOTING version DIAGNOSTIC
# ------------------------------------------------------------------------------------------------------------
#
# WHY (Prop-145, maintainer decision 2026-07-14): no supported host contract exposes a way to identify the host
# executable/version that a project could not also influence - the probe can only resolve the host command through
# the AMBIENT PATH. So this probe is a DIAGNOSTIC, labeled source 'ambient-path-binding': the `--version` a project
# could equally influence by prepending a shim. It is NON-AUTHORITATIVE and NEVER promotes hook-liveness or
# readiness. Both the WRITE side (the dispatcher, at SessionStart ONLY) and the READ side (the doctor's per-host
# boundary) obtain it through THIS one probe: resolve the host command on PATH, run its fixed host-declared version
# argument SHELL-SAFE + cross-platform (a native binary or shebang script is exec'd DIRECTLY with an argument vector
# - shell-free on every OS; a Windows .cmd/.bat shim, the only interpreter-mediated case, uses the System32 cmd.exe
# with an injection-guarded path), byte-cap the output (bounds memory), and NORMALIZE stdout to the tool's
# self-reported version line. A 'match' between the SessionStart reading and a later probe means only that both
# resolved EQUIVALENT reported versions through the ambient command binding. Any failure (no spec, unresolved
# executable, launch failure, timeout, non-zero exit, oversized/empty/ambiguous/malformed output) fails CLOSED to
# 'unknown'. No user path and no env value is ever persisted.

# The CLI-first gated hosts (FR-050) and the fixed, single version argument each exposes. An unlisted host has no
# probe spec, so its probe fails to 'unknown' (unverified) - support is never assumed.
$script:SpecrewHostVersionProbeSpec = @{
    claude  = @{ command = 'claude';  args = @('--version') }
    codex   = @{ command = 'codex';   args = @('--version') }
    copilot = @{ command = 'copilot'; args = @('--version') }
}

# BYTE cap for a --version probe's captured stdout AND stderr - bounds MEMORY, not just time (Prop-145 finding 1;
# re-anchored to ENCODED BYTES per review finding f2, run 20260714T182921446: a char-counted cap let multibyte
# output consume ~3x the contract's 8 KB before tripping). A real `--version` prints a few bytes; anything past
# this cap is a misbehaving / hostile process, so the probe FAILS CLOSED (-> unknown -> unverified). The pipe
# keeps draining past the cap (discarded) so the child never blocks; NOTHING is written to disk.
$script:SpecrewHostVersionProbeMaxOutputBytes = 8192

function Get-SpecrewHostVersionProbeSpec {
    # The probe spec for a host key (a COPY), or $null if the host has no spec. Read-only accessor.
    param([Parameter(Mandatory)][string]$HostName)
    $key = ConvertTo-SpecrewHookHealthToken -Value $HostName
    if (-not $script:SpecrewHostVersionProbeSpec.ContainsKey($key)) { return $null }
    $spec = $script:SpecrewHostVersionProbeSpec[$key]
    return [pscustomobject]@{ host = $key; command = [string]$spec.command; args = @($spec.args) }
}

function ConvertTo-SpecrewNormalizedVersionLine {
    # Normalize raw `--version` stdout to a single, comparison-stable version line (the tool's self-reported
    # identity+version). Rules: drop blank lines; require EXACTLY ONE line bearing a dotted-numeric token
    # (\d+\.\d+...) - zero = malformed, more than one = AMBIGUOUS - then return that line sanitized (control chars
    # stripped, whitespace collapsed, printable-ASCII, capped). Empty string on ANY failure. Applied IDENTICALLY on
    # the write (SessionStart) and read (doctor/preflight) sides, so the same CLI yields the same token on both and
    # a match is meaningful. Ambiguity fails CLOSED to '' (-> unknown -> unverified) - never a guessed version.
    param([AllowNull()][string]$Stdout)
    if ([string]::IsNullOrWhiteSpace($Stdout)) { return '' }
    $lines = @($Stdout -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
    if ($lines.Count -eq 0) { return '' }
    $versionLines = @($lines | Where-Object { $_ -match '\d+\.\d+' })
    if ($versionLines.Count -ne 1) { return '' }   # 0 -> malformed, >1 -> ambiguous; both fail closed
    $line = (($versionLines[0] -replace '[\x00-\x1F\x7F]+', ' ') -replace '\s{2,}', ' ').Trim()
    if ($line.Length -gt 80) { $line = $line.Substring(0, 80).Trim() }
    if ($line -notmatch '^[\x20-\x7E]+$' -or $line -notmatch '\d+\.\d+') { return '' }
    return $line
}

function Invoke-SpecrewBoundedVersionProcess {
    # Run a RESOLVED executable with a fixed argument VECTOR, bounded by a timeout, capturing stdout. SHELL-SAFE
    # (FR-053a) + CROSS-PLATFORM:
    # * A NATIVE executable image (Windows .exe) OR ANY POSIX binary / shebang script (Linux/macOS) is invoked
    # DIRECTLY (UseShellExecute=$false, FileName=the resolved path, args as an ArgumentList VECTOR). The OS
    # execs it - a shebang is honored by the KERNEL, never by a shell WE spawn - so there is NO shell process in
    # the invocation and NO shell-string is built. This is the ONLY path on non-Windows, and the path Windows
    # .exe hosts (codex/claude here) take: genuinely shell-free.
    # * A Windows .cmd/.bat shim (e.g. an npm-installed CLI) is the ONE case the OS cannot exec directly - only
    # cmd.exe interprets it - and it is WINDOWS-ONLY (guarded on $onWindows; a POSIX shim is a shebang script the
    # kernel execs above). It is hardened on BOTH the interpreter and the argument:
    # - TRUSTED INTERPRETER: cmd.exe is resolved from the OS system directory ([Environment]::SystemDirectory,
    # the Win32 GetSystemDirectory) - NOT the mutable %ComSpec%/%SystemRoot% env vars and NOT PATH - so a
    # caller controlling the inherited environment cannot substitute an arbitrary executable; fail-closed if
    # the trusted cmd.exe is absent.
    # - INJECTION-SAFE ARGUMENT: the resolved shim path is REFUSED if it bears any cmd expansion/operator
    # metacharacter (%, !, &, ^, |, <, >, ") - a legitimate install path never has one - so no untrusted
    # content reaches the interpreter, and the command line is `/d /c "<refused-safe path>" <fixed args>`.
    # No untrusted input reaches a shell and the interpreter is trusted; both surfaces are FALSIFIED by test.
    # Returns { ok; stdout; problem }. Never throws; a hung probe is killed (tree) on timeout.
    param(
        [Parameter(Mandatory)][string]$ExecutablePath,
        [string[]]$Arguments = @('--version'),
        [int]$TimeoutSeconds = 6
    )
    $out = [ordered]@{ ok = $false; stdout = ''; problem = $null }
    $proc = $null
    try {
        $onWindows = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)
        $psi = [System.Diagnostics.ProcessStartInfo]::new()
        $psi.UseShellExecute = $false
        $psi.RedirectStandardOutput = $true
        $psi.RedirectStandardError = $true
        $psi.CreateNoWindow = $true
        $ext = [System.IO.Path]::GetExtension($ExecutablePath).ToLowerInvariant()
        if ($onWindows -and ($ext -eq '.cmd' -or $ext -eq '.bat')) {
            # INJECTION GUARD: refuse a shim path bearing any cmd expansion/operator metacharacter, so nothing
            # untrusted can reach the interpreter (the fixed --version args carry none). A refused path -> unknown.
            if ($ExecutablePath -match '[%!&^|<>"]') {
                $out.problem = 'resolved shim path contains a shell metacharacter; refused (injection guard)'
                return [pscustomobject]$out
            }
            # TRUSTED INTERPRETER: bind cmd.exe to the OS command processor in the system directory, resolved via the
            # Win32 API ([Environment]::SystemDirectory -> GetSystemDirectory, NOT the mutable %ComSpec%/%SystemRoot%
            # env vars and NOT PATH). A caller/project that controls the inherited environment must NOT be able to
            # substitute an arbitrary executable for the interpreter (which would run attacker code during
            # SessionStart / doctor / preflight, and could still emit a matching version so the receipt reads healthy).
            # FAIL CLOSED (-> unknown) if the trusted cmd.exe is not present.
            $sysDir = [System.Environment]::SystemDirectory
            if ([string]::IsNullOrWhiteSpace($sysDir)) { $out.problem = 'cannot resolve the trusted system directory for the shim interpreter'; return [pscustomobject]$out }
            $comspec = Join-Path $sysDir 'cmd.exe'
            if (-not (Test-Path -LiteralPath $comspec -PathType Leaf)) { $out.problem = 'trusted cmd.exe not found in the system directory; shim probe refused'; return [pscustomobject]$out }
            $psi.FileName = $comspec
            # Explicit, self-quoted command line: /d (no AutoRun) /c "<path>" <fixed args>. Built explicitly because
            # .NET ArgumentList's C-runtime quoting does NOT match cmd.exe's rules; the path is refused above if it
            # bears an injection metacharacter, so this raw string has no injection surface.
            $argline = '/d /c "' + $ExecutablePath + '"'
            foreach ($a in $Arguments) { $argline += ' ' + [string]$a }
            $psi.Arguments = $argline
        }
        else {
            $psi.FileName = $ExecutablePath
            foreach ($a in $Arguments) { $psi.ArgumentList.Add([string]$a) }
        }
        # DETERMINISTIC decode (finding f2): pin both stream readers to UTF-8 so the byte accounting below is
        # exact and platform-independent (real host version strings are ASCII, which UTF-8 subsumes).
        $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
        $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
        $proc = [System.Diagnostics.Process]::Start($psi)

        # BYTE-CAPPED CONCURRENT DRAIN (Prop-145 finding 1; ENCODED-BYTE accounting per finding f2, run
        # 20260714T182921446): the timeout bounds TIME; this bounds MEMORY on the contract's RAW-BYTE boundary -
        # each decoded chunk is charged its UTF-8 ENCODED byte count, so multibyte output cannot consume past the
        # 8 KB cap under a char count. Read BOTH streams concurrently on THIS thread via ReadAsync + WaitAny (no
        # PowerShell scriptblock on a threadpool thread), then CONTINUE to read + DISCARD past the cap so the
        # child never blocks on a full pipe. Nothing is written to disk. Exceeding the cap fails CLOSED. On the
        # deadline the whole descendant tree is killed.
        $cap = $script:SpecrewHostVersionProbeMaxOutputBytes
        $deadlineMs = [Math]::Max(1000, $TimeoutSeconds * 1000)
        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        $sbOut = [System.Text.StringBuilder]::new(); $ovOut = $false; $outEof = $false; $bytesOut = 0
        $sbErr = [System.Text.StringBuilder]::new(); $ovErr = $false; $errEof = $false; $bytesErr = 0
        $bufOut = [char[]]::new(4096); $bufErr = [char[]]::new(4096)
        $tOut = $proc.StandardOutput.ReadAsync($bufOut, 0, $bufOut.Length)
        $tErr = $proc.StandardError.ReadAsync($bufErr, 0, $bufErr.Length)
        $timedOut = $false
        while (-not ($outEof -and $errEof)) {
            if ($sw.ElapsedMilliseconds -ge $deadlineMs) { $timedOut = $true; break }
            $pending = @(); if (-not $outEof) { $pending += $tOut }; if (-not $errEof) { $pending += $tErr }
            $slice = [int][Math]::Min(200, [Math]::Max(1, $deadlineMs - $sw.ElapsedMilliseconds))
            $idx = [System.Threading.Tasks.Task]::WaitAny([System.Threading.Tasks.Task[]]$pending, $slice)
            if ($idx -lt 0) { continue }
            $done = $pending[$idx]
            if (-not $outEof -and $done -eq $tOut) {
                $n = 0; try { $n = $tOut.GetAwaiter().GetResult() } catch { $n = 0 }
                if ($n -le 0) { $outEof = $true }
                else {
                    if (-not $ovOut) {
                        $chunkBytes = [System.Text.Encoding]::UTF8.GetByteCount($bufOut, 0, $n)
                        if (($bytesOut + $chunkBytes) -gt $cap) { $ovOut = $true }
                        else { [void]$sbOut.Append($bufOut, 0, $n); $bytesOut += $chunkBytes }
                    }
                    $tOut = $proc.StandardOutput.ReadAsync($bufOut, 0, $bufOut.Length)
                }
            }
            elseif (-not $errEof -and $done -eq $tErr) {
                $n = 0; try { $n = $tErr.GetAwaiter().GetResult() } catch { $n = 0 }
                if ($n -le 0) { $errEof = $true }
                else {
                    if (-not $ovErr) {
                        $chunkBytes = [System.Text.Encoding]::UTF8.GetByteCount($bufErr, 0, $n)
                        if (($bytesErr + $chunkBytes) -gt $cap) { $ovErr = $true }
                        else { [void]$sbErr.Append($bufErr, 0, $n); $bytesErr += $chunkBytes }
                    }
                    $tErr = $proc.StandardError.ReadAsync($bufErr, 0, $bufErr.Length)
                }
            }
        }
        if ($timedOut) {
            try { $proc.Kill($true) } catch { $null = $_ }   # kill the whole descendant tree
            $out.problem = ("version probe timed out after {0}s" -f $TimeoutSeconds)
            return [pscustomobject]$out
        }
        # Both streams reached EOF -> the process finished writing; confirm it exited (bounded) and read the code.
        $graceMs = [int][Math]::Max(0, $deadlineMs - $sw.ElapsedMilliseconds)
        if (-not $proc.WaitForExit($graceMs)) {
            try { $proc.Kill($true) } catch { $null = $_ }
            $out.problem = ("version probe timed out after {0}s" -f $TimeoutSeconds)
            return [pscustomobject]$out
        }
        if ($ovOut -or $ovErr) {
            $out.problem = 'version probe output exceeded the byte cap; refused (fail-closed)'
            return [pscustomobject]$out
        }
        if ($proc.ExitCode -ne 0) {
            $out.problem = ("version probe exited {0}" -f $proc.ExitCode)
            return [pscustomobject]$out
        }
        $out.ok = $true
        $out.stdout = $sbOut.ToString()
        return [pscustomobject]$out
    }
    catch {
        $out.problem = ("version probe launch failed: {0}" -f $_.Exception.Message)
        return [pscustomobject]$out
    }
    finally {
        if ($null -ne $proc) { try { $proc.Dispose() } catch { $null = $_ } }
    }
}

function Get-SpecrewHostVersionProbe {
    # The ONE host-CLI version probe - a NON-AUTHORITATIVE, NON-PROMOTING DIAGNOSTIC. Resolves the host command on
    # the AMBIENT PATH and runs its fixed host-declared version argument bounded + shell-safe / cross-platform
    # (see Invoke-SpecrewBoundedVersionProcess), then normalizes stdout to the tool's self-reported version line.
    # Returns { ok; host; version; source; problem }; source is always 'ambient-path-binding'. ANY failure ->
    # ok=$false, version='unknown'. NEVER throws, NEVER reads an ambient version env value. The production probe uses
    # ONLY the fixed host-declared arguments from the spec; -CommandOverride is the TEST seam that points the probe
    # at a controllable fake executable (still ambient-path-binding). This value NEVER promotes health or readiness.
    param(
        [Parameter(Mandatory)][string]$HostName,
        [int]$TimeoutSeconds = 6,
        [AllowNull()][string]$CommandOverride
    )
    $result = [ordered]@{ ok = $false; host = ''; version = 'unknown'; source = $script:SpecrewHookVersionSource; problem = $null }
    try {
        $key = ConvertTo-SpecrewHookHealthToken -Value $HostName
        $result.host = $key

        # Arguments are ALWAYS the fixed host-declared vector (never caller-controlled): the spec's args, or the
        # standard --version for a CommandOverride test target. No arbitrary arguments are ever accepted.
        $command = $null
        $vargs = @('--version')
        if (-not [string]::IsNullOrWhiteSpace($CommandOverride)) {
            $command = $CommandOverride
        }
        else {
            $spec = Get-SpecrewHostVersionProbeSpec -HostName $key
            if ($null -eq $spec) {
                $result.problem = ("no version-probe spec for host '{0}'" -f $key)
                return [pscustomobject]$result
            }
            $command = $spec.command
            $vargs = @($spec.args)
        }

        # Bind to the RESOLVED executable (requirement 3: not an unqualified ambient command). A path-like override
        # is used as-is; a bare command name resolves via Get-Command (Application only). Unresolved -> unknown.
        $exePath = $null
        if ((($command -match '[\\/]') -or ($command -match '^[A-Za-z]:')) -and (Test-Path -LiteralPath $command -PathType Leaf)) {
            $exePath = (Resolve-Path -LiteralPath $command).Path
        }
        else {
            $cmd = @(Get-Command -Name $command -CommandType Application -ErrorAction SilentlyContinue) | Select-Object -First 1
            if ($null -ne $cmd) { $exePath = [string]$cmd.Source }
        }
        if ([string]::IsNullOrWhiteSpace($exePath)) {
            $result.problem = ("host executable '{0}' did not resolve" -f $command)
            return [pscustomobject]$result
        }

        $raw = Invoke-SpecrewBoundedVersionProcess -ExecutablePath $exePath -Arguments $vargs -TimeoutSeconds $TimeoutSeconds
        if (-not $raw.ok) { $result.problem = $raw.problem; return [pscustomobject]$result }

        $normalized = ConvertTo-SpecrewNormalizedVersionLine -Stdout $raw.stdout
        if ([string]::IsNullOrWhiteSpace($normalized)) {
            $result.problem = 'version output empty / ambiguous / malformed'
            return [pscustomobject]$result
        }
        $result.ok = $true
        $result.version = $normalized
        return [pscustomobject]$result
    }
    catch {
        $result.ok = $false
        $result.version = 'unknown'
        $result.problem = ("probe error: {0}" -f $_.Exception.Message)
        return [pscustomobject]$result
    }
}

# ------------------------------------------------------------------------------------------------------------
# Path helpers
# ------------------------------------------------------------------------------------------------------------

function ConvertTo-SpecrewHookHealthToken {
    # Normalize a host / surface / event value to a filesystem-safe, lowercase token (receipts are keyed into a
    # filename). Empty -> 'unknown' so a token is always well-defined.
    param([AllowNull()][string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return 'unknown' }
    $token = ($Value.Trim().ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-')
    if ([string]::IsNullOrWhiteSpace($token)) { return 'unknown' }
    return $token
}

function Get-SpecrewHookHealthStorePath {
    # The receipt store: <ProjectRoot>/.specrew/runtime/hook-health/ (the established gitignored runtime dir).
    param([Parameter(Mandatory)][string]$ProjectRoot)
    return (Join-Path $ProjectRoot '.specrew/runtime/hook-health')
}

function Get-SpecrewHookHealthReceiptPath {
    # One receipt file per (host, surface, event): <host>-<surface>-<event>.json under the store.
    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][Alias('Host')][string]$HostName,
        [string]$Surface = $script:SpecrewHookHealthSurface,
        [Parameter(Mandatory)][string]$Event
    )
    $file = ('{0}-{1}-{2}.json' -f (ConvertTo-SpecrewHookHealthToken -Value $HostName), (ConvertTo-SpecrewHookHealthToken -Value $Surface), (ConvertTo-SpecrewHookHealthToken -Value $Event))
    return (Join-Path (Get-SpecrewHookHealthStorePath -ProjectRoot $ProjectRoot) $file)
}

# ------------------------------------------------------------------------------------------------------------
# Sanitization + receipt construction (the WRITE side)
# ------------------------------------------------------------------------------------------------------------

function ConvertTo-SpecrewHookHealthSafeString {
    # Bound a scalar destined for a receipt FIELD: strip CR/LF + control chars, collapse whitespace, cap length.
    # This guarantees a field can never smuggle a multi-line prompt, an argument blob, or an environment dump -
    # a defense-in-depth complement to the field-set allow-list (which already forbids extra keys).
    param([AllowNull()][string]$Value, [int]$MaxLength = 200)
    if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
    $clean = ($Value -replace '[\x00-\x1F\x7F]+', ' ').Trim()
    $clean = ($clean -replace '\s{2,}', ' ')
    if ($clean.Length -gt $MaxLength) { $clean = $clean.Substring(0, $MaxLength).Trim() }
    return $clean
}

function New-SpecrewHookHealthReceiptObject {
    # Build the SANITIZED receipt object field-by-field from ONLY the allowed inputs. No parameter here carries a
    # prompt / arg vector / env table / secret, and nothing but these seven fields is ever emitted. host/surface/
    # event/timestamp = HOOK-LIVENESS evidence; observed_host_version + version_source = the VERSION DIAGNOSTIC.
    param(
        [Parameter(Mandatory)][Alias('Host')][string]$HostName,
        [Parameter(Mandatory)][string]$Event,
        [string]$Surface = $script:SpecrewHookHealthSurface,
        [AllowNull()][string]$ObservedHostVersion,
        [AllowNull()][string]$ObservedVersionSource,
        [AllowNull()][int]$AdapterContractVersion,
        [AllowNull()][datetime]$TimestampUtc
    )
    $contract = if ($PSBoundParameters.ContainsKey('AdapterContractVersion')) { [int]$AdapterContractVersion } else { $script:SpecrewHookHealthAdapterContractVersion }
    $ts = if ($PSBoundParameters.ContainsKey('TimestampUtc') -and $null -ne $TimestampUtc) { $TimestampUtc.ToUniversalTime() } else { (Get-Date).ToUniversalTime() }
    $obsVer = ConvertTo-SpecrewHookHealthSafeString -Value $ObservedHostVersion -MaxLength 200
    # version_source: honor an explicit caller value; else default from whether a real version diagnostic was
    # captured ('ambient-path-binding' when a version is present, 'unavailable' when it is 'unknown'/blank).
    $vsource = if ($PSBoundParameters.ContainsKey('ObservedVersionSource') -and -not [string]::IsNullOrWhiteSpace($ObservedVersionSource)) {
        ConvertTo-SpecrewHookHealthSafeString -Value $ObservedVersionSource -MaxLength 40
    }
    elseif ([string]::IsNullOrWhiteSpace($obsVer) -or $obsVer -ieq 'unknown') { 'unavailable' }
    else { $script:SpecrewHookVersionSource }
    return [pscustomobject][ordered]@{
        host                     = ConvertTo-SpecrewHookHealthSafeString -Value $HostName -MaxLength 64
        surface                  = ConvertTo-SpecrewHookHealthSafeString -Value $Surface -MaxLength 32
        event                    = ConvertTo-SpecrewHookHealthSafeString -Value $Event -MaxLength 64
        observed_host_version    = $obsVer
        version_source           = $vsource
        timestamp                = $ts.ToString('o')
        adapter_contract_version = $contract
    }
}

function Write-SpecrewHookHealthReceipt {
    # Record a SANITIZED hook-health receipt from a REAL host-triggered hook fire (SessionStart/Stop). This is
    # provided FOR the hook path to call (and for tests to exercise) - it does NOT edit the protected hook file.
    # Best-effort / fail-open: a receipt-write failure must never break the hook, so it returns $null on failure.
    # On success returns { Path; Receipt }. Sanitized by construction (see New-SpecrewHookHealthReceiptObject).
    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][Alias('Host')][string]$HostName,
        [Parameter(Mandatory)][string]$Event,
        [string]$Surface = $script:SpecrewHookHealthSurface,
        [AllowNull()][string]$ObservedHostVersion,
        [AllowNull()][string]$ObservedVersionSource,
        [AllowNull()][int]$AdapterContractVersion,
        [AllowNull()][datetime]$TimestampUtc
    )
    try {
        $builder = @{ HostName = $HostName; Event = $Event; Surface = $Surface }
        if ($PSBoundParameters.ContainsKey('ObservedHostVersion')) { $builder.ObservedHostVersion = $ObservedHostVersion }
        if ($PSBoundParameters.ContainsKey('ObservedVersionSource')) { $builder.ObservedVersionSource = $ObservedVersionSource }
        if ($PSBoundParameters.ContainsKey('AdapterContractVersion')) { $builder.AdapterContractVersion = $AdapterContractVersion }
        if ($PSBoundParameters.ContainsKey('TimestampUtc')) { $builder.TimestampUtc = $TimestampUtc }
        $receipt = New-SpecrewHookHealthReceiptObject @builder

        $path = Get-SpecrewHookHealthReceiptPath -ProjectRoot $ProjectRoot -HostName $HostName -Surface $Surface -Event $Event
        $dir = Split-Path -Parent $path
        if (-not (Test-Path -LiteralPath $dir -PathType Container)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        $json = $receipt | ConvertTo-Json -Depth 4 -Compress
        [System.IO.File]::WriteAllText($path, $json, [System.Text.UTF8Encoding]::new($false))
        return [pscustomobject]@{ Path = $path; Receipt = $receipt }
    }
    catch {
        return $null
    }
}

# ------------------------------------------------------------------------------------------------------------
# Receipt reading + classification (the READ side)
# ------------------------------------------------------------------------------------------------------------

function ConvertTo-SpecrewHookHealthUtcInstant {
    # Resolve a receipt timestamp to a timezone-UNAMBIGUOUS UTC [datetime]. This MUST tolerate the value however
    # ConvertFrom-Json handed it back: PowerShell auto-deserializes an ISO-8601 string into a [datetime] whose
    # Kind is Unspecified (the trailing 'Z' is dropped), so a naive [string] re-parse would mis-read it as LOCAL
    # and skew freshness by the runner's UTC offset. So: a [datetime] Kind=Utc is used as-is; Local is converted;
    # Unspecified is treated as UTC (our writer ALWAYS emits a 'Z'/UTC 'o' string, so an unspecified-kind value is
    # a UTC wall-clock that merely lost its Kind); a [DateTimeOffset] uses its UtcDateTime; a raw string is parsed
    # via DateTimeOffset (which honors 'Z'/offset). Throws only on an unparseable string; callers guard.
    param([Parameter(Mandatory)]$Timestamp)
    if ($Timestamp -is [datetime]) {
        $dt = [datetime]$Timestamp
        switch ($dt.Kind) {
            ([System.DateTimeKind]::Utc) { return $dt }
            ([System.DateTimeKind]::Local) { return $dt.ToUniversalTime() }
            default { return [datetime]::SpecifyKind($dt, [System.DateTimeKind]::Utc) }
        }
    }
    if ($Timestamp -is [System.DateTimeOffset]) { return ([System.DateTimeOffset]$Timestamp).UtcDateTime }
    return ([System.DateTimeOffset]::Parse([string]$Timestamp, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind)).UtcDateTime
}

function Read-SpecrewHookHealthReceiptFile {
    # Parse ONE receipt file into { Path; WellFormed; Receipt; Problem }. WELL-FORMED requires: parseable JSON;
    # EXACTLY the allowed field-set (missing OR extra keys -> malformed - an injected/tampered key is a red flag,
    # never silently tolerated); non-empty host/surface/event/observed_host_version/timestamp; a round-trip
    # timestamp; an integer adapter_contract_version. Never throws.
    param([Parameter(Mandatory)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        return [pscustomobject]@{ Path = $Path; WellFormed = $false; Receipt = $null; Problem = 'absent' }
    }
    $raw = $null
    try { $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 } catch { $raw = $null }
    if ([string]::IsNullOrWhiteSpace($raw)) {
        return [pscustomobject]@{ Path = $Path; WellFormed = $false; Receipt = $null; Problem = 'empty-file' }
    }
    $obj = $null
    try { $obj = $raw | ConvertFrom-Json -ErrorAction Stop } catch { $obj = $null }
    if ($null -eq $obj -or $obj -isnot [psobject]) {
        return [pscustomobject]@{ Path = $Path; WellFormed = $false; Receipt = $null; Problem = 'unparseable-json' }
    }

    # Enumerate names via the pipeline (NOT $obj.PSObject.Properties.Name): member-enumeration of .Name on a
    # zero-property object throws under Set-StrictMode -Version Latest.
    $keys = @($obj.PSObject.Properties | ForEach-Object { $_.Name })
    $required = @($script:SpecrewHookHealthReceiptFields)
    $missing = @($required | Where-Object { $keys -notcontains $_ })
    $extra = @($keys | Where-Object { $required -notcontains $_ })
    if ($missing.Count -gt 0 -or $extra.Count -gt 0) {
        return [pscustomobject]@{ Path = $Path; WellFormed = $false; Receipt = $obj; Problem = ('field-set-mismatch (missing=[{0}] extra=[{1}])' -f ($missing -join ','), ($extra -join ',')) }
    }

    foreach ($stringField in @('host', 'surface', 'event', 'observed_host_version', 'version_source', 'timestamp')) {
        if ([string]::IsNullOrWhiteSpace([string]$obj.$stringField)) {
            return [pscustomobject]@{ Path = $Path; WellFormed = $false; Receipt = $obj; Problem = ("empty required field '{0}'" -f $stringField) }
        }
    }

    # Timestamp validity: a value ConvertFrom-Json already typed as [datetime]/[DateTimeOffset] is valid by
    # construction; a raw string must parse invariantly (culture-independent). This avoids a false 'malformed' on
    # a non-en-US runner where [string]$datetime would render in the local culture but be parsed as invariant.
    $tsValue = $obj.timestamp
    $tsValid = ($tsValue -is [datetime]) -or ($tsValue -is [System.DateTimeOffset])
    if (-not $tsValid) {
        $parsedTs = [System.DateTimeOffset]::MinValue
        $tsValid = [System.DateTimeOffset]::TryParse([string]$tsValue, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind, [ref]$parsedTs)
    }
    if (-not $tsValid) {
        return [pscustomobject]@{ Path = $Path; WellFormed = $false; Receipt = $obj; Problem = 'unparseable-timestamp' }
    }

    $parsedContract = 0
    if (-not [int]::TryParse([string]$obj.adapter_contract_version, [ref]$parsedContract)) {
        return [pscustomobject]@{ Path = $Path; WellFormed = $false; Receipt = $obj; Problem = 'non-integer adapter_contract_version' }
    }

    return [pscustomobject]@{ Path = $Path; WellFormed = $true; Receipt = $obj; Problem = $null }
}

function New-SpecrewHookHealthResult {
    # Uniform verdict shape with INDEPENDENT fields (Prop-145): hook_status (liveness) and version_status
    # (diagnostic) are computed and reported separately - version_status NEVER promotes hook_status. Each is a
    # closed-set member. This is the ONLY constructor for a verdict, so the closed sets can never be circumvented.
    param(
        [Parameter(Mandatory)][string]$HostName,
        [Parameter(Mandatory)][string]$Surface,
        [Parameter(Mandatory)][ValidateSet('healthy', 'stale', 'malformed', 'conflicting', 'absent')][string]$HookStatus,
        [Parameter(Mandatory)][ValidateSet('diagnostic-match', 'diagnostic-drift', 'unavailable', 'untrusted-source')][string]$VersionStatus,
        [string]$VersionSource = '',
        [Parameter(Mandatory)][string]$Reason,
        [AllowNull()]$Receipt
    )
    return [pscustomobject][ordered]@{
        host           = $HostName
        surface        = $Surface
        hook_status    = $HookStatus
        version_status = $VersionStatus
        version_source = $VersionSource
        reason         = $Reason
        receipt        = $Receipt
    }
}

function Resolve-SpecrewHookHealth {
    # Classify a host+surface into INDEPENDENT fields (Prop-145): hook_status (LIVENESS - was the configured hook
    # path recently OBSERVED firing) and version_status (a NON-PROMOTING ambient-path-binding version DIAGNOSTIC).
    # version_status NEVER changes hook_status. Returns { host; surface; hook_status; version_status; version_source;
    # reason; receipt }. Receipts are MONITORING evidence, not authenticated (the store is project-writable and the
    # dispatcher can be invoked directly) - `healthy` is operational confidence, never proof of the host process.
    # PURE (no subprocess): the caller may probe the live host and supply -ExpectedHostVersion for the DIAGNOSTIC
    # only. Never throws.
    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][Alias('Host')][string]$HostName,
        [string]$Surface = $script:SpecrewHookHealthSurface,
        [AllowNull()][string]$ExpectedHostVersion,           # a current ambient-path-binding reading, for the version DIAGNOSTIC only (never promotes liveness/readiness)
        [AllowNull()][int]$ExpectedAdapterContractVersion,   # defaults to the current adapter contract version
        [int]$FreshnessHours = $script:SpecrewHookHealthDefaultFreshnessHours,
        [AllowNull()][datetime]$Now
    )

    $nowUtc = if ($PSBoundParameters.ContainsKey('Now')) { $Now.ToUniversalTime() } else { (Get-Date).ToUniversalTime() }
    $currentContract = if ($PSBoundParameters.ContainsKey('ExpectedAdapterContractVersion')) { [int]$ExpectedAdapterContractVersion } else { $script:SpecrewHookHealthAdapterContractVersion }
    $store = Get-SpecrewHookHealthStorePath -ProjectRoot $ProjectRoot
    $hostToken = ConvertTo-SpecrewHookHealthToken -Value $HostName
    $surfaceToken = ConvertTo-SpecrewHookHealthToken -Value $Surface

    $files = @()
    if (Test-Path -LiteralPath $store -PathType Container) {
        $pattern = ('{0}-{1}-*.json' -f $hostToken, $surfaceToken)
        $files = @(Get-ChildItem -LiteralPath $store -Filter $pattern -File -ErrorAction SilentlyContinue)
    }

    # ABSENT: no receipt -> the configured hook path was not observed firing here.
    if ($files.Count -eq 0) {
        return New-SpecrewHookHealthResult -HostName $hostToken -Surface $surfaceToken -HookStatus 'absent' -VersionStatus 'unavailable' -VersionSource '' -Reason 'no hook-health receipt (the configured hook path was not observed firing here: not deployed, host never loaded it, or trust revoked with no subsequent fire).' -Receipt $null
    }

    $parsed = @($files | ForEach-Object { Read-SpecrewHookHealthReceiptFile -Path $_.FullName })

    # MALFORMED: any corrupt / tampered / nonconforming receipt (incl. a pre-v3 receipt missing version_source)
    # cannot be read as liveness.
    $malformed = @($parsed | Where-Object { -not $_.WellFormed })
    if ($malformed.Count -gt 0) {
        return New-SpecrewHookHealthResult -HostName $hostToken -Surface $surfaceToken -HookStatus 'malformed' -VersionStatus 'unavailable' -VersionSource '' -Reason ('a hook-health receipt is malformed ({0}); it cannot be read as liveness - re-fire the hook to rewrite it.' -f $malformed[0].Problem) -Receipt $malformed[0].Receipt
    }

    $good = @($parsed | ForEach-Object { $_.Receipt })

    # WRONG-HOST: a well-formed receipt whose host field disagrees with the requested host (tampered / mis-keyed) is
    # internally inconsistent evidence.
    $hostMismatch = @($good | Where-Object { (ConvertTo-SpecrewHookHealthToken -Value ([string]$_.host)) -ne $hostToken })
    if ($hostMismatch.Count -gt 0) {
        return New-SpecrewHookHealthResult -HostName $hostToken -Surface $surfaceToken -HookStatus 'conflicting' -VersionStatus 'unavailable' -VersionSource '' -Reason ("a receipt's host field ('{0}') disagrees with the requested host ('{1}'); the evidence is internally inconsistent." -f ([string]$hostMismatch[0].host), $hostToken) -Receipt $hostMismatch[0]
    }

    # WRONG-SURFACE (review finding f1, run 20260714T190233598): filename selection binds host+surface, but the
    # EMBEDDED surface field must agree too - a project-writable codex-cli-*.json carrying surface='cloud' must
    # never classify (let alone read healthy) for the CLI query. Same fail-closed posture as wrong-host.
    $surfaceMismatch = @($good | Where-Object { (ConvertTo-SpecrewHookHealthToken -Value ([string]$_.surface)) -ne $surfaceToken })
    if ($surfaceMismatch.Count -gt 0) {
        return New-SpecrewHookHealthResult -HostName $hostToken -Surface $surfaceToken -HookStatus 'conflicting' -VersionStatus 'unavailable' -VersionSource '' -Reason ("a receipt's surface field ('{0}') disagrees with the requested surface ('{1}'); the evidence is internally inconsistent." -f ([string]$surfaceMismatch[0].surface), $surfaceToken) -Receipt $surfaceMismatch[0]
    }

    # UNKNOWN / UNBOUND EVENT (review finding f3, run 20260714T190233598): liveness comes ONLY from a recognized
    # lifecycle fire, and each receipt's embedded event must MATCH the filename identity it was selected under -
    # a fresh forged '<host>-<surface>-forged.json' with event='forged' must never reach freshness
    # classification, and a suffix/event disagreement must not distort which receipt is treated as the
    # SessionStart version evidence.
    $lifecycleEventTokens = @('sessionstart', 'stop', 'agentstop')
    for ($ri = 0; $ri -lt $files.Count; $ri++) {
        $rcptEv = ConvertTo-SpecrewHookHealthToken -Value ([string]$parsed[$ri].Receipt.event)
        if ($rcptEv -notin $lifecycleEventTokens) {
            return New-SpecrewHookHealthResult -HostName $hostToken -Surface $surfaceToken -HookStatus 'malformed' -VersionStatus 'unavailable' -VersionSource '' -Reason ("a receipt's event ('{0}') is not a recognized lifecycle event (SessionStart | Stop | agentStop); it cannot be read as liveness." -f ([string]$parsed[$ri].Receipt.event)) -Receipt $parsed[$ri].Receipt
        }
        $expectedName = ('{0}-{1}-{2}.json' -f $hostToken, $surfaceToken, $rcptEv)
        if (-not [string]::Equals($files[$ri].Name, $expectedName, [System.StringComparison]::OrdinalIgnoreCase)) {
            return New-SpecrewHookHealthResult -HostName $hostToken -Surface $surfaceToken -HookStatus 'conflicting' -VersionStatus 'unavailable' -VersionSource '' -Reason ("a receipt's embedded event ('{0}') disagrees with its filename identity ('{1}'); the evidence is internally inconsistent." -f ([string]$parsed[$ri].Receipt.event), $files[$ri].Name) -Receipt $parsed[$ri].Receipt
        }
    }

    # WRONG-CONTRACT: a receipt written under a different adapter contract does not conform to the current contract.
    $contractMismatch = @($good | Where-Object { [int]$_.adapter_contract_version -ne $currentContract })
    if ($contractMismatch.Count -gt 0) {
        return New-SpecrewHookHealthResult -HostName $hostToken -Surface $surfaceToken -HookStatus 'malformed' -VersionStatus 'unavailable' -VersionSource '' -Reason ('adapter-contract drift (receipt={0}, current={1}); the receipt does not conform to the current contract - re-fire the hook to rewrite it.' -f [int]$contractMismatch[0].adapter_contract_version, $currentContract) -Receipt $contractMismatch[0]
    }

    # CONFLICTING: well-formed SessionStart receipts that disagree on the observed version are internally
    # inconsistent evidence about what fired.
    $sessionStart = @($good | Where-Object { ([string]$_.event).Trim().ToLowerInvariant() -eq 'sessionstart' })
    if ($sessionStart.Count -gt 0) {
        $distinctVersions = @($sessionStart | ForEach-Object { ([string]$_.observed_host_version).Trim() } | Sort-Object -Unique)   # specrew-dedup-not-a-path
        if ($distinctVersions.Count -gt 1) {
            return New-SpecrewHookHealthResult -HostName $hostToken -Surface $surfaceToken -HookStatus 'conflicting' -VersionStatus 'unavailable' -VersionSource '' -Reason ('conflicting SessionStart receipts (observed versions=[{0}]); the evidence disagrees with itself.' -f ($distinctVersions -join ' | ')) -Receipt $null
        }
    }

    # HOOK LIVENESS from the freshest well-formed lifecycle receipt (SessionStart OR Stop - both are the configured
    # hook path being OBSERVED firing). Monitoring evidence, not authenticated.
    $repLive = @($good | Sort-Object { ConvertTo-SpecrewHookHealthUtcInstant -Timestamp $_.timestamp } -Descending)[0]
    $ageLive = ($nowUtc - (ConvertTo-SpecrewHookHealthUtcInstant -Timestamp $repLive.timestamp)).TotalHours
    # FUTURE-dated guard (review finding f2, run 20260714T172315119): a negative age beyond the small
    # explicit clock-skew tolerance means the freshest receipt claims to be from the future - under clock
    # skew or a tampered project-writable store that would stay 'healthy' until <future>+freshness. Such a
    # receipt is MALFORMED (never healthy, so never ready); within-tolerance skew still reads normally.
    $skewToleranceHours = $script:SpecrewHookHealthClockSkewToleranceMinutes / 60.0
    $hookStatus = if ($ageLive -lt (-1.0 * $skewToleranceHours)) { 'malformed' } elseif ($ageLive -gt $FreshnessHours) { 'stale' } else { 'healthy' }

    # VERSION DIAGNOSTIC (INDEPENDENT) from the freshest SessionStart receipt - NEVER changes hook_status. A version
    # probe failure leaves this 'unavailable' but does NOT erase the hook-liveness above.
    $versionStatus = 'unavailable'
    $versionSource = ''
    $repSS = $null
    if ($sessionStart.Count -gt 0) {
        $repSS = @($sessionStart | Sort-Object { ConvertTo-SpecrewHookHealthUtcInstant -Timestamp $_.timestamp } -Descending)[0]
        $versionSource = ([string]$repSS.version_source).Trim()
        $obsVer = ([string]$repSS.observed_host_version).Trim()
        if ($versionSource -eq $script:SpecrewHookVersionSource) {
            # A recognized ambient-path-binding reading: compare against the caller's current ambient reading.
            if ([string]::IsNullOrWhiteSpace($obsVer) -or $obsVer -ieq 'unknown') { $versionStatus = 'unavailable' }
            elseif ([string]::IsNullOrWhiteSpace($ExpectedHostVersion)) { $versionStatus = 'unavailable' }
            elseif ($obsVer -eq $ExpectedHostVersion.Trim()) { $versionStatus = 'diagnostic-match' }
            else { $versionStatus = 'diagnostic-drift' }
        }
        elseif ([string]::IsNullOrWhiteSpace($versionSource) -or $versionSource -ieq 'unavailable') {
            # The SessionStart probe captured no version (recorded 'unavailable') - non-promoting, does not erase liveness.
            $versionStatus = 'unavailable'
        }
        else {
            # An unrecognized version_source (tampered / legacy) - never treated as a valid diagnostic.
            $versionStatus = 'untrusted-source'
        }
    }

    $repReceipt = if ($null -ne $repSS) { $repSS } else { $repLive }
    $livePhrase = switch ($hookStatus) {
        'healthy' { ('hook liveness healthy: a fresh, well-formed receipt shows the configured hook path was observed firing {0:N1}h ago (operational monitoring evidence, not authentication)' -f [Math]::Max(0.0, $ageLive)) }
        'stale' { ('hook liveness stale: the freshest receipt is {0:N1}h old (> {1}h); the hook may have stopped firing (removed / trust revoked)' -f $ageLive, $FreshnessHours) }
        'malformed' { ('hook liveness malformed: the freshest receipt is FUTURE-dated by {0:N1}h (beyond the {1}-minute clock-skew tolerance) - not plausible liveness evidence; never healthy or ready' -f (-1.0 * $ageLive), $script:SpecrewHookHealthClockSkewToleranceMinutes) }
        default { ('hook liveness {0}' -f $hookStatus) }
    }
    $verPhrase = switch ($versionStatus) {
        'diagnostic-match' { 'version diagnostic: match (ambient-path-binding, non-authoritative - both readings resolved an equivalent reported version through the ambient command)' }
        'diagnostic-drift' { 'version diagnostic: drift (ambient-path-binding, non-authoritative - the current ambient reading differs from the SessionStart reading)' }
        'untrusted-source' { "version diagnostic: untrusted source (the receipt's version_source is not the recognized ambient-path-binding diagnostic)" }
        default { 'version diagnostic: unavailable (no ambient version reading to compare; non-promoting)' }
    }
    return New-SpecrewHookHealthResult -HostName $hostToken -Surface $surfaceToken -HookStatus $hookStatus -VersionStatus $versionStatus -VersionSource $versionSource -Reason ("{0}. {1}." -f $livePhrase, $verPhrase) -Receipt $repReceipt
}

# ------------------------------------------------------------------------------------------------------------
# Doctor/status renderer (FR-053 reporting)
# ------------------------------------------------------------------------------------------------------------

function Format-SpecrewHookHealthReport {
    # Renderer for the doctor/status surface (called by the protected surface; this file is NOT protected). Given
    # resolved rows, OR a ProjectRoot + host list to resolve, return a deterministic STRING table + a legend showing
    # the INDEPENDENT fields: hook-liveness status and the NON-AUTHORITATIVE version DIAGNOSTIC. Never promotes: it
    # renders exactly what Resolve-SpecrewHookHealth returned. When resolving from a ProjectRoot it performs the
    # ambient version probe per host for the DIAGNOSTIC ONLY (it never affects liveness). Pre-resolved -Rows bypass
    # the probe (the test/inspection seam). Returns a STRING (no console writes).
    param(
        [object[]]$Rows,
        [string]$ProjectRoot,
        [string[]]$Hosts,
        [string]$Surface = $script:SpecrewHookHealthSurface,
        [AllowNull()][datetime]$Now
    )

    $data = @()
    if ($null -ne $Rows -and @($Rows).Count -gt 0) {
        $data = @($Rows)
    }
    elseif (-not [string]::IsNullOrWhiteSpace($ProjectRoot)) {
        # Default to the CLI-first gated hosts (FR-050) when the caller does not name a host set.
        $hostList = if ($null -ne $Hosts -and @($Hosts).Count -gt 0) { @($Hosts) } else { @('claude', 'codex', 'copilot') }
        foreach ($h in $hostList) {
            $resolveArgs = @{ ProjectRoot = $ProjectRoot; HostName = $h; Surface = $Surface }
            if ($PSBoundParameters.ContainsKey('Now')) { $resolveArgs.Now = $Now }
            # Ambient version probe at this doctor boundary -> feeds the version DIAGNOSTIC ONLY (never liveness).
            if (Get-Command -Name 'Get-SpecrewHostVersionProbe' -ErrorAction SilentlyContinue) {
                $probe = Get-SpecrewHostVersionProbe -HostName $h
                if ($null -ne $probe -and $probe.ok -and -not [string]::IsNullOrWhiteSpace([string]$probe.version) -and ([string]$probe.version) -ne 'unknown') {
                    $resolveArgs.ExpectedHostVersion = [string]$probe.version
                }
            }
            $data += (Resolve-SpecrewHookHealth @resolveArgs)
        }
    }

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('=== Specrew hook-health evidence (FR-053) ===')
    [void]$sb.AppendLine('Hook liveness is MONITORING evidence: a fresh receipt shows the configured hook path was observed')
    [void]$sb.AppendLine('firing. The receipt store is project-writable, so this is operational confidence, not authentication.')
    [void]$sb.AppendLine('The version is a NON-AUTHORITATIVE ambient-path-binding diagnostic and never promotes liveness/readiness.')
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine((' {0,-9} {1,-8} {2,-12} {3,-18} {4}' -f 'HOST', 'SURFACE', 'HOOK', 'VERSION-DIAG', 'SOURCE'))
    [void]$sb.AppendLine((' {0,-9} {1,-8} {2,-12} {3,-18} {4}' -f '----', '-------', '----', '------------', '------'))
    foreach ($row in $data) {
        $vsrc = if ([string]::IsNullOrWhiteSpace([string]$row.version_source)) { '-' } else { [string]$row.version_source }
        [void]$sb.AppendLine((' {0,-9} {1,-8} {2,-12} {3,-18} {4}' -f $row.host, $row.surface, $row.hook_status, $row.version_status, $vsrc))
    }
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('Hook liveness: healthy (fresh receipt observed) | stale | malformed | conflicting | absent')
    [void]$sb.AppendLine('Version diag.: diagnostic-match | diagnostic-drift | unavailable | untrusted-source (never promotes health)')
    return $sb.ToString()
}

# ------------------------------------------------------------------------------------------------------------
# Codex untrusted-headless governance PREFLIGHT (FR-051 / T036)
# ------------------------------------------------------------------------------------------------------------

function Test-SpecrewCodexHeadlessGovernanceReady {
    # PREFLIGHT for an untrusted HEADLESS `codex exec` relying on Specrew governance. Readiness rests on FRESH
    # HOOK-LIVENESS (a recent, well-formed codex/cli receipt) plus the existing Codex config/trust prerequisites -
    # NOT on the version, which is a non-promoting diagnostic. Readiness is OPERATIONAL CONFIDENCE, not tamper-proof
    # host authentication (the receipt store is project-writable and the dispatcher can be invoked directly).
    # Consults the pure Resolve-SpecrewHookHealth for codex/cli. Returns { ready; host; surface; hook_status;
    # version_status; version_source; reason; instruction; receipt }. NEVER writes ~/.codex, NEVER seeds a
    # trusted_hash, NEVER passes --dangerously-bypass-hook-trust.
    # fresh hook liveness (hook_status==healthy) -> ready = $true.
    # missing / stale / malformed / conflicting liveness -> ready = $false + the actionable instruction.
    # A caller-supplied -ExpectedHostVersion (an ambient reading) populates the version DIAGNOSTIC only; it never
    # changes readiness.
    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [AllowNull()][string]$ExpectedHostVersion,
        [int]$FreshnessHours = $script:SpecrewHookHealthDefaultFreshnessHours,
        [AllowNull()][datetime]$Now
    )
    $resolveArgs = @{ ProjectRoot = $ProjectRoot; HostName = 'codex'; Surface = 'cli'; FreshnessHours = $FreshnessHours }
    if ($PSBoundParameters.ContainsKey('ExpectedHostVersion') -and -not [string]::IsNullOrWhiteSpace($ExpectedHostVersion)) { $resolveArgs.ExpectedHostVersion = $ExpectedHostVersion }
    if ($PSBoundParameters.ContainsKey('Now')) { $resolveArgs.Now = $Now }
    $health = Resolve-SpecrewHookHealth @resolveArgs

    $ready = ($health.hook_status -eq 'healthy')
    $instruction = if ($ready) {
        'A fresh codex/cli hook-liveness receipt was observed, so Specrew governance may be relied upon for this run as OPERATIONAL CONFIDENCE - not tamper-proof host authentication (the receipt store is project-writable). Specrew will NOT write Codex''s trust store, NOT seed a trusted_hash, and NOT pass --dangerously-bypass-hook-trust.'
    }
    else {
        ('NOT ready to govern this headless codex run (hook liveness: {0}). Start Codex interactively ONCE (run `codex` in this project), approve the NATIVE Codex hook-trust prompt for the Specrew hook, let a SessionStart hook fire (which records a hook-liveness monitoring receipt), then re-run. Specrew will NOT write Codex''s trust store, NOT seed a trusted_hash, and NOT pass --dangerously-bypass-hook-trust; readiness is operational confidence, not tamper-proof host authentication.' -f $health.hook_status)
    }

    return [pscustomobject][ordered]@{
        ready          = $ready
        host           = 'codex'
        surface        = 'cli'
        hook_status    = $health.hook_status
        version_status = $health.version_status
        version_source = $health.version_source
        reason         = $health.reason
        instruction    = $instruction
        receipt        = $health.receipt
    }
}

# ------------------------------------------------------------------------------------------------------------
# Stop-gate emission fail-open guard (FR-051 / T036 - the malformed-output regression contract)
# ------------------------------------------------------------------------------------------------------------

function Test-SpecrewHookGateEmissionWellFormed {
    # PURE validator for a Stop-hook gate emission. Codex (and copilot) SILENTLY FAIL OPEN on malformed hook stdout
    # (observed FR-051 evidence: non-JSON stdout = clean pass, gate bypassed, no warning). So Specrew's dispatcher
    # MUST emit a well-formed gate envelope or governance is lost with no signal. This encodes what "well-formed"
    # means, so a regression to a malformed emit is caught. Returns { WellFormed; IsBlock; Decision; Reason; Problem }.
    # {} (empty object) -> WellFormed, allow (IsBlock=$false)
    # {"decision":"block"|"continue","reason":<non-empty>} -> WellFormed, IsBlock (the force-continue directive)
    # {"followup_message":<non-empty>} -> WellFormed, IsBlock (cursor best-effort)
    # unparseable / truncated JSON -> NOT WellFormed (the fail-open trap Specrew must avoid)
    # {"decision":"block"} with no reason -> WellFormed=$true but IsBlock=$false (degenerate: no directive)
    # {"continue":false,...} (Codex-manual no-op shape) -> WellFormed=$true, IsBlock=$false (does NOT gate; must not be emitted to block)
    param([AllowNull()][string]$Json)

    $result = [ordered]@{ WellFormed = $false; IsBlock = $false; Decision = $null; Reason = $null; Problem = $null }

    if ([string]::IsNullOrWhiteSpace($Json)) {
        $result.Problem = 'empty emission'
        return [pscustomobject]$result
    }
    $obj = $null
    try { $obj = $Json | ConvertFrom-Json -ErrorAction Stop } catch { $obj = $null }
    if ($null -eq $obj -or $obj -isnot [psobject]) {
        # The dominant fail-open trap: non-JSON / truncated stdout the host silently ignores.
        $result.Problem = 'unparseable JSON (a host silently fails OPEN on this - governance lost)'
        return [pscustomobject]$result
    }

    # Pipeline enumeration (NOT .Properties.Name) so an empty object {} does not throw under StrictMode Latest.
    $keys = @($obj.PSObject.Properties | ForEach-Object { $_.Name })

    # followup_message envelope (cursor).
    if ($keys -contains 'followup_message') {
        $msg = [string]$obj.followup_message
        if (-not [string]::IsNullOrWhiteSpace($msg)) {
            $result.WellFormed = $true; $result.IsBlock = $true; $result.Reason = $msg
            return [pscustomobject]$result
        }
        $result.WellFormed = $true; $result.Problem = 'followup_message present but empty (no directive)'
        return [pscustomobject]$result
    }

    # decision envelope (claude/codex/copilot/antigravity).
    if ($keys -contains 'decision') {
        $decision = [string]$obj.decision
        $reason = if ($keys -contains 'reason') { [string]$obj.reason } else { $null }
        $result.Decision = $decision
        $result.Reason = $reason
        if ($script:SpecrewHookHealthAcceptedBlockDecisions -contains $decision) {
            if (-not [string]::IsNullOrWhiteSpace($reason)) {
                $result.WellFormed = $true; $result.IsBlock = $true
                return [pscustomobject]$result
            }
            # A block/continue decision WITHOUT a reason is a degenerate emit (a force-continue with no directive).
            $result.WellFormed = $true; $result.IsBlock = $false; $result.Problem = ("decision '{0}' present but no reason (no directive to carry)" -f $decision)
            return [pscustomobject]$result
        }
        # A recognized-but-non-blocking decision value (e.g. antigravity 'allow', or any other) = a valid allow.
        $result.WellFormed = $true; $result.IsBlock = $false
        return [pscustomobject]$result
    }

    # An empty object {} is the canonical ALLOW (non-block Stop). Well-formed, does not gate.
    if ($keys.Count -eq 0) {
        $result.WellFormed = $true; $result.IsBlock = $false
        return [pscustomobject]$result
    }

    # Parses, but is neither a gate envelope nor a clean allow (e.g. the Codex-manual {"continue":false,...} no-op).
    # It would NOT gate - so if Specrew emitted THIS intending to block, the block is silently lost.
    $result.WellFormed = $true; $result.IsBlock = $false; $result.Problem = 'parses but is not a recognized gate envelope (would NOT force-continue; must never be emitted to block)'
    return [pscustomobject]$result
}