Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions scripts/coord/install-coordination.ps1
Original file line number Diff line number Diff line change
@@ -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, `<worktree>/.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 `<repo>-<name>`
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 ""
}
222 changes: 222 additions & 0 deletions scripts/coord/overlap.ps1
Original file line number Diff line number Diff line change
@@ -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/<sessionId>/*.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 ""
Loading
Loading