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
35 changes: 35 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17129,6 +17129,41 @@ demonstrate its own pass arm has never been shown to have one.*
**Expiry:** this stops being right if the two registries are unified, or if the vault stops installing
the gate.

**BUILT 2026-09-03 -- the registries are now unifiable, which is the first half of the expiry above.**
**REPRODUCED FIRST, by execution.** `tests/test_claim_shared_registry.py` git-inits two independent
checkouts, claims from one and commits in the other: 5 of 6 arms red, with the single-repository arm
green in the same run as the positive control that the harness and the gate both work. The measured
refusal was `is NOT CLAIMED` -- **not** `claimed by ANOTHER worktree` -- which is the proof the gate was
reading an empty registry rather than adjudicating one.

**THE ROOT CAUSE IS NARROWER THAN THE ROW STATES, AND NAMING IT CHANGED THE FIX.** `claim.ps1` used one
value for TWO questions -- *where the registry is* and *who holds the claim*. Inside one repository those
are always the same tree, so the conflation is invisible; across two they diverge. **The Console's
preferred option, the gate reading the registry the tool writes, is necessary but NOT sufficient on its
own:** the gate also compares the record's `worktree` against the tree being committed, so a claim taken
by the engine's tool still reads as *held by another worktree* from the second repository. Both halves
shipped, and both are proven load-bearing by mutation -- reverting the pointer reproduces all 5 original
failures, reverting the holder split kills exactly the one arm it serves.

**Option taken: ONE SHARED REGISTRY, both gates reading it** (not a vault-local registry). `git config
mefor.claimsRoot <path>`, set in the repository that does not host the registry; both halves resolve from
the repository the claim is FOR, so they cannot disagree about where to look. `-AsWorktree` names the
holder when the tool is run from a tree it does not live in. Unset, behaviour is byte-for-byte what
shipped before -- asserted, not asserted-about -- so **no existing claim is invalidated**. An
unresolvable pointer FAILS CLOSED: the silent fallback would send the gate to a directory nothing writes,
where a misconfigured pointer presents as an honestly unclaimed item.

**THE VAULT HALF IS UNVERIFIED BY CONSTRUCTION and that is not a hedge.** CLAUDE.md limits reading that
tree to `roles/`, so whether it carries its own `claim.ps1` is still the one unmeasured fact -- exactly
as the row said. The fix is therefore built to not depend on the answer: the pass arm is tested in BOTH
shapes, the second repository running its own copy of the tool and running this repository's copy by
absolute path. **What remains for someone who may open that tree:** set the config key there, and re-run
`install-git-hooks.ps1` so the shared `.git/hooks` payload is not the pre-change copy.

**The commit-body route recorded above was the only route through an unpassable gate**, and it stops
being needed here. It still must not be conflated with the 2026-08-06 evasion, where claiming properly
was possible; the distinction is whether a correct alternative existed, and now one does.

## 1347. a multi-item commit cites only its first number with the BACKLOG prefix, so sibling items read as unbuilt to every citation-based check

> 🔢 **Filed 2026-08-23 - not started.** The house form is `(BACKLOG #1319, #1322, #1323, #1331)` -- **the prefix appears ONCE and the siblings carry a bare `#N`.** So any check greping `BACKLOG #<N>` finds the first item and misses the rest. **Measured on `origin/main`: `#1319` matches, its three siblings do not.** ***The failure direction is the expensive one -- a sibling whose work landed months ago reads as unbuilt, and a dispatcher hands a builder work that is already done.***
Expand Down
10 changes: 10 additions & 0 deletions docs/SESSION-DRIFT-CONTROLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ Frequently forgotten in discussions of "the gate", but it is the same problem cl
*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
closed as duplicates and the one that merged had not tested the failure mode the others found.
- **One registry can serve two repositories, and before BACKLOG #1346 it could not.** Set
`git config mefor.claimsRoot <path>` in the repository that does *not* host the registry — the
separate `MessageFoundry-vault` clone is the case it was built for. Both halves then resolve from the
repository the claim is **for**, never from the tree a script happens to live in, so `claim.ps1` and
`claim_check.py` cannot disagree about where to look. Take a claim in another tree's name with
`claim.ps1 -Take <key> -AsWorktree <that-tree>`. Unset — this repository's own state — nothing
changes. `install-git-hooks.ps1 -Status` prints which claims directory the gate actually reads;
an unresolvable pointer **fails closed** rather than falling back to a local registry nothing writes,
because that fallback would make a misconfigured pointer present as an honestly unclaimed item. The
reasoning is stated once, in those two scripts.
- **[`scripts/coord/alloc.ps1`](../scripts/coord/alloc.ps1)** + **[`ledger_check.py`](../scripts/hooks/ledger_check.py)**
— the same test-and-set for ADR/BACKLOG *numbers*. See [LEDGER-GATE.md](LEDGER-GATE.md).

Expand Down
97 changes: 81 additions & 16 deletions scripts/coord/claim.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,20 @@ param(
# What the work is -- recorded so a sibling session sees WHY the key is taken.
[string]$Note,
# Release a claim held by ANOTHER worktree (for a session that died without releasing).
[switch]$Force
[switch]$Force,
# Hold the claim in the name of THIS tree instead of the one this script lives in (BACKLOG #1346).
#
# ONE VALUE USED TO ANSWER TWO QUESTIONS -- *where the registry is* and *who holds the claim* -- and
# inside a single repository those are always the same tree, so the conflation was invisible. Across
# two repositories that share a registry they diverge, and there was no way to say "the tool lives
# over there, the committing tree is here". `scripts/hooks/claim_check.py` compares the record's
# worktree against the tree being committed, so without this the gate in a second repository refused
# a claim that had just been taken for it.
#
# THIS IS NOT A RETREAT FROM THE $PSScriptRoot ANCHORING (BACKLOG #1060). That defect was a SILENT
# read of the caller's cwd; this is an explicit argument, recorded in the claim, printed back on
# every surface that shows a holder. Nothing changes unless someone asks for it.
[string]$AsWorktree
)

$ErrorActionPreference = "Stop"
Expand All @@ -76,25 +89,66 @@ if (-not $repo) { throw "scripts/coord/ is not inside a git repository: $PSScrip
try { . "$PSScriptRoot/occupancy.ps1" } catch { }
$repo = $repo.Trim()

# WHO HOLDS THE CLAIM, which is a different question from where this script lives (BACKLOG #1346).
# Defaults to $repo, so every existing invocation behaves exactly as it always has.
$holder = $repo
if ($AsWorktree) {
$holderTop = (& git -C $AsWorktree rev-parse --path-format=absolute --show-toplevel 2>$null)
if (-not $holderTop) { throw "-AsWorktree '$AsWorktree' is not inside a git repository." }
# Its TOPLEVEL, not the string as typed: a subdirectory, a trailing slash or a relative path would
# otherwise be recorded verbatim, and the gate compares this field against `git rev-parse
# --show-toplevel` in the committing tree. A record that cannot match is a claim nothing honours.
$holder = $holderTop.Trim()
}

# ONE divergence test, three call sites (BACKLOG #1358). The note used to be written inline at the very
# end of the script, which put it after the `-Take` success block and therefore made it UNREACHABLE from
# `-Release` -- the script stated the release rule at claim time and went silent at the moment the
# operator applied it. `$Subject` is the only part that varies, because the true sentence differs: a take
# is recorded to $repo, whereas a release is BOTH recorded to it and adjudicated against it.
#
# Deliberately reads $repo at CALL time from script scope rather than taking it as a parameter: a second
# copy of "which tree is this" is exactly the drift this note exists to report.
# Deliberately reads $holder at CALL time from script scope rather than taking it as a parameter: a
# second copy of "which tree is this" is exactly the drift this note exists to report.
#
# Compares against $holder, not $repo. The note exists to warn that the claim is being recorded against a
# tree the operator is not standing in -- so once `-AsWorktree` names the tree they ARE standing in, there
# is no divergence left to warn about and firing anyway would be a false alarm on the correct usage.
function Write-DivergenceNote([Parameter(Mandatory)][string]$Subject) {
$cwdTop = (& git rev-parse --path-format=absolute --show-toplevel 2>$null)
if (-not $cwdTop) { return }
$a = ($cwdTop.Trim() -replace '\\', '/').TrimEnd('/')
$b = ($repo -replace '\\', '/').TrimEnd('/')
$b = ($holder -replace '\\', '/').TrimEnd('/')
if ($a -ieq $b) { return }
Write-Host " NOTE: your shell is in $a, but this script lives in $b," -ForegroundColor Yellow
Write-Host " NOTE: your shell is in $a, but this claim is recorded against $b," -ForegroundColor Yellow
Write-Host " so the $Subject" -ForegroundColor Yellow
}

$common = (& git -C $repo rev-parse --path-format=absolute --git-common-dir).Trim()
# WHERE THE REGISTRY LIVES, resolved from the repository the claim is FOR (BACKLOG #1346).
#
# Anchored on $holder rather than $repo so that the tool and `scripts/hooks/claim_check.py` resolve from
# the SAME tree. The gate reads this key in the repository being committed; if the tool read it anywhere
# else the two could disagree, and a claim written where the gate never looks is the unpassable state this
# fixes. `mefor.claimsRoot` is unset in this repository, so the ordinary path is unchanged: $holder's own
# common dir, exactly as before.
#
# ONE HOP. If the host repository sets the key too, its own gate follows the same single hop, so both
# sides still land together and a chain cannot split them.
$registryRepo = $holder
$claimsRoot = $null
try { $claimsRoot = (& git -C $holder config --get mefor.claimsRoot 2>$null) } catch { $claimsRoot = $null }
if ($claimsRoot) {
$claimsRoot = $claimsRoot.Trim()
$rootTop = (& git -C $claimsRoot rev-parse --path-format=absolute --show-toplevel 2>$null)
# THROW rather than fall back. A silent fallback would write this claim into a registry the gate does
# not read, which looks like success and refuses at commit time with no way to see why -- the exact
# shape of #1346.
if (-not $rootTop) {
throw "mefor.claimsRoot in $holder names '$claimsRoot', which is not a git repository. Fix it with: git -C $holder config mefor.claimsRoot <path>"
}
$registryRepo = $rootTop.Trim()
}

$common = (& git -C $registryRepo rev-parse --path-format=absolute --git-common-dir).Trim()
$claims = Join-Path $common "mefor-coord/claims"
New-Item -ItemType Directory -Force -Path $claims | Out-Null

Expand Down Expand Up @@ -137,7 +191,7 @@ function ConvertTo-Stamp($Value) {
function Get-Mine([string]$Path) {
$c = Get-Content $Path -Raw | ConvertFrom-Json
$held = ($c.worktree -replace '\\', '/').TrimEnd('/')
$me = ($repo -replace '\\', '/').TrimEnd('/')
$me = ($holder -replace '\\', '/').TrimEnd('/')
[pscustomobject]@{ Claim = $c; IsMine = ($held -ieq $me) }
}

Expand Down Expand Up @@ -301,7 +355,7 @@ function Get-HolderLiveness([string]$HeldPath) {
function Show-List {
$files = @(Get-ChildItem $claims -Filter *.json -EA SilentlyContinue | Sort-Object Name)
if (-not $files) { Write-Host "No active claims."; return }
$me = ($repo -replace '\\', '/').TrimEnd('/')
$me = ($holder -replace '\\', '/').TrimEnd('/')
Write-Host ""
Write-Host "Active work claims ($($files.Count)):"
foreach ($f in $files) {
Expand Down Expand Up @@ -356,8 +410,8 @@ function Show-List {

# Resolved for BOTH paths, not just -Take. A release record that names who released the claim is only
# half an answer without the branch they were standing on -- the same question -Take records.
$branch = & git -C $repo branch --show-current
if ([string]::IsNullOrWhiteSpace($branch)) { $branch = "detached@" + (& git -C $repo rev-parse --short HEAD) }
$branch = & git -C $holder branch --show-current
if ([string]::IsNullOrWhiteSpace($branch)) { $branch = "detached@" + (& git -C $holder rev-parse --short HEAD) }
$branch = $branch.Trim()

if ($Release) {
Expand Down Expand Up @@ -413,7 +467,7 @@ if ($Release) {
# tree, so "another worktree" can be the operator's OWN, with the foreign thing being the copy of
# this script they invoked. Without the note that reads as a genuine cross-session collision and
# invites a -Force, which is the one action the whole block exists to talk them out of.
Write-DivergenceNote "ownership was judged against it, NOT against your shell's tree -- re-run this from $repo before concluding anyone else holds it."
Write-DivergenceNote "ownership was judged against it, NOT against your shell's tree -- re-run this from $holder, or pass -AsWorktree, before concluding anyone else holds it."
exit 1
}
# RECORD FIRST, then act. Both orders can lie once and only one lie is recoverable: removing first
Expand All @@ -428,7 +482,7 @@ if ($Release) {
ts = (Get-Date).ToString("o")
event = "release"
key = $Release
released_by = $repo
released_by = $holder
released_branch = $branch
prior_holder = ConvertTo-Stamp $info.Claim.worktree
prior_branch = ConvertTo-Stamp $info.Claim.branch
Expand Down Expand Up @@ -458,7 +512,7 @@ if ($Release) {
ts = (Get-Date).ToString("o")
event = "release-failed"
key = $Release
released_by = $repo
released_by = $holder
reason = $_.Exception.Message
} | ConvertTo-Json -Compress) | Out-Null
throw
Expand Down Expand Up @@ -619,7 +673,7 @@ try {
key = $Take
note = if ($Note) { $Note } else { "(no note)" }
branch = $branch
worktree = $repo
worktree = $holder
claimed = (Get-Date).ToString("o")
} | ConvertTo-Json -Compress
# UTF8 WITHOUT a BOM: the python-side gate reads this with encoding="utf-8", and a BOM makes
Expand All @@ -632,9 +686,20 @@ try {

Write-Host ""
Write-Host "CLAIMED '$Take'" -ForegroundColor Green
Write-Host " by : $repo [$branch]"
Write-Host " by : $holder [$branch]"
Write-Host " note : $(if ($Note) { $Note } else { '(no note)' })"
Write-Host " release when done: pwsh -NoProfile -File scripts\coord\claim.ps1 -Release $Take"
# Built outside the string: a nested double-quoted subexpression inside a double-quoted string is a
# PowerShell parse error, not a runtime one, so it takes the whole script down at load time.
$releaseArgs = "-Release $Take"
if ($AsWorktree) { $releaseArgs += " -AsWorktree `"$holder`"" }
Write-Host " release when done: pwsh -NoProfile -File scripts\coord\claim.ps1 $releaseArgs"
# Say where it landed WHENEVER that is not this tree's own registry. A claim written into another
# repository's registry is the correct outcome under mefor.claimsRoot and an alarming one unexplained,
# and the operator has to know the answer to read `-List` anywhere (BACKLOG #1346).
if ($registryRepo -ne $holder) {
Write-Host " registry: $claims" -ForegroundColor Yellow
Write-Host " (SHARED -- mefor.claimsRoot in $holder points at $registryRepo)" -ForegroundColor Yellow
}

# Same note alloc.ps1 prints, for the same reason (BACKLOG #1060): anchoring is correct but surprising,
# and a claim recorded to a worktree the caller is not standing in otherwise surfaces only as a refused
Expand Down
27 changes: 27 additions & 0 deletions scripts/coord/install-git-hooks.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@ if ($Status) {
# shim" -- reporting on OUR marker would say 'not installed' for a perfectly healthy setup.
$pcShim = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match 'File generated by pre-commit')
Write-Host "hooks dir : $hooksDir"

# WHERE WORK CLAIMS LIVE, which is not always this repository (BACKLOG #1346). The commit-msg gate
# below refuses a code-touching commit whose SUBJECT cites a ledger number unless a claim for THIS
# tree exists in that registry -- so an operator reading a refusal needs to know which directory the
# gate actually opened. Nothing anywhere said, and that silence is most of why the split between the
# tool's registry and the gate's went unnoticed: a refusal against a registry in another repository
# is indistinguishable, from the outside, from an item nobody has claimed.
$claimsRoot = (& git -C $RepoRoot config --get mefor.claimsRoot)
if ($claimsRoot) {
$claimsRoot = $claimsRoot.Trim()
$rootTop = (& git -C $claimsRoot rev-parse --path-format=absolute --show-toplevel 2>$null)
if ($rootTop) {
$rootCommon = (& git -C $rootTop.Trim() rev-parse --path-format=absolute --git-common-dir).Trim()
Write-Host "claims : $(Join-Path $rootCommon 'mefor-coord/claims')"
Write-Host " ^ SHARED -- mefor.claimsRoot points at $($rootTop.Trim())"
}
else {
Write-Host "claims : UNRESOLVABLE -- mefor.claimsRoot names '$claimsRoot'," -ForegroundColor Red
Write-Host " which is not a git repository. The claim gate FAILS CLOSED on this," -ForegroundColor Red
Write-Host " so every code-touching commit citing a ledger number is refused" -ForegroundColor Red
Write-Host " until it is corrected or unset:" -ForegroundColor Red
Write-Host " git -C $RepoRoot config --unset mefor.claimsRoot" -ForegroundColor Red
}
}
else {
Write-Host "claims : $(Join-Path $common 'mefor-coord/claims') (this repository's own)"
}
Write-Host "commit-msg : $(if ($claimInstalled) { 'INSTALLED (claim gate)' } elseif (Test-Path $commitMsg) { 'present, but NOT ours' } else { 'not installed' })"
Write-Host "pre-commit : $(if ($pcShim) { 'pre-commit framework (carries the ledger gate + leak gate)' } elseif ($stale) { 'STALE standalone ledger hook -- re-run this script to migrate' } elseif (Test-Path $preCommit) { 'present, but NOT ours' } else { 'NOT INSTALLED -- run: pre-commit install' })"
if ($stale) {
Expand Down
Loading
Loading