Tier 3 of 4 · Shared system

Apps with a shared design system

Several apps share one design system, so shared parts must stay shared. Every rule notes who approved it, and each app gets its own progress number.

3 to 8 people · A year or more+ A SQL database

Paste into your coding agent
Set this repo up to ntent tier 3.

Fetch https://ntent.app/r/tier/3.json?with=database and follow the plan in it exactly. Write every file verbatim, and check each file's sha256 against its step before you write it. That plan covers tiers 1 to 3, so it is everything, not just the tier 3 part. It also carries steps that follow from what the product is: it stores data in postgres, sqlite or supabase. Those are as required as the tier's own.

Skip any step whose "needs" this repo does not satisfy, and do not install a framework just to satisfy one. The backend-deps step is the one exception: it adds the pinned devDependency the database check runs on. Run each step's verify line before you call it done, write the manifest the plan describes, then tell me what you installed and what you skipped.

Reads https://ntent.app/r/tier/3.json?with=database

  • 16checks that block the commit
  • 5checks that only warn
  • 9steps a person checks
  • 16files, notes and settings

The full plan46 steps

Your agent follows this, so you don’t need to read it. It includes everything from tiers 1 to 3, plus 5 steps for what the product does. Open any step to see the file it writes and how to tell it works.

Tier 1

Prototypes & landing pages

8 steps · 01–08
  1. 01
    Shared project instructionsA person checks this

    Everyone inventing their own conventions, and every coding agent inventing a different set again. It also says in words what the gates assume: a failing test is a finding, and the test is not edited to get a commit through.

    Details

    writes AGENTS.md · point CLAUDE.md at it with a one-line @AGENTS.md import

    Edit it: It is a template. Replace every <angle bracket>, delete the sections marked for tiers above yours, and make exactly these other edits. Every command in it is the one a step installs, a file in scripts/ run with node. (1) Delete each table row, and each hard rule, whose command names a scripts/ file that is not on disk: the step was skipped or is not in this plan. Rule 1 goes with check-locale-keys.mjs, rules 5 and 6 with check-api-routes.mjs, rule 7 with check-env.mjs. (2) Rules 2, 3, 4 and 8 are enforced by `pnpm lint` through scripts/eslint-rules.mjs. Keep them only if that file is on disk and wired into the lint config, otherwise delete them. (3) Keep the `pnpm typecheck` and `pnpm lint` rows only if package.json defines those scripts, under the names it uses. Do not add a script to make a row true. (4) Renumber the rules that are left and change every "Rule N" cell in the table to match. (5) Delete the doctor line under "Running it" if scripts/doctor.sh is not on disk, and write the repo’s own install and dev commands in the two angle brackets there. (6) Where the template gives a path (src/env.ts, messages/, <lib dir>), write the repo’s own. Change no other prose.

    How to tell it works: Ask your agent "what are the rules in this repo" and it answers from the file.

  2. 02

    2 rule files that disagree, because one tool reads CLAUDE.md and another reads AGENTS.md.

    Details

    Skipped unless the repo has AGENTS.md.

    How to tell it works: Run it twice: the second run says there is nothing to do. Then start a session and ask your agent to name a rule that only exists in AGENTS.md. In Claude Code, /context lists CLAUDE.md under Memory files. Copy a line of AGENTS.md into CLAUDE.md and run it again: it names that line and leaves it where it is.

  3. 03
    Pre-commit checksBlocks the commit

    A mistake caught an hour later on the build server, after someone has already spent time reviewing it. This catches it on your machine, before the commit.

    Details

    writes scripts/git-hooks/pre-commit · make it executable

    How to tell it works: Stage a file that fails a check this tier installs and the commit is refused, naming the check and the fix. At tier 1 that is check-env: add a variable to the env schema and not to .env.example. The hardcoded-colour case needs the tier 2 lint rules. From tier 2, edit a test file a built journey cites in contract.json and stage only that file: the contract gate runs anyway and refuses the commit, naming the journey. Rename or delete it instead and the same happens. Accept the journey again in the same commit and it passes, with a notice under the passed line that the test and the record moved together. The notice can be avoided by spreading the change over several commits, and a merge or a rebase does not run this hook at all: CI is the check there.

  4. 04

    Git hooks that only exist on one person’s machine, so a teammate or a coding agent commits without the checks.

    Details

    merges into package.json · inside the "scripts" block

    Edit it: Say which of these this repo is, and do that one. (1) No package.json at the root (a Python, Go or Rust repo): add no prepare line. Run `git config core.hooksPath scripts/git-hooks` once and put that line in the setup step the repo already documents (README, Makefile, a bootstrap script), because the setting is local to a clone. (2) package.json already has a `prepare` script: merge, never replace. Keep the existing command first and append ours after a `;`, so `"prepare": "husky; <our line>"`. The line ends in `exit 0`, so it has to be last. (3) Husky (a .husky/ folder, or core.hooksPath pointing at .husky/_): leave the path alone and chain. Add one line to .husky/pre-commit, after the husky.sh line if the file has one: `bash scripts/git-hooks/pre-commit || exit 1`. It says bash, not sh, because the hook is a bash script and on Debian and Ubuntu `sh` is dash, which fails it on every commit. (4) A .githooks/ folder, or any other core.hooksPath: same chain, one line in that folder’s pre-commit, creating the file with a `#!/bin/sh` first line if there is none. (5) The Python pre-commit framework (.pre-commit-config.yaml): do not set core.hooksPath, because `pre-commit install` refuses to run once it is set. Add a local hook under `repos:` instead: `- repo: local`, `hooks:`, `- id: ntent`, `name: ntent checks`, `entry: scripts/git-hooks/pre-commit`, `language: system`, `always_run: true`, `pass_filenames: false`. In every case where you chained rather than set the path, the pre-commit step still goes to scripts/git-hooks/pre-commit. Then, whatever the case, run `git check-ignore -v scripts/ AGENTS.md scripts/git-hooks/pre-commit`. A line of output means .gitignore hides it and the install would never be committed: add a negation line for it (`!scripts/`, `!AGENTS.md`), run the command again until it prints nothing, and say what you changed.

    How to tell it works: Run the install, then `git config core.hooksPath` prints scripts/git-hooks. A fresh clone gets the same answer with no manual step. The line cannot fail an install: with no .git directory it prints nothing and exits 0, and where core.hooksPath already points somewhere else it prints one line to stderr and exits 0. Leave .git/hooks alone: it may hold hooks somebody else installed. Where the repo had hooks of its own (Husky, .githooks, a Python pre-commit config), do not trust the config to tell you they work. Stage a change that breaks a gate and commit it: the commit must be refused, naming the gate. Say which of the five cases in customise you took.

  5. 05

    A setting the app needs that is missing from .env.example, so someone who downloads the repo cannot start it and has no idea why. Also the reverse, and code that reads process.env directly instead of through the schema. And a secret that reaches the browser: a service-role key behind NEXT_PUBLIC_ or VITE_, or read in a client component.

    Details

    writes scripts/check-env.mjs

    Edit it: Point CONFIG.schemaFile at this repo’s env schema, CONFIG.exampleFile at its example file, and CONFIG.sourceDirs at the directories it keeps source in, if they are not src/env.ts, .env.example and src. The pre-commit hook reads those three back out of the file, so moving them keeps the gate wired.

    How to tell it works: Add a variable to the schema, do not add it to .env.example, and the check names it. Rename a key to NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY and the check names it as a secret in the client bundle.

  6. 06
    Credential scanBlocks the commit

    A real token pasted into a script, a doc or a fixture and committed. .gitignore only protects the paths it names, and a token that reaches history is in every clone and may already be in a model’s context.

    Details

    writes scripts/check-secrets.mjs

    Edit it: It needs the gitleaks binary (brew install gitleaks). Without it the hook warns and passes, and doctor names the gap. Add one CI step that runs `node scripts/check-secrets.mjs --all` after a full-history checkout (fetch-depth: 0), which fails if gitleaks is missing. Add a .gitleaks.toml only when the default rules read one of your own identifiers as a secret, and allowlist that exact shape, with a description.

    How to tell it works: Stage a file holding a token-shaped string and the commit is refused with the finding redacted, telling you to rotate the credential as well as delete the line. A finding means rotate it, not just remove it: a token that reached a diff may already be in a model context, a log or a clone. Unstage it and the commit goes through. On a machine without gitleaks the commit passes with a warning that the diff was not scanned; in CI the same run fails.

  7. 07

    Test that a missing gitleaks warns locally and fails in CI, and that a staged token is refused, so an edit to the checker cannot quietly stop it scanning.

    Details

    writes scripts/check-secrets.test.mjs

    Skipped unless the repo has check-secrets.mjs.

    How to tell it works: node --test passes with or without gitleaks installed: the cases that need the binary are skipped, with the reason printed. Stage an edit to check-secrets.mjs that drops --redact and the commit is refused, naming this file.

  8. 08
    Decision logA person checks this

    The same decision being re-made in month 6 because nobody remembers the reason for the first one.

    Details

    writes DECISIONS.md

    Edit it: The first entry is an example from a billing app: delete it. Then write the decisions this project has actually made, one entry each, and label any whose source is memory rather than a thread or a call as "(from memory)". A fresh project may have none yet, and a file with only the template entry in it is correct.

    How to tell it works: Every entry names a real decision, who agreed it and when. Nothing in the file is invented, and an entry reconstructed from memory says so.

Tier 2

A product with a fixed scope

21 steps · 09–29
  1. 09

    Hardcoded colours, font sizes and interface strings. Spacing that does not adapt to right-to-left layouts.

    Details

    writes scripts/eslint-rules.mjs · import it from eslint.config.mjs

    Edit it: Wire it into eslint.config.mjs the way the header of the file shows, and make these edits. (1) Merge, never add a second block: flat config keeps only the LAST block that sets a rule, so a second no-restricted-syntax block silently replaces the repo’s own. If the config already sets no-restricted-syntax, put its existing entries into the same array as designRules and copyRules and keep one block. Count the lint warnings before and after wiring; a count that drops means a block replaced theirs. The same goes for no-restricted-imports: see the import-boundaries step. (2) If this plan has no check-locale-keys.mjs, the repo has no t(), so delete the first entry of copyRules in scripts/eslint-rules.mjs (the aria-label, placeholder and alt rule that demands t()). Left in, it errors on every string in the app: 102 errors in one repo. Keep the em dash entry. (3) typescript-eslint must be a direct dependency and eslint must be importable from the install root, or the probes cannot load the config. Run `pnpm add -D eslint typescript-eslint` (npm: `npm install -D eslint typescript-eslint`, yarn: `yarn add -D eslint typescript-eslint`, bun: `bun add -d eslint typescript-eslint`) for whichever of the two the repo lacks as a direct dependency, then `node -e "import('eslint')"` from the root must print nothing. (4) Run lint once and report the error count. Do not fix what it finds in this install.

    Skipped unless the repo has ESLint flat config.

    How to tell it works: Write text-[13px] in a component. pnpm lint fails and points at it. In a repo that already linted, the warnings it had before are still there after: wiring the rules replaced nothing.

  2. 10

    A rule everyone believes is on, which has been silently off since a config change.

    Details

    writes scripts/probes/rules.probe.tsx

    Edit it: If you deleted the t() rule from copyRules (no locales in this plan), delete the three copy probes for it, each FAIL line and its OK twin: the placeholder, aria-label and alt ones. A probe for a rule that is not there reads as a rule that stopped firing. Change nothing else, and never delete a FAIL line on its own: a rule left with no FAIL line is refused.

    How to tell it works: check-probes reports one hit per rule, and zero false hits. Delete the only FAIL line a rule has, such as `tokFail3`, and leave the rule, and check-probes exits 1 naming the rule. Where a rule has a second FAIL line, deleting one is not noticed.

  3. 11
    Lint rule probesBlocks the commit

    Lint rules that have quietly stopped working: they miss what they should catch, complain about good code, or are switched off in one package. Also a rule whose probe line was deleted, so nothing would notice the day it stopped.

    Details

    writes scripts/check-probes.mjs

    Edit it: CONFIG.PACKAGES ships with one entry for a single-package repo, naming src/app/layout.tsx. Point probeFile at a real source file in this repo: app/layout.tsx, src/main.tsx, whatever exists. In a monorepo add one entry per package that lints, each naming a real source file in it. The probes import eslint and typescript-eslint, so both must be direct dependencies, importable from the root where this runs (`pnpm add -D eslint typescript-eslint`, or `npm install -D`, `yarn add -D`, `bun add -d`). A repo that only has them nested under another package fails here with a module-not-found, and 13 of 48 did.

    Skipped unless the repo has eslint-rules.mjs, rules.probe.tsx.

    How to tell it works: Break a rule pattern on purpose and the probe fails even though lint passes. Take the rules out of one package config and it names that package and says how many rules run nowhere in it. Delete the only FAIL line a rule has (`tokFail3`, the arbitrary font size one, or the server-import line) and it fails with RULE HAS NO PROBE, naming the rule; delete the rule with it and it passes. A rule with two FAIL lines still passes with one of them gone. Rules are told apart by their message, compared whole, so two rules with the same message share a probe.

  4. 12

    Rule exceptions nobody can see or date. Each one records a reason, an owner and an expiry, so reviewers can check it later.

    Details

    writes scripts/lib/waivers.mjs

    Skipped unless the repo has a checker of your own to import it.

    How to tell it works: Waive a line with no reason and the run prints NO REASON GIVEN against it. Fix the underlying value and leave the waiver, and the next run names it as stale and tells you to delete it. Date one `until` yesterday and the finding comes back, naming the waiver that ran out.

  5. 13
    Design token checksBlocks the commit

    Design token names that do not exist in the stylesheet, so the style silently does nothing. Also colours, sizes, animation timings and stacking layers typed in by hand instead of taken from the tokens.

    Details

    writes scripts/check-tokens.mjs

    Edit it: Point CONFIG.stylesheets at the generated stylesheet this repo actually ships, set CONFIG.scanDirs to this repo’s source directories if they are not src and packages (an app with no src/ lists app, components and lib), and list a family in CONFIG.ownedFamilies only once your tokens replace the framework’s scale for it.

    Skipped unless the repo has a generated stylesheet, waivers.mjs.

    How to tell it works: Write text-fg-nope next to a real text-fg-muted. The check names it, says it resolves to nothing, and lists the nearest real tokens. Delete every token in a family and it says that family is no longer checked rather than passing. Write transition: color 0.18s beside a --dur-state: 180ms token and it names the token.

  6. 14

    Test that each of the five token rules convicts its target and leaves its near miss alone, so an edit to the checker cannot quietly move the line.

    Details

    writes scripts/check-tokens.test.mjs

    Skipped unless the repo has check-tokens.mjs.

    How to tell it works: node --test passes. Stage an edit to check-tokens.mjs that stops z-[999] being caught and the commit is refused, naming this file.

  7. 15
    Colour contrast checksBlocks the commit

    Two real tokens that are unreadable together, like muted text on the muted fill, in light or dark. Also a colour role that no pair measures, which was never passing, only never asked.

    Details

    writes scripts/check-contrast.mjs

    Edit it: Point CONFIG.stylesheets at the stylesheet this repo ships, then make CONFIG.pairs the foreground and background pairs your UI actually draws. The defaults are shadcn’s role names. Allowlist a colour role only with the reason it has no pair. Then run node scripts/check-contrast.mjs once and record every failing pair as a CONFIG.debt entry: the pair, the mode, today’s date as recorded, the measured ratio as ratio, and a reason saying what it costs a user. Set no until date. The install lands green, and the debt prints on every run until someone fixes the colour.

    Skipped unless the repo has a generated stylesheet.

    How to tell it works: Set --muted-foreground to a grey too light for --muted. The check names the pair, the mode, the ratio against its 4.5 floor and both colours. Add a colour role to the stylesheet with no pair and it is reported as unpaired. Record the failure in CONFIG.debt and it passes, printing the debt; set its until date to yesterday and it fails again.

  8. 16
    API route checksBlocks the commit

    A server endpoint that accepts form data without checking it, does not check who is asking, or has no limit on how often it can be called. Your forms talk to these directly, so a gap here is a gap in the UI.

    Details

    writes scripts/check-api-routes.mjs

    Edit it: Set CONFIG.routeDirs to where this repo keeps its handlers, and add your own validation, auth and rate-limit helper names to the three matcher lists.

    How to tell it works: Add a POST handler with no schema parse. The check names the file and the missing piece.

  9. 17
    Real data validationBlocks the commit

    The app expects data in one shape and the server now sends another. On screen that looks like a column of NaN, an empty list when there is data, or Invalid Date.

    Details

    writes scripts/verify-fixtures.mjs · put real captured payloads in fixtures/

    Edit it: It ships pointed at fixtures/ and src/lib/schemas/index.js. Change CONFIG.fixtureDir and CONFIG.schemaModule if this repo keeps either somewhere else.

    Skipped unless the repo has Zod schemas, at least one captured payload.

    How to tell it works: fixtures/invoice.json parses with InvoiceSchema. Change a field type in the schema and the check prints the field path and the reason. Replace a fixture with `[]` and it fails: an empty batch exercises nothing.

  10. 18
    Message placement rulesA person checks this

    The same message written 2 different ways in one product. Or a pop-up toast for a problem that is still there after the toast fades away.

    Details

    writes src/lib/errorToSurface.ts

    Edit it: Put it beside the repo’s existing lib directory instead of creating src/: if the repo has lib/, app/lib or src/lib, write it there (and its test next to it), and say which. Create src/lib only if the repo already has a src/ directory and no lib anywhere. Reformat it with the repo’s own formatter and lint fixer (prettier, Biome, or eslint --fix, whichever the repo runs on commit), because the file as shipped fails most hosts’ style rules, and that reformatting is the only licensed change to the code. Do not rename anything in it. Then fix the paths named in the test and in AGENTS.md to match where the file went.

    How to tell it works: Every message in the product resolves to one of 4 surfaces: full-page empty state, inline, persistent banner, toast.

  11. 19

    A message rule that reads well in a doc and does something else in code.

    Details

    writes src/lib/errorToSurface.test.mjs

    Edit it: Beside the file it tests, wherever error-to-surface put it, with the import path changed to match. Reformat it with the repo’s own formatter. Change nothing else.

    How to tell it works: node --test passes.

  12. 20

    "Mostly done" as an answer. With this you can say the screens lift 80% of the model, here are the 3 named gaps, and one journey has no UI at all.

    Details

    writes contract.json

    Edit it: It is an example billing product. Keep the shape and replace the actors, entities, journeys, rules, constraints and open questions with this product’s own. Delete the example acceptance records: they describe journeys you have not built. When a journey of yours is accepted against a test file, put the file’s sha256 under `pins` in its record: `node scripts/check-contract-coverage.mjs --pins <journey>` prints the block. The example cites no test file, so it needs none.

    How to tell it works: The coverage check runs and prints a number, and `--refs` says the references resolve. Start with entities and journeys; add a pillar when you can name the screen it changes. Mark a journey built only with its acceptance record beside it. Cite a test file with no pin and `--refs` refuses it, naming the journey and the command that prints the pin.

  13. 21
    Access rulesA person checks this

    Permissions and row access spread across screens, so gated navigation, empty lists and disabled actions disagree. This puts them in one place.

    Details

    merges into contract.json · at the top level, after actors

    How to tell it works: Point at a new route and ask which matrix row permits it. If the answer needs a conversation, the row is missing.

  14. 22

    Shows how much of the model is in the code, as 3 separate numbers: fields in the types, fields in real data, and fields the screens mention. Also catches the model contradicting itself: duplicate ids, links to things that do not exist, and a journey marked built while its questions are open or with no note of who saw it work. And a test edited after a journey was accepted on it: the acceptance record pins each cited test file, and a file that no longer matches is refused.

    Details

    writes scripts/check-contract-coverage.mjs

    Edit it: The maps at the top of the file describe an example billing app. Replace CONFIG.entityTypeMap, CONFIG.entityUiMap and CONFIG.fieldAliases with this product’s own entities, and point typeDirs/fixtureDirs/uiDirs at its directories.

    Skipped unless the repo has contract.json, TypeScript.

    How to tell it works: It prints a table with a percentage per entity, names the gaps, and says how many of the contract’s fields the number covers. Under --check an unmapped entity fails until you map it or excuse it with a reason. Set minCoverage just below your current number so it fails when coverage goes backwards. `--refs` is the gate half: it runs in milliseconds, exits non-zero on a duplicate id or a broken reference, and every finding names its fix. None of it says a journey works: that is the journey’s state, set by hand, and `--refs` refuses `built` unless an acceptance record sits under it: who exercised it, when, at which commit, against what evidence. An evidence line is a test file that exists, `pnpm <script>` for a script in package.json, or `manual:` and what a person saw; a built journey that depends_on an agreed rule must name its id in the evidence or carry a manual line. It does not run the test. It does pin it: `accepted.pins` holds the sha256 of each cited test file, written `sha256:<hex>` so the credential scan does not read it as a key, `--refs` recomputes it, and `--pins <journey>` prints the block to paste and writes nothing. To see it work, accept a journey on a test file, change one character in the test and run `--refs`: it exits 1 saying the file has changed since the journey was accepted, and it does not print the new hash. A person accepts the journey again, or the edit is reverted. `--print-config` lists the cited files, which is how the commit hook knows to run this when only the test is staged. A cited path has no space in it, is spelt exactly as the file is, and is not a symlink; `--refs` refuses the others and says what to write. Limits: `pnpm <script>` and `manual:` lines cannot be pinned, an agent can move the pin itself (the diff shows the record changing), or stop citing the test in one commit and edit it in the next, a test that was weak when accepted still matches, and a test no journey cites is not watched.

  15. 23

    Test that each contract check catches its target issue and stays quiet on valid cases.

    Details

    writes scripts/check-contract-coverage.test.mjs

    Skipped unless the repo has check-contract-coverage.mjs, contract.json.

    How to tell it works: node --test passes. Break one reference in the contract by hand and exactly one test goes red, naming the check that caught it. Stage an edit that breaks check-contract-coverage.mjs and the pre-commit hook runs this file and refuses the commit. When that happens the check is what gets fixed: the test changes only when the person has said the rule moved.

  16. 24

    A decision that names a file somebody has since renamed or deleted, so the reasoning can no longer be followed to the code and gets decided again. Also checks the session notes and the build log.

    Details

    writes scripts/check-decision-refs.mjs · make it executable

    Edit it: Point CONFIG.decisionLogs, CONFIG.stateFiles and CONFIG.buildLogDir at where this repo keeps its notes, if they are not DECISIONS.md, STATE.md and docs/build-log. Add to CONFIG.roots any top-level source directory it does not list.

    Skipped unless the repo has DECISIONS.md.

    How to tell it works: Name a real file in backticks in a decision, then rename that file with git mv and commit. The check names the entry, the commit that renamed it and the new path, and prints the Moved line to add. Add that line under the entry and it passes, with the entry itself unchanged. It says nothing about whether the decision is still right.

  17. 25

    Project instructions that no longer describe the repo. A command nobody can run, a check the enforcement table leaves out, a row saying pre-commit for a gate the hook never runs, a hard rule and the table naming different commands, and a CLAUDE.md that has drifted from AGENTS.md.

    Details

    writes scripts/check-rules-doc.mjs

    Edit it: Point CONFIG.rulebook, CONFIG.tableHeading and CONFIG.hook at this repo’s own if they are not AGENTS.md, a "What enforces what" section and scripts/git-hooks/pre-commit.

    Skipped unless the repo has AGENTS.md.

    How to tell it works: Name `pnpm ship:check` in AGENTS.md with no such script in package.json and the commit is refused, naming the command and the line. Add a scripts/check-*.mjs the table never mentions and it names the file. While AGENTS.md is still the unfilled template it says so and lets the commit through, rather than passing it or blocking setup.

  18. 26

    A commit made with --no-verify, and a project’s own tests, which the hook is too quick to run. This runs them, and every installed checker, on each pull request and on main, where nobody can skip them quietly.

    Details

    writes .github/workflows/ci.yml · GitHub Actions. On another host, port the steps

    Edit it: Nothing for a pnpm repo with a packageManager field in package.json. Without that field, add "packageManager": "pnpm@<version>" there. If ntent was installed in a package folder rather than the repo root, set defaults.run.working-directory to that folder. A checker that is not installed is skipped and named in the log; when you add one, its line is already here.

    How to tell it works: Open a pull request: the project’s test, typecheck and lint scripts run, then each installed checker. Break one of the references in contract.json and push, and the "Installed checkers" step fails naming check-contract-coverage.mjs. The same step recomputes the pins, so a test edited under an accepted journey fails here when it was committed with --no-verify, or arrived by a merge or a rebase, neither of which runs the commit hook. It runs on pull requests and on pushes to main, not on every push to a branch.

  19. 27

    A broken local setup or missing generated files that a new contributor has to find out by hand. This gives them a list of what to fix.

    Details

    writes scripts/doctor.sh · make it executable

    How to tell it works: Run it on a fresh clone. Every line is a pass, or names the one thing to fix.

  20. 28

    Generated files older than their sources, so you work from the previous build without knowing. Also reports problems that cannot block offline work.

    Details

    writes scripts/session-status.sh · make it executable

    How to tell it works: It prints when someone opens the repo, which is the moment they can act on it. Touch a token source without rebuilding and it tells you that you are looking at the previous build.

  21. 29
    Proof of doneA person checks this

    "Done, it works" that nobody can check, because the message says what was verified and not how.

    Details

    merges into AGENTS.md · as a new section

    How to tell it works: Pick any row, run its command, and get the result it gives. A row with no command starts with "manual:" and says what was looked at.

Tier 3

Apps with a shared design system

12 steps · 30–41
  1. 30
    Translation key checksBlocks the commit

    A t('key') with no entry in the base locale. A key in the base locale missing from a translation. An incomplete plural set.

    Details

    writes scripts/check-locale-keys.mjs

    Edit it: Set CONFIG.sourceDirs to the directories this repo calls t() from, if they are not src (an app with no src/ lists app, components and lib).

    Skipped unless the repo has more than one language, or the certainty you will have one.

    How to tell it works: Add a t() call with a new key and no entry. The commit is refused. Before messages/ exists it says there is nothing to check yet and lets the commit through, so taking the locales capability on day one costs nothing.

  2. 31

    Test that a t() key with no entry is caught, that a prop merely named like a key is not, and that a locale format the checker cannot read is exit 2 rather than a pass.

    Details

    writes scripts/check-locale-keys.test.mjs

    Skipped unless the repo has check-locale-keys.mjs.

    How to tell it works: node --test passes. Stage an edit to check-locale-keys.mjs that stops a missing key being caught and the commit is refused, naming this file.

  3. 32
    Shared component checksBlocks the commit

    Shared components left in route folders, and duplicate component names. An exception needs a reason and an expiry date.

    Details

    writes scripts/check-stray-components.mjs

    Edit it: Point CONFIG at this repo’s route and shared-component directories if they are not src/app and src/components.

    How to tell it works: Import a private component from a second route and the check tells you to promote it. Allowlist it and the reason and date print on every run afterwards, rather than the finding disappearing. Backdate the entry and it fails.

  4. 33

    Test that a private component reused from a second route is caught, and that stories, tests, Next special files and package imports of the same name are left alone, so an edit to the checker cannot quietly move the line.

    Details

    writes scripts/check-stray-components.test.mjs

    Skipped unless the repo has check-stray-components.mjs.

    How to tell it works: node --test passes. Stage an edit to check-stray-components.mjs that stops a reused private component being caught and the commit is refused, naming this file.

  5. 34

    An overflow clip nobody can explain, which cuts off focus rings, shadows and descenders without an error. Each clip needs a comment saying what it holds in.

    Details

    writes scripts/check-clip-comments.mjs

    Edit it: CONFIG.ignorePaths skips src/components/ui, where shadcn vendors its primitives. Delete that entry once those files are yours, and point CONFIG.scanDirs at this repo’s source if it is not src.

    Skipped unless the repo has waivers.mjs.

    How to tell it works: Write overflow-hidden on a component with no comment near it. The commit is refused, naming the file and line. Put a comment above it saying what it clips and the same commit goes through. A file under src/components/ui is skipped, and every run says so.

  6. 35

    Test that every spelling of a clip is caught and every near miss is left alone, so an edit to the checker cannot quietly stop asking.

    Details

    writes scripts/check-clip-comments.test.mjs

    Skipped unless the repo has check-clip-comments.mjs.

    How to tell it works: node --test passes. Stage an edit to check-clip-comments.mjs that stops overflow-hidden being caught and the commit is refused, naming this file.

  7. 36

    Find what only exists once a page is rendered: a page that scrolls sideways, text cut off with no ellipsis or pushed off the screen, a focus ring an ancestor clips, text that fails contrast on the surface it actually landed on, a translation key on screen, and right-to-left leaks. Orphan words are reported separately and never fail the run.

    Details

    writes scripts/check-screens.mjs

    Edit it: Set CONFIG.baseUrl to where this app runs locally and CONFIG.routes to the routes worth measuring. If dark mode is an attribute or a class rather than the system setting, set CONFIG.theme.strategy to match.

    Skipped unless the repo has Playwright with Chromium installed, a web app to run it against.

    How to tell it works: Start the app and run it with no arguments: it prints each route with its findings, or says it is clean and names what it could not check. Put a fixed-width box wider than 390px on a page and it names the box and says the page scrolls sideways. Stop the app and it exits 2, saying nothing answered, rather than reporting a pass.

  8. 37
    Import boundariesBlocks the commit

    Server code pulled into code that runs in the browser, which can send your secret keys to every visitor. Also real code depending on something in the explorations sandbox, so a design that breaks the rules on purpose cannot leak into the product.

    Details

    merges into eslint.config.mjs · the import at the top; the two blocks in the exported array, after the one holding the house rules

    Edit it: Merge, never add. Flat config keeps only the last block that sets a rule, so spreading boundaryRules next to a no-restricted-imports the repo already has REPLACES the repo’s: one repo went from 379 warnings to 0 that way. If the config already sets no-restricted-imports, wrap its entry in mergeBoundaryRules from scripts/eslint-rules.mjs, as in `{ rules: { 'no-restricted-imports': mergeBoundaryRules(existingEntry) } }`, which adds the two boundaries to its patterns and paths and keeps its severity. Count the warnings before and after: the number must not drop.

    Skipped unless the repo has eslint-rules.mjs.

    How to tell it works: Import a server util into a "use client" file, or import anything under src/explorations/ from a route. Lint refuses both by name, and an exploration importing a sibling exploration still passes. Narrow the sandbox pattern and check-probes names the import that stopped being caught.

  9. 38
    Rules by file pathA person checks this

    Project instructions too long to read, because every local rule sits in one file. This loads a rule only where it applies.

    Details

    writes .claude/rules/copy-a-sibling.md · one file per concern, named after the rule

    Edit it: It is a template. Set the paths it applies to and write the rule in this repo’s own terms.

    Skipped unless the repo has AGENTS.md.

    How to tell it works: Open a file the rule’s paths cover and ask your agent to state the rule. It answers without being shown the file.

  10. 39
    Source priorityA person checks this

    Two sources that disagree with no rule for which wins. This sets the priority and records the resolution where the team can find it.

    Details

    merges into contract.json · at the top level

    How to tell it works: 2 sources disagree, and the argument takes 30 seconds instead of a meeting.

  11. 40
    Rule approvalsA person checks this

    A rule nobody can trace to a person or a date. This keeps the approval beside each rule.

    Details

    merges into contract.json · on each entry in rules

    How to tell it works: Every rule names a person and a date, and quotes them where the wording matters.

  12. 41
    Product entitlementsA person checks this

    Paid features and quotas nobody wrote down, so access near the limit, at the limit and after a downgrade gets improvised.

    Details

    merges into contract.json · at the top level. A plan is not an entity

    How to tell it works: Every gate names what a person who has not paid sees. Ask the model which journeys have a paywall in them and get an answer before QA does.

Because

It stores data in Postgres, SQLite or Supabase

5 steps · 42–46
  1. 42

    Nothing on its own. It is the one dependency the Postgres checks run on (SQLite needs none): Postgres 17 compiled to WebAssembly, run in-process, so the check needs no Docker, no network and no running database.

    Details

    merges into package.json · inside the "devDependencies" block. Then run the repo’s own install command, so the lockfile records it

    How to tell it works: node -e "import('@electric-sql/pglite').then(() => console.log('ok'))" prints ok.

  2. 43
    Database access checksBlocks the commit

    A table any signed-in user can read in full, a policy that lets one customer read another customer’s rows, and tables that contradict the model: a required field in a column that allows empty, or a model entity with no table. It builds the database from the migrations in-process and tries the reads the model says must fail. The table checks run on Postgres, Supabase and SQLite; the read tests run on Postgres and Supabase only, because SQLite has no row security.

    Details

    writes scripts/check-backend.mjs

    Edit it: First run node scripts/check-backend.mjs --print-config. It searches the repo at any depth and prints the detected platform (and why), clientAccess, enforcement and, when it finds no inputs, ranked candidate lines. Accept the detected platform, or paste the top candidate as CONFIG.sources (a candidate line is a ready CONFIG sources line; where migrations apply in file order, use order: 'listed'). CONFIG.platform and CONFIG.enforcement default to auto, so accept what it detects: it ignores templates and examples, and sets postgres or supabase for you. On plain Postgres with no RLS anywhere in the schema, auto treats access as enforced in app code: exit 0, a note, and 'access NOT checked' in the summary line; set enforcement: 'app' to confirm. It never does this on Supabase. If the schema does have RLS on plain Postgres, set CONFIG.actAs to the role your app connects as, and the migrations must create that role and grant it the tables. A candidate for a SQLite or D1 schema carries dialect: 'sqlite'. If no client reaches the database directly (a server holds the only connection), set CONFIG.clientAccess to server-only. Access enforced in app code (a missing owner filter, an IDOR) is not checked by this script. In contract.json, give each entity you want checked a table, and set access.tenancy.column (and membership, if users join a tenant through a table). Before you set an entity's read scope, read the table's SELECT policies: a policy that is using (true), is named for the public or everyone, or filters only on a published-style column (is_published, active, status = 'published') means the table is probably public on purpose, so ask, or decide from the product, whether readers get scope all, and add a visitor actor (id visitor) with a read row at scope all when anon can read it. Take owner from the policy or foreign key that enforces ownership, not from the column name, and mark a field required only when the schema says NOT NULL. For Prisma, set CONFIG.sources to [{ kind: 'command', preset: 'prisma' }] (Prisma 7 also needs datasource.url set to process.env.DATABASE_URL in prisma.config.ts, even to print SQL), then let install fetch its schema engine: with pnpm 10, add "@prisma/engines" to pnpm.onlyBuiltDependencies in package.json; with pnpm 11, which ignores that key, add @prisma/engines: true under allowBuilds in pnpm-workspace.yaml; then run the install once online. For Drizzle set [{ kind: 'command', preset: 'drizzle' }], for Alembic [{ kind: 'command', preset: 'alembic', dialect: 'postgres' }] (alembic must be in .venv or on PATH, and the migrations must run offline), and for SQLite migration files [{ kind: 'sql', paths: ['db/migrations/*.sql'], dialect: 'sqlite' }]. SQLite and Turso have no row security, so the access gate says it did not check and the schema gate runs as normal. Add a package.json script "backend:check": "node scripts/check-backend.mjs --check", a row for it in the AGENTS.md enforcement table, and a CI step that runs node scripts/check-backend.mjs --check --allow-empty, so CI passes before the first migration exists. Then run node scripts/check-backend.mjs --check once. If the migrations do not load (a table made in the dashboard, a dump that does not run, a real SQL error), do not leave the commit blocked and do not drop the check: first try the escapes its LOAD-FAILED lines name (a base schema file first in CONFIG.sources, CONFIG.preamble, late: true, CONFIG.skipStatements). If it still does not load, set loadGate: 'warn' in CONFIG and tell the person plainly that the database is not being checked yet. Warn mode prints every load failure on every run and does not block. The first clean load exits 1 with LOAD-GATE-READY; set loadGate back to 'gate' and commit that, and from then on a migration set that does not load blocks the commit. Never set 'warn' on a repo that loads.

    Skipped unless the repo has a Postgres, SQLite or Supabase database, now or planned.

    How to tell it works: Write a migration that creates a table in public without enabling row level security, and the commit is refused naming RLS-OFF and the table. Enable RLS with a policy on auth.uid() and it passes. Map an entity to that table with scope own, change the policy to using (true), and the refusal says LEAK-READ: user A read a row that belongs to user B. A table the check cannot fill with test rows refuses the commit with the column it could not fill, never a pass.

  3. 44

    Test that the checker still fails a table with RLS off, still refuses a cross-tenant read, and still says it could not check when it cannot seed a table, so an edit to the checker cannot quietly turn it green.

    Details

    writes scripts/check-backend.test.mjs

    Skipped unless the repo has check-backend.mjs.

    How to tell it works: node --test passes. Remove the grant pre-check so a permission error on a probe counts as denied, and the missing-grants test fails.

  4. 45
    Migration decision checkBlocks the commit

    A migration that drops a table or a column, narrows a column’s type, or edits a migration that already ran on the base branch, with nobody having written it down. It compares the schema this branch builds with the base branch’s, on Postgres or SQLite, from SQL files or a Prisma, Drizzle or Alembic schema, and lets the change through only when DECISIONS.md names the migration file and the table or column.

    Details

    writes scripts/check-migrations.mjs

    Edit it: It reads the same CONFIG.sources as check-backend.mjs, so keep the two scripts together. Set CONFIG.decisionsFile if the decision log is not DECISIONS.md, CONFIG.baseRef if the base branch is not main or master, and add to CONFIG.mutable any file that is meant to change after it is committed, such as a declarative schema. While nothing is live, set CONFIG.stage to pre-production and findings print as notes. Add a package.json script "migrations:check": "node scripts/check-migrations.mjs --check", a row for it in the AGENTS.md enforcement table, and a CI step that runs node scripts/check-migrations.mjs --check --against origin/main, with full git history fetched (fetch-depth: 0).

    Skipped unless the repo has a Postgres, SQLite or Supabase database, now or planned.

    How to tell it works: Commit a migration that creates invoices with a pdf_url column. On a branch, stage a second migration with alter table public.invoices drop column pdf_url, and the commit is refused naming DROP-COLUMN and public.invoices.pdf_url. Add an entry to DECISIONS.md that names the migration file and `public.invoices.pdf_url` in backticks, and it passes. A decision that names only the column does not count.

  5. 46

    Test that the checker still refuses a dropped table or column, a narrowed type and an edited migration, still accepts a change that DECISIONS.md names, and still reads the index rather than the worktree, so an edit to the checker cannot quietly turn it green.

    Details

    writes scripts/check-migrations.test.mjs

    Skipped unless the repo has check-migrations.mjs.

    How to tell it works: node --test passes. Make the checker compare a column’s type only by name and the widening test fails.