# HANDOFF — Épico Evolução do Harness

> LEIA no início de toda sessão. ALIMENTE ao fim de cada task: progresso, testes rodados, bugs achados/corrigidos, o que ficou pra trás, estado atual.

**Spec:** docs/superpowers/specs/2026-07-23-harness-evolution-design.md
**Plano da fase atual:** docs/superpowers/plans/2026-07-23-harness-fase0-convergencia.md

## Estado atual
- **ÉPICO COMPLETO.** A spec tem cinco fases, numeradas **0 a 4**, e todas fecharam — a última (Painel de Evolução) em 2026-07-27, na branch `feat/operacao-visivel`. *(Correção: uma versão anterior desta linha dizia "falta a Fase 5". Não existe Fase 5 — a contagem "5 fases" virou um número de fase que a spec nunca teve. Confira em `docs/superpowers/specs/2026-07-23-harness-evolution-design.md`: os cabeçalhos vão de "Fase 0" a "Fase 4".)*
- **Continuação do épico: ENTREGUE em 2026-07-27** (`docs/superpowers/plans/2026-07-27-mapeamento-funil-agente.md`) — a tela onde o tenant diz em qual etapa do funil o agente coloca o cliente. Era a única lacuna que o Painel de Evolução relatava sem oferecer conserto; `crm_stages.agent_stage_hint` deixou de ser coluna que só sonda de teste escreve. Detalhe no fim deste arquivo.
- **Continuação da continuação: ENTREGUE em 2026-07-28** (`docs/superpowers/plans/2026-07-27-gerenciar-etapas-do-funil.md`) — a seção onde o tenant renomeia, cria, reordena, marca ganho/perda e arquiva as ETAPAS do próprio funil. Fecha o achado B da feature anterior (`is_won`/`is_lost` sem caminho de produto) e provou, numa organização recém-criada, o funil de e-commerce de fábrica virando o de uma clínica e o agente movendo o card para a etapa que o dono nomeou. Detalhe no fim deste arquivo — **inclusive dois achados de produto que não são dela** (transbordo a 390px em qualquer tela; não existe criação de funil).
- **Uma prova continua em aberto, e é do Rafael:** ninguém mandou uma mensagem real de WhatsApp fechando o ciclo completo. A receita de 1 minuto está no fim deste arquivo.

## Log
- 2026-07-23 — Task 1 concluída: PublishedAgentConfig expõe KB ativa e knobs de RAG (activeKbVersionId, ragTopK, ragSimilarityThreshold). Testes: 3/3 verde (campos RAG, defaults, null). Typecheck zerado. Pronta pra Task 2 (retrieval setup).
- 2026-07-23 — Task 2 concluída: lib/agent-engine/agent/search-knowledge.ts (searchKnowledge + citationsFromHits), 3/3 testes verdes; commit extra necessário no vitest.setup (loader de .env p/ import-time env validation).
- 2026-07-23 — Task 3 concluída: tool `search_knowledge` cabeada no turno (inbound-turn.ts) — def estática em AGENT_TOOL_DEFS, execute com searchKnowledge/citationsFromHits, gate pós-montagem (só entra com activeKbVersionId), citações anexadas em messages.metadata na outbound 'sent'. Achado: READ_ONLY_TOOLS morava em inbound-turn.ts, não em tool-breaker.ts como o brief assumia — movido pra tool-breaker.ts (config do breaker, mais coeso) por decisão do coordenador. Testes: 11/11 verdes (lib/agent-engine), typecheck e lint zerados.
- 2026-07-23 — Task 4 concluída: drain pula orgs em ai_dispatch_mode=external (spec 14). Implementação: query settings->>'ai_dispatch_mode' em processEvent após parse, early return se 'external', evento marcado done sem enfileirar job. Testes: 1/1 verde (mock WAHA pool, verifica guard consulta modo e evento vira done sem job). Typecheck zerado. Commit: 028ac19.
- 2026-07-23 — Task 5 concluída: dispatch nativo aposentado — cron `/api/v1/cron/agent-dispatcher` virou no-op permanente em QUALQUER valor de AGENT_DISPATCH_CONSUMER (auth preservada), worker liga o drain incondicionalmente (warn se `native`), headers `@deprecated` em lib/ai/dispatcher/index.ts e lib/ai/runtime/agent.ts (módulos mantidos, fora do caminho quente). TDD: teste do caso 'native' ajustado primeiro (FAIL confirmado com a rota ainda despachando), depois PASS após a mudança. Testes: 3/3 verdes na rota; typecheck e lint zerados (0 erros). Nenhum outro teste tocava o branch do worker (sem *.test.ts em workers/).
- 2026-07-23 — Task 6 em curso: suite completa verde (343/343, typecheck/lint 0), worker NOVO no ar (healthz ok, 8787; worker antigo do worktree multimodal foi parado p/ evitar disputa de fila). BLOQUEIO: sem OPENAI_API_KEY/AI_GATEWAY_API_KEY no .env.local — embeddings indisponíveis, KB não pode ser indexada (ai_chunks=0). Aguardando chave + mensagem real de WhatsApp do Rafael.
- 2026-07-23 — ACHADO de calibração: threshold default 0.72 filtra hits legítimos do text-embedding-3-small (pergunta de frete deu 0.703). Ajustado knob do agente de prova p/ 0.5; recalibrar default em fase futura.
- 2026-07-23 ~13:50 — PROVA PAUSADA a pedido do Rafael (teste dele p/ vídeo usa o mesmo agente/sessão). Restaurei a v24 AgendaPlus dele (published_version_id=c54dc195, active_kb_version_id=null). Para RETOMAR a prova quando ele liberar: (1) ele fecha o worker dele; (2) publicar v25-like: published_version_id=nova cópia da v23/3003ce45 + active_kb_version_id=df9810c7; (3) subir `npm run worker` da branch; (4) Rafael manda pergunta do frete; (5) validar sent+ack+citations e screenshot do inbox; (6) restaurar v24 dele de novo. Já provado nesta sessão: search_knowledge devolveu o fato da KB no turno 12:44 (resposta com R$ 199), envio real `sent`+external_id funcionou às 13:39 pós-fix da WAHA key. Dev server da branch na 3000 FICA no ar (o teste dele depende dele — envios passam por lá).
- 2026-07-23 ~14:2x — TASK 6 CONCLUÍDA (prova real ponta-a-ponta): pergunta real de WhatsApp → turno v27 → search_knowledge → resposta com o fato da KB (R$ 199) ENVIADA de verdade (sent + external_id 3EB0640A213518922775DA) → citations em messages.metadata → painel "Citações da resposta IA" renderizado no inbox (screenshot em .superpowers/sdd/evidence/prova-fase0-citacoes.png). Contra-prova do guard external ao vivo: evento done, 0 jobs, log "org em modo external (spec 14) — evento pulado". Achados de UX p/ fase futura: painel de citações mostra "65%" sem rótulo e "message_id" cru — jargão p/ atendente leigo (UI herdada do EPIC-13, fora do escopo da Fase 0). Ambiente: v24 AgendaPlus do Rafael RESTAURADA como publicada (vídeo dele); KB df9810c7 existe e pode ser reativada a qualquer momento; OPENAI_API_KEY agora no .env.local; WAHA_API_KEY alinhada ao container.
- 2026-07-23 — FASE 0 FECHADA: review final da branch READY TO MERGE (1 fix aplicado: ee1873d, try/catch da RPC no searchKnowledge). Suite completa + typecheck verdes pós-fix. Minors deferidos documentados no ledger .superpowers/sdd/progress.md. Próxima fase do épico: Fase 1 — Memória Geral da Org (spec seção "Fase 1").
- 2026-07-23 — FASE 1 Task 1 concluída: migration 0067 (`org_memory_versions`/`org_memory_pointers`/`org_memory_entries` + novo valor `org_memory_entry` no check de `flywheel_distiller_proposals.type` + RLS `tenant_isolation_*_all`). NNNN renumerado de 0054→0067 no meio da task: o pre-commit hook bloqueou por colisão com `0054_followup_flows` em `feat/followup-flows` (outra branch local); 0066 era o maior NNNN entre TODAS as branches locais. Aplicada no dev via `supabase db query --linked` (role postgres — `SUPABASE_DB_URL` do `.env.local` é o role `agent_worker`, sem CREATE/owner; achado registrado). Idempotência provada (2ª aplicação sem erro). `database.types.ts` regenerado via `supabase gen types typescript --linked` (contém as 3 tabelas). RLS provada manualmente contra dados reais do dev (docker indisponível p/ `test:db`): 0 rows cross-tenant, ≥1 na própria org. Seed do `rls-isolation.test.ts` estendido (org_memory_versions/entries) para quando `test:db` puder rodar.
- 2026-07-23 — FASE 1 Task 2 concluída: lib/agent-engine/agent/org-memory.ts (loader trio versão/ponteiro + render + compose). TDD: 5/5 testes verdes (loadOrgMemory—doc pelo ponteiro + entries em ordem estável, render—bloco determinístico, composeSystemPrompt—ordem playbook→memória→skills, zero separadores órfãos). Typecheck zerado. Padrão mirrored do playbook.ts (sem cache intencional, resolução em início de cada run, determinístico byte-a-byte para mesma versão+entries). Commit: bfc45d5.
- 2026-07-23 — FASE 1 Task 3 concluída: memória da org cabeada no turno (inbound-turn.ts) — `loadOrgMemory` chamado logo após `skillIndex`, `system` remontado via `composeSystemPrompt(playbook, orgMemoryBlock, skillIndex)`. Suite lib/agent-engine: 18/18 verde (composeSystemPrompt da Task 2 cobre a byte-identidade playbook-only/playbook+skills — não existe llm-cache.test.ts separado no repo). Typecheck zerado. Prova funcional no banco dev: inserida versão+ponteiro de teste p/ org 00000000-0000-4000-8000-000000000000, script tsx descartável confirmou o bloco "TESTE F1: prova de composição — v2." renderizado (junto com entry pré-existente da org); linhas de teste deletadas ao final (contagem 0/0 confirmada).
- 2026-07-23 — FASE 1 Task 4 concluída: API `/api/v1/ai/memory` (GET estado completo + POST publica versão), `/entries` (POST cria manual), `/entries/[id]` (PATCH archive/active), `/versions/[id]` (GET conteúdo p/ Dialog de histórico). 3 novas ações de audit (`ai.org_memory_published`, `ai.org_memory_entry_created`, `ai.org_memory_entry_updated`) adicionadas a `lib/audit/actions.ts` (não estavam na union). TDD: 4/4 testes verdes em route.test.ts (401 sem auth, GET vazio document null, POST publica+upsert pointer+audit, 422 content vazio). Typecheck e lint zerados. Entries POST/PATCH e versions/[id] GET seguem o mesmo padrão mas sem teste dedicado (brief só pediu route.test.ts).
- 2026-07-23 — FASE 1 Task 6 concluída: tela `app/app/ai/memory` (hooks/ai/useOrgMemory.ts + page.tsx + _client.tsx) — doc-mãe versionado (textarea, contagem de caracteres, "Publicar versão" só habilita com mudança real, badge da versão ativa, histórico clicável abrindo Dialog somente-leitura com "Restaurar como nova versão"), timeline de aprendizados (badge manual/flywheel, arquivar/reativar, form colapsável). Item "Memória da IA" na Sidebar + permissões `ai.memory.view`(manager)/`ai.memory.publish`(admin) em AuthProvider. Typecheck e eslint zerados. Prova real clicando: manager (e2e-manager) criou aprendizado manual → apareceu na timeline → arquivou → reapareceu em "ver arquivados" → reativou; botão Publicar corretamente desabilitado p/ manager com nota explicativa. Admin (e2e-admin) publicou v1 e v2, testou histórico → Dialog com conteúdo read-only da v1 → "Restaurar" recarregou o textarea → publicou de novo. Achado de ambiente (não é bug do Task 6): `.e2e-creds.json` do repo estava com org_id/user_id STALE de outro seed run — org real usada pelos e2e users é `00000000-0000-4000-8000-000000000000` (confirmado via `user_organizations`), não a do arquivo; e2e-admin não tinha MFA enrolado (o factor foi criado do zero durante o teste, secret do `.e2e-creds.json` ficou desatualizado — specs que usam `loginAdminTotp()`/`admin_totp.secret` podem falhar até re-seed). Cleanup: `org_memory_pointers/versions/entries` deletados para a org real, contagem 0/0/0 confirmada.
- 2026-07-23 — FASE 1 Task 5 concluída: distiller decide escopo (`scope: "agent" | "org"` no JSON) e grava `type='org_memory_entry'`/`target='org'` quando é aprendizado da org inteira (live.ts). `applyProposal` ganhou o ramo `org_memory_entry` — insere em `org_memory_entries` (source=flywheel, status=active, proposal_id) e marca `applied_at`/`applied_by` na proposta, SEM criar versão de agent. `ApplyProposalResult` virou união de 2 sucessos (`{versionId,versionNumber}` vs `{entryId}`, distinguidos por presença de campo — shape do caminho playbook_bullet intocado); a rota `apply` distingue via `"entryId" in result` e responde `ok({entry_id})`. UI: `TYPE_LABEL`/botão/toast cobrem "Memória da organização" sem alegar versão nova. Gate humano preservado: distiller só grava proposta, só o clique de apply grava em org_memory_entries. TDD: teste novo do ramo org_memory_entry FAIL→PASS; suite lib/ai 19/19 verde; typecheck e lint zerados.
- 2026-07-23 — FOLLOW-UP registrado (review T5, minor 3): o distillerPrompt ainda é redigido em torno de "bullet de playbook" e higiene de memória do LEAD, mas agora pede escopo org — o caminho org tende a nascer pouco usado. Calibrar o prompt do distiller (ou criar um destilador próprio de memória da org) numa fase futura.
- 2026-07-23 (noite) — AMBIENTE (achados do controller antes da prova da F1): (1) published_version_id do agente 69d04579 estava apontando p/ uma v3 de 20/jul ("You are a helpful assistant") — RESTAURADO p/ v24 AgendaPlus (c54dc195), a persona do Rafael; (2) worker na 8787 (PID 38398, start 19:32) roda do WORKTREE feat-inbox-multimodal, que NÃO tem o código da F1 — a prova exige worker do repo principal (a fila é compartilhada: dois workers = corrida); (3) .e2e-creds.json com org/user obsoletos e admin_totp.secret dessincronizado após enrollment manual na Task 6 — specs que usam loginAdminTotp podem falhar até re-seed.
- 2026-07-24 — FASE 1 código 100% provado; prova de WhatsApp BLOQUEADA por billing externo. (a) Publiquei a regra de memória PELA TELA como admin (login+MFA reais; screenshot f1-memoria-v1-publicada.png: "v1 ativa"). (b) Prova de integração com dados reais do banco (script montando o system do inbound-turn:549-567): persona do agente NÃO contém a assinatura (false); bloco de memória da org contém (true); SYSTEM final que iria ao modelo contém (true) → a memória publicada pela tela entra no prompt real do turno ao lado da persona, sem tocar no prompt do agente. (c) A resposta no WhatsApp falhou SÓ na chamada ao modelo: "credit balance too low" — a conta Anthropic (chave env == BYOK ygAA, mesma chave) está sem saldo; google/openai BYOK existem mas o engine só registra anthropic. RETOMAR a prova de WhatsApp quando houver crédito: 1min — republicar a regra pela tela /app/ai/memory + subir worker do repo principal + mandar mensagem. Ambiente restaurado: memória de teste REMOVIDA da org (não deixar regra de teste no agente real do Rafael); persona v24 AgendaPlus intacta; MFA do e2e-admin re-enrollado no CLOUD (secret no scratchpad admin-mfa-secret.txt; .e2e-creds.json continua apontando p/ ambiente LOCAL desligado — não mexi).
- 2026-07-24 — FASE 1 review final: 2 fixes. (1) removidos `insertOrgMemoryVersion`/`setOrgMemoryPointer` de `org-memory.ts` (órfãos — grep confirmou zero chamadores; a API escreve via Supabase admin client, não pg.Pool). (2) `org-memory` entrou no mapa vivo (`docs/architecture/agent-turn.workflow.json`): nós `org_memory` + `memoryscreen` (tela/API `app/app/ai/memory`) numa lane nova `config`, 2 arestas reais (`memoryscreen→orgmemory` escreve, `orgmemory→turn` injeta no prefixo estável) — `archify validate` 0 erros, HTML re-renderizado. Suite/typecheck/lint verdes.
- 2026-07-24 13:03 — FASE 1 PROVA PONTA-A-PONTA COMPLETA (crédito Anthropic reposto pelo Rafael): mensagem real "oi, vocês atendem sábado?" → agente respondeu no WhatsApp real (external_id 3EB03DD39F6BE7F525CA1E, status sent) terminando com a linha exata "— Equipe AgendaPlus 🌱" — a assinatura que existe SÓ na memória da org (publicada pela tela), CONFIRMADO ausente do system_prompt do agente. Prova o contrato da Fase 1: memória da org muda o comportamento de conversa real sem tocar no prompt do agente. Ambiente limpo depois (regra de teste REMOVIDA da org — não deixar assinatura de teste no agente de produção). OBSERVAÇÃO (flake, não-bloqueante): 1ª tentativa do turno falhou com "JSON de checkpoint inválido no fechamento do turno" e a fila re-tentou com sucesso — vale investigar a robustez do parse de checkpoint numa fase futura. FASE 1 COMPLETA E PROVADA.
- 2026-07-24 — FASE 2 Task 1 concluída: migration 0068 (`skill_versions` ganha `manifest` jsonb + `forked_from_version_id`; nova `skill_activations` telemetria hard/probe; bucket `skill-assets` privado 5MB; policy SELECT de catálogo `organization_id is null` em skill_versions/pointers). Apêndice espelhado no baseline.sql + bloco storage + MANIFEST. Aplicado no banco dev via `supabase db query --linked` (role `agent_worker` sem DDL); re-aplicação sem erro (idempotente); bucket + colunas + policies confirmados por query direta. Types regenerados e reduzidos ao diff de 0068 (banco dev compartilhado trouxe tabelas de outras branches). `npm run test:db`: 34/34 arquivos, 198/199 testes verdes (1 skip pré-existente) — RLS de `skill_activations` incluída no invariante de isolamento. Typecheck zerado.
- 2026-07-24 — FASE 2 Task 2 concluída: LoadedSkill expõe versionId (pré-requisito da telemetria). TDD: 1/1 teste verde (mock db, skills[0].versionId='ver-1'). Implementação: interface +versionId:string, SELECT +v.id, Map +versionId:r.id. Typecheck zerado. Nenhum outro consumidor de LoadedSkill necessitou ajuste em mocks. Commit: eb51e2d.
- 2026-07-24 — FASE 2 Task 3 concluída: `lib/ai/skills/package.ts` (parser puro `.zip` → `ParsedSkillPackage`) via `fflate` (unzipSync); reusa `skillMatcherSchema`/`validateSkillBody` de skills.ts (já exportados, sem mudança lá). Frontmatter lido à mão (só name/description/matcher.any_keywords/probe_keywords, sem dep de YAML). Guards: ≤64 arquivos, ≤5MB total, ≤1MB por arquivo, path traversal (`..`/absoluto/backslash), whitelist de extensão de asset, paths fora de SKILL.md/references//assets/ recusados — todos checados ANTES de materializar conteúdo além do necessário. TDD: 5/5 testes verdes (brief). Typecheck e eslint zerados. package.json/pnpm-lock.yaml só ganharam a linha do `fflate` (isolei do diff pré-existente não relacionado no package.json via blob staged manualmente).
- 2026-07-24 — FOLLOW-UP (review T3): parser de skill usa fflate unzipSync (síncrono) — o filter/originalSize rejeita bombs honestamente-declarados ANTES de inflar, mas um header que MENTE o originalSize ainda infla o real. Defesa completa = streaming Unzip com corte por bytes acumulados. Revisitar antes de expor o import a upload não-autenticado (hoje é atrás de requireRole manager).
- 2026-07-24 — FASE 2 Task 4 concluída: `lib/ai/skills/install.ts` (`importSkillPackage` insere a skill_version PRIMEIRO, sobe cada arquivo do pkg em `{org}/{skill}/{versionId}/{path}` no bucket `skill-assets`, move o ponteiro só no fim; upload falho remove os já subidos e lança, versão órfã fica no banco inofensiva) + `installPlatformSkill` (fork-on-install: lê a versão de plataforma pelo `name` via `skill_pointers`→`skill_versions`, copia body/matcher/manifest pra uma versão da org com `forked_from_version_id`, move o ponteiro da org). `insertSkillVersion` (skills.ts) estendido com `manifest?`/`forkedFromVersionId?` opcionais (default `[]`/null) — sem chamador existente quebrado (grep confirmou zero chamadores externos). TDD: 5/5 testes verdes (insert-then-upload-then-pointer, org_id sempre de `input.organizationId` nunca do pkg, cleanup de upload falho, fork com forked_from_version_id, plataforma inexistente). Typecheck e lint zerados; suite lib/ai+lib/agent-engine 52/52 verde.
- 2026-07-24 — FASE 2 Task 5 concluída: API de skills (`GET /api/v1/ai/skills`, `POST .../import` multipart, `POST .../[name]/install`, `DELETE .../[name]`). 3 ações de audit novas (`ai.skill_imported`, `ai.skill_installed`, `ai.skill_uninstalled`). Achado do "Before You Begin": NENHUMA rota do app usava `pg.Pool` antes desta — só o worker/scripts chamavam `createPool` do agent-engine. Resolvido reusando o MESMO `createPool` (não duplica setup de conexão) atrás de um singleton lazy novo (`lib/ai/skills/db.ts::getSkillsPool()`), com `SUPABASE_DB_URL` promovido de env exclusivo do engine pra `lib/env.ts` (schema `required()`, tolerante em dev — já existia no `.env.example`). GET resolve `installed` via os pointers da PRÓPRIA org (join em skill_versions; `source` derivado de `forked_from_version_id`) e `catalog` via pointers de plataforma cujo name não está em installed — 3 queries simples, sem embed do PostgREST (padrão do repo). DELETE só remove `skill_pointers`, nunca `skill_versions` (histórico imutável). Achado de teste: multipart real (`File`/`FormData`) corrompe em ambiente jsdom (default do projeto) ao passar pelo parser undici do `NextRequest` — `import/route.test.ts` roda em `// @vitest-environment node` (isolado, só este arquivo). TDD: 13/13 testes verdes (4 arquivos: GET, import, install, delete). Typecheck e lint zerados (0 erros, 0 warnings).
- 2026-07-24 — FASE 2 Task 6 concluída: tool `read_skill_reference` no turno + telemetria `skill_activations`. `LoadedSkill.manifest` não existia (só Task 2 tinha trazido `versionId`) — trazido pra `loadSkills` (SELECT `v.manifest` + campo na interface, 2 linhas, sem quebrar `skills.test.ts` que mocka rows sem o campo). Achado de ordenação: `matchSkills` rodava DEPOIS de `wrapToolsWithBreaker` no arquivo original — movido pra ANTES da montagem de `rawTools` (junto da telemetria fire-and-forget do brief) porque o gate "só entra quando há skill casada com references" precisa do resultado do match no MESMO ponto onde `search_knowledge`/`request_human_handoff` já são ligados/desligados, antes do breaker envolver as tools. Storage: `deps.crmCfg.supabase` já era o admin client (service role) usado pelas outras bordas do CRM — nenhum seam novo. Guarda anti-vazamento entre skills: `readSkillReference` só aceita `skill_name` presente em `skillMatch.matched` (as skills CASADAS deste turno, nunca todas as skills do tenant) — pedir uma skill instalada mas não casada volta `skill_not_active` antes de tocar manifest/storage. TDD: 8/8 testes novos verdes (`skill-references.test.ts` + 1 em `tool-breaker.test.ts`); suite completa `lib/agent-engine` 27/27 verde; typecheck e lint zerados. Não existe `llm-cache.test.ts`/teste de byte-identidade dedicado no repo (confirmado por busca — mesmo achado da Task 3 da Fase 1).
- 2026-07-24 — FASE 2 Task 8 concluída: migration 0069 — seed de 2 skills de plataforma (`organization_id null`) no catálogo do marketplace: `objecao-preco` (diagnostica o motivo real por trás do "caro" — orçamento/valor não visto/concorrente/tática/timing — antes de reagir, nunca cede desconto não documentado) e `agendamento` (nunca inventa disponibilidade sem checar agenda real, oferece horários fechados em vez de pergunta aberta, exige confirmação explícita por escrito). Bodies markdown if-then (~80/76 linhas, teto 200) + `matcher` `{any_keywords, probe_keywords}` validado por `skillMatcherSchema` — não há CHECK no banco pro shape (confirmado lendo migration 0050: "shape validado no código"). Idempotente via guard `where not exists (select 1 from skill_pointers where organization_id is null and name=...)` em `do $seed$` blocks — aplicado 2x no banco dev: 2 pointers/1 versão cada nas duas rodadas (sem duplicação). Espelhado no apêndice do baseline.sql + linha no MANIFEST. Sem mudança de contrato — `database.types.ts` não regenerado (seed de dados). Typecheck zerado.
- 2026-07-24 — Fase 2 CONCLUÍDA — prova real ponta-a-ponta (skill .zip muda resposta real no WhatsApp on-keyword, código só na skill não no prompt, skill_activations hard; contra-prova sem keyword = skill dormente); skills no mapa vivo.
- 2026-07-24 — FASE 2 Task 7 concluída: tela `app/app/ai/skills` (hooks/ai/useSkills.ts + page.tsx + _client.tsx) — "Skills instaladas" (cards com badge manual/do catálogo, Desinstalar, nota de que reenviar um .zip com o mesmo nome personaliza) e "Catálogo" (skills de plataforma não instaladas, Instalar) + botão "Enviar skill (.zip)" (upload multipart via `fetch` direto — `apiClient` sempre serializa JSON, não dá pra FormData). Item "Skills da IA" na Sidebar + permissões `ai.skills.view`/`ai.skills.manage` (ambas manager) em AuthProvider. Ícone `PuzzlePiece` (não `Puzzle` — nome real no phosphor 2.1.10) + `UploadSimple`/`DownloadSimple` adicionados ao mapa canônico `lib/ui/icons.ts`. Typecheck e eslint zerados. Prova real clicando (manager e2e-manager, sem MFA): catálogo mostrou os 2 seeds da Task 8 → Instalar `objecao-preco` moveu pra Instaladas com badge "do catálogo" → Desinstalar removeu; upload de um .zip mínimo (SKILL.md+references/, montado com fflate) → apareceu em Instaladas com badge "manual". Screenshot em `.superpowers/sdd/evidence/f2-skills-tela.png`. Cleanup: `skill_pointers`(0)/`skill_versions`(0) da org de teste deletados via psql + objeto de storage em `skill-assets/{org}/teste-f2-upload/...` removido via admin client — confirmado 0/0 e catálogo de plataforma intacto (os 2 seeds voltaram a aparecer).
- 2026-07-24 (F2 T7) — RESSALVA de ambiente compartilhado: o commit 225ba2d (Skills UI) varreu a linha de nav "Radar" no Sidebar.tsx de uma sessão PARALELA (feature Radar de Risco, commit 786d2f6 em outra branch). A pasta app/app/radar/ está UNTRACKED (??) no working tree desta branch. NÃO removi (desfazer apaga trabalho paralelo do Rafael). Se a Fase 2 for mergeada antes da sessão do Radar commitar a rota, o item vira link morto — coordenar ordem de merge. A feature de Skills em si está limpa e aprovada.
- 2026-07-24 (F2 T9) — PROVA REAL COMPLETA: skill instalável muda o comportamento do agente em WhatsApp real, sob demanda (keyword), sem tocar no prompt do agente; telemetria skill_activations hard registrada; disclosure provado por contra-prova (msg sem keyword → skill dormente). Skill de teste limpa do banco+storage.
- 2026-07-26 — FASE 3 Task 1 concluída: migration 0085 — `ai_routers` (1 ativo por channel_session via índice parcial, config classifier_model/sticky/min_confidence, fallback_agent_id), `ai_router_members` (agente + intenção declarada + exemplos, unique router_id+intent_name), `ai_router_decisions` (telemetria append-only sem PII); `conversations` ganha `active_ai_agent_id`/`active_intent`/`active_agent_set_at` (stickiness). Triggers de audit+updated_at nas editáveis (padrão ai_agents). NNNN confirmado via `git log --all --name-only` (branch atual 0067-0069, `main` 0070-0084 — 0085 é o primeiro livre nas duas linhas). Aplicada via `supabase db query --linked` (role `agent_worker` do `.env.local` sem CREATE); reaplicação sem erro (idempotente). Types regenerados via `supabase gen types typescript --linked` e reduzidos ao diff de 0085 (banco dev compartilhado trouxe tabelas de outras branches — agent_cases/followup_*/crm_lead_risk_states etc. ficaram de fora). Docker indisponível para `test:db` — RLS provada manualmente contra o dev real dentro de uma transação com ROLLBACK (org A/B + channel_sessions + ai_routers + ai_router_decisions sintéticos, JWT claims simulados via `set_config('request.jwt.claims', ...)`, cross-tenant=0/own-org=1 nas duas tabelas, 0 linhas remanescentes após o rollback). Seed do `rls-isolation.test.ts` estendido (ai_routers usa o `channel_sessions` que o seed já cria por org; ai_router_decisions semeada em paralelo) para quando `test:db` puder rodar. Typecheck zerado.
- 2026-07-26 — FASE 3 Task 2 concluída: `lib/agent-engine/agent/router-config.ts` (`loadActiveRouter` — 2 queries: router ativo por org+channel_session, membros por router_id+org ordenados position/intent_name; leitura defensiva de `config` jsonb com defaults haiku/sticky/0.6) + `loadPublishedAgentConfigById` em `agent-config.ts` (mesmo SELECT de `loadPublishedAgentConfig`, filtro `a.id=$2` em vez de `v.channel_session_id=$2`, sem order/limit — membros de router não têm vínculo com a sessão, quem tem é o router). Mapeamento Row→PublishedAgentConfig extraído pra `mapAgentConfigRow()` compartilhada pelas duas variantes (refactor sob teste, comportamento idêntico). TDD: 3/3 testes novos verdes (null sem router, membros ordenados, config malformada→defaults); suite `lib/agent-engine/agent` 7 arquivos/25 testes verde (agent-config.test.ts pré-existente intacto). Typecheck zerado.
- 2026-07-26 — FASE 3 Task 3 concluída: `lib/agent-engine/agent/intent-classifier.ts` (`buildClassifierPrompt`/`parseIntentVerdict`/`classifyIntent`) pelo MESMO seam `runModelCall` (purpose `intent_router`, modelo de `router.classifierModel`, nunca hardcoded). `parseIntentVerdict` nunca lança: JSON malformado, campo faltando, `intent` fora da lista de `members` do router (defesa contra alucinação) e `confidence` não-numérico/fora de [0,1] tudo vira `{intentName:null,confidence:0}` (clamp em vez de rejeitar quando o resto do shape é válido). `classifyIntent` embrulha tudo em try/catch — qualquer erro (falha do modelo, `LlmModelNotEnabledError`, timeout) vira `log.warn`+`null`, chamador cai no `fallbackAgentId`. `deps.runModelCall` injetável (default = `runModelCall` real) — mesmo padrão de `deps.embed` do `searchKnowledge` (Fase 0). TDD: 9/9 testes verdes (brief, transcritos). Typecheck e eslint zerados.
- 2026-07-26 — FASE 3 Task 4 concluída: `lib/agent-engine/agent/resolve-turn-agent.ts` (`resolveTurnAgent`) — decisão sticky→classificação→fallback→genérico, as 4 dependências (`loadActiveRouter`/`loadPublishedAgentConfigById`/`loadPublishedAgentConfig`/`classifyIntent`) injetáveis via `deps` com default real. 2 decisões do Rafael aplicadas: sem match e sem fallback responde com o agente GENÉRICO (`config:null`, não silêncio); stickiness só troca com confiança >= minConfidence na intenção DIFERENTE (senão mantém sticky). Todo o branch do router embrulhado em try/catch — erro inesperado cai no `loadPublishedAgentConfig` de hoje com outcome `classifier_failed`, nunca derruba o turno. Ambiguidade resolvida: membro sticky removido do router ⇒ tratado como sem sticky (classifica normal), decisão segura documentada no report. TDD: 9/9 testes verdes (8 do brief + 1 extra de robustez a erro de DB). Typecheck e eslint zerados.
- 2026-07-26 — FASE 3 Task 5 concluída: `resolveTurnAgent` costurado em `runAgentTurn` (inbound-turn.ts:538) exatamente onde `loadPublishedAgentConfig` era chamado — DEPOIS do guard `isLeadInHandoff` (L530), confirmado por leitura direta antes de editar. Sticky (`conversations.active_ai_agent_id/active_intent`) e sinal de roteamento (última `messages` inbound da conversa, direto — `getLeadContext` só roda mais abaixo) lidos em 2 queries baratas antes da chamada. `agentConfig` continua a mesma variável (~15 call sites intocados) — só a fonte virou `routed.config`. Persistência do sticky + insert em `ai_router_decisions` fire-and-forget (try/catch, `runLog.warn`, nunca lança) só quando `routed.routerId !== null` (canal sem router = outcome `no_router`, zero side effect, comportamento idêntico a hoje). `performHumanHandoff` (human-handoff.ts) ganhou o passo (b2): zera `active_ai_agent_id/active_intent/active_agent_set_at` da conversa — quem vai pro humano perde a aderência ao router. Suite `lib/agent-engine`: 56/56 verde (nenhum teste dedicado a inbound-turn/human-handoff existe no repo — regressão coberta pelos 47 testes dos módulos consumidos, todos intactos). Typecheck zerado.
- 2026-07-26 — FASE 3 Task 6 concluída: API `/api/v1/ai/routers` (GET lista+member_count, POST cria), `/[id]` (GET detalhe+membros, PATCH, DELETE hard-delete com cascade), `/[id]/members` (PUT substitui a lista inteira, position=índice), `/[id]/test` (POST classifica mensagem de teste reusando `loadActiveRouter`/`classifyIntent`, NUNCA grava em `ai_router_decisions`). 4 ações de audit novas (`ai.router_created/updated/deleted/members_updated`). Unique parcial index (channel_session ativo) tratado: `23505`→409 `router_already_exists` no POST/PATCH; duplicidade de `intent_name` no PUT de members idem→409 `duplicate_intent_name`. Achado real (não cosmético): `classifyIntent` (Task 3) tipava `leadId`/`jobId` como `string` obrigatório — mapeiam pra `llm_calls.contact_id`/`job_id`, FKs reais; um uuid inventado pro clique de "testar" teria estourado a FK, o `try/catch` do classificador teria engolido o erro e o endpoint SEMPRE devolveria "sem match" silenciosamente enquanto ainda queimava a chamada real ao modelo (achado antes de escrever o teste, não depois). Fix cirúrgico: `leadId`/`jobId` viraram `string | null` em `intent-classifier.ts` (retrocompatível — callers existentes passam string; `resolve-turn-agent.ts`/`resolve-turn-agent.test.ts` intactos, 26/26 verde nos 3 arquivos afetados), `/test` passa `null` pros dois. `getSkillsPool()` (Fase 2) reusado só onde `pg.Pool` é exigido pela assinatura de `loadActiveRouter`/`classifyIntent`; as 3 rotas CRUD usam o admin client Supabase como os moldes. TDD: 11/11 testes verdes (5 do brief + 6 extras de cobertura de erro/borda: 500 de listagem, 404/409 de `/test`, etc.). Typecheck e eslint zerados (0 erros).
- 2026-07-26 — FASE 3 Task 7 concluída: tela `app/app/ai/routers` (lista com cards nome/canal/N intenções/badge ativo + dialog "Novo roteador" nome+canal) e `[id]` (editor: nome, canal read-only pós-criação — PATCH não aceita trocar, `fallback_agent_id` com "Nenhum — atendimento padrão", lista de intenções agente+nome+descrição+exemplos com add/remove, painel "Testar classificação"). `hooks/ai/useRouters.ts` no molde de `useSkills.ts` (7 hooks: useRouters/useRouter/useCreateRouter/useUpdateRouter/useDeleteRouter/useSaveMembers/useTestRouter) — `useRouter` colide de nome com `next/navigation`, resolvido com alias `useRouter as useRouterData/useNextRouter` nos 2 client components. Permissões `ai.routers.view`(manager)/`ai.routers.manage`(admin) no AuthProvider + item "Roteadores" (ícone `Signpost`, novo no mapa `lib/ui/icons.ts`) na Sidebar. Aviso na tela do agente (`AgentForm.tsx`, thread via `AgentTabs`→page.tsx com embed `ai_router_members→ai_routers(name)`) quando o agente é membro de um router: "o campo de número abaixo não se aplica" + link pro router. Prova real clicando (admin e2e-admin, MFA TOTP): criar roteador no canal E2E Wave12 → 2 intenções (quer comprar→QA W11 TC-05 retest, quer suporte→Lia—AgendaPlus) → salvar → testar classificação com frase de cada intenção → confiança 95% nos dois, agente certo nos dois → toggle inativo (painel de teste se autodesabilita com aviso "Ative o roteador") → excluir com confirm dialog. Notice do agente confirmado à parte (router temp via psql). Typecheck e eslint zerados. Cleanup: 0 routers/0 members na org e2e ao final.
- 2026-07-27 — **MERGE DA MAIN (693 commits) na feat/operacao-visivel** — commit `43b8dad`, pedido do Rafael antes da Fase 4 ("deixar tudo igual à main, vai te trazer muita coisa que você não tinha"). Estado: 0 atrás / 28 à frente. Backup completo antes de começar em `scratchpad/pre-merge-backup/` (patch dos rastreados + tgz dos não-rastreados + SHA de origem `7fbee4c`). **5 conflitos, todos resolvidos mantendo OS DOIS LADOS**: `lib/audit/actions.ts` (união de 35 literais de ação); `components/shell/Sidebar.tsx` (união dos ícones; nav mantém Roteadores da F3 + Follow-ups + Radar — o item Radar deixou de ser link morto porque a main trouxe a rota, fechando a ressalva de F2 T7); `lib/agent-engine/agent/inbound-turn.ts` (as 5 tools coexistem — `send_message`, `search_knowledge` da F0, `read_skill_reference` da F2, `open_human_case` e `provide_case_update` da main; imports mesclados SEM `loadPublishedAgentConfig`, confirmado zero uso no corpo após a F3); `supabase/baseline.sql` (apêndices em ordem numérica 0067→0068→0069→0070-0084 da main→0085); `MANIFEST.md` (18 linhas por timestamp). **3 defeitos que só o merge revelou**: (1) `lib/ui/icons.ts` com `DownloadSimple` duplicado — main e F2 adicionaram o mesmo ícone em blocos diferentes do mesmo import, o git aceitou os dois; (2) fixture de `resolve-turn-agent.test.ts` incompleto — a main deu 4 campos novos ao `PublishedAgentConfig` (`splitMessages`, `splitMaxChars`, `multimodalInput`, `casesEnabled`), ou seja o roteador da F3 agora despacha agentes que sabem quebrar mensagem, ler mídia e abrir caso humano, de graça; (3) o guarda `tests/unit/evidencia-citada.test.ts` da main pegou o plano da F3 citando uma screenshot de roteadores que nunca foi entregue como arquivo (a prova da F3 foi inline + ledger) — texto corrigido para não apontar para o vazio. **E o conserto reincidiu no defeito:** a primeira redação deste parágrafo escreveu o nome do arquivo entre crases ao descrevê-lo, recriando a citação morta que ele narra. O cabeçalho do próprio guarda avisa disso ("documentar o falso positivo CRIOU falso positivo"); a lição é que o nome do arquivo não pode aparecer nem na explicação. Também não re-rodei a suíte depois de acrescentar este parágrafo — a medição de "1114 testes verdes" abaixo é anterior a ele, e foi ele que a invalidou. Quem escreve prosa neste repo re-roda o guarda. **Trabalho de sessões paralelas do Rafael restaurado por cima** (16 arquivos, stash preservado): 4 conflitos a mais no pop, com um caso que exigiu COMBINAR em vez de escolher — `markConversation` mudou de assinatura na main (ganhou `organizationId`) enquanto a sessão paralela melhorava o preview do inbox com a transcrição do áudio; ficar com um lado perdia a transcrição em silêncio, ficar com o outro chamava assinatura inexistente. Resolvido com assinatura nova + preview transcrito. Convergência independente: main e sessão paralela escreveram os MESMOS helpers `mediaUrlOf`/`mediaMimeOf` em lugares diferentes do `ingest.ts` (git aplicou ambos sem conflito textual → `TS2393 Duplicate function implementation`); ficou a da main, que é exportada e tem teste dedicado fixando a precedência (`waha-ingest-media.test.ts`). **Verificação**: typecheck 0, `vitest` 149 arquivos / 1114 testes verdes, lint 0 erros (150 warnings pré-existentes). Migrations sem número duplicado, `0085` é a última, apêndice do baseline termina nela; 0074 e 0076 não têm apêndice de propósito — foram substituídas por 0075 e 0077, e o baseline carrega só o estado final (correto para instalação nova).
- 2026-07-27 — **FASE 4 CONCLUÍDA (Painel de Evolução, 7 tasks).** O que entrou: `knowledge_searches` (migration 0086 + apêndice no baseline + MANIFEST + invariante de RLS) e a telemetria escrita pelo `searchKnowledge` (hits, top_score, limiar do CHAMADOR — gravar o piso `-1` do RPC faria toda busca parecer acima do corte e mataria o sinal de "quase acertou"); a **ponte do funil deixou de ser stub** (`mirrorLeadStageToCrm` delega para `sincronizaEstagioDoAgente` — o agente move o cartão do tenant pela primeira vez); o agregador puro `lib/ai/evolution/aggregate.ts`; a rota `GET /api/v1/ai/evolution`; e a tela `app/app/ai/evolution` (3 blocos + "o que está travando", com CTA por lacuna). Commits: `bb53b78`+`4410985` (T1), `54e7b52`→`386a7a1`→`c320a50` (T2), `f518624`→`3b5eecf`→`78b9b55` (T3), `8dbdc17`→`ca73db4` (T4), `717930e`→`9f77e9e` (T5), `84114aa`→`b065da2`→`e5b639c`→`f8eb785` (T6).
- 2026-07-27 — **Bugs achados e corrigidos NA RAIZ durante a Fase 4** (nenhum era da fase; a fase os acordou): (1) `lib/leads/agent-stage-sync.ts` descartava o `error` de dois selects — e `supabase-js` NÃO lança em falha de rede, então banco fora do ar virava `sem_negocio`→`not_configured`→warn silencioso, indistinguível de configuração ausente; (2) o mesmo arquivo mapeava erro de UPDATE para `ja_esta_la` e o UPDATE não tinha `.select()`, então quando a trava otimista atuava (humano arrastou o cartão) o motor devolvia "movido" **e emitia atividade `stage_changed` de um movimento que não aconteceu** — história fabricada no CRM; (3) a taxa de handoff do painel lia `event_log`, escrito só pelo runtime nativo ANTIGO, enquanto o agent-engine (canônico) grava `agent_inbox_items` — medido: `event_log` zerado nas duas orgs, a taxa saía 0,0% e "0% de handoff" lê-se como elogio; corrigido somando as duas fontes (0,0%→5,9% e 0,0%→2,9%); (4) três links da tela apontavam para `/app/ai/knowledge`, rota que não existe (só `/app/ai/knowledge/sources`) — é o que um tenant recém-instalado veria; (5) "Negócios ganhos" vinha de `lead_state_transitions`, que só a máquina do agente escreve: dono que fechou 12 na mão leria "0" num bloco chamado "o que mudou no resultado" — rótulos reescritos para dizer *pelo agente*; (6) `contaPor` com objeto literal perdia a contagem de uma skill chamada `__proto__` (nome de skill vem de `.zip` enviado pelo usuário) — `Object.create(null)`.
- 2026-07-27 — **Verificação final da Fase 4** (exit code capturado direto, sem `| tail`): `npm run typecheck` EXIT=0; `npm run lint` EXIT=0 (0 erros, 151 warnings pré-existentes); `npx vitest run` **153 arquivos / 1171 testes, EXIT=0**; `npm run test:db` **56 arquivos / 372 testes + 1 skip, EXIT=0**; `npm run build` EXIT=0. Mapa vivo: o painel entrou em `docs/architecture/agent-turn.workflow.json` (lane "Evolução da IA (Fase 4)") com 3 arestas reais — `Turno IA → Buscas na base` (toda busca grava hits/top_score/limiar), `Buscas na base → Evolução da IA` (alimenta o painel) e a de SAÍDA `Evolução da IA → Routers` (a lacuna vira ação); `archify validate --quality standard` 0 erros, HTML re-renderizado a partir do JSON.
- 2026-07-27 — **O que a prova de fechamento cobriu, e por qual caminho.** (a) **Emissor real + pool real**: `searchKnowledge` chamado contra o banco de dev (org `00000000`, KB `df9810c7`, `topK`/limiar lidos da config publicada do agente — limiar 0,5) gravou 5 linhas verdadeiras em `knowledge_searches`, que estava zerada: `hits=1/top=0.717`, `hits=1/top=0.556`, e três `hits=0` (`top=0.174`, `0.389`, `0.301`). (b) **Tela, logado como `e2e-manager` em produção (`next build` + `next start -p 3025`, HEAD `f8eb785`)**: o painel mostrou **"5 no período"** em "Consultas aos seus materiais" e a lacuna **"3 perguntas de clientes não encontraram resposta nos seus materiais"** com o botão "Abrir a base de conhecimento" — `f4-painel-com-dado.png`. (c) **Contraste controlado**: apagadas as 5 linhas (contagem de volta a 0) e reaberta a MESMA tela, no MESMO período, o cartão virou o vazio "O agente não consultou seus materiais" e a lacuna sumiu, com todo o resto igual (as 3 decisões de roteamento continuaram lá) — `f4-painel-sem-dado.png`. É durante × depois, uma variável só. **O que isso NÃO prova:** ninguém mandou mensagem no WhatsApp. O elo `WhatsApp → turno do agente → busca → painel` está provado do `searchKnowledge` para a frente; o pedaço `mensagem real → turno` continua sem prova desta fase.
- 2026-07-27 — **Ficou para trás (dívida enumerada, não esquecida):** o painel **não** mostra vereditos do juiz, comparativo antes/depois automático nem follow-ups executados — as três exclusões estão justificadas na spec. "Quase acertou" (`knowledge_near_misses`) tem código e teste mas **nunca foi exercitado na tela**: nenhuma pergunta real de teste caiu na faixa de 0,1 abaixo do limiar. Menores adiados para a revisão final da branch: sem `check (hits >= 0)` no banco (o guarda vive no emissor); telemetria de busca só sai pelo `inbound-turn` (se follow-up/case-reply ganharem RAG, o `jobId` default `null` não vai gritar); truncamento de 50k linhas só chega ao log, o payload não carrega bandeira, então a tela não tem como dizer "período truncado"; a taxa de handoff soma unidades diferentes (episódio do engine + evento do runtime antigo — sem efeito vivo porque só um runtime está ativo por org); `resolveRange` duplicado com `/ai/usage`, agora com 2º consumidor que justifica extrair; a sonda `sonda-agente-move-card.ts` escreve `agent_stage_hint` nos pipelines do banco de dev e não desfaz (o dev não representa um tenant virgem). **Ambiente, não código:** o Upstash de `.env.local` (`lucky-polliwog-108881.upstash.io`) dá NXDOMAIN — rate limit está fora em todo o app; e o **AI Gateway está no teto do free tier** (`GatewayRateLimitError` em duas chamadas de embedding seguidas, hoje), o que derruba a busca de conhecimento inteira com `knowledge_unavailable`.

## Prova de ponta a ponta que falta — receita de 1 minuto (Rafael)

O painel já foi provado da telemetria para a frente. O que falta é o ciclo completo com uma mensagem de verdade — e **todos os bloqueios foram removidos em 2026-07-27**. Restou só o que exige um humano: mandar a mensagem.

**Ambiente já preparado (não precisa configurar nada):**
- Base de conhecimento **ligada** na `Lia — AgendaPlus` (KB `df9810c7`, 2 verbetes: garantia de 12 meses e frete grátis acima de R$ 199), limiar de semelhança em **0,5** — o padrão 0,72 cortaria acertos legítimos, já medido em sessão anterior.
- Worker rodando o código atual (`npm run worker`, healthz na 8787). **Isto importa:** um worker antigo processa o turno e **não grava** a telemetria, e a prova falharia em silêncio.
- App em produção na **porta 3000**, health `healthy` nos três (Supabase, Redis, WAHA).
- Redis local no ar (`docker start deskcomm-redis-local deskcomm-srh-local` se tiver reiniciado a máquina).
- Embedding indo **direto na OpenAI**, sem passar pelo gateway (commit `e5d702d`) — era o teto do plano anônimo que derrubava a busca inteira.

**Os dois passos que são seus:**

1. **Mande duas mensagens do seu WhatsApp** para o número conectado: uma que a base responde — *"qual o prazo de garantia dos produtos?"* — e uma que ela não responde — *"vocês patrocinam torneios de xadrez na Islândia?"*.
2. **Olhe a tela**: `/app/ai/evolution`, período incluindo hoje. "Consultas aos seus materiais" sobe 2, e em "O que está travando" aparece *"1 pergunta de cliente não encontrou resposta nos seus materiais"*, com o botão para a base.

**Como conferir por baixo, se quiser:**
```sql
select hits, top_score, threshold, job_id, created_at
  from knowledge_searches order by created_at desc limit 2;
```
A primeira com `hits >= 1`, a segunda com `hits = 0` e `top_score` preenchido (baixo). **O `job_id` é o que distingue a prova real:** turno de verdade preenche; a prova de bancada desta fase gravou nulo de propósito.

**Se não gravar nada**, a ordem de suspeita mudou: worker velho (confira que o processo é posterior ao último commit) → agente errado respondendo (a KB está na Lia, não em outro) → só então o código.

## Ambiente consertado em 2026-07-27 (o que era "achado" virou conserto)

- **AI Gateway no teto: RESOLVIDO na causa raiz, sem mexer em cobrança.** `lib/ai/embed.ts` prometia no cabeçalho usar o provider OpenAI direto quando não há `AI_GATEWAY_API_KEY`, e esse caminho **não existia no código** — passava a string `openai/text-embedding-3-small` para `embed()`, e no AI SDK id com barra é resolvido pelo gateway da Vercel **mesmo sem chave**, caindo no plano anônimo. Commit `e5d702d` + teste que assere o TIPO do que chega em `embed({model})` (string = gateway; objeto = provider explícito), sabotado e vermelho. Prova real: 1536 dimensões / 8 tokens direto na OpenAI. O caminho de CONVERSA já estava certo (`lib/agent-engine/edge/llm/providers.ts` usa providers explícitos com endpoint contido) — o defeito era exclusivo do embedding.
- **Upstash morto: substituído por Redis local.** A instância da nuvem dá NXDOMAIN (foi removida). Subi o mesmo par do `docker-compose.prod.yml` — `deskcomm-redis-local` (redis:7-alpine) + `deskcomm-srh-local` (serverless-redis-http na porta 8079, que fala o protocolo REST do Upstash sobre o redis normal). `.env.local` aponta para lá, com os valores da nuvem **comentados logo acima** para reverter. Backup em `.env.local.bak-*`. Health voltou a `healthy`.
- **Servidor da porta 3000:** o processo antigo (quebrado porque o `pnpm install` do merge trocou o `node_modules` por baixo dele) morreu sozinho; subi um `next start` novo do build atual.
- **Worker reiniciado** para pegar o código da fase — o anterior era das 07:54 e não tinha a telemetria nem a ponte do funil.

## PROVA DE PONTA A PONTA FECHADA — 2026-07-27 (Rafael mandou as mensagens)

O elo que faltava está provado. Rafael mandou duas mensagens reais no WhatsApp e o ciclo inteiro andou:

- **"qual o prazo de garantia dos produtos?"** → o agente respondeu com o fato da base ("12 meses contra defeitos de fabricação, contando da data de entrega") e gravou `hits=1, top_score=0.676531, threshold=0.5`.
- **"vocês patrocinam torneios de xadrez na Islândia?"** → o agente recusou honestamente ("Não temos essa informação aqui") e gravou `hits=0, top_score=0.165184`.

**As duas linhas têm `job_id` preenchido** — que era o discriminador declarado entre prova de bancada e prova real. A bancada gravou nulo de propósito; turno de verdade preenche.

O `top_score` de 0,165 é o ponto do épico: ele diz **"a base não tem esse assunto"**, e não "o corte estava apertado demais". Antes desta fase o sistema não sabia distinguir os dois — e são problemas com consertos opostos.

**Na tela** (`/app/ai/evolution`, logado como `e2e-manager`): "Consultas aos seus materiais — **2 no período**" com o pico em 27/07, e em "O que está travando": *"1 pergunta de cliente não encontrou resposta nos seus materiais. São os assuntos que ainda faltam escrever — cada um deles é uma conversa em que o agente teve que improvisar ou passar adiante"*, com o botão para a base. Prova visual em `f4-prova-real-whatsapp.png`.

**ACHADO DE AMBIENTE (não é bug, mas custa tempo de quem for repetir):** a conta de login do Rafael (`pessoa@exemplo-ficcao.invalid`) é admin da org **Deskcomm Admin**, enquanto o número de WhatsApp, a agente Lia e toda a telemetria vivem na org **E2E Test Org**. Abrir o painel com a conta dele mostra zeros — corretamente, porque o isolamento entre empresas está funcionando. Quem for repetir a prova precisa entrar com uma conta da org que tem o número, ou o painel vai parecer quebrado quando está certo.

**A lacuna de funil apareceu com dados reais**, confirmando o plano `docs/superpowers/plans/2026-07-27-mapeamento-funil-agente.md`: os funis "Pedidos" (4 passos sem etapa) e "CRM Vivo — Clínica" (2 passos) estão listados na tela, e hoje **não existe interface para consertá-los**.

---

# Mapeamento do funil do agente — ENTREGUE 2026-07-27 (branch `feat/operacao-visivel`)

**Plano:** `docs/superpowers/plans/2026-07-27-mapeamento-funil-agente.md` · **Ledger das 4 tasks:** `.superpowers/sdd/2026-07-27-mapeamento-funil-agente/progress.md`

## O que passou a existir

Uma seção dentro da tela de Funis (`/app/settings/tenant/pipelines`) com **uma linha por passo do atendimento**, na ordem em que o cliente avança, cada uma escolhendo a etapa do funil do tenant. A tela **inverte o banco de propósito**: o banco guarda `etapa → passo` (`crm_stages.agent_stage_hint`), a tela mostra `passo → etapa` — o modelo mental de quem configura, e a forma que torna impossível pedir dois destinos para o mesmo passo. «Não mover o card» é escolha legítima, nunca pendência.

- `lib/leads/agent-mapping.ts` — regras puras (coerência com ganho/perda antes do banco, `diffParaUpdates` com **os UNSETs antes dos SETs**).
- `app/api/v1/pipelines/[id]/agent-mapping/route.ts` — `GET` mapa + etapas; `PUT` **total** (os 7 passos sempre, `null` explícito), papel `manager`.
- `app/app/settings/tenant/pipelines/_mapping.tsx` + `hooks/pipelines/useAgentMapping.ts` — a seção e sua leitura/gravação.
- `components/ai/EvolutionGaps.tsx` — o CTA da lacuna passou a **levar** a essa tela (antes apontava para o quadro com o verbo "Ver").
- `docs/architecture/agent-turn.workflow.json` (+ `agent-turn.html` regerado) — faixa **Mapeamento do funil**, com as duas arestas que fecham o ciclo: `Evolução da IA → Mapa do funil` ("lacuna vira ação") e `crm_stages → Turno IA` ("o card anda para a etapa escolhida").

## Números (verificação final, exit code capturado direto)

`typecheck 0` · `lint 0` (159 warnings pré-existentes, 0 erros) · `vitest 1232/1232 em 157 arquivos` · `test:db 372 passaram + 1 skip em 56 arquivos` (inclui baseline `install` fresh e `update` re-aplicado) · `build 0`.
Da feature em si, **medido arquivo a arquivo agora** (não somado dos relatórios): `agent-mapping.test.ts` **19** · `agent-mapping/route.test.ts` **16** · `_mapping.test.tsx` **18** = **53 nos três arquivos que a feature criou**; mais `tests/unit/evolution-gaps-copy.test.ts` **17**, arquivo da Fase 4 que esta feature ampliou com as guardas do CTA (href, verbo e nomes dos passos).
Sabotagens: **49** (17 + 16 + 16) — número **relatado pelas tasks**, não recontado por mim; cada uma exigindo a marca no disco por `grep -cF` antes de rodar.

## A prova que fecha o ciclo (a que faltava)

`tests/prova-ciclo-funil.ts` — **um processo só**, com controle negativo e a mesma chamada dos dois lados:

1. `mirrorLeadStageToCrm` (a função que o turno chama) com o passo «Qualificado» → `not_configured`, card parado em «Primeiro contato»;
2. **navegador de verdade**, login como `e2e-manager`, tela de Funis, escolhe «Negociação» na linha «Qualificado» pelo NOME visível, salva;
3. a MESMA chamada, o MESMO card → `{"ok":true}`, card em **«Negociação»**, e timeline `stage_changed · Movido de Primeiro contato para Negociação · ator=system`.

Uma variável muda entre as duas medições: o clique. Estado restaurado **pela própria tela** ao fim (mapa idêntico ao inicial; `contacts/crm_leads/crm_lead_activities` 55/53/337 antes e depois). Evidência: `.superpowers/evidence/ciclo-funil-tela.png`.

## Bugs achados e corrigidos (todos provados por execução, não por leitura)

1. **Permuta de passos colidia com o índice único** (`16310b7`): trocar dois passos de etapa gerava 2 SETs e nenhum UNSET, e o primeiro `UPDATE` batia em `uniq_crm_stages_pipeline_hint` (23505 — índice é imediato, a transação não salva). Os UNSETs passaram a sair primeiro; o repro `{qualifying:'e2', qualified:'e1'}` foi re-executado contra o HEAD.
2. **Mapa parcial apagava mapeamento em silêncio** quando a etapa alvo já carregava outro passo — passou a recusar citando o conflito. Perda silenciosa era o pior desfecho possível.
3. **`23514`/`23505` escapavam como 500 com texto cru do Postgres em inglês** (`830530a`) — viraram 409 `state_conflict` com frase em português citando o nome da etapa.
4. **O CTA mandaria um manager para 403**: a página de Funis era admin-only e o painel é manager+. O ciclo que a feature existe para fechar estava quebrado por permissão. Página passou a manager+, com o editor de vocabulário/campos ainda admin.
5. **O painel tinha uma SEGUNDA tradução dos 7 passos** — quem clicasse no CTA procuraria linhas com nomes que não existem no destino. Apagada; `ROTULO_DO_PASSO` virou fonte única dos dois lados.
6. **A tela imprimia qualquer mensagem do servidor como copy** — incluindo «Auth required.», a palavra "role" e texto cru do Postgres. Passou a filtrar por status (409/422 vão inteiras; 401/403/resto viram frase de tela).
7. **Texto prometia um caminho que não existe**: a lista vazia mandava "crie um funil no quadro" e o quadro manda "ir para configurações" — pingue-pongue fechado (ver achado A abaixo).

## Dois achados sobre o PRODUTO que esta feature revelou

### A. O produto não cria funil nem etapa — mas o BANCO semeia (a premissa do achado original estava errada)

**O que é verdade:** nenhuma rota, server action ou tela insere em `crm_pipelines` ou `crm_stages`. Grep no HEAD: os únicos `insert` nessas tabelas estão em `tests/invariants/*` e no `supabase/baseline.sql`. O tenant não consegue criar um segundo funil, acrescentar/renomear etapa, nem reordenar — só editar vocabulário, campos e motivos de perda (`updatePipelineConfig`, admin).

**O que NÃO é verdade, e o ledger da Task 3 afirmou:** que "como o instalador não provisiona nenhum, toda instalação nova nasce sem funil — P0". **Provei o contrário executando**: inseri uma organização nova no banco real e li o que apareceu. O trigger `trg_seed_default_pipeline_for_org` (`supabase/baseline.sql:2766`, função em `:682`) cria, a cada `INSERT` em `organizations`, o funil **"Pedidos"** com 8 etapas — `Pago` com `is_won`, `Cancelado` com `is_lost`. A org de teste foi apagada em seguida (0 funis restantes). É o mesmo funil "Pedidos" que a E2E Test Org tem, com os mesmos slugs na mesma ordem.

**Então o P0 é outro, e menor:** instalação nova nasce funcional; o teto aparece quando o tenant quer um funil que não seja "Pedidos" (clínica, imobiliária, serviços) — aí ele depende de SQL/script. Quem for atacar isto: o caminho que revelou foi a mensagem de lista vazia da tela de mapeamento, que precisou ser reescrita para não prometer um botão inexistente.

### B. `is_won`/`is_lost` não são escritos por nada do produto

Só pelo trigger de seed. Grep no HEAD: todo o resto é leitura (`.eq("is_won", true)` na rota de fechar negócio, filtros da tela de mapeamento). Consequência prática: o funil semeado já vem com ganho e perda marcados, mas **qualquer funil criado por fora fica sem caminho de produto para marcar a etapa de fechamento** — e é exatamente o que a tela de mapeamento diz ao usuário quando «Ganho»/«Perdido» não têm candidata ("quem montou o funil precisa marcar a etapa de ganho"). A frase é honesta; o caminho que ela pressupõe não existe. Revelado pela avaliação de experiência da Task 3 (E2), ao conferir se o CTA cumpre a promessa "você mesmo escolhe".

## O que ficou para trás (consciente, não esquecido)

- **O CTA leva à página, não ao funil específico** — `unmapped_agent_steps` não carrega `pipeline_id`. Com dois funis com lacuna, o usuário procura qual é.
- **Audit não sai quando a mutação falha no meio** do laço de `UPDATE`s (M3) e **não há concorrência otimista** (M4): depois de qualquer erro a tela relê o servidor e substitui o rascunho, que é o antídoto escolhido — reenviar sobre um funil que mudou gravaria por cima da decisão de outra pessoa.
- **Sugerir mapeamento por semelhança de nome** ("Proposta" → `negotiating`) ficou FORA de propósito: é a adivinhação que o cabeçalho de `lib/leads/agent-stage-sync.ts` proíbe. Se um dia entrar, tem que ser sugestão que o usuário confirma.
- **A branch segue atrás da `main`** (o merge foi deixado para depois da feature, por doutrina de higiene de branches).

---

# Gerenciar as etapas do funil — ENTREGUE 2026-07-28 (branch `feat/operacao-visivel`)

**Plano:** `docs/superpowers/plans/2026-07-27-gerenciar-etapas-do-funil.md` · **Ledger das 4 tasks:** `.superpowers/sdd/2026-07-27-gerenciar-etapas-do-funil/progress.md`

Fecha o achado **B** da feature anterior (`is_won`/`is_lost` sem caminho de produto) e metade do achado **A** (o tenant já cria, renomeia, reordena e arquiva ETAPAS; criar um segundo FUNIL continua sem caminho — ver achado 2 abaixo).

## O que passou a existir

Uma seção **«Etapas deste funil»** dentro da tela de Funis (`/app/settings/tenant/pipelines`), acima do mapeamento, onde o dono da operação renomeia, cria, reordena, marca ganho/perda e arquiva as colunas do próprio quadro. As duas seções se completam e se **referenciam nas duas direções**: quem arquiva uma etapa vinculada ao assistente é avisado e levado ao mapeamento; quem não tem etapa de fechamento para o passo «Ganho» é levado às etapas.

- `lib/leads/stage-editing.ts` — regras puras (nome→slug, `posicaoEntre`, guarda de desfecho, validação de arquivamento e do destino dos negócios).
- `app/api/v1/pipelines/[id]/stages/route.ts` (`POST`) e `.../stages/[stageId]/route.ts` (`PATCH`/`DELETE`), papel `manager`, com `_funil.ts` de leitura compartilhada.
- `app/app/settings/tenant/pipelines/_stages.tsx` + `hooks/pipelines/useStages.ts`.
- `docs/architecture/agent-turn.workflow.json` (+ `agent-turn.html` regerado) — a faixa virou **«Funil: etapas + mapeamento»** com a peça nova e **3 arestas reais**: `Etapas → crm_stages` (cria/renomeia/reordena/ganho-perda/arquiva), `Etapas → Mapa do funil` (arquivar mata o vínculo com o assistente) e a de volta `Mapa do funil → Etapas` (sem etapa de fechamento, o passo não tem destino). `archify validate --quality standard` **0 erros**, 1 warning de cruzamento próprio; HTML regerado a partir do JSON.

## Números (verificação final, exit code capturado direto — nunca por `| tail`)

`typecheck EXIT=0` · `lint EXIT=0` (0 erros, **159 warnings** — as mesmas de antes da feature) · `vitest` **1355 passaram / 1356 em 163 arquivos, EXIT=1** · `test:db` **372 passaram + 1 skip em 56 arquivos, EXIT=0** · `build EXIT=0`.

**A única vermelha é HERDADA da `main` e não é desta feature:** `tests/unit/evidencia-citada.test.ts` reprova porque `docs/growth/lp-prompts-imagens.md` cita 11 imagens 3D que nunca foram versionadas. O arquivo veio da `main` (`60216e7`) e esta branch não tocou em `docs/growth/` nem no teste — conferido por `git diff origin/main...HEAD`.

Testes da feature, **medidos arquivo a arquivo agora** (não somados dos relatórios): `lib/leads/stage-editing.test.ts` **31** · `stages/route.test.ts` **10** · `stages/[stageId]/route.test.ts` **34** · `_stages.test.tsx` **34** = **109 nos quatro arquivos que a feature criou**; mais `_mapping.test.tsx`, que saiu de 18 para **19** ao ganhar a guarda do link de volta.
Sabotagens: **97** (24 + 38 + 34 + 1 desta task) — os três primeiros números são **relatados pelas tasks**, com script versionado ao lado de cada relatório; o último é meu, descrito abaixo.

## A prova que é a razão da feature existir

`tests/prova-org-fresca-clinica.ts` — **19/19**, numa organização criada do zero e apagada no fim.

1. `INSERT` em `organizations` → o gatilho `trg_seed_default_pipeline_for_org` semeia "Pedidos" com **vocabulário de e-commerce** ("Carrinho abandonado" … "Pos-venda"), `Pago` com `is_won`, **nenhuma** etapa com `agent_stage_hint`;
2. **no navegador, logado por senha como manager**, as 8 etapas são renomeadas para o vocabulário de uma clínica ("Primeiro contato", "Avaliação", … "Tratamento concluído", "Desistiu") e a marcação de fechamento é **movida de «Pago» para «Tratamento concluído»** — slugs intactos;
3. **CONTROLE:** a ponte do agente recusa os dois passos por `not_configured`, e o card não sai do lugar;
4. ainda no navegador, «Qualificado» → «Avaliação» e «Ganho» → «Tratamento concluído», salvo;
5. **as MESMAS chamadas** movem o card para «Avaliação» e depois «Tratamento concluído» — nomes que **não existem** no funil de fábrica;
6. a org é apagada; `organizations/crm_pipelines/crm_stages/crm_leads/contacts` voltam a **5/6/46/71/108**, idênticos.

**O caminho de cada afirmação, declarado** (não são a mesma coisa, e confundir os dois é como uma dívida foi dada como quitada nesta sessão):
- passos 2 e 4 passaram **por HTTP, no navegador, com sessão de usuário real** — é o clique, não a rota chamada por fora;
- passos 3 e 5 **chamaram `mirrorLeadStageToCrm`, a mesma função que o turno chama** (`inbound-turn.ts:1200`), no processo do script, com o client Supabase real. **Não é um turno de IA completo** — nenhum modelo foi chamado. Chamar `sincronizaEstagioDoAgente` direto passaria com a ponte desligada, por isso a entrada é a ponte;
- o servidor sob prova foi um `next start` **recém-buildado da árvore desta sessão** (`PROVA_APP=http://localhost:3055`), não o `:3000` — que roda um build anterior a esta sessão.

**A prova se prova (sabotagem 97):** trocar `e.agent_stage_hint === passo` por nada em `resolveDestinoDoAgente` deixa **5 asserções vermelhas** (14/19), inclusive "o card vai para a etapa marcada na tela". Mutação confirmada no disco por `grep` antes de rodar, arquivo restaurado e re-medido em 19/19.

**E o instrumento errou primeiro, como nas três rodadas anteriores:** a 1ª versão esperava o `value` do próprio campo virar o nome novo — o que o `fill()` já satisfaz **antes de qualquer ida ao servidor**. Sete renomeações passaram por sorte (a iteração seguinte dava tempo do PATCH voltar) e a oitava, sem ninguém depois dela, reprovou. O detector passou a ser o **banco**.

Evidência visual: `.superpowers/evidence/org-fresca-{1-ecommerce,2-clinica,3-mapeado,4-final}.png`.

## Bugs achados e corrigidos (todos por execução, nenhum por leitura)

1. **🔴 A guarda de desfecho protegia a coisa errada, e o vazio era exatamente a instalação fresca** (`b06545f`): ela se apoiava em `agent_stage_hint === passo`, mas o gatilho de seed insere as etapas **sem hint** — «Pago» nasce `is_won=true, hint=null`, e o backfill da 0084 só pegou linhas pré-existentes. **Toda org criada depois** podia desmarcar a única etapa de ganho, e `/leads/[id]/win:52` passava a responder 422. A correção **encolheu** o código: `etapa[campo]` não é uma guarda diferente da do hint, é o **superconjunto** dela (o CHECK garante `hint='won' ⇒ is_won`).
2. **🔴 Arquivar a etapa de ganho passava, e a consequência é pior que a prevista** (`b06545f`): `/leads/[id]/win` consulta `is_won=true` **sem filtrar arquivada** — o negócio fechado ERA movido, para uma coluna fora do quadro, em silêncio.
3. **🔴 O `PATCH` aceitava etapa ARQUIVADA como alvo da marcação** (`462aee1`): liberava a etapa de ganho real e ocupava a arquivada; 200, e a tela não via. O índice parcial não barra (ignora arquivadas de propósito). Alcançável por aba desatualizada. Passou a recusar 409 guardando a operação inteira.
4. **🟠 Destino do arquivamento sem checagem de ganho/perda** (`462aee1`): destino = etapa de ganho fazia N negócios virarem `status='won'` com `closed_at=now()` em silêncio — **receita mexida por ação de configuração**; destino = perda estouraria `22023 lost_reason_required`.
5. **🟠 Segunda irreversibilidade não avisada** (`af743d4`): `validarArquivamento` não olhava `agent_stage_hint` e o `DELETE` não o limpava. A tela dizia "O assistente usa esta etapa para «X»" e mantinha o Arquivar habilitado a 300px dali; arquivar matava o vínculo em silêncio. O painel passou a avisar, com link para o mapeamento.
6. **Rota sem filtro de `pipeline_id` na leitura** (achado por sabotagem sobrevivente): etapa de outro funil da MESMA org passava pelo 404 e o `UPDATE` casava zero linhas — "200 alegre, nada gravado".
7. **Texto que mentia:** "Cada funil tem uma de cada" (o guarda só impede remover A ÚLTIMA, nunca garante que exista) → "Cada funil **precisa de** uma de cada"; "1 negócios estão nesta etapa"; e o rótulo «Ordem» desalinhado no celular (`y=4893` vs `y=4883`), corrigido e re-medido.

**E o achado sobre o instrumento, 3ª vez nesta sessão:** ao ligar a emissão de atividade do arquivamento em massa, a suíte ficou VERDE — as atividades não saíam. O dublê de banco devolvia `data: null` para `select(cols, { count })`, o que só vale com `head: true`. **Um dublê que mentia em silêncio tinha acabado de esconder uma feature inteira.**

## Dois achados sobre o PRODUTO que esta feature revelou — e que NÃO são dela

### 1. O app inteiro transborda a 390px de largura

`app/app/_components/AppShell.tsx:16` aplica `ml-60` (240px) **sem breakpoint**, desde `a078f83` (2026-04-28). Medido por ferramenta num `next start` do build desta sessão, logado como manager, viewport 390×844:

| rota | `scrollWidth` | `innerWidth` | `marginLeft` do shell |
|---|---|---|---|
| `/app` | 486 | 390 | 240px |
| `/app/kanban` | 532 | 390 | 240px |
| `/app/settings/tenant/pipelines` | 610 | 390 | 240px |
| `/app/contacts` | 984 | 390 | 240px |

Não é da feature: é **qualquer tela**. O caminho que revelou foi a correção I3 da Task 3 (rótulos aplicados num viewport só), quando o implementador foi medir no celular. O raciocínio dele para medir a SEÇÃO e não a página é preciso e vale guardar: *"medir a página reprovaria esta seção por defeito do shell, e passaria a esconder um transbordo meu quando alguém consertasse o shell"* — a seção cabe (`scrollWidth = clientWidth = 224`).

### 2. Não existe criação de FUNIL no produto

Nenhuma rota, server action ou tela insere em `crm_pipelines` — só o gatilho de seed. Depois desta feature, `is_won`/`is_lost` **passaram** a ter caminho de produto (o seletor «O que acontece nesta coluna»), então o achado B da feature anterior está fechado; o que sobra é só o funil. Como o gatilho garante **um** funil por organização, o teto real é estreito e específico: **o tenant não consegue um SEGUNDO funil** (clínica que quer separar "Consultas" de "Cirurgias", agência com dois produtos). Revelado ao escrever o brief de escopo desta feature, e reconfirmado pela prova da org fresca (1 funil, sempre "Pedidos").

**Terceiro achado, menor, de higiene:** `razaoDaMudancaPeloAgente` (`lib/leads/agent-stage-sync.ts:69`) é **órfã** — grep no HEAD, zero chamadores de produção, só o próprio teste unitário. Pior: o docstring dela argumenta que a timeline precisa nomear os dois lados ("Movido para Avaliação" esconde que foi o agente), e o código que de fato roda, 40 linhas abaixo, decidiu o contrário de propósito (mesma gramática do arrasto humano; quem moveu está no ATOR). Duas doutrinas opostas no mesmo arquivo, uma delas morta. É da feature de mapeamento, não desta.

## Dívidas declaradas pelas tasks (nenhuma escondida)

- **`lerFunil` e a tradução de `23505`/`23514` estão duplicadas** entre `app/api/v1/pipelines/[id]/stages/_funil.ts` e `.../agent-mapping/route.ts`. O `_funil.ts` é o **superconjunto**; a irmã deveria importar dele. Duas fontes que começam iguais e divergem no primeiro ajuste — a doença que a própria feature de mapeamento nomeou.
- **A mensagem gentil do 500 virou genérica** na tela de etapas (defensável: status desconhecido não deve virar copy), mas é uma regressão de tom em relação ao que a tela de mapeamento faz.
- **`_stages.tsx` importa `mensagemDeErro` de `_mapping.tsx`** — acoplamento unidirecional e declarado; a decisão registrada é extrair para módulo próprio no **terceiro** consumidor.
- Sem transação nos dois caminhos de escrita dupla (a ordem escolhida garante que falha nunca deixa negócio apontando para coluna arquivada); `position_in_stage` dos negócios movidos em massa **não é recalculado**.
- `posicaoEntre` pode devolver `NaN` — o modo de falha foi MEDIDO e é alto e claro (`JSON.stringify` vira `null`, coluna é `NOT NULL` → `23502`), não corrupção silenciosa.

## O que ficou de fora, com razão declarada

- **Criação e arquivamento de FUNIS** — o gatilho garante um por organização, e depois desta feature um segundo funil é quase de graça (a metade difícil, as etapas, já existe). É a continuação natural.
- **Vocabulário do funil** (`crm_pipelines.vocabulary`, que renomeia lead/negócio/ganho/perdido): já tem editor, hoje admin-only. Mexer nele junto misturaria duas conversas na mesma tela.
- **Sugerir nomes de etapa por nicho** — é a mesma adivinhação que o cabeçalho de `lib/leads/agent-stage-sync.ts` proíbe. Se entrar, tem que ser sugestão que o usuário confirma, e é feature de onboarding, não de configuração.
- **A branch segue atrás da `main`** (merge deixado para depois da feature, por doutrina de higiene de branches).

## ⚠️ Estado do ambiente ao fim desta sessão

O servidor da **:3000 está servindo 500 nos chunks estáticos** (`/_next/static/chunks/*`) e a tela de login cai para submit nativo (vira `GET /login?email=…`). Causa: o `npm run build` da verificação final reescreveu `.next` sob um `next start` que já rodava desde 08:47 — e que, além disso, é **Next 16.2.11 enquanto `node_modules` está em 16.2.12**, ou seja, já era um servidor de build antigo. **Não matei o processo (PID 79644) por ser de outra sessão.** Quem retomar precisa reiniciá-lo. A prova desta task não dependeu dele: rodou contra um `next start` próprio na :3055, encerrado ao fim.
