scripts/internal/continuous-co-review/host-support-tier.ps1

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

# F-198 Iteration 005 / T035 (FR-050): the truthful host+surface support-tier model.
#
# Beta2 is CLI-FIRST. The CLI is the AUTHORITATIVE supported surface; cloud agents and
# cloud-gated development are UNSUPPORTED. This file is the ONE place a host+surface support
# CLAIM is recorded (DATA), plus a pure lookup and a renderer for the doctor/status surface.
# Adding or changing a support claim is a ROW edit here - never a scattered doc assertion.
#
# CLOSED SET (exactly one classification per host+surface):
# verified - exercised end-to-end on that surface (a real conformance probe passed).
# configuration-compatible - documented SHARED configuration with a verified surface; the lifecycle is
# NOT independently exercised on this surface (so it is not `verified`).
# unsupported - no reliable gated integration; MUST NOT be implied to be governed.
# unverified - intended support exists but the conformance probe has NOT passed
# (the honest default for anything not proven - NEVER a fabricated `verified`).
#
# HONESTY (NFR-001/NFR-006): a tier is a CLAIM about evidence, never file presence. `verified` is reserved
# for a surface whose Stop/SessionStart contract has been exercised end-to-end. Codex CLI and Copilot CLI are
# GATED surfaces whose Stop-contract conformance probes (FR-051/T036, FR-052/T037) have now PASSED (iter-005,
# 2026-07-14), so per the maintainer ruling they are `verified` - each carrying an HONEST evidence PROVENANCE
# (what was RUNNER-observed vs HUMAN-observed) plus any NARROWER limitation (e.g. Codex untrusted-headless), so
# the claim never overstates. `verified` is still never fabricated: an unknown host/surface resolves to
# `unverified`, and a row whose probe had NOT passed would stay `unverified`. Provenance TRAVELS WITH the claim -
# a bare `verified` with no recorded evidence is exactly the false-green this feature exists to prevent.
#
# Richer desktop / IDE / cloud certification (host capability negotiation, multi-version + desktop/IDE
# certification) is the Beta3 modernization tracked in GitHub issue #3084 - OUT OF SCOPE for Beta2.

$script:SpecrewHostSupportTierSet = @(
    'verified'
    'configuration-compatible'
    'unsupported'
    'unverified'
)

# Beta3 follow-up reference (public GitHub issue - safe to surface; NOT a Specrew-internal identifier).
$script:SpecrewHostSupportBeta3Issue = 'https://github.com/alonf/specrew/issues/3084'

function Get-SpecrewHostSupportTierSet {
    # The closed classification set (the ONLY four tiers any host+surface may carry).
    return @($script:SpecrewHostSupportTierSet)
}

function Test-SpecrewHostSupportTierValue {
    # Is $Tier a member of the closed set? (Structural guard - used to reject a fabricated tier.)
    param([AllowNull()][string] $Tier)
    if ([string]::IsNullOrWhiteSpace($Tier)) { return $false }
    return @($script:SpecrewHostSupportTierSet) -contains $Tier
}

function ConvertTo-SpecrewHostSupportHostKey {
    # Normalize a host name to its canonical key (lowercase; a few documented aliases).
    param([AllowNull()][string] $HostName)
    if ([string]::IsNullOrWhiteSpace($HostName)) { return '' }
    $needle = $HostName.Trim().ToLowerInvariant()
    switch ($needle) {
        'claude-code'    { return 'claude' }
        'claude-cli'     { return 'claude' }
        'github-copilot' { return 'copilot' }
        'copilot-cli'    { return 'copilot' }
        'codex-cli'      { return 'codex' }
        'cursor-agent'   { return 'cursor' }
        default          { return $needle }
    }
}

function ConvertTo-SpecrewHostSupportSurfaceKey {
    # Normalize a surface name to its canonical key. Surfaces: cli | vscode | ide | desktop | cloud.
    param([AllowNull()][string] $Surface)
    if ([string]::IsNullOrWhiteSpace($Surface)) { return '' }
    $needle = ($Surface.Trim().ToLowerInvariant() -replace '\s+', '-')
    switch ($needle) {
        'vs-code'           { return 'vscode' }
        'code'              { return 'vscode' }
        'vscode-extension'  { return 'vscode' }
        'editor'            { return 'vscode' }
        'extension'         { return 'vscode' }
        'app'               { return 'desktop' }
        'cloud-agent'       { return 'cloud' }
        'cloud-agents'      { return 'cloud' }
        'web'               { return 'cloud' }
        default             { return $needle }
    }
}

function Get-SpecrewHostSupportTierRows {
    # The canonical host+surface support claims (DATA). Each row carries EXACTLY ONE tier from the closed set
    # plus a one-line consumer-legible rationale. This is the seed per the maintainer ruling (F-198 iter-005):
    # * CLI is the authoritative supported surface; claude/codex/copilot CLI are all `verified` gated surfaces
    # (codex/copilot flipped unverified->verified in iter-005 per the maintainer ruling, each carrying an
    # honest provenance + narrower limitation - see the codex/copilot cli rows below).
    # * claude VS Code + codex IDE/desktop = configuration-compatible (shared config; lifecycle not exercised).
    # * copilot VS Code = unsupported for CLI Stop-hook enforcement (its CLI hooks are a DIFFERENT surface;
    # it MUST NOT claim hook-gated CLI compatibility).
    # * cursor desktop = unverified.
    # * cloud = unsupported (categorical; handled in the lookup for EVERY host, plus this display row).
    $rows = @(
        [pscustomobject][ordered]@{ host = 'claude'; surface = 'cli'; tier = 'verified'
            rationale = 'CLI Stop/SessionStart hook contract exercised end-to-end; the authoritative gated surface.'
            provenance = ''
            limitation = '' }
        [pscustomobject][ordered]@{ host = 'claude'; surface = 'vscode'; tier = 'configuration-compatible'
            rationale = 'Shares Claude settings/hooks with the CLI; the lifecycle is not independently exercised here.'
            provenance = ''
            limitation = '' }
        [pscustomobject][ordered]@{ host = 'codex'; surface = 'cli'; tier = 'verified'
            rationale = 'CLI Stop-contract verified: decision:block gates (runner-observed T036 probe); interactive trust prompt + Specrew hook execution human-observed (maintainer, iter-005).'
            provenance = 'Stop response-shape gating is RUNNER-OBSERVED by the REPRODUCIBLE, DIGEST-BOUND probe tests/host-probes/codex-stop-contract-probe.ps1 (recorded via T018 at the reviewed digest; 2026-07-15): {"decision":"block","reason":...} GATES (structurally: it force-continues the turn, re-firing the Stop hook), {} allows (one fire, no continue), and the Codex-manual {"continue":...} shape does NOT gate (one fire). The interactive native trust request + subsequent Specrew hook execution in a REAL Codex session is HUMAN-OBSERVED (the maintainer exercised it 2026-07-14; not reproducible in a PTY-less probe environment, so it stays human-observed provenance).'
            limitation = 'Narrower headless-trust caveat, recorded SEPARATELY (NOT a whole-CLI downgrade): a trusted / previously-observed headless codex exec hook fires and is supported; an UNTRUSTED headless hook is silently skipped, so governance must fail/degrade before it is relied upon in that case.' }
        [pscustomobject][ordered]@{ host = 'codex'; surface = 'ide'; tier = 'configuration-compatible'
            rationale = 'Shares Codex config layers with the CLI; the lifecycle is not independently exercised here.'
            provenance = ''
            limitation = '' }
        [pscustomobject][ordered]@{ host = 'codex'; surface = 'desktop'; tier = 'configuration-compatible'
            rationale = 'Shares Codex config layers with the CLI; the lifecycle is not independently exercised here.'
            provenance = ''
            limitation = '' }
        [pscustomobject][ordered]@{ host = 'copilot'; surface = 'cli'; tier = 'verified'
            rationale = 'CLI hook contract verified: user-level sessionStart/agentStop fire in -p AND interactive; agentStop decision:block gate + fail-open confirmed (runner-observed T037 probe, iter-005).'
            provenance = 'RUNNER-OBSERVED by the REPRODUCIBLE, DIGEST-BOUND probe tests/host-probes/copilot-hook-firing-probe.ps1 (recorded via T018 at the reviewed digest; 2026-07-15, Copilot CLI 1.0.70): USER-level hooks FIRE in headless copilot -p from an UNTRUSTED cwd (governance rides the user hook; not trust-gated), while REPO-level hooks from an untrusted folder do NOT fire (the trustedFolders gate). User-hook firing in the INTERACTIVE surface plus the agentStop decision:block gate + fail-open behaviors are documented in the T037 characterization (iterations/005/evidence/copilot-cli-contract-characterization.md); the PTY-less probe reproduces the -p surface, and the interactive surface stays characterization-documented (not probe-reproduced).'
            limitation = 'Repo-LEVEL hooks in -p require the trustedFolders opt-in (an untrusted -p folder silently skips repo hooks) - reproduced by the probe. Specrew rides the USER-level hook, which is not trust-gated, so governance is not subject to this gate. The reviewer path''s INTENTIONAL suppression (SPECREW_REFOCUS_DISABLE=1: the hook fires, then no-ops) stays DISTINCT from an accidental never-fired bypass.' }
        [pscustomobject][ordered]@{ host = 'copilot'; surface = 'vscode'; tier = 'unsupported'
            rationale = 'Does NOT receive CLI Stop-hook enforcement; its CLI hooks are a different surface - no reliable gated integration.'
            provenance = ''
            limitation = '' }
        [pscustomobject][ordered]@{ host = 'cursor'; surface = 'desktop'; tier = 'unverified'
            rationale = 'Intended support exists but no conformance probe has passed.'
            provenance = ''
            limitation = '' }
        [pscustomobject][ordered]@{ host = '(any)'; surface = 'cloud'; tier = 'unsupported'
            rationale = 'Beta2 is CLI-first; no cloud-agent Stop-hook enforcement or governance. Richer certification is Beta3 (issue #3084).'
            provenance = ''
            limitation = '' }
    )

    # Structural closed-set guard: a row carrying a tier outside the closed set is a build-time error, so a
    # fabricated tier can never enter the model (NFR-001 - no false-green, enforced at the data seam).
    foreach ($row in $rows) {
        if (-not (Test-SpecrewHostSupportTierValue -Tier $row.tier)) {
            throw "Host-support-tier row for $($row.host)/$($row.surface) uses an invalid tier '$($row.tier)'. Allowed: $((Get-SpecrewHostSupportTierSet) -join ', ')."
        }
    }
    return $rows
}

function Get-SpecrewHostSupportTier {
    # PURE lookup: given a host + surface, return its tier + one-line rationale.
    # * cloud (any host) -> `unsupported`, categorically (NO cloud-agent support may be implied).
    # * a seeded host+surface -> its recorded tier.
    # * anything else -> `unverified` (NEVER a fabricated `verified`).
    # Returns @{ host; surface; tier; rationale; known }. `known` = the pair was in the seeded model.
    param(
        [Parameter(Mandatory)][string] $HostName,
        [Parameter(Mandatory)][string] $Surface
    )
    $hostKey = ConvertTo-SpecrewHostSupportHostKey -HostName $HostName
    $surfaceKey = ConvertTo-SpecrewHostSupportSurfaceKey -Surface $Surface

    # Cloud is categorically unsupported for EVERY host (known or unknown) - the ruling admits no cloud support.
    if ($surfaceKey -eq 'cloud') {
        return [pscustomobject][ordered]@{
            host       = $hostKey
            surface    = 'cloud'
            tier       = 'unsupported'
            rationale  = 'Beta2 is CLI-first; no cloud-agent Stop-hook enforcement or governance. Richer certification is Beta3 (issue #3084).'
            provenance = ''
            limitation = ''
            known      = $true
        }
    }

    $match = @(Get-SpecrewHostSupportTierRows) |
        Where-Object { $_.host -eq $hostKey -and $_.surface -eq $surfaceKey } |
        Select-Object -First 1

    if ($null -ne $match) {
        return [pscustomobject][ordered]@{
            host       = $match.host
            surface    = $match.surface
            tier       = $match.tier
            rationale  = $match.rationale
            provenance = [string]$match.provenance
            limitation = [string]$match.limitation
            known      = $true
        }
    }

    # Unknown host/surface: fail-honest to `unverified`. Support is never ASSUMED - only a passed probe earns
    # `verified`, and only a recorded shared-config claim earns `configuration-compatible`.
    return [pscustomobject][ordered]@{
        host       = $hostKey
        surface    = $surfaceKey
        tier       = 'unverified'
        rationale  = 'No support claim on record for this host/surface; unverified until a conformance probe passes (never assume verified).'
        provenance = ''
        limitation = ''
        known      = $false
    }
}

function Format-SpecrewHostSupportTierReport {
    # Renderer for the doctor/status surface: a deterministic, aligned host+surface -> tier table with a
    # tier legend and the Beta2/Beta3 framing. Returns a STRING (testable; no direct console writes here).
    param(
        [object[]] $Rows
    )
    $data = if ($null -ne $Rows -and @($Rows).Count -gt 0) { @($Rows) } else { @(Get-SpecrewHostSupportTierRows) }

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('=== Specrew Beta2 host-support tiers ===')
    [void]$sb.AppendLine('CLI is the AUTHORITATIVE supported surface. Cloud agents are UNSUPPORTED in Beta2.')
    [void]$sb.AppendLine('Richer desktop / IDE / cloud certification is tracked in issue #3084 (Beta3).')
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine((' {0,-9} {1,-9} {2,-25} {3}' -f 'HOST', 'SURFACE', 'TIER', 'RATIONALE'))
    [void]$sb.AppendLine((' {0,-9} {1,-9} {2,-25} {3}' -f '----', '-------', '----', '---------'))
    foreach ($row in $data) {
        [void]$sb.AppendLine((' {0,-9} {1,-9} {2,-25} {3}' -f $row.host, $row.surface, $row.tier, $row.rationale))
    }
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('Tiers:')
    [void]$sb.AppendLine(' verified - exercised end-to-end on that surface (a real conformance probe passed)')
    [void]$sb.AppendLine(' configuration-compatible - documented shared configuration; lifecycle NOT independently exercised')
    [void]$sb.AppendLine(' unsupported - no reliable gated integration (never implied to be governed)')
    [void]$sb.AppendLine(' unverified - intended support exists but the conformance probe has not passed')

    # Evidence provenance for `verified` gated surfaces whose proof carries HONEST runner/human-observed
    # provenance (+ any narrower limitation). Rendered ONLY for rows that record a provenance string, so the
    # doctor surface never shows a bare `verified` for a flipped gated surface (NFR-001 - provenance travels
    # WITH the claim). StrictMode-safe property probes tolerate caller-supplied rows without these fields.
    $provenanceRows = @($data | Where-Object {
        $_.PSObject.Properties.Match('provenance').Count -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$_.provenance)
    })
    if ($provenanceRows.Count -gt 0) {
        [void]$sb.AppendLine('')
        [void]$sb.AppendLine('Evidence provenance (verified gated surfaces):')
        foreach ($row in $provenanceRows) {
            [void]$sb.AppendLine((' {0}/{1}: {2}' -f $row.host, $row.surface, [string]$row.provenance))
            $lim = if ($row.PSObject.Properties.Match('limitation').Count -gt 0) { [string]$row.limitation } else { '' }
            if (-not [string]::IsNullOrWhiteSpace($lim)) {
                [void]$sb.AppendLine((' limitation: {0}' -f $lim))
            }
        }
    }
    return $sb.ToString()
}