From d34f99e50ed471c32e318f0dd655c84b041e1914 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Wed, 29 Jul 2026 16:03:41 -0500 Subject: [PATCH] feat(coord): make sessions coordinate without anyone having to remember to claim.ps1 has existed for a while and has been used EXACTLY ZERO times. That is the whole finding: a coordination step you must remember is a coordination step you will skip, so anything built on voluntary declaration decays to nothing. Everything here runs off by-products of working normally -- git state and the task list a session already keeps -- so participation is not optional, it is automatic. THE BLOCKER FOUND FIRST. session-context.ps1 (the coordination banner) is wired only in PROJECT settings, and /.claude/ is GITIGNORED (.gitignore:142) -- git cannot deliver it to a new worktree. Worktrees the harness creates under .claude/worktrees/ get a copy; worktrees new.ps1 creates as - siblings DO NOT. Measured: 5 of 9 worktrees had no project settings, and a live VS Code session was working in one of them with zero coordination context. 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. install-coordination.ps1 wires at USER level, where it loads in every worktree however created. NO INSTALLED COPY. Each hook is a shim that resolves the repo from the session's cwd and runs THAT checkout's script, so a git pull updates the hook everywhere with nothing to go stale. The worktree gate's installer copies its script, and running it from a stale checkout has already silently downgraded the live gate once. overlap.ps1 answers "what is everyone touching" on two independent signals, because they catch different failures: FILES (branch vs origin/main + working tree) catches concurrent edits; WORK (each session's task subjects) catches duplicate EFFORT on DIFFERENT files -- which is the collision that actually cost this project rework, and which nothing file-shaped can see. Cached, because the git walk is ~1.8s and a PreToolUse hook runs on every edit. collision_gate.ps1 denies an edit to a file another LIVE session is changing, naming the session, branch, and what it is building -- a deny that says neither who nor what reads as a broken tool and gets overridden. Two properties pull against each other and both are pinned by mutation: it blocks on LIVE only (a dormant worktree cannot race you, and a gate that cries wolf gets uninstalled), and it FAILS OPEN on every error (this prevents rework; it must never be why a session cannot work) -- deliberately the opposite of the worktree gate, which protects the shared tree and fails closed. Task subjects are another session's free text and are sanitised before they reach our JSON or a deny message; a mangled Unicode arrow arrived as a raw 0x1A and broke the parse, which is also why the JSON path pins UTF-8 output. 14 tests drive the real scripts as subprocesses. Mutations verified: removing the live-only filter reds the dormant test; failing closed instead of open reds a fail-open test. --- scripts/coord/install-coordination.ps1 | 146 +++++++++++++++ scripts/coord/overlap.ps1 | 222 ++++++++++++++++++++++ scripts/hooks/collision_gate.ps1 | 89 +++++++++ scripts/worktree/session-context.ps1 | 21 +++ tests/test_collision_gate.py | 249 +++++++++++++++++++++++++ 5 files changed, 727 insertions(+) create mode 100644 scripts/coord/install-coordination.ps1 create mode 100644 scripts/coord/overlap.ps1 create mode 100644 scripts/hooks/collision_gate.ps1 create mode 100644 tests/test_collision_gate.py 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"