docs(adr): ADR-019 — Sandcastle for agent orchestration
Captures the decision to adopt @ai-hero/sandcastle as the orchestration substrate for agent-driven development in this template. Records the 8-point decision (workspace dep, .sandcastle/ prompts, Dockerfile, dispatch.mjs orchestrator, planning vs execute modes, generator-first reviewer check, bring-your-own-key, per-task max-attempts), the four alternatives considered (bare CLI / Copilot Workspace / custom-from- scratch / no orchestrator), and four trade-offs (external dep, token cost, Docker dependency, manual state mutation in v1). Surfaces the decision at the top of README.md and AGENTS.md so new contributors see the agent-driven framing before they hit the package map or daily commands.
This commit is contained in:
118
AGENTS.md
118
AGENTS.md
@@ -2,26 +2,37 @@
|
||||
|
||||
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.
|
||||
|
||||
## Agent-driven development
|
||||
|
||||
This template assumes agents (Claude, Codex, etc.) will author most feature work. The orchestration substrate is [Sandcastle](https://github.com/mattpocock/sandcastle) — see [ADR-019](./docs/decisions/adr-019-sandcastle-for-agent-orchestration.md). Day-to-day entry points:
|
||||
|
||||
- `pnpm work next` / `ready` / `blocked` — DAG-aware task selection from `docs/work/`
|
||||
- `pnpm work dispatch` — print the next dispatch plan (planning mode, no agent invoked)
|
||||
- `pnpm work dispatch --execute` — invoke sandcastle (requires `ANTHROPIC_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. Every `bindProductionX(ctx)` and `bindDevSeedX(ctx)` self-asserts at its tail via `assertFeatureConformance(...)`. Five conformance gates catch drift at four latency tiers: TypeScript (0s), ESLint (<1s), boot (~3s), CI (`pnpm conformance`, `pnpm fallow`). See `docs/guides/conformance-quickref.md` and `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) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -37,13 +48,13 @@ This is a **Turborepo + pnpm monorepo** organized by vertical features. Each fea
|
||||
|
||||
### 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 |
|
||||
| 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
|
||||
|
||||
@@ -254,8 +265,10 @@ Void controllers (e.g. `signOutController`, `deleteMediaController`) return `Pro
|
||||
DI binds each factory with `.toDynamicValue()`:
|
||||
|
||||
```typescript
|
||||
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase)
|
||||
.toDynamicValue((ctx) => getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository)));
|
||||
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue(
|
||||
(ctx) =>
|
||||
getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository)),
|
||||
);
|
||||
```
|
||||
|
||||
### Feature-scoped tRPC error mapping (Plan 9, R13–R17)
|
||||
@@ -283,14 +296,14 @@ The router then uses `blogProcedure.input(xInputSchema)` for every procedure —
|
||||
|
||||
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 |
|
||||
| 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.
|
||||
|
||||
@@ -304,7 +317,10 @@ Feature packages that need Payload receive the `SanitizedConfig` via constructor
|
||||
export class ArticlesRepository implements IArticlesRepository {
|
||||
constructor(private config: SanitizedConfig) {}
|
||||
|
||||
async getArticles(options?: { status?: string; limit?: number }): Promise<Article[]> {
|
||||
async getArticles(options?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}): Promise<Article[]> {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
// ...
|
||||
}
|
||||
@@ -350,8 +366,17 @@ export async function bindAllProduction(): Promise<void> {
|
||||
export async function bindAllDevSeed(): Promise<void> {
|
||||
const { tracer, logger } = resolveInstrumentation();
|
||||
|
||||
const ctx: BindContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
|
||||
tracer, logger, bus, queue, realtime, realtimeRegistry,
|
||||
const ctx: BindContext<
|
||||
IEventBus,
|
||||
IRealtimeBroadcaster,
|
||||
IRealtimeHandlerRegistry
|
||||
> = {
|
||||
tracer,
|
||||
logger,
|
||||
bus,
|
||||
queue,
|
||||
realtime,
|
||||
realtimeRegistry,
|
||||
};
|
||||
|
||||
await bindDevSeedAuth(ctx);
|
||||
@@ -387,8 +412,8 @@ See `docs/guides/conformance-quickref.md` for the canonical pattern; the generat
|
||||
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.
|
||||
- **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.
|
||||
|
||||
@@ -405,7 +430,7 @@ See `docs/guides/events-and-jobs.md` and `docs/decisions/adr-015-events-and-jobs
|
||||
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.ts` is re-exported from the package root barrel; `realtime/handlers/*.handler.ts` is wired only in the feature's own bind-* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`).
|
||||
- **R1:** Channel descriptors are exported; handlers are private. A feature's `realtime/<name>.channel.ts` is re-exported from the package root barrel; `realtime/handlers/*.handler.ts` is wired only in the feature's own bind-\* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`).
|
||||
- **R2:** `socket.io` lives in one package only. Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. Allowlist: `packages/core-realtime/src/socket-io-*.ts` + `apps/*/server.ts`. ESLint rule `no-direct-socket-io` enforces 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.
|
||||
@@ -421,6 +446,7 @@ See `docs/guides/realtime.md` and `docs/decisions/adr-016-realtime-layer.md`.
|
||||
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 to `ITracer` (`NoopTracer` / `OtelTracer`)
|
||||
- `INSTRUMENTATION_SYMBOLS.ILogger` — bound to `ILogger` (`NoopLogger` / `OtelLogger`)
|
||||
- `INSTRUMENTATION_SYMBOLS.IMetrics` — bound to `IMetrics` (`NoopMetrics` / `OtelMetrics`)
|
||||
@@ -483,12 +509,12 @@ const wrappedCtrl = withSpan(
|
||||
|
||||
**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 | — |
|
||||
| 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, R40 + R52):**
|
||||
Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"`. Allowlists:
|
||||
@@ -499,6 +525,7 @@ Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"
|
||||
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` / `RecordingMetrics` from `@repo/core-testing/instrumentation`
|
||||
- Real Sentry SDK + OTel SDK MUST NOT initialize during tests (guarded by `core-testing/setup/no-instrumentation.ts`; old alias `no-sentry` kept for one release)
|
||||
@@ -518,6 +545,7 @@ The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`
|
||||
- **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.md`
|
||||
- `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md`, `core-trpc/AGENTS.md`
|
||||
- `packages/core-ui/AGENTS.md` (optional — generated by `pnpm turbo gen core-package ui`; see `turbo/generators/templates/core-package/ui/AGENTS.md.hbs`)
|
||||
|
||||
Reference in New Issue
Block a user