Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4a8e111
fix(coord): overlap gave two different answers the same bytes, twice
wshallwshall Aug 2, 2026
c6b8f42
fix(coord): the collision gate reported an all-clear when it had chec…
wshallwshall Aug 2, 2026
07deb2f
fix(coord): claim.ps1 accepted a new note, reported success, and disc…
wshallwshall Aug 2, 2026
3bacb6a
docs(coord): record the three fixes, and correct a claim that has exp…
wshallwshall Aug 2, 2026
2ac05f4
Merge remote-tracking branch 'origin/main' into claude/announce-hook-…
wshallwshall Aug 2, 2026
c8f0030
docs(coord): announce-on-join merged and was never installed
wshallwshall Aug 2, 2026
6f5495b
Merge remote-tracking branch 'origin/main' into claude/announce-hook-…
wshallwshall Aug 2, 2026
81e11d8
fix(coord): five defects this PR's own first pass introduced or left
wshallwshall Aug 2, 2026
38505f3
docs(worktrees): "is it live yet" has two answers, and they are diffe…
wshallwshall Aug 2, 2026
ad64b90
Merge remote-tracking branch 'origin/main' into claude/announce-hook-…
wshallwshall Aug 2, 2026
bc0f684
docs(worktrees): the freeze bullet had the right lesson and the wrong…
wshallwshall Aug 2, 2026
de868f5
docs(worktrees): put the two numbers behind the freeze bullet, with t…
wshallwshall Aug 2, 2026
8555fb4
docs(ledger): the CI backstop does not re-check ownership, and said i…
wshallwshall Aug 2, 2026
f3699b9
Merge branch 'main' into claude/announce-hook-intersession-d11524
wshallwshall Aug 2, 2026
376a9a0
Merge branch 'main' into claude/announce-hook-intersession-d11524
wshallwshall Aug 2, 2026
3f1bba5
Merge branch 'main' into claude/announce-hook-intersession-d11524
wshallwshall Aug 2, 2026
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
20 changes: 18 additions & 2 deletions docs/LEDGER-GATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,22 @@ worse than no gate.

`git commit --no-verify` bypasses the hook, and a branch cut from a **stale main** cannot see a collision
at all — each branch is internally consistent, and the duplicate only exists once *both* have merged. So
CI re-runs the same rules with `--ci` against a freshly fetched `origin/main`.
CI re-runs the rules with `--ci` against a freshly fetched `origin/main`.

**It re-runs all of them but one, and the exception is the ownership rule** — `ledger_check.py:196` and
`:241` both read `not self.ci and not self.owns(...)`, so *"was this number allocated to you"* is
**enforced locally and never in CI**. It has to be: `owns()` reads the allocation store from
`<git-common-dir>/mefor-coord/alloc`, a CI runner clones fresh and has none, so the check would return
False for every ADR and no ADR could ever merge. The consequence is worth stating plainly rather than
leaving as an inference — **a green CI on an ADR or BACKLOG PR is not evidence that the number was
allocated to anybody.** What CI *does* still catch, and what actually prevents the ledger corruption
this gate exists for, is collision-with-base, the missing index row, and duplicate rows.

So the residual after `--no-verify` is narrower than "unprotected" and wider than "backstopped": a
number belonging to another session's *unmerged* branch can be taken and committed with nothing
objecting. The corruption surfaces when the second of the two merges — the collision rule blocks it —
which is late, loud, and recoverable, rather than silent. That is the property the gate was built for;
allocation discipline itself is on the honour system once the local hook is skipped.

That step is **deliberately ungated** in `.github/workflows/ci.yml`. Every other step in the `test` job is
conditioned on `code == 'true'`, which is **false for a docs-only PR** — and an ADR-only PR *is* docs-only.
Expand All @@ -119,7 +134,8 @@ a pair.
## Limits

- **`--no-verify` bypasses the pre-commit hook.** It is a guardrail, not a security boundary. The `--ci`
leg is the backstop, and it cannot be bypassed from a branch.
leg is the backstop for *collision*, and it cannot be bypassed from a branch — but it does **not**
re-check ownership (§3), so that one rule has no backstop at all.
- **It does not stop two sessions building the same thing** under two different numbers. Duplicated work
has no file conflict and no number conflict; nothing here sees it.
- **Numbers leak.** An abandoned branch's number is never reclaimed. Accepted, deliberately.
Expand Down
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
69 changes: 65 additions & 4 deletions docs/WORKTREES.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,35 @@ hooks are wired at **user** level by
[../scripts/coord/install-coordination.ps1](../scripts/coord/install-coordination.ps1): git cannot
deliver a project-level hook to a worktree.

**A change to these scripts reaches a session by one of TWO rules, and they are not the same rule.**
Getting this wrong produces a confident, wrong answer to "can I use it yet", so state which one applies:

| How the script is reached | When your change reaches a session |
|---|---|
| **Run by a hook** (`collision_gate.ps1`, and `overlap.ps1` as its callee) | The installed shim resolves the **primary checkout** first, falling back to the calling worktree. So: **when the primary advances** — regardless of what any session's own branch contains |
| **Run by hand from a worktree** (`claim.ps1`, `overlap.ps1`, `presence.ps1`) | From that session's **own tree**. So: **when that session's branch contains it** — the primary is irrelevant |

Reported 2026-08-02 by a peer session that was told a merged `claim.ps1` improvement was available to
it, tried it, and got the old behaviour: its branch predated the change. Both halves of the answer were
individually true and the combination was wrong. Test the property, not the provenance, and test it
where the script will actually run from:

```powershell
grep -c <a token from the change> <primary>/scripts/coord/overlap.ps1 # hook-run scripts
grep -c <a token from the change> ./scripts/coord/claim.ps1 # hand-run scripts
```

**Asking who holds a file, right now, without changing anything.** The collision gate answers this
directly, and it is the only command that does — `-PathOverride` makes it skip stdin and print its
decision:

```powershell
pwsh -NoProfile -File scripts\hooks\collision_gate.ps1 -PathOverride docs\BACKLOG.md
```

Empty output means no live session holds it. Documented in-script as a test affordance; surfaced here
because a session that needed the answer found it by reading the source.

## Announcing yourself (UserPromptSubmit hook)

**What it fixes.** Everything above is **pull**-based: a new session discovers its peers and the peers
Expand All @@ -341,7 +370,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 +428,37 @@ 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", and five sessions held. **#119
merged** — 2026-08-02 01:45:00Z, merge commit `002be182`, **12h15m** after its auto-merge was armed
(`auto_squash_enabled` 2026-08-01 13:29:37Z, from the issue timeline — note the event is
`auto_squash_enabled`, so a filter on `auto_merge_enabled` finds nothing and the wait looks
unmeasurable). The failure was never that the condition could not arrive; it was that **the world
moved while everyone waited**. `main` advanced four times before it did:

| | |
|---|---|
| #74 | 2026-08-01 20:27:03Z |
| #120 | 2026-08-01 23:59:43Z |
| #131 | 2026-08-02 00:35:29Z |
| #130 | 2026-08-02 01:01:35Z |

The first of those landed **8m26s** after the claim declaring the freeze was taken (that claim is
stamped 2026-08-01 23:51:17Z and is *still on the board*; #120 merged 23:59:43Z). Stated as the claim
timestamp rather than "when the freeze was written" deliberately — `claimed` records when the key was
taken, and only a `refreshed` stamp would evidence when the note itself was last edited. That field
is absent here, which on the code of the day is consistent: there was no way to edit a note in place,
so the two coincide unless someone hand-edited the JSON.

So the freeze did not hold `main` still even while it was nominally in force — it held only the
sessions honouring it, which is the worst of both. And it outlived its own condition in the other
direction too: on 2026-08-02 that same claim note was still announcing the freeze to every joining
session hours after #119 had merged. A predicate the recipient cannot evaluate does not expire.

This bullet said "#119 never merged" for a day, which made it a compensating control resting on a
false premise — the failure [`CLAUDE.md`](../CLAUDE.md) §11 names, inside the document arguing for
it. Corrected against the API, and the same framing was independently corrected in `ci.yml`
(`07b6e55a`) and in BACKLOG #340 by two other sessions; timestamps above are theirs, re-verified
here rather than restated.
- **"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
Loading
Loading