From 63fce4a248525765431bfebce6777f9e93cdde42 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 11:40:02 +0000 Subject: [PATCH] fix(pm): the line ratchet declares its repo-root population, so an AGENTS.md card derives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AeA3nU1B5Q2pgxqxgUrexd --- scripts/pm/check-skill-line-ratchet.mjs | 55 +++++++++++++++++++++++++ scripts/pm/dispatch-gates.mjs | 37 +++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index d0ded1b522..fd3a4f7de3 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -114,6 +114,50 @@ export const CEILINGS = new Map([ ['AGENTS.md', 958], ]); +/** + * The repo-ROOT files of the map above, spelled so `scripts/pm/dispatch-gates.mjs` + * can derive this gate from a card that touches one. + * + * ## The gap this closes + * + * That tool reads a gate's population out of the path literals in the gate's own + * source, and "looks like a path" there means "carries a separator" (plus a short + * allowlist of dotted top-level dirs). Every key above satisfies that except + * `AGENTS.md` — a repo-root FILE has no separator to be found by. So the map's + * eighteen entries yielded seventeen watch hints, an AGENTS.md card derived ZERO + * gates, and the dev met this ratchet as red CI instead of as a local command. + * That lands on the largest ceiling in the map at headroom 0, where one added + * paragraph crosses it. CI still enforces either way (lint.yml carries no path + * filter) — what was missing was discoverability, and this restores it. + * + * ## Why the subtree spelling, and why it covers exactly one file + * + * `/**` is the only form that reaches a repo-root file: the extractor + * requires the separator, and dispatch-gates collapses a hint's globs before + * comparing, which reduces this back to `AGENTS.md` and matches that path alone. + * Nothing in the tree lives under `AGENTS.md/`, so it claims no directory — + * measured at exactly one (gate, file) pair added, one family gaining coverage. + * + * The alternative was widening the extractor to accept bare top-level `*.md` + * literals. Measured over 114 families x 6326 tracked files it is cheap by + * VOLUME (+17 pairs) and fails on PROVENANCE: 8 of those 17 are fabricated, + * because gates spell `README.md` and `CHANGELOG.md` as BASENAMES they join with + * a package directory (a manifest `files` entry, a per-package exclusion, a + * remote directory listing). A README.md card would come back with six leads of + * which five name a gate that never reads that file — the false-lead class + * dispatch-gates' own header errs against, one extension over from the + * `package.json` basenames it already refuses. + * + * ## This is provenance, NOT a lookup key + * + * `run` opens files through CEILINGS. This list is read by nothing in this + * script, and deliberately does not live in that map: a key rewritten into the + * glob form would send the ratchet looking for a file that does not exist. The + * self-test pins both halves — every separator-less ceiling is declared here, + * and nothing declared here is a CEILINGS key. + */ +export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**']; + export function verdict(rel, lineCount, maxLines) { if (lineCount === 0) return { ok: false, msg: `${rel} read as empty — refusing to treat a missing/empty input as a pass (#4690).` }; if (lineCount > maxLines) { @@ -175,6 +219,17 @@ function selfTest() { ['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], ['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], ['root AGENTS.md is covered (#9792)', CEILINGS.has('AGENTS.md'), true], + // The dispatch-gates declaration (#9964). Enforcement cannot hold any of + // these: the declaration is read by another tool entirely, so a wrong or + // missing entry runs perfectly green here and only shows up as a dev + // dispatched on a root-file card with an empty gate brief. + ['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], + ['and the declaration names no file the map does not cover', ROOT_FILE_WATCH_HINTS.every((h) => CEILINGS.has(h.replace(/\/\*+$/, ''))), true], + ['AGENTS.md is the root file it declares', ROOT_FILE_WATCH_HINTS.includes('AGENTS.md/**'), true], + // Provenance, never a lookup key: `run` opens every CEILINGS key, so the + // glob form appearing there would make the ratchet read a path that does + // not exist — red under #4690's cannot-read rule, for a file that is fine. + ['the declared form is NOT a CEILINGS key', [...CEILINGS.keys()].some((k) => ROOT_FILE_WATCH_HINTS.includes(k)), false], // The boundary the header states, pinned (#9923). Enforcement cannot hold // it: a ceiling on a real published SKILL.md runs green like any other row, // so without this case the header paragraph could drift from the map diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index a89efe1fb6..f85bd0ef00 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -736,6 +736,22 @@ export function runnableInvocation({ check, filter, direct }) { * with a package directory — the same explosion, one class over. A miss there * costs one card one CI round; that is the side this file errs on. * + * Re-measured (#9964) on 114 families x 6326 tracked files, narrowing that + * admission to bare `*.md` literals naming a real tracked root file makes the + * VOLUME trivial — 26060 pairs to 26077 — and it still fails, on PROVENANCE: + * 8 of those 17 new pairs are fabricated, because `README.md` / `CHANGELOG.md` + * are exactly the basenames gates join with a package directory (a manifest + * `files` entry, a per-package markdown exclusion, a remote directory listing). + * A README.md card would come back with six leads of which five name a gate + * that never reads that file. Volume was never the whole criterion; the header + * above prices a fabricated lead, not a big number. + * + * So the class stays out, and a gate whose population genuinely IS a repo-root + * file reaches it by DECLARING the subtree spelling — `AGENTS.md/**`, which the + * collapse above reduces to that one path and to nothing else. One gate pays + * for its own precision instead of every gate paying for one gate's. The pm + * line ratchet is the worked instance; its own header carries the reasoning. + * * ## Why a segment boundary and not a raw string prefix (#8534) * * A path prefix is not a string prefix. Compared raw, a hint naming one entry @@ -2643,6 +2659,27 @@ function selfTest() { t('nor under examples/', !slotHints.some((h) => hintCovers(h, 'examples/crm/objects/account.object.ts'))); t('nor a content page', !slotHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx'))); + // The third gate of that class (#9964), and the one nothing above could + // reach: the pm line ratchet's population includes the repo-ROOT AGENTS.md, + // and a root file carries no separator for `looksPathy` to find — so its + // eighteen ceilings produced seventeen hints and an AGENTS.md card derived + // zero gates, on the largest ceiling in that map at headroom 0. It declares + // the subtree spelling instead. Read from the real gate, not a fixture: what + // is pinned is that the tree still HAS the declaration. + const lineRatchetHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/pm/check-skill-line-ratchet.mjs'), 'utf8')); + t('the pm line ratchet reaches the repo-root instruction file it declares', lineRatchetHints.some((h) => hintCovers(h, 'AGENTS.md'))); + // The negative half, and the reason this is a DECLARATION rather than an + // extractor change. Widening the extractor to admit bare top-level `*.md` + // literals was measured on the same corpus as the refusal above — 114 + // families x 6326 tracked files — and costs only 17 pairs, but 8 of them are + // fabricated: gates spell `README.md` and `CHANGELOG.md` as basenames they + // join with a package directory, so a README.md card gains six leads of which + // five name a gate that never reads it. The class stays refused; these pin + // that this declaration bought no part of it. + t('and claims no other repo-root file', !lineRatchetHints.some((h) => hintCovers(h, 'README.md'))); + t('nor a same-named file inside a directory', !lineRatchetHints.some((h) => hintCovers(h, 'examples/AGENTS.md'))); + t('a bare top-level file literal is still no hint at all', extractWatchHints("const F = 'README.md';").length === 0); + // ── A trailing sentence period is not part of the path (#8534, half two) ── // // Coupled to the rule above: the raw-prefix comparison reached the real file