Skip to content

tooling(pm): admit a same-PR cross-file line-budget move, and take the first one - #14860

Open
os-litant wants to merge 5 commits into
mainfrom
claude/issue-14685-ratchet-cross-file-move
Open

tooling(pm): admit a same-PR cross-file line-budget move, and take the first one#14860
os-litant wants to merge 5 commits into
mainfrom
claude/issue-14685-ratchet-cross-file-move

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Part of #14685
Part of #13597

Item 5 of decision batch 3, ruled A. Ruling comment 5520452691 on the card, verbatim:

the per-file line ratchet admits a cross-file move in one PR when the destination's allowance rises by no more than the source's net decrease and total lines do not increase, with the ruling cited in the ratchet comment; check:ratchet-remedy-authority's self-description gains this exception. platform-readings.md absorbs the three misplaced per-repo facts under it.

Two halves land together because neither is useful alone: the rule, and the first move taken under it.

1. The rule — scripts/pm/check-skill-line-ratchet.mjs

The defect is structural, not a hardship claim: a fact can be in the wrong file. A per-repo gate reading written into one lane charter is read by that lane and missed by the other six; its single source is references/platform-readings.md. Consolidating it there reduces the corpus — one copy, one reader path — yet under a shrink-only per-file ceiling it was impossible without a per-instance maintainer ruling, because the destination sits at headroom 0 like every other entry. The ratchet was pricing a move as though it were growth, and the file's own remedy sentence (move narrative out rather than raise the roof) was the very act it could not price.

A declared cross-file move is now the one raise an author may take alone. Three conditions, all re-derived from the tree on every run:

  • (a) the destination's raise is at most the net decrease of the sources that move declares, per move;
  • (b) the sum of ceilings over the whole map does not increase;
  • (c) the raised entry declares the ruling that authorises it.

The mechanical form, and why this one

The flight brief offered two shapes. I took (i), in-tree declaration data, not (ii) a --baseline origin/main diff, and the map entry carries the human comment the ruling asks for plus a machine-readable CROSS_FILE_MOVES declaration rather than a comment the script has to regex out of its own source.

Why:

  • A baseline ref answers nothing once the move has landed. --baseline origin/main only sees a raise while the PR is open; after merge, HEAD and origin/main agree and the check goes silent — so the rule would be enforced exactly once, by a run nobody re-reads.
  • origin/main is not a stable input. It is a local snapshot a sibling worktree's fetch moves under the run, and a shallow CI checkout need not carry it at all. A gate whose verdict depends on it must either red on a missing ref (breaking shallow checkouts) or skip — and a gate that cannot find its input must fail, not skip.
  • Parsing your own source for a trailing comment breaks silently. The declaration is data, so the fields a move needs (ruling, was, sources) are read, not scraped; the prose comment beside the ceiling stays what it has always been, prose for a reviewer.

What the gate can see, and what stays a reviewer's job

Stated in the header rather than implied. The script reads the working tree and its own map; it never sees the PR diff, so it cannot know a raise happened at all, still less that a decrease landed in the same PR. What it does is hold a declared move to its own arithmetic: each participant records the ceiling it carried before the move, so the raise, the decreases and the map-wide total are computable from the tree alone — deterministic, hermetic, available in a shallow checkout.

What stays with the reviewer is completeness: that each recorded was is the value on origin/main, and that no other entry moved. Both are one git diff away in this PR and neither is inferable from a tree. That is the bar this map has always used — every ceiling in it is an author's number, read at review against the diff — so the declaration weakens nothing; it is the first raise path in the file a machine can check at all.

Condition (b) is belt and braces, and the header says so: over declarations whose participants are disjoint (which the total check requires) it follows from (a) summed over the moves. It is written out because it is the ruling's own wording and because it is the condition that would still be right if (a) were ever loosened.

Lifecycle, deliberately asymmetric

A declaration keeps working after its PR lands.

  • Lowering the destination later is always legitimate, so a destination that has fallen back to or below its was reads as PAID DOWN and passes. A move declaration must never red a shrink.
  • Raising a source later is the loophole a move mechanism opens — the destination keeps the lines it was given while the files that paid for them grow back, and the corpus is up on net with the move's warrant silently spent. So the sum is re-checked against the sources' pre-move values for as long as the declaration stands. That red belongs to the later raise, and its author is holding the ruling that has to account for it.

Self-test

--self-test goes 111 to 136 cases (+25). The four the brief names, plus the boundary and lifecycle cases: a legal move passes; a raise whose sources did not shrink is RED; a raise exceeding the net decrease is RED even by one line, while a raise exactly equal to it is legal; a declaration citing no ruling is RED however sound its arithmetic; a declaration netting positive fails the map-wide total; one source may not pay for two destinations; a destination lowered back reads as paid down; a source grown back re-opens the move; an unknown participant is RED, not a skip; a file may not pay its own raise.

Verdict lines and exit codes are unchanged. Two lines are added to the run's output: the per-move verdict, and the map-wide total (printed even at zero declarations, so "the move audit ran" is visible rather than assumed).

2. The exception — scripts/check-ratchet-remedy-authority.mjs

The #8435 convention asks who may take a registry-growing remedy on a shrink-only ratchet, and the answer has always been "a maintainer, per instance". The ruling authorises one class of act generally instead, and the gate's self-description now records it.

Three calls worth reading rather than counting:

  • It is written in comment space, never a string literal. That gate reads author-facing text, so an exception describing a registry-growing act, written into a message, would make the gate an instance of the convention it enforces and flip its own verdict off excluded — the shape its assertion (17) exists to catch. A new self-test assertion pins the exception phrase as present in source and absent from every author-facing message. This reverse-verified itself on the first attempt: the assertion reddened because the phrase was spelled as a plain literal in the test, which put it into author-facing text. It is assembled from parts now, the way the file's existing ADD hoist is, and for the same reason.
  • Today the exception is documentary, and the gate says so. The corpus walk is a non-recursive readdir of scripts/, and the ratchet the ruling amends lives one directory down in scripts/pm/. It has never been in this sweep, so no verdict of this gate moves — before and after are the same OK line, byte for byte. A second new assertion pins that, so widening the walk reds here and makes the class a decision someone takes rather than one that lands in silence.
  • No new verdict class. marked / refused / unmarked / excluded stay as they are. A fifth class for "authorised by standing ruling" would have a population of zero in this corpus, and an unreachable class is a claim of enforcement nobody has — the same defect that gate's control corpus exists to prevent, one level up.

3. The first move — three per-repo readings consolidate

Site by site. Every source span was measured at this branch's base f3ae441fa2, and the destination was grepped for the fact first, exactly as the flight brief asks.

Fact Source span (at base) Already at the destination? Disposition
The objectstack required-check set (six names), plus in_progress is not a pass and advisory red into main is shared damage references/lanes/cli.md:17-20 (4 lines) The per-job-conclusion / do-not-trust-the-aggregate rule: YES, stated in platform-readings.md 队列成员资格 (门禁放行判据 = 承载门禁族 job 的 conclusion …). The six names, in_progress and the advisory clause: no Rule NOT copied — the moved bullet points at the existing statement. Names and boundaries added; source deleted
Gate readings do not trust aggregates: check:type-check-debt can red while a package typecheck is green; check:i18n exiting 1 on PREREQUISITE NOT MET is not drift references/lanes/services.md:21-22 (2 lines) No Added to 读数陷阱, folded into the same bullet as the row above (it is the same rule with two named instruments); source deleted
The merge_group count-is-not-a-mechanism tombstone references/rest-channel.md:79-83 (5 lines) No Added to 读数陷阱 byte-identically (verified by comparing the deleted span against the added span); source deleted

No pointer sentence is left at any source. Nothing at the three sites becomes unreadable without the moved sentence: cli.md already routes queue readings to platform-readings.md in the bullet above the deleted one; services.md keeps two independent standing commitments; rest-channel.md keeps four queue-routing bullets and its own header already refuses to hold a second copy of what platform-readings.md owns. A pointer per move is how a corpus grows while its ledger says it shrank.

Per-file arithmetic

File Lines before/after Ceiling before/after Bytes before/after Tokens before/after
references/lanes/cli.md 35 / 31 (-4) 35 / 31 2117 / 1786 530 / 447
references/lanes/services.md 30 / 28 (-2) 30 / 28 1762 / 1577 441 / 395
references/rest-channel.md 93 / 88 (-5) 93 / 88 8277 / 7704 2070 / 1926
references/platform-readings.md 314 / 324 (+10) 314 / 324 33424 / 34549 8356 / 8638
TOTAL 472 / 471 (-1) -1 45580 / 45616 (+36) 11397 / 11406 (+9)

Condition (a): the destination rises +10 against a net source decrease of 11. Condition (b): the whole ceiling map goes 5112 to 5111, down one line — measured by summing CEILINGS at base and at head, not asserted.

Reported rather than smoothed: lines fall by one while bytes rise by 36 and tokens by 9. The ruled condition is stated in lines and the ratchet is a line ratchet, so the condition holds — but the byte direction is the honest reading and it is here rather than buried. The 36 bytes are the navigational clause the new bullet carries in place of the rule it declines to restate (pointing at the destination's own queue section instead of copying it); the three facts themselves shrank. Token counts use countTokens from check-skills-token-ratchet.mjs, the catalog's own instrument, not an estimate.

Ceiling map diff

- ['.claude/skills/pm-dispatch/references/platform-readings.md', 314],
+ ['.claude/skills/pm-dispatch/references/platform-readings.md', 324],
- ['.claude/skills/pm-dispatch/references/rest-channel.md', 93],
+ ['.claude/skills/pm-dispatch/references/rest-channel.md', 88],
- ['.claude/skills/pm-dispatch/references/lanes/services.md', 30],
+ ['.claude/skills/pm-dispatch/references/lanes/services.md', 28],
- ['.claude/skills/pm-dispatch/references/lanes/cli.md', 35],
+ ['.claude/skills/pm-dispatch/references/lanes/cli.md', 31],

Each carries its own comment citing the ruling and naming the other participants, in the register the map already uses. MAX_TABLE_ROW_BYTES is untouched: all four files pin at 0 and the moved text carries no table row.

The declaration itself:

export const CROSS_FILE_MOVES = new Map([
  [
    '.claude/skills/pm-dispatch/references/platform-readings.md',
    {
      ruling: 'per-repo readings consolidation, authorised by #14685 item 5 (comment 5520452691)',
      was: 314,
      sources: [
        ['.claude/skills/pm-dispatch/references/lanes/cli.md', 35],
        ['.claude/skills/pm-dispatch/references/lanes/services.md', 30],
        ['.claude/skills/pm-dispatch/references/rest-channel.md', 93],
      ],
    },
  ],
]);

4. Reverse verification

Three mutations against the committed implementation, each with its landing proved on disk (a grep count of the removed text and of the injected text, never an editor's exit code), each restored with git checkout HEAD -- {absolute path} and the restore proved by a git hash-object match against the HEAD blob plus an empty git diff HEAD. The script carries a trap on EXIT/INT/TERM with absolute paths.

Mutation Expected direction Observed
A source ceiling grown back (lanes/cli.md 31 to 35) RED — the payment is undone exit 1; per-move verdict names +10 against a net decrease of 7, and the total reds at +3
The declaration's ruling citation replaced RED — a raise citing nothing is a raise exit 1; verdict spells the citation it wanted; the total stays green, so the two checks are independent
The destination raised beyond what the sources paid (324 to 326) RED — +12 against 11 exit 1; both the per-move verdict and the map-wide total red
Control: unmutated tree GREEN exit 0, both verdict lines green

5. Gates

Union derived at this head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands. Derivation line:

dispatch-gates: gate list derived from the tree of 'objectstack-ai/objectstack' at commit 62ddd75fb5 (/home/user/objectstack-14685-item5).
dispatch-gates: change set derived from git — 6 path(s) vs merge base 5ff5f9576 of 'origin/main' and HEAD

Exit codes were captured by redirect before any pipe, and each verdict below is the gate's own line.

Gate Exit Its own verdict line (excerpt)
node scripts/check-ci-filter-parity.mjs 0 OK: all 133 declared cross-package glob(s) (93 unique) are covered …
node scripts/check-cross-package-test-inputs.mjs 0 OK: 25 package(s) read outside themselves, all declared …
node scripts/check-self-test-wired.mjs 0 ✓ every one of the 164 script(s) CI runs that ship a --self-test has that self-test run by CI
node scripts/check-shard-attestation.mjs 0 ✓ 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s)
node scripts/check-test-completeness.mjs 3 PREREQUISITE NOT MET — see below. NOT MEASURED, not a red
node scripts/check-whole-set-label-write.mjs 0 ✓ 0 violations — 250 file(s) over 3 root(s) …
node scripts/pm/bare-root-worklist.mjs --self-test 0 OK self-test: 57 live row(s), 49 unreachable as spelled, 49 recorded verdict(s) — none stale …
pnpm --filter @objectstack/lint run check:doc-formula-expressions 0 ✓ 9 @example(s) judged clean across 1120 packages/spec/src files — first run exited 3 (PREREQUISITE NOT MET), prerequisite supplied, re-run to a real verdict
pnpm check:agent-test-spelling 0 ✓ 0 violations — 430 file(s) · 5705 bare -- token(s) …
pnpm check:bash32-floor 0 ✓ 26 tracked shell file(s) … name no bash 4+ construct
pnpm check:cli-command-ids 0 ✓ 317 command-id literal(s) across 113 file(s) … all resolve
pnpm check:cross-package-test-inputs 0 OK: 25 package(s) read outside themselves, all declared
pnpm check:doc-authoring 0 ✓ 14546 customer-facing string(s) across 710 spec sources clean
pnpm check:entry-guard 0 ✓ 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs
pnpm check:parse-guard 0 ✓ 198 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs
pnpm check:pm-dispatch-gates 0 ✓ dispatch-gates self-test: 1241 cases pass
pnpm check:pm-governed-merges 0 ✓ check-governed-merges --self-test: 243 assertions …
pnpm check:pm-skill-id-lint 0 ✓ 23 file(s) clean (pattern /#[0-9]{3,}/g) — the moved text carries no issue id
pnpm check:pm-skill-ratchet 0 ✓ cross-file move into … platform-readings.md: +10 (314→324) against a net source decrease of 11 …; authorised by #14685 item 5 (comment 5520452691) and ✓ declared cross-file moves: 1, total ceilings down 1 line; self-test 136 cases pass
pnpm check:pnpm-filter-targets 0 ✓ 142/181 --filter occurrence(s) across 32 file(s) resolve
pnpm check:ratchet-remedy-authority 0 OK 184 scripts swept; 12 mark the expanding remedy ⛔ MAINTAINER-ONLY, 6 turn it down outright, 166 hand out no ratchet-expanding remedy. Control corpus: 27 …byte-identical to the same line at the base commit
pnpm check:skill-frame-sync 0 ✓ 4 copies of the decision frame are structurally isomorphic across 3 files (untouched by this PR)
pnpm check:watch-hint-literal 0 ✓ 47 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree
pnpm check:role-word (named by the flight brief) 0 check-role-word: OK, no new occurrences of the reserved word
node scripts/check-skills-token-ratchet.mjs (the other binding ratchet) 0 ✓ 36 authored bundle file(s) within their ceilings; 11 generator-owned file(s) measured, not ratcheted

NOT MEASURED, and why

  • node scripts/check-test-completeness.mjs — NOT MEASURED (exit 3). It grades a saved turbo run test log and does not run tests; the derived family names it with no argument, which is the branch that has no log to grade. CI tees the log and passes the path on every invocation, so CI behaviour is unchanged. The prerequisite could only be supplied by running the whole test farm, which this diff (two gate scripts and four instruction-surface markdown files, no package source) does not touch. Recorded as the gate's own text asks — NOT MEASURED, not a pass and not a red.
  • The always-runs tail is not here. dispatch-gates --commands says so itself: workflows with no path filter are outside the derived list, and one further family matched by path is CI-measured only (it reads the workflow event payload, so no local run can produce a verdict). Nine more families apply only once a changeset exists, and this PR deliberately carries none (skip-changeset: these paths publish nothing from any package). CI is the reading for all of those.
  • pnpm lint (the repo-wide eslint scan) was NOT run, and this is a stated narrowing rather than a silent one. It is not in the derived union for these paths; the diff adds no JavaScript that eslint governs beyond two scripts/** files whose own gate families all ran green above; and the repo-wide scan is a run CI owns unconditionally. No evidence of scope is claimed for it here.

6. Judgement calls

  • Both lanes/cli.md and lanes/services.md facts land in one destination bullet. They are the same rule (an aggregate is not a verdict) with different instruments, and the destination register bundles related instruments into one bullet routinely. Splitting them would have cost a second lead-in for no reader gain.
  • The per-job-conclusion rule was deliberately not copied. It is already stated in the destination's queue section, so restating it would have been paying ceiling for a second copy of a rule the file already owns. The moved bullet carries a clause pointing at it instead. This is recorded in the ceiling comment so a future author does not "restore" the sentence.
  • All three facts land in 读数陷阱 rather than splitting the merge_group tombstone into 队列成员资格. Both placements are defensible; 读数陷阱 is the section about how to read a tool's output correctly, which is what all three facts are, and one section keeps the three under one heading a reader reaches by one route.
  • The remedy-authority exception gains no verdict class, for the reason in section 2. If the sweep is ever widened to nested scripts, the new assertion reds and the class becomes a decision rather than an inference.

7. Governance

Governed surfaces (.claude/**, scripts/pm/**). Draft only — not flipped ready, not enqueued, no auto-merge, no reviewers requested, no approval. Labels skip-changeset (these paths publish nothing from any package) and needs:contract-review. The seat clears the review label at PASS.

Generated by Claude Code


Generated by Claude Code

The per-file line ratchet is shrink-only, which prices a FACT MOVING TO ITS
SINGLE-SOURCE FILE as though it were growth: the destination sits at headroom 0
like every other entry, so consolidating a misplaced per-repo reading needed a
per-instance maintainer ruling even though the corpus shrank on net.

Adds the declared cross-file move as the one raise an author may take alone,
under the three ruled conditions: the destination's raise is at most the net
decrease of the sources it names, the map-wide total does not increase, and the
declaration cites the ruling that authorises it. The declaration is in-tree data
carrying each participant's pre-move ceiling, so the arithmetic is computable
from the working tree alone — no baseline ref a shallow CI checkout can fail to
provide, and no answer that evaporates once the move has landed.

Verdict lines and exit codes are unchanged; the map is empty in this commit.

Part of #14685
Part of #13597

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1
…y gate

The #8435 convention asks who may take a registry-growing remedy on a
shrink-only ratchet, and the answer has always been "a maintainer, per
instance". One class of act is now authorised generally: a cross-file move in
the per-file line ratchet, while the destination's raise is covered by the named
sources' net decrease, the map-wide total does not increase, and the raised entry
cites the ruling.

The exception is written in comment space, because author-facing text describing
a registry-growing act would make this gate an instance of the convention it
enforces. Two self-test assertions hold it there: one that the phrase never
reaches an author-facing message, one that the amended ratchet is still outside
this non-recursive walk — so the exception stays honestly documentary, and a
wider walk reds here instead of admitting a remedy class in silence. No new
verdict class: a fifth class with a population of zero is enforcement nobody has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1
A per-repo gate reading written into one lane charter is read by that lane and
missed by the other six. Their single source is references/platform-readings.md,
so the three the Phase-1 tables measured move there, under the cross-file-move
rule this branch adds:

  lanes/cli.md        -4  the objectstack required-check set, `in_progress` is
                          not a pass, advisory red into main is shared damage
  lanes/services.md   -2  the two aggregate-reading gate boundaries
  rest-channel.md     -5  the merge_group count-is-not-a-mechanism tombstone,
                          moved byte-identically

platform-readings.md +10 against a net source decrease of 11, so the corpus
falls by one line: the per-job-conclusion rule cli.md carried is already stated
in the destination's queue section, so the moved bullet points at it rather than
paying for a second copy. Ceilings move with the text (314→324, 35→31, 30→28,
93→88) and the move is declared, so the gate re-derives the arithmetic on every
run instead of trusting the comment beside it.

No pointer is left at any source: nothing at the three sites becomes unreadable
without the moved sentence, and a pointer per move is how a corpus grows while
its ledger says it shrank.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation labels Sep 3, 2026
@os-litant os-litant added skip-changeset PR has no user-facing published change; bypasses the changeset gate needs:contract-review and removed documentation Improvements or additions to documentation size/m labels Sep 3, 2026 — with Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants