Code conventions
This page is the quick reference for contributors hacking on the klera codebase itself. It is not a guide for adopters; if you’re authoring flows, head over to authoring prose flows.
The repo’s CLAUDE.md is the canonical source — when in doubt, read
that first. The rules below are the subset that comes up on every PR.
Workspace layout
The repo is a pnpm workspace with eleven packages under packages/*,
plus an Expo demo at examples/expo-demo that lives outside the
pnpm workspace and uses plain npm. Run cd examples/expo-demo && npm install once after cloning; pnpm -w commands do not cover it.
vitest.config.ts aliases @klera/* directly to each package’s
src/index.ts, so tests run against source without a build step. Don’t
run pnpm build just to make tests see a change.
Language
TypeScript, strict mode. Three flags are non-negotiable:
exactOptionalPropertyTypesnoUncheckedIndexedAccessverbatimModuleSyntax
Don’t fight them. If something feels awkward — a type predicate that won’t narrow, an array access that won’t compile without a guard — the fix is almost always to be more precise about what you mean, not to loosen the flag.
Modules
ESM throughout. Imports use the .js extension even for .ts sources
(TypeScript ESM convention):
import { runFlow } from './runner.js';Workspace packages depend on each other through src/index.ts exports
only — import/no-internal-modules enforces this. If you need to
reach into another package’s internals, the right move is to widen the
exporting package’s surface, not to deep-import.
Validation
Parse at trust boundaries with Zod. Never as SomeType across a
boundary.
// Wrong: cast across a boundary.
const flow = JSON.parse(raw) as Flow;
// Right: validate at the boundary.
const flow = Flow.parse(JSON.parse(raw));The boundaries that matter today are: YAML / Markdown flow files,
WebSocket messages between runtime and bridge, MCP tool inputs, planner
LLM responses, on-disk report JSON. Each has a Zod schema in
@klera/protocol; reach for safeParse when you want a structured
error and parse when “the file is corrupt” is the only sensible
response.
Async
async/await everywhere. No floating promises —
@typescript-eslint/no-floating-promises enforces this. If you’re
firing-and-forgetting on purpose, mark it explicitly:
void backgroundFlush(); // intentionalLogging
Route through a per-package logger. console.log is forbidden in
shipped code; the lint rule will catch it. console.warn and
console.error are allowed for library fallbacks where a real logger
isn’t available yet (e.g. early bootstrap before the logger is
constructed).
import { logger } from './logger.js';
logger.info('flow.start', { flow: name });
logger.error('matcher.exhausted', { target, attempts });Adopter-side log routing happens through the OTel correlated logger described in observability; within the codebase you only need to call the package logger.
Tests
Vitest. Co-locate *.test.ts next to the source file. Target ≥80%
coverage (enforced in vitest.config.ts). Test names read like
sentences:
test('self-heals when the parent re-keys', async () => { ... });Running a single test
By file
pnpm test packages/engine/src/matcher.test.tsFor local iteration, pnpm test:watch runs vitest in watch mode.
Lint, format, filenames, commits
- Lint:
@typescript-eslint/strict-type-checked. Don’t silence a rule without a comment explaining why. - Format: Prettier, repo config. CI runs
pnpm format:check. - File names: kebab-case.
unicorn/filename-caseenforces this. - Commit messages: Conventional Commits (
feat:,fix:,docs:,chore:,refactor:,test:). One logical change per commit.
feat(engine): add waitForIdle network gate
fix(matcher): self-heal across re-keyed parents
docs(observability): document OTLP backfillWorking on a new package? Add a folder under packages/ with
package.json, tsconfig.json, tsup.config.ts, src/index.ts. Name
it @klera/<short-name>. Add a project reference in the root
tsconfig.json. Add a design note under docs/decisions/ if the
package introduces a new concept or boundary.
Verification
Before declaring work done, run the full gate. CI runs the same commands; if they pass locally, they pass on PR.
Install once after pulling
pnpm installBuild every package
pnpm buildFans out across the workspace with -r --parallel.
Typecheck
pnpm typecheckTests
pnpm testLint
pnpm lintFormat
pnpm format:checkLocal iteration helpers (not part of the verification gate):
pnpm test:watch— vitest in watch mode.pnpm lint:fix— autofix lint where possible.pnpm format— rewrite files with prettier.
If anything fails, fix it before handing back. “It probably works” is not sufficient.
When in doubt
Prefer deleting to adding. Prefer derived state to primary state.
Prefer failing loudly to failing quietly. If you can’t decide between
two options, write a short design note in docs/decisions/ — the act
of writing usually makes the answer obvious.
Next: releasing for how a merge to main becomes a
published version on npm.