Nove armadilhas que custaram caro de verdade — e o que TODAS têm em comum: elas falham em SILÊNCIO. Residue zero sem aviso, símlink que some, teste que "passa" sem rodar, empate resolvido por ordem escondida. Esta lição diagrama cada erro e o seu fix, e fecha com a rotina que transforma o operador na última linha de defesa.
O motor é fail-closed nas BORDAS: argv inválido morre em exit 2, config errada vira err nomeado, gasto sem dupla trava vira preview. As gotchas desta lição vivem no espaço que sobra — onde o comportamento é TECNICAMENTE correto (a função fez o que o tipo promete) mas a EXPECTATIVA do operador estava errada. walkCorpus não "falha" ao ignorar um símlink; pickCheapestForTier não "erra" ao manter o primeiro no empate. O contrato foi cumprido; a surpresa é sua. Por isso cada gotcha abaixo tem o mesmo formato: o erro COMO ELE APARECE, o mecanismo com citação, e o fix como comando ou regra.
Pense como… as placas afiadas de uma oficina de marceneiro: cada uma marca o lugar EXATO onde alguém já perdeu um dedo. Onde a analogia quebra: aqui a serra nem faz barulho — a placa é a única testemunha de que o perigo existe.
classifyFamily (packages/etl/src/priors.ts:70–76) lê o PRIMEIRO segmento do path relativo ao corpus; segmento não reconhecido ⇒ Unknown. Aponte o distill na RAIZ de uma família (ex.: …/Resources/Bookmarks) e os primeiros segmentos viram items/, index/… ⇒ tudo Unknown ⇒ residue 0, silencioso. O fix: aponte no PAI (…/Resources), para o 1º segmento SER o nome da família; para escopar UMA família, o truque cpio — copiar só os package.jsonl daquela família preservando a árvore.
O readDir do FsPort mapeia cada Dirent para { isDirectory, isFile } (packages/etl/src/fs-port.ts:139–146) — e um símlink responde false para os dois. Resultado: walkCorpus (pipeline.ts:127–131) pula o símlink sem registrar nada, e o SkillStore idem (packages/hermes/src/skills/skill-store.ts:89: if (!entry.isDirectory) continue;) — skills de plugin symlinkadas não entram no import. Nenhum erro, nenhum warning: o item simplesmente NÃO EXISTE para o walker.
pickCheapestForTier (packages/contracts/src/registry.ts:244–257) reduz com entryCost < bestCost ? entry : cheapest — no EMPATE, o incumbente (o declarado primeiro) vence. No T2, dois modelos custam os mesmos 0.0005/1k somados; quando o primeiro declarado era o deepseek-v4-pro (AccessDenied no gateway), a rota T2 inteira caía num modelo inutilizável. O fix do PR #157 não mudou a função: mudou a ORDEM — gemini-3.5-flash declarado antes. Ordem de declaração no registry é decisão de produto, não detalhe.
pnpm --filter <pkg> test sai com código 0 quando o pacote NÃO TEM script test — e há instância viva: @alembic/loop-engineering não declara o script (prove: grep '"test"' packages/loop-engineering/package.json → vazio), então o filter "passa" tendo rodado ZERO dos seus 8 testes. Eles só rodam pelo glob do raiz (vitest.workspace.ts: packages/*/src/**/*.test.ts). E um pacote que queira --filter test confiável precisa do próprio vitest.config.ts com setupFiles em caminho absoluto — 17 pacotes têm o seu (prove: ls packages/*/vitest.config.ts | wc -l).
Dois ingredientes: um teste pendurado sem teardown E o pai morto sem matar a árvore. Os workers tinypool do vitest reparentam para o PID 1 e pinam um core (~90% CPU) por horas. O fix é estrutural e já é o BASELINE da casa: scripts/safe-test.mjs roda a suíte em process group PRÓPRIO (detached: true ⇒ setsid), aplica hard-timeout, mata o grupo INTEIRO (kill(-pid)) e varre sobras (pkill -9 -f vitest); hard-timeout sai com 124. Por isso o baseline é node scripts/safe-test.mjs pnpm -w test — nunca o vitest pelado num loop.
Você faz checkout de uma branch que ADICIONA uma dependência workspace:*. O node_modules local não tem o símlink novo: tsc falha com Cannot find module '@alembic/X' — enquanto o vitest PASSA (resolve os fontes pelo glob). A dupla mentira: o typecheck vermelho parece bug do código, e o teste verde parece absolvição. Fix mecânico: pnpm install após o checkout, e pnpm -r build para os dependentes enxergarem os .d.ts novos. CI é imune (instala sempre do zero) — a armadilha é só local.
A gotcha mais barata de evitar e a mais recorrente do histórico: escrever "o CLI tem N comandos" contando no olho. O digest #1 do failure-historian elegeu o drift de contagem como pior ofensor; a resposta foi scripts/derive-counts.mjs (PR #158), e a primeira execução JÁ pegou drift vivo — docs diziam 32; a fonte (USAGE) dizia 33/47. Regra da casa desde então: número de superfície se deriva da fonte por script; contagem manual em doc é bug esperando leitor.
Squash-merge cria um commit NOVO em main, com SHA diferente dos commits da branch. Consequência: git merge-base --is-ancestor e git cherry respondem como se a branch NÃO tivesse sido mergeada — e um operador apressado "re-mergeia" trabalho já entregue, ou conclui que algo se perdeu. A única testemunha confiável é o log de main: procure o ASSUNTO do commit, não o SHA.
A gotcha mais perigosa porque parece SUCESSO. O turno do employee (employee run --online e automation run) NÃO tem tool-loop: o modelo recebe prompt e devolve texto — ele não executa ferramenta nenhuma. Peça "varra o diretório X e liste os arquivos" e a resposta virá com uma varredura fabricada, plausível e falsa (provado na fronteira). O fix até a perna tool-loop existir: dados VERIFICADOS vão NO PROMPT (você varre, o modelo raciocina), e a honestidade estrutural da casa cerca o resto — employee explain etiqueta cada claim como observed/inferred/unknown, e o write-back A3b grava só o goal real + excerpt real da resposta, nunca um "learning" inventado.
Contra erro silencioso, ritual explícito. O baseline canônico — o MESMO para humano e agente, sempre com o safe-test na frente da suíte:
pnpm -r typecheck && pnpm -r build && node scripts/safe-test.mjs pnpm -w test
E a rotina que amarra as nove placas num hábito só — cada passo mata uma classe de gotcha:
| Passo | O que fazer | Gotchas que mata |
|---|---|---|
| 1 · Chegar | git pull + pnpm install + baseline completo | ⑥ símlink de workspace · ⑤ órfãos |
| 2 · Apontar | Conferir o path do corpus (PAI da família) e a contagem pós-walk | ① family-prefix · ② símlinks |
| 3 · Rodar | Dry-run PRIMEIRO; efeito só com a flag do efeito | ⑨ fabricação (dados no prompt) |
| 4 · Verificar | Contar o que rodou no OUTPUT (testes, linhas, records) — nunca só o exit 0 | ④ filter-test · ③ empate escondido |
| 5 · Registrar | Números derivados por script; merge conferido no log de main | ⑦ contagem à mão · ⑧ squash-illusion |
alembic distill ~/Documents/Resources/Bookmarks --offline e o run termina com residue 0, sem erro algum. Qual é a leitura correta?classifyFamily (priors.ts:70–76) lê o 1º segmento do path RELATIVO AO CORPUS. Apontando na raiz da família, os primeiros segmentos são as subpastas internas ⇒ Unknown ⇒ residue 0 SILENCIOSO. Fix: aponte no PAI (…/Resources) — e desconfie de todo zero que veio fácil demais.test no package.json (prove com grep '"test"' packages/loop-engineering/package.json), o filter sai 0 sem rodar NADA. Os 8 testes de runtime.test.ts só rodam na suíte raiz (pnpm -w test, glob do vitest.workspace.ts). Verde sem contagem de testes no output é verde vazio.Reproduza cada mecanismo desta lição ($0, leitura pura):
# ① a família vem do 1º segmento sed -n '66,80p' packages/etl/src/priors.ts # ② símlinks: o Dirent mapeado e os dois walkers que pulam sed -n '139,146p' packages/etl/src/fs-port.ts sed -n '127,131p' packages/etl/src/pipeline.ts sed -n '84,96p' packages/hermes/src/skills/skill-store.ts # ③ o empate mantém o primeiro declarado sed -n '244,257p' packages/contracts/src/registry.ts # ④ o filter enganoso, ao vivo grep -n '"test"' packages/loop-engineering/package.json ; echo "exit=$?" ls packages/*/vitest.config.ts | wc -l # ⑤ o safe-test por dentro (grupo próprio + sweep + 124) sed -n '1,50p' scripts/safe-test.mjs # ⑧ a verdade pós-squash é o log de main git log --oneline main | grep -i "<assunto do PR>" # o baseline canônico, sempre pnpm -r typecheck && pnpm -r build && node scripts/safe-test.mjs pnpm -w test
pnpm install pós-checkout (⑥) e SEMPRE safe-test.mjs na suíte (⑤).