diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index b87324574..917c6e8a7 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -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 `, 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 #` 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.*** diff --git a/docs/SESSION-DRIFT-CONTROLS.md b/docs/SESSION-DRIFT-CONTROLS.md index b554b1390..a47246147 100644 --- a/docs/SESSION-DRIFT-CONTROLS.md +++ b/docs/SESSION-DRIFT-CONTROLS.md @@ -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 ` 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 -AsWorktree `. 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). diff --git a/scripts/coord/claim.ps1 b/scripts/coord/claim.ps1 index 2c884b6d0..27ffdb39b 100644 --- a/scripts/coord/claim.ps1 +++ b/scripts/coord/claim.ps1 @@ -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" @@ -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 " + } + $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 @@ -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) } } @@ -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) { @@ -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) { @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/scripts/coord/install-git-hooks.ps1 b/scripts/coord/install-git-hooks.ps1 index c6d17d5c3..2c9e5e34f 100644 --- a/scripts/coord/install-git-hooks.ps1 +++ b/scripts/coord/install-git-hooks.ps1 @@ -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) { diff --git a/scripts/hooks/claim_check.py b/scripts/hooks/claim_check.py index eaa821d26..fbf57fe5e 100644 --- a/scripts/hooks/claim_check.py +++ b/scripts/hooks/claim_check.py @@ -29,6 +29,22 @@ Stdlib only, no `messagefoundry` import: most worktrees have no .venv, and a gate that silently skips is worse than no gate. + +ONE REGISTRY CAN SERVE TWO REPOSITORIES (BACKLOG #1346), and before that it could not. This gate is +installed in more than one repository -- the engine carries it, and the separate MessageFoundry-vault +clone runs the same file. Each read the claims directory of the tree it was committing in, while +``scripts/coord/claim.ps1`` writes the claims directory of the checkout the SCRIPT lives in. Inside one +repository those are the same directory and the split is invisible; across two they are not, so a second +repository's commit whose SUBJECT cited a ledger number could NEVER pass, however honestly the item was +held -- its gate looked in a registry nothing had ever written. The only route through was to cite the +item in the commit BODY, which this gate permits by design, and a gate whose sole remedy is a sanctioned +way around it is the state that manufactures evasion. + +``git config mefor.claimsRoot `` fixes it, set in the +repository that does NOT host the registry. Both halves then resolve the same way -- **from the +repository the claim is FOR, never from the tree a script happens to live in** -- so the tool and the +gate cannot disagree about where to look. Unset, which is the default and this repository's own state, +nothing changes: the registry is this repository's own, exactly as it has always been. """ from __future__ import annotations @@ -105,10 +121,21 @@ def _safe_for_message(value: object, limit: int = 400) -> str: return text +#: Set in a repository whose work claims live in ANOTHER repository's registry. Its value is a path to +#: that repository. Kept in git config rather than a tracked file because it is a property of one CLONE +#: on one box, not of a branch: a tracked pointer would travel to every checkout of the repository, +#: including ones sitting beside a different engine tree or none at all. +_CLAIMS_ROOT_KEY = "mefor.claimsRoot" + + class GitReadError(RuntimeError): """git could not answer, so nothing downstream may treat its silence as an answer.""" +class ClaimRegistryError(RuntimeError): + """The registry pointer is SET and cannot be resolved, so where to look is unknown -- not empty.""" + + def _git(*args: str) -> str: """Run a git read and REFUSE TO RETURN ITS SILENCE AS DATA. @@ -167,24 +194,94 @@ def _touches_code(paths: list[str]) -> bool: return False -def _claims_dir() -> Path: - common = _git("rev-parse", "--path-format=absolute", "--git-common-dir").strip() - return Path(common) / "mefor-coord" / "claims" +def _config(repo: str, key: str) -> str | None: + """One git config value, with UNSET told apart from UNREADABLE. + + ``git config --get`` exits 1 for a missing key and 2-or-more for a real failure, so the two ARE + distinguishable -- and they must be. Collapsing them turns a broken read into "not configured", + which is the silent fallback the registry resolution below exists to refuse: it would send this + gate to a directory nothing writes, where every claim reads as absent and a misconfigured pointer + presents as an honestly unclaimed item. + """ + proc = subprocess.run( # nosec B603 B607 - fixed argv, no shell, no caller-supplied executable + ["git", "-C", repo, "config", "--get", key], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if proc.returncode == 1: + return None + if proc.returncode != 0: + detail = (proc.stderr or "").strip().splitlines() + raise GitReadError( + f"git config --get {key} exited {proc.returncode}: " + f"{detail[0] if detail else 'no output'}" + ) + return proc.stdout.strip() or None + + +def _claims_dir(repo: str) -> tuple[Path, str | None]: + """Where ``repo``'s work claims live, plus the repository hosting them when that is somewhere else. + + BACKLOG #1346. Resolved from the repository the claim is FOR -- the one being committed -- and never + from wherever a script sits, because that is the only anchor both this gate and ``claim.ps1`` can + agree on without calling each other. + + ONE HOP, deliberately. If the host repository sets the key too, that is its own business: its own + gate follows the same single hop, so both sides still land in the same place and a chain cannot split + them. Following the chain here would add a second definition of "where" for no reachable gain. + """ + shared = _config(repo, _CLAIMS_ROOT_KEY) + host = repo + if shared: + try: + host = _git( + "-C", shared, "rev-parse", "--path-format=absolute", "--show-toplevel" + ).strip() + except GitReadError as exc: + raise ClaimRegistryError(str(exc)) from exc + if not host: + raise ClaimRegistryError(f"'{shared}' is not a git repository") + common = _git("-C", host, "rev-parse", "--path-format=absolute", "--git-common-dir").strip() + return Path(common) / "mefor-coord" / "claims", (host if shared else None) def _repo() -> str: return _git("rev-parse", "--path-format=absolute", "--show-toplevel").strip() +def _claim_command(repo: str, host: str | None, args: str, *, holds: bool = False) -> str: + """The remedy line, spelled so it can be RUN FROM THE REPOSITORY IT IS PRINTED IN. + + THE REMEDY A GATE PRINTS IS ITS TEACHING SURFACE. Unshared, the repository-relative path is right + and familiar. Shared, it can name nothing: the second repository need not carry ``claim.ps1`` at + all, so an operator following a relative path gets "the file does not exist", lands back at the same + refusal, and learns that the way past this gate is the commit body. That is how a gate teaches the + evasion instead of the fix, which is exactly what #1346 recorded. + + ``holds`` marks the verbs that record an OWNER -- take and release. A listing has no owner, so + ``-AsWorktree`` on it would name a tree the command never consults. + + Quoted, because a Windows checkout path can contain spaces and an unquoted one silently binds only + its first word. + """ + if host is None: + return f"pwsh -NoProfile -File scripts\\coord\\claim.ps1 {args}" + tool = Path(host) / "scripts" / "coord" / "claim.ps1" + owner = f' -AsWorktree "{repo}"' if holds else "" + return f'pwsh -NoProfile -File "{tool}" {args}{owner}' + + def _norm(p: str) -> str: return p.replace("\\", "/").rstrip("/").casefold() -def _holder(item: str) -> dict[str, object] | None: +def _holder(claims: Path, item: str) -> dict[str, object] | None: """The claim record for `item`, or None if unclaimed/unreadable. A malformed claim reads as UNCLAIMED on purpose: the gate then asks for a claim rather than silently passing on a corrupt one. A non-object payload (a bare list/string) is treated the same way -- it cannot name a holder, so it grants nothing.""" - f = _claims_dir() / f"{item}.json" + f = claims / f"{item}.json" try: loaded = json.loads(f.read_text(encoding="utf-8")) except (OSError, ValueError): @@ -221,7 +318,8 @@ def main() -> int: # for a dead board. A commit hook has no such surface. try: paths = _staged_paths() - me = _norm(_repo()) + repo = _repo() + me = _norm(repo) except GitReadError as exc: sys.stderr.write( f"\nCLAIM GATE: git could not be read, so this commit was NOT checked.\n" @@ -236,18 +334,38 @@ def main() -> int: if not _touches_code(paths): return 0 # docs/ledger-only: cites the item, does not build it + # RESOLVED AFTER the docs-only exit, on purpose: a banner flip or a ledger reconcile must stay + # unblockable, and a misconfigured pointer is not a reason to stop one. + try: + claims, host = _claims_dir(repo) + except (GitReadError, ClaimRegistryError) as exc: + sys.stderr.write( + f"\nCLAIM GATE: the claim registry could not be resolved, so this commit was NOT checked.\n" + f" {_safe_for_message(exc)}\n" + f" Where this gate looks is decided by {_CLAIMS_ROOT_KEY}: set, it names another\n" + f" repository whose registry serves this one; unset, the registry is this repository's\n" + f" own. That read failed. The gate refuses rather than fall back to a registry nothing\n" + f" writes, where every claim would read as absent and a misconfigured pointer would\n" + f" present as an honestly unclaimed item.\n" + f" Check it, then commit again:\n" + f" git config --get {_CLAIMS_ROOT_KEY}\n" + ) + return 1 + problems: list[str] = [] for item in dict.fromkeys(items): # de-dupe, keep order - claim = _holder(item) + claim = _holder(claims, item) if claim is None: + take = _claim_command(repo, host, f'-Take {item} -Note ""', holds=True) problems.append( f" BACKLOG #{item} is NOT CLAIMED.\n" f" Another session may already be building it -- that is the duplicate work this\n" f" gate exists to stop. Claim it, then commit again:\n" - f' pwsh -NoProfile -File scripts\\coord\\claim.ps1 -Take {item} -Note ""' + f" {take}" ) continue if _norm(str(claim.get("worktree", ""))) != me: + release = _claim_command(repo, host, f"-Release {item} -Force", holds=True) problems.append( f" BACKLOG #{item} is claimed by ANOTHER worktree:\n" f" held by: {_safe_for_message(claim.get('worktree'))} " @@ -255,7 +373,7 @@ def main() -> int: f" since : {_safe_for_message(claim.get('claimed'))}\n" f" note : {_safe_for_message(claim.get('note'))}\n" f" Do not build it in parallel. Coordinate with that session, or if it is dead:\n" - f" pwsh -NoProfile -File scripts\\coord\\claim.ps1 -Release {item} -Force" + f" {release}" ) if not problems: @@ -263,8 +381,19 @@ def main() -> int: sys.stderr.write("\nMessageFoundry claim gate\n\n") sys.stderr.write("\n\n".join(problems)) + sys.stderr.write(f"\n\n See who is building what: {_claim_command(repo, host, '-List')}\n") + if host is not None: + # SAY WHERE YOU LOOKED. The absence of this line is why BACKLOG #1346 stayed invisible for + # months: a refusal against a registry in another repository is indistinguishable, from the + # outside, from an item nobody has claimed -- so the operator re-ran a claim tool that wrote + # somewhere this gate never reads, got the same refusal, and concluded the gate was broken + # rather than that the two halves disagreed about WHERE. + sys.stderr.write( + f" Claims are SHARED: this repository's {_CLAIMS_ROOT_KEY} points at\n" + f" {_safe_for_message(host)}\n" + f" so a claim must be held in THIS tree's name ({_safe_for_message(repo)}) there.\n" + ) sys.stderr.write( - "\n\n See who is building what: pwsh -NoProfile -File scripts\\coord\\claim.ps1 -List\n" " This fires only on a code-touching commit whose SUBJECT says 'BACKLOG #N'.\n" " A docs-only commit (banner flip, ledger reconcile) is never blocked.\n\n" ) diff --git a/tests/test_claim_shared_registry.py b/tests/test_claim_shared_registry.py new file mode 100644 index 000000000..71f2176ca --- /dev/null +++ b/tests/test_claim_shared_registry.py @@ -0,0 +1,358 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""BACKLOG #1346 -- the claim gate must be SATISFIABLE from a second repository that shares the registry. + +``scripts/hooks/claim_check.py`` runs as a commit-msg hook in more than one repository: this engine +carries it, and the separate MessageFoundry-vault clone runs the same gate. Before this module the two +halves answered *where does the claim registry live* from DIFFERENT trees -- the tool from the checkout +the script lives in, the gate from the tree being committed. So a second repository's commit whose +SUBJECT cited a ledger number could never pass, however honestly the item was held: its gate looked in a +registry nothing had ever written. + +**The only route through was to move the citation into the commit BODY**, which the gate permits by +design. That is not the same act as the evasion recorded elsewhere in the ledger, where claiming properly +was possible and the body was a way around a PASSABLE gate -- here it was the only route through an +unpassable one, and the distinction is whether a correct alternative existed. It did not. A gate whose +sole remedy is a sanctioned way around it is the state that manufactures evasion, which is the cost this +module exists to remove. + +**EVERY TEST HERE NEEDS TWO INDEPENDENT CHECKOUTS, because one cannot tell the two answers apart.** Run +inside a single repository, "the registry of the tree I live in" and "the registry of the tree being +committed" name the same directory, so the defect is invisible and a passing test proves nothing. The row +recorded both control arms as unachievable; the harness that git-inits two independent checkouts already +existed one module over (``test_script_root_anchoring.py::_coord_checkout``), and the fixture below is +that same construct narrowed to this question. + +The row named the two arms it owed, and they are the first tests here: **a claim held by the committing +worktree that MUST pass, and one held elsewhere that MUST fail.** *A gate that cannot demonstrate its own +pass arm has never been shown to have one.* + +THE PASS ARM IS TESTED IN BOTH SHAPES THE SECOND REPOSITORY CAN BE IN, deliberately. Whether the vault +carries its own copy of ``claim.ps1`` is a fact this engine checkout cannot establish -- CLAUDE.md limits +reading that tree to ``roles/`` -- so the fix must not depend on which case it is. One test runs the +second repository's OWN copy of the tool; the other runs THIS repository's copy with ``-AsWorktree``. +Both must land one record, in one registry, that the second repository's gate accepts. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_CLAIM = _ROOT / "scripts" / "coord" / "claim.ps1" +_CHECK = _ROOT / "scripts" / "hooks" / "claim_check.py" + +#: The config key that makes one registry serve two repositories. Spelled out here as well as in the two +#: scripts because a test that read it from the script under test could not fail when the script renamed +#: it -- the key is a CONTRACT between a tool and a gate that never call each other. +_POINTER = "mefor.claimsRoot" + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None, reason="pwsh (PowerShell 7) not on PATH" +) + + +def _git(*args: str, cwd: Path) -> None: + subprocess.run(["git", *args], cwd=str(cwd), check=True, capture_output=True) + + +def _toplevel(repo: Path) -> str: + return subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +def _checkout(path: Path) -> Path: + """An independent git repository carrying BOTH halves of the claim machinery. + + Both halves on purpose. The second repository in the real topology runs the gate, and may or may not + also carry the tool; a fixture that shipped only the gate could not exercise the case where it does, + which is one of the two shapes the fix has to survive. + """ + (path / "scripts" / "coord").mkdir(parents=True) + (path / "scripts" / "hooks").mkdir(parents=True) + shutil.copy2(_CLAIM, path / "scripts" / "coord" / "claim.ps1") + shutil.copy2(_CHECK, path / "scripts" / "hooks" / "claim_check.py") + (path / "code.py").write_text("x = 1\n", encoding="utf-8") + _git("init", "-b", "main", ".", cwd=path) + _git("config", "user.email", "t@example.invalid", cwd=path) + _git("config", "user.name", "T", cwd=path) + _git("add", "-A", cwd=path) + _git("commit", "-m", "fixture", "--no-verify", cwd=path) + return path + + +@pytest.fixture +def pair(tmp_path: Path) -> tuple[Path, Path]: + """``(registry_repo, other_repo)`` -- two checkouts that share nothing but the pointer under test.""" + return _checkout(tmp_path / "Engine"), _checkout(tmp_path / "Vault") + + +def _point_at(repo: Path, registry_repo: Path) -> None: + _git("config", _POINTER, str(registry_repo), cwd=repo) + + +def _claim(script_tree: Path, cwd: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(script_tree / "scripts" / "coord" / "claim.ps1"), + *args, + ], + cwd=str(cwd), + capture_output=True, + text=True, + timeout=180, + ) + + +def _records(repo: Path) -> list[Path]: + """Every claim file in ``repo``'s OWN registry -- the directory git would put it in unpointed.""" + return sorted((repo / ".git" / "mefor-coord" / "claims").glob("*.json")) + + +def _commit_msg_gate(repo: Path, message: str) -> subprocess.CompletedProcess[str]: + """Drive the REAL hook the way git drives it: argv[1] is the message file, cwd is the committing tree.""" + msg = repo / "COMMIT_EDITMSG.tmp" + msg.write_text(message, encoding="utf-8") + return subprocess.run( + [sys.executable, str(repo / "scripts" / "hooks" / "claim_check.py"), str(msg)], + cwd=str(repo), + capture_output=True, + text=True, + ) + + +def _stage_code(repo: Path) -> None: + """A staged CODE diff, because the gate exempts documentation-only commits by design.""" + (repo / "code.py").write_text("x = 2\n", encoding="utf-8") + _git("add", "code.py", cwd=repo) + + +_SUBJECT = "feat(coord): wire the thing (BACKLOG #1346)" + + +def test_the_second_repositorys_own_tool_claims_into_the_shared_registry( + pair: tuple[Path, Path], +) -> None: + """THE PASS ARM, shape one: the other repository carries its own ``claim.ps1``. + + Unpointed, that copy writes its own tree's registry and its own gate reads the same one, so this shape + already worked in isolation. What it could NOT do is coordinate -- two repositories claiming into two + registries is precisely the split the row names, and a key held in one is invisible to the other. So + the assertion is not merely that the gate passes: it is that the record landed in the ENGINE's + registry and NOT in the second repository's, because a claim only prevents duplicate work if every + session that might duplicate it can see it. + """ + engine, other = pair + _point_at(other, engine) + + taken = _claim(other, other, "-Take", "1346", "-Note", "shared-registry fixture") + assert taken.returncode == 0, taken.stderr or taken.stdout + + landed = _records(engine) + assert [p.name for p in landed] == ["1346.json"], ( + f"the claim did not reach the shared registry:\n{taken.stdout}\n{taken.stderr}" + ) + assert not _records(other), ( + "a second, private registry was written -- that is the split state this fixes" + ) + held = json.loads(landed[0].read_text(encoding="utf-8"))["worktree"] + assert held.replace("\\", "/").casefold() == _toplevel(other).replace("\\", "/").casefold(), ( + f"the record names the wrong holder: {held}" + ) + + _stage_code(other) + gate = _commit_msg_gate(other, _SUBJECT + "\n") + assert gate.returncode == 0, ( + f"THE GATE IS STILL UNPASSABLE from the second repository:\n{gate.stderr}\n{gate.stdout}" + ) + + +def test_this_repositorys_tool_can_hold_a_claim_for_the_second_repository( + pair: tuple[Path, Path], +) -> None: + """THE PASS ARM, shape two: the other repository has NO ``claim.ps1`` and runs this one's by path. + + This is the measured shape -- the row records that running the claim tool from a vault worktree + refreshes the ENGINE record rather than creating a vault one, which is what an engine-anchored script + does. Anchoring is correct and stays (BACKLOG #1060): what was missing is that one value answered TWO + questions, *where the registry is* and *who holds the claim*, and across two repositories those + diverge. ``-AsWorktree`` splits them, explicitly, at the one call site that needs it. + """ + engine, other = pair + _point_at(other, engine) + + taken = _claim( + engine, + other, + "-Take", + "1346", + "-AsWorktree", + str(other), + "-Note", + "held for the other tree", + ) + assert taken.returncode == 0, taken.stderr or taken.stdout + + landed = _records(engine) + assert [p.name for p in landed] == ["1346.json"], taken.stdout + held = json.loads(landed[0].read_text(encoding="utf-8"))["worktree"] + assert held.replace("\\", "/").casefold() == _toplevel(other).replace("\\", "/").casefold(), ( + f"-AsWorktree did not move the holder: {held}" + ) + + _stage_code(other) + gate = _commit_msg_gate(other, _SUBJECT + "\n") + assert gate.returncode == 0, ( + f"THE GATE IS STILL UNPASSABLE from the second repository:\n{gate.stderr}\n{gate.stdout}" + ) + + +def test_a_claim_held_by_another_tree_still_fails_from_the_second_repository( + pair: tuple[Path, Path], +) -> None: + """THE MUST-FAIL ARM, and it asserts WHICH refusal so it cannot pass for the wrong reason. + + Before the fix this arm was green and blind: the second repository's gate read an EMPTY registry, so + it refused every commit with 'is NOT CLAIMED' and would have refused this one too. That is a gate + that cannot see, scoring as a gate that works. Pinning the *text* is what tells the two apart -- the + refusal must be the one that names the rival holder, which is only reachable once the shared record + is actually being read. + """ + engine, other = pair + _point_at(other, engine) + + taken = _claim(engine, engine, "-Take", "1346", "-Note", "a rival session is on this") + assert taken.returncode == 0, taken.stderr or taken.stdout + + _stage_code(other) + gate = _commit_msg_gate(other, _SUBJECT + "\n") + assert gate.returncode == 1, f"a claim held elsewhere did not block:\n{gate.stdout}" + assert "claimed by ANOTHER worktree" in gate.stderr, ( + "the gate refused for the WRONG REASON -- it did not read the shared registry at all:\n" + f"{gate.stderr}" + ) + assert _toplevel(engine).replace("\\", "/") in gate.stderr.replace("\\", "/"), ( + f"the refusal does not name the rival holder:\n{gate.stderr}" + ) + + +def test_the_deny_text_hands_over_a_command_that_can_actually_be_run( + pair: tuple[Path, Path], +) -> None: + """The remedy a gate prints IS its teaching surface, so it must work where it is printed. + + The unpointed remedy is a repository-RELATIVE path to ``claim.ps1``. Printed in a repository that + carries no such file, it names nothing, and an operator who follows it lands back at the same refusal + with no idea why -- which is how a gate teaches evasion rather than the fix. Where the registry is + shared the gate knows both halves it needs, so it prints the absolute tool and the holder to record. + """ + engine, other = pair + _point_at(other, engine) + _stage_code(other) + + gate = _commit_msg_gate(other, _SUBJECT + "\n") + assert gate.returncode == 1, gate.stdout + assert "is NOT CLAIMED" in gate.stderr, gate.stderr + + remedy = gate.stderr.replace("\\", "/") + engine_fwd = str(engine).replace("\\", "/") + other_fwd = str(other).replace("\\", "/") + + # QUOTED, and asserted quoted. A Windows checkout path can contain spaces, and an unquoted -File + # argument binds only its first word -- a remedy that fails on `C:/Program Files/...` is the same + # defect as one naming a file that does not exist. + assert f'-File "{engine_fwd}/scripts/coord/claim.ps1"' in remedy, ( + f"the remedy does not name a tool that exists from here:\n{gate.stderr}" + ) + assert f'-AsWorktree "{other_fwd}"' in remedy, ( + f"the remedy does not say whose name to claim in:\n{gate.stderr}" + ) + # And the gate must SAY where it looked. The absence of that line is why #1346 stayed invisible: a + # refusal against a registry in another repository is indistinguishable, from the outside, from an + # item nobody has claimed. + assert _POINTER in gate.stderr, gate.stderr + assert engine_fwd in remedy, gate.stderr + + +def test_an_unresolvable_pointer_refuses_rather_than_falling_back( + pair: tuple[Path, Path], +) -> None: + """FAIL CLOSED. A pointer that cannot be resolved must not degrade into the local registry. + + The quiet fallback is the worse bug, not the safer one. It would send the gate to a directory nothing + writes, where every claim reads as absent -- so a MISCONFIGURED pointer would present exactly as an + unclaimed item, and the remedy printed would be the one that cannot work. The same reasoning the file + already applies to an unreadable git: refusing costs a re-run, passing costs the duplicate build. + """ + _engine, other = pair + _git("config", _POINTER, str(other / "no-such-repository"), cwd=other) + _stage_code(other) + + gate = _commit_msg_gate(other, _SUBJECT + "\n") + assert gate.returncode == 1, f"an unresolvable pointer was silently ignored:\n{gate.stdout}" + assert _POINTER in gate.stderr, ( + f"the refusal does not name the setting that caused it:\n{gate.stderr}" + ) + + +def test_as_worktree_refuses_a_path_that_is_not_a_repository(pair: tuple[Path, Path]) -> None: + """``-AsWorktree`` names the OWNER of a claim, so an unverified value would be a forged one. + + The gate compares the record's ``worktree`` against ``git rev-parse --show-toplevel`` in the + committing tree. A holder that no repository can produce therefore matches nothing, and the claim it + creates blocks the key for every session while granting it to none -- the stranded-claim state the + registry's own cleanup tooling exists to unpick. So the flag resolves to a real TOPLEVEL or refuses, + and the refusal must leave NOTHING behind: a tool that fails loudly and writes anyway is a tool that + failed quietly. + """ + engine, _other = pair + + proc = _claim( + engine, + engine, + "-Take", + "1346", + "-AsWorktree", + str(engine / "not-a-repository"), + "-Note", + "should never land", + ) + assert proc.returncode != 0, f"a bogus holder was accepted:\n{proc.stdout}" + assert "-AsWorktree" in (proc.stderr + proc.stdout), proc.stderr or proc.stdout + assert not _records(engine), "the refusal still wrote a claim" + + +def test_a_repository_with_no_pointer_behaves_exactly_as_before( + pair: tuple[Path, Path], +) -> None: + """THE REGRESSION CONTROL, and the reason the shared registry is the option that was taken. + + Every claim in existence is engine-side, so a fix that moved the registry would invalidate all of + them. This one adds a pointer that is ABSENT by default: with nothing configured, the tool writes its + own tree and the gate reads its own tree, which is byte-for-byte the behaviour that has always + shipped. Asserting it here is what makes 'invalidates no existing record' a measurement rather than a + claim. + """ + engine, _other = pair + + taken = _claim(engine, engine, "-Take", "1346", "-Note", "ordinary single-repository flow") + assert taken.returncode == 0, taken.stderr or taken.stdout + assert [p.name for p in _records(engine)] == ["1346.json"], taken.stdout + + _stage_code(engine) + gate = _commit_msg_gate(engine, _SUBJECT + "\n") + assert gate.returncode == 0, f"the unpointed single-repository flow regressed:\n{gate.stderr}" diff --git a/tests/test_script_root_anchoring.py b/tests/test_script_root_anchoring.py index 3875c6c38..390c91305 100644 --- a/tests/test_script_root_anchoring.py +++ b/tests/test_script_root_anchoring.py @@ -239,7 +239,14 @@ def _run_claim(script_tree: Path, cwd: Path, *args: str) -> subprocess.Completed #: Not the whole line: it interpolates two absolute paths and PowerShell hard-wraps host output at the #: console width, so an equality assertion would fail on formatting rather than on behaviour. This #: fragment carries the instruction a reader has to act on and nothing that varies. -_DIVERGENCE = "but this script lives in" +#: +#: REWORDED BY BACKLOG #1346, and the reword was forced rather than cosmetic. The note used to say "this +#: script lives in ", which was true only while one value answered both *where the script is* and +#: *who holds the claim*. ``-AsWorktree`` splits those, so the note now compares against the HOLDER and +#: the old sentence would be false in exactly the case the flag exists for. The behaviour under test -- +#: that the note fires on a divergence and stays silent without one -- is unchanged; only the sentence +#: naming which tree the claim landed against moved. +_DIVERGENCE = "but this claim is recorded against" def test_the_divergence_note_FIRES_on_take_from_a_foreign_cwd(tmp_path: Path) -> None: diff --git a/tests/tooling_manifest.txt b/tests/tooling_manifest.txt index bf0b6f6d7..5543c903d 100644 --- a/tests/tooling_manifest.txt +++ b/tests/tooling_manifest.txt @@ -50,6 +50,7 @@ tests/test_ci_tooling_gate.py tests/test_ci_venv_pinning.py tests/test_citation_line_check.py tests/test_claim_check.py +tests/test_claim_shared_registry.py tests/test_coord_alloc_strand_sweep.py tests/test_gate_ci_mirror_parity.py tests/test_hook_prose_folding.py