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 |