Skip to content
58 changes: 56 additions & 2 deletions docs/SESSION-DRIFT-CONTROLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,12 @@ and the open work claims.
Frequently forgotten in discussions of "the gate", but it is the same problem class:

- **[`scripts/coord/claim.ps1`](../scripts/coord/claim.ps1)** — atomic exclusive-create of
`<git-common-dir>/mefor-coord/claims/<key>.json`. Claims work, not numbers.
`<git-common-dir>/mefor-coord/claims/<key>.json`. Claims work, not numbers. `-Take` on a key you
already hold **refreshes the note and branch in place**, stamping `refreshed`; until 2026-08-02 it
accepted a new `-Note`, reported success and discarded it, so the only way to correct a note was
`-Release` then `-Take` — which drops the claim in between and re-opens the race it exists to close.
The note is what `announce-session.ps1` broadcasts to every joining session *in preference to the
worktree name*, so a note that cannot be corrected is announced as current intent indefinitely.
- **[`scripts/hooks/claim_check.py`](../scripts/hooks/claim_check.py)** — `commit-msg` gate: a commit whose
*subject* declares `BACKLOG #N` with a code-touching diff must hold a claim on N **for this worktree**.
Motivated by a recorded incident: three sessions independently fixed one npm advisory; two PRs were
Expand Down Expand Up @@ -114,6 +119,25 @@ writes when measured.
> strictly downstream of the failure it existed to detect. Looking was not neglected; it was
> impossible. When adding a control, ask which surface still reports when the control itself fails to
> load. (Formulation owed to the session that hit four instances of the same class in one day.)
>
> **A fifth instance, found 2026-08-02 in the collision gate itself — fixed.** Every fail-open path
> (`overlap.ps1` missing, throwing, or printing garbage) exited 0 with **empty stdout**, which on this
> hook's stdout is byte-for-byte identical to *"checked, nobody else is in this file"*. A gate that had
> consulted nothing reported an all-clear, so its own failure reached the session as reassurance. The
> sharpest case needed no breakage at all, and the first attempt at the fix walked straight into it:
> **`overlap.ps1` had no representation for "nobody else"**. `@() | ConvertTo-Json -AsArray` sends zero
> objects down the pipeline, so ConvertTo-Json never runs and the script printed *nothing* — `-AsArray`
> only shapes output that already exists. A gate rule of "empty output means the script never answered"
> therefore fired on an ordinary edit to an untouched file, which is most edits. Caught by running the
> real pair rather than the test stubs, which used a shape the real script never produced.
>
> So the fix is in two places, and the second is the load-bearing one: `overlap.ps1` now always emits a
> JSON array (`[]` for no hits), and only then can the gate treat silence as a fault. It emits a
> `hookSpecificOutput.additionalContext` notice naming the reason, rate-limited per reason so a
> persistent fault cannot narrate itself into every edit, and **fails toward noise** when it cannot
> write that rate-limit stamp. The posture is unchanged: it still allows. Note the shape of the fix —
> it is not "check harder", it is *give the two states different bytes*, and you cannot detect a
> difference the producer never encoded.

### Recovery and lifecycle

Expand Down Expand Up @@ -142,9 +166,12 @@ reading the emitted decision — not by reading source alone.
| Selfheal — primary auto-repair | user (4 of 5 dirs) | LIVE |
| Selfheal — hijack warning | user (4 of 5 dirs) | **LIVE and currently mis-firing** (§3, G4) |
| `session-context.ps1` banner | project | LIVE where the branch carries the file |
| Announce-on-join (`announce-session.ps1`) | user | **NEW** — the only **push** control; asks, cannot send, and every decision leaves a receipt |
| Announce-on-join (`announce-session.ps1`) | user | **MERGED, NOT INSTALLED — inert by accident, 2026-08-02.** The only **push** control; asks, cannot send, and every decision leaves a receipt. It has never run. See below |
| Announce wiring reaches a real script | test | **NEW** — `tests/test_announce_wiring.py`; nothing asserted this for *any* hook before, which is how a wired-but-inert shim survived weeks |
| Announce missing-script notice | user | **NEW** — the one surface that still reports when the script itself fails to resolve |
| Collision gate — unresolved notice | user | **NEW** — every fail-open path used to be indistinguishable from an all-clear; it now says which reason, once per 30 min, and still allows |
| Claim note refresh (`-Take -Note` on a held key) | manual | **NEW** — was a silent discard; refresh is now in place, so correcting a note never drops the claim |
| Overlap session attribution | manual + gate | **FIXED** — longest-prefix, not first-hit. Linked worktrees are nested under the primary, so the primary's row used to absorb an arbitrary peer's session and report itself LIVE on `main` |
| Claim / alloc / ledger gates | git hooks | LIVE |
| `new.ps1` / `remove.ps1` / `prune-merged.ps1` | manual | LIVE, **sibling-layout only** |
| `tests/test_worktree_gate*.py`, `test_install_gate_wiring.py` | CI + local | Was **85 green, and blind** — every one bound the repo copy; nothing read the installed copy or any live `settings.json`. Now 91 across six files, plus the local-only parity check below |
Expand All @@ -153,6 +180,33 @@ Rule 4 being inert is **deliberate and announced** — the commit that landed it
nothing changes until `install-gate.ps1` is re-run." It is listed as INERT here because a control that
has never been installed is a source artefact, not an enforcement.

**Announce-on-join is inert too, and that one is an accident.** Measured 2026-08-02, hours after
[#133](https://github.com/MEFORORG/MessageFoundry/pull/133) merged it to `main`:

| Check | Result |
|---|---|
| `mefor-announce` UserPromptSubmit entry in any of the 5 config roots | **absent** |
| The one UserPromptSubmit entry that *is* installed | `# mefor-web-announce`, resolving `scripts/hooks/announce.ps1` — **a different script in a different repo** |
| `<git-common-dir>/mefor-coord/announce/` | **does not exist**, so there is not one receipt: it has never executed |

`install-coordination.ps1` was last run before the announce row existed, and merging a hook does not
install one. The two `mefor-coord` entries it wired then — the SessionStart banner and the collision
gate — are present, which is exactly why nothing looked wrong.

Two things this costs, both observed rather than predicted. A peer session announced itself **by hand**
on 2026-08-02 and reported the hook as unavailable because it was "on an unmerged branch"; it had
merged, so the correct diagnosis was never reached. And the missing-script notice listed below — the
surface built precisely so this class cannot hide — **cannot fire when the hook is not wired at all**,
because it lives inside the shim. That is this section's own lesson recurring one level up: the
detector was still downstream of the failure. Re-arm from a plain terminal, then confirm by receipt
rather than by reading the settings file:

```powershell
pwsh -NoProfile -File scripts\coord\install-coordination.ps1
# then, after one prompt in any session in this repo:
ls (Join-Path (git rev-parse --path-format=absolute --git-common-dir) 'mefor-coord/announce/receipts')
```

---

## 2. What it demonstrably buys
Expand Down
15 changes: 11 additions & 4 deletions docs/WORKTREES.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,11 @@ the id rule; the model does the sending.
banners is the **registry** id. `ccd_session_mgmt` uses a *different* id for the same session. **The cwd
is the only join key, and it must be matched exactly, never by prefix** — every worktree cwd is an
extension of the primary's, so a prefix match resolves a peer in the primary to an arbitrary worktree
session. Branch is not a join key either: measured 2026-08-01, the two rosters reported different
session. (The same trap, same nesting, was live in `overlap.ps1` until 2026-08-02: it *needs* a prefix
match, since a session may sit in any subdirectory, and it took the **first** hit — so the primary's row
absorbed whichever nested-worktree session the hash table enumerated first and reported the primary LIVE
on `main`. Where a prefix match is genuinely required, the rule has to be **longest prefix wins**.)
Branch is not a join key either: measured 2026-08-01, the two rosters reported different
branches for the same checkout in 2 of 6 cases. A usable id starts with `local_`. **A registry id passed
to `send_message` fails silently**, which reads as the peer ignoring you.

Expand Down Expand Up @@ -395,9 +399,12 @@ it by hand. Three constraints came out of that, recorded here so the next attemp
them:

- **A broadcast needs an expiry or a predicate the *recipient* can evaluate — never a promise from the
sender.** A merge freeze went out with "lift when #119 merges". #119 never merged (it died on an
unrelated CI timeout), so five sessions held on a condition that could not arrive, and it took a
second round to retract.
sender.** A merge freeze went out with "lift when #119 merges". #119 did not merge that day (an
unrelated CI timeout), so five sessions held on a condition that had not arrived and it took a second
round to retract. It merged the *following* day, 2026-08-02 01:45Z — which sharpens the point rather
than softening it: the recipients had no way to evaluate the predicate, so the freeze outlived its
own condition in both directions. As of 2026-08-02 a claim note still announcing that freeze was
being read by every joining session hours after both it and #133 had merged.
- **"Don't do X" is the wrong primitive when automation already has X armed.** The freeze asked
sessions not to merge, while six PRs had auto-merge *armed* and would have landed with nobody
clicking anything. The correct ask was an action — "disarm auto-merge" — not restraint.
Expand Down
52 changes: 50 additions & 2 deletions scripts/coord/claim.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
#>
[CmdletBinding()]
param(
# Claim this key for THIS worktree. Idempotent: re-taking your own claim just refreshes the note.
# Claim this key for THIS worktree. Idempotent: re-taking a key you already hold refreshes its note
# and branch in place (never dropping the claim), rather than failing.
[string]$Take,
# Release a claim this worktree holds.
[string]$Release,
Expand Down Expand Up @@ -64,6 +65,16 @@ function ConvertTo-KeyFile([string]$Key) {
$safe
}

# ConvertFrom-Json SILENTLY COERCES an ISO-8601 string into a [datetime], so `[string]$c.claimed` does
# not give you back what was written -- it gives the local short form ("08/01/2026 23:17:46"), losing
# the sub-second precision and the UTC offset. Writing that back would quietly downgrade the stamp on
# every refresh, and it would still parse, so nothing would ever complain. Round-trip it instead.
function ConvertTo-Stamp($Value) {
if ($Value -is [datetime]) { return $Value.ToString("o") }
if ($Value -is [datetimeoffset]) { return $Value.ToString("o") }
return [string]$Value
}

function Get-Mine([string]$Path) {
$c = Get-Content $Path -Raw | ConvertFrom-Json
$held = ($c.worktree -replace '\\', '/').TrimEnd('/')
Expand Down Expand Up @@ -154,8 +165,45 @@ try {
} catch [System.IO.IOException] {
$info = Get-Mine $file
if ($info.IsMine) {
# Re-taking your own claim is a no-op, not an error: a session should be able to re-assert freely.
# Re-taking your own claim is not an error: a session should be able to re-assert freely. It was
# also, until now, not a REFRESH -- despite the -Take parameter documenting one. A new -Note was
# accepted, reported as success, and silently discarded.
#
# That is worse than an outright failure, because the note is the one field written deliberately
# to say what a session is doing, and announce-session.ps1 broadcasts it to every session that
# joins the repo, telling them to prefer it over the worktree name. Measured 2026-08-02: the
# 'announce-hook' claim still read "NO PR OPENED -- honouring the #119 merge freeze" to every
# joining session hours after both #133 and #119 had merged. A stale note broadcast as current
# intent is a coordination fault, not a cosmetic one -- and the documented workaround (-Release
# then -Take) drops the claim in between, re-opening the race the claim exists to close.
if ($Note) {
$updated = [ordered]@{
key = [string]$info.Claim.key
note = $Note
# Refresh the branch too: a worktree can have switched branches since the claim was made,
# and a claim naming a branch nobody is on is another confidently-wrong coordination fact.
branch = $branch
worktree = [string]$info.Claim.worktree
# `claimed` is the identity of the claim and never moves; `refreshed` is what tells a
# reader how old the NOTE is, which is the question a stale note makes urgent.
claimed = ConvertTo-Stamp $info.Claim.claimed
refreshed = (Get-Date).ToString("o")
} | ConvertTo-Json -Compress
# Write-then-rename, not a truncating in-place write. A torn claim file does not fail loudly:
# scripts/hooks/claim_check.py swallows a parse error into "not claimed", which would disable
# the enforced gate for that key -- so a crash mid-write must leave the OLD file intact.
$tmp = "$file.$PID.tmp"
# UTF8 WITHOUT a BOM, for that same reader: a BOM makes json.loads raise.
[System.IO.File]::WriteAllBytes($tmp, [System.Text.Encoding]::UTF8.GetBytes($updated))
Move-Item -LiteralPath $tmp -Destination $file -Force
Write-Host ""
Write-Host "REFRESHED '$Take' (held since $($info.Claim.claimed))." -ForegroundColor Green
Write-Host " note : $Note"
exit 0
}
Write-Host "You already hold '$Take' (claimed $($info.Claim.claimed))." -ForegroundColor Green
Write-Host " note : $($info.Claim.note)"
Write-Host " (pass -Note to update it -- it is what other sessions are shown.)"
exit 0
}
Write-Host ""
Expand Down
54 changes: 46 additions & 8 deletions scripts/coord/overlap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ function ConvertTo-Norm([string]$p) {
return ($p -replace '\\', '/').TrimEnd('/').ToLowerInvariant()
}

# One emitter for every -Json exit, so no path can print nothing. See the note at the -File query below.
function Write-JsonArray($Rows) {
$r = @($Rows)
if ($r.Count -eq 0) { Write-Output "[]"; return }
Write-Output ($r | ConvertTo-Json -Depth 6 -AsArray)
}

$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) {
Expand Down Expand Up @@ -127,8 +134,35 @@ function Build-Map {
$sessionsByCwd[$k] = $e.Record
}

$worktrees = @(Get-WorktreeList)

# ATTRIBUTE A SESSION TO THE MOST SPECIFIC WORKTREE CONTAINING IT, not to the first one that matches.
#
# Linked worktrees live UNDER the primary checkout (.../MessageFoundry/.claude/worktrees/<name>), so
# every linked worktree's path is ALSO a prefix match for the primary's row. The old rule walked the
# session table and broke on the first hit, so the primary row was handed whichever linked-worktree
# session the hashtable happened to enumerate first -- reporting the primary checkout as LIVE, on a
# branch nobody was on, "building" a peer's task list. Hashtable order is not stable, so the wrong
# answer was also a different wrong answer each run, which is why it read as noise rather than a bug.
#
# Longest prefix wins is the only rule that survives nesting: a session in .../worktrees/foo matches
# both the primary and foo, and foo is the one it is actually sitting in.
$ownerByWorktree = @{}
foreach ($k in @($sessionsByCwd.Keys | Sort-Object)) {
$best = $null
foreach ($w in $worktrees) {
$wn = ConvertTo-Norm $w.Path
if ($k -eq $wn -or $k.StartsWith("$wn/")) {
if ($null -eq $best -or $wn.Length -gt $best.Length) { $best = $wn }
}
}
# Two sessions inside one worktree: first by sorted cwd, so the answer is at least the SAME
# answer every run. Reporting one of them is correct -- the row says the worktree is live.
if ($best -and -not $ownerByWorktree.ContainsKey($best)) { $ownerByWorktree[$best] = $sessionsByCwd[$k] }
}

$rows = @()
foreach ($w in (Get-WorktreeList)) {
foreach ($w in $worktrees) {
$norm = ConvertTo-Norm $w.Path
if ($norm -eq $myRootNorm) { continue } # not a collision with yourself
if (-not (Test-Path -LiteralPath $w.Path)) { continue }
Expand Down Expand Up @@ -169,11 +203,9 @@ function Build-Map {
$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 }
}
# A session sitting anywhere INSIDE the worktree owns it, not just one whose cwd is the root --
# resolved above, against every worktree at once, because "inside" is ambiguous when they nest.
$sess = $ownerByWorktree[$norm]
if ($files.Count -eq 0 -and -not $sess) { continue }

$rows += [pscustomobject]@{
Expand Down Expand Up @@ -241,15 +273,21 @@ if ($File) {
$hits += $r
}
}
if ($Json) { ($hits | ConvertTo-Json -Depth 6 -AsArray) | Write-Output; exit 0 }
# ALWAYS EMIT AN ARRAY. `@() | ConvertTo-Json -AsArray` sends ZERO objects down the pipeline, so
# ConvertTo-Json never runs and the script prints NOTHING -- despite -AsArray, which only shapes
# output that exists. "Nobody else is in this file" therefore looked identical on stdout to "this
# script died before it could answer", and a consumer had no way to tell an all-clear from a
# failure. (-InputObject is not the fix: with -AsArray it double-wraps to [[]].) Measured
# 2026-08-02 against the real script.
if ($Json) { (Write-JsonArray $hits); 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 ($Json) { (Write-JsonArray $map); exit 0 }

if (@($map).Count -eq 0) { Write-Host "No other worktree has changes."; exit 0 }
Write-Host ""
Expand Down
Loading
Loading