The docs/scaffolding/ directory held two how-to guides (core-package
+ core-ui-component generator references). Both are operationally
identical in shape to docs/guides/scaffolding-a-feature.md — they
just live in a separate top-level docs directory. Consolidating
removes one directory + makes the three scaffolding guides
discoverable as siblings.
Moves (via git mv to preserve history):
docs/scaffolding/core-package-generator.md
-> docs/guides/scaffolding-core-package.md
docs/scaffolding/core-ui-component-generator.md
-> docs/guides/scaffolding-core-ui-component.md
Empty docs/scaffolding/ directory removed.
AGENTS.md (only consumer of the old paths) updated to point at the
new locations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
31 KiB
AGENTS.md — Vertical Feature Monorepo
This is a Turborepo + pnpm monorepo organized by vertical features. Each feature package owns its own Clean Architecture layers (entities, application, infrastructure, interface-adapters) and integrations (CMS collections, tRPC routers, UI components). Core packages provide foundation: primitives, design system, CMS composition, API aggregation, and tRPC client platform.
Vocabulary: Every cross-cutting term used in this repo (feature, use case, manifest, slice, conformance, dispatch, etc.) is defined in
docs/glossary.md. When in doubt about what a term means here, check the glossary first — it's the single source for shared vocabulary between humans and agents.
Agent-driven development
This template assumes agents (Claude, Codex, etc.) will author most feature work. The orchestration substrate is Sandcastle — see ADR-019. Day-to-day entry points:
pnpm work next/ready/blocked— DAG-aware task selection fromdocs/work/pnpm work dispatch— print the next dispatch plan (planning mode, no agent invoked)pnpm work dispatch --execute— invoke sandcastle (requiresANTHROPIC_API_KEY).sandcastle/— 5 prompt templates (PRD eliciter, ADR eliciter, decomposer, implementer, reviewer); all enforce generator-first (pnpm turbo gen <kind>over hand-rolling)
Every feature has a src/feature.manifest.ts declaring its use cases AND its coverage bands. Every bindProductionX(ctx) and bindDevSeedX(ctx) self-asserts at its tail via assertFeatureConformance(...). Quality is enforced by two parallel multi-latency systems:
- Conformance (5 gates) — TypeScript brands (0s), ESLint (<1s), boot (~3s),
pnpm conformance(~120s),pnpm fallow(~30–60s). Catches manifest↔code drift. Seedocs/guides/conformance-quickref.md. - Coverage (4 layers, ADR-020) — L0 vitest thresholds, L1
pnpm coverage:diff(cover-the-diff gate), L2pnpm coverage:aggregate→ committedcoverage/summary.json, L3pnpm mutate(nightly). The manifest'scoverage.bandsis the single source of truth. Seedocs/guides/coverage.md.
See docs/guides/runbook.md for the full workflow.
Package Map
| Package | Tag | Purpose |
|---|---|---|
@repo/core-shared |
core | Generic primitives (Zod, env, Payload hooks/fields/blocks, tRPC init/context) |
@repo/core-ui |
core | Design system (atoms, molecules, generic organisms, templates) — optional, scaffold via pnpm turbo gen core-package ui |
@repo/core-audit |
core | DPA-compliant audit logging (4 impls, GDPR erasure, OTel correlation) — optional, scaffold via pnpm turbo gen core-package audit |
@repo/core-api |
core-composition | tRPC router aggregator — imports @repo/<feature>/api only |
@repo/core-cms |
core-composition | Payload config aggregator — imports @repo/<feature>/cms only |
@repo/core-trpc |
core-composition | Frontend tRPC client + framework-specific providers (Next.js, TanStack) |
@repo/auth |
feature | Users collection + sign-in/up/out |
@repo/blog |
feature | Articles collection + article use-cases |
@repo/media |
feature | Media collection + upload helpers |
@repo/marketing-pages |
feature | Pages collection + SiteSettings global |
@repo/navigation |
feature | Header global |
@repo/core-eslint |
tooling | Shared ESLint 9 flat configs (base, next, react-internal, boundaries) |
@repo/core-typescript |
tooling | Shared TypeScript base configs + Vitest base |
@repo/core-testing |
tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks) |
Boundary Rules
Five tags
- app (4 packages) —
apps/web-next,apps/web-tanstack,apps/cms,apps/storybook - core-composition (3 packages) —
packages/core-api,core-cms,core-trpc - core (1–2 packages) —
packages/core-shared;core-uiis optional (scaffold withpnpm turbo gen core-package ui) - feature (5 packages) —
packages/auth,blog,media,marketing-pages,navigation - tooling (3 packages) —
packages/core-eslint,core-typescript,core-testing
Allowed dependency directions
| Tag | May depend on |
|---|---|
| app | app, core, core-composition, feature, tooling |
| core-composition | core, core-composition, feature, tooling |
| core | core, core-composition, tooling |
| feature | core, tooling |
| tooling | tooling |
Composition exceptions
core-apimay import@repo/<feature>/apisubpath exports only (to compose tRPC routers).core-cmsmay import@repo/<feature>/cmssubpath exports only (to compose Payload collections).core-trpcreaches features transitively throughcore-api'sAppRoutertype.
No other cross-package boundary deviations are permitted.
Four enforcement layers
package.jsondependencies — only allowed deps are declared; illegal imports fail at install time.exportsmaps — feature packages expose.,./ui,./cms,./api,./di/bind-production,./di/bind-dev-seedonly; no deep source paths exist.- ESLint
eslint-plugin-boundaries(lint-time) — configured inpackages/core-eslint/:- Enforces the five-tag rules at linting
- Feature packages may import from
coreand tooling only. core-shared,core-uimay not import any feature.core-apirestricted to@repo/<feature>/apiimports.core-cmsrestricted to@repo/<feature>/cmsimports.- No
../../../cross-package relative imports.
- Turborepo
boundaries(build-graph time) — configured in rootturbo.json:- Validates the entire workspace dependency graph, including transitive dependencies
- Catches issues ESLint might miss (e.g., transitive feature reaches through composition packages)
- Run with
pnpm turbo boundaries
Adding a Feature
Fast path — use the generator. pnpm turbo gen feature scaffolds a package under packages/<name>/ (single entity, single getX use case) matching the navigation reference shape. It emits package files, entities, use case + controller (with input/output schemas + presenter), mock + real repositories, DI container, both binders (bind-production / bind-dev-seed), tRPC procedures + router with tests, contract suite, dev seed, and an empty ui/ barrel — all wired with the span + capture sandwich at bind time.
pnpm turbo gen feature # interactive
pnpm turbo gen feature --args widgets Widget widgets # non-interactive: <name> <Entity> <entities-plural>
The generator does NOT wire aggregators or emit Payload CMS templates / faker factories / multi-entity layouts. After running, hand-edit apps/web-next/src/server/bind-production.ts, packages/core-api/src/root.ts, and the two package.json files (the generator prints the exact checklist on success). See docs/guides/scaffolding-a-feature.md for the full reference.
Manual path. When the generator's scope doesn't fit (multiple entities/use cases, custom layout, extending an existing feature), follow docs/guides/adding-a-feature.md — a step-by-step walkthrough covering folder structure, Clean Architecture layers, Payload + tRPC integration, core wiring, and testing / lint validation.
Key Commands
pnpm install # Install all dependencies
pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
pnpm typecheck # Type-check all packages
pnpm lint # Lint all packages (ESLint boundaries enforced)
pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries)
pnpm turbo gen feature # Scaffold a new feature package (see docs/guides/scaffolding-a-feature.md)
pnpm turbo gen core-package # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/guides/scaffolding-core-package.md)
pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/guides/scaffolding-core-ui-component.md)
pnpm test # Run all unit + integration tests (Vitest)
pnpm test:e2e # Run e2e tests (Playwright across both apps)
pnpm build # Build all packages (Turborepo)
docker compose up -d # Start PostgreSQL
# Filtered commands
pnpm dev --filter @repo/web-next # Only Next.js app
pnpm dev --filter @repo/cms # Only CMS admin
pnpm dev --filter @repo/storybook # Only Storybook
pnpm typecheck --filter @repo/blog # Only blog feature
pnpm test --filter @repo/blog # Only blog unit/integration tests
Per-Package Conventions
Canonical summary:
CLAUDE.md§ Key Conventions. Decision records:docs/decisions/adr-012-feature-conventions.mdanddocs/decisions/adr-013-input-output-unification.md.
Source files use RELATIVE imports (not @/)
Inside src/ files, import from sibling layers using relative paths (no .js extension — modern Node/Vitest resolves without it):
// packages/blog/src/application/use-cases/get-articles.use-case.ts
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
import { BLOG_SYMBOLS } from "../../di/symbols";
import type { Article } from "../../entities/models/article";
Entity models live at entities/models/<x>.ts; domain errors at entities/errors/<domain>.ts; the shared InputParseError at entities/errors/common.ts.
Mock siblings use the .mock.ts suffix (<x>.repository.mock.ts); real repository impls drop the Payload prefix (articles.repository.ts); interface filenames are dot-separated (articles.repository.interface.ts).
This keeps source code portable and avoids circular alias issues.
Test files use @/ alias
Test files (*.test.ts) use the @/ alias to import from src/:
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
vitest.config.ts MUST declare @/ alias
Every package's vitest.config.ts must define the alias:
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "node", globals: true },
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
tsconfig.json rootDir = "."
TypeScript configs must set "rootDir": "." to allow both src/ and test files to coexist:
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist"
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
Use cases own input + output schemas
Every use-case file exports its Zod schemas and inferred types. The use case body validates its output before returning — a misbehaving repository fails loudly at the layer that owns the contract.
// packages/blog/src/application/use-cases/get-articles.use-case.ts
import { z } from "zod";
import { articleSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const getArticlesInputSchema = z
.object({ status: z.string().optional(), limit: z.number().int().optional() })
.strict();
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const getArticlesOutputSchema = z.array(articleSchema);
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
export const getArticlesUseCase =
(articlesRepository: IArticlesRepository) =>
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
const result = await articlesRepository.getArticles(input);
return getArticlesOutputSchema.parse(result);
};
Void-input use cases use z.object({}).strict() and accept _input: XInput. Void-output use cases (e.g. signOutUseCase, deleteMediaUseCase) export only xInputSchema — no xOutputSchema.
Tests inject mocks directly — no container rebinding:
const repo = new MockArticlesRepository([]);
const useCase = getArticlesUseCase(repo);
const articles = await useCase({ status: "published" });
Controllers receive unknown + presenter
Controllers safeParse(xInputSchema) from the use-case file and throw InputParseError on failure. Every non-void controller defines a top-level function presenter(value: XOutput) and returns Promise<ReturnType<typeof presenter>>. Identity is fine — return value — but the function form is always present so adding a transform later is a one-line edit.
// packages/blog/src/interface-adapters/controllers/get-articles.controller.ts
import { InputParseError } from "../../entities/errors/common";
import {
getArticlesInputSchema,
type GetArticlesOutput,
type IGetArticlesUseCase,
} from "../../application/use-cases/get-articles.use-case";
function presenter(value: GetArticlesOutput) {
return value;
}
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
export const getArticlesController =
(getArticlesUseCase: IGetArticlesUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticlesInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid input", { cause: parsed.error });
}
return presenter(await getArticlesUseCase(parsed.data));
};
Void controllers (e.g. signOutController, deleteMediaController) return Promise<void> and skip the presenter entirely. One controller file per use case — no multi-method controller files.
DI binds each factory with .toDynamicValue():
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue(
(ctx) =>
getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository)),
);
Feature-scoped tRPC error mapping
Each feature owns integrations/api/procedures.ts that wires domain errors to tRPC codes. core-shared provides the defineErrorMiddleware factory but never enumerates feature error classes.
// packages/blog/src/integrations/api/procedures.ts
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { ArticleNotFoundError } from "../../entities/errors/article";
import { InputParseError } from "../../entities/errors/common";
export const blogProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[ArticleNotFoundError, "NOT_FOUND"],
]),
);
The router then uses blogProcedure.input(xInputSchema) for every procedure — schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as TRPCError(code: INTERNAL_SERVER_ERROR); the original domain error is preserved as .cause.
Per-feature public-API surface
Each feature package exposes exactly these subpath exports:
| Subpath | What it exports | Who consumes |
|---|---|---|
. (root) |
Contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants |
Any consumer |
./ui |
Query builders (queryOptions), UI components |
App packages |
./api |
tRPC router (xRouter + XRouter type) |
@repo/core-api only |
./cms |
Payload collections | @repo/core-cms only |
./di/bind-production |
App boot side-effect — swaps mock for real Payload impl | App packages only |
./di/bind-dev-seed |
App boot side-effect — swaps empty mock for populated mock | App packages, storybook |
Apps import schemas/types from @repo/<feature> (root) and React Query builders from @repo/<feature>/ui. Deep source paths are not accessible — the exports map enforces this.
Payload-backed features use constructor injection
Feature packages that need Payload receive the SanitizedConfig via constructor, not via @repo/core-cms dependency:
// packages/blog/src/infrastructure/repositories/articles.repository.ts
@injectable()
export class ArticlesRepository implements IArticlesRepository {
constructor(private config: SanitizedConfig) {}
async getArticles(options?: {
status?: string;
limit?: number;
}): Promise<Article[]> {
const payload = await getPayload({ config: this.config });
// ...
}
}
Class names carry no Payload prefix — ArticlesRepository, PagesRepository, HeaderRepository, etc. The config comes from the app at boot time (see below).
Apps call bindAll() per feature at boot
Each app (web-next, web-tanstack, cms) imports both binders per feature and uses a small dispatcher (bindAll()) that picks based on environment:
USE_DEV_SEED === "true"→ dev seed (explicit override; works in anyNODE_ENV)NODE_ENV === "production"→ production (real Payload)- otherwise → dev seed (developer default;
pnpm devboots without Payload)
// apps/web-next/src/server/bind-production.ts
// Slim default template — no optional packages scaffolded yet.
// After running e.g. `pnpm turbo gen core-package events`, the full
// IEventBus type can be plugged via the generic args
// `BindProductionContext<IEventBus, ...>` and the bus/queue construction
// (resolveEventsAndJobsProduction) wires back in per the printed next-steps.
import type { BindProductionContext, BindContext } from "@repo/core-shared/di";
export async function bindAllProduction(): Promise<void> {
const { tracer, logger } = resolveInstrumentation();
const resolvedConfig = await config;
const ctx: BindProductionContext = {
config: resolvedConfig,
tracer,
logger,
};
bindProductionAuth(ctx);
bindProductionBlog(ctx);
bindProductionMarketingPages(ctx);
bindProductionNavigation(ctx);
bindProductionMedia(ctx);
}
export async function bindAllDevSeed(): Promise<void> {
const { tracer, logger } = resolveInstrumentation();
const ctx: BindContext<
IEventBus,
IRealtimeBroadcaster,
IRealtimeHandlerRegistry
> = {
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
};
await bindDevSeedAuth(ctx);
await bindDevSeedBlog(ctx);
// ... (same for marketing-pages, navigation, media)
}
Actual function names: bindProductionAuth, bindProductionBlog, bindProductionMarketingPages, bindProductionNavigation, bindProductionMedia.
Each feature binder signature is (ctx: BindProductionContext): void for production and (ctx: BindContext): Promise<void> for dev-seed. Required ctx fields: tracer, logger. Production-only: config. Optional: bus, queue, realtime, realtimeRegistry.
Conformance contract (every feature)
Every feature package MUST declare a src/feature.manifest.ts using defineFeature from @repo/core-shared/conformance. The manifest declares the use cases, what they audit/publish/consume, and which optional cores they require.
The feature's src/di/bind-production.ts MUST call assertFeatureConformance(container, manifest, symbols, ctx) at the tail of bindProduction<Name> so pnpm dev refuses to boot if a binding loses its brand.
Re-export the manifest from src/index.ts:
export { fooManifest, type FooManifest } from "./feature.manifest";
See docs/guides/conformance-quickref.md for the canonical pattern; the generator (pnpm turbo gen feature <name>) emits all of this correctly by default.
Cross-feature events and background jobs (ADR-015)
Three rules:
- E0: Events are for cross-feature decoupling. In-feature reactions are direct use-case calls — do not use the bus.
- E1: Event contracts are exported from the publisher's root; handlers are private to the consumer's bind-* files (never re-exported, ESLint-enforced).
- J0: Jobs are for deferred work, not abstraction. Synchronous code stays synchronous.
@repo/core-events provides IEventBus (InMemoryEventBus for dev/test, PayloadJobsEventBus for prod). @repo/core-shared/jobs provides IJobQueue (InMemoryJobQueue / PayloadJobQueue). Both are swapped by bindAll() using the same USE_DEV_SEED / NODE_ENV rules as repositories.
Per-feature folders (all optional): events/<x>.event.ts, events/handlers/on-<publisher>-<event>.handler.ts, jobs/<x>.job.ts, integrations/cms/jobs/<x>.task.ts.
Use the generators: pnpm turbo gen event {publish|consume}, pnpm turbo gen job. They insert at six fixed // <gen:*> anchor comments present in every feature.
See docs/guides/events-and-jobs.md and docs/decisions/adr-015-events-and-jobs.md.
Realtime layer (ADR-016)
Three rules:
- R0: Realtime is for state delivery, not for replacing tRPC. Persistent operations with request/response semantics belong on tRPC procedures. Use realtime when the server needs to push without a request, or the data is too high-frequency for HTTP.
- R1: Channel descriptors are exported; handlers are private. A feature's
realtime/<name>.channel.tsis re-exported from the package root barrel;realtime/handlers/*.handler.tsis wired only in the feature's own bind-* files and never re-exported (ESLint-enforced viano-realtime-handler-reexport). - R2:
socket.iolives in one package only. Feature packages MUST NOTimport "socket.io"orimport "socket.io-client". Allowlist:packages/core-realtime/src/socket-io-*.ts+apps/*/server.ts. ESLint ruleno-direct-socket-ioenforces this.
@repo/core-realtime provides IRealtimeBroadcaster (server → client), IRealtimeHandlerRegistry (client → server), and the SocketIORealtimeServer adapter. apps/web-next/server.ts replaces next start/next dev with a custom Node http server hosting both Next.js and Socket.IO on port 3000.
Use the generators: pnpm turbo gen realtime channel, pnpm turbo gen realtime handler. They insert at three fixed // <gen:realtime-*> anchor comments per feature.
See docs/guides/realtime.md and docs/decisions/adr-016-realtime-layer.md.
Instrumentation conventions
Substrate: OpenTelemetry SDK (ADR-017). Sentry is wired as the exporter via @sentry/opentelemetry. Vendor swaps are exporter swaps — feature code never touches Sentry or OTel SDK directly.
Symbols (in core-shared/instrumentation/symbols.ts):
INSTRUMENTATION_SYMBOLS.ITracer— bound toITracer(NoopTracer/OtelTracer)INSTRUMENTATION_SYMBOLS.ILogger— bound toILogger(NoopLogger/OtelLogger)INSTRUMENTATION_SYMBOLS.IMetrics— bound toIMetrics(NoopMetrics/OtelMetrics)
Repository constructor signature (every feature):
constructor(
config: SanitizedConfig,
tracer: ITracer = new NoopTracer(),
logger: ILogger = new NoopLogger(),
)
Repository method body (every public async method):
return this.tracer.startSpan(
{ name: "<entity>.<method>", op: "repository", attributes: { /* ... */ } },
async (span) => {
try {
const result = await /* payload op */;
span.setAttribute("count", /* ... */);
return result;
} catch (err) {
this.logger.captureException(err, {
tags: { feature: "<feature>", repo: "<entity>", method: "<method>" },
});
span.setStatus("error", err instanceof Error ? err.message : String(err));
throw err;
}
},
);
Use case + controller spans + capture (applied at DI bind time):
const wrappedUC = withSpan(
tracer,
{ name: "blog.getArticles", op: "use-case" },
withCapture(
logger,
{ feature: "blog", layer: "use-case", name: "blog.getArticles" },
getArticlesUseCase(repo),
),
);
const wrappedCtrl = withSpan(
tracer,
{ name: "blog.getArticles", op: "controller" },
withCapture(
logger,
{ feature: "blog", layer: "controller", name: "blog.getArticles" },
getArticlesController(wrappedUC),
),
);
withSpan is outermost; withCapture is between span and factory so the error is captured before the span closes with error status. Bodies stay vendor-clean — neither use cases nor controllers call tracer / logger inline.
Capture rules (each error captured exactly once via the __sentryReported flag from core-shared/instrumentation/reported-flag.ts):
| Layer | Captures | Doesn't capture |
|---|---|---|
| Repository | Infra/Payload errors that originate here (inline in catch) | Bubbled errors |
| Use case | Business-rule violations + output-schema failures originated in this body (via withCapture) |
Errors from repos — flag set, withCapture bails |
| Controller | InputParseError from safeParse failure (via withCapture) |
Errors from use cases — flag set, withCapture bails |
defineErrorMiddleware |
Nothing — maps domain → TRPCError only | — |
Boundary rules (eslint-enforced):
Feature packages MUST NOT import "@sentry/*" or import "@opentelemetry/sdk-*". Allowlists:
@sentry/*:**/instrumentation/otel/sentry-bridge.{ts,js},**/instrumentation/sentry/init-client*.{ts,js},**/instrumentation/sentry/init-server*.{ts,js},**/setup/no-instrumentation.{ts,js},apps/*/instrumentation*.{ts,mjs,js},apps/*/next.config.{mjs,ts,js},apps/*/vite.config.{ts,mjs,js}@opentelemetry/sdk-*,@opentelemetry/instrumentation-*,@opentelemetry/resources,@opentelemetry/semantic-conventions,@sentry/opentelemetry:**/instrumentation/otel/**
The vendor-neutral API packages (@opentelemetry/api, @opentelemetry/api-logs) are unrestricted within core-shared/instrumentation/.
Test rules:
- Default to
NoopTracer/NoopLogger/NoopMetrics(constructor defaults) - Assert spans/captures by injecting
RecordingTracer/RecordingLogger/RecordingMetricsfrom@repo/core-testing/instrumentation - Real Sentry SDK + OTel SDK MUST NOT initialize during tests (guarded by
core-testing/setup/no-instrumentation.ts; old aliasno-sentrykept for one release)
Specification & Guides
- Vertical Feature Spec —
docs/architecture/vertical-feature-spec.md— full design, rationale, decision log - Architecture Overview —
docs/architecture/overview.md— package responsibilities, data flow - Dependency Flow —
docs/architecture/dependency-flow.md— allowed directions and composition pattern - Scaffolding a Feature —
docs/guides/scaffolding-a-feature.md—turbo gen featurereference (fast path) - Adding a Feature Guide —
docs/guides/adding-a-feature.md— step-by-step new feature walkthrough (manual path) - Events and Jobs Guide —
docs/guides/events-and-jobs.md— publish, consume, schedule background work - Realtime Guide —
docs/guides/realtime.md— declare channels, broadcast, receive - Testing Strategy —
docs/guides/testing-strategy.md— test placement, Vitest per-package, Playwright e2e - TDD Workflow —
docs/guides/tdd-workflow.md— red-green-refactor cycle, mocking decision tree, coverage targets
Per-package documentation lives in each AGENTS.md:
packages/core-shared/AGENTS.mdpackages/core-api/AGENTS.md,core-cms/AGENTS.md,core-trpc/AGENTS.mdpackages/core-ui/AGENTS.md(optional — generated bypnpm turbo gen core-package ui; seeturbo/generators/templates/core-package/ui/AGENTS.md.hbs)packages/auth/AGENTS.md,blog/AGENTS.md,media/AGENTS.md,marketing-pages/AGENTS.md,navigation/AGENTS.mdpackages/core-eslint/AGENTS.md,core-typescript/AGENTS.md,core-testing/AGENTS.mdapps/cms/AGENTS.md,web-next/AGENTS.md,web-tanstack/AGENTS.md,storybook/AGENTS.md