Use it in your project
The room is a format and four commands. It installs next to your code, keeps the rules in your repository, and leaves behind tests that run without it. Marked throughout: what runs today, what does not exist yet.
Four commands built
Nothing to configure to get started. init writes one rule rather than
none — the format is easier to argue with than to imagine, and the first real rule
is usually this one with the words replaced.
npm i -D waitingroom npx waitingroom init scaffold a rules folder, a config and the skills npx waitingroom docs write the markdown npx waitingroom test write the tests npx waitingroom check all of the above, verified rather than written npx waitingroom skills the agent skills, for a project that already has rules
check exits non-zero, so it can sit in front of a commit. It is the
same checker this repository has always run — it moved into the package so that
the fortieth rule is checked the way the first one was.
Two skills, and what they refuse to do built
Copied into .claude/skills/ rather than pasted into a prompt, for
the same reason the rules are files: a procedure that lives in someone's chat
history is a procedure only that person has.
-
waitingroom-draftturns a specification into a rule — and derives the open questions from it, against a checklist where every entry is a question one of the three rules here actually had to ask: the parameter, the initial state, the ordering, the other end of the threshold, two values that look alike, who maintains a list, retention, the assumption about the data, the device, downstream, mandatory, validation. It does not invent an answer the specification did not give. “Presumably” and “it makes sense to” are the sentences it moves intoopenQuestions, phrased as questions. -
waitingroom-buildimplements a component against a rule. Every decision goes through three questions in order: is it inapply()— then call it, never restate it. Is it inedgeCases— then follow it. Is it inopenQuestions— then stop and ask. And if it is in none of the three, that is a gap the rule does not know about, which is the most expensive kind.
The build skill also writes the adapter, which is the step that is easy to skip because nothing fails when you do — and the step that switches the conformance test from skipped to binding.
One rule, and the cases fall out of it built
A rule already declares its own vocabulary. A threshold rule carries the number, a ladder rule lists its rungs, a state-dependency rule says what each field reads. None of that has to be restated as test data — it only has to be read.
npx waitingroom test
tests/selection-control-type.test.mjs
9 cases — sweep 0…8 around threshold = 5
tests/disclosure-conditional-fields.test.mjs
11 cases — config.samples
tests/names-truncation.test.mjs
7 cases — config.samples
What it writes is a golden file with reasons. The decisions are recorded as literals, so changing the logic lands as a changed expectation in the same diff as the provenance entry that says why.
// GENERATED by `waitingroom test` — do not edit.
// Rule: selection/control-type
// Inputs: sweep 0…8 around threshold = 5
const TABLE = [
{ label: "0 options", args: [0], expected: "none" },
{ label: "1 option", args: [1], expected: "fixed" },
{ label: "4 options", args: [4], expected: "radio" },
{ label: "5 options", args: [5], expected: "select" },
…
];
Four tests come out of that one file. The table itself, case by case. Every outcome the contract promises is one the logic can actually produce — a documented return value nothing reaches is a promise to an engineer that nothing keeps. The switch happens at the threshold, not after it. And the one below.
The test that makes a rule binding built
Everything above tests the rule against itself. This one runs the same table against what was actually built — reading the answer out of the rendered result, never asking the component what it thinks it did.
the component drifts, the rule does not
✔ selection/control-type — decides every case as recorded ✔ selection/control-type — every outcome in contract.returns is reachable ✔ selection/control-type — the switch happens at threshold = 5, not after it ✖ selection/control-type — the built component follows the rule AssertionError: 2 options: the component did not do what the rule says 'select' !== 'radio'
That is the difference between a rule that is written down and a rule that holds. Until an adapter exists the test is skipped, visibly, with the line of config that would switch it on — never silently absent.
The two things nobody else can write
A threshold can be swept on its own. A form's answers and a name in a column of a given width cannot be invented — and a case nobody chose looks exactly like a case someone did, which is the failure this whole room argues against.
// waitingroom.config.js
export default {
// Argument lists for apply(), one per case.
samples: {
'disclosure/conditional-fields': [
{ label: 'married', args: [{ maritalStatus: 'married' }] },
],
},
// Render your own component and report what actually appeared.
adapters: {
'selection/control-type': './adapters/selection.mjs',
},
};
The adapter is ten lines. It is also the only place in the whole arrangement that knows what a component is — which is why it belongs to the project and not to the tool.
What check refuses to let past
-
A rule with nothing open.
openQuestionsmay not be empty. A rule with nothing open has either been thought through completely or has stopped asking, and the second is far more common. - A number spelled out in prose. The parameter is changed and the text is re-read; anything that still mentions the old value was never derived from it. This caught the first draft of the very first rule.
- Generated files that have drifted. The markdown and the tests are compared against the rules byte for byte. There is no way to change them except by changing the rule.
- A rule nobody can generate a case for, an outcome that is declared but unreachable, a dependency that does not actually gate its field, a raw colour in a component, a token defined and never used.