scripts/internal/deploy-refocus-hooks.ps1

# Feature 171 T010+T014 (FR-013/FR-014): merge-aware refocus hook deployment,
# multi-host. Per-host formats verified live 2026-06-07 (see
# specs/171-specrew-refocus/research-matrix.md for citations):
#
# claude : .claude/settings.local.json (per-user project-local; merge-aware
# groups under hooks.<Event>) — SessionStart only (TG-004 option a)
# codex : ~/.codex/hooks.json ({ hooks: { <Event>: [ { hooks: [...] } ] } } — events
# NESTED under `hooks` per codex's schema) — SessionStart + UserPromptSubmit + Stop
# copilot : ~/.copilot/hooks/specrew-refocus.json (hooks-DIR model: this file
# is wholly Specrew-owned; {version,hooks.<event>[]} with type=command
# + bash/powershell pair) — sessionStart (B2)
# cursor : ~/.cursor/hooks.json ({version,hooks.<event>[]} with bare
# {command} entries) — sessionStart (B2)
#
# C6 invariants (every host): add-if-absent; update ONLY entries recognized as
# Specrew's (dispatcher path inside the command); user entries preserved exactly;
# re-deploys byte-idempotent; recorded opt-out respected (never silently
# re-enabled); PreToolUse NEVER registered (dormant F-165 gate seat).
# Cwd-independence (the central mechanism): the DEPLOYED dispatcher self-locates the
# project root from its OWN location ($PSScriptRoot) — never from a guessed cwd — so
# once invoked it always resolves the correct project. The only per-host variation is
# HOW the host's command string names an entry point that resolves from ANY cwd:
# - claude (PROJECT-level .claude/settings.local.json, version-tracked here): use the
# host-substituted ${CLAUDE_PROJECT_DIR} placeholder so the committed config stays
# portable across clone / worktree / relocation (Claude replaces it host-side before
# spawn; a baked absolute path stales on relocation, a bare relative path fails when
# cwd != project root — the "file does not exist" hook errors we are fixing).
# - codex / copilot / cursor (USER-level configs under ~/, shared across ALL of the
# user's projects): the command string cannot name a per-project dispatcher, and
# these hosts expose NO reliable project-root placeholder in the command string
# (codex/copilot have none; cursor's is shell-dependent). So they point at ONE
# per-machine launcher (~/.specrew/specrew-hook-launch.ps1) that resolves WHICH
# project the live session is in (env CLAUDE_PROJECT_DIR/CURSOR_PROJECT_DIR -> stdin
# cwd/workspace_roots -> cwd walk-up keyed on the dispatcher subpath) and hands off
# to that project's deployed dispatcher.
[CmdletBinding()]
param(
    [string]$ProjectPath = '.',
    [ValidateSet('claude', 'codex', 'copilot', 'cursor')][string]$HostKind = 'claude',
    [switch]$Remove,
    [switch]$Force,
    # Test seam: override the user-home root so suites never touch the real one.
    [string]$UserHomeOverride
)

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

$projectRoot = (Resolve-Path -LiteralPath $ProjectPath).Path
$userHome = if (-not [string]::IsNullOrWhiteSpace($UserHomeOverride)) { $UserHomeOverride } else { [Environment]::GetFolderPath('UserProfile') }
$dispatcherRelPath = '.specify/extensions/specrew-speckit/scripts/specrew-hook-dispatcher.ps1'
# The per-machine launcher the USER-level hosts (codex/copilot/cursor) point at. It lives OUTSIDE any project
# (under ~/) because one user-level config is shared across all the user's projects; it resolves whichever
# project the live session is in, then hands off to that project's deployed dispatcher. An ABSOLUTE path is
# correct here: user-level configs are per-machine and regenerated by this deploy, never committed/cloned.
$launcherPath = (Join-Path $userHome '.specrew/specrew-hook-launch.ps1') -replace '\\', '/'

$settingsPath = switch ($HostKind) {
    'claude'  { Join-Path $projectRoot '.claude/settings.local.json' }
    'codex'   { Join-Path $userHome '.codex/hooks.json' }
    'copilot' { Join-Path $userHome '.copilot/hooks/specrew-refocus.json' }
    'cursor'  { Join-Path $userHome '.cursor/hooks.json' }
}
$optOutMarker = Join-Path $projectRoot ('.specrew/runtime/refocus-hooks-optout' + $(if ($HostKind -ne 'claude') { "-$HostKind" } else { '' }))

function Get-SpecrewHookCommand {
    param([string]$EventName)
    # claude (PROJECT-level, version-tracked config): point straight at the dispatcher via the host-substituted
    # ${CLAUDE_PROJECT_DIR} placeholder — portable across clone/worktree (Claude replaces it host-side before
    # spawn) AND cwd-robust (an absolute path after substitution). The BRACE form is required: bare
    # $CLAUDE_PROJECT_DIR is not substituted and fails on Windows. codex/copilot/cursor (USER-level configs
    # shared across all projects): point at the per-machine launcher, which resolves the live session's project
    # and hands off to that project's deployed dispatcher. Both forms still contain a Specrew ownership token (a
    # dispatcher or launcher filename) so Test-IsSpecrewCommandText recognizes our entries on re-deploy.
    $target = if ($HostKind -eq 'claude') { '${CLAUDE_PROJECT_DIR}/' + $dispatcherRelPath } else { $launcherPath }
    return ('pwsh -NoProfile -ExecutionPolicy Bypass -File "{0}" -Event {1} -HostKind {2}' -f $target, $EventName, $HostKind)
}

function Test-IsSpecrewCommandText {
    param([AllowNull()][string]$CommandText)
    # Ownership = the command names one of OUR two entry points: the dispatcher (claude points straight at it via
    # the placeholder) or the launcher (codex/copilot/cursor point at it). Matching BOTH lets a re-deploy
    # recognize-and-replace legacy entries that named the dispatcher relatively AND the current launcher-based
    # entries, so the migration is automatic (strip-then-add) with no orphaned/duplicate hooks.
    if ([string]::IsNullOrWhiteSpace($CommandText)) { return $false }
    return $CommandText.Contains('specrew-hook-dispatcher.ps1') -or $CommandText.Contains('specrew-hook-launch.ps1')
}

function Install-HookLauncher {
    # Generate the per-machine user-level launcher (~/.specrew/specrew-hook-launch.ps1) that the USER-level hosts
    # (codex/copilot/cursor) point at. Idempotent: same bytes every deploy. The launcher is intentionally
    # SELF-CONTAINED — it runs BEFORE any project is known, so it cannot dot-source project files; it does only
    # the minimal bootstrap resolution needed to FIND the deployed dispatcher, then hands off (the dispatcher
    # re-resolves the project from its own $PSScriptRoot). A single-quoted here-string keeps the launcher's own
    # $env:/$raw/${...} literal — it is NOT expanded at deploy time. The launcher path is the only deploy-time
    # value, and it is baked into the command string (Get-SpecrewHookCommand), not into the launcher body.
    $launcherBody = @'
# Specrew user-level hook launcher — GENERATED per-machine by deploy-refocus-hooks.ps1 (do NOT edit by hand).
# User-level host configs (codex ~/.codex, copilot ~/.copilot, cursor ~/.cursor) are SHARED across ALL of the
# user's projects, so their command string cannot name a per-project dispatcher path. This launcher resolves
# WHICH project the live session belongs to, then hands off to that project's DEPLOYED dispatcher. It holds only
# the minimal bootstrap resolution needed to FIND the dispatcher file; the dispatcher itself re-resolves the
# project root from its own location. ALWAYS exits 0 (fail-open) — a launcher failure may never block a session.
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)][string]$Event,
    [ValidateSet('claude', 'codex', 'copilot', 'cursor')][string]$HostKind = 'codex',
    [int]$ProviderTimeoutSeconds = 20
)
# KILL SWITCH FIRST — before any logic that could itself fail (FR-008 doctrine).
if (-not [string]::IsNullOrWhiteSpace($env:SPECREW_REFOCUS_DISABLE)) { exit 0 }
$ErrorActionPreference = 'Stop'
 
# The dispatcher's project-relative subpath — the SENTINEL we look for when walking up a candidate root. We key
# on the dispatcher FILE (not a .specrew dir) so a stray ~/.specrew up the cwd tree never mis-resolves a project
# (this launcher itself lives under ~/.specrew).
$dispatcherSub = '.specify/extensions/specrew-speckit/scripts/specrew-hook-dispatcher.ps1'
 
function Find-DispatcherUpTree {
    param([string]$Start, [string]$Sub)
    $candidate = $Start
    while (-not [string]::IsNullOrWhiteSpace($candidate)) {
        $probe = Join-Path $candidate $Sub
        if (Test-Path -LiteralPath $probe -PathType Leaf) { return $probe }
        $parent = Split-Path -Parent $candidate
        if ($parent -eq $candidate) { break }
        $candidate = $parent
    }
    return $null
}
 
# Read the host event JSON from stdin ONCE — but ONLY when stdin is actually redirected. A bare ReadToEnd() on a
# NON-redirected stdin BLOCKS until EOF, and a hanging hook blocks the session (the exact fail-open violation we
# must avoid; a try/catch does NOT rescue a blocking read). Mirrors the dispatcher's own guard.
$raw = ''
if ([Console]::IsInputRedirected) {
    try { $raw = [Console]::In.ReadToEnd() } catch { $raw = '' }
}
$payloadCwd = $null
$payloadRoots = @()
if (-not [string]::IsNullOrWhiteSpace($raw)) {
    try {
        $obj = $raw | ConvertFrom-Json
        if ($obj.PSObject.Properties['cwd']) { $payloadCwd = [string]$obj.cwd }
        if ($obj.PSObject.Properties['workspace_roots'] -and $null -ne $obj.workspace_roots) {
            $payloadRoots = @($obj.workspace_roots | ForEach-Object { [string]$_ })
        }
    } catch { $null = $_ } # malformed payload -> fall through to env/cwd resolution
}
 
# Candidate project roots, in priority order: host project-root env vars (cursor sets CURSOR_PROJECT_DIR + a
# CLAUDE_PROJECT_DIR alias; codex/copilot set neither), then the payload cwd/workspace_roots (codex/copilot's
# only project signal), then the live cwd. For EACH we walk UP looking for the dispatcher subpath.
$candidates = New-Object System.Collections.Generic.List[string]
foreach ($c in @($env:CLAUDE_PROJECT_DIR, $env:CURSOR_PROJECT_DIR, $payloadCwd)) {
    if (-not [string]::IsNullOrWhiteSpace($c)) { $candidates.Add($c) }
}
foreach ($r in $payloadRoots) { if (-not [string]::IsNullOrWhiteSpace($r)) { $candidates.Add($r) } }
try { $candidates.Add((Get-Location).Path) } catch { $null = $_ }
 
$dispatcher = $null
foreach ($start in $candidates) {
    $found = Find-DispatcherUpTree -Start $start -Sub $dispatcherSub
    if (-not [string]::IsNullOrWhiteSpace($found)) { $dispatcher = $found; break }
}
if ([string]::IsNullOrWhiteSpace($dispatcher)) { exit 0 } # no project resolvable from any signal -> fire nothing (fail-open)
 
# Hand off to the project's deployed dispatcher. Pass the captured payload via -EventJson so the dispatcher does
# not try to re-read the now-consumed stdin (only when non-empty). The dispatcher's stdout (injection output)
# flows through this process to the host. Always exit 0.
$dispatchArgs = @{ Event = $Event; HostKind = $HostKind; ProviderTimeoutSeconds = $ProviderTimeoutSeconds }
if (-not [string]::IsNullOrWhiteSpace($raw)) { $dispatchArgs['EventJson'] = $raw }
try { & $dispatcher @dispatchArgs }
catch { [Console]::Error.WriteLine("[specrew-refocus] WARN LAUNCH_FAILED $($_.Exception.Message)") }
exit 0
'@

    New-Item -ItemType Directory -Path (Split-Path -Parent $launcherPath) -Force | Out-Null
    [System.IO.File]::WriteAllText($launcherPath, $launcherBody, [System.Text.UTF8Encoding]::new($false))
}

function Test-IsSpecrewGroup {
    # A group is Specrew's when EVERY command inside it is ours. Group shapes vary:
    # claude/codex: { matcher?, hooks: [ { type, command } ] }
    # cursor: { command }
    # copilot: { type, bash, powershell }
    param($Group)
    $commands = @()
    if ($Group.PSObject.Properties['hooks'] -and $null -ne $Group.hooks) {
        $commands = @(@($Group.hooks) | ForEach-Object { if ($_.PSObject.Properties['command']) { [string]$_.command } })
    }
    elseif ($Group.PSObject.Properties['command']) { $commands = @([string]$Group.command) }
    elseif ($Group.PSObject.Properties['bash'] -or $Group.PSObject.Properties['powershell']) {
        $commands = @(
            $(if ($Group.PSObject.Properties['bash']) { [string]$Group.bash }),
            $(if ($Group.PSObject.Properties['powershell']) { [string]$Group.powershell })
        ) | Where-Object { $_ }
    }
    if ($commands.Count -eq 0) { return $false }
    return @($commands | Where-Object { -not (Test-IsSpecrewCommandText -CommandText $_) }).Count -eq 0
}

function Remove-SpecrewEntriesFromEventMap {
    # Strips Specrew groups from an event map (PSCustomObject of event -> group[]).
    # Mixed claude/codex-style groups keep their user hooks; user groups untouched.
    param($EventMap)
    if ($null -eq $EventMap) { return }
    foreach ($eventProp in @($EventMap.PSObject.Properties)) {
        $kept = New-Object System.Collections.Generic.List[object]
        foreach ($group in @($eventProp.Value)) {
            if (Test-IsSpecrewGroup -Group $group) { continue }   # wholly ours: dropped
            if ($group.PSObject.Properties['hooks'] -and $null -ne $group.hooks) {
                $userHooks = @(@($group.hooks) | Where-Object {
                        -not ($_.PSObject.Properties['command'] -and (Test-IsSpecrewCommandText -CommandText ([string]$_.command)))
                    })
                if ($userHooks.Count -lt @($group.hooks).Count -and $userHooks.Count -gt 0) {
                    $group.PSObject.Properties['hooks'].Value = $userHooks
                }
                elseif ($userHooks.Count -eq 0) { continue }
            }
            $kept.Add($group) | Out-Null
        }
        # PSPropertyInfo setter — dynamic `$obj.($name) =` trips the binder on JSON objects.
        if ($kept.Count -gt 0) { $eventProp.Value = $kept.ToArray() }
        else { $EventMap.PSObject.Properties.Remove($eventProp.Name) }
    }
}

function Get-HostEventGroups {
    # The per-host registrations (verified formats; matrix-gated trigger set).
    switch ($HostKind) {
        'claude' {
            # SessionStart (B1/B2 + F-174 bootstrap) + Stop (F-174 iter-4 rolling handover - portable +
            # crash-safe). PLUS PostToolUse (F-174 iter-9.1): refresh the rolling handover mid-turn during
            # picker-driven phases like the design workshop, where no end-of-turn Stop fires - the handover
            # provider's material-change gate keeps the per-tool-call cost cheap. (This also activates refocus
            # B3 boundary-cross injection on claude, which is the intended F-171 behavior.)
            return [ordered]@{
                'SessionStart' = [pscustomobject]@{ hooks = @([pscustomobject]@{ type = 'command'; command = (Get-SpecrewHookCommand -EventName 'SessionStart') }) }
                'Stop'         = [pscustomobject]@{ hooks = @([pscustomobject]@{ type = 'command'; command = (Get-SpecrewHookCommand -EventName 'Stop') }) }
                'PostToolUse'  = [pscustomobject]@{ hooks = @([pscustomobject]@{ type = 'command'; command = (Get-SpecrewHookCommand -EventName 'PostToolUse') }) }
            }
        }
        'codex' {
            # SessionStart (B1/B2) + UserPromptSubmit (B3) + Stop (F-174 iter-4 rolling handover).
            return [ordered]@{
                'SessionStart'     = [pscustomobject]@{ hooks = @([pscustomobject]@{ type = 'command'; command = (Get-SpecrewHookCommand -EventName 'SessionStart'); timeout = 30 }) }
                'UserPromptSubmit' = [pscustomobject]@{ hooks = @([pscustomobject]@{ type = 'command'; command = (Get-SpecrewHookCommand -EventName 'UserPromptSubmit'); timeout = 30 }) }
                'Stop'             = [pscustomobject]@{ hooks = @([pscustomobject]@{ type = 'command'; command = (Get-SpecrewHookCommand -EventName 'Stop'); timeout = 30 }) }
            }
        }
        'copilot' {
            # B2 (sessionStart) + agentStop (F-174 iter-4 rolling handover; Copilot's end-of-turn event).
            $cmd = Get-SpecrewHookCommand -EventName 'SessionStart'
            $stopCmd = Get-SpecrewHookCommand -EventName 'agentStop'
            return [ordered]@{
                'sessionStart' = [pscustomobject]@{ type = 'command'; bash = $cmd; powershell = $cmd; timeoutSec = 30 }
                'agentStop'    = [pscustomobject]@{ type = 'command'; bash = $stopCmd; powershell = $stopCmd; timeoutSec = 30 }
            }
        }
        'cursor' {
            # B2 (sessionStart) + stop (F-174 iter-4 rolling handover; Cursor's end-of-turn event).
            return [ordered]@{
                'sessionStart' = [pscustomobject]@{ command = (Get-SpecrewHookCommand -EventName 'SessionStart') }
                'stop'         = [pscustomobject]@{ command = (Get-SpecrewHookCommand -EventName 'stop') }
            }
        }
    }
}

# --- load (or initialize) the target file --------------------------------------
$settings = $null
if (Test-Path -LiteralPath $settingsPath -PathType Leaf) {
    try { $settings = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json }
    catch { throw "config file unreadable at $settingsPath — refusing to modify a file I cannot parse (user content safety): $($_.Exception.Message)" }
}
if ($null -eq $settings) { $settings = [pscustomobject]@{} }

# Locate the event map per host file shape. ALL hosts nest events under a top-level `hooks` object
# ({ ..., hooks: { event: [...] } }; + version for cursor/copilot). codex was previously written with
# top-level event keys (no `hooks` wrapper), but codex's documented schema is { hooks: { <Event>: [...] } }
# (developers.openai.com/codex/hooks), so it never saw the top-level entries — the SessionStart bootstrap
# silently never fired on codex. ONE-TIME MIGRATION: when a codex file is still the old top-level shape,
# strip our old top-level entries (user keys preserved) BEFORE switching to the wrapped map, so we do not
# leave orphaned/duplicate hooks.
if ($HostKind -eq 'codex' -and -not ($settings.PSObject.Properties['hooks'] -and $null -ne $settings.hooks -and -not ($settings.hooks -is [System.Array]))) {
    Remove-SpecrewEntriesFromEventMap -EventMap $settings
}
if (-not $settings.PSObject.Properties['hooks'] -or $null -eq $settings.hooks -or ($settings.hooks -is [System.Array])) {
    # Defensiveness: a stale `hooks` written as a JSON ARRAY by a corrupted prior deploy is NOT a valid
    # event map. The old code passed it straight into Remove-SpecrewEntriesFromEventMap, which iterated the
    # array's intrinsic members and crashed setting the read-only `Length` ("Length is a ReadOnly property"),
    # aborting the deploy and leaving ~/.codex/hooks.json unparseable by codex ("invalid type: map, expected
    # a sequence"). Drop the malformed `hooks` and reset it to a clean map so the deploy self-heals.
    if ($settings.PSObject.Properties['hooks']) { $settings.PSObject.Properties.Remove('hooks') | Out-Null }
    $settings | Add-Member -NotePropertyName 'hooks' -NotePropertyValue ([pscustomobject]@{}) -Force
}
$eventMap = $settings.hooks

function Save-Target {
    param($SettingsObject)
    if ($HostKind -in @('cursor', 'copilot') -and -not $SettingsObject.PSObject.Properties['version']) {
        $SettingsObject | Add-Member -NotePropertyName 'version' -NotePropertyValue 1 -Force
    }
    $json = $SettingsObject | ConvertTo-Json -Depth 16
    New-Item -ItemType Directory -Path (Split-Path -Parent $settingsPath) -Force | Out-Null
    [System.IO.File]::WriteAllText($settingsPath, $json, [System.Text.UTF8Encoding]::new($false))
}

if ($Remove) {
    if ($HostKind -eq 'copilot') {
        # Hooks-dir model: the whole file is ours — remove it.
        if (Test-Path -LiteralPath $settingsPath -PathType Leaf) { Remove-Item -LiteralPath $settingsPath -Force }
    }
    else {
        Remove-SpecrewEntriesFromEventMap -EventMap $eventMap
        Save-Target -SettingsObject $settings
    }
    New-Item -ItemType Directory -Path (Split-Path -Parent $optOutMarker) -Force | Out-Null
    [System.IO.File]::WriteAllText($optOutMarker, ("opted out {0}`n" -f (Get-Date).ToUniversalTime().ToString('o')), [System.Text.UTF8Encoding]::new($false))
    Write-Output ("[specrew-refocus] {0} hooks removed; opt-out recorded (re-enable: deploy-refocus-hooks.ps1 -HostKind {0} -Force)" -f $HostKind)
    exit 0
}

if ((Test-Path -LiteralPath $optOutMarker -PathType Leaf) -and -not $Force) {
    Write-Output ("[specrew-refocus] {0} hook deployment skipped: opt-out recorded (re-enable explicitly with -Force)" -f $HostKind)
    exit 0
}
if ($Force -and (Test-Path -LiteralPath $optOutMarker -PathType Leaf)) {
    Remove-Item -LiteralPath $optOutMarker -Force
}

# --- install: strip our old entries, append current ones -------------------------
# The USER-level hosts point at the per-machine launcher, so it must exist before their hooks fire. Generate it
# here (idempotent). claude points straight at the dispatcher via the ${CLAUDE_PROJECT_DIR} placeholder and needs
# no launcher.
if ($HostKind -ne 'claude') { Install-HookLauncher }

Remove-SpecrewEntriesFromEventMap -EventMap $eventMap

$eventGroups = Get-HostEventGroups
foreach ($eventName in $eventGroups.Keys) {
    $group = $eventGroups[$eventName]
    $existing = $eventMap.PSObject.Properties[$eventName]
    if ($null -ne $existing -and $null -ne $existing.Value) {
        $existing.Value = @(@($existing.Value) + @($group))
    }
    else {
        $eventMap | Add-Member -NotePropertyName $eventName -NotePropertyValue @($group) -Force
    }
}

Save-Target -SettingsObject $settings
$boundEvents = ($eventGroups.Keys -join ' + ')
Write-Output ("[specrew-refocus] {0} hooks deployed to {1} ({2}; PreToolUse dormant)" -f $HostKind, $settingsPath, $boundEvents)
exit 0