Implementation plan for the design at docs/superpowers/specs/2026-05-09-core- package-generator-design.md. Eight phases (numbered Phase 0..7 in the plan; Phase 0 is the read-first orientation, Phases 1-7 implement spec phases 0-5 plus a final cross-doc sweep). Phase 1 ships BindContext with bounded generics over protocol types in core- shared. Phase 2 ships the generator framework (entry, prompt, dispatch table — empty). Phases 3-6 each capture one optional package as a verbatim template, ship the generator action, and remove the package from main with byte-identical reconstruction verified via a snapshot. Phase 7 is the final cross-doc sweep (template-tiers.md, README updates, HTML explainer pass). Companion ADR will be assigned at implementation time (expected ADR-017). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 KiB
Core-package generator + template slimming — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Slim the default monorepo template to a minimal kernel by removing four optional core packages (core-realtime, core-events, core-trpc, core-ui) from main, and add a pnpm turbo gen core-package generator that scaffolds any of them back byte-identical to the current shipped versions.
Architecture: Six sequential phases. Phase 0 introduces a BindContext object in core-shared with bounded generics over minimal protocol types — feature binders take a single ctx arg, optional packages keep their full interfaces but extends-link to the protocols. Phase 1 ships the generator framework (entry, prompt, dispatch table — empty). Phases 2-5 each: capture a package's source as a verbatim template, ship the generator action that scaffolds the package + edits consuming-app config + patches core-eslint/base.js for any rules to re-add, then remove the package from main. Phase 6 is a final cross-doc sweep + an end-to-end "scaffold everything from slim main" acceptance test.
Tech Stack: TypeScript, Node 22, plop (via @turbo/gen), Vitest, ESLint, Turborepo, Inversify, Next.js, Payload CMS, Vite (for apps/web-tanstack), Storybook.
Spec: docs/superpowers/specs/2026-05-09-core-package-generator-design.md — read first, especially §4 (Phase 0 BindContext), §6 (per-package lifecycle), §6.2 (ESLint rule placement).
Phase numbering: the spec numbers work phases 0-5 (BindContext, framework, realtime, events, trpc, ui). This plan keeps the realtime-plan convention of using "Phase 0 — Read first" as the orientation preamble, so plan phases shift up by one. Mapping:
| Plan | Spec |
|---|---|
| Phase 0 (Read first) | (orientation; not in spec) |
| Phase 1 (BindContext refactor) | spec Phase 0 |
| Phase 2 (Generator framework) | spec Phase 1 |
| Phase 3 (core-realtime) | spec Phase 2 |
| Phase 4 (core-events) | spec Phase 3 |
| Phase 5 (core-trpc) | spec Phase 4 |
| Phase 6 (core-ui) | spec Phase 5 |
| Phase 7 (Final cross-doc sweep) | spec §3 trailing "+ a final cross-doc sweep" |
Phase 0 — Read first
- Step 1: Read the spec end-to-end
Open docs/superpowers/specs/2026-05-09-core-package-generator-design.md. The plan maps to its sections roughly 1:1. §4 (Phase 0 details), §6.1 (per-package variations table), §7 (testing), and §8 (doc/HTML updates) are load-bearing references.
- Step 2: Skim the existing generators
Read turbo/generators/config.ts (top to bottom; ~1000 lines). Note the existing patterns: setGenerator shape, prompts, actions, plop add / modify types, assertAnchors from lib/anchor-validate.js, the Handlebars helper eq. Phase 1 reuses all of these.
- Step 3: Read CLAUDE.md "Read first" docs
Confirm familiarity with:
AGENTS.md— package map, boundary rulesdocs/architecture/dependency-flow.md— current binder shapes (will change in Phase 0)docs/architecture/vertical-feature-spec.md— feature package conventions- ADR-008 (per-feature DI containers), ADR-014 (instrumentation), ADR-015 (events/jobs), ADR-016 (realtime)
Phase 1 — BindContext refactor
Goal: Decouple feature binders from optional package interfaces by introducing a single ctx arg parameterized over minimal protocol types defined in core-shared. Optional package interfaces remain in their packages but extends-link to the protocols.
Files touched in this phase:
- Create:
packages/core-shared/src/di/bind-protocols.ts - Create:
packages/core-shared/src/di/bind-protocols.test.ts - Create:
packages/core-shared/src/di/bind-context.ts - Create:
packages/core-shared/src/di/index.ts - Modify:
packages/core-shared/src/index.ts(add./diexport) - Modify:
packages/core-shared/package.json(add./disubpath export) - Modify:
packages/core-events/src/event-bus.interface.ts(extends EventBusProtocol) - Modify:
packages/core-realtime/src/realtime-broadcaster.interface.ts(extends RealtimeBroadcasterProtocol) - Modify:
packages/core-realtime/src/realtime-handler-registry.ts(extends RealtimeRegistryProtocol) - Modify: All 5 feature
bind-production.tsandbind-dev-seed.ts(10 files) - Modify:
apps/web-next/src/server/bind-production.ts(aggregator) - Modify:
apps/web-next/src/server/bind-production.test.ts - Modify:
packages/<each-feature>/src/di/bind-dev-seed.test.ts(5 files) - Modify:
docs/architecture/dependency-flow.md,docs/architecture/vertical-feature-spec.md,CLAUDE.md,AGENTS.md, per-featureAGENTS.md - Modify:
docs/architecture/di-explainer.html,docs/architecture/data-flow-explainer.html
Task 1.1: Create protocol types in core-shared (TDD)
Files:
-
Create:
packages/core-shared/src/di/bind-protocols.ts -
Create:
packages/core-shared/src/di/bind-protocols.test.ts -
Step 1: Write the failing test
Create packages/core-shared/src/di/bind-protocols.test.ts:
import { describe, it, expectTypeOf } from "vitest";
import type {
EventBusProtocol,
RealtimeBroadcasterProtocol,
RealtimeRegistryProtocol,
} from "./bind-protocols";
describe("EventBusProtocol", () => {
it("requires publish(event, payload) and subscribe(event, consumer, handler)", () => {
type Bus = EventBusProtocol;
expectTypeOf<Bus["publish"]>().toBeFunction();
expectTypeOf<Bus["subscribe"]>().toBeFunction();
});
});
describe("RealtimeBroadcasterProtocol", () => {
it("requires broadcast(channel, payload)", () => {
type Rt = RealtimeBroadcasterProtocol;
expectTypeOf<Rt["broadcast"]>().toBeFunction();
});
});
describe("RealtimeRegistryProtocol", () => {
it("requires register, registerChannel, listChannels", () => {
type Reg = RealtimeRegistryProtocol;
expectTypeOf<Reg["register"]>().toBeFunction();
expectTypeOf<Reg["registerChannel"]>().toBeFunction();
expectTypeOf<Reg["listChannels"]>().toBeFunction();
});
});
- Step 2: Run test to verify it fails
Run: pnpm --filter @repo/core-shared test src/di/bind-protocols.test.ts
Expected: FAIL — Cannot find module ./bind-protocols
- Step 3: Create the protocol types
Create packages/core-shared/src/di/bind-protocols.ts:
/**
* Minimal protocol surfaces used by feature binders to interact with optional
* cross-cutting infrastructure (event bus, realtime broadcaster, realtime
* handler registry). Lives in `core-shared` so `BindContext` can reference
* these unconditionally — features depend on `core-shared`, never on the
* optional packages directly.
*
* The optional packages' full interfaces (`IEventBus`, `IRealtimeBroadcaster`,
* `IRealtimeHandlerRegistry`) `extends` these — typechecks fail if a refactor
* narrows the protocol surface in a way the full interface would lose.
*/
export type EventBusProtocol = {
publish<T>(event: { name: string }, payload: T): Promise<void>;
subscribe<T>(
event: { name: string },
consumer: string,
handler: (payload: T) => Promise<void>,
): void;
};
export type RealtimeBroadcasterProtocol = {
broadcast<T>(
channel: { name: string; key?: unknown },
payload: T,
): Promise<void>;
};
export type RealtimeRegistryProtocol = {
register(entry: { descriptor: unknown; handler: unknown }): void;
registerChannel(descriptor: unknown): void;
listChannels(): unknown[];
};
- Step 4: Run test to verify it passes
Run: pnpm --filter @repo/core-shared test src/di/bind-protocols.test.ts
Expected: PASS — 3 tests
- Step 5: Commit
git add packages/core-shared/src/di/bind-protocols.ts packages/core-shared/src/di/bind-protocols.test.ts
git commit -m "feat(core-shared): bind-protocols (event bus / realtime / realtime registry)"
Task 1.2: Create BindContext type
Files:
-
Create:
packages/core-shared/src/di/bind-context.ts -
Step 1: Write
bind-context.ts
Create packages/core-shared/src/di/bind-context.ts:
import type { SanitizedConfig } from "payload";
import type { ITracer, ILogger } from "../instrumentation";
import type { IJobQueue } from "../jobs";
import type {
EventBusProtocol,
RealtimeBroadcasterProtocol,
RealtimeRegistryProtocol,
} from "./bind-protocols";
/** Always-present fields. Feature binders rely on these unconditionally. */
export type BindContextBase = {
tracer: ITracer;
logger: ILogger;
};
/**
* Optional cross-cutting deps. Generics let the app aggregator narrow the
* shape to full interfaces (`IEventBus`, `IRealtimeBroadcaster`, etc.); feature
* binders see only the protocol surface, which is enough for the methods they
* call. When an optional core package is absent the corresponding generic
* defaults to its protocol type, and `ctx.bus` / `ctx.realtime` are undefined
* at runtime.
*/
export type BindContext<
Bus extends EventBusProtocol = EventBusProtocol,
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
> = BindContextBase & {
bus?: Bus;
queue?: IJobQueue;
realtime?: Realtime;
realtimeRegistry?: RealtimeReg;
};
/** Production binders also receive the resolved Payload config. */
export type BindProductionContext<
Bus extends EventBusProtocol = EventBusProtocol,
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
> = BindContext<Bus, Realtime, RealtimeReg> & {
config: SanitizedConfig;
};
- Step 2: Run typecheck to confirm
Run: pnpm --filter @repo/core-shared typecheck
Expected: PASS
- Step 3: Commit
git add packages/core-shared/src/di/bind-context.ts
git commit -m "feat(core-shared): BindContext + BindProductionContext types"
Task 1.3: Add ./di subpath export
Files:
-
Create:
packages/core-shared/src/di/index.ts -
Modify:
packages/core-shared/package.json -
Step 1: Create the barrel
Create packages/core-shared/src/di/index.ts:
export * from "./bind-protocols";
export * from "./bind-context";
- Step 2: Add subpath export to package.json
In packages/core-shared/package.json, add to the exports object:
"./di": "./src/di/index.ts",
"./di/bind-protocols": "./src/di/bind-protocols.ts",
"./di/bind-context": "./src/di/bind-context.ts"
(Place these alphabetically among the existing ./jobs, ./payload, ./trpc/... entries.)
- Step 3: Verify resolution
Run: pnpm --filter @repo/core-shared typecheck
Expected: PASS
- Step 4: Commit
git add packages/core-shared/src/di/index.ts packages/core-shared/package.json
git commit -m "feat(core-shared): expose ./di subpath export"
Task 1.4: Wire IEventBus to extend EventBusProtocol
Files:
-
Modify:
packages/core-events/src/event-bus.interface.ts -
Step 1: Update interface
Read packages/core-events/src/event-bus.interface.ts. Add the extends clause:
import type { EventBusProtocol } from "@repo/core-shared/di/bind-protocols";
// ... existing imports ...
export interface IEventBus extends EventBusProtocol {
// existing method declarations
}
- Step 2: Verify
Run: pnpm --filter @repo/core-events typecheck
Expected: PASS — if it fails, IEventBus's existing method signatures don't structurally match the protocol; reconcile by either tightening the protocol (only if all impls already conform) or relaxing the IEventBus signatures (preferred — features should depend on the smaller surface).
- Step 3: Commit
git add packages/core-events/src/event-bus.interface.ts
git commit -m "feat(core-events): IEventBus extends EventBusProtocol"
Task 1.5: Wire IRealtimeBroadcaster to extend protocol
Files:
-
Modify:
packages/core-realtime/src/realtime-broadcaster.interface.ts -
Step 1: Update interface
Read packages/core-realtime/src/realtime-broadcaster.interface.ts. Add:
import type { RealtimeBroadcasterProtocol } from "@repo/core-shared/di/bind-protocols";
export interface IRealtimeBroadcaster extends RealtimeBroadcasterProtocol {
// existing method declarations
}
- Step 2: Verify
Run: pnpm --filter @repo/core-realtime typecheck
Expected: PASS
- Step 3: Commit
git add packages/core-realtime/src/realtime-broadcaster.interface.ts
git commit -m "feat(core-realtime): IRealtimeBroadcaster extends RealtimeBroadcasterProtocol"
Task 1.6: Wire IRealtimeHandlerRegistry to extend protocol
Files:
-
Modify:
packages/core-realtime/src/realtime-handler-registry.ts -
Step 1: Update interface
Read packages/core-realtime/src/realtime-handler-registry.ts. Find the interface IRealtimeHandlerRegistry declaration. Add the extends:
import type { RealtimeRegistryProtocol } from "@repo/core-shared/di/bind-protocols";
export interface IRealtimeHandlerRegistry extends RealtimeRegistryProtocol {
// existing method declarations
}
- Step 2: Verify
Run: pnpm --filter @repo/core-realtime typecheck
Expected: PASS
- Step 3: Commit
git add packages/core-realtime/src/realtime-handler-registry.ts
git commit -m "feat(core-realtime): IRealtimeHandlerRegistry extends RealtimeRegistryProtocol"
Task 1.7: Migrate auth binders to ctx arg
Files:
-
Modify:
packages/auth/src/di/bind-production.ts -
Modify:
packages/auth/src/di/bind-dev-seed.ts -
Step 1: Read existing binder
Open packages/auth/src/di/bind-production.ts. Note the current 7-arg positional signature.
- Step 2: Refactor
bindProductionAuthsignature
Change from:
export function bindProductionAuth(
config: SanitizedConfig,
tracer: ITracer,
logger: ILogger,
bus: IEventBus,
queue: IJobQueue,
realtime: IRealtimeBroadcaster,
realtimeRegistry: IRealtimeHandlerRegistry,
): void {
// body
}
To:
import type { BindProductionContext } from "@repo/core-shared/di";
export function bindProductionAuth(ctx: BindProductionContext): void {
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
// body unchanged otherwise
// optional usages become guarded:
bus?.subscribe(/* ... */);
realtimeRegistry?.register(/* ... */);
}
Drop the now-unused positional imports (IEventBus, IRealtimeBroadcaster, etc. used only for type signatures — keep imports if the body still references them inside guard blocks).
- Step 3: Refactor
bindDevSeedAuthsignature
Open packages/auth/src/di/bind-dev-seed.ts. Change from positional 6-arg (tracer, logger, bus, queue, realtime, realtimeRegistry) to:
import type { BindContext } from "@repo/core-shared/di";
export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
// body unchanged; same guards as above
}
- Step 4: Verify
Run: pnpm --filter @repo/auth typecheck
Expected: PASS
- Step 5: Commit
git add packages/auth/src/di/bind-production.ts packages/auth/src/di/bind-dev-seed.ts
git commit -m "refactor(auth): bindProductionAuth + bindDevSeedAuth take BindContext arg"
Task 1.8: Migrate blog binders
Files:
- Modify:
packages/blog/src/di/bind-production.ts - Modify:
packages/blog/src/di/bind-dev-seed.ts
Apply the same pattern as Task 1.7. Read each file, convert the positional signature to single-arg ctx: BindProductionContext (production) / ctx: BindContext (dev-seed), destructure inside, guard optional usages.
- Step 1: Read + refactor
packages/blog/src/di/bind-production.ts
Same pattern as Task 1.7 step 2.
- Step 2: Read + refactor
packages/blog/src/di/bind-dev-seed.ts
Same pattern as Task 1.7 step 3.
- Step 3: Verify
Run: pnpm --filter @repo/blog typecheck
Expected: PASS
- Step 4: Commit
git add packages/blog/src/di/bind-production.ts packages/blog/src/di/bind-dev-seed.ts
git commit -m "refactor(blog): binders take BindContext arg"
Task 1.9: Migrate marketing-pages binders
Files:
- Modify:
packages/marketing-pages/src/di/bind-production.ts - Modify:
packages/marketing-pages/src/di/bind-dev-seed.ts
Same pattern as Task 1.7.
- Step 1: Read + refactor both binder files (same shape as Task 1.7)
- Step 2:
pnpm --filter @repo/marketing-pages typecheck→ PASS - Step 3: Commit
git add packages/marketing-pages/src/di/bind-production.ts packages/marketing-pages/src/di/bind-dev-seed.ts
git commit -m "refactor(marketing-pages): binders take BindContext arg"
Task 1.10: Migrate navigation binders
Files:
- Modify:
packages/navigation/src/di/bind-production.ts - Modify:
packages/navigation/src/di/bind-dev-seed.ts
Same pattern.
- Step 1: Read + refactor both binder files
- Step 2:
pnpm --filter @repo/navigation typecheck→ PASS - Step 3: Commit
git add packages/navigation/src/di/bind-production.ts packages/navigation/src/di/bind-dev-seed.ts
git commit -m "refactor(navigation): binders take BindContext arg"
Task 1.11: Migrate media binders
Files:
- Modify:
packages/media/src/di/bind-production.ts - Modify:
packages/media/src/di/bind-dev-seed.ts
Same pattern.
- Step 1: Read + refactor both binder files
- Step 2:
pnpm --filter @repo/media typecheck→ PASS - Step 3: Commit
git add packages/media/src/di/bind-production.ts packages/media/src/di/bind-dev-seed.ts
git commit -m "refactor(media): binders take BindContext arg"
Task 1.12: Migrate web-next aggregator (bind-production.ts)
Files:
-
Modify:
apps/web-next/src/server/bind-production.ts -
Step 1: Read aggregator end-to-end
Open apps/web-next/src/server/bind-production.ts. Note where each bindProduction<X> and bindDevSeed<X> is called. They currently get 7 (production) / 6 (dev-seed) positional args.
- Step 2: Build BindProductionContext once at the top of
bindAllProduction
Replace the per-call positional args with a single ctx:
import type { BindProductionContext } from "@repo/core-shared/di";
import type { IEventBus } from "@repo/core-events";
import type {
IRealtimeBroadcaster,
IRealtimeHandlerRegistry,
} from "@repo/core-realtime";
export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
if (bound) return;
bound = true;
const { tracer, logger } = resolveInstrumentation();
const { bus, queue } = await resolveEventsAndJobsProduction();
const resolvedConfig = await config;
const { realtime, realtimeRegistry } = deps;
const ctx: BindProductionContext<
IEventBus,
IRealtimeBroadcaster,
IRealtimeHandlerRegistry
> = {
config: resolvedConfig,
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
};
bindProductionAuth(ctx);
bindProductionBlog(ctx);
bindProductionMarketingPages(ctx);
bindProductionNavigation(ctx);
bindProductionMedia(ctx);
maybeRegisterRealtimePing(realtimeRegistry, realtime, tracer, logger);
bindRealtimeBridge(bus, realtime);
}
- Step 3: Same for
bindAllDevSeed
export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
if (bound) return;
bound = true;
const { tracer, logger } = resolveInstrumentation();
const { bus, queue } = resolveEventsAndJobsDevSeed();
const { realtime, realtimeRegistry } = deps;
const ctx: BindContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
};
await bindDevSeedAuth(ctx);
await bindDevSeedBlog(ctx);
await bindDevSeedMarketingPages(ctx);
await bindDevSeedNavigation(ctx);
await bindDevSeedMedia(ctx);
maybeRegisterRealtimePing(realtimeRegistry, realtime, tracer, logger);
bindRealtimeBridge(bus, realtime);
}
- Step 4: Verify
Run: pnpm --filter @repo/web-next typecheck
Expected: PASS
- Step 5: Commit
git add apps/web-next/src/server/bind-production.ts
git commit -m "refactor(web-next): aggregator builds BindContext once"
Task 1.13: Update web-next bind-production tests
Files:
-
Modify:
apps/web-next/src/server/bind-production.test.ts -
Step 1: Read existing tests
Open apps/web-next/src/server/bind-production.test.ts. The existing tests assert binders are called with positional args (e.g., expect(args[3]).toBeInstanceOf(PayloadJobsEventBus)). After Task 1.12, binders receive a single ctx arg.
- Step 2: Update assertions to ctx-shape
Replace positional-index assertions with property-based ones:
// Was:
const args = vi.mocked(bindProductionAuth).mock.calls[0]!;
expect(args[3]).toBeInstanceOf(PayloadJobsEventBus); // bus
expect(args[4]).toBeInstanceOf(PayloadJobQueue); // queue
// Now:
const ctx = vi.mocked(bindProductionAuth).mock.calls[0]![0];
expect(ctx.bus).toBeInstanceOf(PayloadJobsEventBus);
expect(ctx.queue).toBeInstanceOf(PayloadJobQueue);
Update all such assertions throughout the file. The realtime-ping env-gate tests added in f801345 continue to work as-is since they read from the registry, not from binder args.
- Step 3: Run tests
Run: pnpm --filter @repo/web-next test src/server/bind-production.test.ts
Expected: PASS — all tests (24 currently)
- Step 4: Commit
git add apps/web-next/src/server/bind-production.test.ts
git commit -m "test(web-next): bind-production tests assert ctx-shape arg"
Task 1.14: Update per-feature bind-dev-seed tests
Files:
-
Modify:
packages/auth/src/di/bind-dev-seed.test.ts -
Modify:
packages/blog/src/di/bind-dev-seed.test.ts -
Modify:
packages/marketing-pages/src/di/bind-dev-seed.test.ts -
Modify:
packages/navigation/src/di/bind-dev-seed.test.ts -
Modify:
packages/media/src/di/bind-dev-seed.test.ts -
Step 1: Read each file
For each, find calls to bindDevSeed<Feature>(...) with positional args.
- Step 2: Convert each test to construct ctx
Replace patterns like:
await bindDevSeedAuth(tracer, logger, bus, queue, realtime, realtimeRegistry);
With:
await bindDevSeedAuth({ tracer, logger, bus, queue, realtime, realtimeRegistry });
- Step 3: Run all 5 feature test suites
pnpm --filter @repo/auth test
pnpm --filter @repo/blog test
pnpm --filter @repo/marketing-pages test
pnpm --filter @repo/navigation test
pnpm --filter @repo/media test
Expected: All PASS.
- Step 4: Commit (one combined commit since they're mechanical)
git add packages/{auth,blog,marketing-pages,navigation,media}/src/di/bind-dev-seed.test.ts
git commit -m "test: bind-dev-seed tests pass ctx object across all 5 features"
Task 1.15: Run all gates
- Step 1: Run lint, typecheck, test, boundaries from repo root
pnpm lint
pnpm typecheck
pnpm test
pnpm turbo boundaries
Expected: All clean (lint may have pre-existing warnings about turbo.json env-vars — unchanged from before).
If any feature's test fails, re-read its bind-dev-seed.test.ts against the latest binder shape and reconcile.
- Step 2: No commit yet — Phase 0 commits are already in place; this is just the verification gate.
Task 1.16: Update Phase 0 docs
Files:
-
Modify:
docs/architecture/dependency-flow.md -
Modify:
docs/architecture/vertical-feature-spec.md -
Modify:
CLAUDE.md -
Modify:
AGENTS.md -
Modify:
packages/<each-feature>/AGENTS.md(5 files) -
Step 1: Update
docs/architecture/dependency-flow.md
Find the section describing binder signatures. Replace any bindProductionX(config, tracer, logger, bus, queue, realtime, realtimeRegistry) example with the single-arg bindProductionX(ctx: BindProductionContext) form. Add a paragraph explaining: required fields (tracer, logger, config-when-production), optional fields (bus, queue, realtime, realtimeRegistry), why optional (corresponds to optional core packages).
- Step 2: Update
docs/architecture/vertical-feature-spec.md
Same — replace positional binder examples with ctx-arg examples.
- Step 3: Update
CLAUDE.md"Key Conventions" binder rules
Find the bullets mentioning bindProductionX(config, tracer, logger, bus, queue, realtime, realtimeRegistry). Replace with the new single-arg shape. Add a new convention bullet:
Binders take a
ctxarg fromcore-shared/di. Required:tracer,logger, plusconfigfor production. Optional:bus,queue,realtime,realtimeRegistry(correspond to optional core packages — guard with?.when used).
- Step 4: Update root
AGENTS.md
Find the binder convention reference; align with CLAUDE.md.
- Step 5: Update each feature's
AGENTS.md
For each of packages/{auth,blog,marketing-pages,navigation,media}/AGENTS.md, find binder examples and update to the ctx form.
- Step 6: Commit
git add docs/architecture/dependency-flow.md docs/architecture/vertical-feature-spec.md CLAUDE.md AGENTS.md packages/*/AGENTS.md
git commit -m "docs: BindContext binder shape across architecture + per-feature AGENTS"
Task 1.17: Update HTML explainers (Phase 0)
Files:
-
Modify:
docs/architecture/di-explainer.html -
Modify:
docs/architecture/data-flow-explainer.html -
Step 1: Open
di-explainer.htmlin a browser (or read the HTML directly)
Find any visualization showing positional binder args (boxes labeled "config / tracer / logger / bus / queue / realtime / realtimeRegistry" feeding into a feature). Replace with a single "ctx" box that fans into the feature.
- Step 2: Update
data-flow-explainer.html
Same — find binder-arg flows and consolidate into a single ctx flow.
- Step 3: Verify visually
Open each in a browser. Confirm the visualization is coherent (no orphaned arrows, labels match).
- Step 4: Commit
git add docs/architecture/di-explainer.html docs/architecture/data-flow-explainer.html
git commit -m "docs(html): di + data-flow explainers reflect BindContext"
Task 1.18: Phase 0 verification gate
- Step 1: Final cross-cutting test sweep
pnpm lint
pnpm typecheck
pnpm test
pnpm turbo boundaries
Expected: All green. Phase 0 is complete.
Phase 2 — Generator framework
Goal: Ship the pnpm turbo gen core-package entry, prompt, and dispatch table — but no templates yet. Phase 3+ each register their template entry.
Files touched:
- Modify:
turbo/generators/config.ts - Create:
turbo/generators/templates/core-package/.gitkeep - Create:
turbo/generators/lib/core-package-utils.ts - Create:
turbo/generators/lib/core-package-utils.test.ts - Modify:
turbo/generators/config.test.ts(or create it if absent) - Modify:
CLAUDE.md(Quick Start),AGENTS.md - Create:
docs/scaffolding/core-package-generator.md
Task 2.1: Add the generator entry + dispatch table (TDD)
Files:
-
Modify:
turbo/generators/config.ts -
Modify:
turbo/generators/config.test.ts -
Step 1: Read existing config.test.ts (if present) or skim other generator registrations.
-
Step 2: Write the failing test for the registration shape
Add to turbo/generators/config.test.ts (create if absent — copy boilerplate from existing test files in the repo):
import { describe, it, expect } from "vitest";
import generator from "./config";
describe("core-package generator", () => {
it("is registered with an empty choices list initially", () => {
const captured: Array<{ name: string; def: unknown }> = [];
const plopMock = {
setHelper: () => {},
setGenerator: (name: string, def: unknown) => captured.push({ name, def }),
} as unknown as Parameters<typeof generator>[0];
generator(plopMock);
const corePkg = captured.find((c) => c.name === "core-package");
expect(corePkg).toBeDefined();
const def = corePkg!.def as { prompts: { name: string; choices: unknown[] }[] };
expect(def.prompts[0]!.name).toBe("name");
expect(def.prompts[0]!.choices).toEqual([]);
});
});
- Step 3: Run the test → FAIL
Run: pnpm --filter ... test turbo/generators/config.test.ts (use the workspace path that owns the generators package).
Expected: FAIL — core-package not registered.
- Step 4: Add the registration to
turbo/generators/config.ts
After the existing setGenerator("realtime", ...) block, add:
const CORE_PACKAGE_GENERATORS: Record<
string,
() => PlopTypes.ActionType[]
> = {
// Phases 3-6 each insert one entry here.
};
plop.setGenerator("core-package", {
description: "Scaffold an optional core package (realtime, events, trpc, ui)",
prompts: [
{
type: "list",
name: "name",
message: "Which optional core package?",
choices: [],
},
],
actions: (answers) => {
const a = answers as { name: string };
const handler = CORE_PACKAGE_GENERATORS[a.name];
if (!handler) {
throw new Error(`No generator for core-package '${a.name}'`);
}
return handler();
},
});
-
Step 5: Run the test → PASS
-
Step 6: Commit
git add turbo/generators/config.ts turbo/generators/config.test.ts
git commit -m "feat(generators): add core-package entry + dispatch table (empty)"
Task 2.2: Create core-package-utils helper module
Files:
-
Create:
turbo/generators/lib/core-package-utils.ts -
Create:
turbo/generators/lib/core-package-utils.test.ts -
Step 1: Write tests for the utility functions
Create turbo/generators/lib/core-package-utils.test.ts:
import { describe, it, expect } from "vitest";
import { existsSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
assertOptionalPackageNotPresent,
addToTranspilePackages,
} from "./core-package-utils";
describe("assertOptionalPackageNotPresent", () => {
it("throws if packages/<name>/ exists", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const pkgRoot = join(tmp, "packages", "core-foo");
require("node:fs").mkdirSync(pkgRoot, { recursive: true });
expect(() => assertOptionalPackageNotPresent("core-foo", tmp)).toThrow(
/already exists/,
);
});
it("returns silently if absent", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
expect(() => assertOptionalPackageNotPresent("core-foo", tmp)).not.toThrow();
});
});
describe("addToTranspilePackages", () => {
it("inserts package name alphabetically into transpilePackages array", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const cfgPath = join(tmp, "next.config.mjs");
writeFileSync(
cfgPath,
`const nextConfig = {\n transpilePackages: [\n "@repo/core-cms",\n "@repo/core-shared",\n ],\n};`,
);
addToTranspilePackages(cfgPath, "@repo/core-realtime");
const result = readFileSync(cfgPath, "utf8");
expect(result).toMatch(/"@repo\/core-cms",\s*"@repo\/core-realtime",\s*"@repo\/core-shared"/);
});
it("is idempotent — duplicate insertion is a no-op", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const cfgPath = join(tmp, "next.config.mjs");
writeFileSync(
cfgPath,
`const nextConfig = {\n transpilePackages: ["@repo/core-realtime"],\n};`,
);
addToTranspilePackages(cfgPath, "@repo/core-realtime");
const result = readFileSync(cfgPath, "utf8");
expect(result.match(/@repo\/core-realtime/g)?.length).toBe(1);
});
});
- Step 2: Run tests → FAIL
Run: pnpm --filter <generators-package> test
Expected: FAIL — module not found.
- Step 3: Implement
core-package-utils.ts
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/**
* Throws if a core package directory already exists. Used as the first action
* in every per-package generator so re-running is safe.
*/
export function assertOptionalPackageNotPresent(
name: string,
cwd: string = process.cwd(),
): void {
const pkgRoot = join(cwd, "packages", name);
if (existsSync(pkgRoot)) {
throw new Error(
`packages/${name}/ already exists — refusing to scaffold (delete it first if intentional)`,
);
}
}
/**
* Inserts a package name into the transpilePackages array of a Next.js config
* file, preserving alphabetical order. Idempotent.
*/
export function addToTranspilePackages(
nextConfigPath: string,
pkgName: string,
): void {
const source = readFileSync(nextConfigPath, "utf8");
if (source.includes(`"${pkgName}"`)) return; // idempotent
const updated = source.replace(
/(transpilePackages:\s*\[\s*)([\s\S]*?)(\s*\])/,
(_match, open: string, body: string, close: string) => {
const entries = body
.split(",")
.map((e) => e.trim())
.filter(Boolean);
entries.push(`"${pkgName}"`);
entries.sort();
const formatted = entries.map((e) => ` ${e}`).join(",\n");
return `${open}\n${formatted},${close}`;
},
);
writeFileSync(nextConfigPath, updated);
}
-
Step 4: Run tests → PASS
-
Step 5: Commit
git add turbo/generators/lib/core-package-utils.ts turbo/generators/lib/core-package-utils.test.ts
git commit -m "feat(generators): core-package-utils (assertNotPresent + addToTranspilePackages)"
Task 2.3: Create empty templates directory + scaffolding doc
Files:
-
Create:
turbo/generators/templates/core-package/.gitkeep -
Create:
docs/scaffolding/core-package-generator.md -
Modify:
CLAUDE.md(Quick Start) -
Modify:
AGENTS.md -
Step 1: Create the placeholder + doc
mkdir -p turbo/generators/templates/core-package
touch turbo/generators/templates/core-package/.gitkeep
Create docs/scaffolding/core-package-generator.md:
# Core-package generator
`pnpm turbo gen core-package` scaffolds an optional core package back into
a slimmed template. Each name maps to a verbatim copy of the package as it
shipped at the time the generator was added.
## Usage
```bash
pnpm turbo gen core-package
# → Which optional core package? (use arrow keys)
# ❯ realtime
# events
# trpc
# ui
The generator emits the package files, updates consuming-app config (e.g.
apps/web-next/next.config.mjs transpilePackages), patches
packages/core-eslint/base.js to re-add any package-specific lint rules,
then prints the manual app/server wiring needed to bring the package fully
online.
Available templates
| Name | Description | Phase added |
|---|---|---|
realtime |
Socket.IO realtime layer (ADR-016) | Phase 3 |
events |
Cross-feature event bus + Payload jobs adapter (ADR-015) | Phase 4 |
trpc |
tRPC server setup | Phase 5 |
ui |
Design-system package | Phase 6 |
Verifying an existing project
If your project already has a core-* package and you want to verify the generator's template hasn't drifted from the shipped source, use the byte-identical reconstruction snapshot:
git stash -u
pnpm turbo gen core-package <name>
git diff packages/core-<name>/
# Expect: zero diff (modulo .hbs strip + trailing-newline normalization)
Snapshots live at turbo/generators/__snapshots__/core-package/<name>.snapshot.json.
- [ ] **Step 2: Add Quick Start line in CLAUDE.md**
In the Quick Start code block at the top of `CLAUDE.md`, add after the existing `pnpm turbo gen` lines:
```bash
pnpm turbo gen core-package # Scaffold an optional core package (see docs/scaffolding/core-package-generator.md)
-
Step 3: Add to AGENTS.md the
core-packagegenerator alongside the existing four (in whatever section enumerates available generators). -
Step 4: Commit
git add turbo/generators/templates/core-package/.gitkeep docs/scaffolding/core-package-generator.md CLAUDE.md AGENTS.md
git commit -m "docs: core-package generator reference + Quick Start entry"
Task 2.4: Phase 2 verification gate
- Step 1: Run all gates
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: green.
- Step 2: Smoke-test the generator (manual)
pnpm turbo gen core-package
# Pick "realtime" (or any name)
# Expected: error "No generator for core-package 'realtime'"
This is the desired state — phases 3-6 add the entries.
Phase 3 — core-realtime template + scaffold + remove
Goal: Capture the current packages/core-realtime/ source as a verbatim template, ship the generator action that scaffolds it back + re-adds ESLint rules, then remove core-realtime from main and verify byte-identical reconstruction.
Files touched (template capture):
- Create:
turbo/generators/templates/core-package/realtime/**/*.hbs(all files mirrorpackages/core-realtime/**) - Create:
turbo/generators/__snapshots__/core-package/realtime.snapshot.json - Create:
turbo/generators/templates/core-package/realtime-eslint-rules/no-direct-socket-io.js.hbs - Create:
turbo/generators/templates/core-package/realtime-eslint-rules/no-direct-socket-io.test.js.hbs - Create:
turbo/generators/templates/core-package/realtime-eslint-rules/no-realtime-handler-reexport.js.hbs - Create:
turbo/generators/templates/core-package/realtime-eslint-rules/no-realtime-handler-reexport.test.js.hbs
Files touched (generator wiring):
- Modify:
turbo/generators/config.ts(push to dispatch table + choices list + emit helpers) - Modify:
turbo/generators/lib/core-package-utils.ts(addaddBoundariesEntry,splicePluginRulesAt,splicePluginImportsAt)
Files touched (anchors in core-eslint/base.js):
- Modify:
packages/core-eslint/base.js(add// <gen:realtime-rules-imports>and// <gen:realtime-rules>anchor comments) - Modify:
packages/core-eslint/anchors.test.js(assert anchors present)
Files touched (slimming):
- Delete:
packages/core-realtime/(entire directory) - Modify:
apps/web-next/next.config.mjs(remove@repo/core-realtimefromtranspilePackages) - Modify:
apps/web-next/server.ts(strip realtime bootstrap) - Modify:
apps/web-next/src/server/bind-production.ts(drop realtime fields from ctx; removemaybeRegisterRealtimePing,bindRealtimeBridge) - Modify:
apps/web-next/package.json(remove@repo/core-realtimedep) - Modify:
packages/core-eslint/base.js(strip rule files imports + plugin block; anchors remain) - Delete:
packages/core-eslint/rules/no-direct-socket-io.js+.test.js - Delete:
packages/core-eslint/rules/no-realtime-handler-reexport.js+.test.js - Modify: each feature's
package.json(remove@repo/core-realtimedep) - Modify:
apps/web-next/src/__tests__/realtime-ping.test.ts(delete file — moves into the template) - Modify: ADR-016 (add Status header)
- Modify:
docs/guides/realtime.md(add prerequisite note)
Task 3.1: Add anchors to core-eslint/base.js (TDD)
Files:
-
Modify:
packages/core-eslint/base.js -
Modify:
packages/core-eslint/anchors.test.js -
Step 1: Add anchor assertion to
anchors.test.js
Open packages/core-eslint/anchors.test.js. Add to its assertions:
expect(baseSource).toContain("// <gen:realtime-rules-imports>");
expect(baseSource).toContain("// <gen:realtime-rules>");
- Step 2: Run test → FAIL
Run: pnpm --filter @repo/core-eslint test anchors.test.js
Expected: FAIL — anchors not present.
- Step 3: Add the anchors to
base.js
In packages/core-eslint/base.js:
-
Above the realtime rule imports (around line 7), add:
// <gen:realtime-rules-imports> -
Above the realtime rules block (around line 174), add:
// <gen:realtime-rules> -
Step 4: Run test → PASS
-
Step 5: Commit
git add packages/core-eslint/base.js packages/core-eslint/anchors.test.js
git commit -m "feat(core-eslint): anchors for realtime rules splice"
Task 3.2: Capture core-realtime as template files
Files: ~22 .hbs files under turbo/generators/templates/core-package/realtime/
- Step 1: Mirror the package tree
mkdir -p turbo/generators/templates/core-package/realtime/src
cp -r packages/core-realtime/AGENTS.md turbo/generators/templates/core-package/realtime/AGENTS.md.hbs
cp -r packages/core-realtime/eslint.config.js turbo/generators/templates/core-package/realtime/eslint.config.js.hbs
cp -r packages/core-realtime/package.json turbo/generators/templates/core-package/realtime/package.json.hbs
cp -r packages/core-realtime/tsconfig.json turbo/generators/templates/core-package/realtime/tsconfig.json.hbs
cp -r packages/core-realtime/turbo.json turbo/generators/templates/core-package/realtime/turbo.json.hbs
cp -r packages/core-realtime/vitest.config.ts turbo/generators/templates/core-package/realtime/vitest.config.ts.hbs
# Then mirror src/* (each file gets a .hbs sibling)
for f in packages/core-realtime/src/*; do
base=$(basename "$f")
cp "$f" "turbo/generators/templates/core-package/realtime/src/$base.hbs"
done
- Step 2: Verify the file list
find turbo/generators/templates/core-package/realtime -type f | sort
Expected: 6 top-level .hbs + 22 src/*.hbs ≈ 28 files. Cross-check against packages/core-realtime/ shape.
- Step 3: Commit
git add turbo/generators/templates/core-package/realtime
git commit -m "feat(generators): capture core-realtime as verbatim template files"
Task 3.3: Capture realtime ESLint rule files as templates
Files: 4 .hbs files under turbo/generators/templates/core-package/realtime-eslint-rules/
- Step 1: Copy rule files
mkdir -p turbo/generators/templates/core-package/realtime-eslint-rules
cp packages/core-eslint/rules/no-direct-socket-io.js turbo/generators/templates/core-package/realtime-eslint-rules/no-direct-socket-io.js.hbs
cp packages/core-eslint/rules/no-direct-socket-io.test.js turbo/generators/templates/core-package/realtime-eslint-rules/no-direct-socket-io.test.js.hbs
cp packages/core-eslint/rules/no-realtime-handler-reexport.js turbo/generators/templates/core-package/realtime-eslint-rules/no-realtime-handler-reexport.js.hbs
cp packages/core-eslint/rules/no-realtime-handler-reexport.test.js turbo/generators/templates/core-package/realtime-eslint-rules/no-realtime-handler-reexport.test.js.hbs
- Step 2: Commit
git add turbo/generators/templates/core-package/realtime-eslint-rules
git commit -m "feat(generators): capture realtime ESLint rules as template files"
Task 3.4: Create the realtime byte-identical snapshot
Files:
-
Create:
turbo/generators/__snapshots__/core-package/realtime.snapshot.json -
Create:
turbo/generators/lib/snapshot.ts -
Create:
turbo/generators/lib/snapshot.test.ts -
Step 1: Write the snapshot helper test
Create turbo/generators/lib/snapshot.test.ts:
import { describe, it, expect } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { computeSnapshot } from "./snapshot";
describe("computeSnapshot", () => {
it("returns sorted file paths + sha256 hashes", () => {
const tmp = mkdtempSync(join(tmpdir(), "snapshot-"));
mkdirSync(join(tmp, "src"));
writeFileSync(join(tmp, "package.json"), `{ "name": "x" }\n`);
writeFileSync(join(tmp, "src", "index.ts"), `export {};\n`);
const snap = computeSnapshot(tmp);
expect(snap).toEqual([
{ path: "package.json", sha256: expect.any(String) },
{ path: "src/index.ts", sha256: expect.any(String) },
]);
});
});
-
Step 2: Run test → FAIL
-
Step 3: Implement
computeSnapshot
Create turbo/generators/lib/snapshot.ts:
import { readdirSync, readFileSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative } from "node:path";
export type SnapshotEntry = { path: string; sha256: string };
/**
* Recursively collect all files under root, sorted by relative path, with
* sha256 of post-normalized contents (LF line endings, single trailing
* newline). Used by the byte-identical reconstruction test.
*/
export function computeSnapshot(root: string): SnapshotEntry[] {
const out: SnapshotEntry[] = [];
walk(root, root, out);
out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
return out;
}
function walk(root: string, dir: string, out: SnapshotEntry[]): void {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const stat = statSync(full);
if (stat.isDirectory()) {
if (name === "node_modules" || name === ".turbo") continue;
walk(root, full, out);
} else if (stat.isFile()) {
const raw = readFileSync(full, "utf8");
const normalized = raw.replace(/\r\n/g, "\n").replace(/\n*$/, "\n");
const sha = createHash("sha256").update(normalized).digest("hex");
out.push({ path: relative(root, full).replace(/\\/g, "/"), sha256: sha });
}
}
}
-
Step 4: Run test → PASS
-
Step 5: Generate the realtime snapshot from current source
mkdir -p turbo/generators/__snapshots__/core-package
pnpm exec tsx -e '
import { computeSnapshot } from "./turbo/generators/lib/snapshot";
import { writeFileSync } from "node:fs";
const snap = computeSnapshot("./packages/core-realtime");
writeFileSync(
"./turbo/generators/__snapshots__/core-package/realtime.snapshot.json",
JSON.stringify(snap, null, 2) + "\n",
);
'
- Step 6: Commit
git add turbo/generators/lib/snapshot.ts turbo/generators/lib/snapshot.test.ts turbo/generators/__snapshots__/core-package/realtime.snapshot.json
git commit -m "feat(generators): byte-identical snapshot machinery + realtime snapshot"
Task 3.5: Add splicePluginImportsAt + splicePluginRulesAt helpers (TDD)
Files:
-
Modify:
turbo/generators/lib/core-package-utils.ts -
Modify:
turbo/generators/lib/core-package-utils.test.ts -
Step 1: Add tests
Append to core-package-utils.test.ts:
describe("splicePluginRulesAt", () => {
it("inserts rule block at the named anchor", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const path = join(tmp, "base.js");
writeFileSync(
path,
`before\n// <gen:realtime-rules>\nafter\n`,
);
splicePluginRulesAt(path, "realtime-rules", "INSERTED_BLOCK");
const result = readFileSync(path, "utf8");
expect(result).toContain("// <gen:realtime-rules>\nINSERTED_BLOCK\nafter");
});
it("is idempotent — re-inserting same block at anchor is a no-op", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const path = join(tmp, "base.js");
writeFileSync(
path,
`// <gen:realtime-rules>\nINSERTED_BLOCK\nrest\n`,
);
splicePluginRulesAt(path, "realtime-rules", "INSERTED_BLOCK");
const result = readFileSync(path, "utf8");
expect(result.match(/INSERTED_BLOCK/g)?.length).toBe(1);
});
});
-
Step 2: Run → FAIL (function not exported)
-
Step 3: Implement
Append to core-package-utils.ts:
/**
* Inserts a code block immediately after a `// <gen:NAME>` anchor in a file.
* Idempotent: refuses to insert if the exact block already follows the anchor.
*/
export function splicePluginRulesAt(
filePath: string,
anchorName: string,
block: string,
): void {
const source = readFileSync(filePath, "utf8");
const anchor = `// <gen:${anchorName}>`;
const idx = source.indexOf(anchor);
if (idx === -1) {
throw new Error(`Anchor ${anchor} not found in ${filePath}`);
}
const after = source.slice(idx + anchor.length);
if (after.trimStart().startsWith(block.trim())) return; // idempotent
const updated =
source.slice(0, idx + anchor.length) + "\n" + block + after;
writeFileSync(filePath, updated);
}
/**
* Inserts an import line immediately after the matching anchor. Idempotent.
*/
export function splicePluginImportsAt(
filePath: string,
anchorName: string,
importLine: string,
): void {
splicePluginRulesAt(filePath, anchorName, importLine);
}
-
Step 4: Run → PASS
-
Step 5: Commit
git add turbo/generators/lib/core-package-utils.ts turbo/generators/lib/core-package-utils.test.ts
git commit -m "feat(generators): splicePluginRulesAt + splicePluginImportsAt helpers"
Task 3.5b: Add addBoundariesEntry and emitTemplateTree helpers (TDD)
Files:
-
Modify:
turbo/generators/lib/core-package-utils.ts -
Modify:
turbo/generators/lib/core-package-utils.test.ts -
Step 1: Write tests
Append to core-package-utils.test.ts:
describe("addBoundariesEntry", () => {
it("inserts entry before the packages/core-* wildcard", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const path = join(tmp, "base.js");
writeFileSync(
path,
`"boundaries/elements": [
{ type: "core-composition", pattern: "packages/core-cms" },
{ type: "core", pattern: "packages/core-*" },
{ type: "feature", pattern: "packages/!(core-*)" },
],`,
);
addBoundariesEntry(path, "packages/core-realtime", { mode: "folder" });
const result = readFileSync(path, "utf8");
// The new entry must appear BEFORE the packages/core-* wildcard
const newIdx = result.indexOf(`pattern: "packages/core-realtime"`);
const wildcardIdx = result.indexOf(`pattern: "packages/core-*"`);
expect(newIdx).toBeGreaterThan(0);
expect(newIdx).toBeLessThan(wildcardIdx);
});
it("is idempotent — re-inserting same entry is a no-op", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const path = join(tmp, "base.js");
writeFileSync(
path,
`"boundaries/elements": [
{ type: "core", pattern: "packages/core-realtime", mode: "folder" },
{ type: "core", pattern: "packages/core-*" },
],`,
);
addBoundariesEntry(path, "packages/core-realtime", { mode: "folder" });
const result = readFileSync(path, "utf8");
expect(result.match(/packages\/core-realtime/g)?.length).toBe(1);
});
});
describe("emitTemplateTree", () => {
it("produces an `add` plop action per .hbs file in the template directory", () => {
// emitTemplateTree reads from turbo/generators/templates/ — the test uses a
// temp template directory injected via the `templatesRoot` arg.
const tmpTemplates = mkdtempSync(join(tmpdir(), "tpl-"));
mkdirSync(join(tmpTemplates, "core-package", "demo", "src"), { recursive: true });
writeFileSync(join(tmpTemplates, "core-package", "demo", "package.json.hbs"), "{}");
writeFileSync(join(tmpTemplates, "core-package", "demo", "src", "index.ts.hbs"), "export {};");
const actions = emitTemplateTree("core-package/demo", "packages/demo", { templatesRoot: tmpTemplates });
expect(actions).toHaveLength(2);
expect(actions[0]!.type).toBe("add");
const paths = actions.map((a) => (a as { path: string }).path);
expect(paths).toContain("packages/demo/package.json");
expect(paths).toContain("packages/demo/src/index.ts");
});
});
-
Step 2: Run → FAIL (helpers not exported)
-
Step 3: Implement both helpers in
core-package-utils.ts
import { readdirSync, statSync, mkdirSync } from "node:fs";
import { dirname, join, relative } from "node:path";
/**
* Inserts a `{ type, pattern, ...opts }` entry into the boundaries/elements
* array of `core-eslint/base.js`, placed immediately BEFORE the
* `packages/core-*` wildcard so its more-specific match wins. Idempotent.
*/
export function addBoundariesEntry(
baseJsPath: string,
packagePath: string,
opts: { mode?: "folder" } = {},
): void {
const source = readFileSync(baseJsPath, "utf8");
if (source.includes(`pattern: "${packagePath}"`)) return; // idempotent
const wildcardLine = source.match(
/(\s*\{\s*type:\s*"core",\s*pattern:\s*"packages\/core-\*"[^}]*\},)/,
);
if (!wildcardLine) {
throw new Error(`packages/core-* wildcard not found in ${baseJsPath}`);
}
const modeFragment = opts.mode ? `, mode: "${opts.mode}"` : "";
const newEntry = ` { type: "core", pattern: "${packagePath}"${modeFragment} },\n`;
const updated = source.replace(wildcardLine[0], `\n${newEntry}${wildcardLine[1]}`);
writeFileSync(baseJsPath, updated);
}
/**
* Walks turbo/generators/templates/<srcPrefix>/ recursively. For each .hbs
* file, returns a plop `add` action that emits the file (without .hbs
* extension) at <destPrefix>/<relative-path>. The actions are sorted so
* directory creation is deterministic.
*/
export function emitTemplateTree(
srcPrefix: string,
destPrefix: string,
opts: { templatesRoot?: string } = {},
): PlopTypes.AddAction[] {
const root =
opts.templatesRoot ?? join(__dirname, "..", "templates");
const srcRoot = join(root, srcPrefix);
const out: PlopTypes.AddAction[] = [];
walkHbs(srcRoot, srcRoot, srcPrefix, destPrefix, out);
out.sort((a, b) => a.path.localeCompare(b.path));
return out;
}
function walkHbs(
topRoot: string,
dir: string,
srcPrefix: string,
destPrefix: string,
out: PlopTypes.AddAction[],
): void {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
walkHbs(topRoot, full, srcPrefix, destPrefix, out);
continue;
}
if (!name.endsWith(".hbs")) continue;
const rel = relative(topRoot, full).replace(/\.hbs$/, "");
out.push({
type: "add",
path: join(destPrefix, rel).replace(/\\/g, "/"),
templateFile: join("templates", srcPrefix, relative(topRoot, full)),
} as PlopTypes.AddAction);
}
}
-
Step 4: Run → PASS
-
Step 5: Commit
git add turbo/generators/lib/core-package-utils.ts turbo/generators/lib/core-package-utils.test.ts
git commit -m "feat(generators): addBoundariesEntry + emitTemplateTree helpers"
Task 3.6: Wire the realtime entry into the dispatch table
Files:
-
Modify:
turbo/generators/config.ts -
Modify:
turbo/generators/config.test.ts -
Step 1: Add an emit-test for the realtime action
Append to turbo/generators/config.test.ts:
describe("core-package realtime", () => {
it("emits actions covering package files, transpilePackages, and ESLint rules", () => {
// Capture the actions returned by the realtime entry.
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
const plop = {
setHelper: () => {},
setGenerator: (n: string, d: unknown) => captured.push({ name: n, def: d as PlopTypes.PlopGeneratorConfig }),
} as unknown as PlopTypes.NodePlopAPI;
generator(plop);
const corePkg = captured.find((c) => c.name === "core-package")!.def;
const actions = (corePkg.actions as (a: { name: string }) => PlopTypes.ActionType[])(
{ name: "realtime" },
);
// Expectations: at least one assertNotPresent guard, multiple `add` actions, and a transpilePackages action.
expect(actions.length).toBeGreaterThan(20); // 28 files + extras
});
});
-
Step 2: Run → FAIL (no entry registered)
-
Step 3: Implement the realtime entry
In turbo/generators/config.ts, populate CORE_PACKAGE_GENERATORS["realtime"]:
import {
assertOptionalPackageNotPresent,
addToTranspilePackages,
splicePluginRulesAt,
splicePluginImportsAt,
addBoundariesEntry,
emitTemplateTree,
} from "./lib/core-package-utils.js";
const REALTIME_RULE_IMPORTS = `import noDirectSocketIO from "./rules/no-direct-socket-io.js";
import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";`;
const REALTIME_RULE_BLOCK = ` {
files: ["**/*.{ts,tsx,mjs,cjs,js}"],
plugins: {
"repo-rules": {
rules: {
"no-direct-socket-io": noDirectSocketIO,
"no-realtime-handler-reexport": noRealtimeHandlerReexport,
},
},
},
rules: {
"repo-rules/no-direct-socket-io": "error",
"repo-rules/no-realtime-handler-reexport": "error",
},
},`;
CORE_PACKAGE_GENERATORS["realtime"] = () => [
() => assertOptionalPackageNotPresent("core-realtime"),
...emitTemplateTree("core-package/realtime", "packages/core-realtime"),
...emitTemplateTree(
"core-package/realtime-eslint-rules",
"packages/core-eslint/rules",
),
() => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-realtime"),
() => addBoundariesEntry("packages/core-realtime", { mode: "folder" }),
() => splicePluginImportsAt(
"packages/core-eslint/base.js",
"realtime-rules-imports",
REALTIME_RULE_IMPORTS,
),
() => splicePluginRulesAt(
"packages/core-eslint/base.js",
"realtime-rules",
REALTIME_RULE_BLOCK,
),
() => printRealtimeNextSteps(),
];
Add the realtime name to the choices array in the prompt.
emitTemplateTree(srcPrefix, destPrefix) is a helper that recursively expands all .hbs files in the template directory into plop add actions. Implement it in core-package-utils.ts if not already done:
export function emitTemplateTree(
srcPrefix: string,
destPrefix: string,
): PlopTypes.AddAction[] {
// Walk turbo/generators/templates/<srcPrefix>/, build an `add` action per .hbs file
// mapping to <destPrefix>/<relative-path-without-.hbs>
const out: PlopTypes.AddAction[] = [];
const root = join(__dirname, "..", "templates", srcPrefix);
// ... recursive walk, push plop add actions
return out;
}
(Implement the walk; mirror existing pattern in config.ts where templates are inlined.)
addBoundariesEntry(pkgPath, opts) patches packages/core-eslint/base.js to add a boundaries entry before the packages/core-* wildcard. Implement similarly to splicePluginRulesAt.
printRealtimeNextSteps() returns a custom plop action that prints the manual-wiring text. Mirror existing printHandlerNextSteps in config.ts.
-
Step 4: Run → PASS
-
Step 5: Commit
git add turbo/generators/config.ts turbo/generators/config.test.ts turbo/generators/lib/core-package-utils.ts
git commit -m "feat(generators): wire realtime entry into core-package dispatch table"
Task 3.7: Generator e2e test (scaffold core-realtime into a tmp workspace)
Files:
-
Create:
turbo/generators/__tests__/core-package-realtime.e2e.test.ts -
Step 1: Write the e2e test
import { describe, it, expect } from "vitest";
import { mkdtempSync, cpSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { join } from "node:path";
import { computeSnapshot } from "../lib/snapshot";
import expectedSnapshot from "../__snapshots__/core-package/realtime.snapshot.json";
describe("e2e: core-package realtime", () => {
it("byte-identical reconstruction matches snapshot", { timeout: 60_000 }, () => {
const tmp = mkdtempSync(join(tmpdir(), "e2e-"));
cpSync(process.cwd(), tmp, {
recursive: true,
filter: (src) => !src.includes("node_modules") && !src.includes(".turbo") && !src.includes("packages/core-realtime"),
});
execSync(`cd ${tmp} && pnpm install`, { stdio: "ignore" });
execSync(`cd ${tmp} && pnpm turbo gen core-package --args realtime`, { stdio: "ignore" });
const result = computeSnapshot(join(tmp, "packages/core-realtime"));
expect(result).toEqual(expectedSnapshot);
});
});
-
Step 2: Run the test → PASS (only after Phase 3.6 entry is wired)
-
Step 3: Commit
git add turbo/generators/__tests__/core-package-realtime.e2e.test.ts
git commit -m "test(generators): e2e byte-identical reconstruction of core-realtime"
Task 3.8: Remove core-realtime from main
Files: see "Files touched (slimming)" list above.
- Step 1: Strip realtime from
apps/web-next/server.ts
Open apps/web-next/server.ts. Remove all imports from @repo/core-realtime, socket.io, socket.io-client. Remove the IOServer construction, the authenticator, the SocketIORealtimeServer.start() call. The file collapses to a vanilla Next custom server (or replace entirely with next start if all custom-server logic was realtime-related).
- Step 2: Strip realtime from
apps/web-next/src/server/bind-production.ts
Drop the realtime, realtimeRegistry fields from BindAllDeps. Remove maybeRegisterRealtimePing and bindRealtimeBridge. Update the BindProductionContext<...> generic args to drop the realtime params (revert to default protocol types or to BindProductionContext<IEventBus>).
- Step 3: Strip dependency declarations
Edit each of these package.json files to remove the "@repo/core-realtime": "workspace:*" line:
-
apps/web-next/package.json -
packages/auth/package.json -
packages/blog/package.json -
packages/marketing-pages/package.json -
packages/navigation/package.json -
packages/media/package.json -
Step 4: Strip transpile entry
In apps/web-next/next.config.mjs, remove the "@repo/core-realtime" line from transpilePackages.
- Step 5: Strip the boundaries entry
In packages/core-eslint/base.js, remove the line:
{ type: "core", pattern: "packages/core-realtime", mode: "folder" },
- Step 6: Strip the realtime ESLint rule registration
In packages/core-eslint/base.js, between the anchors:
-
After
// <gen:realtime-rules-imports>: remove the twoimportlines. -
After
// <gen:realtime-rules>: remove the entirerepo-rulesplugin block. -
Anchors themselves remain.
-
Step 7: Delete the rule files
rm packages/core-eslint/rules/no-direct-socket-io.js
rm packages/core-eslint/rules/no-direct-socket-io.test.js
rm packages/core-eslint/rules/no-realtime-handler-reexport.js
rm packages/core-eslint/rules/no-realtime-handler-reexport.test.js
- Step 8: Delete the realtime e2e test (it lives in the template now)
rm apps/web-next/src/__tests__/realtime-ping.test.ts
- Step 9: Delete the package
rm -rf packages/core-realtime
- Step 10: Re-install + run gates
pnpm install # rewrites lockfile
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: All green. The IRealtimeBroadcaster etc. type imports in feature binders went away (Phase 1 made them optional via protocol defaults).
- Step 11: Commit (large commit — slimming)
git add -A
git commit -m "refactor: remove core-realtime from main (scaffoldable via gen core-package realtime)"
Task 3.9: Update Phase 3 docs + HTML
Files:
-
Modify:
docs/decisions/adr-016-realtime-layer.md(Status header) -
Modify:
docs/guides/realtime.md(prerequisite note) -
Modify:
docs/architecture/data-flow-explainer.html(mark realtime as conditional) -
Step 1: Add Status header to ADR-016
At the top of docs/decisions/adr-016-realtime-layer.md, after the title, insert:
**Status:** Optional — scaffold via `pnpm turbo gen core-package realtime`. The package is not in the default template; this ADR documents the design that the generator emits.
- Step 2: Add prerequisite note to realtime guide
At the top of docs/guides/realtime.md:
> **Prerequisite:** This guide assumes `@repo/core-realtime` is present. If you started from the slim template, run `pnpm turbo gen core-package realtime` first.
- Step 3: Update
data-flow-explainer.html
Find any solid arrows representing realtime flows. Convert to dashed lines + add an "optional" tag. (If the explainer doesn't have realtime arrows yet, no change needed.)
- Step 4: Commit
git add docs/decisions/adr-016-realtime-layer.md docs/guides/realtime.md docs/architecture/data-flow-explainer.html
git commit -m "docs: realtime now optional — Status headers + conditional HTML rendering"
Task 3.10: Phase 3 verification gate
- Step 1: Final gate sweep
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: green.
- Step 2: Run the e2e test directly
pnpm exec vitest run turbo/generators/__tests__/core-package-realtime.e2e.test.ts
Expected: byte-identical reconstruction PASS.
Phase 4 — core-events template + scaffold + remove
Goal: Same lifecycle as Phase 3 for core-events. Differences: events ESLint rules are inline no-restricted-syntax blocks in base.js (no separate rule files), and the boundaries entry is not needed (the wildcard catches it).
Task 4.1: Add events anchor to base.js (TDD)
Files:
-
Modify:
packages/core-eslint/base.js(add// <gen:events-rules>anchor) -
Modify:
packages/core-eslint/anchors.test.js(assert anchor) -
Step 1: Add anchor assertion → run → FAIL
-
Step 2: Add anchor in base.js around the existing
no-restricted-syntaxblock for E1 + J -
Step 3: Run → PASS
-
Step 4: Commit
git add packages/core-eslint/base.js packages/core-eslint/anchors.test.js
git commit -m "feat(core-eslint): anchor for events rules splice"
Task 4.2: Capture core-events as template files
Files: turbo/generators/templates/core-package/events/**/*.hbs
- Step 1: Mirror the package tree
mkdir -p turbo/generators/templates/core-package/events/src
cp packages/core-events/{AGENTS.md,eslint.config.js,package.json,tsconfig.json,turbo.json,vitest.config.ts} turbo/generators/templates/core-package/events/
# Rename to .hbs:
for f in turbo/generators/templates/core-package/events/*; do
[[ -f "$f" && "$f" != *.hbs ]] && mv "$f" "$f.hbs"
done
for f in packages/core-events/src/*; do
base=$(basename "$f")
cp "$f" "turbo/generators/templates/core-package/events/src/$base.hbs"
done
- Step 2: Commit
git add turbo/generators/templates/core-package/events
git commit -m "feat(generators): capture core-events as verbatim template files"
Task 4.3: Generate the events snapshot
- Step 1: Use the snapshot helper from Task 3.4
pnpm exec tsx -e '
import { computeSnapshot } from "./turbo/generators/lib/snapshot";
import { writeFileSync } from "node:fs";
const snap = computeSnapshot("./packages/core-events");
writeFileSync(
"./turbo/generators/__snapshots__/core-package/events.snapshot.json",
JSON.stringify(snap, null, 2) + "\n",
);
'
- Step 2: Commit
git add turbo/generators/__snapshots__/core-package/events.snapshot.json
git commit -m "feat(generators): events byte-identical snapshot"
Task 4.4: Wire events entry + e2e test
Files:
-
Modify:
turbo/generators/config.ts(pusheventsentry; add to choices) -
Create:
turbo/generators/__tests__/core-package-events.e2e.test.ts -
Step 1: Write the events emit test (in config.test.ts) + e2e test (separate file)
Mirror the realtime pattern from Tasks 3.6-3.7. The events entry is simpler:
const EVENTS_RULE_BLOCK = `<copy from current base.js — the no-restricted-syntax block for E1 + J>`;
CORE_PACKAGE_GENERATORS["events"] = () => [
() => assertOptionalPackageNotPresent("core-events"),
...emitTemplateTree("core-package/events", "packages/core-events"),
() => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-events"),
() => splicePluginRulesAt(
"packages/core-eslint/base.js",
"events-rules",
EVENTS_RULE_BLOCK,
),
() => printEventsNextSteps(),
];
-
Step 2: Run tests → PASS
-
Step 3: Commit
git add turbo/generators/config.ts turbo/generators/config.test.ts turbo/generators/__tests__/core-package-events.e2e.test.ts
git commit -m "feat(generators): wire events entry + e2e test"
Task 4.5: Remove core-events from main
Files (slimming):
-
Strip
bus,queuefrom each feature'spackage.jsondeps ifcore-eventsis the source. Confirm: events bringsIEventBus; if features only use it viactx.bus, the dep was probably for type imports only and goes away when binders are protocol-driven. -
Delete
packages/core-events/ -
Strip
@repo/core-eventsfromapps/web-next/next.config.mjstranspilePackages -
Update
apps/web-next/src/server/bind-production.ts— dropbus, queueconstruction, drop theIEventBusimport + generic arg -
Strip the events
no-restricted-syntaxblock fromcore-eslint/base.js(anchor remains) -
Update
bind-production.test.tsif any test exercisesbus/queue -
Step 1: Strip from app aggregator
-
Step 2: Strip ESLint block
-
Step 3: Strip dependencies from feature package.json files (search for
@repo/core-events) -
Step 4: Strip from next.config.mjs transpilePackages
-
Step 5: Delete
packages/core-events -
Step 6: Update
bind-production.test.ts -
Step 7:
pnpm install+ run all gates → green -
Step 8: Commit
git add -A
git commit -m "refactor: remove core-events from main (scaffoldable via gen core-package events)"
Task 4.6: Update Phase 4 docs + HTML
- Step 1: ADR-015 Status header
- Step 2:
docs/guides/events-and-jobs.mdprerequisite note - Step 3:
data-flow-explainer.htmlevents arrows → dashed/conditional - Step 4: Commit
git add docs/decisions/adr-015-events-and-jobs.md docs/guides/events-and-jobs.md docs/architecture/data-flow-explainer.html
git commit -m "docs: events now optional — Status headers + conditional HTML rendering"
Task 4.7: Phase 4 verification gate
- Step 1: Final gates green + e2e test passes
Phase 5 — core-trpc template + scaffold + remove
Goal: Same lifecycle for core-trpc. Differences: no ESLint rules (skip the rule splice steps); two apps (web-next + web-tanstack) need tRPC root mount removal.
Task 5.1: Capture core-trpc as template
mkdir -p turbo/generators/templates/core-package/trpc/src
cp packages/core-trpc/{AGENTS.md,eslint.config.js,package.json,tsconfig.json,turbo.json,vitest.config.ts} turbo/generators/templates/core-package/trpc/
for f in turbo/generators/templates/core-package/trpc/*; do
[[ -f "$f" && "$f" != *.hbs ]] && mv "$f" "$f.hbs"
done
for f in packages/core-trpc/src/*; do
base=$(basename "$f")
cp "$f" "turbo/generators/templates/core-package/trpc/src/$base.hbs"
done
git add turbo/generators/templates/core-package/trpc
git commit -m "feat(generators): capture core-trpc as verbatim template files"
Task 5.2: Generate the trpc snapshot
pnpm exec tsx -e '
import { computeSnapshot } from "./turbo/generators/lib/snapshot";
import { writeFileSync } from "node:fs";
writeFileSync(
"./turbo/generators/__snapshots__/core-package/trpc.snapshot.json",
JSON.stringify(computeSnapshot("./packages/core-trpc"), null, 2) + "\n",
);
'
git add turbo/generators/__snapshots__/core-package/trpc.snapshot.json
git commit -m "feat(generators): trpc byte-identical snapshot"
Task 5.3: Wire trpc entry + e2e test
CORE_PACKAGE_GENERATORS["trpc"] = () => [
() => assertOptionalPackageNotPresent("core-trpc"),
...emitTemplateTree("core-package/trpc", "packages/core-trpc"),
() => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-trpc"),
() => printTrpcNextSteps(), // includes manual app-mount instructions
];
printTrpcNextSteps() prints copy-paste-ready instructions for both apps' tRPC root mounting (web-next: app/api/trpc/[trpc]/route.ts; web-tanstack: equivalent).
- Step 1: Implement entry + e2e test
- Step 2: Run e2e → PASS
- Step 3: Commit
git add turbo/generators/config.ts turbo/generators/__tests__/core-package-trpc.e2e.test.ts
git commit -m "feat(generators): wire trpc entry + e2e test"
Task 5.4: Remove core-trpc from main
- Step 1: Strip tRPC root mount from
apps/web-next/app/api/trpc/[trpc]/route.ts(delete file or convert to a stub) - Step 2: Strip tRPC from
apps/web-tanstack/src/routes/__root.tsx
The current __root.tsx wraps <Outlet /> with <TanstackTrpcProvider trpcUrl="http://localhost:3000/api/trpc">. After removal, simplify to:
import { Outlet, createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => <Outlet />,
});
Also remove the @repo/core-trpc/tanstack import and any feature-router calls in apps/web-tanstack/src/routes/index.tsx that use tRPC.
- Step 3: Strip
@repo/core-trpcfromapps/web-next/package.jsonandapps/web-tanstack/package.jsondependencies - Step 4: Strip
@repo/core-trpcfromapps/web-next/next.config.mjstranspilePackages(web-tanstack has no vite/next config to update) - Step 5: Delete
packages/core-trpc/ - Step 6:
pnpm install+ run gates → green - Step 7: Commit
git add -A
git commit -m "refactor: remove core-trpc from main (scaffoldable via gen core-package trpc)"
Task 5.5: Phase 5 docs
- Step 1: Add prerequisite notes wherever feature
AGENTS.mdmentions tRPC integration - Step 2: Mark tRPC layer as conditional in
data-flow-explainer.html - Step 3: Commit
git add packages/*/AGENTS.md docs/architecture/data-flow-explainer.html
git commit -m "docs: trpc now optional — prerequisite notes + conditional HTML"
Task 5.6: Phase 5 verification gate
- Step 1: Gates green + e2e PASS
Phase 6 — core-ui template + scaffold + remove
Goal: Same lifecycle. Differences: third app (Storybook) consumes core-ui.
Task 6.1: Capture core-ui as template
mkdir -p turbo/generators/templates/core-package/ui/src
# (mirror the structure — core-ui is a React-library shape; preserve src tree)
git add turbo/generators/templates/core-package/ui
git commit -m "feat(generators): capture core-ui as verbatim template files"
Task 6.2: Generate the ui snapshot + wire entry + e2e test
CORE_PACKAGE_GENERATORS["ui"] = () => [
() => assertOptionalPackageNotPresent("core-ui"),
...emitTemplateTree("core-package/ui", "packages/core-ui"),
() => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-ui"),
() => printUiNextSteps(),
];
apps/web-tanstack currently has no vite.config.ts (TanStack Start dev/build scripts are placeholders). When Tanstack Start gains a real config in a future plan, the printUiNextSteps() block instructs the user to add @repo/core-ui to optimizeDeps.include (or equivalent) at that point. Storybook's apps/storybook consumption of core-ui is also handled via printed next-steps — registering stories in Storybook's main config is project-specific and resists templating.
- Step 1: Capture template + snapshot
- Step 2: Wire entry
- Step 3: e2e test
- Step 4: Run → PASS
- Step 5: Commit
git add turbo/generators/config.ts turbo/generators/templates/core-package/ui turbo/generators/__snapshots__/core-package/ui.snapshot.json turbo/generators/__tests__/core-package-ui.e2e.test.ts
git commit -m "feat(generators): wire ui entry + capture template + e2e test"
Task 6.3: Remove core-ui from main
- Step 1: Strip
@repo/core-uiimport sites in apps + storybook - Step 2: Strip Storybook stories list registrations
- Step 3: Strip from package.json deps in web-next, web-tanstack, storybook
- Step 4: Strip from next.config.mjs transpilePackages + vite.config.ts
- Step 5: Delete
packages/core-ui/ - Step 6:
pnpm install+ gates → green - Step 7: Commit
git add -A
git commit -m "refactor: remove core-ui from main (scaffoldable via gen core-package ui)"
Task 6.4: Phase 6 docs + verification
- Step 1: Docs + HTML conditional rendering for ui layer
- Step 2: Gates green
- Step 3: Commit
git add packages/core-ui/README.md # if it survives
git commit -m "docs: ui now optional — prerequisite + conditional HTML"
Phase 7 — Final cross-doc sweep + acceptance
Task 7.1: Add docs/architecture/template-tiers.md
Files:
-
Create:
docs/architecture/template-tiers.md -
Step 1: Write the doc
Create with this content:
# Template tiers
This template ships in three tiers:
## Must-have (always present)
- `@repo/core-shared` — protocol types, `BindContext`, instrumentation interfaces, jobs interface, tRPC primitives, payload helpers
- `@repo/core-eslint` — flat-config preset + repo-rules ESLint plugin + boundaries
- `@repo/core-typescript` — tsconfig presets (base, react-library, nextjs)
- `@repo/core-testing` — vitest helpers, factories, mocks, contracts
- `@repo/core-cms` — Payload config base
- `@repo/core-api` — top-level tRPC router root (mounts feature routers)
Plus all 5 feature packages: auth, blog, marketing-pages, navigation, media.
## Optional (scaffolded on demand)
| Package | Generator | ADR | Guide |
|---|---|---|---|
| core-realtime | `pnpm turbo gen core-package realtime` | ADR-016 | docs/guides/realtime.md |
| core-events | `pnpm turbo gen core-package events` | ADR-015 | docs/guides/events-and-jobs.md |
| core-trpc | `pnpm turbo gen core-package trpc` | (none) | (none) |
| core-ui | `pnpm turbo gen core-package ui` | (none) | (none) |
## Why optional
Each optional package addresses a specific need (realtime delivery, cross-feature events, tRPC, design system). Projects that don't need them get a slimmer template. The generator emits byte-identical copies of the packages as they shipped — see `turbo/generators/__snapshots__/core-package/<name>.snapshot.json` for the canonical content hashes.
- Step 2: Commit
git add docs/architecture/template-tiers.md
git commit -m "docs: template-tiers reference (must-have + optional + generator commands)"
Task 7.2: Update README.md (project root)
Files:
-
Modify:
README.md -
Step 1: Add an "Optional packages" section near the Quick Start
## Optional packages
The default template includes the must-have core packages and all 5 feature packages. Four core packages are optional and scaffold on demand:
```bash
pnpm turbo gen core-package realtime # Socket.IO realtime layer (ADR-016)
pnpm turbo gen core-package events # Cross-feature events + Payload jobs (ADR-015)
pnpm turbo gen core-package trpc # tRPC server setup
pnpm turbo gen core-package ui # Design system
See docs/architecture/template-tiers.md for the full tier list.
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs(readme): Optional packages section + scaffold commands"
Task 7.3: HTML explainers final review
- Step 1: Open
docs/architecture/di-explainer.htmlanddata-flow-explainer.html
Verify all 4 optional packages have consistent dashed/conditional treatment. Any solid arrow into a removed package layer is a bug — fix.
- Step 2: Verify legend explains the conditional/dashed convention
Add a legend if missing.
- Step 3: Commit any fixes
git add docs/architecture/*.html
git commit -m "docs(html): final pass on conditional rendering for optional packages"
Task 7.4: Re-check all "Read first" lists
- Step 1:
grep -rn "Read first" CLAUDE.md AGENTS.md packages/*/AGENTS.md
Confirm every link still resolves (no references to deleted package READMEs that would 404).
-
Step 2: Fix any broken refs
-
Step 3: Commit if changes
git add CLAUDE.md AGENTS.md packages/*/AGENTS.md
git commit -m "docs: refresh Read first lists after optional-package extraction"
Task 7.5: End-to-end acceptance test (manual)
- Step 1: Run from a clean clone of slim main
git clone <slim-main-path> /tmp/test-project
cd /tmp/test-project
pnpm install
pnpm turbo gen core-package realtime
pnpm turbo gen core-package events
pnpm turbo gen core-package trpc
pnpm turbo gen core-package ui
pnpm install
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green; resulting tree byte-identical to pre-slim main (modulo lockfile order).
- Step 2: Commit summary memory note (project memory) describing the verified end-state
(No commit to repo — write to user memory if desired.)
Task 7.6: Final verification gate
- Step 1: All gates green
- Step 2: All e2e tests passing
- Step 3: Plan complete
Notes for the executing agent
- Phases 0-7 are sequenced. Don't start Phase 2 until Phase 1's gates are green; don't start Phase 3 until Phase 2 is in place.
- Phases 3-6 follow identical lifecycles. After completing Phase 3, the pattern is established — phases 4-6 should move faster.
- The "byte-identical reconstruction" promise depends on snapshots being captured at the moment the templates are extracted. If any other commit lands between Task 3.4 (snapshot capture) and Task 3.7 (e2e test) that touches
packages/core-realtime/, regenerate the snapshot before proceeding. - ESLint rules: realtime has separate rule files; events has inline
no-restricted-syntaxblocks. Trpc and ui have neither. The phase-specific tasks call out what to do. - Doc updates per phase are non-negotiable. The HTML explainers in particular get tweaked on every phase — keep them honest.
- Use
pnpm turbo gen core-packagefrom the repo root (or any subdirectory — turbo finds the generators). - The
printNextStepsprint actions for each package are content-heavy. Mirror the existingprintHandlerNextStepspattern inturbo/generators/config.tsfor output formatting.