You've read seven shipped subsystems and built two of your own in the labs. Now distill it into a checklist a future agent can follow to add an eighth @alembic/hermes subsystem without rediscovering the conventions. The recipe is not arbitrary taste — every step traces to an invariant or an ADR you've already met. Follow it and your subsystem will look like it was always there; skip a step and CI or a reviewer will catch you, because the rules are enforced, not suggested.
packages/hermes/src/<name>/ with three files: types.ts (Zod schemas + port interfaces), the implementation (a class or functions over injected ports), and <name>.test.ts (fakes for every port). All exports are named and re-exported from src/index.ts. That's the whole template — memory, learning, clarify, web, skills, curator, and media all match it.| Step | Do | Because (invariant / ADR) |
|---|---|---|
| 1 | Define ports — pass IO/time/randomness as injected interfaces (FsPort, a backend, Clock, an id factory) | Invariant 2: pure kernel, injected side-effects (ADR-0009) |
| 2 | Schema-validate every input with Zod safeParse at the boundary | Inputs may be untrusted model/network output (ADR-0011) |
| 3 | Return Result<T, Error> from every fallible function; never throw across the public boundary | The narrow waist / never-throws (ADR-0009) |
| 4 | No Date.now()/new Date()/Math.random() — inject a Clock/id factory instead | Determinism & replay, invariant 3 |
| 5 | Add no new runtime dependency without justification | "Rules for safe changes" (CLAUDE.md) |
| 6 | Add the per-package vitest.config.ts hardening + run via test:safe | Anti-orphan test safety (Lesson 25) |
| 7 | Export named symbols from src/index.ts; document the CLONE/ADAPT provenance + sources | Discoverability + clean-room (ADR-0011 §4) |
| 8 | Keep the suite green; the total test count must increase or stay flat | Every feature needs tests (CLAUDE.md) |
Start with types.ts. Define what a valid input is (Zod) and what the subsystem depends on (port interfaces). The shipped subsystems do exactly this — e.g. the web subsystem's WebBackend + Compressor ports, the learning loop's ReviewProposer + ReviewGate ports. Ports are types, never concrete classes:
// packages/hermes/src/<name>/types.ts import { z } from 'zod'; export const requestSchema = z.object({ /* … bounded fields … */ }); export type Request = z.infer<typeof requestSchema>; // A port = an injected capability, never a concrete import: export type Backend = (req: Request) => Promise<import('@alembic/contracts').Result<Response, Error>>; // never throws
The implementation file is functions or a class over those ports. Every fallible path returns Result; IO is wrapped in tryCatchAsync; time/randomness come from an injected Clock/id factory, never a global (Lesson 28). This is the exact skeleton from Lab 1 — it's not a toy, it's the production pattern at minimum size.
// packages/hermes/src/<name>/<name>.ts import { ok, err, tryCatchAsync, type Result } from '@alembic/contracts'; import { requestSchema, type Backend } from './types.js'; export const doThing = async ( input: unknown, deps: { backend: Backend; now: () => number }, ): Promise<Result<Response, Error>> => { const parsed = requestSchema.safeParse(input); // ② boundary Zod if (!parsed.success) return err(new Error(parsed.error.message)); return deps.backend(parsed.data); // ③ Result through, ④ no globals };
Before adding a package, ask: can a global already in the runtime do it? The web and media subsystems answered yes — they use global fetch via a thin createFetchBackend rather than adding an HTTP client (Lesson 11). The skills subsystem wrote its own dependency-free scalar frontmatter parser rather than pulling YAML (Lesson 12). The rule: "Do not add new dependencies without justification." A new dep is a supply-chain surface and a clean-room risk — earn it or avoid it.
Each package carries its own vitest.config.ts (the hermes package has one). Match the hardened root config — bounded timeouts and pool:'forks' — and always run the suite through the process-group wrapper, never bare vitest:
# run the whole suite safely (Lesson 25): pnpm test:safe # bounded run in its own group → kill the group + sweep # or one package: pnpm --filter @alembic/hermes test
testTimeout/teardownTimeout/forks hardening (Lesson 25) is part of the contract — the test-safety wrapper is the floor, the config is the first line.Re-export every public symbol from src/index.ts with a header comment naming the CLONE/ADAPT/IGNORE provenance and the source map sections (read any subsystem's block in index.ts — they all do this). Then run the baseline and confirm the count moved the right way:
# the build/test baseline every change must keep green (CLAUDE.md):
pnpm -r typecheck && pnpm -r build && pnpm -w test
The discipline closes here: "Every new feature needs tests; total test count should increase or stay flat." A subsystem with no tests, or one that drops the count, is not done.
packages/hermes/src/<name>/ with types.ts + <name>.ts + <name>.test.tssafeParse at the boundaryResult<T, Error>; never throws publiclyFsPort, backend, Clock, id factory) — no node:fs, no Date.now(), no Math.random()test:safesrc/index.ts with provenance + sourcespnpm -r typecheck && pnpm -r build && pnpm -w test green; count flat-or-upfetch via a thin backend — no HTTP dependency. And the backend is injected, so tests pass a fake instead of hitting the network. "Do not add new dependencies without justification" (CLAUDE.md).Clock port makes the lifecycle deterministic in test and replay-safe in production (Lesson 28), and keeps the code uniform with the rest of the package.test:safe?test:safe (Lesson 25) guarantees a hung test can't pin CPU for hours. The recipe bakes both into the definition of "done," so a future agent can't quietly skip either.node:fs call saves three lines and costs you in-memory tests, deterministic replay, and a swappable backend. The pattern scales down cleanly.Date.now(), CI runs the never-throws-shaped types, the test wrapper kills orphans, and a reviewer checks provenance. Deviation isn't a style debate — it's a failing build or a rejected change.