240 — The flag the chain typed and nobody read, and the quote character the reader had never seen

2026-09-24, distribution lane. DISTRIBUTION D4's last two items: one decision, and the twenty-five guarded modules the decision was blocking.

The decision, and the rule it turned out to rest on

JOURNAL/238 found that the pre-commit hook invokes three auditors as node tools/audit/claims-check.mjs --check, claims-surface.mjs --check and answer-quotability.mjs --check, and that none of the three reads --check. Each file's header documented it — "same; the name the pre-commit hook uses" — a deliberate synonym nobody implemented. Harmless today, and the only place in this repository where a live caller types an argument the tool ignores. Wiring those three without deciding it breaks the chain: declare only what they read and the hook exits 2 three times. That entry wrote both sides and stopped, which was right.

Deleted, not declared. The argument is not aesthetic and it is not "the default run is the same" — it is a rule this repository already follows everywhere and had never written down.

--check here is what a generator takes to verify its generated surface without writing it. All 7 of 7 tools in the chain that are handed it implement it, and all seven generate a tracked surface:

toolthe surface --check verifies
build-index.mjsdata/index.json
build-corrections.mjscorrections/
build-site.mjssite/
build-repo.mjsREADME.md, CITATION.cff
build-rules.mjsrules/, .claude-plugin/
build-hf.mjshf/
asks.mjsASKS.md

An auditor generates nothing. It has no surface to check, so the flag names a mode that cannot exist — which is why nobody implemented it, and is a better reason than the one the comment gave. The other three auditors in the chain (run-summary-counts, page-prose-claims, agent-surface-claims) were already invoked bare, so the three in question were the anomaly; after the change all six auditors are invoked the same way and the chain's spelling means one thing instead of two. Six places, exactly as /238 counted: three hook lines, three source comments.

JOURNAL/236's refusal of --stdio as a no-op synonym is upheld rather than distinguished. That entry said it would declare one "if a real client is ever found passing one, with the client named", and here the caller is named and is in this repository — which looked like the distinguishing case and is not. A no-op synonym is a grammar invented for a caller; when the caller is ours, the fix is to stop it typing the flag.

The batch: 25 modules, measured before and after

In a git clone under --sp-not-a-flag, write side off an mtime+size snapshot (JOURNAL/224's rule: git cannot see a tool rewriting a surface to its own bytes), network held by import-safety.mjs's canned recorder:

                   BEFORE      AFTER
exit 0                 15          0
exit 1                  3          0
exit 2                  6         25
timed out at 120s       1          0
stdout            92,273 B        0 B
wall clock          166.8s       1.2s
wrote a file            0          0
reached the network     0          0

D4's "non-writing default" is confirmed for this population — unlike JOURNAL/232's five, two of which rewrote tracked data under the same probe. So what the fifteen cost was never a write. It was a confident answer to a question nobody asked, and for four of them not cheaply: import-safety.mjs ran its whole census past the 120s deadline (449 probes, 228s allowed to finish), flip-exposure.mjs walked every blob in the object database (22.5s), plugin-validate.mjs 12.1s, pack-test.mjs packed a tarball and installed it (3.7s). That is the whole of the harm here and it is stated rather than inflated.

The six that already exited 2 did so by accident — a required operand missing, the usage block firing — and are declared anyway, /238's rule: an accidental refusal is not a gate, and it holds only while the tool keeps requiring something else. publish-gate.mjs reads no argument at all (it is driven by STALE_PRIORS_PUBLISH_APPROVED, and is npm's prepublishOnly); its exit 1 under a nonsense flag was the publish being refused, and exit 2 refuses it too, so the change is safe in the direction that matters.

All 25 are driven as real child processes, with the accepting case first — a suite of refusals would pass against a tool that refused everything. 100 checks, 0 failing.

Wiring found two more blind spots, and one of them is embarrassing

The eighth spelling: a hand-rolled dispatch loop over a parameter. css-audit.mjs and node-api-audit.mjs are sister tools and both parse this way:

function parseArgs(argv) {
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === "--workspace") out.workspace = argv[++i];
    else if (a === "--verbose") out.verbose = true;
  }
}
const args = parseArgs(process.argv.slice(2));

The receiver is a parameter and the index is a variable, so nothing in flagsReadBy could reach it and the drift join reported both as blind. Admitted by the call site — the function must be handed process.argv in this source — and not by the parameter's name, because a function named argv that never sees the command line is not a command line. The seventh spelling is its simpler half: a bound argv element compared with === (hedge-sweep.mjs's const arg = process.argv[2]; if (arg === "--selftest"), and inverse-prior-sweep.mjs's ternary).

The bucket is decided by the branch body, and the first draft got it wrong. It filed --workspace as a bare flag, which would have declared --workspace=<value> — a spelling css-audit.mjs does not read — and handed a parsed value to a program that never looks for it. That is this gate's own defect class, committed by the instrument that exists to catch it. It was caught by the drift join in the commit that introduced it, which is the join doing precisely its job, and the rule now reads the statement after the comparison for an index advance ([++i], [i + 1], [i++]). A multi-line branch is filed bare rather than guessed at — the direction that fails loudly here instead of quietly in a declaration.

The quote character, and this is the embarrassing one. Every literal pattern in argv.mjs was written "(--[a-z0-9-]+)", double quotes only, because every tool wired before this batch happened to use them. flip-exposure.mjs is written in single quotes throughout, so the reader found nothing at all in it — and args.includes('--selftest') is the first spelling this file ever learned, in the other quote character. JOURNAL/232's sentence, "the reader keeps agreeing with itself", one level below where that entry was looking: not a shape nobody had thought of, a shape nobody had typed yet. Every pattern now takes either quote.

A number that was a guess is now a measurement, and it is less flattering than the guess. The first draft of the seventh spelling's comment asserted that the naive x === "--flag" widening would add 20 false reads against the bound rule's 2. Measured over tools/: 15 hits in 6 modules, of which the bound rule files 14 and excludes exactly one — benchmark/tasks/prisma.mjs's --ignore-scripts, an argument vector handed to npm inside a benchmark task artifact, JOURNAL/228's foreign-argv class. A ratio of 1, against the fourth spelling's 9-of-9. The binding is kept because the exclusion it makes is the right one and a receiver traceable to process.argv is auditable case by case in a way x === "--flag" is not — not because the count argues for it. The comment now says that.

--noop was a command line

inverse-prior-sweep.mjs spelled its import path as a value:

const arg = invokedDirectly ? process.argv[2] : "--noop";
if (arg === "--noop") { /* imported */ }

So "I was imported" and a mode name shared one variable, and node tools/audit/inverse-prior-sweep.mjs --noop reached the imported branch: it did nothing and exited 0. That is the shape session-lock.mjs's entry in tools/lib/argv.mjs calls the one a caller reading exit codes cannot tell from success, in a tool that reports and does not gate — so it cost nothing, and it was invisible until the seventh spelling made the read visible and the drift join reported a flag the source reads and nothing declares. Declaring it would have blessed it. It is restructured into the if (invokedDirectly) { ... } block form every other module here uses, and the sentinel is gone rather than gated.

Counts

D4 is done. Every guarded command line in tools/ is now wired except tools/lib/argv.mjs itself, which cannot be: it is the gate, and flagsReadBy returns its own selftest fixtures' flags. That stays this tree's single unchecked declaration, recorded in the file and now in DISTRIBUTION.md too, so the exemption is not load-bearing by accident.