From bce25bd874d17f6d641b430ebfc998757b5e6164 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 3 Sep 2026 17:14:01 -0500 Subject: [PATCH 1/3] fix(coord): make the claim gate satisfiable from a second repository (BACKLOG #1346) claim.ps1 used ONE value for TWO questions -- where the registry is, and who holds the claim. Inside one repository those are the same tree, so the conflation was invisible; across two they diverge. The vault's installed claim_check.py therefore read a registry the claim tool never wrote, and no code-touching vault commit whose SUBJECT cited a ledger number could pass, however honestly the item was held. The only route through was to cite the item in the commit BODY, which the gate permits by design -- a gate whose sole remedy is a sanctioned way around it is the state that manufactures evasion. Option taken: ONE SHARED REGISTRY, both gates reading it. `git config mefor.claimsRoot ` is set in the repository that does not host the registry, and BOTH halves resolve from the repository the claim is FOR, so they cannot disagree about where to look. `claim.ps1 -AsWorktree ` names the holder when the tool runs from a tree it does not live in -- the half the pointer alone does not cover, because the gate also compares the record's worktree against the tree being committed. Unset, behaviour is what shipped before, and that is asserted rather than asserted about, so no existing claim is invalidated. An unresolvable pointer FAILS CLOSED on both sides: the silent fallback would send the gate to a directory nothing writes, where a misconfigured pointer presents as an honestly unclaimed item. The gate now also says WHERE it looked and prints a remedy that runs from the repository it is printed in; install-git-hooks.ps1 -Status reports the same directory. That missing diagnostic is most of why the split went unnoticed. REPRODUCED FIRST, then proven by mutation. tests/test_claim_shared_registry.py git-inits two independent checkouts: 5 of 6 arms red before the fix, with the single-repository arm green in the same run as the positive control. The measured refusal was "is NOT CLAIMED", not "claimed by ANOTHER worktree" -- the proof the gate was reading an empty registry rather than adjudicating one. Reverting the pointer reproduces all 5; reverting the holder split kills exactly the one arm it serves. The vault half is unverified by construction: CLAUDE.md limits reading that tree to roles/, so whether it carries its own claim.ps1 is still unmeasured. The fix is built not to depend on the answer -- the pass arm is tested in both shapes. claim.ps1's divergence note was reworded because -AsWorktree makes its old sentence false in exactly the case the flag exists for. The behaviour it asserts is unchanged. --- docs/BACKLOG.md | 35 +++ docs/SESSION-DRIFT-CONTROLS.md | 10 + scripts/coord/claim.ps1 | 97 ++++++-- scripts/coord/install-git-hooks.ps1 | 27 +++ scripts/hooks/claim_check.py | 149 ++++++++++++- tests/test_claim_shared_registry.py | 331 ++++++++++++++++++++++++++++ tests/test_script_root_anchoring.py | 9 +- 7 files changed, 631 insertions(+), 27 deletions(-) create mode 100644 tests/test_claim_shared_registry.py diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 4b71b3902..fa2ae4eba 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -16470,6 +16470,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..a9fc93b65 --- /dev/null +++ b/tests/test_claim_shared_registry.py @@ -0,0 +1,331 @@ +# 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_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: From 24a6a855d08403c683a898b5b2116a1fd49d221e Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 3 Sep 2026 17:19:54 -0500 Subject: [PATCH 2/3] test(coord): pin that -AsWorktree refuses a path no repository can produce (BACKLOG #1346) --- tests/test_claim_shared_registry.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_claim_shared_registry.py b/tests/test_claim_shared_registry.py index a9fc93b65..71f2176ca 100644 --- a/tests/test_claim_shared_registry.py +++ b/tests/test_claim_shared_registry.py @@ -309,6 +309,33 @@ def test_an_unresolvable_pointer_refuses_rather_than_falling_back( ) +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: From 6e3c3b61af3b1e5176d3601107224ea981f0b9ac Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 4 Sep 2026 03:47:44 -0500 Subject: [PATCH 3/3] test(tooling): classify the new harness test in the manifest tests/test_tooling_partition.py::test_every_non_engine_test_is_classified reds all three required test legs when a test that does not import the engine is absent from tests/tooling_manifest.txt. This PR adds such a test, so CI could not go green as it stood. The manifest is read as a set, so this is a single inserted line at its alphabetical slot; no existing line moves. --- tests/tooling_manifest.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/tooling_manifest.txt b/tests/tooling_manifest.txt index bb5029a6f..641b78a44 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_gate_ci_mirror_parity.py tests/test_hook_prose_folding.py tests/test_hook_prose_folding_push_ledger.py