Skip to content

Mint LLP numbers across every ref, and fail the branch that takes a claimed one - #916

Merged
philcunliffe merged 4 commits into
masterfrom
fix/issue-907
Aug 19, 2026
Merged

Mint LLP numbers across every ref, and fail the branch that takes a claimed one#916
philcunliffe merged 4 commits into
masterfrom
fix/issue-907

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What was wrong

Three open branches each minted llp/0266-* with a different slug. Git reports
no conflict between differently named files, so whichever merged second would
have added a second document numbered 0266 and every @ref LLP 0266 would have
become ambiguous. The in-tree gates (.github/workflows/llp-check.yml and the
no LLP number is claimed by two documents test) only see the collision once
both documents are in one tree, which is after the damage.

Root cause

The minting rule existed only as prose, and the prose named the wrong
denominator. /llp-create step 1 said max(existing) + 1 over llp/ in the
working tree, so every worker on a branch cut from the same master computed the
same answer. LLP 0156 had already settled the correct rule ("above the highest
number claimed anywhere, including branches without an open PR"), but
nothing executed it.

The fix

scripts/llp-numbers.js makes LLP 0156's rule executable, in three modes:

  • next prints the next free number across every ref that could still merge
    (local heads, remote tracking branches, HEAD), tombstones included, because
    a retired number is never reused.
  • check exits nonzero when a number this branch mints is already claimed by a
    different document somewhere else.
  • survey lists every collision across every ref.

check deliberately reports only the numbers the branch adds relative to its
merge base with the default branch. That scoping is load-bearing, not caution: a
survey of this repository right now finds 23 collided numbers, and most of
them (0026, 0032, 0058, 0059, 0086, 0098, 0099, 0111, 0142, 0143, 0144, 0188,
0193, 0200, 0212, 0223, 0224) are the corpus's own already-repaired collisions,
alive on branches cut before the repair. A gate that reported those would redden
pull requests party to none of them and would stop being read.

Wired in two places:

  • .github/workflows/llp-check.yml gains a cross-branch-numbers job that
    fetches every branch and runs check. The existing in-tree job stays.
  • test/core/llp-number-minting.test.js runs the same check against this
    repository, so npm test carries the in-suite half.

Also updated: /llp-create step 1 and /ref-check step 3 (a duplicate number is
BROKEN, not a WARNING, and exits 1), and the LLP tooling bullet in
AGENTS.md (CLAUDE.md is a symlink to it).

Regression test and its before/after

test/core/llp-number-minting.test.js rebuilds the issue's history in a temp git
repo: a master at LLP 0265 with the three branches hanging off it, each minting
llp/0266-* under its own slug, mirrored under refs/remotes/origin/. It
asserts that master alone answers 0266 (the wrong answer all three workers got)
while every-ref answers 0267, that the collision is reported with all three
claimants, that check fails on a minting branch and points at 0267 or above,
and that a settled collision surviving on a stale branch does not fail an
unrelated branch.

Before (the pre-fix denominator, mergeableRefs returning just the
checked-out tree): 5 of 11 fail, including the reproduction
the next free number is the highest claimed on any ref, not on the checked-out one.

not ok 4 - the next free number is the highest claimed on any ref, not on the checked-out one
not ok 5 - a number claimed by two documents on different refs is a collision
not ok 6 - the check fails on the branch that minted the taken number, and names every claimant
not ok 8 - a settled collision surviving on a stale branch does not fail an unrelated branch
not ok 9 - the next mode prints the free number zero-padded
# tests 11
# pass 6
# fail 5

After: 11 pass, 0 fail. Full suite npm test: 4494 pass, 0 fail, 1
skipped. npm run typecheck clean.

What this PR does not do

It does not renumber the three colliding documents. They live on three other
branches, so a master-based PR cannot touch them, and the issue is explicit that
which document keeps 0266 depends on intended merge order. Ground truth as of
this branch, from survey, is worse than the issue recorded: six documents
now claim 0266, and 0248, 0249, 0265, 0267 and 0268 collide too. The next free
number is 0280.

LLP 0266 -> 0266-behavioral-rows-carry-cwd.decision.md
         |  0266-core-command-argument-validation.decision.md
         |  0266-hidden-rows-stay-off-the-sync-gate.decision.md
         |  0266-native-prepared-batches-through-query-sources.decision.md
         |  0266-prune-asks-every-client-not-the-run.decision.md
         |  0266-x509-and-local-ca-residuals.decision.md

Once this lands, those branches go red on check (and on npm test) until they
renumber, which is the point: the failure now arrives before the merge instead of
after it. That is the residual risk worth naming explicitly.

Fixes #907

…laimed one (#907)

Three branches cut from the same master each minted `llp/0266-*`. Each read the
highest number in the tree it had checked out, each got 0265, and each picked
0266. The slugs differed, so git reported no conflict: whichever merged second
would simply have added a second document numbered 0266, and every
`@ref LLP 0266` would have become ambiguous.

LLP 0156 already settles the rule ("above the highest number claimed anywhere,
including branches without an open PR"), but nothing executed it: the minting
rule lived only as prose in `/llp-create`, which said `max(existing) + 1` over
the working tree.

`scripts/llp-numbers.js` is that rule as code, in three modes:

- `next` prints the next free number across every ref that could still merge.
- `check` exits nonzero when a number this branch mints is claimed elsewhere.
- `survey` lists every collision across every ref.

`check` reports only the numbers the branch adds relative to its merge base. A
survey of the real repository found 23 collided numbers, and most are the
corpus's already-repaired ones surviving on branches cut before the repair; a
gate that reported those would redden pull requests party to none of them.

Wired into `.github/workflows/llp-check.yml` (the existing in-tree job only sees
a collision after the second merge) and into `npm test`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
neutral and others added 2 commits August 19, 2026 07:10
…916)

A shallow or single-branch checkout carries one ref and no default branch,
which is what `actions/checkout` hands the `test` and `typecheck` jobs in
`.github/workflows/ci.yml`. In that clone every mode answered from a corpus
of one: `next` printed 0267 on a tree where 0266 is claimed eight times and
the real answer is 0281, and the in-suite gate in
`test/core/llp-number-minting.test.js` passed green without comparing
anything, because `mintedNumbers` found no base and returned an empty set.
That is the defect of issue #907 wearing this script's own clothes.

- `partialScan` names the three conditions that make an answer a guess: not
  a git checkout, a shallow clone, no default-branch ref. `run` refuses
  outright (exit 2) in the first case, which is the fallback the new
  `/llp-create` step 1 already told the agent to take, and warns on stderr
  in the others while still printing the number.
- The in-suite test skips with the reason instead of passing vacuously, so
  a CI run that cannot carry the check says so.
- `mergeableRefs` used the throwing `git` helper, so outside a checkout every
  mode died with a raw Node stack trace and a leaked `fatal:` line. It uses
  `tryGit` like the rest of the file; `git` had no other caller and is gone.
- `addedDocs`'s JSDoc claimed basenames stop the LLP 0156 renumber repair
  from reading as a fresh mint. A renumber changes the basename and does read
  as a mint, correctly, since the number it moves to has to be free too. What
  basenames absorb is a move between directories, which is what its own test
  asserts.
- `AGENTS.md` said `check` runs "in `npm test` and in CI"; in CI the `npm test`
  half cannot see the other branches, and the gate is the `cross-branch-numbers`
  job that fetches them.
- `/llp-create` step 1 said to run the script unconditionally, then said to
  include non-standard LLP trees "in the scan" the script cannot extend. It now
  names all three fallbacks: no script, no checkout, LLP tree elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…view of #916)

Four defects the ref-only scan carried, each reproduced against this repo
before and after.

**A document you have not committed claims its number.** The scan read
`git ls-tree` over refs only, so `next` printed 0281 before and after
`llp/0281-*.decision.md` was created on disk. Two `/llp-create` calls in one
session therefore minted the same number, which is issue #907 with both
claimants on one machine, and the prose rule this replaced (`scan all files
in llp/`) did catch it. `worktreeFiles` adds the tracked and untracked files
under `llp/`, `scanRefFiles` is the union every mode now reads, and the tip
`mintedNumbers` diffs is the working tree, so `npm test` reddens on the
document you just wrote rather than only after you commit it.

**A slug rewrite is not a mint.** `addedDocs` matched basenames, so renaming
`0265-grep-search-implementation.plan.md` to `0265-renamed-slug.plan.md`
read as minting 0265, a number the branch already owned, and `check` exited
1 telling it to renumber a collision it was not party to. `mintedAgainst`
counts distinct documents per number instead: a number is minted when more
documents claim it at the tip than at the merge base. A slug rewrite (1 to
1) is not, a second document at an existing number (1 to 2) is, and the LLP
0156 renumber repair mints the number it moves to and is checked there.

**A merge base that cannot be found is unknown, not empty.** With a base ref
that resolves but no common ancestor, `mintedNumbers` passed `[]` as the base
tree and counted the whole corpus at HEAD as newly minted, so `check` blamed
the branch for every collision already settled in it. It now reports a null
`mergeBase` and `check` says so and exits 0.

**A claimant line is capped.** One document is carried by every branch cut
since it landed, which here is 105 refs on one line and 64KB from `survey`.
`describeRefs` names six and counts the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review of #916 at 98ed6b6

Verdict: findings. Eight actionable, all fixed and pushed as 7817ed2 and ec85533. The design is right and the regression test genuinely reproduces #907. What the review found is that the executable rule was narrower than the prose rule it replaced, and that three of its own failure modes were silent passes.

Every finding below was reproduced against this repository before the fix and re-checked after.

High

1. A document you have not committed claims no number, so two /llp-create calls in one session collide. scripts/llp-numbers.js:159 built the index from git ls-tree over refs only, so the working tree and index were invisible. Verified on this branch: next printed 0281, then printed 0281 again after llp/0281-*.decision.md was created on disk, and again after git add. That is issue #907 with both claimants on one machine, and the prose rule this PR replaced (Scan all files in llp/) did catch it. check shared the blind spot, so npm test before committing could not see the doc you had just written.

Fixed: worktreeFiles lists --cached --others --exclude-standard under llp/, scanRefFiles is the union every mode reads, and the tip mintedNumbers diffs is the working tree. Verified after: next goes 0281 to 0282 on an untracked doc, and an untracked llp/0266-* fails check with the claimant line on the working tree.

Medium

2. A slug rewrite read as a fresh mint, and drew a renumber demand for a number the branch already owned. scripts/llp-numbers.js:128, addedDocs matched basenames. Verified: git mv llp/0265-grep-search-implementation.plan.md llp/0265-renamed-slug.plan.md plus a commit made check exit 1 with LLP 0265 claimed by more than one document and the remedy Renumber the later claimant to 0281 or above. Slugs get reworded during review constantly.

Fixed: mintedAgainst counts distinct documents per number instead of matching names. A number is minted when more documents claim it at the tip than at the merge base: a slug rewrite (1 to 1) is not, a second document at an existing number (1 to 2) is, and the LLP 0156 renumber repair mints the number it moves to and is checked there. Verified after: the same rename now exits 0 with 0 LLP numbers minted against refs/remotes/origin/master, no collision.

3. A merge base that cannot be found was read as an empty base, blaming the branch for the whole corpus. scripts/llp-numbers.js:180. When the base ref resolved but git merge-base failed, mintedNumbers passed [] as baseFiles, so every document at HEAD counted as newly minted. Verified with git clone --depth 1 --no-single-branch: check exited 1 reporting 12 pre-existing collisions (0098, 0099, ...) the branch is party to none of, which is exactly the noisy gate the scoping comment says it avoids.

Fixed: mintedNumbers returns a null mergeBase and run reports no common ancestor with <base>, so what this branch mints cannot be told from what it inherited, exit 0. Regression test added.

4. In CI, the npm test half of the gate never ran, and next returned a wrong number silently. .github/workflows/ci.yml gives the test and typecheck jobs actions/checkout@v6 with the default shallow, single-branch fetch. Verified with git clone --depth 1 --single-branch --branch <a branch that mints an already-claimed 0266>:

$ node scripts/llp-numbers.js next
0267                                     # the real answer is 0281
$ node scripts/llp-numbers.js check
no default branch to compare against, so nothing is minted here
exit 0
$ node --test test/core/llp-number-minting.test.js
ok 11 - this branch mints no number another ref already claims   # green, having compared nothing

So the PR body's those branches go red on check (and on npm test) and AGENTS.md's check in npm test and in CI did not hold in CI. The cross-branch-numbers job (which does fetch-depth: 0 plus a full fetch) was the only real gate.

Fixed: partialScan names the three conditions that make an answer a guess (not a checkout, shallow clone, no default-branch ref). run refuses outright with exit 2 in the first case, which is the fallback /llp-create step 1 already prescribed, and warns on stderr in the others while still printing the number. The in-suite test now skips with the reason rather than passing vacuously: ok 12 ... # SKIP this is a shallow clone, so the history it carries is not the corpus. AGENTS.md now names the cross-branch-numbers job as the CI gate.

Low

5. mergeableRefs crashed with a raw Node stack trace outside a git checkout. scripts/llp-numbers.js:143 used the throwing git helper while the rest of the file used tryGit, and git inherited stderr, so a leaked fatal: not a git repository preceded the trace. The /llp-create step this PR adds tells the agent to fall back in exactly that case. Fixed: tryGit throughout, a clean message and exit 2; the now-unused git helper is gone.

6. A claimant line was unbounded. scripts/llp-numbers.js:242, claimant.refs.join(', '). One document is carried by every branch cut since it landed: 105 refs on a single line here, and 64KB from survey. Fixed: describeRefs names six and counts the rest (... and 99 more).

7. addedDocs's JSDoc misstated what basenames buy. scripts/llp-numbers.js:121 claimed they stop the LLP 0156 renumber repair from reading as a fresh mint. A renumber changes the basename and does read as a mint, correctly, since the number it moves to has to be free too. What basenames absorb is a move between directories, which is what the function's own test asserts. Fixed (the comment is now on mintedAgainst and says what it does).

8. /llp-create step 1 contradicted itself. .claude/skills/llp-create/SKILL.md:24 mandates node scripts/llp-numbers.js next; line 36 still said to include a non-standard LLP tree "in the scan", which the script cannot do (LLP_DIR is hardcoded to llp). The only fallback named was "not a git checkout", though the skill is written to be portable to repos with no scripts/llp-numbers.js at all. Fixed: all three fallbacks named, with the instruction to say when a number is a guess.

Checked and sound

llpNumberOf's reviews/ exclusion; tombstone moves resolving to one claimant; the @ref LLP 0156#renumber and @ref LLP 0001#tooling anchors both resolve; actions/checkout@v6 matches ci.yml; the new job needs no npm i since the script is node builtins only; ~0.8s over this repo's 235 refs, well inside timeout-minutes: 5; no semicolons, no em dashes.

Not fixed, worth naming: if you push a new doc and then rename its slug without force-pushing over the old ref, the stale refs/remotes/origin/<branch> still carries the old basename and your local check reddens against your own draft until the ref is updated. A git fetch --prune clears it.

Verification

npm test: 4499 pass, 0 fail, 1 skipped (up from 4494/11 tests in this file to 16). npm run typecheck: clean. Local runs are advisory; CI is the authority.

…rseded (review of #916)

Four findings from the review of ec85533, all reproduced against fixtures first.

Medium: `check` counted a base-branch document sitting in the working tree as
newly minted, because the floor was the merge-base tree while the tip was the
working tree. An interrupted `git merge origin/master` (or a
`git checkout origin/master -- llp/`) put master's own document on disk, and the
branch was told to renumber a document it never wrote. `mintedNumbers` now adds
the base branch's documents that are actually present here to the floor. A rival
that landed on master first is still caught: it claims the number under a name
this branch does not carry, so the branch really did mint it.

Medium: renaming the slug of a number this branch minted and pushed failed the
gate against its own pre-rename filename, still sitting on
`refs/remotes/origin/<branch>`, and prescribed a renumber for a collision with
itself. `check` now drops refs reachable from HEAD: they are prior versions of
this branch, not rivals. `next` and `survey` still read every ref, so a number is
never handed out because a stale ref was filtered.

Low: `check` exited 0 on a checkout that could not see the corpus. A job that
lost its `fetch-depth: 0` would go green having compared one ref with itself,
which is issue #907 one layer up. It now refuses with exit 2.

Low: the partial-scan warning named `git fetch --prune --unshallow` for both
cases, and that aborts with `--unshallow on a complete repository does not make
sense` on the single-branch clone. Each case now names the fetch that works, in
the script and in `/llp-create` step 1.

Regression tests: four added, three of which fail against the pre-fix script.
Full suite 4503 pass, 0 fail, 1 skipped. `npm run typecheck` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review of #916 at ec85533

Verdict: findings. Four actionable, all fixed and pushed as f403dfd. Every finding was reproduced in a throwaway git fixture before the fix and re-checked after; three of the four now have a regression test that fails against the pre-fix script.

The design holds up: the reproduction in test/core/llp-number-minting.test.js is genuine, next/survey answer correctly across every ref, and the three-branches-mint-0266 scenario of #907 fails the gate as intended. What this round found is the other half of a gate's job: it also has to stay quiet about numbers the branch is not answerable for, and it must not pass when it cannot see.

Medium

1. check counted a base-branch document sitting in the working tree as newly minted. scripts/llp-numbers.js:266 used the merge-base tree as the floor while tipFiles read the working tree, so any base-branch content on disk that the fork point lacked read as a fresh mint. Reproduced: topic mints 0102, then master's own 0101-winner.decision.md lands on disk from an interrupted git merge --no-commit origin/master, with a stale branch still carrying 0101-loser. check reported minted: [101, 102], exited 1, and printed Renumber the later claimant to 0103 or above for a document topic never wrote.

Fixed: mintedNumbers adds the base branch's own documents that are actually present in this tree to the floor (inheritedFrom, scripts/llp-numbers.js:190). Verified after: minted: [102], exit 0. The count comparison stays honest in both directions, and both directions are now tested: a slug this branch rewrote is not in the base tip under its new name, so it stays inherited (test changing a slug without changing the number is not a mint), while a rival landing on master first claims the number under a name this branch does not carry, so it is still minted and still fails (new test a rival document landing on the base branch first still fails the gate).

2. Renaming the slug of a number this branch minted failed the gate against its own pushed filename. scripts/llp-numbers.js:392 ran collisions over every ref, including refs/remotes/origin/<branch>. Reproduced: mint llp/0101-first-slug.decision.md, push, git mv to llp/0101-clearer-slug.decision.md, commit. check exited 1 with 0101-clearer-slug on refs/heads/topic, HEAD, the working tree / 0101-first-slug on refs/remotes/origin/topic and prescribed a renumber for a collision with yourself. Slugs get reworded after review constantly, so this turns local npm test red on a routine edit until the rename is pushed, which is how a gate stops being read. The previous review named this as a residual with git fetch --prune as the remedy; the prune does not clear it, since the branch really is still on the remote under the old name.

Fixed: supersededRefs (scripts/llp-numbers.js:212) asks git once for for-each-ref --merged HEAD, and check drops those refs. A ref reachable from HEAD carries nothing that can merge separately: every document it has is either in this tree too or was removed on the way here, so a claim only it holds is a prior version of this branch. next and survey deliberately still read every ref, so no number is ever handed out because a ref was filtered. Verified after: exit 0, and the three-way 0266 collision still exits 1 naming all three claimants.

Low

3. check exited 0 on a checkout that could not see the corpus. scripts/llp-numbers.js:369. partialScan warned on stderr and execution continued; mintedNumbers then found no base ref and returned no default branch to compare against, so nothing is minted here, exit 0. A cross-branch-numbers job that loses its fetch-depth: 0, or the check copied into another workflow, goes green having compared one ref with itself. That is issue #907 one layer up, and it contradicts the partialScan doc comment's own claim that the condition is "reported rather than answered silently".

Fixed: check refuses with exit 2 and says why. next and survey still answer with the warning, which is the useful behaviour for them.

4. The partial-scan warning named a remedy that errors in one of the two cases it was printed for. scripts/llp-numbers.js:370 emitted git fetch --prune --unshallow for both partialScan outcomes, but the no-default-branch case is a complete single-branch clone, where that aborts with fatal: --unshallow on a complete repository does not make sense (verified). Same text in .claude/skills/llp-create/SKILL.md:36. This repo shipped #831/#833 for exactly this class of defect.

Fixed: a REMEDY map keyed on the named reason, so the shallow clone gets --unshallow and the single-branch clone gets git fetch --no-tags --prune origin '+refs/heads/*:refs/remotes/origin/*'. The SKILL text now names both, and the new test asserts the no-base-ref report does not mention --unshallow.

Checked and sound

@ref LLP 0156#renumber and @ref LLP 0001#tooling both resolve and the glosses are honest; llpNumberOf's reviews/ exclusion matches the -path llp/reviews -prune in the existing job; a tombstone move still resolves to one claimant; actions/checkout@v6 matches ci.yml; the new job needs no npm i (node builtins only) and a full scan of this repo's refs runs in ~2.5s, well inside timeout-minutes: 5; the in-suite test still skips with its reason on the shallow ci.yml checkout rather than passing vacuously; CLAUDE.md is a symlink to AGENTS.md, so the new guidance reaches agents. No semicolons, no em dashes.

Left alone deliberately: check is still scoped to the numbers the branch mints, so the 20-odd already-settled collisions that stale branches carry stay out of the report. That scoping is the right call and the PR body argues it well. And check still passes with exit 0 when there is no common ancestor with the base, which is the honest answer when what the branch minted cannot be told from what it inherited.

Verification

Four regression tests added (16 to 20 in this file); three fail against the pre-fix script, the fourth is the guard that the finding-1 fix must not swallow a real rival. npm test: 4503 pass, 0 fail, 1 skipped. npm run typecheck: clean. Local runs are advisory; CI is the authority.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage at f403dfde3b4a0f69cfcf8ce19b0eb51a1fa9516d: the review-round cap was reached, so the residual findings were judged against the head. Every actionable finding from both review rounds is verifiably fixed at this head (inheritedFrom floor, supersededRefs filter, check refusing on a partial scan with exit 2, the per-case REMEDY map), the regression file passes 20/20, npm test is 4503/0/1 skipped, npm run typecheck is clean, and check exits 0 on this branch. No residual finding is a production blocker; the deliberately deferred items (cross-branch renumbering of the collided numbers, the no-common-ancestor exit-0 path, and corpus hygiene for settled collisions on stale branches) are recorded with evidence in #937.

@philcunliffe
philcunliffe marked this pull request as ready for review August 19, 2026 11:10
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 19, 2026
@philcunliffe
philcunliffe merged commit 781ede3 into master Aug 19, 2026
10 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-907 branch August 19, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Three open branches each mint llp/0266-*, so the second one merged silently collides

1 participant