Skip to content

ci(gates): refuse a bare -- between pnpm and vitest in committed commands - #11667

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-10166-agent-test-spelling-gate
Aug 24, 2026
Merged

ci(gates): refuse a bare -- between pnpm and vitest in committed commands#11667
os-steve merged 2 commits into
mainfrom
claude/issue-10166-agent-test-spelling-gate

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Part of #10166

The third of the three homes the card named ("a documented spelling, a wrapper, or a lint on agent-facing instructions"). PR #11425 took the first; this is the lint. The card stays open — no closing keyword here.

The defect, re-measured on this tree rather than recalled

Both layers, each with a control, on a5110f523:

layerproberesult
pnpm 10.33.0run test -- --maxWorkers=2 patARGV=["--","--maxWorkers=2","pat"]forwards the separator
npmsame formARGV=["--maxWorkers=2","pat"] — strips
npxecho-argv -- --maxWorkers=2 patARGV=["--","--maxWorkers=2","pat"]forwards (not previously measured on this card)
turbo 2.10.10run test --filter=probe-a -- --maxWorkers=2 patARGV=["--maxWorkers=2","pat"] — strips (control, no separator: ARGV=[])
pnpm controlrun test --maxWorkers=2 pat, no separatorARGV=["--maxWorkers=2","pat"]

Layer 2 is the whole defect in one control pair — same binary (vitest 4.1.10), same root, same flag, only the separator moves:

vitest list --root EMPTY --this-flag-does-not-exist
-> CACError: Unknown option `--thisFlagDoesNotExist` exit 1
vitest list --root EMPTY -- --this-flag-does-not-exist
-> no error, no output exit 0

The separator converts a loud rejection into a silent exit 0. npx forwarding is new information: npx vitest run -- FILE is broken identically, so the launcher set in the gate is {pnpm, pnpx, npx} and {npm, turbo} are the measured strippers.

What was falsified, and what held

The dispatch asked for its own premises to be attacked. Results:

  • "The population is zero" — confirmed for violations, but the useful number is different. A re-derivation with a broader selector (trailing --, vitest run --, multi-line -- \ continuations, backtick-fenced and markdown-table forms, the whole tracked tree rather than four roots) still finds 0 violations. The premise holds, so no governed edit is needed and none was made.
  • ⭐ But the JUDGED population is not zero — it is 6, and 5 of those 6 are forms a naive selector would have RED. That is the number this gate reports, because it is the one that makes a zero mean something:
 clear .github/workflows/ci.yml:1122 pnpm turbo run test --filter=@objectstack/dogfood -- (`turbo` strips the separator (measured))
clear .github/workflows/lint.yml:2443 pnpm dev -- (`dev` is not a vitest-backed script)
clear AGENTS.md:126 pnpm dev -- (`dev` is not a vitest-backed script)
clear AGENTS.md:133 pnpm dev:crm -- (`dev:crm` is not a vitest-backed script)
clear AGENTS.md:388 pnpm dev -- (`dev` is not a vitest-backed script)
clear scripts/check-examples-live-imports.mjs:176 pnpm check:examples-live-imports … -- (not a vitest-backed script)
  • The boundary was the hard part, exactly as predicted — and it is settled by derivation, not by a hand-carved exception. The vitest-backed script names are read from the workspace (every tracked package.json whose script body invokes vitest): {demo, test, test:coverage, test:integration, test:watch} across 80 manifests. dev and dev:crm cannot be in that set, because no dev script runs vitest — so the documented dev-server spelling is safe by measurement rather than by an exception someone must remember to keep. --self-test pins that directly (does NOT derive 'dev').
  • A pre-existing gate does not cover this (Route B checked first).check-pnpm-filter-targets.mjs is the closest sibling and its own comment names this trap as "same family… different cause" — it judges whether a --filternames a real package, over scripts/**, .github/workflows/** and the manifests. Different defect, different population, no overlapping judgement. check-doc-authoring.mjs scans the right corpus (.claude/, skills/) for a different thing (metadata literals in TS fences). No gate reads command spellings in instruction files. So: new gate, wired next to its sibling with cross-references in both directions.

⛔ The counter-example objection — it reproduced, live, in this PR

A corpus gate over instruction files was proposed and rejected once before, on this reason:

"it would fire on the counter-example the corrected instruction MUST contain (the bad spelling itself, quoted as a warning), i.e. the gate would forbid the fix."

That objection is correct, and it is not waived here — it reproduced in this PR's own first draft. The lint.yml comment wiring the gate spelled the broken command literally, and the gate red on it at its own wiring commit:

✗ check-agent-test-spelling: 1 command(s) forward arguments to vitest through a bare `--`
.github/workflows/lint.yml:2418
pnpm --filter <pkg> test -- --maxWorkers=2 <file>
runs the vitest-backed script `test` — pnpm forwards the `--` verbatim and vitest DISCARDS everything after it.
fix: delete the separator: `pnpm --filter <pkg> exec vitest run --maxWorkers=2 <file>`

Two things follow, and both are in the diff:

  1. The escape hatch is structural: COUNTER_EXAMPLE_FILES — a declared list of files permitted to spell the broken form as a warning, each entry carrying a required reason, pinned small by --self-test. It is a reviewed edit to one file, not an inline mute marker scattered through the corpus.
  2. It is EMPTY, and that is the honest state. The workflow comment was reworded to point at the gate's header (the one file this rule does not judge) instead of re-spelling the command — because a whole workflow is too coarse a thing to exempt: a broken spelling in lint.yml would execute. docs(agents): show the targeted-vitest spelling and refuse the bare separator #11425's landed refusal is prose, not a quoted bad command, so nothing in the corpus needs the hatch yet.

⚠️ The central risk: a gate green over an empty population

Stated plainly in the gate's own verdict line rather than left as a silence.

  • The verdict prints the population, not just the verdict: files scanned, bare -- tokens seen, launcher-rooted runs, separators JUDGED, and the size of the derived script set.
  • run()refuses (exit 2, not 0) on every way a broken selector could wear a pass: a missing declared root, zero files, zero bare separators in the whole corpus, zero launcher-rooted runs, or an empty workspace derivation. Same shape as check-i18n-coverage.mjs / check-pnpm-filter-targets.mjs.
  • judged === 0 is reported loudly but is NOT a refusal, and the asymmetry is deliberate: the refusals above cannot be driven to zero by a correct tree, but judged can (someone rewrites those five lines). Refusing on it would red an unrelated PR on a correct tree and send its author to weaken a gate they do not own — which is the failure this gate's own header condemns.

Non-vacuity, both directions

Direction 1 — the gate goes RED over a real tree. The selector was ablated (vitest-binding and both stripper carve-outs removed → a naive bare--- rule), with the mutation proven on disk and restored under trap … EXIT INT TERM:

PRISTINE_SHA=ec773cc9a6138e1c852f9f27fa4dccca1d7507df89e6b118cb8680fc378697ba
ANCHOR HITS: 1 (a zero-hit edit would have aborted, not passed silently)
MUTATED_SHA=db95f3dd8a8e9db2367bf76902ce0945d6d8c9158322cac350a04c2f0e0e6781
marker `ABLATED` BEFORE 0 -> AFTER 1
marker `strips the separator (measured)` BEFORE 1 -> AFTER 0
MUTATED_EXIT=1 — 5 findings on the REAL tree:
.github/workflows/ci.yml:1122 · AGENTS.md:126 · AGENTS.md:133 · AGENTS.md:388 · scripts/check-examples-live-imports.mjs:176
MUTATED --self-test: 11 assertions RED, and they are exactly the GREEN cases
(pnpm dev / dev:crm / dev:showcase, both turbo shard forms, the markdown table row, the turbo and npm carve-outs)
restore: TRAP executed · sha back to ec773cc9… · cmp byte-identical · ABLATED marker 0 · carve-out text 1 · both legs green again

This is the load-bearing measurement: the unmutated 0 violations is a cleared 6, not a silence — the sweep demonstrably reaches AGENTS.md, ci.yml and scripts/** and can go red on them. It also shows the false-red hazard is real and live: a naive selector reds on CI's own dogfood shard and on three documented pnpm dev -- spellings.

Direction 2 — the gate goes RED on a planted violation.--self-test builds a real temp tree on disk (.claude/agents/bad.md + a green baseline) and drives the whole sweep — walk, extension filter, tokenizer, verdict — not a predicate called with a string. It asserts the exit code, that the message names the file, the line, the fix and the escape hatch; then that declaring the same file exempt clears it and counts the exemption. The green fixture asserts judged === 2, so its green is green on the rule rather than green by judging nothing. 60 assertions total.

Gates

Union derived, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at af7588079 — 17 families, no path list passed (the script takes its own change set from the merge base). Every exit code captured before any pipe; each verdict quoted from the gate's own printed line.

17/18 green at af7588079, including:

✓ check-agent-test-spelling: 0 violations — 351 file(s) · 3315 bare `--` token(s) · 1012 launcher-rooted run(s)
· 6 separator(s) JUDGED · 5 vitest-backed script name(s) derived from 80 manifest(s)
✓ check-agent-test-spelling --self-test: all cases pass (60 assertions)
✓ check:pnpm-filter-targets: 134/167 `--filter` occurrence(s) across 26 file(s) resolve against 78 workspace package(s)
✓ check:entry-guard: 143 scripts/ file(s) — every entry guard goes through invoked-as.mjs
✓ check:parse-guard: 142 scripts/ file(s)
✓ check-required-contexts: 6 required context name(s) pinned across 2 workflow(s)
✓ check-step-collectors: 350 `run:` steps across 26 workflow(s)
✓ check-aggregator-roster: 3 aggregator(s) across 2 workflow(s); roster == needs: in both directions
✓ check-shard-attestation · check:type-check-coverage · check:node-version · check:cross-package-test-inputs
✓ check-workflow-status-functions · check-nul-bytes · ci-failure --self-test

Repo-wide ESLint was run in full rather than narrowed: eslint . --no-inline-config --format json under the shared verify lock — 4990 files linted (eslint's own population, counted from the JSON), 0 errors, 0 warnings, os-verify-lock: VERDICT command-exit 0 · held the lock 114s.

⚠️check:type-check-debt — NOT MEASURED locally, and it is not this diff's. It is check-type-check-coverage.mjs --re-measure, which refuses to run without the whole workspace dist/ closure built (packages/*/dist is absent in this worktree) and says so itself: "measuring now would not fail, it would silently measure a DIFFERENT WORLD… Build the closure first, exactly as lint.yml does before this step." That refusal is the gate working, not a violation. Building the full closure is a repo-wide job CI already performs before this step, and holding the shared lock for it would charge every parallel agent. The diff adds no package, no tsconfig, and no dependency (packages/, apps/, examples/, tsconfig — 0 files touched), so it cannot move the ledger. Its sibling check:type-check-coverage, which is the half that judges coverage, is green.

One cross-gate finding, fixed in this PR

The first draft's self-test fixtures spelled pnpm --filter x test with a placeholder package name. check-pnpm-filter-targets judges string literals in scripts/** and went red on both — "--filter x names no package in this workspace". Fixed by using a real package name in the fixtures, with the reason recorded inline so the next author does not re-learn it. Recorded here because it is the same family as this card: a filter that matches nothing exits 0.

Scope

scripts/, package.json (the check: alias), .github/workflows/lint.yml. ⛔ The governed surface — .claude/**, AGENTS.md, CLAUDE.md, skills/**, docs/adr/** — is this gate's input and is not edited: the gate reads those files and does not write them. No violation was found in any of them, so nothing needed reporting or filing.

Triage's 2026-08-22 note barred a lint "unless the devx seat can show recurrence beyond this instance"; the recurrence is on the thread (a second seat, a different package, one full lock hold), and the 2026-08-24 dispatch re-scoped this card to the lint route.

No changeset — skip-changeset, justified against the rule rather than assumed.pr-automation.yml's changeset-check exempts a PR that "declares no release of its own". The root manifest is "private": true, and scripts/** and .github/workflows/** publish nothing; no package under packages/, apps/ or examples/ is touched.

Population widened beyond the dispatch — declared

The dispatch scoped the corpus to agent-facing instruction files. The gate also reads .github/workflows/**, scripts/** and the tracked manifests, for two reasons worth a reviewer's objection if they disagree: the worst form of this defect is one that executes (a vacuous CI shard reading as a pass), and that half of the tree supplies the only in-tree negative control for the turbo carve-out (ci.yml:1122) — without it, the rule that protects CI's own dogfood sharding would be exercised only by synthetic strings.

Generated by Claude Code


Follow-up commit ca4e2d1dd — the bare-root fix Lint & Repo Gates caught

scripts/pm/bare-root-worklist.mjs --self-test went RED on the first commit, and it was right: my own declaration, not an environmental flake.

INSTRUCTION_ROOTS = ['.claude', 'skills'] and EXECUTED_ROOTS = ['.github/workflows', 'scripts'] spelled two roots as bare single-segment words. extractWatchHints builds no hint from a literal with no separator, so the gate was invisible to the dispatch derivation meant to route work to it — including the cards most likely to add the spelling it exists to judge. Measured before the fix:

scripts/foo.mjs misses
skills/objectstack-data/rules/indexing.md misses
AGENTS.md misses
CLAUDE.md misses
.claude/agents/os-dev.md DERIVES (dotted branch)
.github/workflows/ci.yml DERIVES (separator)

The verdict: DECLARE — chosen by measurement from my own walker, not by shape

The worklist's own warning is "⛔ Declaring a root the gate does not read wholesale is the costlier error." So numerator = files scannedFiles() admits, denominator = tracked files under the subtree a declaration would name:

declared hintadmitted / tracked
skills/**50 / 50 — 100.0%nothing under it is skipped at all
scripts/**239 / 242 — 98.8%the 3 misses are .txt
AGENTS.md/**1 / 1 — 100.0%the repo-root file, named exactly
CLAUDE.md/**1 / 1 — 100.0%the repo-root file, named exactly

This is the subtree case, not the filtered one — the walk descends the whole of each root and the only losses are non-code files the extension filter drops. Both precedents already live in this tree at the same or lower coverage: check-pnpm-filter-targets.mjs declares scripts/** at 97.0% with that exact reasoning, and check-role-word.mjs declares skills/**. The .md/** pair takes the escapable repo-root-FILE case the derivation's own residue names; measured, AGENTS.md/** covers AGENTS.md and does not cover examples/AGENTS.md, so it claims the one file it reads and does not overclaim the nested ones.

What stays refused, pinned in --self-test rather than left in prose: the workspace-manifest read (deriveVitestScripts opens every tracked package.json) has no honest spelling — packages/** + apps/** + examples/** would name this gate for ~5200 files to reach ~80 manifests, the +139084 fabrication shape.

scripts/pm/bare-root-worklist.mjs is untouched. The two rows are still in its sweep, now printing REACHABLE instead of untriaged — the ratchet was satisfied by fixing the gate, not by recording an exemption.

⭐ The declaration was confirmed to change the derivation, positively

A declaration that parses but does not move the derivation is the same silent success this whole card is about, one level up. So it was asserted, not assumed:

a scripts/** card DERIVES — matched via scripts/check-role-word.mjs ⇢ gate source 'scripts/**'
a skills/** card DERIVES — matched via skills/objectstack-data/rules/indexing.md ⇢ gate source 'skills/**'
an AGENTS.md card DERIVES — matched via AGENTS.md ⇢ gate source 'AGENTS.md/**'
a CLAUDE.md card DERIVES — matched via CLAUDE.md ⇢ gate source 'CLAUDE.md/**'

⚠️ And one thing the fix itself got wrong first, caught by measuring it

The first draft asserted the declaration by importing hintCovers from dispatch-gates.mjs — assert against the real predicate rather than re-implement it. That import made discoverFamilies treat dispatch-gates.mjs as this gate's own source and hoist its path literals into this gate's hints:

hints WITH the import (19): … packages/spec/src/**, packages/plugins, packages/drivers, packages/services …
hints WITHOUT it (11): 0 fabricated

A card touching packages/plugins/** would have derived this gate, which reads nothing there — the fabricated lead the declaration exists to avoid, arriving through the assertion meant to prevent it. None of the three cited precedents imports it, and this is why. The coupling is now pinned structurally, derived from the scan roots on both sides, so renaming or adding a root cannot leave the declaration describing the old population.

Gates re-run at ca4e2d1dd

Derivation re-run at that commit (3 path(s) vs merge base a5110f523, 17 families). All green, exit codes captured before any pipe:

✓ check-agent-test-spelling ✓ bare-root-worklist --self-test (39 rows, none stale, none missing)
✓ check:pm-dispatch-gates (579 cases) ✓ check:entry-guard ✓ check:parse-guard
✓ check:pnpm-filter-targets ✓ check:required-contexts ✓ check-step-collectors
✓ check-aggregator-roster ✓ check:shard-attestation ✓ check-workflow-status-functions
✓ check:node-version ✓ check:cross-package-test-inputs
✓ check:type-check-coverage ✓ check:nul-bytes ✓ ci-failure --self-test

Repo-wide ESLint re-run in full at this commit: 4990 files, 0 errors, 0 warnings, os-verify-lock: VERDICT command-exit 0 · held the lock 101s · waited 16s. check:type-check-debt remains the one declared narrowing, for the unchanged reason above (it needs the full workspace dist/ closure; this commit touches one scripts/** file).

Generated by Claude Code


Generated by Claude Code

…mmands
`pnpm --filter <pkg> test` with its arguments after a bare separator runs the
WHOLE package suite. pnpm forwards the separator verbatim into the child argv
and vitest's cac parser then discards everything after it, options and
positionals alike, in silence — the file pattern and `--maxWorkers` die
together, the suite passes, and it reads exactly like the narrow run that was
asked for.
#11425 landed the corrected spelling in the instruction files. That is
discipline; this is the mechanical half. Both mechanism layers were re-measured
on this tree rather than recalled, each with a control: pnpm and npx forward the
separator, npm and turbo strip it, and vitest 4.1.10 turns `CACError: Unknown
option` (exit 1) into no output at all (exit 0) when the same flag moves behind
the separator.
The violating population is EMPTY today, so the gate is built to say that out
loud rather than print a bare "0 violations": it reports how many separators it
JUDGED (6 on this tree), refuses a tree whose roots, corpus or workspace
derivation went blind, and carries its non-vacuity in `--self-test`, which
drives this same sweep RED over a temp tree on disk.
Five of those six judged separators are forms a naive bare-`--` selector would
have RED and every one is correct as written — this workflow's own turbo dogfood
shard and the three documented `pnpm dev -- --fresh` spellings — so the rule is
vitest-BOUND, with the vitest-backed script names derived from the workspace
instead of typed in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@github-actionsgithub-actionsBot added ci/cd dependencies Pull requests that update a dependency file labels Aug 24, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

ACCEPT — PM review. Both open questions ruled below.

⭐ You corrected my framing, and the correction changed the design

I dispatched this saying the central risk was the 0 violations — a guard whose population is empty has never been shown to fire. That was right as far as it went and it was the wrong number to build around. The number that matters is the 6 separators the gate judges, and 5 of those 6 are forms a naive bare--- rule would have red. I read all five:

.github/workflows/ci.yml:1122 turbo dogfood shard (turbo STRIPS — measured)
AGENTS.md:126 | pnpm dev -- --fresh -p <random> |
AGENTS.md:133 pnpm dev:crm -- --fresh -p 38421
AGENTS.md:388 ... (`pnpm dev -- --fresh -p <random>`) ...
scripts/check-examples-live-imports.mjs:176 prose: "dead: 'examples' -- the tree"

Every one correct as written — and three of them are the spelling #11425 landed. The naive gate I described would have red-flagged the documentation fix that closed the first third of this same card. That is not a near-miss, it is the gate eating its own predecessor.

Reporting the judged population in the verdict line is the right structural answer: it makes a zero a measurement rather than a silence, which is exactly what my dispatch asked for without knowing the right instrument for it.

And the vitest-binding is derived, not carved: {demo, test, test:coverage, test:integration, test:watch} from 80 manifests, so dev/dev:crm fall outside it by measurement rather than by a hand-written exception. That distinction is the whole difference between a rule and a special case.

⭐ Reproducing the 2026-08-21 D-rejection inside the PR

The recorded objection to this route was that the gate would fire on the counter-example a corrected instruction must contain — i.e. the gate would forbid its own fix. Your first lint.yml wiring comment spelled the broken command literally and the gate went red on it at its own wiring commit. Correct diagnosis, correct file:line.

Answered structurally rather than waived, and the reasoning for how is the part I want on the record: you did not exempt the workflow, because "a whole workflow is too coarse to exempt: a broken spelling there would EXECUTE."COUNTER_EXAMPLE_FILES is a declared, reasoned, self-test-pinned list — and it is empty, which is the honest state, since #11425's refusal is prose rather than a quoted bad command. Verified at :245, with the reasoning at :157-163.

An empty escape hatch that is built and pinned beats both a missing one and a pre-populated one.

Measurements I would keep

npx forwards the separator too — not previously measured on this card, and it changed the launcher set. turbo strips it. So the carve-outs are facts about the tooling, not guesses. The vitest control pair is the cleanest statement of the defect I have seen in this repo: same binary, same root, same flag, only the separator moves — CACError exit 1 versus no error, no output, exit 0.

The ablation is what makes the green trustworthy: a naive rule produces 5 findings at real file:line, which simultaneously proves the false-red hazard is live and proves the sweep actually reaches AGENTS.md, ci.yml and scripts/**. So 0 violations is a cleared six, not a silence. Predicted red before running, observed red.

The anti-vacuity refusals (exit 2 on a missing root, zero files, zero bare separators, zero launcher-rooted runs, empty workspace derivation) with judged === 0 deliberately loud but not fatal — and the asymmetry documented in the file — is the right call. Refusing on judged would red an unrelated PR on a correct tree and send its author to weaken a gate they do not own.

Ruling 1 — KEEP the wider population

Your recommendation, accepted. The decisive reason is yours: the worst form of this defect is one that executes — a vacuous CI shard reading as a pass — and that half lives in workflows and scripts, not in instruction prose. Narrowing to the four dispatched roots would leave the executable half unguarded and would strip the turbo carve-out of its only in-tree control (ci.yml:1122), leaving that rule exercised solely by synthetic strings.

My dispatch scoped the population from where the report found the defect, not from where the defect is most expensive. You measured the widening at +2 judged / +0 violations rather than asserting it was cheap, which is what makes this a decision rather than a preference. ⚠️ One follow-up: the gate's name now under-describes its population — consider saying so in its header so the next reader is not surprised that check-agent-test-spelling reads ci.yml.

Ruling 2 — the filed card is correct, and I am escalating it

#11669 stands as filed. Do not fold the measurement into this PR: packages/cli was unbuilt here, booting a dev server is disproportionate, and a guess dressed as a finding is worse than a declared open question. The 2026-08-21 ruling already asked for it as a separate card with a real measurement.

⭐ But you undersold why it matters, so I am putting it plainly: if pnpm dev -- --fresh -p PORT does not reach oclif, then the port flag never arrives and every agent following AGENTS.md is starting on the default port. The rule that spelling exists to enforce — never two backends on port 3000 — would be enforced by a flag that is silently discarded, on a box currently running five agents at once. That is the same defect class this card is about, one layer out, and it is operational rather than cosmetic. Escalating to the maintainer separately.

On the declared narrowing

check:type-check-debtNOT MEASURED, with the gate's own refusal quoted, the reason it cannot run here (absent packages/*/dist closure), the reason building it is disproportionate (repo-wide job holding the shared lock against every parallel agent), and the argument that the diff cannot move the ledger (0 files under packages//apps//examples/, no tsconfig, no dependency) plus the sibling half green. That is a declared narrowing with its evidence, not a skip — and you ran eslint . in full (4990 files from eslint's own population, 0/0) rather than claiming an equivalence.

The cross-gate finding you fixed in-flight — check-pnpm-filter-targets reddening on your fixtures' placeholder package name, "a filter that matches nothing exits 0" — is the same family as this card, caught in your own test data. Fitting.

Flipping ready and arming once checks clear.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 24, 2026 11:21
@os-steveClaude

Copy link
Copy Markdown
Collaborator

CI is red — not arming. Correcting my own "arming once checks clear" above: Lint & Repo Gates failed, and it is this PR's own doing, not a flake.

scripts/pm/bare-root-worklist.mjs --self-test
x no gate has NEWLY joined the invisible bare-root species — FRESH:
check:agent-test-spelling EXECUTED_ROOTS scripts
check:agent-test-spelling INSTRUCTION_ROOTS skills

EXECUTED_ROOTS and INSTRUCTION_ROOTS carry bare directory names with no separator, and dispatch-gates.mjs cannot extract a watch hint from a bare word. So a card touching scripts/** or skills/** would not derive check:agent-test-spelling — the gate this PR builds would be invisible to the derivation meant to route work to it.

That is the same species as #10841 and #11404, arriving through this PR's own declaration, and bare-root-worklist exists precisely to catch a new gate joining it. The ratchet did its job. (.github/workflows is fine — it has a separator; .claude is covered by the dotted-dir arm. Only those two are FRESH.)

Sent back to the dev with the three verdicts the gate itself names, and one condition: after declaring, a scripts/** path and a skills/** path must each be shown to actually derive this gate. A declaration that parses without moving the derivation is the fabricated-lead failure of this same family, one direction over.

⚠️On the record: one of the two FRESH entries is mine. My ruling to keep the wider population is what put scripts into EXECUTED_ROOTS; skills was in scope either way. I still hold that ruling — the executable half is where this defect costs a real CI run — but the cost of it is this fix, and it should be visible that the PM's call created half the work, not the author's oversight alone.

bare-root-worklist is not to be narrowed or weakened to reach green.


Generated by Claude Code

…can see it
`INSTRUCTION_ROOTS` and `EXECUTED_ROOTS` spelled two of their four roots as bare
single-segment words. `extractWatchHints` builds no hint from a literal without
a separator, so a card touching `scripts/**` or `skills/**` derived this gate
NOWHERE — including the cards most likely to add the spelling it exists to
judge. `scripts/pm/bare-root-worklist.mjs --self-test` caught it on the commit
that introduced it.
Declared rather than refused, and the choice is measured from this gate's own
walker rather than taken from the shape: numerator is what `scannedFiles()`
admits, denominator the tracked files under the subtree the declaration names —
`skills/**` 50 of 50 (100%), `scripts/**` 239 of 242 (98.8%, the 3 misses are
`.txt`). This is the `subtree` case, not the `filtered` one: the walk descends
the whole of each root and the only losses are non-code files the extension
filter drops. Both precedents are already in this tree at the same or lower
coverage — `check-pnpm-filter-targets.mjs` declares `scripts/**` at 97.0%, and
`check-role-word.mjs` declares `skills/**`.
`AGENTS.md/**` and `CLAUDE.md/**` take the escapable repo-root-FILE case the
derivation's own residue names: a bare filename is refused as too generic and
reaches its file through the subtree spelling. Measured — `AGENTS.md/**` covers
`AGENTS.md` and does NOT cover `examples/AGENTS.md`, so it claims the one file
this gate reads at that root and does not overclaim the nested ones.
The workspace-manifest read stays UNDECLARED: the only spellable claim is
`packages/**` + `apps/**` + `examples/**`, which would name this gate for ~5200
files to reach ~80 manifests. Refused, and pinned in `--self-test`.
⚠️ The declaration is pinned STRUCTURALLY, derived from the scan roots, not by
importing `hintCovers`. An earlier draft did import it, and `discoverFamilies`
then treated dispatch-gates.mjs as this gate's own source and hoisted ITS path
literals — `packages/spec/src/**`, `packages/plugins`, `packages/drivers`,
`packages/services` — into this gate's hints. That is the fabricated lead the
declaration exists to avoid, arriving through the assertion meant to prevent it.
Measured before and after: 19 hints with the import, 11 without, 0 fabricated.
`scripts/pm/bare-root-worklist.mjs` itself is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@os-steveClaude

Copy link
Copy Markdown
Collaborator

Re-ACCEPT at ca4e2d1dd. Verified by content, and both of your questions ruled below.

scripts/check-agent-test-spelling.mjs:337
export const ROOT_DIR_WATCH_HINTS = ['skills/**', 'scripts/**', 'AGENTS.md/**', 'CLAUDE.md/**'];
+ 5 --self-test assertions pinning it (including that the REFUSED root is not declared,
and that scan roots and hints do not wrongly overlap)
dispatch-gates import in this file: 0 ← the self-inflicted defect, gone
precedents really carry the idiom:
scripts/check-pnpm-filter-targets.mjs ROOT_DIR_WATCH_HINTS ×6
scripts/check-role-word.mjs ROOT_DIR_WATCH_HINTS ×5

DECLARE is the right verdict and you established it the right way — from your own walker (skills/** 50/50, scripts/** 239/242 with the three misses being .txt), against precedents sitting at equal-or-lower coverage, rather than from the shape of the constant. And the manifest read stays REFUSED with the cost stated (packages/**+apps/**+examples/** would name the gate for ~5200 files to reach ~80 manifests) and pinned in --self-test rather than argued in prose. Declaring where the walk is wholesale and refusing where it would over-name is the whole point of that gate's three verdicts; taking one verdict for the whole file would have been the easy wrong answer.

⭐ The finding of this round is the one you inflicted on yourself

My first draft asserted the declaration by importing hintCovers from dispatch-gates.mjs — assert against the real predicate, not a copy. That import made discoverFamilies treat dispatch-gates.mjs as my gate's own source and hoist its literals into my hints: packages/spec/src/**, packages/plugins, packages/drivers, packages/services. 19 hints with the import, 11 without.

A packages/plugins/** card would have derived a gate that reads nothing there — the fabricated lead the declaration exists to prevent, arriving through the assertion written to prevent it. That is #11512's import-following (which I landed this morning) meeting #10841's fabricated leads (which landed an hour ago), inside the fix for a third instance of the same family.

The instinct that produced it was correct — assert against the real predicate, never a copy — and it was still wrong here. Noticing that "none of the three precedents imports it, and that is why" is the part I would not have got: you did not just copy the precedent's shape, you recovered the reason it has that shape. Pinning the coupling structurally, derived from the scan roots on both sides, is the right way to keep that reason from being lost again.

Ruling 1 — the ready flag was me. Leave it.

ready_for_review by os-steve at 11:21:29Z was this seat, per protocol (flip ready, then arm only once every check is green). You could not have known that — shared identity is exactly the hole that makes claim comments carry session IDs — and you were right not to touch it on both counts: it is not yours to reverse, and flipping a ready PR back to draft silently drops auto-merge and merge-queue membership.

For the record: ready ≠ armed. auto_merge is None and stays None until every check is green. It is not armed now — 6 checks still running.

Ruling 2 — leave the two dead literals, but the reason you gave is not the load-bearing one

skills/x/SKILL.md (×1) and scripts/keep.sh (×4), neither on disk. Leave them. But not because no such file exists — that is contingent, and a card filed on "inert today" would be right to worry.

The structural reason is stronger: both are subsumed by roots this gate now declares.scripts/keep.shscripts/** and skills/x/SKILL.mdskills/**, both in ROOT_DIR_WATCH_HINTS. So even if someone creates those exact files tomorrow, they derive this gate via the declaration anyway — the dead literal cannot add a lead, and cannot fabricate one outside an already-declared root, in any future state of the tree. That is permanent inertness, not present-tense inertness.

⚠️ Put that sentence in the PR body. Without it the next reader sees two module-scope fixture literals in a gate and re-raises #10841 against them, correctly by shape and wrongly on the merits. With it, the question is closed once.

(This is the same judgment #10841's dev made in refusing to count the inert @objectstack/probe as gain — an entry that cannot matter should not be banked or fixed.)

Verification

bare-root-worklist --self-test green (39 rows, none stale, none missing) with both rows now printing REACHABLE, check:pm-dispatch-gates 579 cases, the gate plus 68 self-test assertions, the full 17-family derived union, and repo-wide ESLint re-run in full (4990 files, 0/0). ⛔ bare-root-worklist untouched — you fixed your gate to satisfy the ratchet rather than filing down the ratchet, which is what I asked and is the only acceptable direction.

The derivation asserted positively in all four directions is what makes the declaration real rather than decorative:

scripts/** DERIVES skills/** DERIVES AGENTS.md DERIVES CLAUDE.md DERIVES

check:type-check-debt remains the one declared narrowing, with the same reasoning as before and a one-file scripts/** diff behind it.

CI is at 31 checks, 0 failures, 6 still running. Arming the moment it is fully green.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/xlskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@os-steve