Skip to content

Latest commit

 

History

History
272 lines (193 loc) · 70.6 KB

File metadata and controls

272 lines (193 loc) · 70.6 KB

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 6e567068-fd1c-4f94-ae1f-40e0334be190, 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 é 6e567068-fd1c-4f94-ae1f-40e0334be190 (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 .zipParsedSkillPackage) 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_pointersskill_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 NextRequestimport/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), 54e7b52386a7a1c320a50 (T2), f5186243b5eecf78b9b55 (T3), 8dbdc17ca73db4 (T4), 717930e9f77e9e (T5), 84114aab065da2e5b639cf8eb785 (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_negocionot_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 6e567068, 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:

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.ymldeskcomm-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 (rafael@maudibrasil.com.br) é 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.tsGET 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.tsum 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íficounmapped_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 UPDATEs (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.ts19/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 NULL23502), 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.