Você já leu sete subsistemas em produção e construiu dois nos labs. Agora destile isso numa checklist que um agente futuro possa seguir para adicionar um oitavo subsistema a @alembic/hermes sem redescobrir as convenções. A receita não é gosto arbitrário — cada passo rastreia a uma invariante ou a um ADR que você já encontrou. Siga-a e seu subsistema parecerá que sempre esteve lá; pule um passo e o CI ou um revisor vai te pegar, porque as regras são impostas, não sugeridas.
test:safe, lição 25) e o determinismo (lição 28).@alembic/hermes compartilha.A maioria dos sistemas adiciona um subsistema novo do jeito que der: cada um com seu formato, suas dependências, seu estilo. O @alembic/hermes faz o contrário — há uma forma, repetida sete vezes, e a oitava deve ser a mesma. A receita captura essa forma como uma lista que um agente futuro segue sem redescobrir nada.
A receita de oito passos — cada passo rastreia a uma invariante ou um ADR. A baseline na fundação é o que deve permanecer verde.
Uma pasta sob packages/hermes/src/<nome>/ com três arquivos: types.ts (schemas Zod + interfaces de port), a implementação (uma classe ou funções sobre ports injetados) e <nome>.test.ts (fakes para cada port). Todos os exports são nomeados e re-exportados de src/index.ts. Esse é o template inteiro — memory, learning, clarify, web, skills, curator e media seguem todos ele.
packages/hermes/src/index.ts, vai ver um bloco de cabeçalho por subsistema dizendo se ele é um CLONE, um ADAPT ou um IGNORE, e apontando as seções do mapa de origem. O oitavo subsistema ganha o seu próprio bloco no mesmo formato.Dos oito passos abaixo, qual você acha que mais gente acha que é só "estilo" e pode pular?
test:safe. Parecem detalhes, mas são exatamente os que a máquina impõe: a plan-VM rejeita Date.now()/Math.random(), e o wrapper test:safe mata workers órfãos. Pular não é uma escolha de estilo — é um build quebrado ou CPU travada por horas.| Passo | Faça | Porque (invariante / ADR) |
|---|---|---|
| 1 | Defina ports — passe IO/tempo/aleatoriedade como interfaces injetadas (FsPort, um backend, Clock, uma id factory) | Invariante 2: kernel puro, efeitos colaterais injetados (ADR-0009) |
| 2 | Valide com Zod safeParse toda entrada na fronteira | Entradas podem ser saída não-confiável de modelo/rede (ADR-0011) |
| 3 | Retorne Result<T, Error> de toda função falível; nunca lance através da fronteira pública | A cintura estreita / nunca-lança (ADR-0009) |
| 4 | Sem Date.now()/new Date()/Math.random() — injete um Clock/id factory | Determinismo & replay, invariante 3 |
| 5 | Não adicione nova dependência de runtime sem justificativa | "Regras para mudanças seguras" (CLAUDE.md) |
| 6 | Adicione o vitest.config.ts endurecido por pacote + rode via test:safe | Test-safety anti-órfão (lição 25) |
| 7 | Exporte símbolos nomeados de src/index.ts; documente a proveniência CLONE/ADAPT + as fontes | Discoverabilidade + clean-room (ADR-0011 §4) |
| 8 | Mantenha a suíte verde; a contagem total de testes deve subir ou ficar estável | Toda feature precisa de testes (CLAUDE.md) |
Comece pelo types.ts. Defina o que uma entrada válida é (Zod) e de que o subsistema depende (interfaces de port). Os subsistemas em produção fazem exatamente isso — por exemplo, os ports WebBackend + Compressor do subsistema web, ou os ports ReviewProposer + ReviewGate do loop de aprendizado. Ports são tipos, nunca classes concretas.
O schema descreve a entrada com campos limitados; o port é uma capacidade injetada, declarada como tipo (uma função ou interface), nunca um import concreto. Repare que o backend já promete Result e "never throws".
// packages/hermes/src/<nome>/types.ts import { z } from 'zod'; export const requestSchema = z.object({ /* … campos limitados … */ }); export type Request = z.infer<typeof requestSchema>; // Um port = uma capacidade injetada, nunca um import concreto: export type Backend = (req: Request) => Promise<import('@alembic/contracts').Result<Response, Error>>; // nunca lança
types.ts é o que o teste substitui por um fake — testabilidade nasce aqui.O arquivo de implementação é uma classe ou funções sobre aqueles ports. Todo caminho falível devolve Result; o IO é embrulhado em tryCatchAsync; tempo e aleatoriedade vêm de um Clock/id factory injetado, nunca de um global (lição 28). Esse é o mesmo esqueleto do Lab 1 — não é um brinquedo, é o padrão de produção no tamanho mínimo.
A função valida com safeParse (passo ②), devolve Result em todo ramo (passo ③) e recebe now como dependência em vez de chamar Date.now() (passo ④). Quem chama checa o discriminante .ok — nunca embrulha em try/catch esperando surpresa.
// packages/hermes/src/<nome>/<nome>.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); // ② fronteira Zod if (!parsed.success) return err(new Error(parsed.error.message)); return deps.backend(parsed.data); // ③ Result passa, ④ sem globais };
err é um valor que você propaga, e o relógio chega como argumento — é o que mantém o subsistema testável e replayável de uma só vez.Antes de adicionar um pacote, pergunte: um global que já está no runtime resolve? Os subsistemas web e media responderam que sim — eles usam o fetch global por meio de um fino createFetchBackend em vez de adicionar um cliente HTTP (lição 11). O subsistema skills escreveu seu próprio parser de frontmatter escalar, sem dependências, em vez de puxar um YAML (lição 12). A regra: "Não adicione novas dependências sem justificativa." Uma dep nova é uma superfície de supply-chain e um risco de clean-room — mereça-a ou evite-a.
package.json do @alembic/hermes tem só três dependências de runtime. Toda dep nova teria que se justificar contra essa linha.fetch global e um pouco de código próprio.Cada pacote carrega seu próprio vitest.config.ts (o pacote hermes tem um). Espelhe a config endurecida da raiz — timeouts limitados e pool:'forks' — e rode a suíte sempre pelo wrapper de grupo de processo, nunca pelo vitest pelado:
# rode a suíte inteira com segurança (lição 25): pnpm test:safe # run limitado no próprio grupo → mata o grupo + varredura # ou um pacote: pnpm --filter @alembic/hermes test
test:safe é o piso. Juntos, um teste pendurado falha em vez de travar a máquina.testTimeout/teardownTimeout/forks (lição 25) faz parte do contrato — o wrapper de test-safety é o piso, a config é a primeira linha de defesa.Re-exporte todo símbolo público de src/index.ts com um comentário de cabeçalho nomeando a proveniência CLONE/ADAPT/IGNORE e as seções do mapa de origem (leia o bloco de qualquer subsistema no index.ts — todos fazem isso). Depois rode a baseline e confirme que a contagem moveu na direção certa:
# a baseline de build/test que toda mudança deve manter verde (CLAUDE.md):
pnpm -r typecheck && pnpm -r build && pnpm -w test
A disciplina se fecha aqui: "Toda feature nova precisa de testes; a contagem total de testes deve subir ou ficar estável." Um subsistema sem testes, ou que derruba a contagem, não está pronto.
packages/hermes/src/<nome>/ com types.ts + <nome>.ts + <nome>.test.tssafeParse na fronteiraResult<T, Error>; nunca lança em públicoFsPort, backend, Clock, id factory) — sem node:fs, sem Date.now(), sem Math.random()test:safesrc/index.ts com proveniência + fontespnpm -r typecheck && pnpm -r build && pnpm -w test verde; contagem estável-ou-acimaA receita é imposta em quatro pontos — VM, CI, wrapper de teste e revisor. Pular um passo é um build quebrado ou uma mudança rejeitada, não um debate de estilo.
Clique num passo para ver o que ele exige, por quê, e como um subsistema real o cumpre.
Passe IO, tempo e aleatoriedade como interfaces injetadas — um backend, FsPort, Clock, uma id factory. Ports são tipos, nunca classes concretas.
Porque: invariante 2 — kernel puro, efeitos colaterais injetados (ADR-0009). Exemplo: web usa WebBackend + Compressor."Bom senso" é um conselho: cada agente interpreta do seu jeito, e o oitavo subsistema sai diferente dos sete. A receita é uma lista fechada: siga-a e você não tem como divergir. Cada passo existe porque compra uma propriedade verificável — testabilidade (① ②), determinismo (④), segurança de execução (⑥), proveniência (⑦) — não porque é elegante.
A maior parte da receita é imposta por máquina, não por documentação: a plan-VM rejeita Date.now() (④); os tipos no formato never-throws e o typecheck pegam um throw público (③); o wrapper test:safe mata órfãos (⑥); o build/test da baseline falha se a suíte quebrar ou a contagem cair (⑧). Os passos que sobram (⑤ dependência, ⑦ proveniência) caem na revisão humana. Não há "deixa pra depois" silencioso — ou passa nos quatro pontos de imposição, ou não entra.
Você vai adicionar um subsistema notify (envia um aviso quando um run termina). Para cada decisão, qual passo da receita ela cumpre — ou viola?
packages/hermes/src/notify/types.ts com notifyRequestSchema (Zod) e um type NotifyBackend = (req) => Promise<Result<…>>. → Cumpre ① e ②. O port é um tipo injetado; a entrada tem schema. Testabilidade garantida.Date.now() direto. → Viola ④. Injete now: () => number em vez disso — a plan-VM rejeitaria isso num módulo de plano, e quebra o replay.node-fetch ao package.json. → Viola ⑤. O fetch global já está no runtime (Node 18+); embrulhe-o num createNotifyBackend fino, como web/media fizeram. Nenhuma dep nova.fakeNotifyBackend e roda pnpm test:safe; a contagem total sobe de N para N+3. → Cumpre ⑥ e ⑧. Suíte verde pelo wrapper, contagem para cima — pronto para o re-export nomeado (⑦).NotifyBackend de src/index.ts, mas sem comentário de cabeçalho dizendo que é um CLONE de tal fonte". Qual passo isso parece cumprir, mas na verdade deixa incompleto? (Resposta: o ⑦. O export nomeado está lá, mas a metade da proveniência/clean-room falta — e é justo o que um revisor cobra.)Tente responder antes de virar cada cartão (prática de recuperação).
types.ts (Zod + ports), <nome>.ts (sobre ports injetados) e <nome>.test.ts (fakes por port). Tudo re-exportado de src/index.ts.fetch global atrás de um backend fino injetado (como createFetchBackend). Adicionar dependência exige justificativa.Clock (epoch ms): produção passa o relógio real, o teste passa um fixo. Nada de Date.now() — a VM rejeita.typecheck && build && test está verde e a contagem total de testes subiu ou ficou estável. Sem testes ou contagem caindo = não pronto.node:fs economiza três linhas e custa testes em memória, replay determinístico e um backend trocável. O padrão escala para baixo limpo.Date.now(), o CI roda os tipos no formato never-throws, o wrapper de teste mata órfãos, e um revisor confere a proveniência. Desvio não é debate de estilo — é um build falhando ou uma mudança rejeitada.| Afirmação | Convenção frágil? | Imposta? |
|---|---|---|
"Por favor, não use Date.now() no subsistema" | sim | não |
A plan-VM rejeita o módulo com Date.now() | não | sim |
| "Lembre de rodar a suíte com segurança" | sim | não |
O build/test da baseline falha se a suíte quebra | não | sim |
Três perguntas. A pontuação corre conforme você responde — acerte as três e a revisão fecha.
web e media usam o fetch global por meio de um backend fino — sem dependência HTTP. E o backend é injetado, então os testes passam um fake em vez de bater na rede. "Não adicione novas dependências sem justificativa" (CLAUDE.md).Clock torna o ciclo de vida determinístico no teste e replay-safe na produção (lição 28), e mantém o código uniforme com o resto do pacote.test:safe?test:safe (lição 25) garante que um teste pendurado não pode travar a CPU por horas. A receita assa as duas coisas na definição de "pronto", para um agente futuro não pular nenhuma em silêncio.packages/hermes/src/ e percorra os oito passos — "ele tem os três arquivos? valida com Zod? devolve Result? injeta o tempo? evita deps? roda por test:safe? exporta nomeado com proveniência? a suíte está verde?". Se um passo falhar, você achou a falha. A seguir (lição 30): o capstone — onde você junta tudo: o motor, a fusão, o método e a receita num exercício de ponta a ponta.