Skip to content

Contributing

Operating rules (from PLAN.md §7, still the law)

  • Read packages/contracts before writing anything that touches shared shapes. Never change a contract without also updating CHANGELOG-contracts.md at the repo root — every entry there is additive by convention: existing shapes are never changed, only extended, so nothing downstream breaks silently.
  • Stay inside your owned directories. The original ten-slice build split the repo into disjoint ownership (desktop, muse, twin, studio-ui, agents, skills, souls, market, generators, api-core, landing, web-skills, docs-site, docs-content — see PLAN.md §7’s table); shared code goes through packages/* only, never by reaching into another slice’s directory.
  • Cheap models by default. strong is reserved for the Studio planner and QA reviewer only — and this is enforced structurally for generators, not just by convention: see Generators.
  • Beautiful by default: no unstyled fallback UI ships. Every empty/loading/error state in the web app has a real design, not a bare “Loading…” string.
  • Secrets only in .env; never commit. .env.example is the tracked template; the real .env is gitignored.

Before you say you’re done

Terminal window
pnpm verify

is the whole of it, and it is what CI runs. Before it existed you had to know five commands and, worse, know which two environment variables to set — and nobody set them, so two suites that guard things which fail silently were being proved by nobody. scripts/verify.mjs runs these seven steps in this order and prints a per-step timing table at the end:

StepWhat it runs
typecheckpnpm -r typecheck across all 12 projects
test:rootvitest run over tests/** — every tripwire is in here
test:packagespackages/{sandbox,skills} own suites
test:chromethe module isolation proof, in a real browser (NOVA_CHROME_TESTS=1)
test:scaffoldthe scaffold still builds with lifecycle scripts off (NOVA_SCAFFOLD_BUILD_TEST=1)
build:webapps/web production bundle
build:docsapps/docs static site

About six minutes on a warm machine. pnpm verify --list prints the steps; pnpm verify --only=test runs just the test ones. A failing step does not stop the run — you get the state of all seven, not just the first thing that broke.

One prerequisite, and it is not optional

pnpm verify refuses to start without the WASM sandbox binary, and says so in as many words. runtime/target/ is gitignored and runtime/wasm/*.wasm is 22 MB of third-party binary that is deliberately not committed, so a fresh clone has neither — and WASM is the default sandbox tier. Without it, three tests fail outright and sixteen sandbox containment attacks quietly do not run while the file still reports “1 passed”. Build it once:

Terminal window
cargo build --release -p nova-wasm --manifest-path runtime/Cargo.toml
runtime/wasm/fetch.sh all # fetch.ps1 on Windows; version- and SHA-256-pinned

The pieces, individually

Terminal window
pnpm typecheck

runs tsc --noEmit (or the package’s own typecheck script) across every workspace package — pnpm -r typecheck at the root. Each package defines its own typecheck script (apps/api: tsc -p tsconfig.json; apps/web: tsc -p tsconfig.json --noEmit; packages/contracts and the others: the same pattern), all extending one shared tsconfig.base.json at the repo root (strict: true, ES2022 target, bundler module resolution). No any in contracts — every entity, route input/output, and WebSocket event in packages/contracts/src is a real Zod schema with an inferred type, never a loose escape hatch.

Terminal window
pnpm test

runs the real automated suite: vitest run over tests/**1,752 tests in 109 files as of 7 September 2026: 1,725 passing, 27 skipped, none failing, in about 114 seconds — covering contract invariants, the skill registry, the planner’s graph repair, the budget guards, deliverable and build contracts, twin/studio integration flows, and — since the mesh landed — canonical JSON against the published RFC 8785 vectors, signing and rotation, the sealed federation wire, Seed wire sizes, moderation, and the directory’s boundaries. On top of that, packages/sandbox and packages/skills keep their own suites deliberately outside the root collection (pnpm test:packages, 119 tests), because they spawn real worker threads and subprocesses.

Of the 27 skipped, 12 are the Docker sandbox tests and that is correct — Docker was retired as a tier in 03c0e54 and those probe for a daemon that is not there. The other 15 are the two gated suites, which is why pnpm verify sets their flags rather than leaving them to memory.

What’s still missing is ESLint — despite PLAN.md §7’s Phase 0 line mentioning “eslint” as part of the initial spine, there is no ESLint configuration anywhere in the repo, so pnpm typecheck and pnpm test catch type errors and logic regressions, but not a style or lint pass. See Roadmap & FAQ.

CI

.github/workflows/ci.yml runs on every push and pull request, on Node 22 (global WebSocket, node:sqlite and Ed25519 in node:crypto all need it). Two jobs:

  • verify — the gate. Install, build the WASM runtime, typecheck, both test suites, the browser isolation proof, and both builds.
  • scaffold-build — the NOVA_SCAFFOLD_BUILD_TEST=1 suite, on its own because it is the only thing in CI that talks to the public npm registry at test time. Separate so that a registry outage reads as “the scaffold job is red” and cannot mask the state of the real suite.

Both gated suites run on every push, not nightly. A gate nobody sets is a test that does not exist, and burying one in a nightly job is most of the way back to that.

CI caches the pnpm store and the cargo target directory, and deliberately caches neither node_modules nor vitest’s transform cache. Those are keyed on paths and mtimes; a stale transform cache serves yesterday’s module for today’s source, and a green run that proves nothing is worse than a slow one.

Two checks in that workflow are worth knowing about because they exist to catch a green result:

  • After the gated suites run, CI asserts from the JSON report that they collected a non-zero number of tests and skipped none. Both are built to skip quietly: with its flag off the browser proof reports “1 passed” and a skipped test titled NOT RUN, and with the WASM runtime missing the sandbox suite reports “1 passed | 16 skipped”. Both are green, and both are lies.
  • vitest.config.ts refuses to start at all if a tripwire file has been deleted or moved. The cheapest way to defeat an invariant test has always been git rm: the suite goes green and the count drops by a few.

The tripwires

Seven of those tests are not ordinary regression tests. They exist because the failures they catch are invisible, and if one goes red the right response is to make the code right, never the assertion.

They also fail badly. expected [ …(10) ] to deeply equal [] is true and useless: it names neither what broke nor why anyone cares. So tests/reporters/tripwires.ts prints, at the end of any run where one of them fails, the invariant it guards and the opening comment of the test file itself — written by whoever paid for the lesson. Not a second copy of the explanation, which would rot; the file’s own words, read at the moment of failure. TRIPWIRES in that file is the registry, and vitest.config.ts checks every path in it exists before collecting a single test.

TestWhat it protects
tests/contracts/routes.test.tsroute-catalogue drift. It statically diffs packages/contracts/src/api.ts against every route registration in the API, and its exception list must stay empty
tests/contracts/primitives.test.tsevery exported entity schema classified against the primitive ontology — a new schema that names no primitive fails
tests/contracts/citizenship.test.tsno capability may branch on being.kind. A human and an AI citizen get the same world
tests/skills/plugin-contract.test.tsthe registry gates exactly the world-acting skills — no more, no fewer
tests/db/single-owner-invariant.test.tsone owner per node
tests/identity/no-key-material.test.tsno private key ever reaches a client — in any response body, including an error
tests/modules/frame-isolation.chrome.test.tsmodules never run in the app’s origin — proved by driving a real Chrome, because jsdom implements no iframe sandbox and would pass for the wrong reason

The last two are the ones whose absence is hardest to notice, because both prove that something cannot be reached — a class of property that fails silently by definition. The browser proof is also the one that spent longest being run by nobody: it is gated behind NOVA_CHROME_TESTS=1, and until pnpm verify and CI set that flag, the single security property the whole module system rests on had no automated proof at all.

Updating packages/contracts

  1. Add or extend a Zod schema in packages/contracts/src/*.ts — additively. If you must change an existing field’s shape (not just add one), think hard about who else reads that row first.
  2. Add the route to the catalogue in api.ts in the same change. The catalogue is not documentation that follows the code; a route the catalogue does not know about fails the build.
  3. Add an entry to CHANGELOG-contracts.md at the repo root, in the same terse style as the existing entries: what changed, the route or WS event affected, the exact new shape.
  4. Run pnpm typecheck from the root — a contract change that breaks another package shows up here immediately, since every package imports the contract types directly rather than duplicating them.

One trap that has cost real data

PATCH bodies use a purpose-built partial helper, never Zod’s .partial(). .partial() leaves .default() in place, and a default fires on undefined — so a one-field patch parses into a complete object, and the merge writes every other field back to its default. That emptied a live account’s profile, across five routes, before anyone noticed. If you are adding a PATCH, look at how the neighbouring ones do it.

Database changes

Terminal window
pnpm db:push # apps/api's schema push, from the root

Schema lives in apps/api/src/db (Drizzle ORM over better-sqlite3). db:push runs tsx src/db/push.ts (apps/api/package.json), not drizzle-kit push directly: it calls the exact same initDatabase() the server runs at boot — Drizzle migrations plus the raw FTS5 virtual table and triggers drizzle-kit push doesn’t know about — so it’s idempotent and non-interactive, safe to run repeatedly, and never blocks on the confirmation prompt drizzle-kit push would raise when it sees FTS5 objects it doesn’t recognise and proposes dropping them. There’s no migration history file today; schema changes should be additive and backward-compatible with existing rows wherever possible, the same discipline as the contracts changelog.

Skills and generators

  • A new skill is a defineSkill({ name, description, category, input, output, run }) call (packages/skills/src/registry.ts) registered into CORE_SKILLS (or a Twin-specific registry extension). Its name should be added to BUILTIN_SKILLS in packages/contracts/src/skills.ts if it’s meant to be usable from a generator step — a step referencing a skill outside that list fails generator validation.
  • A new generator follows the authoring kit in apps/api/src/studio/generators/define.ts (form(), str()/text()/num()/bool()/pick() for the input schema, step() for each pipeline stage) and must pass validateSeedGenerators() — unique ids/slugs, every skill real, every dependsOn resolved, no step on the strong tier, every declared outputKind actually produced. Add it to SEED_GENERATORS in apps/api/src/studio/generators/index.ts and to the Generators catalogue here in the docs.

Documentation

Docs content lives in docs/content/**/*.md and is deliberately decoupled from the site that renders it (apps/docs, Astro + Starlight) — writing a page here never requires touching the site build. Match the existing house style: frontmatter with title + one-sentence description, plain prose grounded in real code (a real request/response, a real config snippet, a real skill invocation), and — where PLAN.md describes something the code doesn’t actually do — say so plainly rather than describing the aspiration as if it shipped. Every numbered section directory needs an index.md; sub-pages follow the section’s own established naming pattern (check the directory before inventing a new one).