Public hygiene: the tree carries no imprint, held by a ratchet
A repository that grew inside one company is full of that company: its name in URLs and workflow files, its people in comments, its private issue numbers as provenance, its planning vocabulary (KTD16, U3) in docstrings, its Slack and account ids in fixtures, and dated retellings of incidents where a timeless rule should stand. None of it helps a reader who is not that company, and some of it is a leak. The policy is that every comment, docstring, fixture and prose line in the public tree is answerable from the repo alone; the mechanism is a check, because a policy held by discipline lasts one busy week.
The check is a ratchet, the shape the clock allowlist already uses (tracing.md item 8): every hit in the tree is counted per file and class in a committed list that can only shrink. The tree grows imprint nowhere from the day the check lands, and a scrub of any directory is measurable — its files leave the list. When the list is empty the check is the ordinary "none" gate and stays.
- Code:
scripts/public-hygiene.mjs(the classes, the scope, the scan, the ratchet; plain JS with a.d.mtstwin — nothing undersrc/imports it),scripts/public-hygiene.allowlist.json(the recorded imprint, path → class → count),scripts/public-hygiene.allow(lines allowed by name, each with its reason),scripts/vocabulary-check.mjs(the sibling vocabulary ratchet: the fourteen words, the extraction, the surfaces),scripts/vocabulary-check.baseline.json(its baseline, path → word → count). - Tests:
src/publicHygiene.test.ts,src/vocabularyCheck.test.ts. - Docs: AGENTS.md ("Comments are for the stranger"), decision records 0022 and 0066 ("Two checks make it a guarantee"), the vocabulary.
Behavior
- Five classes, one regex each, tested per line.
names(the company, its sibling products, people, the private Slack channel — case-insensitive; a 1Passwordop://reference is an integration's own syntax, not imprint, and a vault path that names the company is caught by the company),trackers(#NNNissue references and the private org's GitHub URLs; a line anchor, an HTML entity, a heading or a hex colour is not a tracker),planIds(KTD…,KD…,OQ…, and the bareU1–U9/R1–R99markers; a Slack user id and Cloudflare'sR2are not),ids(Slack channel/user/DM ids and 32-hex account ids),dates(a full ISO date). A line can hit several classes; each is counted. - The scope is the public tree. Tracked files under
src/,deploy/,web/,scripts/,config/,docs/,.github/, the root markdown andpackage.json. Not in scope:docs/plans/(working records, not published),CHANGELOG.mdand generated notices (provenance lives there), lockfiles, binaries, and the ratchet's own three files and test. Decision records, and the index generated from them, carry dates and may cite PRs for provenance, sotrackersanddatesare not counted there. - The list only shrinks.
npm run hygiene:check(incheck:consistency) scans the tree and compares it with the recorded list: a file whose count in any class grew, or a file with hits that is not listed, is new imprint and fails with the line's remedy; a file whose count fell, or that vanished, fails asking fornpm run hygiene:genso the progress is recorded. Equal passes.hygiene:gen(infix) writes the list from the tree — but never records growth: a class that grew, or a file with hits that is not listed, makesgenrefuse (exit 1, the same message, nothing written) unless-- --forcesays the growth is deliberate; a stale allow entry likewise stops the write. Sonpm run fixis safe to run blindly and cannot absorb new imprint into the list. CI checks a PR merged into its base, so a branch whose base has moved fails until it rebases and re-records; growth that arrived from the base is the one legitimate use of--force. - A legitimate line is allowed by name.
scripts/public-hygiene.allowholdspath<TAB>trimmed lineentries with a comment saying why (the English word "nominal", a colour that looks like an issue number, an integration's own identifier). An allowed line is skipped whole. An entry is stale — and fails the check — when its line no longer exists or no longer matches any class, so the file carries neither ghosts nor dead weight. - A scrub sees what is left.
npm run hygiene:check -- --list <prefix…>prints every remaining hit under the given paths aspath:line, class, text, and a count.npm run vocabulary:check -- --list <prefix…>does the same for the sibling's hits. - The vocabulary ratchet is a sibling, over what is printed.
npm run vocabulary:check(incheck:consistency) fails one of the fourteen internal words of decision record 0066 ("The internal words") where a user reads it —session,lease,segment,instance,attempt,hosted(only immediately before run/parent/pipeline; a GitHub-hosted runner is the vendor's noun),host key,coordinator,runner(only as "plan runner"),admission,intake,handoff,wind-down,tier. It reads what is printed, never what the code says to itself: string and template literals extracted (TypeScript AST — never an identifier, a comment, an import specifier, erased type syntax or a literal used solely as a strict equality/inequality operand) fromsrc/core/dispatch/andsrc/core/ship/, thedescribe:summaries the command registry's CLI help and MCP tool list print, the text nodes of theweb/src/templates (an attribute binding is not printed text), and the lines of the non-spec docs trees (reference, how-to, tutorials, explanation — the specs keep their internal precision).docs/reference/vocabulary.mdand this spec quote the words by design and are exempt by path — there is no per-line allow file: a violation is rewritten in the user's nouns or it sits on the baseline. - The sibling's baseline only shrinks, by the shared ratchet.
scripts/vocabulary-check.baseline.jsonrecords path → word → count; growth (or a new file) fails naming the file, the word and the remedy; shrinkage asks fornpm run vocabulary:gen(infix), which refuses to record growth unless-- --forcesays it is deliberate — the first recording of the brand-new baseline was that one legitimate use, alongside growth arriving from a moved base.
Validation criteria
| Criterion | Proof |
|---|---|
Each class matches its imprint and not its look-alikes: names incl. the case-insensitive company and people, not nominally/justinian/the op:// scheme; trackers not #L12, ', ## 2026, hex colours; planIds not Slack ids or R2; ids at word boundaries; dates only full ISO (item 1) | [unit] src/publicHygiene.test.ts::the classes::* |
Scope: the public prefixes, root markdown and package.json are in; plans, changelog, notices, lockfiles, binaries, skills and the ratchet's own files are out; decision records and their generated index skip trackers and dates (item 2) | [unit] src/publicHygiene.test.ts::scope::* |
| One file's scan counts per class, names each hit with its line, skips an allowed line whole and reports it used, and counts nothing for an exempt class (items 1, 4) | [unit] src/publicHygiene.test.ts::scanText::* |
The allow file parses path<TAB>line entries past comments and blanks; a stale entry is named (item 4) | [unit] src/publicHygiene.test.ts::the allow file::* |
| The ratchet: equal passes; shrinkage or a vanished file asks to regenerate; growth or a new file names the class, the delta and the remedy (item 3) | [unit] src/publicHygiene.test.ts::ratchetProblems::* |
hygiene:gen refuses growth: only grown classes and new files are named, shrinkage is recorded (item 3) | [unit] src/publicHygiene.test.ts::growthProblems — what \hygiene:gen` refuses to record::*` |
hygiene:gen with growth in the tree exits 1 and leaves the list unchanged; -- --force records it (item 3) | [agent] add // see #123 to a source file; npm run hygiene:gen → names the file, 0 → 1, exit 1, scripts/public-hygiene.allowlist.json byte-identical (shasum before and after); npm run hygiene:gen -- --force → records it, exit 0; revert the line and hygiene:gen again. |
| The tree equals the committed list and no allow entry is stale (item 3) | [agent] npm run hygiene:check → public-hygiene ok — N file(s), M hit(s) still listed, K line(s) allowed, exit 0. Red step: add // see #123 to any source file and re-run — it must name the file, the class, 0 → 1, and exit 1. |
The vocabulary extraction reads printed strings only: literals, never identifiers, comments or import specifiers; the registry surface only its describe: summaries (item 6) | [unit] src/vocabularyCheck.test.ts::extracts string literals, never identifiers or comments; src/vocabularyCheck.test.ts::the registry surface reads only the describe summaries… |
| Erased TypeScript type syntax and literals used solely as strict comparison operands are not printed strings; runtime messages and output branches remain checked. | [unit] src/vocabularyCheck.test.ts::ignores erased types while retaining runtime messages; src/vocabularyCheck.test.ts::ignores strict comparison operands without hiding output expressions |
A web template's text node hits; an attribute binding, a comment or a {{ … }} value does not (item 6) | [unit] src/vocabularyCheck.test.ts::a web template text node hits; an attribute binding does not |
| The vocabulary page and this spec are exempt by path; the spec tree is out and the non-spec docs trees, the enumerated bot modules and the web templates are in (item 6) | [unit] src/vocabularyCheck.test.ts::the vocabulary page and the hygiene spec are exempt by path |
The fourteen words with the two scoped senses: hosted run hits, a GitHub-hosted runner does not; plan runner hits, a bare runner does not; the rest at word boundaries (item 6) | [unit] src/vocabularyCheck.test.ts::the scoped senses — hosted run hits, GitHub-hosted runner does not |
The sibling's ratchet shares the hygiene helpers with its own wording: growth is refused with the rewrite remedy, shrinkage asks for npm run vocabulary:gen (item 7) | [unit] src/vocabularyCheck.test.ts::growth is refused; shrink demands a re-recorded baseline |
| The surfaces equal the committed baseline (item 7) | [agent] npm run vocabulary:check → vocabulary ok — N file(s), M hit(s) still on the baseline, exit 0. Red step: add "the session ended" to a printed string under src/core/ship/ and re-run — it must name the file, the word, 0 → 1, and exit 1. |