Skip to content

Commit f48be89

Browse files
os-warrenclaude
andauthored
fix(pm): the line ratchet declares its repo-root population, so an AGENTS.md card derives it (#9978)
`extractWatchHints` requires a path separator, so the ratchet's eighteen CEILINGS keys yielded seventeen hints: the repo-root `AGENTS.md` has none. `node scripts/pm/dispatch-gates.mjs AGENTS.md` therefore derived ZERO gates, and a dev dispatched on the largest ceiling in that map (958 lines, headroom 0) first met `check:pm-skill-ratchet` as red CI. lint.yml has no path filter, so CI always enforced — what was missing was discoverability. The gate now declares its root-file population as `AGENTS.md/**`, the one form the extractor already accepts for a repo-root file: `collapseHint` reduces it back to `AGENTS.md` and it matches that path alone. Provenance only — it stays out of CEILINGS, which is the map `run` opens files through. Measured over 114 families x 6326 tracked files, reusing the methodology of the original genericity refusal (the control reproduces it: 26060 -> 175192 pairs, one packages/spec card 8 -> 37 families). This declaration: 26060 -> 26061 pairs, one family gaining coverage, one file. The extractor-widening alternative was measured and REFUSED: 26060 -> 26077 is cheap by volume but 8 of its 17 new pairs are fabricated, because gates spell README.md and CHANGELOG.md as basenames they join with a package directory — a README.md card would gain six leads of which five name a gate that never reads it. Self-tests: dispatch-gates 310 -> 314, line ratchet 15 -> 19. Claude-Session: https://claude.ai/code/session_01AeA3nU1B5Q2pgxqxgUrexd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97ab2a2 commit f48be89

2 files changed

Lines changed: 92 additions & 0 deletions

File tree

scripts/pm/check-skill-line-ratchet.mjs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,50 @@ export const CEILINGS = new Map([
114114
['AGENTS.md', 958],
115115
]);
116116

117+
/**
118+
* The repo-ROOT files of the map above, spelled so `scripts/pm/dispatch-gates.mjs`
119+
* can derive this gate from a card that touches one.
120+
*
121+
* ## The gap this closes
122+
*
123+
* That tool reads a gate's population out of the path literals in the gate's own
124+
* source, and "looks like a path" there means "carries a separator" (plus a short
125+
* allowlist of dotted top-level dirs). Every key above satisfies that except
126+
* `AGENTS.md` — a repo-root FILE has no separator to be found by. So the map's
127+
* eighteen entries yielded seventeen watch hints, an AGENTS.md card derived ZERO
128+
* gates, and the dev met this ratchet as red CI instead of as a local command.
129+
* That lands on the largest ceiling in the map at headroom 0, where one added
130+
* paragraph crosses it. CI still enforces either way (lint.yml carries no path
131+
* filter) — what was missing was discoverability, and this restores it.
132+
*
133+
* ## Why the subtree spelling, and why it covers exactly one file
134+
*
135+
* `<file>/**` is the only form that reaches a repo-root file: the extractor
136+
* requires the separator, and dispatch-gates collapses a hint's globs before
137+
* comparing, which reduces this back to `AGENTS.md` and matches that path alone.
138+
* Nothing in the tree lives under `AGENTS.md/`, so it claims no directory —
139+
* measured at exactly one (gate, file) pair added, one family gaining coverage.
140+
*
141+
* The alternative was widening the extractor to accept bare top-level `*.md`
142+
* literals. Measured over 114 families x 6326 tracked files it is cheap by
143+
* VOLUME (+17 pairs) and fails on PROVENANCE: 8 of those 17 are fabricated,
144+
* because gates spell `README.md` and `CHANGELOG.md` as BASENAMES they join with
145+
* a package directory (a manifest `files` entry, a per-package exclusion, a
146+
* remote directory listing). A README.md card would come back with six leads of
147+
* which five name a gate that never reads that file — the false-lead class
148+
* dispatch-gates' own header errs against, one extension over from the
149+
* `package.json` basenames it already refuses.
150+
*
151+
* ## This is provenance, NOT a lookup key
152+
*
153+
* `run` opens files through CEILINGS. This list is read by nothing in this
154+
* script, and deliberately does not live in that map: a key rewritten into the
155+
* glob form would send the ratchet looking for a file that does not exist. The
156+
* self-test pins both halves — every separator-less ceiling is declared here,
157+
* and nothing declared here is a CEILINGS key.
158+
*/
159+
export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**'];
160+
117161
export function verdict(rel, lineCount, maxLines) {
118162
if (lineCount === 0) return { ok: false, msg: `${rel} read as empty — refusing to treat a missing/empty input as a pass (#4690).` };
119163
if (lineCount > maxLines) {
@@ -175,6 +219,17 @@ function selfTest() {
175219
['all six lane job descriptions are covered', ['engine', 'services', 'cli', 'devx', 'skills', 'spec'].every((n) => CEILINGS.has(`.claude/skills/pm-dispatch/references/lanes/${n}.md`)), true],
176220
['the other four skills are covered (#9473)', ['checklist-test', 'checklist-author', 'dogfood-verification', 'spec-property-retirement'].every((n) => CEILINGS.has(`.claude/skills/${n}/SKILL.md`)), true],
177221
['root AGENTS.md is covered (#9792)', CEILINGS.has('AGENTS.md'), true],
222+
// The dispatch-gates declaration (#9964). Enforcement cannot hold any of
223+
// these: the declaration is read by another tool entirely, so a wrong or
224+
// missing entry runs perfectly green here and only shows up as a dev
225+
// dispatched on a root-file card with an empty gate brief.
226+
['every separator-less ceiling declares a root-file watch hint', [...CEILINGS.keys()].filter((k) => !k.includes('/')).every((k) => ROOT_FILE_WATCH_HINTS.includes(`${k}/**`)), true],
227+
['and the declaration names no file the map does not cover', ROOT_FILE_WATCH_HINTS.every((h) => CEILINGS.has(h.replace(/\/\*+$/, ''))), true],
228+
['AGENTS.md is the root file it declares', ROOT_FILE_WATCH_HINTS.includes('AGENTS.md/**'), true],
229+
// Provenance, never a lookup key: `run` opens every CEILINGS key, so the
230+
// glob form appearing there would make the ratchet read a path that does
231+
// not exist — red under #4690's cannot-read rule, for a file that is fine.
232+
['the declared form is NOT a CEILINGS key', [...CEILINGS.keys()].some((k) => ROOT_FILE_WATCH_HINTS.includes(k)), false],
178233
// The boundary the header states, pinned (#9923). Enforcement cannot hold
179234
// it: a ceiling on a real published SKILL.md runs green like any other row,
180235
// so without this case the header paragraph could drift from the map

scripts/pm/dispatch-gates.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,22 @@ export function runnableInvocation({ check, filter, direct }) {
736736
* with a package directory — the same explosion, one class over. A miss there
737737
* costs one card one CI round; that is the side this file errs on.
738738
*
739+
* Re-measured (#9964) on 114 families x 6326 tracked files, narrowing that
740+
* admission to bare `*.md` literals naming a real tracked root file makes the
741+
* VOLUME trivial — 26060 pairs to 26077 — and it still fails, on PROVENANCE:
742+
* 8 of those 17 new pairs are fabricated, because `README.md` / `CHANGELOG.md`
743+
* are exactly the basenames gates join with a package directory (a manifest
744+
* `files` entry, a per-package markdown exclusion, a remote directory listing).
745+
* A README.md card would come back with six leads of which five name a gate
746+
* that never reads that file. Volume was never the whole criterion; the header
747+
* above prices a fabricated lead, not a big number.
748+
*
749+
* So the class stays out, and a gate whose population genuinely IS a repo-root
750+
* file reaches it by DECLARING the subtree spelling — `AGENTS.md/**`, which the
751+
* collapse above reduces to that one path and to nothing else. One gate pays
752+
* for its own precision instead of every gate paying for one gate's. The pm
753+
* line ratchet is the worked instance; its own header carries the reasoning.
754+
*
739755
* ## Why a segment boundary and not a raw string prefix (#8534)
740756
*
741757
* A path prefix is not a string prefix. Compared raw, a hint naming one entry
@@ -2643,6 +2659,27 @@ function selfTest() {
26432659
t('nor under examples/', !slotHints.some((h) => hintCovers(h, 'examples/crm/objects/account.object.ts')));
26442660
t('nor a content page', !slotHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx')));
26452661

2662+
// The third gate of that class (#9964), and the one nothing above could
2663+
// reach: the pm line ratchet's population includes the repo-ROOT AGENTS.md,
2664+
// and a root file carries no separator for `looksPathy` to find — so its
2665+
// eighteen ceilings produced seventeen hints and an AGENTS.md card derived
2666+
// zero gates, on the largest ceiling in that map at headroom 0. It declares
2667+
// the subtree spelling instead. Read from the real gate, not a fixture: what
2668+
// is pinned is that the tree still HAS the declaration.
2669+
const lineRatchetHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/pm/check-skill-line-ratchet.mjs'), 'utf8'));
2670+
t('the pm line ratchet reaches the repo-root instruction file it declares', lineRatchetHints.some((h) => hintCovers(h, 'AGENTS.md')));
2671+
// The negative half, and the reason this is a DECLARATION rather than an
2672+
// extractor change. Widening the extractor to admit bare top-level `*.md`
2673+
// literals was measured on the same corpus as the refusal above — 114
2674+
// families x 6326 tracked files — and costs only 17 pairs, but 8 of them are
2675+
// fabricated: gates spell `README.md` and `CHANGELOG.md` as basenames they
2676+
// join with a package directory, so a README.md card gains six leads of which
2677+
// five name a gate that never reads it. The class stays refused; these pin
2678+
// that this declaration bought no part of it.
2679+
t('and claims no other repo-root file', !lineRatchetHints.some((h) => hintCovers(h, 'README.md')));
2680+
t('nor a same-named file inside a directory', !lineRatchetHints.some((h) => hintCovers(h, 'examples/AGENTS.md')));
2681+
t('a bare top-level file literal is still no hint at all', extractWatchHints("const F = 'README.md';").length === 0);
2682+
26462683
// ── A trailing sentence period is not part of the path (#8534, half two) ──
26472684
//
26482685
// Coupled to the rule above: the raw-prefix comparison reached the real file

0 commit comments

Comments
 (0)