scripts/internal/refocus-deploy-integration.ps1

# Feature 171 T017 (FR-014/FR-018): refocus deploy integration for init/update.
# Dot-sourced by scripts/specrew-update.ps1 and scripts/specrew-init.ps1.
#
# Two responsibilities:
# 1. Catalog managed-with-overlay merge: the speckit-extension deploy mirrors
# refocus-scopes.json WHOLESALE, which would clobber user keys. Capture the
# user-owned keys BEFORE the deploy (per-trigger `enabled` flags + provider
# rows whose id is not canonical) and re-apply them AFTER. Fail SAFE: an
# unreadable pre-existing catalog aborts the overlay (capture returns the
# abort marker) and the re-apply leaves the freshly-deployed canonical file
# untouched rather than guessing.
# 2. Hook deployment wiring: invoke deploy-refocus-hooks.ps1 per host — claude
# when the project carries .claude/, codex/copilot/cursor when the host
# binary is on PATH. The deploy script itself respects recorded opt-outs
# (no silent re-enable; the update-never-flips-disables principle).

function Get-RefocusCatalogOverlay {
    param([Parameter(Mandatory = $true)][string]$ProjectPath)
    $catalogPath = Join-Path $ProjectPath '.specify/extensions/specrew-speckit/refocus-scopes.json'
    if (-not (Test-Path -LiteralPath $catalogPath -PathType Leaf)) {
        return [pscustomobject]@{ Present = $false; Aborted = $false; TriggerEnabled = @{}; UserProviders = @() }
    }
    try {
        $catalog = Get-Content -LiteralPath $catalogPath -Raw -Encoding UTF8 | ConvertFrom-Json
    }
    catch {
        # Fail SAFE: unreadable catalog -> no overlay (the deploy will restore a
        # clean canonical file; we never merge into something we cannot parse).
        return [pscustomobject]@{ Present = $true; Aborted = $true; TriggerEnabled = @{}; UserProviders = @() }
    }
    $triggerEnabled = @{}
    if ($catalog.PSObject.Properties['triggers'] -and $null -ne $catalog.triggers) {
        foreach ($prop in $catalog.triggers.PSObject.Properties) {
            if ($prop.Value.PSObject.Properties['enabled']) {
                $triggerEnabled[$prop.Name] = [bool]$prop.Value.enabled
            }
        }
    }
    # Canonical Specrew provider ids (module-shipped): refocus (F-171) + bootstrap + handover
    # (F-174). Everything else in the project catalog is a user overlay row to capture + re-apply.
    $canonicalProviderIds = @('refocus', 'bootstrap', 'handover')
    $userProviders = @()
    if ($catalog.PSObject.Properties['providers'] -and $null -ne $catalog.providers) {
        $userProviders = @($catalog.providers | Where-Object { $canonicalProviderIds -notcontains [string]$_.id })
    }
    return [pscustomobject]@{ Present = $true; Aborted = $false; TriggerEnabled = $triggerEnabled; UserProviders = $userProviders }
}

function Set-RefocusCatalogOverlay {
    param(
        [Parameter(Mandatory = $true)][string]$ProjectPath,
        [Parameter(Mandatory = $true)]$Overlay
    )
    if (-not $Overlay.Present -or $Overlay.Aborted) { return $false }
    if (($Overlay.TriggerEnabled.Count -eq 0) -and (@($Overlay.UserProviders).Count -eq 0)) { return $false }
    $catalogPath = Join-Path $ProjectPath '.specify/extensions/specrew-speckit/refocus-scopes.json'
    if (-not (Test-Path -LiteralPath $catalogPath -PathType Leaf)) { return $false }
    try {
        $catalog = Get-Content -LiteralPath $catalogPath -Raw -Encoding UTF8 | ConvertFrom-Json
    }
    catch { return $false }   # fail safe: never merge into an unparsable file

    $changed = $false
    if ($catalog.PSObject.Properties['triggers'] -and $null -ne $catalog.triggers) {
        foreach ($key in $Overlay.TriggerEnabled.Keys) {
            $trigger = $catalog.triggers.PSObject.Properties[$key]
            if ($null -ne $trigger -and $trigger.Value.PSObject.Properties['enabled'] -and ([bool]$trigger.Value.enabled) -ne $Overlay.TriggerEnabled[$key]) {
                $trigger.Value.PSObject.Properties['enabled'].Value = $Overlay.TriggerEnabled[$key]
                $changed = $true
            }
        }
    }
    if (@($Overlay.UserProviders).Count -gt 0 -and $catalog.PSObject.Properties['providers']) {
        # Dup-ID guard (PR #2152 review): if a newer canonical catalog now ships a
        # provider whose id matches a captured user row, do NOT re-append it — that would
        # duplicate the id and run the provider twice. Only restore user rows whose id is
        # absent from the freshly-deployed canonical set.
        $canonical = @($catalog.providers)
        $canonicalIds = @($canonical | ForEach-Object { [string]$_.id })
        $newUserProviders = @($Overlay.UserProviders | Where-Object { $canonicalIds -notcontains [string]$_.id })
        if (@($newUserProviders).Count -gt 0) {
            $catalog.PSObject.Properties['providers'].Value = @($canonical + $newUserProviders)
            $changed = $true
        }
    }
    if ($changed) {
        [System.IO.File]::WriteAllText($catalogPath, ($catalog | ConvertTo-Json -Depth 16), [System.Text.UTF8Encoding]::new($false))
    }
    return $changed
}

function Invoke-RefocusHookDeployment {
    param(
        [Parameter(Mandatory = $true)][string]$ProjectPath,
        [Parameter(Mandatory = $true)][string]$DeployScriptPath,
        # Hermetic-test seam (PR #2152 review): deploy-refocus-hooks.ps1 already supports
        # -UserHomeOverride; expose it here so e2e/consumer test runners can keep codex/
        # copilot/cursor writes out of the REAL user home. Unset in production (no behavior
        # change — the deploy script defaults to the real home).
        [string]$UserHomeOverride
    )
    $actions = New-Object System.Collections.Generic.List[object]
    if (-not (Test-Path -LiteralPath $DeployScriptPath -PathType Leaf)) { return $actions.ToArray() }

    # F-174 iter-11 (FR-028 layer 1, T010, decision f174-i011-hook-deploy-hardening): PROACTIVE provisioning.
    # Provision hook configs for ALL hook-capable registry hosts, NOT only hosts detected on PATH. The old
    # PATH gate (Get-Command codex/copilot/cursor) left a SILENT degradation hole: a user who ran `specrew
    # init`, later installed Codex/Copilot/Cursor, and launched directly got hookless behavior with no
    # warning. The user-level configs point at the per-machine launcher, which no-ops outside a Specrew
    # project, so provisioning is SAFE even when the host binary is absent (the launcher install rides this
    # too — a later `specrew update` is all the user needs). claude is PROJECT-level
    # (.claude/settings.local.json), provisioned only when the project carries .claude/ (the Squad runtime
    # creates it before this runs — the ordering invariant). The deploy script preserves user entries,
    # replaces only Specrew-owned entries, and RESPECTS recorded opt-outs (no silent re-enable).
    #
    # Hook-capable hosts come from the registry (single source of truth: a manifest carrying
    # RefocusHookBindings — Get-SpecrewHookCapableHosts), with a fail-open ladder: (1) the function if already
    # loaded; (2) dot-source the registry from the resolved repo path; (3) a last-resort known set so a
    # registry-load failure never silently provisions NOTHING. Antigravity (hookless — no RefocusHookBindings)
    # is correctly excluded by the registry.
    $hookCapable = $null
    if (Get-Command Get-SpecrewHookCapableHosts -ErrorAction SilentlyContinue) {
        try { $hookCapable = @(Get-SpecrewHookCapableHosts) } catch { $hookCapable = $null }
    }
    if ($null -eq $hookCapable -or $hookCapable.Count -eq 0) {
        $registryPath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'hosts/_registry.ps1'
        if (Test-Path -LiteralPath $registryPath -PathType Leaf) {
            try { . $registryPath; $hookCapable = @(Get-SpecrewHookCapableHosts) } catch { $hookCapable = $null }
        }
    }
    if ($null -eq $hookCapable -or $hookCapable.Count -eq 0) {
        $hookCapable = @('claude', 'codex', 'copilot', 'cursor')   # last-resort known hook-capable set
    }

    $hostTargets = New-Object System.Collections.Generic.List[string]
    foreach ($hostKind in $hookCapable) {
        if ($hostKind -eq 'claude') {
            # PROJECT-level config — only meaningful once the project carries .claude/ (created by the Squad
            # runtime before this runs). Skip if absent rather than create the directory here.
            if (Test-Path -LiteralPath (Join-Path $ProjectPath '.claude') -PathType Container) { $hostTargets.Add('claude') | Out-Null }
        }
        else {
            # USER-level config (codex/copilot/cursor) -> per-machine launcher; provisioned PROACTIVELY
            # regardless of whether the host binary is currently on PATH (the user may install it later).
            $hostTargets.Add($hostKind) | Out-Null
        }
    }

    foreach ($hostKind in $hostTargets) {
        try {
            $deployArgs = @{ ProjectPath = $ProjectPath; HostKind = $hostKind }
            if (-not [string]::IsNullOrWhiteSpace($UserHomeOverride)) { $deployArgs['UserHomeOverride'] = $UserHomeOverride }
            $output = @(& $DeployScriptPath @deployArgs 2>&1 | ForEach-Object { [string]$_ })
            $actions.Add([pscustomobject]@{ HostKind = $hostKind; Action = 'refocus-hooks'; Detail = ($output -join ' ') }) | Out-Null
        }
        catch {
            # Fail open: hook deployment problems never fail init/update.
            $actions.Add([pscustomobject]@{ HostKind = $hostKind; Action = 'refocus-hooks-failed'; Detail = $_.Exception.Message }) | Out-Null
        }
    }
    return $actions.ToArray()
}