scripts/internal/specrew-hook-health.ps1

<#
.SYNOPSIS
  F-174 iteration 011 (FR-028, decision f174-i011-hook-deploy-hardening): hook-health inspection — the
  NON-MIRRORED single source of truth shared by the `specrew hooks` command (layer 2) and the degradation
  diagnostic (layer 3).
.DESCRIPTION
  Two pure, fail-open helpers:
    - Get-SpecrewHooksStatus: per hook-capable host, report installed / missing / stale / opted-out / failed.
    - Test-SpecrewBootstrapDirectiveArrived: did the SessionStart/bootstrap directive land THIS session?
      (layer-3 diagnostic input — see specrew-hook-health Test-* below; the warn-once gate rides it.)
 
  This file is deliberately NOT one of the 3-copy-mirrored hook scripts (deploy-refocus-hooks.ps1 /
  specrew-hook-dispatcher.ps1). It does NOT dot-source the mirrored deploy script (that script has top-level
  side effects — dot-sourcing it would TRIGGER a deploy). It re-derives the per-host config path + the opt-out
  marker (the same shapes deploy-refocus-hooks.ps1 uses) and keys staleness on the STABLE ownership tokens
  (the dispatcher / launcher filenames + the ${CLAUDE_PROJECT_DIR} brace placeholder), which are the contract
  and do not change with command-format tweaks. Pure I/O + string building; never throws.
#>


Set-StrictMode -Version Latest

$script:HookHealthScriptRoot = $PSScriptRoot

function Get-SpecrewHookHealthHostList {
    # The hook-capable host set (registry-driven, single source of truth: a manifest carrying
    # RefocusHookBindings). Fail-open ladder mirrors the deploy orchestrator: (1) the function if loaded;
    # (2) dot-source the registry from the resolved repo path; (3) a last-resort known set.
    if (Get-Command Get-SpecrewHookCapableHosts -ErrorAction SilentlyContinue) {
        try { $h = @(Get-SpecrewHookCapableHosts); if ($h.Count -gt 0) { return $h } } catch { $null = $_ }
    }
    $repoRoot = Split-Path -Parent (Split-Path -Parent $script:HookHealthScriptRoot)
    $registry = Join-Path $repoRoot 'hosts/_registry.ps1'
    if (Test-Path -LiteralPath $registry -PathType Leaf) {
        try { . $registry; $h = @(Get-SpecrewHookCapableHosts); if ($h.Count -gt 0) { return $h } } catch { $null = $_ }
    }
    return @('claude', 'codex', 'copilot', 'cursor')
}

function Get-SpecrewHostHookConfigPath {
    # The per-host hook-config file path — the SAME shapes deploy-refocus-hooks.ps1 writes (claude is
    # PROJECT-level; codex/copilot/cursor are USER-level under the home root).
    param(
        [Parameter(Mandatory)][string]$HostKind,
        [Parameter(Mandatory)][string]$ProjectPath,
        [Parameter(Mandatory)][string]$UserHome
    )
    switch ($HostKind) {
        'claude' { return (Join-Path $ProjectPath '.claude/settings.local.json') }
        'codex' { return (Join-Path $UserHome '.codex/hooks.json') }
        'copilot' { return (Join-Path $UserHome '.copilot/hooks/specrew-refocus.json') }
        'cursor' { return (Join-Path $UserHome '.cursor/hooks.json') }
        default { return $null }
    }
}

function Get-SpecrewHostOptOutMarkerPath {
    # The opt-out marker path — claude has no suffix; user-level hosts are per-host (matches
    # deploy-refocus-hooks.ps1 line 64). Lives under the PROJECT (per-machine, .gitignored runtime).
    param([Parameter(Mandatory)][string]$HostKind, [Parameter(Mandatory)][string]$ProjectPath)
    $suffix = if ($HostKind -ne 'claude') { "-$HostKind" } else { '' }
    return (Join-Path $ProjectPath ('.specrew/runtime/refocus-hooks-optout' + $suffix))
}

function Get-SpecrewHooksStatus {
    # Per hook-capable host: installed | missing | stale | opted-out | failed. Fail-open (never throws).
    # State precedence: opted-out (marker present) > missing (no config file) > failed (config unparsable) >
    # installed/stale/missing (by ownership token). "stale" = a Specrew entry exists but in the OLD form (the
    # pre-ff34e776 bare/relative dispatcher entry instead of the cwd-robust ${CLAUDE_PROJECT_DIR} placeholder
    # (claude) or the per-machine launcher (user-level)) — i.e. `specrew hooks install` would change it.
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)][string]$ProjectPath,
        [string]$UserHomeOverride
    )
    $userHome = if (-not [string]::IsNullOrWhiteSpace($UserHomeOverride)) { $UserHomeOverride } else { [Environment]::GetFolderPath('UserProfile') }
    $rows = New-Object System.Collections.Generic.List[object]
    foreach ($hostKind in (Get-SpecrewHookHealthHostList)) {
        $configPath = Get-SpecrewHostHookConfigPath -HostKind $hostKind -ProjectPath $ProjectPath -UserHome $userHome
        $optOut = Get-SpecrewHostOptOutMarkerPath -HostKind $hostKind -ProjectPath $ProjectPath
        $state = 'missing'; $detail = 'no Specrew hook entry'

        # Defensive (145-review Q-001): Get-SpecrewHostHookConfigPath has cases for the 4 current hook-capable
        # hosts; a FUTURE 5th host in the registry would yield a $null path. Keep the "never throws" contract
        # real — report 'unknown' rather than passing $null to Test-Path (a binding throw that would fail-CLOSE
        # the status surface). Not reachable today (the host list is exactly the 4 cased hosts).
        if ($null -eq $configPath) {
            $rows.Add([pscustomobject]@{ Host = $hostKind; State = 'unknown'; ConfigPath = $null; Detail = 'no config-path mapping for this host (add a case to Get-SpecrewHostHookConfigPath)' }) | Out-Null
            continue
        }

        if (Test-Path -LiteralPath $optOut -PathType Leaf) {
            $state = 'opted-out'; $detail = ("opt-out recorded (re-enable: specrew hooks install --host {0})" -f $hostKind)
        }
        elseif (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) {
            $state = 'missing'; $detail = 'no hook config file'
        }
        else {
            $raw = $null
            try { $raw = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8 } catch { $raw = $null }
            if ($null -eq $raw) {
                $state = 'failed'; $detail = 'config unreadable'
            }
            else {
                $parseOk = $true
                try { $null = $raw | ConvertFrom-Json } catch { $parseOk = $false }
                if (-not $parseOk) {
                    $state = 'failed'; $detail = 'config is not valid JSON (left untouched; specrew hooks cannot repair a hand-broken file)'
                }
                else {
                    $hasDispatcher = $raw.Contains('specrew-hook-dispatcher.ps1')
                    $hasLauncher = $raw.Contains('specrew-hook-launch.ps1')
                    $hasBraced = $raw.Contains('${CLAUDE_PROJECT_DIR}')
                    if ($hostKind -eq 'claude') {
                        if ($hasDispatcher -and $hasBraced) { $state = 'installed'; $detail = 'dispatcher via ${CLAUDE_PROJECT_DIR} placeholder (cwd-robust)' }
                        elseif ($hasDispatcher) { $state = 'stale'; $detail = 'dispatcher entry present but NOT the cwd-robust ${CLAUDE_PROJECT_DIR} form (run: specrew hooks install --host claude)' }
                        else { $state = 'missing'; $detail = 'no Specrew hook entry' }
                    }
                    else {
                        if ($hasLauncher) { $state = 'installed'; $detail = 'per-machine launcher entry' }
                        elseif ($hasDispatcher) { $state = 'stale'; $detail = ("legacy dispatcher entry (pre-launcher form; run: specrew hooks install --host {0})" -f $hostKind) }
                        else { $state = 'missing'; $detail = 'no Specrew hook entry' }
                    }
                }
            }
        }
        $rows.Add([pscustomobject]@{ Host = $hostKind; State = $state; ConfigPath = $configPath; Detail = $detail }) | Out-Null
    }
    # Plain return: the caller wraps with @(). (Do NOT use a leading-comma anti-unwrap here — with the multi-row
    # result it NESTS the array, collapsing every row's fields into one Object[] row.)
    return $rows.ToArray()
}

function Test-SpecrewBootstrapDirectiveArrived {
    # F-174 iteration 011 (FR-028 layer 3, T012): did the SessionStart/bootstrap directive actually land this
    # session? The bootstrap provider writes a per-session runtime trail when its hooks fire
    # (.specrew/runtime/session-marker.json + bootstrap-journal.jsonl). Absence of BOTH in a Specrew project is
    # the signal that hooks are not active for this host. Best-effort + fail-open: an error/uncertainty returns
    # $true (assume arrived) so the layer-3 warning errs toward SILENCE on ambiguity rather than false alarms —
    # the warning is a fallback, not the integrity mechanism. Pass -SessionId to scope to the live session when
    # the host exposes one; without it, ANY recent runtime trail counts as arrived.
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$ProjectPath,
        [AllowNull()][string]$SessionId
    )
    try {
        $runtime = Join-Path $ProjectPath '.specrew/runtime'
        if (-not (Test-Path -LiteralPath $runtime -PathType Container)) { return $false }
        $marker = Join-Path $runtime 'session-marker.json'
        $journal = Join-Path $runtime 'bootstrap-journal.jsonl'
        if (-not (Test-Path -LiteralPath $marker -PathType Leaf) -and -not (Test-Path -LiteralPath $journal -PathType Leaf)) {
            return $false
        }
        # A session-scoped check when we have an id: the marker/journal must reference THIS session.
        if (-not [string]::IsNullOrWhiteSpace($SessionId)) {
            $idHit = $false
            foreach ($f in @($marker, $journal)) {
                if (Test-Path -LiteralPath $f -PathType Leaf) {
                    try { $txt = Get-Content -LiteralPath $f -Raw -Encoding UTF8 } catch { $txt = '' }
                    if (-not [string]::IsNullOrWhiteSpace($txt) -and $txt.Contains($SessionId)) { $idHit = $true; break }
                }
            }
            return $idHit
        }
        # No session id: presence of a runtime trail at all is the best signal we have.
        return $true
    }
    catch {
        return $true   # fail-open toward silence (never false-alarm the human)
    }
}

function Test-SpecrewIsProject {
    # A Specrew project = a .specrew/ directory AND the deployed speckit extension present. Both, so a bare
    # .specrew/ (or a non-Specrew repo that happens to have one) does not false-positive the diagnostic.
    [OutputType([bool])]
    param([Parameter(Mandatory)][string]$ProjectPath)
    if (-not (Test-Path -LiteralPath (Join-Path $ProjectPath '.specrew') -PathType Container)) { return $false }
    return (Test-Path -LiteralPath (Join-Path $ProjectPath '.specify/extensions/specrew-speckit') -PathType Container)
}

function Get-SpecrewHookDegradationWarning {
    # F-174 iteration 011 (FR-028 layer 3, T012, SC-018): the warn-ONCE degradation gate. Returns the warning
    # STRING when ALL hold — (1) in a Specrew project, (2) the SessionStart/bootstrap directive did NOT arrive
    # this session (hooks look inactive for this host), (3) not already warned this session — else $null. This is
    # a FALLBACK diagnostic the agent surfaces from an always-loaded instruction; it is NEVER the integrity
    # mechanism. Warn-once is enforced by a per-session marker so a multi-turn session does not spam. -Peek
    # computes the verdict WITHOUT recording the marker (for `specrew hooks status` / tests). Fail-open: any error
    # returns $null (err toward silence; never false-alarm). Pure-ish I/O; never throws.
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$ProjectPath,
        [AllowNull()][string]$SessionId,
        [switch]$Peek
    )
    try {
        if (-not (Test-SpecrewIsProject -ProjectPath $ProjectPath)) { return $null }
        if (Test-SpecrewBootstrapDirectiveArrived -ProjectPath $ProjectPath -SessionId $SessionId) { return $null }
        $key = if ([string]::IsNullOrWhiteSpace($SessionId)) { 'nosession' } else { ($SessionId -replace '[^A-Za-z0-9]', '-') }
        $marker = Join-Path $ProjectPath ('.specrew/runtime/hook-degradation-warned-' + $key)
        if (Test-Path -LiteralPath $marker -PathType Leaf) { return $null }
        if (-not $Peek) {
            try {
                $dir = Split-Path -Parent $marker
                if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
                [System.IO.File]::WriteAllText($marker, ("warned {0}" -f $key), [System.Text.UTF8Encoding]::new($false))
            }
            catch { $null = $_ }
        }
        return 'Specrew hooks do not appear active for this host. Automatic handover and verdict capture may be unavailable. Run `specrew hooks status` or `specrew update` to repair.'
    }
    catch {
        return $null
    }
}