diff --git a/scripts/coord/install-coordination.ps1 b/scripts/coord/install-coordination.ps1 new file mode 100644 index 00000000..79a92599 --- /dev/null +++ b/scripts/coord/install-coordination.ps1 @@ -0,0 +1,146 @@ +<# +.SYNOPSIS + Install the cross-session coordination hooks so they load in EVERY worktree, not just some. + +.DESCRIPTION + THE PROBLEM THIS FIXES. The coordination banner (session-context.ps1) is wired only in the + PROJECT settings file, `/.claude/settings.json` -- and `/.claude/` is GITIGNORED + (.gitignore:142), so git cannot deliver it to a new worktree. Worktrees the Claude Code harness + creates under `.claude/worktrees/` get a copy; worktrees `new.ps1` creates as `-` + siblings DO NOT. Measured 2026-07-29: 5 of 9 worktrees had no project settings, and a live VS Code + session was working in one of them with zero coordination context -- it could not see the other + four sessions, and they could not see it. + + That is fatal to the whole idea. You cannot force sessions to coordinate when the mechanism does + not load for half of them, and the half it misses is invisible rather than obviously broken. + + THE FIX: wire the hooks at USER level (~/.claude/settings.json), which is per-machine and loads in + every worktree regardless of how it was created -- the same place the worktree gate already lives. + + NO INSTALLED COPY. Each hook is a one-line shim that resolves the repo from the session's cwd and + runs THAT checkout's script. So there is nothing to go stale: after a `git pull` the hook is + current, everywhere, immediately. This is deliberate -- the worktree gate's installer copies its + script, and running it from a stale checkout has already silently downgraded the live gate once. + + WHAT GETS WIRED + SessionStart -> scripts/worktree/session-context.ps1 (who is live, what they build) + PreToolUse Edit|Write|MultiEdit|Notebook -> scripts/hooks/collision_gate.ps1 (refuse a file a live session is changing) + + Idempotent: re-running replaces our own entries and leaves every other hook untouched. + +.EXAMPLE + pwsh -NoProfile -File scripts\coord\install-coordination.ps1 -Status + pwsh -NoProfile -File scripts\coord\install-coordination.ps1 + pwsh -NoProfile -File scripts\coord\install-coordination.ps1 -Uninstall +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [switch]$Status, + [switch]$Uninstall, + # Settings file to modify. Tests point this at a fixture instead of the real user settings. + [string]$SettingsPath = (Join-Path $env:USERPROFILE ".claude\settings.json") +) + +$ErrorActionPreference = "Stop" + +# Marker so we can find and replace exactly our own entries on a re-install, without disturbing hooks +# another tool (or another session) added to the same file. +$MARKER = "mefor-coord" + +# The shim. Resolves the repo from the session's cwd, so one wiring serves every worktree and there is +# no copy to fall behind the checkout. Silent and exit-0 outside a repo: this file is user-global and +# will run in unrelated projects, where it must do nothing at all. +function New-ShimCommand([string]$RelativeScript) { + return ( + "# $MARKER`n" + + '$r = (& git rev-parse --show-toplevel 2>$null); ' + + 'if ($LASTEXITCODE -eq 0 -and $r) { ' + + "`$s = Join-Path `$r '$RelativeScript'; " + + 'if (Test-Path -LiteralPath $s) { & $s } }' + ) +} + +$WIRING = @( + @{ Event = "SessionStart"; Matcher = $null; Script = "scripts/worktree/session-context.ps1"; Timeout = 30; Msg = "Session coordination" } + @{ Event = "PreToolUse"; Matcher = "Edit|Write|MultiEdit|NotebookEdit"; Script = "scripts/hooks/collision_gate.ps1"; Timeout = 20; Msg = "Checking for a colliding session" } +) + +function Read-Settings { + if (-not (Test-Path -LiteralPath $SettingsPath)) { return [ordered]@{} } + $raw = Get-Content -LiteralPath $SettingsPath -Raw + if (-not $raw.Trim()) { return [ordered]@{} } + # Fail loudly rather than overwrite: a settings file we cannot parse is one we must not rewrite, + # because a bad write silently disables EVERY setting in it. + return ($raw | ConvertFrom-Json -AsHashtable) +} + +function Test-IsOurs([hashtable]$Entry) { + foreach ($h in @($Entry.hooks)) { if ([string]$h.command -match [regex]::Escape($MARKER)) { return $true } } + return $false +} + +$settings = Read-Settings +if (-not $settings.hooks) { $settings.hooks = [ordered]@{} } + +if ($Status) { + Write-Host "" + Write-Host "Coordination hooks in $SettingsPath" + $any = $false + foreach ($w in $WIRING) { + $groups = @($settings.hooks[$w.Event]) + $ours = @($groups | Where-Object { $_ -and (Test-IsOurs $_) }) + $state = if ($ours.Count -gt 0) { "INSTALLED" } else { "missing" } + if ($ours.Count -gt 0) { $any = $true } + Write-Host (" {0,-12} {1,-34} {2}" -f $w.Event, $w.Script, $state) + } + Write-Host "" + if (-not $any) { Write-Host " Not installed. Run without -Status to wire it up." -ForegroundColor Yellow } + exit 0 +} + +# Strip our entries first -- this is both the uninstall path and the idempotency of re-install. +foreach ($w in $WIRING) { + if ($settings.hooks[$w.Event]) { + $kept = @(@($settings.hooks[$w.Event]) | Where-Object { $_ -and -not (Test-IsOurs $_) }) + if ($kept.Count -gt 0) { $settings.hooks[$w.Event] = $kept } else { $settings.hooks.Remove($w.Event) } + } +} + +if (-not $Uninstall) { + foreach ($w in $WIRING) { + $entry = [ordered]@{} + if ($w.Matcher) { $entry.matcher = $w.Matcher } + $entry.hooks = @( + [ordered]@{ + type = "command" + command = (New-ShimCommand $w.Script) + shell = "powershell" + timeout = $w.Timeout + statusMessage = $w.Msg + } + ) + $existing = @($settings.hooks[$w.Event]) + $settings.hooks[$w.Event] = @($existing | Where-Object { $_ }) + @($entry) + } +} + +$backup = "$SettingsPath.bak-coord" +if ($PSCmdlet.ShouldProcess($SettingsPath, $(if ($Uninstall) { "remove coordination hooks" } else { "install coordination hooks" }))) { + if (Test-Path -LiteralPath $SettingsPath) { Copy-Item -LiteralPath $SettingsPath -Destination $backup -Force } + $json = $settings | ConvertTo-Json -Depth 12 + # Validate what we are about to write BEFORE replacing the file. A malformed settings.json does not + # error at startup -- it silently disables every setting in it, which is the worst failure mode here. + try { $null = $json | ConvertFrom-Json } catch { throw "Refusing to write: generated settings JSON is invalid. $_" } + Set-Content -LiteralPath $SettingsPath -Value $json -Encoding UTF8 + + Write-Host "" + if ($Uninstall) { Write-Host "Coordination hooks REMOVED from $SettingsPath" -ForegroundColor Yellow } + else { + Write-Host "Coordination hooks INSTALLED (user level -- loads in every worktree)" -ForegroundColor Green + foreach ($w in $WIRING) { Write-Host (" {0,-12} -> {1}" -f $w.Event, $w.Script) } + Write-Host "" + Write-Host " Takes effect in NEWLY STARTED sessions; existing ones keep the config they booted with." + } + Write-Host " backup: $backup" + Write-Host "" +} diff --git a/scripts/coord/overlap.ps1 b/scripts/coord/overlap.ps1 new file mode 100644 index 00000000..30acf91a --- /dev/null +++ b/scripts/coord/overlap.ps1 @@ -0,0 +1,222 @@ +<# +.SYNOPSIS + What every OTHER session in this repo is changing right now -- files and stated work. + +.DESCRIPTION + presence.ps1 answers "who is here". This answers "what are they touching", which is the question + that actually prevents a collision. Two sessions in separate worktrees are isolated on disk and + still collide: they edit the same file and one of them rebases onto a surprise, or -- the case that + actually cost this project rework -- they build the SAME FIX in DIFFERENT files, produce zero merge + conflicts, and both PRs go green (three sessions, one npm advisory, two PRs closed as duplicates). + + So two independent signals, because they catch different failures: + + FILES -- per worktree: committed changes vs origin/main, plus the uncommitted working tree. + Catches concurrent edits. Exact, cheap, no cooperation required. + WORK -- per session: the subjects of its task list (~/.claude/tasks//*.json). + Catches duplicate EFFORT on different files. Free: sessions write these anyway, so + nobody has to remember to declare anything. + + NOBODY HAS TO OPT IN. Every input here is a by-product of working normally -- git state and a task + list a session already keeps. That is deliberate: claim.ps1 has existed for a while and has been + used exactly zero times, because a coordination step you must remember is a coordination step you + will skip. Anything built on voluntary declaration decays to nothing. + + LIVE vs DORMANT. A worktree whose session is live is a CONCURRENT collision -- someone is editing + it now. A worktree with changes but no live session is still worth knowing about (the work may + already be done), but it cannot be racing you. Callers are expected to treat these differently: + block on live, mention dormant. + + CACHED, because the git walk costs ~1.5s across a dozen worktrees and a PreToolUse hook runs on + every single edit. The cache is a plain last-write-wins file: a stale-by-seconds view of who is + editing what is fine, and two sessions refreshing at once cost a duplicate walk, not corruption. + +.EXAMPLE + pwsh -NoProfile -File scripts\coord\overlap.ps1 # human summary + pwsh -NoProfile -File scripts\coord\overlap.ps1 -Json # machine-readable + pwsh -NoProfile -File scripts\coord\overlap.ps1 -File messagefoundry\api\app.py + pwsh -NoProfile -File scripts\coord\overlap.ps1 -Refresh # ignore the cache +#> +[CmdletBinding()] +param( + # Ask about ONE path: who else is changing it. Repo-relative or absolute. Exit 0 with no output + # when nobody is -- the fast path a hook takes on nearly every edit. + [string]$File, + # Emit JSON. + [switch]$Json, + # Ignore the cache and re-walk. + [switch]$Refresh, + # How long a cached walk stays usable. + [int]$CacheSeconds = 60, + # Repo to inspect. Defaults to the current worktree's repo family. + [string]$Repo, + # Config roots for the session registry (tests point this at a fixture). + [string[]]$ConfigRoot, + # Where task lists live. Separate param so tests can supply their own. + [string]$TasksDir = (Join-Path $env:USERPROFILE ".claude\tasks") +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot\session-registry.ps1" + +# Emit UTF-8 explicitly. The default console encoding mangles non-ASCII in task subjects (a Unicode +# arrow came through as a raw 0x1A), which turns valid output into JSON a consumer cannot parse. +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } + +function ConvertTo-Norm([string]$p) { + if (-not $p) { return "" } + return ($p -replace '\\', '/').TrimEnd('/').ToLowerInvariant() +} + +$gitArgs = @(); if ($Repo) { $gitArgs = @("-C", $Repo) } +$common = (& git @gitArgs rev-parse --path-format=absolute --git-common-dir 2>$null) +if ($LASTEXITCODE -ne 0 -or -not $common) { + if ($Json) { "[]" | Write-Output } + exit 0 +} +$common = $common.Trim() +$myRoot = (& git @gitArgs rev-parse --path-format=absolute --show-toplevel 2>$null) +$myRootNorm = ConvertTo-Norm $myRoot + +$cacheFile = Join-Path $common "mefor-coord/overlap-cache.json" + +function Get-WorktreeList { + $out = @(); $cur = $null + foreach ($line in (& git @gitArgs worktree list --porcelain 2>$null)) { + if ($line -like "worktree *") { + $cur = [pscustomobject]@{ Path = $line.Substring(9).Trim(); Branch = "" } + $out += $cur + } + elseif ($line -like "branch *" -and $cur) { $cur.Branch = ($line.Substring(7).Trim() -replace '^refs/heads/', '') } + elseif ($line -like "detached*" -and $cur) { $cur.Branch = "(detached)" } + } + return $out +} + +# The subjects a session has declared it is working on. Free signal: TaskCreate writes these anyway. +# in_progress first -- that is what it is doing NOW, which is what a sibling needs to know. +function Get-SessionWork([string]$SessionId) { + if (-not $SessionId) { return @() } + $dir = @(Get-ChildItem $TasksDir -Directory -Filter "$SessionId*" -EA SilentlyContinue | Select-Object -First 1) + if (-not $dir) { return @() } + $items = @() + foreach ($f in @(Get-ChildItem $dir[0].FullName -Filter *.json -EA SilentlyContinue)) { + try { $t = Get-Content $f.FullName -Raw -EA Stop | ConvertFrom-Json -EA Stop } catch { continue } + if ($t.status -eq "completed") { continue } + # Another session's free text: sanitise before it reaches our JSON or a hook's deny message. + # Control characters here are usually a mangled Unicode arrow rather than anything hostile, + # but they corrupt the JSON a consumer has to parse, and this is untrusted input either way. + $subject = ([string]$t.subject) -replace '[\p{C}]', ' ' + $subject = ($subject -replace '\s+', ' ').Trim() + if ($subject.Length -gt 100) { $subject = $subject.Substring(0, 97) + "..." } + $items += [pscustomobject]@{ Subject = $subject; Status = [string]$t.status } + } + return @($items | Sort-Object @{ E = { if ($_.Status -eq "in_progress") { 0 } else { 1 } } }) +} + +function Build-Map { + $sessionsByCwd = @{} + foreach ($e in (Get-SessionRecords -ConfigRoot $ConfigRoot)) { + if (-not $e.Record.cwd) { continue } + $l = Test-RecordLiveness -Record $e.Record + # Only a POSITIVE liveness answer counts as "someone is here". A dead/stale verdict is never + # trustworthy on this host (no heartbeat), but it also cannot hurt us here: the worst case is + # we report a worktree as dormant when its owner is actually around, and dormant still shows. + if ($l.State -ne "LIVE" -and $l.State -ne "UNVERIFIED") { continue } + $k = ConvertTo-Norm $e.Record.cwd + $sessionsByCwd[$k] = $e.Record + } + + $rows = @() + foreach ($w in (Get-WorktreeList)) { + $norm = ConvertTo-Norm $w.Path + if ($norm -eq $myRootNorm) { continue } # not a collision with yourself + if (-not (Test-Path -LiteralPath $w.Path)) { continue } + + # Committed work on this branch, plus whatever is uncommitted in its tree. Three dots: what the + # BRANCH added, not what main added underneath it -- otherwise every worktree looks like it is + # changing every file that main has moved since it branched. + $files = @() + $committed = @(& git -C $w.Path diff --name-only origin/main...HEAD 2>$null) + if ($LASTEXITCODE -eq 0) { $files += $committed } + $dirty = @(& git -C $w.Path status --porcelain 2>$null | + Where-Object { $_.Length -gt 3 } | ForEach-Object { $_.Substring(3).Trim('"') }) + $files += $dirty + $files = @($files | Where-Object { $_ } | Sort-Object -Unique) + + # A session sitting anywhere INSIDE the worktree owns it, not just one whose cwd is the root. + $sess = $null + foreach ($k in $sessionsByCwd.Keys) { + if ($k -eq $norm -or $k.StartsWith("$norm/")) { $sess = $sessionsByCwd[$k]; break } + } + if ($files.Count -eq 0 -and -not $sess) { continue } + + $rows += [pscustomobject]@{ + Worktree = Split-Path $w.Path -Leaf + Path = $w.Path + Branch = $w.Branch + Live = [bool]$sess + SessionId = if ($sess) { [string]$sess.sessionId } else { "" } + Short = if ($sess -and $sess.sessionId) { ([string]$sess.sessionId).Substring(0, 8) } else { "" } + Surface = if ($sess) { ([string]$sess.entrypoint) -replace '^claude-', '' } else { "" } + Files = $files + Work = @(Get-SessionWork $(if ($sess) { [string]$sess.sessionId } else { "" }) | ForEach-Object { $_.Subject }) + } + } + return $rows +} + +# --- cache ------------------------------------------------------------------------------------- +$map = $null +if (-not $Refresh -and (Test-Path -LiteralPath $cacheFile)) { + try { + $c = Get-Content $cacheFile -Raw -EA Stop | ConvertFrom-Json -EA Stop + $age = ((Get-Date) - [datetime]::Parse($c.at)).TotalSeconds + # Bound BOTH ways: a cache stamped in the future (clock skew, or a file copied between boxes) + # would otherwise look eternally fresh and pin a stale map forever. + if ($age -ge 0 -and $age -lt $CacheSeconds -and $c.root -eq $myRootNorm) { $map = @($c.rows) } + } catch { $map = $null } +} +if ($null -eq $map) { + $map = Build-Map + try { + New-Item -ItemType Directory -Force -Path (Split-Path $cacheFile) | Out-Null + # Last-write-wins on purpose: a duplicate walk is the only cost of a race, and a lock on the + # hot path of every edit would be worse than the thing it protects. + @{ at = (Get-Date).ToString("o"); root = $myRootNorm; rows = $map } | + ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $cacheFile -Encoding UTF8 + } catch { } # a cache we cannot write is a slow hook, not a broken one +} + +# --- single-file query (the hook's fast path) ---------------------------------------------------- +if ($File) { + $q = $File + if ([System.IO.Path]::IsPathRooted($q)) { + $qn = ConvertTo-Norm $q + $rn = "$myRootNorm/" + if ($qn.StartsWith($rn)) { $q = $qn.Substring($rn.Length) } else { $q = $qn } + } + $q = ConvertTo-Norm $q + $hits = @() + foreach ($r in $map) { + if (@($r.Files | ForEach-Object { ConvertTo-Norm $_ }) -contains $q) { $hits += $r } + } + if ($Json) { ($hits | ConvertTo-Json -Depth 6 -AsArray) | Write-Output; exit 0 } + foreach ($h in $hits) { + $state = if ($h.Live) { "LIVE $($h.Surface) session $($h.Short)" } else { "dormant worktree" } + Write-Host " $File is also changed by $state in $($h.Worktree) [$($h.Branch)]" + } + exit 0 +} + +if ($Json) { ($map | ConvertTo-Json -Depth 6 -AsArray) | Write-Output; exit 0 } + +if (@($map).Count -eq 0) { Write-Host "No other worktree has changes."; exit 0 } +Write-Host "" +foreach ($r in $map) { + $who = if ($r.Live) { "LIVE $($r.Surface) $($r.Short)" } else { "dormant" } + Write-Host ("{0,-38} {1,-38} {2}" -f $r.Worktree, $r.Branch, $who) + foreach ($w in @($r.Work | Select-Object -First 3)) { Write-Host " building: $w" } + Write-Host (" {0} changed file(s)" -f @($r.Files).Count) +} +Write-Host "" diff --git a/scripts/hooks/collision_gate.ps1 b/scripts/hooks/collision_gate.ps1 new file mode 100644 index 00000000..697282bc --- /dev/null +++ b/scripts/hooks/collision_gate.ps1 @@ -0,0 +1,89 @@ +<# +.SYNOPSIS + PreToolUse gate: refuse to edit a file another LIVE session is already changing. + +.DESCRIPTION + Worktrees stop two sessions from overwriting each other's bytes. They do NOT stop two sessions + from editing the same file in parallel and discovering it at merge -- by which point both have + built on divergent assumptions and someone's work is thrown away. This gate turns that from a + merge-time surprise into an edit-time refusal, which is the only point where it is still cheap. + + NOTHING TO OPT INTO. It reads git state and the session registry, both by-products of working + normally. That is the whole design: `claim.ps1` has existed for some time and has been used + exactly ZERO times, because a coordination step you must remember is one you will skip. A guard + that needs cooperation to work does not work. + + ONLY LIVE SESSIONS BLOCK. A dormant worktree with changes cannot be racing you -- its owner is not + typing -- so it is reported and allowed. Blocking on dormant worktrees would deny edits to every + file any abandoned branch ever touched, and a gate that cries wolf gets uninstalled. + + FAILS OPEN, DELIBERATELY. Any error -- unparseable payload, no git, no registry, a broken overlap + script -- exits 0 and allows the edit. This gate prevents rework; it must never be the reason a + session cannot work. That is the opposite of the worktree gate's posture (which protects the + shared tree and should fail closed), and the difference is intentional. + + Wired on Edit|Write|MultiEdit|NotebookEdit. The overlap map is cached, so the common case is a + cache read, not a git walk across every worktree. +#> +[CmdletBinding()] +param( + # Overlap script to consult. Parameterised so tests drive the REAL gate against a fixture rather + # than re-implementing its rule -- a test that asserts a copy of the rule proves nothing. + [string]$OverlapScript = (Join-Path $PSScriptRoot "..\coord\overlap.ps1"), + # Emit the decision and skip reading stdin (tests). + [string]$PathOverride +) + +# No $ErrorActionPreference = Stop: this gate fails OPEN, and a throw would be a deny-by-crash. +$ErrorActionPreference = "SilentlyContinue" + +function Deny([string]$Reason) { + # The hookSpecificOutput wrapper is MANDATORY -- a bare permissionDecision is silently ignored, + # which would leave this looking installed while permitting everything. + $payload = @{ + hookSpecificOutput = @{ + hookEventName = "PreToolUse" + permissionDecision = "deny" + permissionDecisionReason = $Reason + } + } + [Console]::Out.Write(($payload | ConvertTo-Json -Compress -Depth 6)) + exit 0 +} + +$target = $PathOverride +if (-not $target) { + if (-not [Console]::IsInputRedirected) { exit 0 } + try { $hook = [Console]::In.ReadToEnd() | ConvertFrom-Json } catch { exit 0 } + if (-not $hook) { exit 0 } + $target = [string]$hook.tool_input.file_path + # NotebookEdit and some variants name the path differently; absence just means nothing to check. + if (-not $target) { $target = [string]$hook.tool_input.notebook_path } +} +if (-not $target) { exit 0 } + +if (-not (Test-Path -LiteralPath $OverlapScript)) { exit 0 } + +$rows = @() +try { + $raw = & pwsh -NoProfile -NonInteractive -File $OverlapScript -File $target -Json 2>$null + if ($raw) { $rows = @($raw | ConvertFrom-Json) } +} catch { exit 0 } +if (-not $rows -or $rows.Count -eq 0) { exit 0 } + +$live = @($rows | Where-Object { $_.Live }) +if ($live.Count -eq 0) { exit 0 } # dormant only: worth knowing, not worth blocking + +$leaf = Split-Path $target -Leaf +$lines = @("$leaf is already being changed by another LIVE session -- editing it now means one of you loses work at merge.", "") +foreach ($r in $live) { + $lines += " $($r.Short) ($($r.Surface)) in $($r.Worktree) [$($r.Branch)]" + foreach ($w in @($r.Work | Select-Object -First 2)) { $lines += " building: $w" } +} +$lines += "" +$lines += "Before overriding: that session may already be doing what you are about to do." +$lines += " see everything in flight : pwsh -NoProfile -File scripts\coord\overlap.ps1" +$lines += " who is live : pwsh -NoProfile -File scripts\coord\presence.ps1" +$lines += "If you genuinely need this file, coordinate first -- or edit a different one." + +Deny ($lines -join "`n") diff --git a/scripts/worktree/session-context.ps1 b/scripts/worktree/session-context.ps1 index 4c3ef988..37212a8d 100644 --- a/scripts/worktree/session-context.ps1 +++ b/scripts/worktree/session-context.ps1 @@ -88,6 +88,27 @@ if ($root) { } $lines += " Full roster: pwsh -NoProfile -File scripts\coord\presence.ps1 -All" } + + # WHAT they are building, not just where they are. The roster above prevents you editing + # the same FILE; this is the only thing that prevents you building the same THING. That is + # the collision that actually cost this project rework -- three sessions independently + # fixing one npm advisory, in DIFFERENT files, so nothing file-shaped could have caught it. + # Sourced from each session's own task list, so no one has to declare anything. + $overlap = Join-Path $PSScriptRoot "..\coord\overlap.ps1" + if (Test-Path $overlap) { + $inflight = @() + try { $inflight = @(& $overlap -Json | ConvertFrom-Json) } catch { $inflight = @() } + $busy = @($inflight | Where-Object { $_.Live -and (@($_.Work).Count -gt 0 -or @($_.Files).Count -gt 0) }) + if ($busy.Count -gt 0) { + $lines += "" + $lines += "WHAT THEY ARE BUILDING -- check before you start, so you don't build it twice:" + foreach ($b in $busy) { + $lines += " $($b.Short) [$($b.Branch)] -- $(@($b.Files).Count) file(s) changed" + foreach ($w in @($b.Work | Select-Object -First 3)) { $lines += " $w" } + } + $lines += " Everything in flight: pwsh -NoProfile -File scripts\coord\overlap.ps1" + } + } } # Nudge cleanup: count the - siblings new.ps1 creates, so finished ones don't pile up. diff --git a/tests/test_collision_gate.py b/tests/test_collision_gate.py new file mode 100644 index 00000000..319b6f12 --- /dev/null +++ b/tests/test_collision_gate.py @@ -0,0 +1,249 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Tests for the collision gate (``scripts/hooks/collision_gate.ps1``) and its installer. + +The gate refuses to edit a file another LIVE session is already changing. Worktrees stop two sessions +overwriting each other's bytes; they do not stop two sessions editing the same file in parallel and +finding out at merge, when one of them has to throw work away. + +Two properties carry the weight, and they pull in opposite directions: + +* **It denies on a live session and only on a live session.** A dormant worktree cannot be racing you, + and a gate that blocks every file an abandoned branch ever touched gets uninstalled. +* **It fails OPEN on every error.** This gate prevents rework; it must never be the reason a session + cannot work. That is deliberately the opposite of the worktree gate, which protects the shared tree + and fails closed. + +The gate is driven as a real subprocess with a real PreToolUse payload, against a stub overlap script +supplying known rows. Splitting it there is intentional: these tests pin the gate's DECISION, and +``test_coord_overlap.py`` pins how the rows are computed. Neither re-implements the other. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +GATE = ROOT / "scripts" / "hooks" / "collision_gate.ps1" +INSTALLER = ROOT / "scripts" / "coord" / "install-coordination.ps1" + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="collision_gate.ps1 needs pwsh on Windows", +) + + +def make_overlap_stub(tmp_path: Path, rows: list[dict[str, Any]]) -> Path: + """A stand-in for overlap.ps1 that emits the rows we want, in the real script's shape.""" + stub = tmp_path / "overlap-stub.ps1" + payload = json.dumps(rows).replace("'", "''") # '' escapes a quote in a PS single-quoted string + stub.write_text( + "param([string]$File,[switch]$Json,[switch]$Refresh,[int]$CacheSeconds," + "[string]$Repo,[string[]]$ConfigRoot,[string]$TasksDir)\n" + f"Write-Output '{payload}'\n", + encoding="utf-8", + ) + return stub + + +def run_gate(overlap: Path | None, file_path: str | None = "a.py") -> dict[str, Any] | None: + """Invoke the gate exactly as Claude Code does. Returns the deny object, or None for allow.""" + payload: dict[str, Any] = {"tool_name": "Edit", "tool_input": {}} + if file_path is not None: + payload["tool_input"]["file_path"] = file_path + args = ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(GATE)] + if overlap is not None: + args += ["-OverlapScript", str(overlap)] + proc = subprocess.run( + args, input=json.dumps(payload), capture_output=True, text=True, timeout=180, check=False + ) + # A hook must never crash the tool call: a non-zero exit is ignored by the harness, which would + # leave the gate looking installed while permitting everything. + assert proc.returncode == 0, f"gate exited {proc.returncode}: {proc.stderr}" + out = proc.stdout.strip() + return json.loads(out) if out else None + + +LIVE_ROW = { + "Worktree": "sibling-wt", + "Branch": "claude/other-work", + "Live": True, + "Short": "deadbeef", + "Surface": "vscode", + "Files": ["a.py"], + "Work": ["Rewrite the ingest path", "Add the retry test"], +} +DORMANT_ROW = {**LIVE_ROW, "Live": False, "Short": "", "Surface": "", "Worktree": "old-wt"} + + +def test_denies_when_a_live_session_is_changing_the_file(tmp_path: Path) -> None: + got = run_gate(make_overlap_stub(tmp_path, [LIVE_ROW])) + assert got is not None, "expected a deny, got allow" + out = got["hookSpecificOutput"] + assert out["hookEventName"] == "PreToolUse" + assert out["permissionDecision"] == "deny" + + +def test_the_denial_names_the_session_branch_and_what_it_is_building(tmp_path: Path) -> None: + """A deny that doesn't say WHO or WHAT just looks like a broken tool and gets overridden.""" + got = run_gate(make_overlap_stub(tmp_path, [LIVE_ROW])) + assert got is not None + reason = got["hookSpecificOutput"]["permissionDecisionReason"] + assert "deadbeef" in reason + assert "claude/other-work" in reason + assert "Rewrite the ingest path" in reason # the duplicate-work signal + assert "vscode" in reason # surface matters: it cannot be reached by session messaging + + +def test_allows_when_only_a_dormant_worktree_touches_the_file(tmp_path: Path) -> None: + """Nobody is typing in it, so it cannot be racing you.""" + assert run_gate(make_overlap_stub(tmp_path, [DORMANT_ROW])) is None + + +def test_allows_when_nobody_else_touches_the_file(tmp_path: Path) -> None: + assert run_gate(make_overlap_stub(tmp_path, [])) is None + + +def test_fails_open_when_the_overlap_script_is_missing(tmp_path: Path) -> None: + assert run_gate(tmp_path / "does-not-exist.ps1") is None + + +def test_fails_open_when_the_overlap_script_throws(tmp_path: Path) -> None: + broken = tmp_path / "broken.ps1" + broken.write_text("param([string]$File,[switch]$Json)\nthrow 'boom'\n", encoding="utf-8") + assert run_gate(broken) is None + + +def test_fails_open_when_the_overlap_script_emits_junk(tmp_path: Path) -> None: + junk = tmp_path / "junk.ps1" + junk.write_text( + "param([string]$File,[switch]$Json)\nWrite-Output 'not json'\n", encoding="utf-8" + ) + assert run_gate(junk) is None + + +def test_allows_a_payload_with_no_file_path(tmp_path: Path) -> None: + assert run_gate(make_overlap_stub(tmp_path, [LIVE_ROW]), file_path=None) is None + + +# --------------------------------------------------------------------------------- installer + + +def run_installer(settings: Path, *extra: str) -> str: + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(INSTALLER), + "-SettingsPath", + str(settings), + *extra, + ], + capture_output=True, + text=True, + timeout=180, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout + + +@pytest.fixture +def settings(tmp_path: Path) -> Path: + p = tmp_path / "settings.json" + p.write_text( + json.dumps( + { + "theme": "dark", + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "echo other"}]} + ] + }, + } + ), + encoding="utf-8", + ) + return p + + +def load(settings: Path) -> dict[str, Any]: + parsed: dict[str, Any] = json.loads(settings.read_text(encoding="utf-8-sig")) + return parsed + + +def test_install_wires_both_hooks(settings: Path) -> None: + run_installer(settings) + d = load(settings) + assert "SessionStart" in d["hooks"] + matchers = [g.get("matcher") for g in d["hooks"]["PreToolUse"]] + assert "Edit|Write|MultiEdit|NotebookEdit" in matchers + + +def test_install_preserves_unrelated_hooks_and_settings(settings: Path) -> None: + """User settings is shared with other tooling AND edited by sibling sessions. Clobbering it is + the failure that costs the most, because a bad write disables every setting silently.""" + run_installer(settings) + d = load(settings) + assert d["theme"] == "dark" + cmds = [g["hooks"][0]["command"] for g in d["hooks"]["PreToolUse"]] + assert any("echo other" in c for c in cmds), "pre-existing hook was dropped" + + +def test_install_is_idempotent(settings: Path) -> None: + run_installer(settings) + first = load(settings) + run_installer(settings) + second = load(settings) + assert first == second, "re-install duplicated or altered entries" + + +def test_uninstall_removes_only_our_entries(settings: Path) -> None: + run_installer(settings) + run_installer(settings, "-Uninstall") + d = load(settings) + assert "SessionStart" not in d["hooks"] + cmds = [g["hooks"][0]["command"] for g in d["hooks"]["PreToolUse"]] + assert cmds == ["echo other"] + assert d["theme"] == "dark" + + +def test_status_reports_installed_state(settings: Path) -> None: + assert "missing" in run_installer(settings, "-Status") + run_installer(settings) + assert "INSTALLED" in run_installer(settings, "-Status") + + +def test_installed_shim_is_inert_outside_a_git_repo(settings: Path, tmp_path: Path) -> None: + """User settings are global: this hook runs in every unrelated project on the machine and must + do nothing there rather than erroring on each tool call.""" + run_installer(settings) + shim_cmd = next( + g["hooks"][0]["command"] + for g in load(settings)["hooks"]["PreToolUse"] + if g.get("matcher", "").startswith("Edit") + ) + shim = tmp_path / "shim.ps1" + shim.write_text(shim_cmd, encoding="utf-8") + outside = tmp_path / "not-a-repo" + outside.mkdir() + proc = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(shim)], + cwd=str(outside), + input=json.dumps({"tool_name": "Edit", "tool_input": {"file_path": "x.py"}}), + capture_output=True, + text=True, + timeout=180, + check=False, + ) + assert proc.returncode == 0 + assert proc.stdout.strip() == "", "shim produced output outside a repo"