Skip to main content

Templates

Ready-made files to copy into a project. Every config on this page is the exact file described in the standards — copy it, do not retype it.

Each file is also served as a plain file, so a person or an AI agent can fetch it directly instead of copying from this page.

Every config file is .mjs, even in a TypeScript project. Never convert one to .ts or .mts. It works in both, so every project looks the same. A tool that cannot read .mjs keeps its own format.

FileCopy toPlain file
AGENTS.mdAGENTS.md in the project root/templates/AGENTS.md
ESLint — TypeScripteslint.config.mjs in the project root/templates/eslint/typescript/eslint.config.mjs
ESLint — React + TypeScripteslint.config.mjs in the project root/templates/eslint/react/eslint.config.mjs
ESLint — Next.jseslint.config.mjs in the project root/templates/eslint/nextjs/eslint.config.mjs
ESLint — JavaScript / Node.jseslint.config.mjs in the project root/templates/eslint/base/eslint.config.mjs
Prettierprettier.config.mjs in the project root/templates/prettier.config.mjs
Prettier ignore.prettierignore in the project root/templates/.prettierignore
Stylelint — CSS and CSS Modulesstylelint.config.mjs in the project root/templates/stylelint/css/stylelint.config.mjs
Stylelint — SCSSstylelint.config.mjs in the project root/templates/stylelint/scss/stylelint.config.mjs
Global styles and colour paletteapp/globals.css/templates/globals.css
EditorConfig.editorconfig in the project root/templates/.editorconfig
Node version.nvmrc in the project root/templates/.nvmrc
direnv.envrc in the project root/templates/.envrc
Agent skills.agents/skills/ and .claude/commands/Eight files — see below
Docs sitedocsSite/ in the project rootSeveral files — see below
/docs routenext.config.mjs/templates/next.config.mjs
Document templatesdocs/ in the project rootSeveral files — see below

AGENTS.md​

Copy to: AGENTS.md in the project root

Replace every [placeholder]. See AGENTS.md Specifications.

AGENTS.md
# [Project Name]

> [The project's most important rule for agents, stated first so every agent sees it. Usually whether an agent may commit, push or make branches. One line, or a short section if it needs more, such as which agents may commit, when, and how the others ask. See "Project workflow" below.]

> **"go" means carry on.** When the person sends only "go", carry on with the current task. It is never permission to commit, push or open a Pull Request, and it needs no reply asking what they meant.

## Project overview

[One paragraph: what this project does, who uses it, and why it exists.]

## Repository layout

```text
[Directory tree at depth 2. Regenerate it after structural changes.]
```

## Tech stack

| Layer | Technology | Version |
| --- | --- | --- |
| Framework | [e.g. Next.js (App Router)] | [version] |
| Language | [e.g. TypeScript] | [version] |
| Styling | [e.g. Tailwind CSS] | [version] |
| Database | [database] | [version] |
| Hosting | [hosting platform] | — |

## Essential commands

| Task | Command |
| --- | --- |
| Install | `pnpm install` |
| Develop | `pnpm dev` |
| Build | `pnpm build` |
| Test | `pnpm test` |
| Lint | `pnpm lint` |
| Format | `pnpm format` |
| Type-check | `pnpm typecheck` |
| Docs site | `pnpm docs:dev` (then open `/docs`) |

## Project workflow

[How this project runs its work. These are the project's own decisions, not part of the code standards. Say where they are written up (usually `docs/development/`), then list the rules an agent must follow:]

- **Who commits**: [whether AI agents may commit, push or make branches, and when. If the person said yes to asking before committing during setup: "Ask the person before every commit and push, and wait for a clear yes to that specific action. A yes covers one action only." If they said no, leave the asking rule out]
- **Decisions**: [Optional. If the person said yes to being told about decisions during setup: "Tell the person about each coding decision you make that the code standards do not cover, in the summary at the end of the task." Delete this line if they said no]
- **Branches**: [how branches are named]
- **Commit messages**: [the commit message format]
- **Pull requests**: [review and CI requirements, and where Pull Requests merge to]
- **Merging and releases**: [merge strategy, and how work reaches production]
- **GitHub repo**: [Optional. https://github.com/organisation/repo, read from the git remote and confirmed with the person. Delete this line if the project does not use it]
- **Linear project**: [Optional. Workspace, team and project, such as `activatus-life`, team `ACT`, project Launchpad. The ticket prefix is the team's key, shared by every project in that team. Read it from the project's docs or asked for, and confirmed with the person. Delete this line if the project does not use Linear]

**Never copy a Linear workspace or ticket prefix from another project.** If the Linear project above is missing, ask before creating, linking or citing any ticket.

### Status circles

[Only if the person chose status circles during setup. Otherwise delete this section.]

Report every ticket or piece of work as one line: a status circle, the ticket ID as a link, a short title and, where there is one, its Pull Request as a link. The colour alone says whether the person needs to act.

| Circle | Means |
| --- | --- |
| ✅ | Merged, In UAT or Done. Nothing to do |
| 🟢 | Pull Request ready for the person to review and merge. The link is the Pull Request |
| 🟣 | Working on it |
| 🟡 | Ready to commit: done, checked, left uncommitted. Say the branch and where it is. The person commits it, or tells the agent to |
| 🟠 | Waiting on another ticket or Pull Request first. Say which |
| 🔴 | Blocked, or the person has to do something. Say what, in a few words |
| ⚪ | Not started (Backlog or Todo) |

Example: `🟢 [ABC-12](https://linear.app/[workspace]/issue/ABC-12) Short title: [#19](https://github.com/[organisation]/[repo]/pull/19)`

- Every ticket ID links to its ticket, and every Pull Request number links to the Pull Request.
- Work with no ticket is just the circle and a short title.
- Only what the person needs to act on. Leave out what they already know.

## Environment variables

| Variable | Required | Description |
| --- | --- | --- |
| `[VARIABLE_NAME]` | [Yes / No] | [What it is for] |

<!-- code-standards:start -->

## Code standards

**This section, and the Ontology and Documentation sections after it, are copied from the code standards and replaced on every update.** Never edit it to describe a project exception: record exceptions in `docs/development/coding-standards.md`. Anything else the project adds, such as the block a framework writes, goes outside this section.

This project follows the Activatus Code Standards. They are installed as the `@activatus/code-standards` dev dependency: **read them in `node_modules/@activatus/code-standards/docs/`**, which is the exact version this project uses. The same pages are at https://activatus-code-standards.vercel.app for people.

**Code standards version:** [the full version of the installed `@activatus/code-standards` package when the standards were last applied, such as 3.2.2]

This is the version the code follows, written in full so it pairs with the installed package. When the package is updated, read only the entries newer than this number in `node_modules/@activatus/code-standards/docs/version-history.md`, make the changes they list, then update this number. See "Updating to a newer version" in `docs/Guides/setup.md`.

**The standards are the base, and you build freely from there.** Most of a project's code is something the standards never mention: decide it yourself, and keep the decision the same across the codebase. This covers how the code is built: its structure, names, files and where each value goes. It never covers what the product does, says or shows: those are product decisions, and you ask. Only skipping, loosening or changing a standard needs the person's yes.

**Standards this project skips or changes** are listed, by number, in `docs/development/coding-standards.md`. Follow every other standard.

**The standards are this project's base. Never skip, loosen or change one on your own.** Ask the person first. If they agree, record it in `docs/development/coding-standards.md`, under "Details" (the standard's number and name, what is different, why, and the date), then make the change and keep going. Never stop the work to wait for a new version of the standards. ESLint changes go in `eslint.overrides.mjs` with a comment naming the standard's number. Never edit `eslint.config.mjs`: it is copied from the standards unchanged.

Follow these rules in every change:

- **Every function and component has a test file**, created in the same change. One test file tests one function or one component.
- **No file longer than 300 lines.** Test files are exempt, and so are the files in `lib/constants/`, which hold data only.
- **Every name is made of whole, human-readable words.** Variables, parameters, props, types, constants, form fields, files, folders, routes, database columns, scripts and environment variables — everything. No shortened words, no single letters (except `r` and `s`, below), no numbered names, not even for a loop counter or an event handler. Full rule: Human-Readable Names in the code standards (https://activatus-code-standards.vercel.app/docs/Standards/human-readable-names).

| Never | Always |
| --- | --- |
| `btn`, `msg`, `val`, `(e) =>`, `(item, i) =>` | `button`, `message`, `value`, `(event) =>`, `(item, index) =>` |
| `deps`, `env`, `timeoutMs`, `MAX_` | `dependencies`, `environment`, `timeoutMilliseconds`, `MAXIMUM_` |
| `<T>`, `"sm" \| "lg"` | `<ItemType>`, `"small" \| "large"` |
| `name="email1"` | `name="emailAddress"` |
| `helpers/`, `utilities/`, `helpers.ts` | `utils/`, a file named after what it does |

- **`id` is a word.** Write `id`, `userId`, `user_id` — never `identifier` and never `userID`. `identifier` is only for an outside system's separate field of that name (Linear's readable `ACT-73`).
- **Dictionary words stay short:** `id`, `docs`, `lib`, `props`, `ref`, `repo`, `utils`, `app`, `url`, `api`, `uat`, `pdf`, `html`, `css`, `json`, `readme`. Case them as words: `imageUrl`, not `imageURL`. Any other short name is written in full. A new dictionary word is added to the Dictionary in the Human-Readable Names standard first (https://activatus-code-standards.vercel.app/docs/Standards/human-readable-names#dictionary), never just used.
- **Names owned by someone else stay exactly as they are** — Linear's `id` and `pageInfo`, Node's `cwd` option and `process.env`, React's `ref` and `children`, shadcn/ui's `cn` helper, Next.js's `params`, `searchParams` and `LayoutProps`, HTML attributes, Tailwind classes. The rule is for names we write, not names from someone else's code. Renaming them breaks the code. Give the value a full-word name as soon as it comes into our code: `const workingDirectory = process.cwd();`, then pass `{ cwd: workingDirectory }`.
- **When a name clashes with a third-party import, rename our own thing first**, to a more specific full-word name: our `createProjectDatabaseClient`, next to Supabase's `createClient`. Rename the import only when both names are third-party, or ours is fixed by a framework (a Next.js route's `GET`). Then use the owner plus the original name, `Link as LucideLinkIcon`. Never rename an import for taste, and never shorten it.
- **Form fields especially.** A field has one name everywhere it goes: the input's `name` and `id`, the form schema, the API request, the type and the database column (`emailAddress` / `email_address`). Every field has a visible `<label>` in plain English — never a placeholder instead. Option values are words (`"monthly"`), not numbers. Validation messages name the field and say what to do.
- **The linter only checks some names.** Folders, props, form fields, database columns, routes and environment variables are not linted — check every one of them yourself.
- **Always `const`.** Never `var`. Use `let` only when a value must be reassigned, with `// eslint-disable-next-line no-restricted-syntax -- <reason>` on the line above.
- **Arrow functions everywhere.** Never use the `function` keyword: `export const Button = () => {}`, not `export function Button() {}`.
- **Never sort imports by hand.** Prettier sorts them on `pnpm format`. Side-effect imports, such as CSS, go after every other import.
- **Import components from their category:** `import { Button } from "@/components/ui";`. There is no `components/index.ts`. A category's `index.ts` exports only components a Client Component can import: a component that reaches server-only code starts with `import "server-only";`, is left out of it, and is imported from its own folder, such as `@/components/layout/siteHeader`. Tests mock it: `vi.mock("server-only", () => ({}));` in the Vitest setup file.
- **No circular imports, ever.** A file never imports a file that imports it back. Inside a folder, import files directly — never through the folder's own `index.ts` — and never import a parent component from its sub-component.
- **Define before use.** A function or variable is written above the code that uses it.
- **A function does one thing.**
- **Functions, never classes.**
- **One parameter per function.** Use a single object when more than one value is needed.
- **Return early.** Never nest more than three levels deep.
- **No magic numbers or strings.** Use named constants. Only `-1`, `0` and `1` stay bare. Put the unit in the name: `SESSION_TIMEOUT_MILLISECONDS`. Config files keep their numbers at the top of the file.
- **Links go in `r` and text people see or hear goes in `s`**, both in `lib/constants/`, never written straight into a component. The keys of `s` become the translation keys when the site gets more languages. **Every other value lives with the code that uses it until it is shared:** a number, list, status or internal string (ids, keys, event names, log messages) one file uses is a named constant at the top of that file. Once more than one file needs it, it moves to the file in `lib/constants/` that fits, such as `numbers.ts`, `animation.ts`, `classNames.ts` or `status.ts`, or a new file named after what it holds. Stand-in data for local development goes in `noData.ts`, named `NO_DATA_` plus the thing it replaces. **Not sure where a value goes?** Put it in `lib/constants/unsortedValues.ts` and carry on. At the end of the work, ask the person where each value in it goes, move each one there, and delete the file. Constants in `lib/constants/` get real types from `lib/types/`, never `as const`. No `"#"` placeholder links: show plain text with a `{/* TODO: … */}` until the page exists. `r` and `s` are the only single-letter names allowed anywhere. Import constants in one line: `import { r, s } from "@/lib/constants";`. Full rule: https://activatus-code-standards.vercel.app/docs/Structure/folder-structure#constants
- **No nested ternaries. No boolean flag parameters.**
- **No `any`, no non-null `!`, no `as` cast without a comment saying why.**
- **Named exports only**, except where a tool requires a default export.
- **Type names end in `Props` or `Type`**, and say what the thing is, not the job it does. A type that only describes one field of a function's props is written inside the `Props`: `StartAgentRunProps` holds its `dependencies`, with no separate `StartAgentRunDependenciesType`. Every function still has its own `Props` for its single object parameter, such as `LaunchSessionProps` for `launchSession`, used wherever that function is written: `async ({ worktreePath }: LaunchSessionProps) =>`. Type a value where it is declared, never afterwards with `satisfies`, and never `as const`: when the code needs exact values, put them in the type. A constant's list type is `readonly`. The one exception is Storybook story files, which follow Storybook's docs: `satisfies Meta<typeof Component>`.
- **One component per file.** Server and client components live in separate files.
- **A component's children sit flat in its own `components/` folder**, `accordion/components/item/` and `accordion/components/ctaArrow/`, even when one child is only used by another. Never a `components/` folder inside a child: two levels at most.
- **Component file order**: imports, component, variables, state, `useMemo` values, `useCallback` functions, effects, render. No inline functions in render.
- **Reusable functions live in a `utils/` folder** — global, section or component level — one folder per function, with an `index.ts`. A function that sorts, finds, gets, filters or formats the data passed to it goes in a `utils/` folder the first time it is written. Move a function up a level when a second user needs it.
- **Components live where they are used:** Design System Components in `components/ui/`, plus any props-only component not on the list yet, recorded under "Components for the standards" in `docs/development/coding-standards.md`; the site frame (navigation, header, footer) in `components/layout/`; the homepage's components in `app/(homepage)/components/`; components used on three or more pages in `components/shared/`; components used by three or more pages in one section in that section's route group (`app/(billing)/components/`); everything else in its page's `components/` folder. Start a new component beside its page. When a second page needs it, copy it there: one copy only, and each copy has a comment naming the other. When a third page needs it, merge the copies into one shared component, with props designed from all three uses. No `components/features/` folder.
- **Code file names are camelCase**; component files are PascalCase. **Anything that becomes a URL is kebab-case**: route folders and documentation folders and files (`docs/architecture/background-jobs.md`). CSS variables are kebab-case too.
- **Lay out with flexbox first**, and split layouts into child components. Only use CSS grid when specifically asked.
- **Style with Tailwind.** Colours come from the global colour palette in `app/globals.css` only — never a hard-coded or arbitrary colour. The design decisions are in `docs/design/design-system.md`.
- **Comments explain why, not what.** Never leave commented-out code. Mark unfinished work with `// TODO: Message` (`{/* TODO: Message */}` in JSX).
- **Error messages say what happened and what to do.**

**Third-party code is exempt.** Code in `node_modules/` and `vendor/` belongs to someone else: do not lint it, do not reformat it, do not edit it to meet these rules.

**shadcn components are not third-party.** Code the shadcn CLI copies into the project is ours. Convert it to these standards in the same change that adds it — its own camelCase folder, one component per file, `types.ts`, `index.ts` and a test — keep only the parts that are used, and never put it in `vendor/`. **Do not change a component's props or behaviour** — only its styling. Components work identically in every Activatus project.

**Look before you build a component.** Check the project's `components/ui/`, then the Design System Components in the code standards, then other Activatus projects, then shadcn. Build to the props listed in the Design System Components; never invent new prop names for a component that is already listed. A component in `components/ui/` takes everything through props and never imports from features, services, stores or `app/`.

**Lint must pass before you finish.** Run `pnpm lint` and `pnpm test` before finishing any task, and fix every error by changing the code. After setting up or updating the standards, also run `pnpm standards:check`.

**Never switch a lint rule off to make an error go away.** Do not add `eslint-disable` comments, edit `eslint.config.mjs` or `eslint.overrides.mjs`, or move files to hide them. The only exceptions are `let`, a type cast and an `as const` that cannot be avoided, each on a single line with a reason after ` -- `. If a rule seems impossible to satisfy, stop and ask.

When you report a lint error to the person, explain it in plain English: what is wrong, why it matters, and what you did to fix it.

## Ontology

Activatus uses a fixed vocabulary, the ontology, in two parts:

- **Code words** — component, utils, Decision Doc and the rest — are defined in the code standards: https://activatus-code-standards.vercel.app/docs/ontology
- **Product words** — what this project builds and how its work is planned — are defined in `docs/product/ontology.md`.

- **Read both before starting work.** If the project's ontology gives a code word a different meaning, the code standards win — point the difference out.
- **Use their terms** in code, docs, database names and messages — never a synonym. It is `utils`, not `helpers` or `utilities`.
- **Never use a term either ontology has removed.**
- If a person uses a word loosely, work out which ontology term they mean and use that term. **If you are not sure what a term means, ask.** Do not guess and do not invent a new term.

## Documentation

The project's docs are plain Markdown in `docs/`. **Every file is a Decision Doc**: it states one decision and why it was made.

- **Read them before starting work** — the three docs in `docs/overview/` first, then the docs for the area being changed. Read the files directly; do not start the docs site to read them.
- **Check each doc's Status.** `Decided` is settled. `Current plan` may change. `Under evaluation` and `Not started` are **not decisions** — do not treat them as one and do not invent one. Ask.
- **Never invent a product decision to unblock yourself.** If the docs do not answer it, stop and ask.
- **Record a check only after it has run.** Never write a URL, commit, date or "passed" for a check that has not happened yet. Write what was decided, and add the result once it exists.
- **Update the docs in the same change.** If a change makes a doc wrong, fix the doc and change its Updated date.
- If the project has a `docsSite/`, do not edit it unless the task is about the docs viewer itself.

<!-- code-standards:end -->

## Available skills

Skill files live in `.agents/skills/`, mirrored to `.claude/commands/`.

| Skill | Invocation | What it does |
| --- | --- | --- |
| Lint | `/lint [path]` | Runs the linters in check-only mode and reports issues |
| Lint & fix | `/lintFix [path]` | Auto-fixes lint and formatting issues, then reports |
| Format | `/format [path]` | Formats files with Prettier |
| Code review | `/codeReview [branch, commit or path]` | Reviews changes for bugs and standards compliance |
| Update layout | `/updateLayout` | Regenerates the "Repository layout" section of this file |
| Security audit | `/securityAudit [path]` | Audits the code for common security problems |
| TypeScript | `/typescript [path]` | Tightens loose types and removes unsafe assertions |
| Test | `/test [path]` | Runs the tests and reports failures and missing tests |

## Out of scope for AI agents

- Do not write secrets, tokens or `.env` values into any file.
- Do not change dependencies or lockfiles unless the task requires it.
- Do not edit generated files, or third-party code in `node_modules/` or `vendor/`.
- [Add any other forbidden actions.]

ESLint — TypeScript​

Copy to: eslint.config.mjs in the project root

For TypeScript projects. Install commands are on the ESLint page.

eslint.config.mjs
import { recommended as eslintCommentsRecommended } from "@eslint-community/eslint-plugin-eslint-comments/configs";
import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { flatConfigs as importX } from "eslint-plugin-import-x";
import unicorn from "eslint-plugin-unicorn";
import globals from "globals";
import { configs as typescript } from "typescript-eslint";

import { projectOverrides } from "./eslint.overrides.mjs";

// Code patterns that are errors in every file.
const RESTRICTED_SYNTAX = [
{
selector: "ClassDeclaration",
message: "Use functions, not classes.",
},
{
selector: "ClassExpression",
message: "Use functions, not classes.",
},
{
selector: "VariableDeclarator > FunctionExpression",
message: "Use an arrow function.",
},
{
selector: "VariableDeclaration[kind='let']",
message:
"Use const. If this value really must be reassigned, disable this rule on this line with a comment saying why.",
},
];

// Functions take one parameter: a single object when they need more than one value.
const SINGLE_OBJECT_PARAMETER = [
{
selector: "FunctionDeclaration[params.length>1]",
message:
"Use a single object parameter when a function needs more than one value.",
},
{
selector:
"VariableDeclarator > :matches(ArrowFunctionExpression, FunctionExpression)[params.length>1]",
message:
"Use a single object parameter when a function needs more than one value.",
},
];

// Deeper than this, return early instead.
const MAXIMUM_NESTING_DEPTH = 3;

// Type a value where it is declared. Story files are the exception: see below.
const NO_SATISFIES = {
selector: "TSSatisfiesExpression",
message:
"Type a value where it is declared, not with satisfies afterwards.",
};

// Give a value a real type instead. consistent-type-assertions always
// lets `as const` through, so it is banned here.
const NO_AS_CONST = {
selector: "TSAsExpression[typeAnnotation.typeName.name='const']",
message:
"Give the value a real type where it is declared, not as const.",
};

const config = [
{
ignores: [
"dist/",
"build/",
"coverage/",
// The docs viewer is the standard template, with its own packages and build.
"docsSite/",
"**/vendor/**",
],
},
js.configs.recommended,
eslintCommentsRecommended,
importX.recommended,
importX.typescript,
unicorn.configs.recommended,
...typescript.recommended,
{
languageOptions: { globals: { ...globals.node } },
settings: {
"import-x/resolver-next": [createTypeScriptImportResolver()],
},
rules: {
"@eslint-community/eslint-comments/require-description":
"error",
"func-style": ["error", "expression"],
"max-depth": ["error", MAXIMUM_NESTING_DEPTH],
"max-lines": [
"error",
{ max: 300, skipBlankLines: true, skipComments: true },
],
"no-nested-ternary": "error",
"no-var": "error",
"prefer-arrow-callback": "error",
"prefer-const": "error",
"no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
enforceConst: true,
},
],
"no-restricted-syntax": [
"error",
NO_SATISFIES,
NO_AS_CONST,
...RESTRICTED_SYNTAX,
...SINGLE_OBJECT_PARAMETER,
],
"import-x/no-cycle": "error",
"import-x/no-default-export": "error",
"no-use-before-define": "error",
"unicorn/default-export-style": "off",
"unicorn/filename-case": [
"error",
{ cases: { camelCase: true, pascalCase: true } },
],
"unicorn/name-replacements": [
"error",
{
// Dictionary words: see "Dictionary" in the Human-Readable Names standard.
replacements: {
docs: false,
lib: false,
props: false,
ref: false,
repo: false,
utils: false,
},
// Third-party names, allowed only exactly as they are.
allowList: { params: true, searchParams: true },
},
],
},
},
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-undef": "off",
"no-magic-numbers": "off",
"no-use-before-define": "off",
"@typescript-eslint/consistent-type-assertions": [
"error",
{ assertionStyle: "never" },
],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/naming-convention": [
"error",
{
selector: ["typeAlias", "interface"],
format: ["PascalCase"],
custom: { regex: "(Props|Type)$", match: true },
},
],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
enforceConst: true,
ignoreEnums: true,
ignoreNumericLiteralTypes: true,
},
],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-use-before-define": "error",
},
},
{
files: ["**/*.test.{ts,tsx}"],
rules: {
"max-lines": "off",
"no-magic-numbers": "off",
"@typescript-eslint/no-magic-numbers": "off",
},
},
{
// The constants files are data, not logic, so they can be long.
files: ["lib/constants/*.ts"],
rules: { "max-lines": "off" },
},
{
files: ["**/*.config.{js,mjs,ts}", "**/*.stories.tsx"],
rules: { "import-x/no-default-export": "off" },
},
{
files: ["**/*.config.{js,mjs,ts}"],
rules: { "unicorn/no-top-level-side-effects": "off" },
},
{
// Storybook's own docs type stories with `satisfies Meta<typeof Component>`, so story files may use it.
files: ["**/*.stories.{ts,tsx}"],
rules: {
"no-restricted-syntax": [
"error",
NO_AS_CONST,
...RESTRICTED_SYNTAX,
...SINGLE_OBJECT_PARAMETER,
],
},
},
// This project's own changes: see eslint.overrides.mjs.
...projectOverrides,
prettier,
];

export default config;

ESLint — React + TypeScript​

Copy to: eslint.config.mjs in the project root

For React projects. Copy it unchanged. A project on a React version other than 19 sets settings.react.version in eslint.overrides.mjs: { settings: { react: { version: "19.2" } } }.

eslint.config.mjs
import { recommended as eslintCommentsRecommended } from "@eslint-community/eslint-plugin-eslint-comments/configs";
import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { flatConfigs as importX } from "eslint-plugin-import-x";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import unicorn from "eslint-plugin-unicorn";
import globals from "globals";
import { configs as typescript } from "typescript-eslint";

import { projectOverrides } from "./eslint.overrides.mjs";

// Code patterns that are errors in every file.
const RESTRICTED_SYNTAX = [
{
selector: "ClassDeclaration",
message: "Use functions, not classes.",
},
{
selector: "ClassExpression",
message: "Use functions, not classes.",
},
{
selector: "VariableDeclarator > FunctionExpression",
message: "Use an arrow function.",
},
{
selector: "VariableDeclaration[kind='let']",
message:
"Use const. If this value really must be reassigned, disable this rule on this line with a comment saying why.",
},
];

// Functions take one parameter: a single object when they need more than one value.
const SINGLE_OBJECT_PARAMETER = [
{
selector: "FunctionDeclaration[params.length>1]",
message:
"Use a single object parameter when a function needs more than one value.",
},
{
selector:
"VariableDeclarator > :matches(ArrowFunctionExpression, FunctionExpression)[params.length>1]",
message:
"Use a single object parameter when a function needs more than one value.",
},
];

// Deeper than this, return early instead.
const MAXIMUM_NESTING_DEPTH = 3;

// Type a value where it is declared. Story files are the exception: see below.
const NO_SATISFIES = {
selector: "TSSatisfiesExpression",
message:
"Type a value where it is declared, not with satisfies afterwards.",
};

// Give a value a real type instead. consistent-type-assertions always
// lets `as const` through, so it is banned here.
const NO_AS_CONST = {
selector: "TSAsExpression[typeAnnotation.typeName.name='const']",
message:
"Give the value a real type where it is declared, not as const.",
};

const config = [
{
ignores: [
"dist/",
"build/",
"coverage/",
// The docs viewer is the standard template, with its own packages and build.
"docsSite/",
"**/vendor/**",
],
},
js.configs.recommended,
eslintCommentsRecommended,
importX.recommended,
importX.typescript,
unicorn.configs.recommended,
...typescript.recommended,
react.configs.flat.recommended,
react.configs.flat["jsx-runtime"],
reactHooks.configs.flat.recommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
settings: {
// Keep this. eslint-plugin-react crashes on ESLint 10 if it has to detect the version itself.
// A project on another React version sets settings.react.version
// in eslint.overrides.mjs, never here.
react: { version: "19.0" },
"import-x/resolver-next": [createTypeScriptImportResolver()],
},
rules: {
"@eslint-community/eslint-comments/require-description":
"error",
"func-style": ["error", "expression"],
"max-depth": ["error", MAXIMUM_NESTING_DEPTH],
"max-lines": [
"error",
{ max: 300, skipBlankLines: true, skipComments: true },
],
"no-nested-ternary": "error",
"no-var": "error",
"prefer-arrow-callback": "error",
"prefer-const": "error",
"no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
enforceConst: true,
},
],
"no-restricted-syntax": [
"error",
NO_SATISFIES,
NO_AS_CONST,
...RESTRICTED_SYNTAX,
...SINGLE_OBJECT_PARAMETER,
],
"import-x/no-cycle": "error",
"import-x/no-default-export": "error",
"no-use-before-define": "error",
"unicorn/default-export-style": "off",
"unicorn/filename-case": [
"error",
{ cases: { camelCase: true, pascalCase: true } },
],
"unicorn/name-replacements": [
"error",
{
// Dictionary words: see "Dictionary" in the Human-Readable Names standard.
replacements: {
docs: false,
lib: false,
props: false,
ref: false,
repo: false,
utils: false,
},
// Third-party names, allowed only exactly as they are.
allowList: { params: true, searchParams: true },
},
],
"react/function-component-definition": [
"error",
{
namedComponents: "arrow-function",
unnamedComponents: "arrow-function",
},
],
"react/jsx-no-bind": "error",
"react/no-multi-comp": "error",
},
},
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-undef": "off",
"no-magic-numbers": "off",
"no-use-before-define": "off",
"@typescript-eslint/consistent-type-assertions": [
"error",
{ assertionStyle: "never" },
],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/naming-convention": [
"error",
{
selector: ["typeAlias", "interface"],
format: ["PascalCase"],
custom: { regex: "(Props|Type)$", match: true },
},
],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
enforceConst: true,
ignoreEnums: true,
ignoreNumericLiteralTypes: true,
},
],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-use-before-define": "error",
},
},
{
files: ["**/*.test.{ts,tsx}"],
rules: {
"max-lines": "off",
"no-magic-numbers": "off",
"@typescript-eslint/no-magic-numbers": "off",
},
},
{
// The constants files are data, not logic, so they can be long.
files: ["lib/constants/*.ts"],
rules: { "max-lines": "off" },
},
{
files: ["**/*.config.{js,mjs,ts}", "**/*.stories.tsx"],
rules: { "import-x/no-default-export": "off" },
},
{
files: ["**/*.config.{js,mjs,ts}"],
rules: { "unicorn/no-top-level-side-effects": "off" },
},
{
// Storybook's own docs type stories with `satisfies Meta<typeof Component>`, so story files may use it.
files: ["**/*.stories.{ts,tsx}"],
rules: {
"no-restricted-syntax": [
"error",
NO_AS_CONST,
...RESTRICTED_SYNTAX,
...SINGLE_OBJECT_PARAMETER,
],
},
},
// This project's own changes: see eslint.overrides.mjs.
...projectOverrides,
prettier,
];

export default config;

ESLint — Next.js​

Copy to: eslint.config.mjs in the project root

For Next.js projects. Copy it unchanged. A project on a React version other than 19 sets settings.react.version in eslint.overrides.mjs: { settings: { react: { version: "19.2" } } }.

eslint.config.mjs
import { recommended as eslintCommentsRecommended } from "@eslint-community/eslint-plugin-eslint-comments/configs";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
import prettier from "eslint-config-prettier";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { flatConfigs as importX } from "eslint-plugin-import-x";
import unicorn from "eslint-plugin-unicorn";
import globals from "globals";

import { projectOverrides } from "./eslint.overrides.mjs";

// Code patterns that are errors in every file.
const RESTRICTED_SYNTAX = [
{
selector: "ClassDeclaration",
message: "Use functions, not classes.",
},
{
selector: "ClassExpression",
message: "Use functions, not classes.",
},
{
selector: "VariableDeclarator > FunctionExpression",
message: "Use an arrow function.",
},
{
selector: "VariableDeclaration[kind='let']",
message:
"Use const. If this value really must be reassigned, disable this rule on this line with a comment saying why.",
},
];

// Functions take one parameter: a single object when they need more than one value.
// Route handlers are the exception: see below.
const SINGLE_OBJECT_PARAMETER = [
{
selector: "FunctionDeclaration[params.length>1]",
message:
"Use a single object parameter when a function needs more than one value.",
},
{
selector:
"VariableDeclarator > :matches(ArrowFunctionExpression, FunctionExpression)[params.length>1]",
message:
"Use a single object parameter when a function needs more than one value.",
},
];

// Deeper than this, return early instead.
const MAXIMUM_NESTING_DEPTH = 3;

// Type a value where it is declared. Story files are the exception: see below.
const NO_SATISFIES = {
selector: "TSSatisfiesExpression",
message:
"Type a value where it is declared, not with satisfies afterwards.",
};

// Give a value a real type instead. consistent-type-assertions always
// lets `as const` through, so it is banned here.
const NO_AS_CONST = {
selector: "TSAsExpression[typeAnnotation.typeName.name='const']",
message:
"Give the value a real type where it is declared, not as const.",
};

const config = [
{
ignores: [
".next/",
"out/",
"build/",
"coverage/",
"next-env.d.ts",
// The docs viewer is the standard template, with its own packages and build.
"docsSite/",
"**/vendor/**",
],
},
...nextVitals,
...nextTypescript,
eslintCommentsRecommended,
importX.recommended,
importX.typescript,
unicorn.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
settings: {
// Keep this. eslint-plugin-react crashes on ESLint 10 if it has to detect the version itself.
// A project on another React version sets settings.react.version
// in eslint.overrides.mjs, never here.
react: { version: "19.0" },
"import-x/resolver-next": [createTypeScriptImportResolver()],
},
rules: {
"@eslint-community/eslint-comments/require-description":
"error",
"func-style": ["error", "expression"],
"max-depth": ["error", MAXIMUM_NESTING_DEPTH],
"max-lines": [
"error",
{ max: 300, skipBlankLines: true, skipComments: true },
],
"no-nested-ternary": "error",
"no-var": "error",
"prefer-arrow-callback": "error",
"prefer-const": "error",
"no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
enforceConst: true,
},
],
"no-restricted-syntax": [
"error",
NO_SATISFIES,
NO_AS_CONST,
...RESTRICTED_SYNTAX,
...SINGLE_OBJECT_PARAMETER,
],
"import-x/no-cycle": "error",
"import-x/no-default-export": "error",
"no-use-before-define": "error",
"unicorn/default-export-style": "off",
"unicorn/filename-case": [
"error",
{ cases: { camelCase: true, pascalCase: true } },
],
"unicorn/name-replacements": [
"error",
{
// Dictionary words: see "Dictionary" in the Human-Readable Names standard.
replacements: {
docs: false,
lib: false,
props: false,
ref: false,
repo: false,
utils: false,
},
// Third-party names, allowed only exactly as they are.
allowList: { params: true, searchParams: true },
},
],
"react/function-component-definition": [
"error",
{
namedComponents: "arrow-function",
unnamedComponents: "arrow-function",
},
],
"react/jsx-no-bind": "error",
"react/no-multi-comp": "error",
},
},
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-undef": "off",
"no-magic-numbers": "off",
"no-use-before-define": "off",
"@typescript-eslint/consistent-type-assertions": [
"error",
{ assertionStyle: "never" },
],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/naming-convention": [
"error",
{
selector: ["typeAlias", "interface"],
format: ["PascalCase"],
custom: { regex: "(Props|Type)$", match: true },
},
],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
enforceConst: true,
ignoreEnums: true,
ignoreNumericLiteralTypes: true,
},
],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-use-before-define": "error",
},
},
{
files: ["**/*.test.{ts,tsx}"],
rules: {
"max-lines": "off",
"no-magic-numbers": "off",
"@typescript-eslint/no-magic-numbers": "off",
},
},
{
// The constants files are data, not logic, so they can be long.
files: ["lib/constants/*.ts"],
rules: { "max-lines": "off" },
},
{
files: [
"**/*.config.{js,mjs,ts}",
"**/*.stories.tsx",
"app/**/{page,layout,template,default,loading,error,global-error,not-found}.tsx",
],
rules: { "import-x/no-default-export": "off" },
},
{
files: ["**/*.config.{js,mjs,ts}"],
rules: { "unicorn/no-top-level-side-effects": "off" },
},
{
// Storybook's own docs type stories with `satisfies Meta<typeof Component>`, so story files may use it.
files: ["**/*.stories.{ts,tsx}"],
rules: {
"no-restricted-syntax": [
"error",
NO_AS_CONST,
...RESTRICTED_SYNTAX,
...SINGLE_OBJECT_PARAMETER,
],
},
},
{
// Next.js calls a route handler with two values, (request, context), so route.ts files may take two parameters.
files: ["app/**/route.ts"],
rules: {
"no-restricted-syntax": [
"error",
NO_SATISFIES,
NO_AS_CONST,
...RESTRICTED_SYNTAX,
],
},
},
// This project's own changes: see eslint.overrides.mjs.
...projectOverrides,
prettier,
];

export default config;

ESLint — JavaScript / Node.js​

Copy to: eslint.config.mjs in the project root

For plain JavaScript and Node.js projects.

eslint.config.mjs
import { recommended as eslintCommentsRecommended } from "@eslint-community/eslint-plugin-eslint-comments/configs";
import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import { flatConfigs as importX } from "eslint-plugin-import-x";
import unicorn from "eslint-plugin-unicorn";
import globals from "globals";

import { projectOverrides } from "./eslint.overrides.mjs";

// Code patterns that are errors in every file.
const RESTRICTED_SYNTAX = [
{
selector: "ClassDeclaration",
message: "Use functions, not classes.",
},
{
selector: "ClassExpression",
message: "Use functions, not classes.",
},
{
selector: "VariableDeclarator > FunctionExpression",
message: "Use an arrow function.",
},
{
selector: "VariableDeclaration[kind='let']",
message:
"Use const. If this value really must be reassigned, disable this rule on this line with a comment saying why.",
},
];

// Functions take one parameter: a single object when they need more than one value.
const SINGLE_OBJECT_PARAMETER = [
{
selector: "FunctionDeclaration[params.length>1]",
message:
"Use a single object parameter when a function needs more than one value.",
},
{
selector:
"VariableDeclarator > :matches(ArrowFunctionExpression, FunctionExpression)[params.length>1]",
message:
"Use a single object parameter when a function needs more than one value.",
},
];

// Deeper than this, return early instead.
const MAXIMUM_NESTING_DEPTH = 3;

const config = [
{
ignores: [
"dist/",
"build/",
"coverage/",
// The docs viewer is the standard template, with its own packages and build.
"docsSite/",
"**/vendor/**",
],
},
js.configs.recommended,
eslintCommentsRecommended,
importX.recommended,
unicorn.configs.recommended,
{
languageOptions: { globals: { ...globals.node } },
rules: {
"@eslint-community/eslint-comments/require-description":
"error",
"func-style": ["error", "expression"],
"max-depth": ["error", MAXIMUM_NESTING_DEPTH],
"max-lines": [
"error",
{ max: 300, skipBlankLines: true, skipComments: true },
],
"no-nested-ternary": "error",
"no-var": "error",
"prefer-arrow-callback": "error",
"prefer-const": "error",
"no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
enforceConst: true,
},
],
"no-restricted-syntax": [
"error",
...RESTRICTED_SYNTAX,
...SINGLE_OBJECT_PARAMETER,
],
"import-x/no-cycle": "error",
"import-x/no-default-export": "error",
"no-use-before-define": "error",
"unicorn/default-export-style": "off",
"unicorn/filename-case": [
"error",
{ cases: { camelCase: true, pascalCase: true } },
],
"unicorn/name-replacements": [
"error",
{
// Dictionary words: see "Dictionary" in the Human-Readable Names standard.
replacements: {
docs: false,
lib: false,
props: false,
ref: false,
repo: false,
utils: false,
},
// Third-party names, allowed only exactly as they are.
allowList: { params: true, searchParams: true },
},
],
},
},
{
files: ["**/*.test.{js,mjs}"],
rules: { "max-lines": "off", "no-magic-numbers": "off" },
},
{
// The constants files are data, not logic, so they can be long.
files: ["lib/constants/*.{js,mjs}"],
rules: { "max-lines": "off" },
},
{
files: ["**/*.config.{js,mjs}"],
rules: { "import-x/no-default-export": "off" },
},
// This project's own changes: see eslint.overrides.mjs.
...projectOverrides,
prettier,
];

export default config;

Prettier​

Copy to: prettier.config.mjs in the project root

See the Prettier page.

prettier.config.mjs
/**
@type {import("prettier").Config & import("@ianvs/prettier-plugin-sort-imports").PluginConfig & import("prettier-plugin-tailwindcss").PluginOptions}
*/
const config = {
// Short lines, so code reads well on a phone.
printWidth: 70,
// Tabs indent. A tab counts as 2 characters towards the line width.
useTabs: true,
tabWidth: 2,
singleQuote: false,
trailingComma: "all",
bracketSpacing: true,
bracketSameLine: false,
arrowParens: "always",
semi: true,
endOfLine: "lf",
// The Tailwind plugin must stay last.
plugins: [
"@ianvs/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss",
],
// Node's own modules, packages, the project's @/ aliases, then
// ../ and ./, with a blank line between groups. Side-effect
// imports, such as CSS, never move.
importOrder: [
"<BUILTIN_MODULES>",
"",
"<THIRD_PARTY_MODULES>",
"",
"^@/",
"",
"^[.][.]/",
"",
"^[.]/",
],
tailwindStylesheet: "./app/globals.css",
overrides: [
{
// YAML does not allow tabs, and Markdown reads a tab as four spaces.
files: ["*.md", "*.mdx", "*.yml", "*.yaml"],
options: { useTabs: false },
},
],
};

export default config;

Prettier ignore​

Copy to: .prettierignore in the project root, or add its lines to the project's own

See What Prettier skips.

.prettierignore
# pnpm rewrites this on every install, in its own style.
pnpm-workspace.yaml
# Copied from the code standards and compared word for word by
# pnpm standards:check, so Prettier leaves them as they are.
AGENTS.md
.agents/skills/
.claude/commands/

Stylelint — CSS and CSS Modules​

Copy to: stylelint.config.mjs in the project root

See the Stylelint page.

stylelint.config.mjs
/**
@type {import("stylelint").Config}
*/
const config = {
// Generated CSS, and the docs viewer, which is the standard template
// with its own build. Only the project's hand-written CSS is linted.
ignoreFiles: [
".next/**",
"out/**",
"build/**",
"coverage/**",
"docsSite/**",
"**/vendor/**",
],
extends: [
"stylelint-config-standard",
"stylelint-config-recess-order",
],
rules: {
"selector-max-id": 0,
"property-no-vendor-prefix": true,
"color-function-notation": "modern",
"import-notation": "string",
"at-rule-no-unknown": [
true,
{
ignoreAtRules: [
"theme",
"utility",
"variant",
"custom-variant",
"source",
"plugin",
],
},
],
},
overrides: [
{
files: ["**/*.module.css"],
rules: {
"color-named": "never",
"color-no-hex": true,
"selector-class-pattern": [
"^[a-z][a-zA-Z0-9]*$",
{ message: "Expected class selector to be camelCase" },
],
},
},
],
};

export default config;

Stylelint — SCSS​

Copy to: stylelint.config.mjs in the project root

For projects that use SCSS.

stylelint.config.mjs
/**
@type {import("stylelint").Config}
*/
const config = {
// Generated CSS, and the docs viewer, which is the standard template
// with its own build. Only the project's hand-written CSS is linted.
ignoreFiles: [
".next/**",
"out/**",
"build/**",
"coverage/**",
"docsSite/**",
"**/vendor/**",
],
extends: [
"stylelint-config-standard-scss",
"stylelint-config-recess-order",
],
rules: {
"selector-max-id": 0,
"property-no-vendor-prefix": true,
"color-function-notation": "modern",
},
};

export default config;

Global styles and colour palette​

Copy to: app/globals.css

Replace the example colours with the project's palette. See CSS Styling.

globals.css
@import "tailwindcss";

@theme {
--color-brand-primary: #3b82f6;
--color-brand-primary-hover: #2563eb;
--color-brand-secondary: #64748b;
--color-surface: #fff;
--color-text: #0f172a;
}

[data-theme="dark"] {
--color-brand-primary: #60a5fa;
--color-brand-primary-hover: #93c5fd;
--color-surface: #0f172a;
--color-text: #f8fafc;
}

EditorConfig​

Copy to: .editorconfig in the project root

See the EditorConfig page.

.editorconfig
root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true

# Markdown reads a tab as four spaces, so it uses spaces.
[*.{md,mdx}]
indent_style = space
trim_trailing_whitespace = false
max_line_length = off

# YAML does not allow tabs.
[*.{yml,yaml}]
indent_style = space
indent_size = 2

# Python's own style guide uses 4 spaces.
[*.py]
indent_style = space
indent_size = 4

Node version​

Copy to: .nvmrc in the project root

Set it to the project's Node major version. See direnv + nvm.

.nvmrc
20

direnv​

Copy to: .envrc in the project root

Run direnv allow after adding it. See direnv + nvm.

.envrc
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"

# Source the nvm shell function.
# Handles: manual install ($HOME/.nvm), Homebrew on macOS, and WSL.
# On Windows with nvm-windows, nvm is already an executable in PATH — no sourcing needed.
if [ -s "$NVM_DIR/nvm.sh" ]; then
. "$NVM_DIR/nvm.sh"
elif [ "$(uname -s 2>/dev/null)" = "Darwin" ] && command -v brew &>/dev/null; then
_nvm_sh="$(brew --prefix nvm 2>/dev/null)/nvm.sh"
[ -s "$_nvm_sh" ] && . "$_nvm_sh"
unset _nvm_sh
fi

if [ -f ".nvmrc" ]; then
_node_version="$(tr -d '[:space:]' < .nvmrc)"
nvm install "$_node_version"
nvm use "$_node_version"
unset _node_version
fi

# Ensure the pnpm shim declared in package.json#packageManager is active via Corepack.
# `corepack enable` is idempotent — safe to run on every cd.
if command -v corepack &>/dev/null; then
corepack enable pnpm
fi

Agent skills​

Copy to: .agents/skills/ in the project root, and again to .claude/commands/ so Claude Code picks them up as slash commands.

The eight standard skills. Copy all of them into every project. See Agent Skills for how they are written.

SkillInvocationPlain file
Lint/lint [path]/templates/skills/lint.md
Lint & fix/lintFix [path]/templates/skills/lintFix.md
Format/format [path]/templates/skills/format.md
Code review/codeReview [branch, commit or path]/templates/skills/codeReview.md
Update layout/updateLayout/templates/skills/updateLayout.md
Security audit/securityAudit [path]/templates/skills/securityAudit.md
TypeScript/typescript [path]/templates/skills/typescript.md
Test/test [path]/templates/skills/test.md

Lint​

Runs ESLint (and Stylelint if configured) in check-only mode; reports all issues grouped by file.

lint.md
---
name: lint
description: Run all linters in check-only mode and report issues
argument-hint: "[path]"
allowed-tools: Read, Glob, Grep, Bash(pnpm lint:*)
---

Run every configured linter and report the issues. Do NOT modify any files.

1. Read `package.json` and find the lint scripts (`lint`, and `lint:css` if it exists).
2. Run each one in check-only mode:
- ESLint: `pnpm lint $ARGUMENTS`
- Stylelint, only if the `lint:css` script exists: `pnpm lint:css`
3. Report the findings grouped by file. For each issue give the line, the rule name and the message, then explain in one plain-English sentence what is wrong and how to fix it. Write for someone who does not read code.
4. Finish with a one-line total: the number of errors and warnings, and how many are auto-fixable.

If `package.json` has no `lint` script, stop and say so. Do NOT fall back to running `eslint` directly.

Do NOT fix anything, even issues that are auto-fixable — `/lintFix` does that.
Do NOT lint or report on files in `node_modules/` or `vendor/`. Third-party code is exempt from the standards.
Do NOT stage, commit or push.

Lint & fix​

Runs ESLint and Stylelint in fix mode, then Prettier; reports what changed and what is left.

lintFix.md
---
name: lintFix
description: Auto-fix lint and formatting issues, then report
argument-hint: "[path]"
allowed-tools: Read, Glob, Grep, Bash(pnpm lint:*), Bash(pnpm format:*), Bash(git status:*), Bash(git diff:*)
---

Run every linter in fix mode, then the formatter, and report what changed.

1. Read `package.json` and find the scripts that exist: `lint:fix`, `lint:css:fix` and `format`.
2. Run them in this order, skipping any that do not exist:
- ESLint: `pnpm lint:fix $ARGUMENTS`
- Stylelint: `pnpm lint:css:fix`
- Prettier: `pnpm format`
3. Run `git status --short` and `git diff --stat` to see which files changed.
4. Report:
- The files that were changed
- The issues that remain and could not be fixed automatically, grouped by file

If none of the three scripts exist, stop and say so.

Do NOT fix the remaining issues by hand. Report them and leave them for the person.
Do NOT change files in `node_modules/` or `vendor/`. Third-party code is exempt from the standards.
Do NOT stage, commit or push. Leave every change uncommitted.

Format​

Runs Prettier in write mode; reports reformatted files.

format.md
---
name: format
description: Format files with Prettier and report what changed
argument-hint: "[path]"
allowed-tools: Read, Glob, Bash(pnpm format:*), Bash(git status:*)
---

Format the project with Prettier and report which files changed.

1. Read `package.json` and confirm a `format` script exists.
2. Run `pnpm format $ARGUMENTS`.
3. Run `git status --short` to see which files changed.
4. Report the reformatted files. If nothing changed, say the project was already formatted.

If `package.json` has no `format` script, stop and say so.

Do NOT change files in `node_modules/` or `vendor/`.
Do NOT stage, commit or push. Leave every change uncommitted.

Code review​

Reviews changes for correctness, security, types, tests and standards compliance. Report only.

codeReview.md
---
name: codeReview
description: Review changes for bugs and standards compliance
argument-hint: "[branch, commit or path]"
allowed-tools: Read, Glob, Grep, Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git merge-base:*)
---

Review the changes and report problems. Do NOT modify any files.

1. Work out what to review:
- If `$ARGUMENTS` is a file or folder path, review those files.
- If `$ARGUMENTS` is a branch or commit, review `git diff $ARGUMENTS...HEAD`.
- If `$ARGUMENTS` is empty, review `git diff main...HEAD` plus the uncommitted changes from `git diff`.
2. Read `AGENTS.md` for the project's code standards.
3. Read every changed file in full, not only the changed lines.
4. Check each change for:
- **Correctness** — logic errors, unhandled cases, broken error handling
- **Security** — unvalidated input, secrets in code, missing permission checks
- **Types** — `any`, non-null `!`, and `as` casts without a comment saying why
- **Tests** — every new or changed function and component has a test file
- **Readable code** — files over 300 lines, any name that is not human-readable (folders, files, props, form field `name`s and labels, types, union values, database columns, routes, environment variables and test names as well as variables), functions doing more than one thing, more than one parameter, nesting deeper than three levels, magic numbers
- **Structure** — reusable functions outside a `utils/` folder (a sort, find, get, filter or format function inside a component), server and client code in one file, inline functions in render, a component copied a third time instead of merged, a copied component without a comment naming its other copy
- **Changed standards** — any change to `eslint.config.mjs`, and any rule in `eslint.overrides.mjs` or other change to a standard with no matching entry in `docs/development/coding-standards.md`
- **Styling** — hard-coded colours, CSS grid that was not asked for
5. Report the findings in order of severity: **Must fix**, **Should fix**, **Consider**. For each one give the file and line, what is wrong, and how to fix it.
6. Finish with a one-line verdict: ready to commit, or not.

If there are no changes to review, say so and stop.

Do NOT review files in `node_modules/` or `vendor/`. Third-party code is exempt from the standards.
Do NOT fix anything. Report only.
Do NOT stage, commit or push.

Update layout​

Regenerates the "Repository layout" section of AGENTS.md (and "Workspace packages" in monorepos).

updateLayout.md
---
name: updateLayout
description: Regenerate the repository layout in AGENTS.md
allowed-tools: Read, Glob, Edit, Bash(tree:*), Bash(ls:*)
---

Update the "Repository layout" section of `AGENTS.md` so it matches the repository as it is now.

1. Read `AGENTS.md` and find the "Repository layout" section.
2. Build a directory tree two levels deep:
- Run `tree -L 2 -a -I 'node_modules|.git|.next|build|dist|coverage|.DS_Store'`
- If `tree` is not installed, build the same tree with `ls` and Glob, and say that you used the fallback.
3. Replace the code block in the "Repository layout" section with the new tree.
4. If `AGENTS.md` has a "Workspace packages" section, update its table from the workspace `package.json` files: package name, path and description.
5. Report what changed: the folders that were added and removed.

If `AGENTS.md` does not exist, or has no "Repository layout" section, stop and say so.

Do NOT edit any other section of `AGENTS.md`, and do NOT edit any other file.
Do NOT stage, commit or push. Leave the change uncommitted.

Security audit​

Audits dependencies and code for secrets, missing access control, unvalidated input and injection. Report only.

securityAudit.md
---
name: securityAudit
description: Audit the code for common security problems
argument-hint: "[path]"
allowed-tools: Read, Glob, Grep, Bash(pnpm audit:*), Bash(git ls-files:*)
---

Audit the code for security problems and report them. Do NOT modify any files.

1. Work out the scope: `$ARGUMENTS` if given, otherwise the whole repository.
2. Run `pnpm audit` and note every high and critical vulnerability.
3. Check the code for each of these:
- **Secrets** — API keys, tokens or passwords written in code or committed in `.env` files. Use `git ls-files` to check which files are tracked.
- **Access control** — API routes and server actions that do not check who the user is or what they are allowed to do
- **Input validation** — request data used without being validated
- **Injection** — database queries or shell commands built by joining strings
- **Cross-site scripting** — `dangerouslySetInnerHTML` or unescaped user content
- **Sensitive data** — personal data or secrets written to logs or returned in error messages
- **Client exposure** — secrets or server-only code reachable from a client component
4. Report the findings in order of severity: **Critical**, **High**, **Medium**, **Low**. For each one give the file and line, what the risk is, and how to fix it.
5. Finish by listing what you did NOT check, so a clean report is not mistaken for a full security review.

If `pnpm audit` fails, say so and carry on with the code checks.

Do NOT audit the source of packages in `node_modules/` or `vendor/` — `pnpm audit` covers dependencies.
Do NOT print the value of any secret you find. Report the file and line only.
Do NOT fix anything. Report only.
Do NOT stage, commit or push.

TypeScript​

Tightens loose types and removes any, ! and unexplained as.

typescript.md
---
name: typescript
description: Tighten loose types and remove unsafe assertions
argument-hint: "[path]"
allowed-tools: Read, Glob, Grep, Edit, Bash(pnpm typecheck:*), Bash(pnpm lint:*)
---

Replace loose types with precise ones in the given files.

1. Work out the scope: `$ARGUMENTS` if given. If it is empty, stop and ask which file or folder to work on.
2. Find every loose type in scope:
- `any`, written or implied
- Non-null assertions (`value!`)
- Type casts (`value as SomeType`)
- `as const`
3. Fix each one:
- Replace `any` with the real type, or with `unknown` narrowed by a check
- Replace `value!` with a check that the value exists
- Replace a cast with validation or a properly typed source
- Replace `as const` with a real type where the value is declared, from `lib/types/` for a constant, putting any exact values in the type
4. Follow the naming rule for every type you add or rename: the name ends in `Props` (the object parameter of a component or function) or `Type` (everything else).
5. Run `pnpm typecheck`, then `pnpm lint`. Fix any errors your changes caused.
6. Report each change: the file, what the type was, and what it is now. List anything you could not tighten, and why.

If `package.json` has no `typecheck` script, say so and skip that check. Do NOT run `tsc` directly.

Do NOT silence an error with `any`, `!`, `as`, `@ts-ignore` or `@ts-expect-error`.
Do NOT change what the code does. Change types only.
Do NOT edit files in `node_modules/` or `vendor/`.
Do NOT stage, commit or push. Leave every change uncommitted.

Test​

Runs the test suite; reports failures grouped by cause, tests that never ran, and files missing a test.

test.md
---
name: test
description: Run the tests and report failures and missing tests
argument-hint: "[path]"
allowed-tools: Read, Glob, Grep, Bash(pnpm test:*)
---

Run the test suite and report the result. Do NOT modify any files.

1. Read `package.json` and confirm a `test` script exists.
2. Run `pnpm test $ARGUMENTS`.
3. Report the failures grouped by cause, not by file. Tests that fail for the same reason go under one heading. For each cause give the test names, the file and line, and what was expected against what happened.
4. Report separately any test that **never ran** — a file that failed to load, or a skipped test. A test that did not run has not passed.
5. Check for missing test files in scope. Every function and component needs a test file beside it:
- `formatCurrency.ts` needs `formatCurrency.test.ts`
- `Button.tsx` needs `Button.test.tsx`
List every function and component file that has no test file. Ignore `index.ts`, `types.ts`, config files and Next.js route files.
6. Finish with a one-line total: passed, failed, never ran, and files missing a test.

If `package.json` has no `test` script, stop and say so.

Do NOT fix failing tests or the code they test. Report only.
Do NOT report on files in `node_modules/` or `vendor/`.
Do NOT stage, commit or push.

Docs site​

Copy to: a docsSite/ folder in the project root

The Docusaurus viewer that shows the project's docs/ folder at /docs. Copy every file, keeping the folder layout. See Documentation.

FilePlain file
package.json/templates/docsSite/package.json.txt — rename to package.json
docusaurus.config.mjs/templates/docsSite/docusaurus.config.mjs
sidebars.mjs/templates/docsSite/sidebars.mjs
tsconfig.json/templates/docsSite/tsconfig.json
pnpm-workspace.yaml/templates/docsSite/pnpm-workspace.yaml
src/css/custom.css/templates/docsSite/src/css/custom.css
.gitignore/templates/docsSite/gitignore.txt — rename to .gitignore
src/pages/index.tsx/templates/docsSite/src/pages/index.tsx
static/images/logo.svg/templates/docsSite/static/images/logo.svg — replace with the project logo

Set PROJECT_NAME at the top of the config:

docusaurus.config.mjs
import { themes as prismThemes } from "prism-react-renderer";

const TITLE_SEPARATOR = " — ";
const FIRST_GROUP = "overview";

// Each doc is titled "# Group — Menu item". The folder is the menu group and the
// part after the dash is the menu item. Overview comes first; everything else is alphabetical.
const generateSidebarItems = async ({
defaultSidebarItemsGenerator,
docs: pages,
...remainingArguments
}) => {
const items = await defaultSidebarItemsGenerator({
docs: pages,
...remainingArguments,
});
const titleById = new Map(
pages.map((page) => [page.id, page.title]),
);

const labelled = items.map((item) => {
if (item.type !== "category") {
return item;
}

const children = item.items
.map((child) => {
if (child.type !== "doc") {
return child;
}
const title = titleById.get(child.id) ?? child.id;
const label = title.includes(TITLE_SEPARATOR)
? title.split(TITLE_SEPARATOR)[1]
: title;
return { ...child, label };
})
.sort((first, second) =>
first.label.localeCompare(second.label),
);

const firstDoc = item.items.find((child) => child.type === "doc");
const firstTitle = firstDoc
? (titleById.get(firstDoc.id) ?? "")
: "";
const label = firstTitle.includes(TITLE_SEPARATOR)
? firstTitle.split(TITLE_SEPARATOR)[0]
: item.label;
const folder = firstDoc ? firstDoc.id.split("/")[0] : item.label;

return {
...item,
label,
items: children,
customProps: { folder },
};
});

return labelled.sort((first, second) => {
const firstFolder = first.customProps?.folder ?? "";
const secondFolder = second.customProps?.folder ?? "";
if (firstFolder === FIRST_GROUP) {
return -1;
}
if (secondFolder === FIRST_GROUP) {
return 1;
}
return String(first.label).localeCompare(String(second.label));
});
};

// TODO: set the project name
const PROJECT_NAME = "Project Name";

/** @type {import('@docusaurus/types').Config} */
const config = {
title: `${PROJECT_NAME} Docs`,
favicon: "images/logo.svg",

// The docs are only ever served behind the app's /docs route, so this URL is a placeholder.
url: "http://localhost:3000",
baseUrl: "/docs/",

// Internal docs must never be indexed by search engines.
noIndex: true,

onBrokenLinks: "throw",

markdown: {
// Plain .md files are read as CommonMark, so < and { in a doc never break the build.
format: "detect",
mermaid: true,
},

presets: [
[
"classic",
/** @type {import('@docusaurus/preset-classic').Options} */ ({
docs: {
// The Markdown lives in the project's root docs/ folder, not inside this site.
path: "../docs",
routeBasePath: "/",
sidebarPath: "./sidebars.mjs",
sidebarItemsGenerator: generateSidebarItems,
},
blog: false,
theme: {
customCss: "./src/css/custom.css",
},
}),
],
],

themes: [
["@getcanary/docusaurus-theme-search-pagefind", {}],
"@docusaurus/theme-mermaid",
],

themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */ ({
tableOfContents: {
minHeadingLevel: 2,
maxHeadingLevel: 3,
},
colorMode: {
defaultMode: "light",
respectPrefersColorScheme: true,
},
navbar: {
title: `${PROJECT_NAME} Docs`,
logo: {
alt: "Logo",
src: "images/logo.svg",
},
items: [],
},
prism: {
theme: prismThemes.oneLight,
darkTheme: prismThemes.oneDark,
additionalLanguages: ["bash", "json"],
},
}),
};

export default config;
package.json
{
"name": "docs-site",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "docusaurus start --port 3001 --no-open",
"build": "docusaurus build",
"serve": "docusaurus serve --port 3001"
},
"dependencies": {
"@docusaurus/core": "3.10.2",
"@docusaurus/preset-classic": "3.10.2",
"@docusaurus/theme-mermaid": "3.10.2",
"@getcanary/docusaurus-theme-search-pagefind": "1.0.2",
"@getcanary/web": "^1.0.0",
"@mdx-js/react": "^3.0.0",
"pagefind": "^1.3.0",
"prism-react-renderer": "^2.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@docusaurus/tsconfig": "3.10.2",
"@docusaurus/types": "3.10.2",
"typescript": "~5.6.2"
},
"engines": {
"node": ">=20.0"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
}
}
sidebars.mjs
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
// Built from the folder structure of the project's docs/ folder
docsSidebar: [{ type: "autogenerated", dirName: "." }],
};

export default sidebars;
tsconfig.json
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
},
"exclude": [".docusaurus", "build"]
}
pnpm-workspace.yaml
allowBuilds:
core-js: false
src/css/custom.css
/* Theme overrides for the docs site. Empty by default: the stock theme is the standard look. */

/* Code is indented with tabs. Show each tab 2 characters wide, as the editors do. */
pre,
code {
tab-size: 2;
}
.gitignore
node_modules
build
.docusaurus

The home page sends /docs to the first Overview doc. Change the path if the overview file is renamed:

src/pages/index.tsx
import { Redirect } from "@docusaurus/router";
import useBaseUrl from "@docusaurus/useBaseUrl";

// The docs have no home page of their own: /docs opens the first Overview doc.
const Home = () => {
return <Redirect to={useBaseUrl("/overview/what-is-it")} />;
};

export default Home;

The /docs route​

Copy to: next.config.mjs in the project root. If the project already has a Next.js config, add the rewrites function to it.

Plain file: /templates/next.config.mjs

next.config.mjs
const DOCS_URL = process.env.DOCS_URL;

/**
@type {import("next").NextConfig}
*/
const nextConfig = {
async rewrites() {
// The docs are only served when DOCS_URL is set. It is never set in production.
if (!DOCS_URL) {
return [];
}

return [
{ source: "/docs", destination: `${DOCS_URL}/docs/` },
{
source: "/docs/:path*",
destination: `${DOCS_URL}/docs/:path*`,
},
];
},
};

export default nextConfig;

Regression check​

Copy to: tests/visual/ in the project root

Optional. Proves a large refactor changed nothing visible. Copy the whole static/templates/tests/visual/ folder. See Regression Checks for Refactors.


Document templates​

Copy to: the docs/ folder in the project root, keeping the folder layout

The full set of Decision Docs for a new project: every reusable folder with every standard file, each marked Status: Not started. Copy the whole static/templates/docs/ folder from this repository.

FolderExample file
overview//templates/docs/overview/what-is-it.md
architecture//templates/docs/architecture/system.md
scope//templates/docs/scope/core.md
…and 14 moreEach file is served at /templates/docs/<folder>/<file>.md

After copying, rename overview/what-is-it.md after the project, update its title, and update the link to it in overview/writing-docs.md. Then ask the person to review product/ontology.md.

Decision Doc format​

Copy to: any new doc that has no template file

The shape every doc follows. See The doc format. Plain file: /templates/decision-doc.md

decision-doc.md
# <Group> — <Menu item>

> One line: what this doc decides.

**Status:** Not started | Under evaluation | Current plan | Decided · **Updated:** YYYY-MM-DD

## Decision

## Why

## Details

## Alternatives considered

## Current implementation

## Related

## Retro Feedback