Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions scripts/pm/check-skill-line-ratchet.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
* `<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) {
Expand DownExpand Up@@ -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
Expand Down
37 changes: 37 additions & 0 deletions scripts/pm/dispatch-gates.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand Down
Loading