Ship a feature (so it ships, not half-ships)
The nine-row definition-of-done already exists — cohesive-feature-delivery in
CLAUDE.md. On 2026-09-02 equivalentDisasters went out tested, gated and
committed, and failed four of those rows unnoticed: unreachable from every
public door, a neighbour's JSDoc silently stolen, no doc, no stability mark.
Prose in a 2000-line file does not fire. This does. Every check below names
the failure it was written for; nothing here is theory.
1. Before writing — three decisions only you can make
| Decision |
Criterion |
Which door? (package.json exports subpath) |
Free + deterministic → . (src/test.ts). Bills a model → ./eval (paid_ prefix). Hook vocabulary → ./hook. A symbol not re-exported from a door's barrel does not exist to require("vigiles") (failure 1). |
Stable or experimental_? |
STABILITY.md: exported + not stable ⇒ @experimental tag AND experimental_ name. One day old with one consumer is not stable. ESLint local/experimental-name holds tag→name; it cannot write the tag (failure 4). |
Where does the WHY live publicly? (document-the-why) |
A docs/*.md section beside the claim it changes — for equivalentDisasters, next to "blocks 7/7" in docs/compiled-hooks.md. Not the README (brevity), not this file (contributor tier). |
Write the answers into the PR body. The script below refuses to pass the
stability row until you state it.
2. After writing — one command, five checks
npm run build
node .claude/skills/ship-a-feature/scripts/ship-check.mjs <symbol> [--stable "<why it is stable>"]
| Check |
What it executes |
Measured failure it exists for |
| REACHABLE |
import()s every exports subpath from the built dist/; the symbol must be a key on ≥ 1 |
equivalentDisasters in src/guardrail-check.ts, never in src/test.ts → undefined (1) |
| SURFACE |
regenerates api-surface/ (api:report), then READS git diff: any +… (undocumented) fails |
inserted between verifyGuardrail's JSDoc and its declaration; the diff said so, nobody read it (2) |
| DOCUMENTED |
\b<symbol>\b must occur in docs/**/*.md or README.md |
no public home until asked (3) |
| FINDABLE |
two pages must name it — or one names it and another LINKS to that section's anchor |
documented once, inside a guide about a DIFFERENT feature, with no inbound link (5) |
| MARKED |
declaration's JSDoc has @experimental, or the name is prefixed, or you passed --stable "<why>" |
exported one day old, no tag, no prefix; the lint rule had nothing to hold (4) |
DOCUMENTED and FINDABLE are different questions. The first asks whether the
capability is written down anywhere. The second asks whether a reader who does
not already know which file to open can get to it. experimental_equivalentDisasters
(since renamed experimental_alternateSpellings) passed the first and failed the second: it was explained once, under its own
heading, inside the guide for a different feature, and nothing linked there — so
the testing guide, where someone asking "does my guard actually block?" looks,
named it zero times. A pointer satisfies FINDABLE; you do not have to repeat the
explanation.
✗ means fix and re-run; the script prints the fix beside each finding. A ✓ MARKED stable line carries your reason — paste it into the PR body.
3. The gate — on a FROZEN tree, in the foreground, with its real exit code
node .claude/skills/ship-a-feature/scripts/ship-check.mjs --gate # = npm run check, frozen
It hashes the tree before and after and fails if anything changed while the
gate ran. Three measured ways a gate lies, all closed by running it this way:
- Editing during the run (5):
npm run check linted a half-written file and
failed on errors that belonged to no state. Do not touch the tree until it exits.
- Waiting with
until ! pgrep -f 'check.mjs' (6): the waiting shell's own
command line matches the pattern, so it never ends. Twenty minutes reported as
"still running". Run gates in the foreground; if you must background one, wait
the PID you spawned. Never pgrep -f a string that appears in your own command.
npx vitest run … | tail -8 (7): the pipeline's status is tail's. Exit 0
with three tests failing. No pipe after a gate — redirect to a file and read it.
Already mechanized — call it, do not restate it
| Property |
Owner (do not duplicate) |
tag @experimental ⇒ experimental_ name |
ESLint local/experimental-name (in npm run lint) |
./eval symbols carry paid_ |
npm run exports:check (in npm run check) |
| surface drift vs committed snapshot |
npm run api:check (in npm run check) |
docs' ts blocks import real exports |
npm run docs:check — only once a doc EXISTS (3) |
every CLI verb mentioned in docs/ |
src/doc-command-coverage.ts — verbs only, not API symbols |
| the whole command list |
npm run check (node scripts/check.mjs --list prints it) |
4. Done means
The five ✓ lines, a ✓ GATE line, and the PR body naming door · stability
decision · doc section. A feature nobody can import is not a feature, and one
nobody can navigate to is not documented.
1---2name: ship-a-feature3description: Ship a NEW capability to vigiles so it actually reaches a user — a new export, public function, subpath, CLI flag, or hook-vocabulary word. Use when about to add, or having just added, a feature / export / public API to this repo. Runs the executable checks a green `npm run check` does NOT make — reachable from a public door, API-surface diff READ, a public doc home, the experimental_ decision, a gate on a frozen tree. NOT for bug fixes, refactors, doc-only or test-only edits.4---5
6# Ship a feature (so it ships, not half-ships)
7
8The nine-row definition-of-done already exists — `cohesive-feature-delivery` in
9`CLAUDE.md`. On 2026-09-02 `equivalentDisasters` went out tested, gated and
10committed, and failed **four** of those rows unnoticed: unreachable from every
11public door, a neighbour's JSDoc silently stolen, no doc, no stability mark.
12Prose in a 2000-line file does not fire. This does. **Every check below names
13the failure it was written for; nothing here is theory.**
14
15## 1. Before writing — three decisions only you can make
16
17| Decision | Criterion |
18| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19| **Which door?** (`package.json` `exports` subpath) | Free + deterministic → `.` (`src/test.ts`). Bills a model → `./eval` (`paid_` prefix). Hook vocabulary → `./hook`. A symbol not re-exported from a door's barrel **does not exist** to `require("vigiles")` (failure 1). |
20| **Stable or `experimental_`?** | STABILITY.md: exported + not stable ⇒ `@experimental` tag AND `experimental_` name. One day old with one consumer is not stable. ESLint `local/experimental-name` holds tag→name; it cannot write the tag (failure 4). |
21| **Where does the WHY live publicly?** (`document-the-why`) | A `docs/*.md` section beside the claim it changes — for `equivalentDisasters`, next to "blocks 7/7" in `docs/compiled-hooks.md`. Not the README (brevity), not this file (contributor tier). |
22
23Write the answers into the PR body. The script below refuses to pass the
24stability row until you state it.
25
26## 2. After writing — one command, five checks
27
28```sh
29npm run build
30node .claude/skills/ship-a-feature/scripts/ship-check.mjs <symbol> [--stable "<why it is stable>"]
31```
32
33| Check | What it executes | Measured failure it exists for |
34| -------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
35| **REACHABLE** | `import()`s every `exports` subpath from the built `dist/`; the symbol must be a key on ≥ 1 | `equivalentDisasters` in `src/guardrail-check.ts`, never in `src/test.ts` → `undefined` (1) |
36| **SURFACE** | regenerates `api-surface/` (`api:report`), then READS `git diff`: any `+… (undocumented)` fails | inserted between `verifyGuardrail`'s JSDoc and its declaration; the diff said so, nobody read it (2) |
37| **DOCUMENTED** | `\b<symbol>\b` must occur in `docs/**/*.md` or `README.md` | no public home until asked (3) |
38| **FINDABLE** | two pages must name it — or one names it and another LINKS to that section's anchor | documented once, inside a guide about a DIFFERENT feature, with no inbound link (5) |
39| **MARKED** | declaration's JSDoc has `@experimental`, or the name is prefixed, or you passed `--stable "<why>"` | exported one day old, no tag, no prefix; the lint rule had nothing to hold (4) |
40
41**DOCUMENTED and FINDABLE are different questions.** The first asks whether the
42capability is written down anywhere. The second asks whether a reader who does
43not already know which file to open can get to it. `experimental_equivalentDisasters`
44(since renamed `experimental_alternateSpellings`) passed the first and failed the second: it was explained once, under its own
45heading, inside the guide for a different feature, and nothing linked there — so
46the testing guide, where someone asking "does my guard actually block?" looks,
47named it zero times. A pointer satisfies FINDABLE; you do not have to repeat the
48explanation.
49
50`✗` means fix and re-run; the script prints the fix beside each finding. A `✓ MARKED
51stable` line carries your reason — paste it into the PR body.
52
53## 3. The gate — on a FROZEN tree, in the foreground, with its real exit code
54
55```sh
56node .claude/skills/ship-a-feature/scripts/ship-check.mjs --gate # = npm run check, frozen
57```
58
59It hashes the tree before and after and fails if anything changed while the
60gate ran. Three measured ways a gate lies, all closed by running it this way:
61
62- **Editing during the run** (5): `npm run check` linted a half-written file and
63 failed on errors that belonged to no state. Do not touch the tree until it exits.
64- **Waiting with `until ! pgrep -f 'check.mjs'`** (6): the waiting shell's own
65 command line matches the pattern, so it never ends. Twenty minutes reported as
66 "still running". Run gates in the foreground; if you must background one, `wait`
67 the PID you spawned. Never `pgrep -f` a string that appears in your own command.
68- **`npx vitest run … | tail -8`** (7): the pipeline's status is `tail`'s. Exit 0
69 with three tests failing. No pipe after a gate — redirect to a file and read it.
70
71## Already mechanized — call it, do not restate it
72
73| Property | Owner (do not duplicate) |
74| ------------------------------------------ | ----------------------------------------------------------- |
75| tag `@experimental` ⇒ `experimental_` name | ESLint `local/experimental-name` (in `npm run lint`) |
76| `./eval` symbols carry `paid_` | `npm run exports:check` (in `npm run check`) |
77| surface drift vs committed snapshot | `npm run api:check` (in `npm run check`) |
78| docs' `ts` blocks import real exports | `npm run docs:check` — only once a doc EXISTS (3) |
79| every CLI verb mentioned in `docs/` | `src/doc-command-coverage.ts` — verbs only, not API symbols |
80| the whole command list | `npm run check` (`node scripts/check.mjs --list` prints it) |
81
82## 4. Done means
83
84The five ✓ lines, a `✓ GATE` line, and the PR body naming door · stability
85decision · doc section. A feature nobody can `import` is not a feature, and one
86nobody can navigate to is not documented.