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 manifest-declared stable
  ownership tokens such as dispatcher / launcher filenames and any project-root placeholder. Pure I/O +
  string building; never throws.
#>


Set-StrictMode -Version Latest

$script:HookHealthScriptRoot = $PSScriptRoot

function Test-ManifestKey {
    param($Map, [string]$Key)
    if ($null -eq $Map) { return $false }
    if ($Map -is [System.Collections.IDictionary]) { return $Map.Contains($Key) }
    return $null -ne $Map.PSObject.Properties[$Key]
}

function Get-ManifestValue {
    param($Map, [string]$Key, $Default = $null)
    if (-not (Test-ManifestKey -Map $Map -Key $Key)) { return $Default }
    if ($Map -is [System.Collections.IDictionary]) { return $Map[$Key] }
    return $Map.PSObject.Properties[$Key].Value
}

function Get-SpecrewHookHealthRepoRoot {
    $candidate = $script:HookHealthScriptRoot
    while (-not [string]::IsNullOrWhiteSpace($candidate)) {
        if (Test-Path -LiteralPath (Join-Path $candidate 'hosts') -PathType Container) { return $candidate }
        $parent = Split-Path -Parent $candidate
        if ($parent -eq $candidate) { break }
        $candidate = $parent
    }
    return (Split-Path -Parent (Split-Path -Parent $script:HookHealthScriptRoot))
}

function Find-SpecrewHookHealthManifestPath {
    param([Parameter(Mandatory)][string]$HostKind)
    $repoRoot = Get-SpecrewHookHealthRepoRoot
    $manifestPath = Join-Path $repoRoot ("hosts/{0}/host.psd1" -f $HostKind)
    if (Test-Path -LiteralPath $manifestPath -PathType Leaf) { return $manifestPath }
    return $null
}

function Get-SpecrewHookHealthManifest {
    param([Parameter(Mandatory)][string]$HostKind)
    try {
        $manifestPath = Find-SpecrewHookHealthManifestPath -HostKind $HostKind
        if ([string]::IsNullOrWhiteSpace($manifestPath)) { return $null }
        return (Import-PowerShellDataFile -LiteralPath $manifestPath)
    }
    catch {
        return $null
    }
}

function Get-SpecrewHookHealthBindings {
    param([Parameter(Mandatory)][string]$HostKind)
    $manifest = Get-SpecrewHookHealthManifest -HostKind $HostKind
    if ($null -eq $manifest -or -not (Test-ManifestKey -Map $manifest -Key 'RefocusHookBindings')) { return $null }
    return (Get-ManifestValue -Map $manifest -Key 'RefocusHookBindings')
}

function Resolve-SpecrewHookHealthPath {
    param(
        [AllowNull()][string]$PathFromManifest,
        [Parameter(Mandatory)][string]$ProjectPath,
        [Parameter(Mandatory)][string]$UserHome
    )
    if ([string]::IsNullOrWhiteSpace($PathFromManifest)) { return $null }
    if ($PathFromManifest.StartsWith('~/') -or $PathFromManifest.StartsWith('~\')) {
        return (Join-Path $UserHome $PathFromManifest.Substring(2))
    }
    return (Join-Path $ProjectPath $PathFromManifest)
}

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) enumerate host manifests directly.
    if (Get-Command Get-SpecrewHookCapableHosts -ErrorAction SilentlyContinue) {
        try { $h = @(Get-SpecrewHookCapableHosts); if ($h.Count -gt 0) { return $h } } catch { $null = $_ }
    }
    $repoRoot = Get-SpecrewHookHealthRepoRoot
    $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 = $_ }
    }
    $hostsRoot = Join-Path $repoRoot 'hosts'
    $hosts = New-Object System.Collections.Generic.List[string]
    if (Test-Path -LiteralPath $hostsRoot -PathType Container) {
        foreach ($dir in @(Get-ChildItem -LiteralPath $hostsRoot -Directory -ErrorAction SilentlyContinue)) {
            $manifestPath = Join-Path $dir.FullName 'host.psd1'
            if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { continue }
            try {
                $manifest = Import-PowerShellDataFile -LiteralPath $manifestPath
                if ((Get-ManifestValue -Map $manifest -Key 'Status') -eq 'supported' -and (Test-ManifestKey -Map $manifest -Key 'RefocusHookBindings')) {
                    $hosts.Add([string](Get-ManifestValue -Map $manifest -Key 'Kind' -Default $dir.Name)) | Out-Null
                }
            }
            catch { $null = $_ }
        }
    }
    return $hosts.ToArray()
}

function Get-SpecrewHostHookConfigPath {
    # The per-host hook-config file path — the SAME manifest-declared shape deploy-refocus-hooks.ps1 writes.
    param(
        [Parameter(Mandatory)][string]$HostKind,
        [Parameter(Mandatory)][string]$ProjectPath,
        [Parameter(Mandatory)][string]$UserHome
    )
    $bindings = Get-SpecrewHookHealthBindings -HostKind $HostKind
    return (Resolve-SpecrewHookHealthPath -PathFromManifest ([string](Get-ManifestValue -Map $bindings -Key 'SettingsFile')) -ProjectPath $ProjectPath -UserHome $UserHome)
}

function Get-SpecrewHostOptOutMarkerPath {
    # The opt-out marker path is manifest-declared and normally lives under the project runtime directory.
    param([Parameter(Mandatory)][string]$HostKind, [Parameter(Mandatory)][string]$ProjectPath, [string]$UserHome)
    $bindings = Get-SpecrewHookHealthBindings -HostKind $HostKind
    $resolvedUserHome = if (-not [string]::IsNullOrWhiteSpace($UserHome)) { $UserHome } else { [Environment]::GetFolderPath('UserProfile') }
    return (Resolve-SpecrewHookHealthPath -PathFromManifest ([string](Get-ManifestValue -Map $bindings -Key 'OptOutMarkerFile')) -ProjectPath $ProjectPath -UserHome $resolvedUserHome)
}

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 not in the
    # current manifest-declared command binding, 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 -UserHome $userHome
        $state = 'missing'; $detail = 'no Specrew hook entry'

        # Defensive: a malformed future manifest could omit path fields. Keep the "never throws" contract
        # real by reporting unknown rather than passing $null to Test-Path.
        if ($null -eq $configPath -or $null -eq $optOut) {
            $rows.Add([pscustomobject]@{ Host = $hostKind; State = 'unknown'; ConfigPath = $configPath; Detail = 'host manifest missing hook SettingsFile or OptOutMarkerFile' }) | 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 {
                    $decodedCommands = New-Object System.Collections.Generic.List[string]
                    foreach ($match in [regex]::Matches($raw, '(?i)(?:^|\s)-EncodedCommand\s+([A-Za-z0-9+/=]+)')) {
                        try {
                            $decodedCommands.Add([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($match.Groups[1].Value))) | Out-Null
                        }
                        catch { $null = $_ }
                    }
                    $inspectionText = $raw + "`n" + ($decodedCommands.ToArray() -join "`n")
                    $hasDispatcher = $inspectionText.Contains('specrew-hook-dispatcher.ps1')
                    $hasLauncher = $inspectionText.Contains('specrew-hook-launch.ps1')
                    $bindings = Get-SpecrewHookHealthBindings -HostKind $hostKind
                    $commandMode = [string](Get-ManifestValue -Map $bindings -Key 'CommandMode' -Default 'launcher-file')
                    $configShape = [string](Get-ManifestValue -Map $bindings -Key 'ConfigShape' -Default 'event-map')
                    $requiredTokens = New-Object System.Collections.Generic.List[string]

                    switch ($commandMode) {
                        'project-placeholder' {
                            $requiredTokens.Add('specrew-hook-dispatcher.ps1') | Out-Null
                            $placeholder = [string](Get-ManifestValue -Map $bindings -Key 'ProjectDirPlaceholder' -Default '')
                            if (-not [string]::IsNullOrWhiteSpace($placeholder)) { $requiredTokens.Add($placeholder) | Out-Null }
                        }
                        'launcher-file' { $requiredTokens.Add('specrew-hook-launch.ps1') | Out-Null }
                        'launcher-encoded' { $requiredTokens.Add('specrew-hook-launch.ps1') | Out-Null }
                        default {
                            $state = 'failed'
                            $detail = ("manifest declares unsupported hook CommandMode '{0}'" -f $commandMode)
                        }
                    }

                    if ($state -ne 'failed' -and $configShape -eq 'named-definition') {
                        $definitionName = [string](Get-ManifestValue -Map $bindings -Key 'DefinitionName')
                        if (-not [string]::IsNullOrWhiteSpace($definitionName)) { $requiredTokens.Add($definitionName) | Out-Null }
                        foreach ($registration in @(Get-ManifestValue -Map $bindings -Key 'Registrations')) {
                            $eventName = [string](Get-ManifestValue -Map $registration -Key 'Event')
                            if (-not [string]::IsNullOrWhiteSpace($eventName)) { $requiredTokens.Add($eventName) | Out-Null }
                        }
                    }

                    if ($state -ne 'failed') {
                        $missingRequired = @($requiredTokens.ToArray() | Where-Object { -not $inspectionText.Contains($_) })
                        if ($missingRequired.Count -eq 0 -and $requiredTokens.Count -gt 0) {
                            $state = 'installed'
                            if ($commandMode -eq 'project-placeholder') {
                                $detail = 'dispatcher via manifest project placeholder (cwd-robust)'
                            }
                            elseif ($configShape -eq 'named-definition') {
                                $detail = 'named hook definition via cwd-robust launcher'
                            }
                            else {
                                $detail = 'per-machine launcher entry'
                            }
                        }
                        elseif ($hasDispatcher -or $hasLauncher) {
                            $state = 'stale'
                            $detail = ("Specrew entry present but not the current manifest binding (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
    }
}