Skip to content

Commit 3389aa2

Browse files
authored
coord: announce yourself to the other sessions, and stop the collision gate crying wolf (#133)
* feat(coord): announce yourself to the other sessions in this repo Every coordination control in this repo is PULL-based: a new session discovers its peers from the SessionStart banner and the peers learn nothing until someone trips the collision gate. That is too late for the collision that costs the most -- two sessions building the same THING in different files, where nothing file-shaped can catch it. This closes the push direction. It ASKS, it cannot send. Hooks are shell commands and session messaging is MCP, so the hook prints the instruction, the live peer roster and the id-resolution rule at the first prompt that has intent to report; the model does the sending. UserPromptSubmit, not SessionStart: at SessionStart a session knows it exists and nothing else, so it can only say hello -- the interrupt without the information. THE ID RULE IS THE PAYLOAD, and it is counter-intuitive enough that the text states it with its evidence. The registry id in this repo's banners is NOT the MCP session id; measured, a registry id and an MCP id for one session shared no characters. Branch does not join them either -- the two rosters reported different branches for the same checkout in 2 of 6 cases. Only cwd joins, and it must be matched EXACTLY: 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. A registry id passed to send_message fails SILENTLY, which reads as the peer ignoring you. EVERY DECISION LEAVES A RECEIPT, because the bug being fixed was a hook that was wired, fired, resolved nothing and exited 0 for weeks -- byte-identical to a healthy hook with no peers. For the same reason the shim carries its OWN missing-script notice: every receipt the hook writes lives INSIDE the script, strictly downstream of the resolution failure that IS the bug, so the shim is the one surface that still reports when the script does not resolve. It is gated on presence.ps1 so the entry stays silent in every unrelated repo on the machine. It always exits 0 -- a UserPromptSubmit hook that fails can block the user's prompt. It consumes presence.ps1 and therefore the single liveness fence; it does not invent a second notion of live. A separate 'mefor-announce' marker keeps it outside install-coordination's mefor-coord strip and outside the website repo's mefor-web-announce entry in the same settings file, so no installer can delete another's hook, and -Only UserPromptSubmit -Uninstall removes announce alone without disarming the collision gate. * test(coord): pin the announce hook, and the anti-no-op wiring class Most tests for a hook like this assert an ABSENCE, and a hook that does nothing at all satisfies every one of them -- which is precisely the production failure being fixed. So the silence assertions are paired with a positive arm: two tests run the SAME runner against fixtures differing only in whether a peer exists, and if the silence tests ever start passing for the wrong reason the positive one goes red first. test_announce_wiring.py is the class the repo had no test for AT ALL: does the thing that gets INSTALLED reach a script that EXISTS, and does it say so when it does not? Its absence is exactly how a wired-but-inert shim survived for weeks. test_every_wired_script_exists_in_this_checkout was written FIRST and watched fail, naming the missing script and printing all three paths it scanned; a green gate is only evidence if it was shown it can see the failure. Also pinned, each because it was got wrong somewhere first: - The foreign UserPromptSubmit entries -- another repo's shim and an unmarked waiting-flag cleanup -- survive install AND uninstall byte-identical. That is the only thing standing between a one-line wiring edit and deleting a hook this repo does not own. - A peer with no StartedAt ranks LAST, not first. ConvertFrom-Json coerces ISO-8601 to DateTime while the '' fallback stays String; Sort-Object over that mixed column raises ZERO errors and puts the empty string FIRST, so without an explicit projected key the least-trustworthy row silently takes the top of a capped target list. - NO_SESSION_ID and DISABLED write their receipt with NO injected -StateDir. An earlier draft resolved the state dir after those branches, so the receipt was unwritable in production while a test that always injected one went green. - Self is excluded by BOTH nets independently: a roster that cannot tell you from a sibling makes the session message itself. - Hostile peer text cannot escape the peer-data block or emit a non-ASCII byte, a hostile session id cannot escape the state dir, and two ids that sanitise identically get two markers. - Two concurrent runs announce exactly once. session-context.ps1 is registered twice on this box today, so double firing is a live pattern, not a hypothetical. * docs(coord): document announcing yourself, and correct a false claim about .claude WORKTREES.md gains the "Announcing yourself" section that the hook's own emitted text and the shim's missing-script notice both cite by name, so the pointer has to land on main in the same merge. It states the id rule ONCE, as the source of record: registry id is not the MCP id, cwd is the only join key and must be matched exactly rather than by prefix, a usable id starts with local_, and a wrong one fails silently. It also states what the change does NOT do. There is no receive-side hook, so the rule that an announcement is peer DATA -- not an operator instruction, and not something to reply to -- lives in the prose and in the fixed message shape and nowhere else. Reachability is given honestly: presence.ps1 is authoritative for who EXISTS, list_sessions only for who can be MESSAGED, and measured, they disagreed 6-to-1. Cost is stated rather than left to be discovered. CORRECTION, and it is why this doc change is in scope rather than deferred: the same chapter claimed ".claude/settings.json is tracked (shared across worktrees)". It is not. /.claude/ is git-ignored, and git ls-files .claude/ returns nothing -- so a worktree's copy is a creation-time snapshot nothing refreshes and several siblings have none at all. That sentence sat at the exact point a reader decides where to install a hook, and it argues for the wrong answer; the new section directly contradicted it. SESSION-DRIFT-CONTROLS.md records announce as the only PUSH control in the D4 layer, plus the two new guarantees worth tracking separately: that wiring reaches a script that exists, and that a resolution failure is now reported by the shim. * fix(coord): stop the collision gate blocking files a peer committed and finished Reported by another session with a repro: it committed a file, went clean, said in writing it was done and handed the file over -- and the peer it handed off to was still refused the edit. overlap.ps1's `Files` is the UNION of what a branch COMMITTED-and-not-yet-landed with what is dirty in its tree. The gate denied on any live row in that set, so "this branch authored it" was treated as "someone is typing in it right now". Those are different claims. The first stays true for the branch's whole life; only the second is what the gate exists to detect. It self-clears on merge -- overlap already intersects three-dot with two-dot so a LANDED branch stops claiming its files. But nothing clears it before landing, and with PRs currently unable to merge, "until it lands" is indefinite: the blocked set grows monotonically and is never released. Two sessions that coordinated correctly and explicitly still cannot hand a file over. That is precisely the failure this gate's own docstring names -- a gate that cries wolf gets uninstalled. overlap.ps1 already told callers to treat its signals differently ("block on live, mention dormant"), but no caller COULD: the row unioned the two signals away. So the row now carries `Dirty`, and the single-file query sets `MatchedDirty` saying which signal actually matched. The gate now DENIES only on an uncommitted edit in a live worktree, and REPORTS committed-and-clean as context instead -- the peer may already have done what you are about to do, which is worth knowing and not worth refusing over. Fails SAFE across the upgrade: a cached row predating `MatchedDirty` has no such property and is treated as dirty, so the gate degrades to its previous over-blocking rather than silently permitting a real collision. Also, while in the file: `git status` now runs with --no-optional-locks. A plain status REWRITES the index of the repo it inspects, and this walks every peer worktree -- so merely asking "what is in flight" was mutating other sessions' checkouts. Verified against the live repro and both directions: the reported file now allows with context; a file with uncommitted changes in a live worktree still denies; an untouched file stays silent. * feat(coord): lead the announce roster with the claim note, not the worktree name Reported by the session it happened to: its worktree is named inter-session-communication-*, auto-generated at creation from a task that session has never worked on -- it has been doing ASVS scorecard work for its entire life. The directory name is the most visible identifier in presence.ps1, overlap.ps1 and this hook's output, and it had already misled TWO sessions (including this one) into guessing that session was building the announce hook. A worktree name is a creation-time label, not a statement of current work, and nothing keeps the two in sync. The claim note is the only field written DELIBERATELY to say what a session is doing, so the roster now prints it, and the legend tells the reader to prefer it over the name. Joined on the claim's `worktree` path, normalised the same way as every other cwd key here. Fail-open throughout: no claims directory, an unreadable claim, or a peer with no claim all just mean the name is the only thing we have -- which is exactly the status quo, never an error. Same session also flagged that the branch I read for it from list_sessions was stale (a spent, merged branch). The announce text already refuses to join on branch and says why; this is a second, independent reason not to trust it. * docs(coord): name the silent-control defect class in the drift inventory A control that cannot distinguish 'ran and resolved' from 'ran and found nothing' is not installed, however it looks. The announce shim outlived every other silent-control defect found the same day BECAUSE it printed a status message -- which is more convincing than silence. The structural cause is the reusable part: every receipt that hook would have written lived inside the script the shim failed to find, so every check sat strictly downstream of the failure it existed to detect. Looking was not neglected, it was impossible. The question to ask of a new control is which surface still reports when the control itself fails to load. Formulation owed to a peer session that hit four instances of this class in one day and named it more sharply than I had. * docs(coord): record the broadcast constraints six sessions learned the hard way Announce-on-join introduces a session; it does not let an established one push an operational notice. That increment is deferred, and on 2026-08-01 six sessions rehearsed it by hand for four hours. Three constraints fell out, recorded so the next attempt does not rediscover them: - A broadcast needs an EXPIRY or a predicate the RECIPIENT can evaluate, never a promise from the sender. A merge freeze shipped with 'lift when #119 merges'; #119 died on an unrelated CI timeout, so five sessions held on a condition that could not arrive and a second round was needed to retract it. - 'Don't do X' is the wrong primitive when automation already has X armed. The freeze asked for restraint while six PRs had auto-merge ARMED and would have landed with nobody clicking anything. The right ask was an action: disarm. - Coordination a tool cannot read does not count. Two sessions agreed IN WRITING to hand over a file and the gate still refused, because the agreement was prose and the gate reads git. Field data from the sessions that lived it, not speculation. * test(coord): pin overlap's dirty-vs-committed signals against real git Nothing drove overlap.ps1's row computation against a real repository, so the question "does MatchedDirty hold when a file is dirty AND committed at once" was unanswerable by the suite. Raised by the session that spent an evening in exactly that state. THAT CASE IS THE ONE THAT FAILS SILENT, which is why it gets a real fixture rather than a stub row. A peer with uncommitted edits in one region and landed work in another is a genuine collision. Had MatchedDirty been derived from the committed diff instead of the working tree it would read FALSE there, the gate would allow, and two sessions would write one file with nothing reported. The over-block this replaced was loud and annoying; that would be quiet and cost someone their work. Verified the tests can SEE it rather than assuming: sabotaged the row to publish an empty Dirty set -- the precise mis-implementation warned about -- and both MatchedDirty assertions went red; restored, all five green. A test written after the code, never observed failing, is a test of nothing. Also pins that overlap does not rewrite a peer worktree's git index, by comparing the index mtime across two queries. An observer must not perturb what it observes, and this one was doing so on every PreToolUse before f55d6c6. Stub rows would only have asserted that the plumbing carries a value someone else computed; the whole question here is what git actually reports. * test(coord): assert a wired coordination hook resolves to a script that exists Raised by the session that traced the shim: the coordination hooks are not installed copies, they are inline commands that locate their script in a working tree at every invocation. If neither base yields the file, Test-Path fails, the loop ends, nothing runs, and the tool call proceeds with no hook and no signal. "The hook is uninstalled" and "the hook ran and permitted this" are indistinguishable from outside, and nothing was watching. Not hypothetical: a foreign UserPromptSubmit entry sat in this same settings file for weeks probing a script that exists only in another repo. The risk composes badly for collision_gate.ps1 specifically, which now (a) fails OPEN on any error, (b) denies less by design after the dirty-vs-committed split, and (c) silently no-ops when unresolvable. Individually defensible; together the realistic bad day is "the gate was never running and nobody noticed". This closes (c) -- the observation is not mine, and it is a good one. Found immediately on writing it: FIVE user settings files across account directories, not the one I knew about. The informational test also prints the original defect as output rather than leaving it invisible: FOREIGN UserPromptSubmit [mefor-web-announce] -> scripts/hooks/announce.ps1: RESOLVES NOTHING HERE It is another repo's entry, so this reports it and does not touch it. Carries a NEGATIVE CONTROL, because the assertion passed on the first run and a green that has never been shown to fail is not evidence. The real hooks cannot be unwired to prove the predicate works -- the primary checkout is shared with live sessions -- so it is exercised against a path known not to exist. Local-machine only: CI has no user settings and these skip there, which means CI does NOT guard this property. Said plainly, and every test prints what it scanned BEFORE it can skip, per test_gate_installed_parity.py -- the pytest config has no -rs, so a skip would otherwise render as a bare dot with no reason.
1 parent 002be18 commit 3389aa2

11 files changed

Lines changed: 2492 additions & 19 deletions

docs/SESSION-DRIFT-CONTROLS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,25 @@ Frequently forgotten in discussions of "the gate", but it is the same problem cl
9696
Both use exclusive-create because a read-modify-write on a shared list silently lost 4 of 8 concurrent
9797
writes when measured.
9898
99+
- **[`scripts/hooks/announce-session.ps1`](../scripts/hooks/announce-session.ps1)** — a
100+
`UserPromptSubmit` hook that closes the **push** direction of D4. Every control above is pull-based or
101+
commit-time: the peers of a new session learn nothing until someone trips a gate or writes a commit
102+
subject, which is too late for two sessions building the same *thing* in different files. This one
103+
hands the model its live peer roster plus the id-resolution rule at the first prompt that has intent
104+
to report, and asks it to introduce itself. It cannot send anything by itself — hooks cannot call MCP
105+
— so it is an instruction, and whether a message was actually delivered is recorded by the model in
106+
`sent/<key>.tsv`, not by the hook. See [WORKTREES.md](WORKTREES.md), "Announcing yourself".
107+
108+
> **A control that cannot distinguish "ran and resolved" from "ran and found nothing" is not
109+
> installed, however it looks.** The hook the one above replaced fired on every prompt, printed its
110+
> status message, resolved nothing and exited 0 — for weeks. It outlived every other silent-control
111+
> defect found the same day precisely *because* it printed something: a status message is more
112+
> convincing than silence. The structural cause is worth naming, because it recurs — every receipt
113+
> that hook would have written lived **inside** the script the shim failed to find, so every check was
114+
> strictly downstream of the failure it existed to detect. Looking was not neglected; it was
115+
> impossible. When adding a control, ask which surface still reports when the control itself fails to
116+
> load. (Formulation owed to the session that hit four instances of the same class in one day.)
117+
99118
### Recovery and lifecycle
100119
101120
`rescue.ps1` (move dirty primary work into a worktree), `restore-primary.ps1` (re-attach a detached
@@ -123,6 +142,9 @@ reading the emitted decision — not by reading source alone.
123142
| Selfheal — primary auto-repair | user (4 of 5 dirs) | LIVE |
124143
| Selfheal — hijack warning | user (4 of 5 dirs) | **LIVE and currently mis-firing** (§3, G4) |
125144
| `session-context.ps1` banner | project | LIVE where the branch carries the file |
145+
| Announce-on-join (`announce-session.ps1`) | user | **NEW** — the only **push** control; asks, cannot send, and every decision leaves a receipt |
146+
| 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 |
147+
| Announce missing-script notice | user | **NEW** — the one surface that still reports when the script itself fails to resolve |
126148
| Claim / alloc / ledger gates | git hooks | LIVE |
127149
| `new.ps1` / `remove.ps1` / `prune-merged.ps1` | manual | LIVE, **sibling-layout only** |
128150
| `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 |

docs/WORKTREES.md

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,96 @@ chats in the *same* tree can't sweep each other's files into one commit — stag
314314
Review or disable it via `/hooks`.
315315

316316
Because new worktrees branch off `origin/main`, the hook + script reach a new worktree only once
317-
they're committed to `main` (and fetched). `.claude/settings.json` is tracked (shared across worktrees);
318-
`.claude/settings.local.json` stays git-ignored (machine-local).
317+
they're committed to `main` (and fetched). Note that `/.claude/` is **git-ignored** (`.gitignore`), so
318+
*no* project-level `.claude/settings.json` is tracked — a worktree's copy is a creation-time snapshot
319+
that nothing refreshes, and several sibling worktrees have none at all. That is why the coordination
320+
hooks are wired at **user** level by
321+
[../scripts/coord/install-coordination.ps1](../scripts/coord/install-coordination.ps1): git cannot
322+
deliver a project-level hook to a worktree.
323+
324+
## Announcing yourself (UserPromptSubmit hook)
325+
326+
**What it fixes.** Everything above is **pull**-based: a new session discovers its peers and the peers
327+
learn nothing. Nobody finds out about anybody until someone trips the collision gate — too late for the
328+
collision that costs the most, two sessions building the same *thing* in different files, where nothing
329+
file-shaped can catch it. [`../scripts/hooks/announce-session.ps1`](../scripts/hooks/announce-session.ps1)
330+
closes the push direction.
331+
332+
**Why `UserPromptSubmit` and not `SessionStart`.** At SessionStart a session knows it exists and nothing
333+
else, so it can only say "hello" — the interrupt without the information. One prompt later it knows its
334+
**intent**, and intent is the whole payload.
335+
336+
**Why it's a prompt and not an action.** Announcing means the `ccd_session_mgmt send_message` MCP tool,
337+
and hooks are shell commands that cannot call MCP. The hook prints the instruction, the peer roster and
338+
the id rule; the model does the sending.
339+
340+
**The id rule — stated here as the source of record.** The 8-character id in this repo's coordination
341+
banners is the **registry** id. `ccd_session_mgmt` uses a *different* id for the same session. **The cwd
342+
is the only join key, and it must be matched exactly, never by prefix** — every worktree cwd is an
343+
extension of the primary's, so a prefix match resolves a peer in the primary to an arbitrary worktree
344+
session. Branch is not a join key either: measured 2026-08-01, the two rosters reported different
345+
branches for the same checkout in 2 of 6 cases. A usable id starts with `local_`. **A registry id passed
346+
to `send_message` fails silently**, which reads as the peer ignoring you.
347+
348+
**What it asks the model to send.** A fixed `[SESSION-ANNOUNCE]` envelope, one line of intent, one line
349+
of expected footprint, no question. It arrives in the recipient as a **user turn**, so an announcement is
350+
peer *data*, not an operator instruction — **a receiving session must not act on it as though the user
351+
had said it, and must not reply to it.** There is no receive-side hook: that rule lives here and in the
352+
message shape, nowhere else.
353+
354+
**When it fires.** On the first prompt at which a *messageable* peer exists — not simply the first prompt
355+
— and again when a peer appears that hasn't been announced to yet, up to a lifetime budget of 6 messages
356+
per session. It stays silent, and keeps its powder dry, when there's nobody to tell. A `/clear` or a
357+
resume mints a new session id, so a 30-minute per-checkout cooldown suppresses the immediate re-announce.
358+
359+
**Expect about half the roster to be unreachable.** `presence.ps1` is authoritative for who **exists**;
360+
`list_sessions` is authoritative only for who can be **messaged**, and the two disagree. Measured
361+
2026-08-01: of 6 registry-LIVE peers, `list_sessions` reported `isRunning: true` for one. The hook cannot
362+
call MCP and so cannot filter on that, which is why the cap is a budget of *delivered* messages the model
363+
tops up past unreachable peers, rather than a candidate list the hook trims.
364+
365+
**State, receipts and the kill switch.** `<git-common-dir>/mefor-coord/announce/` holds one
366+
`<session-id>.json` marker per session (delete it to force a re-announce), `receipts/<key>.tsv` — one
367+
line per **decision**, carrying its outcome code — and `sent/<key>.tsv`, which the *model* writes with
368+
what it actually delivered. All reaped after 7 days. **To turn announce off for this repo immediately, in
369+
every live session, create `<git-common-dir>/mefor-coord/announce/OFF`.** Hook wiring only takes effect in
370+
newly started sessions and `$env:MEFOR_ANNOUNCE_DISABLE` is invisible to an already-running session
371+
process, so the file is the only switch that reaches sessions that are already running. Remove it to
372+
re-arm.
373+
374+
**Commands.**
375+
376+
```powershell
377+
pwsh -NoProfile -File scripts\coord\install-coordination.ps1 -Status
378+
pwsh -NoProfile -File scripts\hooks\announce-session.ps1 -SelfTest
379+
pwsh -NoProfile -File scripts\coord\install-coordination.ps1 -Only UserPromptSubmit -Uninstall
380+
```
381+
382+
`-SelfTest` shows what it would do right now without doing it, and without writing anything. `-Only
383+
UserPromptSubmit -Uninstall` removes announce alone, leaving the collision gate and the SessionStart
384+
banner armed.
385+
386+
**Cost, stated rather than discovered.** Measured on this host: the shim costs ~0.5 s on every user
387+
prompt in *every* repo on the machine; the peer lookup adds ~1.0 s on the prompts where it actually runs,
388+
because the marker check precedes it. A session with no new messageable peer re-checks at most once a
389+
minute for its first ten checks, then once every ten minutes, and stops entirely after 40.
390+
391+
**What this deliberately does NOT do: broadcast.** Announce-on-join introduces a session. It does not
392+
let an established session push an operational notice ("hold merges", "I've released file X") to its
393+
peers. That is a separate increment, and on 2026-08-01 six sessions ran an unplanned live rehearsal of
394+
it by hand. Three constraints came out of that, recorded here so the next attempt doesn't rediscover
395+
them:
396+
397+
- **A broadcast needs an expiry or a predicate the *recipient* can evaluate — never a promise from the
398+
sender.** A merge freeze went out with "lift when #119 merges". #119 never merged (it died on an
399+
unrelated CI timeout), so five sessions held on a condition that could not arrive, and it took a
400+
second round to retract.
401+
- **"Don't do X" is the wrong primitive when automation already has X armed.** The freeze asked
402+
sessions not to merge, while six PRs had auto-merge *armed* and would have landed with nobody
403+
clicking anything. The correct ask was an action — "disarm auto-merge" — not restraint.
404+
- **Coordination that a tool cannot read does not count.** Two sessions agreed in writing to hand over
405+
a file and the collision gate still refused, because agreement lived in prose and the gate reads git.
406+
A broadcast worth building publishes something the gate consumes, not only something a human reads.
319407

320408
## The worktree gate (enforcement, not a reminder)
321409

0 commit comments

Comments
 (0)