diff --git a/scripts/coord/install-coordination.ps1 b/scripts/coord/install-coordination.ps1 index 79a92599..28fb5eac 100644 --- a/scripts/coord/install-coordination.ps1 +++ b/scripts/coord/install-coordination.ps1 @@ -17,10 +17,16 @@ 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. + NO INSTALLED COPY. Each hook is a one-line shim that locates the script in a checkout and runs it, + 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. + + THE SHIM RESOLVES THE PRIMARY CHECKOUT, not the calling worktree. Coordination is infrastructure + and must be uniform across sessions. Measured 2026-07-29: a worktree sitting on a branch that + predated the coordination merge had none of these scripts, so a cwd-resolved shim found nothing + and exited silently -- that session got no banner and no gate, and nothing reported the absence. + The primary tracks main, so every session runs the same current code whatever branch it is on. WHAT GETS WIRED SessionStart -> scripts/worktree/session-context.ps1 (who is live, what they build) @@ -47,16 +53,27 @@ $ErrorActionPreference = "Stop" # 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. +# The shim. No installed copy: it locates the script in a checkout and runs it, so a `git pull` updates +# the hook everywhere with nothing to fall stale. Silent and exit-0 outside a repo, because this file is +# user-global and runs in every unrelated project on the machine. +# +# IT RESOLVES THE PRIMARY CHECKOUT, NOT THE CURRENT WORKTREE, and that order matters. Coordination is +# INFRASTRUCTURE and has to be uniform: two sessions running different versions of the collision +# protocol is the drift the shared liveness fence exists to prevent. Measured 2026-07-29: a worktree +# sitting on a branch that predated the coordination merge had none of the scripts, so a cwd-resolved +# shim found nothing and exited silently -- the session got no banner and no gate, and nothing said so. +# The primary tracks main, so every session runs the same current code whatever its own branch is. +# The current worktree is kept only as a fallback, for a layout where the primary is unavailable. 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 } }' + '$c = (& git rev-parse --path-format=absolute --git-common-dir 2>$null); ' + + 'if ($LASTEXITCODE -eq 0 -and $c) { ' + + '$bases = @((Split-Path $c.Trim() -Parent), (& git rev-parse --path-format=absolute --show-toplevel 2>$null)); ' + + 'foreach ($b in $bases) { ' + + 'if (-not $b) { continue } ' + + "`$s = Join-Path `$b.Trim() '$RelativeScript'; " + + 'if (Test-Path -LiteralPath $s) { & $s; break } } }' ) } diff --git a/tests/test_collision_gate.py b/tests/test_collision_gate.py index 319b6f12..1cc8a79f 100644 --- a/tests/test_collision_gate.py +++ b/tests/test_collision_gate.py @@ -223,6 +223,73 @@ def test_status_reports_installed_state(settings: Path) -> None: assert "INSTALLED" in run_installer(settings, "-Status") +def shim_for(settings: Path, matcher_prefix: str, tmp_path: Path, name: str) -> Path: + cmd = next( + g["hooks"][0]["command"] + for g in load(settings)["hooks"]["PreToolUse"] + if g.get("matcher", "").startswith(matcher_prefix) + ) + p = tmp_path / name + p.write_text(cmd, encoding="utf-8") + return p + + +def test_shim_runs_the_primary_checkouts_script_not_the_worktrees( + settings: Path, tmp_path: Path +) -> None: + """A worktree on a branch that predates a coordination change has none of the scripts. + + Measured: a cwd-resolved shim found nothing there and exited silently -- no banner, no gate, and + no indication either was missing. Coordination is infrastructure and must be uniform, so the shim + resolves the PRIMARY checkout (which tracks main) rather than whatever branch the caller is on. + """ + primary = tmp_path / "primary" + primary.mkdir() + _git_init(primary) + # Only the PRIMARY gets a gate script; the worktree deliberately does not have one. + (primary / "scripts" / "hooks").mkdir(parents=True) + (primary / "scripts" / "hooks" / "collision_gate.ps1").write_text( + "Write-Output 'PRIMARY-SCRIPT-RAN'\n", encoding="utf-8" + ) + wt = tmp_path / "old-branch-wt" + subprocess.run( + ["git", "-C", str(primary), "worktree", "add", "-q", "-b", "old", str(wt)], + check=True, + capture_output=True, + text=True, + ) + assert not (wt / "scripts" / "hooks" / "collision_gate.ps1").exists() + + run_installer(settings) + shim = shim_for(settings, "Edit", tmp_path, "shim.ps1") + proc = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(shim)], + cwd=str(wt), + input=json.dumps({"tool_name": "Edit", "tool_input": {"file_path": "x.py"}}), + capture_output=True, + text=True, + timeout=180, + check=False, + ) + assert "PRIMARY-SCRIPT-RAN" in proc.stdout, ( + f"shim did not reach the primary checkout's script: {proc.stdout!r} {proc.stderr!r}" + ) + + +def _git_init(repo: Path) -> None: + for args in ( + ["init", "-q"], + ["config", "user.email", "t@example.invalid"], + ["config", "user.name", "t"], + ): + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, text=True) + (repo / "f.txt").write_text("x", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "f.txt"], check=True, capture_output=True) + subprocess.run( + ["git", "-C", str(repo), "commit", "-qm", "init"], check=True, capture_output=True + ) + + 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."""