Files
Rafael MelgaçoandClaude Opus 5 9249e6f219 feat(agente): os três papéis — Conversador, Operador e Segurança (vazamento 30% → 10%) (#181)
* docs(agente): a spec dos tres papeis, e a terceira porta que a doutrina nao previa

A doutrina FALAR/OPERAR ja existia e o passo 1 dela ja estava fechado com
numero (30% de vazamento com prompt de operador, 0% com prompt de atendimento,
18 turnos, gate desarmado). O que faltava era a spec que rege o resto.

Fecha as sete decisoes de produto. As tres que mudam o desenho: uma unidade com
tres papeis (nao tres agentes), o Operador roda DEPOIS do envio (o lead nunca
espera o CRM), e desligar o Operador NAO desliga o registro — a declaracao do
Conversador ja e linguagem de negocio e vira estado por codigo, sem LLM.

Registra a TERCEIRA porta de vazamento, que a doutrina subestimava: o vazamento
medido `unsafe_url:https_required` nao veio da description nem do name da tool,
veio do DADO que ela devolveu. Esconder a ferramenta nao fecha essa porta —
exige camada de projecao propria. Na mesma resposta chegaram UUIDs crus de
usuario a tela do cliente, que o detector nao pega por desenho.

Registra tambem que o botao Testar da tela nao exercita guardrail nenhum:
/versions/[vid]/test chama runAgent do runtime deprecated, que nao importa
runBeforeSend. O self-hoster testa, ve limpo, publica, e producao diverge.

Nao muda comportamento — tres documentos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* feat(agente): a declaracao do turno — a fronteira FALAR/OPERAR vira contrato

Passo 2 da spec 16. O checkpoint ja era a metade de um contrato entre quem fala
e quem opera; faltava o campo que diz o QUE ACONTECEU no turno, e faltava
declara-lo como fronteira em vez de subproduto.

A declaracao viaja na chamada de FECHAMENTO que ja existe, nao numa tool. O
motivo e o que este arquivo ja escrevia sobre o checkpoint: uma tool dependeria
de o modelo lembrar de chama-la, e o turno em que ele esquecesse seria um lead
parado em silencio. Custo adicional zero — e a mesma chamada de modelo.

AUSENTE NAO E VAZIA, e o desenho inteiro gira nisso. `declaracao: undefined`
(coluna NULL) = o modelo nao declarou; `{nada_a_declarar: true}` = ele avaliou e
nao havia nada. Um `.default({})` no Zod, ou um `not null default` na coluna,
colapsaria os dois e faria "esqueceu" parecer "nao havia nada" — escondendo o
esquecimento exatamente onde o invariante 4 do sistema vivo manda mostra-lo.
LeadCheckpointRow faz Omit e redeclara o campo porque as duas pontas
representam o "nao sei" diferente: o modelo omite, o Postgres guarda null.

A declaracao ja NASCE com leitor, senao seria evento sem consumer:
buildHandoffSummary passa a entregar ao humano o que a pessoa quer e o que foi
prometido a ela, antes de compromissos e objecoes — e o prazo vai colado na
promessa, porque quem assume precisa do "ate quando" na mesma linha. Um
artefato, dois leitores (invariante 2).

O texto que o Conversador le nao carrega vocabulario de sistema, e ha teste que
reprova se carregar — e o defeito de 30% que a spec existe para matar.

Schema em tripla: migration 0110 + apendice idempotente no baseline + MANIFEST.

Evidencia observada: 16 testes novos; suite 2603 passed (270 arquivos);
typecheck limpo; lint 0 erros e 189 warnings — o MESMO numero medido na main
com as mudancas em stash, entao nenhum warning novo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* feat(agente): a projecao — fecha a terceira porta, a que a doutrina subestimava

Passo 3 da spec 16. As duas primeiras portas de vazamento (nome e descricao da
ferramenta) se fecham nao mostrando a ferramenta. A terceira nao: e o DADO que
ela devolve, e foi medida em turno real — "deram erro na acao de webhook:
unsafe_url:https_required", copiado do resultado para a tela do cliente.

ALLOWLIST, nunca denylist: campo novo no contexto nao chega ao cliente por
omissao. A linha e identificador do SISTEMA sai (lead_id, conversation_id,
media_storage_path), dado da PESSOA fica (nome, telefone, e-mail) — confundir as
duas produz um agente que nao sabe com quem fala.

O interruptor se justifica sozinho: a projecao arma quando NENHUMA ferramenta de
catalogo entrou no turno, porque e exatamente ai que os ids nao tem uso (as MCP
os recebem como argumento, as nativas resolvem pelo closure). Liga-la sempre
quebraria o agente que opera; por env seria mais um botao para errar. No passo 6,
quando o Conversador perder as ferramentas de escrita, ela passa a valer sozinha.

ACHADO NAO PREVISTO, corrigido aqui: search_knowledge devolvia chunk_id e
knowledge_source_id CRUS ao modelo, dois UUIDs por resultado, em toda busca com
RAG — sem uso nenhum do lado dele, ja que as citacoes sao montadas pelo codigo.
Era fonte silenciosa do UUID cru que a medicao viu chegar na tela do cliente.

Fecha tambem a releitura: sem isso a projecao seria decorativa, bastava o modelo
chamar get_lead_context para receber tudo cru de volta. E o cabecalho do bloco de
estado deixa de imprimir o nome da tabela (lead_state).

UM BUG QUE QUASE ENTROU, registrado porque o teste dele fica: eu aplicava a
traducao de erro ao CORPO das mensagens. "Meu site tem um webhook quebrado" —
frase legitima de quem vende integracao — viraria "nao consegui concluir essa
verificacao", e o agente responderia a uma pergunta que ninguem fez.

Os testes medem contra os turnos REAIS de gpt-5.6-terra versionados em evidence/,
com controle positivo em cada caso e falha declarada se a evidencia sumir.
Fixture inventada mede a imaginacao de quem a escreveu, que e justamente a que ja
falhou — senao o defeito nao teria acontecido.

`projeta` ficou OPCIONAL no tipo de buildOpening porque tests/invariants/** e
congelado por hook: torna-lo obrigatorio forcaria editar um invariante existente,
que a catraca proibe com razao. Custo registrado no handoff.

Evidencia observada: 18 testes novos; suite 2621 passed (271 arquivos);
test:db verde (68 arquivos, 462 passed, 1 skipped); typecheck limpo; lint 0 erros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* fix(projecao): a traducao de erro pisava no conteudo do RAG do tenant

Bug MEU, introduzido no commit anterior e pego pela sabotagem — nao por leitura.

`traduzirErroCru` era aplicada a TODA string do retorno de ferramenta. O detector
de vazamento considera "webhook" vazamento (com razao, na SAIDA), e a base de
conhecimento de qualquer empresa que venda integracao fala webhook. O `content`
do search_knowledge viraria "nao consegui concluir essa verificacao" linha apos
linha: a projecao destruiria o RAG do tenant para proteger contra um vazamento
que o gate de saida ja cobre.

E a MESMA falha que a de reescrever a fala do cliente, corrigida horas antes,
numa irma que nao se parece por fora: as duas nascem de aplicar num texto de
TERCEIROS um filtro desenhado para texto do SISTEMA. Procurei a classe depois de
pegar a primeira instancia e nao achei esta — ela so apareceu quando a sabotagem
mostrou que o teste passava pelo mecanismo errado.

Agora a traducao so alcanca valor sob chave de erro (lista fechada e curta:
erro/error/message/code/reason/detail e os equivalentes em pt). Dois testes
novos: o conteudo do RAG sobrevive intacto falando "webhook", e o campo de erro
continua traduzido — a restricao nao desarmou a defesa.

COMO O TESTE ANTERIOR PASSAVA SEM MEDIR: o detector pega UUID (categoria
erro_cru), entao os casos de evidencia passavam pela TRADUCAO mesmo com a remocao
de chave desligada — dois mecanismos redundantes, e eu atribuia o resultado ao
errado. A sabotagem S2 derrubava 1 caso de 3; era o sinal.

Evidencia observada: 20 testes; typecheck limpo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* docs(handoff): o bug do RAG e a sabotagem que o pegou

Registra no handoff vivo o defeito que entrei e corrigi (traducao pisando no
conteudo da base de conhecimento), COMO o teste passava sem medir (dois
mecanismos redundantes, credito atribuido ao errado), e o sinal que denunciou
— S2 derrubando 1 caso de 3, quando devia derrubar os 3.

Depois da correcao: S2 derruba 3 de 3. Oito sabotagens, oito reprovacoes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* feat(agente): nasce o OPERADOR — disparo imposto pelo runtime, nunca pelo modelo

Passo 4 da spec 16. O papel que mexe no sistema e nunca fala com o lead: ele nao
tem send_message no toolset. A separacao e por AUSENCIA, a unica forma que nao
depende de o modelo obedecer.

O DISPARO E DO RUNTIME. O job `operator_turn` e enfileirado no fim do turno,
logo apos o checkpoint existir, com sourceEventId = job do Conversador (retry da
fila nao gera um segundo Operador). Se o Conversador o "chamasse", devolveria o
problema inteiro: voltaria a depender de o modelo lembrar, e o turno em que ele
nao achasse necessario seria um lead parado no funil, em silencio. Depois do
checkpoint porque a declaracao E o insumo — antes, ele leria o turno anterior.

O CURTO-CIRCUITO E ONDE O PASSO 2 SE PAGA. `nada_a_declarar: true` => nao chama
modelo (quem avaliou estava la, com todo o contexto). Declaracao AUSENTE => roda,
porque ausente significa que NINGUEM avaliou, e e ai que promessa fica orfa. Os
dois estados que o passo 2 se recusou a colapsar decidem, aqui, se a chave do
self-hoster e gasta. Ha teste afirmando que eles levam a decisoes OPOSTAS.

`operator_enabled` nasce false: migration nao liga sozinha um papel que gasta
chamada de modelo por turno. `operator_model` null = herda o do Conversador. O
loader le `?? false` para o clone que ainda nao aplicou a 0111 — schema velho
desliga o papel, nunca gasta modelo por engano.

Promessa declarada e nao cumprida vira `promise_unfulfilled` na Central, com copy
que diz o que o CLIENTE espera, nao o que o sistema deixou de gravar — do lado de
la existe uma pessoa que ouviu um compromisso.

DOIS ERROS MEUS QUE AS CATRACAS DO REPO PEGARAM, nao eu:
  - criei um SEGUNDO bloco de `job_queue_kind_check` no baseline quando ja existe
    um. Isso quebra o update.sh de todo clone com vocabulario posterior. Eu tinha
    aplicado essa licao corretamente ao agent_inbox_items minutos antes e a irma
    passou batido — o padrao pego numa ocorrencia da alibi as outras. Consolidado
    no bloco unico (tests/unit/baseline-constraint-reconstruida.test.ts);
  - o kind novo divergia entre banco e TypeScript
    (tests/invariants/vocabulario-banco-x-typescript.test.ts).

Evidencia observada: 7 testes de decisao + 5 invariantes de schema; suite 2630
passed (272 arquivos); test:db verde (69 arquivos, 467 passed); typecheck limpo;
lint 0 erros / 189 warnings (mesmo numero da main).

Schema em tripla: migration 0111 + apendice no baseline + MANIFEST.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* docs(handoff): passo 4 registrado, com as duas catracas que me pegaram

Registra o que o passo 4 entregou e o que NAO entregou (o Operador tem canal,
ainda nao tem mao), a sabotagem com contagem prevista, e os dois erros meus que
foram pegos por catraca do repo e nao por leitura minha — porque a saida limpa
sem essa nota creditaria ao rigor o que foi merito do mecanismo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* feat(agente): o Operador ganha MAO — ferramentas proprias, e nenhuma delas fala

Fatia 1 do passo 5. O papel deixa de so registrar promessa e passa a operar: le
`operator_tool_ids`, monta o toolset pela ponte MCP e roda um turno de modelo com
briefing proprio e atribuicao de custo propria (purpose 'operator_turn' — sem
isso o gasto dele entraria como se fosse conversa, e "quanto custa ligar o papel"
nao teria resposta).

COLUNA PROPRIA, nao reuso de tool_ids, por tres razoes que se somam: a tela nao
pode mentir (lista compartilhada faria a secao Operador configurar o que o
Conversador executa); o teto de 20 se resolve por DIVISAO em vez de aumento (hoje
sao ate 32 tools num prompt so, e o e2e capacidades-do-agente esta fora do CI por
causa disso); e o passo 6 vira migration de DADOS, mover ids entre colunas.

SEM CANAL, e isso e estrutural: send_message e tool NATIVA do engine e nao existe
no catalogo, entao nao ha id que a ligue; e crm_send_whatsapp_message esta em
BLOCKED_TOOL_IDS, que agora e exportado para ser aussertivel — garantia que
nenhum teste consegue ler e garantia que ninguem percebe quando some. O system do
papel fala de CRM com todas as letras, e PODE: esse texto nao alcanca cliente. E
a assimetria exata da spec — no Conversador o mesmo vocabulario e o defeito de
30% medido.

Sem ferramenta configurada ele nao chama modelo: descobrir que nao tem mao nao
vale a chave do self-hoster. Herdar as do Conversador daria 20 capacidades a quem
nao escolheu nenhuma, entao o default e vazio.

TERCEIRA CATRACA DO REPO A ME PEGAR: VERSION_COLUMNS esta copiado em 6 arquivos e
eu atualizei 1. O proprio teste conta que `cases_enabled` fez isso (entrou em 2
de 7) e o sintoma nao e typecheck nem teste — e um campo que "se desmarca
sozinho" na tela depois do refresh, e o save seguinte grava por cima. Repeti o
padrao que o comentario descrevia.

Evidencia observada: 8 testes novos; suite unit 1459 passed (154 arquivos);
test:db verde (69 arquivos, 467 passed); typecheck limpo.

Schema em tripla: migration 0112 + apendice no baseline + MANIFEST.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* feat(ui): a tela ganha os PAPEIS — e diz a consequencia, nao o nome da feature

Fatia 2 do passo 5. A configuracao do agente deixa de ser uma lista unica e passa
a ter navegacao por papel: "Conversa com o cliente" e "Organiza o sistema". Os
rotulos dizem o que cada papel FAZ — Conversador/Operador e o nosso vocabulario
interno; quem configura pensa em "quem fala com meu cliente" e "quem organiza
minha casa".

SECOES, nao abas com save proprio: o rascunho e um so. Duas telas com dois saves
fariam o usuario publicar metade da configuracao e criariam dois caminhos para o
mesmo ai_agent_versions.

A REGUA DA DISCIPLINA DE INFORMACAO e dizer a CONSEQUENCIA. Com o papel
desligado, a tela explica o que CONTINUA acontecendo (o basico e registrado
sozinho — decisao da spec 16 §2.1) e o que PARA (decidir sobre a operacao). Sem a
primeira frase o usuario conclui que desligar deixa o sistema cego e liga por
medo, nao por escolha. Ha teste exigindo as duas.

Papel desligado NAO mostra a configuracao dele: convidar alguem a configurar o
que nao vai rodar produz a conclusao de que o produto quebrou.

O modelo herdado tem NOME na tela ("A mesma que conversa"), em vez de aparecer
como pendencia — e ganhou o caminho de VOLTA: um Select nao oferece "nenhum" como
item, entao sem o botao a escolha seria de mao unica e o usuario nao teria como
saber por que.

QUARTA CATRACA a me pegar nesta sessao: VERSION_COLUMNS em 6 arquivos, eu
atualizei 1 (corrigido na fatia anterior). Ha um teste novo que varre a tela
inteira atras do nosso vocabulario (MCP, tool_ids, operator_, job, payload).

Evidencia observada: 8 testes de componente; suite unit 1459 passed antes deste
arquivo; typecheck limpo; lint 0 erros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* docs(handoff): passo 5 parcial, e o bloqueio que impediu a prova de tela

Registra as duas fatias entregues (mao do Operador + tela dos papeis) e o motivo
de a prova de jornada NAO ter sido feita: .env.local aponta para a nuvem, e o
playwright sobe o app com next start, que carrega .env.local. Rodar e2e nesta
maquina hoje cria agentes e conversas de teste no banco de PRODUCAO.

Nao e especifico deste epico — vale para qualquer sessao que rode e2e aqui agora.

Fica declarado que o que esta provado da tela e comportamento de componente, nao
jornada de usuario. Sao coisas diferentes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* fix(e2e): a suite escrevia no banco de PRODUCAO — tres camadas de conserto

Medido em 2026-08-06. `.env.local` de um checkout de trabalho aponta para a
nuvem, e o playwright sobe o app com `next start`, que o carrega. A suite criava
organizacoes, usuarios e agentes de teste no banco real, passando verde.

A org `e2e-test-org` esta na producao deste projeto desde 2026-04-29. Ela nao
chegou la por acidente de uma sessao: chegou porque esse era o comportamento
normal da suite.

ISOLAR O webServer NAO BASTAVA, e essa foi a descoberta. Os scripts de seed leem
`.env.local` DIRETO DO DISCO, ignorando process.env — entao o env injetado no
servidor nunca os alcancava, e as specs chamam esses seeds sozinhas no meio do
teste. O sintoma que denunciou: o factor TOTP em `.e2e-creds.json` nao existia no
banco local, porque tinha sido criado na nuvem. 93 arquivos tem esse padrao.

As tres camadas:
  1. `.env.e2e` (gerado por `pnpm e2e:env`, nao versionado) + injecao explicita
     no webServer, com guard que RECUSA rodar se o arquivo faltar — cair no
     .env.local em silencio e o modo de falha caro;
  2. `pnpm e2e:build`, porque as tres NEXT_PUBLIC_* sao embutidas no BUNDLE:
     buildar com o env errado e trocar so no start deixaria a URL de producao
     dentro do JavaScript do browser. `node --env-file` nao serve aqui — o next
     build cria Workers e o Node recusa propagar a flag
     (ERR_WORKER_INVALID_EXEC_ARGV). O script prova as DUAS direcoes: host de
     producao ausente do bundle E host local presente (sem o controle positivo,
     "nao achei producao" pode ser um grep que nao acha nada);
  3. `scripts/lib/env-de-teste.ts` — process.env VENCE o arquivo, e todo seed
     ANUNCIA o destino ("escrevendo em LOCAL" / "⚠️ REMOTO"). Um seed que escreve
     em producao acha os mesmos dados de teste de sempre e termina com "✅ Seed
     completo": sem a linha impressa, nada distingue os dois casos.

`seed-e2e-credentials.ts` migrado (e o que a suite chama sempre). Os outros ~14
seeds e as ~78 sondas ficam declarados como divida no handoff.

GANHO ESTRUTURAL do worktree dedicado: aqui nao existe `.env.local`, entao um
script que o leia do disco falha ALTO (ENOENT) em vez de escrever na nuvem. O
isolamento deixa de depender de disciplina.

Evidencia observada: spec agente-novo-e-uso 5/5 passed contra o banco LOCAL
(era 0/5 antes, por MFA — o factor do banco nao batia com o do arquivo);
suite unit 1467 passed (155 arquivos); typecheck limpo; guard do playwright
recusa sem .env.e2e (verificado).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* docs(handoff): o worktree dedicado, e as tres camadas do isolamento do e2e

Registra o achado que destravou a prova de tela e que vale para o repo inteiro:
a suite escrevia em producao, e isolar so o webServer nao bastava porque os
seeds leem .env.local do disco.

Registra tambem o ganho estrutural do worktree (sem .env.local, script que o leia
falha alto em vez de escrever na nuvem) e as tres dividas que sobraram: os ~92
scripts nao migrados, o spec da aba nova, e a decisao sobre as fixtures que ja
estao na producao.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* fix(e2e): os 16 seeds param de ler .env.local do disco, e um gate congela isso

Completa o conserto de 783ee85b. Todos os `scripts/seed-e2e-*.ts` passam a usar
`credenciaisSupabaseDeTeste()`: process.env VENCE o arquivo, e cada um ANUNCIA
contra qual banco vai escrever.

Dois ja estavam com a precedencia certa e so ganharam o anuncio — `capacidades`
lia o arquivo como fallback e `invite` usava process.env puro. O `capacidades`
tambem estourava (ENOENT) num worktree sem .env.local, que e justamente onde a
ausencia do arquivo E a protecao; o helper trata os dois casos.

O GATE que impede a volta do padrao: tests/unit/seed-nao-le-env-local-do-disco.
Sem ele o 17o seed nasce copiando o mais proximo, e escrever em producao nao e
reparavel por code review depois do fato. Cobre `scripts/seed-e2e-*` — o que a
suite roda SOZINHA; as ~78 sondas que alguem dispara a mao ficam declaradas no
handoff, nao escondidas.

A PRIMEIRA VERSAO DA REGEX DO GATE NAO MEDIA NADA. Era
`/readFileSync\s*\(\s*(?:path\.join\([^)]*)?...\.env\.local/` e o `[^)]*` parava
no `)` de `process.cwd()`, no meio de `path.join(process.cwd(), ".env.local")` —
a alternativa nunca casava. Descoberto por SABOTAGEM: reintroduzir a leitura num
seed nao vermelhou nada. A versao atual nao modela a sintaxe da chamada, procura
o literal — regex que entende estrutura erra calada.

Evidencia observada: os 16 seeds EXECUTADOS de verdade, 16/16 anunciando
"escrevendo em LOCAL" (typecheck sozinho nao pega `env is not defined`, ja
aconteceu nesta serie); gate 33 passed; typecheck limpo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* docs(handoff): os 16 seeds migrados, e a regex do gate que nao media nada

Registra a migracao completa dos seeds e o gate que a congela — inclusive o fato
de a primeira regex do gate nao pegar o padrao real, descoberto por sabotagem e
nao por leitura. Atualiza a divida remanescente: sao ~78 sondas, disparadas a
mao, nao mais 14 seeds automaticos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* test(e2e): a aba do Operador provada pela TELA, com o refresh que pega campo perdido

O passo 5 tinha teste de componente, que mede o componente e nao o produto: entre
a escolha do usuario e o banco existem o formulario, a server action, o Zod, o
VERSION_COLUMNS de seis arquivos e o SELECT que rele. Cada um pode perder um
campo sem quebrar teste de unidade nenhum.

O CASO CENTRAL e salvar → RECARREGAR → conferir, e nao e zelo generico: e o
sintoma exato que agent-version-columns-drift descreve — "um campo que se
desmarca sozinho depois do refresh, e o save seguinte grava o valor errado por
cima". Aconteceu com cases_enabled (2 de 7 arquivos) e eu repeti com operator_*
(1 de 6) nesta serie. Este e o teste que teria pego.

DOIS ACHADOS AO ESCREVER, os dois medidos e nao supostos:

1. `default_agent_id` do seed de credenciais e um **rag_bot**, e a tela de papeis
   e do mcp_agent — apontar para ele fazia o spec falhar como se a aba nao
   existisse. O spec agora semeia a PROPRIA precondicao no beforeAll em vez de
   depender de um mcp_agent que outra spec deixou (depender da ordem alfabetica
   dos arquivos ja mordeu este repo).

2. Um login por teste custava 7 logins para exercitar uma aba, e o produto tem
   teto de 60 por IP a cada 5 minutos — o MESMO que protege a conta de um
   cliente real. A bateria estourava no 3o caso e a falha aparecia como "campo
   de MFA nao apareceu", parecendo defeito de produto onde havia limite de
   ambiente. Agora e UM login numa pagina compartilhada, com mode serial porque
   os casos passam a compartilhar navegacao.

Evidencia observada: 7/7 passed em 1.8min contra o Supabase LOCAL (era 6/7 em
7.5min); typecheck limpo; lint 0 erros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* docs(handoff): a prova pela tela, e a sabotagem que mostrou o culpado

Registra o spec e2e da aba do Operador (7/7 em 1.8min) e a sabotagem que o
valida: removi operator_enabled do SELECT de UM dos seis arquivos — o defeito
exato que ja aconteceu com cases_enabled — e o spec reprovou apontando o culpado
na propria mensagem.

Registra os dois achados de escrita: o default_agent_id do seed e um rag_bot (a
tela de papeis e do mcp_agent), e um login por teste estourava o teto do proprio
produto, fazendo limite de ambiente parecer defeito.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* fix(ui): o botao Testar deixa de mostrar resposta que nenhum gate examinou

Fatia 3 do passo 5, e o ultimo item aberto dele.

O DEFEITO, medido em 2026-08-05 com controle positivo: a rota /versions/[vid]/test
chama runAgent de lib/ai/runtime/agent.ts, marcado @deprecated, que nao importa
runBeforeSend. Nenhum literal da cadeia (internal_vocabulary,
case_promise_without_case, messaging_window_closed) aparece no build do app Next
— ela vive no processo do worker. O self-hoster testava, via limpo, publicava, e
producao divergia. Falha-em-verde no caminho de primeira impressao, que num
produto instalado pela propria pessoa ninguem descobre.

O CONSERTO NAO FINGE COBERTURA. Seis dos dez gates dependem de estado que so
existe no turno real — contadores de pacing, janela de copias, carimbo da ultima
inbound, ledger de envios, base legal. Fabricar esse estado daria um veredito
INVENTADO, que e pior que veredito nenhum porque tem aparencia de prova. Entao a
avaliacao cobre o que e decidivel so com o texto (o gate de vocabulario interno —
justamente o que a medicao elegeu como mais caro: 30% dos turnos com prompt de
operador vazavam) e DECLARA os outros nove na tela, com o motivo de cada um.

Mostrar "nao verificado" em voz alta e o conserto. Silencio que parece aprovacao
era o defeito.

Os motivos sao escritos para o DONO DO NEGOCIO, nao para nos: "depende de quando
o contato falou com voce pela ultima vez", nao "depende do send_ledger". Ha teste
varrendo jargao nessa lista — repetir na tela de configuracao o vocabulario que a
spec 16 existe para matar seria ironico.

A lista de nao-avaliados e AMARRADA a BEFORE_SEND_GATES: acrescentar um gate a
cadeia vermelha o teste e forca decidir de que lado ele cai. O caminho do stub
tambem avalia — um caminho sem avaliacao voltaria a ser o verde que nao olhou
para nada, so que mais dificil de notar.

Evidencia observada: 6 testes novos, dois deles com os textos REAIS medidos (o
que vazou e o que o modelo reescreveu depois do veto); suite 2685 passed (276
arquivos); typecheck limpo; lint 0 erros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* docs(handoff): passo 5 fechado — o Testar declara o que nao consegue verificar

Registra o ultimo item do passo 5 e a decisao que o define: nao fingir cobertura.
Seis dos dez gates dependem do turno real, e fabricar esse estado daria veredito
inventado — entao a tela mostra o que foi verificado E o que nao foi, com motivo
escrito para o dono do negocio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* feat(agente): a CURA — a ferramenta sai do Conversador quando o Operador a assume

Passo 6 da spec 16, o ultimo. O gate de vazamento e REDE: barra na saida e ensina
o modelo. A cura e o Conversador nunca ter visto o vocabulario — ele nao repete o
nome de uma ferramenta que nao esta no contexto dele. Foi pelo NOME que o
vazamento voltou depois de a descricao ser limpa (crm_list_webhook_sources,
medido), e nome de tool e contrato de wire que nao se renomeia.

A REGRA QUE IMPEDE O BURACO: a capacidade so muda de dono quando o novo dono
EXISTE. A nativa sai do Conversador apenas se o Operador estiver ligado E tiver,
marcado na tela, um equivalente que faca a mesma coisa. Ligar o papel e esquecer
de marcar crm_move_lead_stage tiraria o avanco do funil de um lado sem dar a
ninguem — o funil pararia de andar em silencio, que e o modo de falha que este
epico existe para combater. Tirar de um lado sem garantir o outro nao separa
papeis, perde capacidade.

E A INSTRUCAO SAI DO PROMPT JUNTO. Remover a ferramenta e manter a linha
"Marque-o com update_lead_state" seria o pior dos dois mundos: o modelo tentaria
chamar o que nao existe E o nome continuaria no contexto — ou seja, a cura nao
teria acontecido. Ha teste separado so para isso.

O QUE NAO SAI, MEDIDO CONTRA O CATALOGO REAL: save_lead_note, open_human_case e
provide_case_update nao tem equivalente MCP — entrega-las deixaria a capacidade
orfa. Ficam, com a divida declarada. request_human_handoff fica por outra razao:
ela EXISTE no catalogo mas esta em BLOCKED_TOOL_IDS (a variante do catalogo nao
silencia o harness), e passar a conversa para uma pessoa e decisao sobre a
CONVERSA, com efeito imediato — adia-la ao turno seguinte deixaria o assistente
respondendo depois de o lead pedir um humano, num caminho que a Meta fiscaliza.

A tabela de equivalencia e verificada nos DOIS sentidos: toda nativa listada
existe em AGENT_TOOL_DEFS e todo equivalente existe em TOOL_CATALOG. Um typo em
qualquer lado faria o corte nunca acontecer, silenciosamente.

Zero mudanca para quem nao ligou o papel: operator_enabled nasce false.

Evidencia observada: 16 testes novos; suite 2701 passed (277 arquivos);
typecheck limpo; lint 0 erros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* docs(handoff): passo 6 parcial — o mecanismo esta provado, o efeito nao

Registra o que a cura entregou e, com o mesmo destaque, o que ela ainda NAO
provou: o sinal de sucesso definido pela spec e a taxa de 30% cair, e isso exige
re-rodar a medicao de 18 turnos com o Operador ligado. Nao foi feito.

Esta provado o MECANISMO (a ferramenta e o nome somem do contexto do
Conversador), nao o EFEITO (a taxa cair). Sao coisas diferentes.

Registra tambem as 3 nativas que ficaram por falta de equivalente no catalogo —
divida declarada, nao um corte pela metade escondido.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* medicao(passo6): re-medido — o vazamento NAO foi a zero, e agora se sabe por que

O sinal que a spec 16 define para o passo 6 e "o vazamento vai a zero por
AUSENCIA, nao por filtro". Medido: nao vai.

10 cenarios x 3 configuracoes, mesmo modelo da linha de base (gpt-5.6-terra):
  A CONTROLE (base replicada) 10.0% · B passo 6 como entregue 10.0% ·
  C cura completa (operacao sai do Conversador) 10.0%

As tres iguais, e o MESMO cenario vazando. Em C — sem nenhuma ferramenta de
webhook no contexto — o modelo ainda disse "webhook": parte do vocabulario vem
do MODELO, nao do contexto. Ausencia de ferramenta reduz superficie; nao elimina
vazamento. A frase "so se fecha nao mostrando" fecha as portas 1, 2 e 3 — nao
fecha o modelo.

O CONTROLE NAO REPRODUZ OS 30%, e a causa esta identificada: o instrumento nao
executa as ferramentas, entao nao exercita a porta 3 (dado retornado), que era 2
dos 3 vazamentos originais. Ele mede a porta 1/2 e achou 1 de 10 — exatamente o
unico vazamento por NOME da linha de base. Calibracao parcial e explicada.

O que a medicao CONFIRMA: o prompt e a variavel dominante. O valor do epico nao e
"zero por ausencia" — e o dono do negocio deixar de precisar escrever "atenda E
organize" no prompt do Conversador, porque agora existe um papel para isso. Foi
essa troca que a linha de base mediu levando 30% a 0%.

O INSTRUMENTO REPORTOU 0,0% NAS TRES CONFIGURACOES NA PRIMEIRA EXECUCAO. Parecia
o passo 6 funcionando perfeitamente. As 30 chamadas tinham voltado HTTP 400
(gpt-5.6-terra recusa function tools com reasoning em /chat/completions), os
textos vieram vazios, e o detector nao acha nada em texto vazio. O erro estava
capturado por linha; o resumo so imprimia a taxa. Corrigido: recusa calcular taxa
quando ha turnos que nao rodaram, e sai com codigo 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* medicao(passo6): a taxa TOTAL, com ferramentas executadas — 30% cai para 10%

A medicao que faltava, agora com o controle CALIBRADO: a configuracao A
reproduziu 30,0% com os MESMOS tres cenarios da linha de base, com as
ferramentas rodando de verdade contra o banco local. Zero respostas vazias.

  A CONTROLE (21 capacidades) ......... 3/10 = 30,0%
  C sem as de OPERACAO (14) ........... 1/10 = 10,0%

Tirar as sete ferramentas de operacao eliminou DOIS dos tres vazamentos —
exatamente os que vinham do DADO retornado (unsafe_url:https_required e
admin/manager+UUIDs). Sobrou o cenario 3: sem nenhuma ferramenta de webhook no
contexto, o modelo AINDA disse "webhook". O termo veio dele, nao do que lhe foi
mostrado. "So se fecha nao mostrando" fecha as portas 1, 2 e 3 — nao fecha o
modelo.

CONSEQUENCIA PARA O PASSO 6 COMO ESTA: ele entrega as NATIVAS com equivalente, e
nenhum dos tres vazamentos veio delas. Nao move a taxa. O que move e entregar ao
Operador tambem as `crm_list_*` de OPERACAO — que nao servem para responder
pergunta de paciente, servem para o dono cuidar da casa, que e a definicao do
papel. Recomendacao medida, com numero: 30% -> 10%.

DOIS INSTRUMENTOS QUEBRADOS NO CAMINHO. O primeiro imprimiu 0,0% nas tres
configuracoes (30 chamadas com HTTP 400, textos vazios, detector nao acha nada em
vazio). O segundo coletou 10 turnos vazios e o spec passou — tres causas em
cadeia, cada uma escondida pela anterior: credencial cifrada com outra chave,
AI_CRED_AES_KEY de 21 bytes no .env.e2e (placeholder do CI, que nao exercita
cifra), e o spec nunca validando a credencial que criava (funcionava so
reaproveitando uma validada a mao). Os tres consertados, o gerador do .env.e2e
passa a emitir chaves de 32 bytes de verdade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* feat(agente): as capacidades de OPERACAO saem do Conversador — 30,0% cai para 10,0%

Aplica a recomendacao que a medicao anterior produziu, e re-mede com o codigo no
caminho.

  A CONTROLE (todas as capacidades) .... 3/10 = 30,0%
  C codigo aplicado .................... 1/10 = 10,0%

Ferramentas EXECUTADAS contra dados reais, zero respostas vazias nas duas
corridas, controle calibrado contra a linha de base (mesmos tres cenarios).

O MECANISMO E DIFERENTE do resto do modulo: aqui nao ha mapa de equivalencia, e a
MESMA ferramenta mudando de dono. A condicao continua sendo a mesma — so sai se o
Operador estiver ligado e a tiver marcada, para a capacidade nunca ficar orfa.

Os dois vazamentos que sumiram vinham do DADO que essas ferramentas devolvem
(unsafe_url:https_required e admin/manager+UUIDs), nao do nome nem da descricao.
E a porta 3, a que "nao mostrar a ferramenta" so fecha tirando a ferramenta.

crm_list_pipelines/stages/tags FICAM: saber em que etapa o lead esta e contexto
de CONVERSA. Tira-las trocaria um vazamento por um atendimento pior.

O LACO ENTRE CODIGO E MEDICAO ESTA FECHADO: o spec de coleta pergunta ao codigo
quais capacidades sobram, em vez de repetir a lista. Uma copia paralela mediria a
copia, e poderia divergir do que o turno monta sem nada vermelhar. Ha teste
travando o conjunto que sobra — mudar a lista sem re-medir reprova.

Os 10% que restam sao o residuo do MODELO: sem nenhuma ferramenta de webhook no
contexto, ele ainda diz "webhook". O gate segue cobrindo isso.

Evidencia observada: 21 testes; suite 2706 passed (277 arquivos); typecheck
limpo; lint 0 erros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* docs(handoff): fixtures de e2e removidas da producao, e dois defeitos que a operacao expos

Removidas em 2026-08-06, com backup previo: as orgs `e2e-test-org` e
`e2e-tenant-b` e tudo que pendurava nelas — 5 usuarios @deskcomm.test, 57
contatos, 144 mensagens, 56 leads, 18 agentes, 5 canais. Restaram 3 orgs, todas
legitimas. `onboarding-teste` NAO foi tocada: nao e fixture automatica, pode ser
teste manual do dono.

A operacao expos DOIS DEFEITOS REAIS do produto, os dois da mesma familia
(entidade que nao se consegue apagar):

1. DELETAR UMA ORGANIZACAO FALHA. O cascade apaga os filhos, e o trigger de
   audit de cada um tenta inserir em api_audit_log referenciando a org que ja
   nao existe → violacao de FK. So funciona apagando os filhos ANTES, com o pai
   vivo, e a propria api_audit_log por ultimo.

2. DELETAR UM AGENTE DONO DE LEAD FALHA. O SET NULL em
   crm_leads.owner_agent_id viola crm_leads_owner_kind_coherence, que exige
   owner_agent_id quando owner_kind='ai'. Ou seja: um agente que ja atendeu
   alguem nao pode ser removido.

Nenhum dos dois foi corrigido aqui — a tarefa era limpar, e consertar schema de
producao no mesmo movimento seria escopo que ninguem pediu. Ficam registrados.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* fix(e2e): as sondas param de ler .env.local do disco — 108 arquivos, zero restantes

Fecha a divida que sobrou do conserto dos seeds. Todas as sondas, provas e
capturas de scripts/ e tests/ passam a usar `carregarEnvLocal()`.

POR QUE UMA FUNCAO NOVA E NAO `credenciaisSupabaseDeTeste`: essas ~96 sondas nao
leem so as credenciais do Supabase — cada uma pega o que precisa (WAHA_API_KEY,
OPENAI_API_KEY, SUPABASE_DB_URL) de um Record<string,string> que montavam a mao.
`carregarEnvLocal()` devolve o MESMO shape, com duas diferencas que sao o
conserto inteiro: process.env VENCE o arquivo, e a ausencia do arquivo nao e erro
— no worktree dedicado de e2e nao existe .env.local, e e essa ausencia que
protege. O loader antigo estourava com ENOENT ali.

Oito formatos diferentes de loader inline, migrados em quatro passadas com
verificacao a cada uma. Os quatro ultimos casos (funcoes que remontavam o texto
do arquivo para re-parsear) foram substituidos por inteiro, nao remendados.

O GATE FOI ESTENDIDO e ficou mais rigoroso que o meu grep: ele pegou 11 arquivos
que eu tinha dado por migrados (formatos multi-linha que a minha busca nao
casava). Agora sao 188 casos cobrindo scripts/ + tests/ + tests/e2e/. O anuncio
de destino continua exigido so dos SEEDS: sonda e disparada a mao, com a pessoa
lendo a saida.

LINT: subiu para 235 warnings com a migracao — 44 arquivos ficaram com import de
`fs`/`path` orfao depois que o loader saiu. Removidos: 185 agora, contra 183 na
base. Os +2 sao de arquivo novo, nao regressao.

Evidencia observada: gate 188 passed; suite 2861 passed (277 arquivos);
typecheck limpo; 0 arquivos lendo .env.local do disco.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* fix(db): duas entidades que nao se conseguia apagar — organizacao e agente

Achados operando: ao remover as fixtures de E2E da producao, os dois erros
apareceram um depois do outro, cada um abortando a transacao inteira. Sao da
mesma familia — uma escrita AUTOMATICA (trigger de audit, SET NULL de FK)
reagindo ao DELETE e violando regra que vale para o estado normal, nao para a
remocao.

DEFEITO 1 · apagar uma ORGANIZACAO falhava. O cascade apaga os filhos e o
trigger de audit de cada um insere em api_audit_log com o organization_id de uma
org que ja nao existe. Agora o audit e pulado no DELETE quando a org sumiu — nao
se perde auditoria, porque essa linha seria apagada pelo cascade em seguida. A
checagem fica SO no ramo DELETE: um `exists` no INSERT/UPDATE cobraria um SELECT
em todo hot path de escrita para cobrir um caso que nao ocorre la.

DEFEITO 2 · apagar um AGENTE que ja atendeu falhava. A FK e ON DELETE SET NULL e
o CHECK exige owner_agent_id quando owner_kind='ai'; o SET NULL zerava um lado e
deixava o outro. Agora um BEFORE DELETE desfaz a atribuicao INTEIRA antes de a FK
agir, e o lead fica sem dono em vez de meio-atribuido. O CHECK NAO foi afrouxado:
tolerar 'ai' sem agente trocaria erro barulhento por dado incoerente em silencio
— ha teste cobrando isso.

TRES CATRACAS DO REPO ME PEGARAM AQUI:
  - a varredura de hardening: minha funcao nova nasceu SECURITY DEFINER
    executavel por `authenticated` e podendo escrever; meu revoke cobria
    `public` e `anon` e faltou a terceira origem. Revogada — seguro porque o
    unico call site e o trigger, e o Postgres nao exige EXECUTE do usuario para
    invocar funcao de trigger;
  - o hook de sequencia de migration: 0113 ja existe na main (e em 6 branches).
    Renumerada para 0115, que e o proximo livre em TODAS as branches locais;
  - e um caso MEU passava pelo motivo errado: o teste do CHECK montava o insert
    por subquery e caia em `stage_id` nulo — teria seguido verde se alguem
    afrouxasse o CHECK. Agora usa ids diretos.

Schema em tripla: migration 0115 + apendice no baseline + MANIFEST.
Evidencia observada: test:db verde (70 arquivos, 470 passed); typecheck limpo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* test(db): o caso do defeito 1 usava tabela SEM audit e nao media nada

DESKCOMM_GOV_INVARIANTS_EDIT=1 — e a razao, porque a valvula pede que se cite:
este invariante foi criado por mim no commit ANTERIOR desta mesma branch
(9f98784a), nao e invariante estabelecido do repo. Nao e "invariante incomodo
sendo silenciado": e um teste recem-escrito que a sabotagem provou nao medir
nada. Manda-lo para a inbox em vez de corrigi-lo deixaria um caso verde
permanentemente cego no repo.

DESCOBERTO PELA SABOTAGEM, nao por leitura: desfiz o conserto do audit no
baseline e o test:db seguiu VERDE, 470 passed. O caso usava `contacts`, que NAO
tem o trigger fn_audit_log_row — ele vive so nas seis tabelas de IA (ai_agents,
ai_agent_versions, ai_agent_runs, ai_provider_credentials, ai_routers,
ai_router_members). O caso passava por AUSENCIA de audit, nao por conserto.

Agora usa `ai_agents`, que e de onde o erro veio na producao (a org de fixture
tinha 18 agentes). E ha um controle positivo que reprova se a tabela escolhida
deixar de disparar audit — exatamente o furo que deixou isto passar.

Evidencia observada: com o conserto DESFEITO, 1 failed (era 470 passed antes da
correcao); com o conserto no lugar, test:db verde 471 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7

* fix(seeds): a travessia de credenciais perdia o dbUrl e derrubava o CI

O refactor de `scripts/lib/env-de-teste.ts` está certo no mérito — o motivo
escrito no cabeçalho dele é um defeito sério de verdade: cada seed lia
`.env.local` do disco ignorando `process.env`, e a suíte E2E semeava em PRODUÇÃO
mesmo com o ambiente de teste injetado.

Mas a travessia saiu incompleta. `CredenciaisSupabase` não declarava `dbUrl` e
nenhum dos dois ramos a devolvia, enquanto **13 seeds** já liam
`credenciais.dbUrl` para abrir `pg.Pool`.

POR QUE NENHUM GATE PEGOU: `tsconfig.json` tem `"exclude": ["scripts/**"]`, então
os seeds não são typechecked — ler propriedade que o tipo não declara não custa
nada ao compilador. (O `as Record<string, string>` no objeto `env` de cada seed
não é a causa; eu cheguei a achar que fosse, e é o `exclude` que decide.)

E O SINTOMA ESCOLHIA O AMBIENTE, que é o pior tipo de defeito para achar:

  local → cai no ramo do ARQUIVO, ou o shell já tem a variável → passa
  CI    → o workflow EXPORTA as credenciais, o ramo "ambiente" vence → undefined

Com `connectionString: undefined` o `pg` cai no default do libpq. No CI isso é
`ECONNREFUSED 127.0.0.1:5432` — o Supabase local publica na 54322.

Medido nesta máquina, mesmo comando, com as credenciais exportadas no ambiente
(o que o CI faz):

  antes  → [seed-e2e-escalacao] falhou: database "rafaelmelgaco" does not exist
  depois → [seed-e2e-escalacao] escrevendo em LOCAL: http://127.0.0.1:54321
           (origem: ambiente) … travas ligadas

O erro local difere do erro do CI porque esta máquina tem um Postgres local —
mesma causa (connection string indefinida), superfície diferente.

O ramo "ambiente" cai no `.env.local` antes de desistir: quem exporta URL +
service role não necessariamente exporta a conexão direta. Isso não contradiz a
regra da função — `process.env` continua vencendo quando existe, e há um caso de
teste afirmando exatamente isso.

O teste vive em `tests/` e não em `scripts/` de propósito: importar o helper de
lá é o que o traz para dentro do `tsconfig`, que é a razão de fundo de o defeito
ter passado. Sabotado nos dois ramos: zerar o do ambiente reprova 3 casos, o do
arquivo reprova 1.

typecheck limpo, lint sem erros, 2927 testes unitários verdes.

* fix(e2e): o CI cria o .env.e2e que a suíte passou a exigir

O PR protegeu a suíte contra escrever em produção — `playwright.config.ts`
exige um `.env.e2e` e falha alto quando falta. A proteção está certa; o que
saiu incompleto foi a outra ponta: `.github/workflows/e2e.yml` não foi
tocado. O job seguiu rodando `pnpm build` e chamando a suíte sem gerar nada,
e o e2e morreu em `Falta o .env.e2e` depois de 3m48s.

Três consertos, todos medidos:

1. O workflow gera o `.env.e2e` (`pnpm e2e:env`) depois de o stack local
   subir, e builda com `pnpm e2e:build` — não `pnpm build`. As três
   `NEXT_PUBLIC_*` são embutidas no BUNDLE durante o build: buildar com um
   env e trocar só no `next start` deixaria a URL errada dentro do
   JavaScript do browser. Medido: `==> OK (controle): o host local
   (127.0.0.1:54321) ESTÁ no bundle`.

2. `gerar-env-e2e.sh` usa o `supabase` do PATH quando existe (é o que o CI
   instala via supabase/setup-cli) e cai no `npx` só na máquina do dev. A
   CLI não é dependência deste projeto — insistir no `npx` custaria um
   download do registry em cada uma das três chamadas.

3. O heredoc que escreve o `.env.e2e` não é quotado, e um comentário dentro
   dele tem crases. O shell tentou EXECUTAR o que estava entre elas:

     scripts/gerar-env-e2e.sh: linha 79: credential_decrypt_failed:
     comando não encontrado

   Saía em stderr e o arquivo gerado perdia exatamente o identificador que
   o comentário existe para nomear (`# agente morria em , o que aparecia`).
   Varri o heredoc inteiro: as crases eram a única expansão indesejada, as
   `$VAR` são todas de propósito.

A guarda nova é sobre a ORDEM, não a presença: `tests/unit/
e2e-workflow-honra-o-env.test.ts` casa quem gera o arquivo contra quem o
consome e exige que o índice do primeiro seja menor. Sabotei as quatro
propriedades uma a uma — passo removido, ordem invertida, `pnpm build` de
volta, e a exigência sumindo do playwright.config (guarda de vacuidade) —
e cada uma vermelha o caso certo.

gov:verify: 0 erros de lint, 286 arquivos / 2931 testes (2927 + 4).
e2e:build: exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HstH7nNmrTvCtZsasppeHj

* fix(e2e): uma fonte de ambiente, em vez de três redações do mesmo segredo

O conserto anterior destravou a suíte (27 specs passaram, contra zero). As 8
que sobraram vermelhas — 6 de system-update no `heartbeat`, o drain do
anti-SSRF e o invite-lifecycle — eram todas 401 em rota interna, e a causa
era a mesma: `INTERNAL_SECRET` estava declarado em TRÊS lugares com valores
diferentes.

  .env.e2e              e2e-placeholder-nao-e-segredo   → o servidor
  env: dos 2 passos     ci-placeholder-nao-e-segredo    → o processo de teste
  .env.local do seed    ci-placeholder-nao-e-segredo    → os seeds

MEDIDO (Playwright 1.5x, webServer que imprime o que recebeu):

  var só no process.env       → CHEGA (mescla, não substitui)
  var nos dois, valores dif.  → vence a do `env:` do config

Ou seja: o servidor pegava a do arquivo, o teste mandava a do passo, e
igualar as strings à mão só adiaria a próxima divergência. O conserto é
fonte única — o workflow publica o `.env.e2e` inteiro no `$GITHUB_ENV` e o
`.env.local` do seed passa a ser `cp .env.e2e`. Zero segredos redigitados no
workflow (era 24 linhas).

De brinde, o processo de teste passa a ver as chaves de CIFRA de verdade (32
bytes, geradas pelo script) em vez do rótulo de 21 caracteres que o bloco
`env:` fixava — o mesmo que o comentário do script já registrava como causa
de `credential_decrypt_failed`.

Dois comentários que a medição desmentiu, corrigidos junto:

- `playwright.config.ts` avisava "NÃO declare `env:` aqui sem espalhar
  `process.env` junto ... declarar substitui o ambiente inteiro" — e a linha
  logo acima declarava `env: envDoE2E()`. O arquivo se contradizia, e a
  afirmação é falsa: o Playwright mescla. Trocado pela tabela medida.
- `gerar-env-e2e.sh` prometia "valores iguais aos do CI" e eles não eram
  iguais. É a frase que fazia a divergência parecer impossível.

A guarda ganhou as duas propriedades novas, sabotadas uma a uma:
redigitar `INTERNAL_SECRET` num passo → vermelho; parar de publicar o
`.env.e2e` (que faria o caso anterior passar por ausência) → vermelho.

gov:verify: 0 erros de lint, 286 arquivos / 2933 testes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HstH7nNmrTvCtZsasppeHj

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 08:23:57 -03:00

580 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Seed de demonstração do CRM Vivo (Wave 0) — board determinístico em estados
* variados. Sem isto, toda verificação visual passa num board vazio e não prova nada.
*
* Cria um pipeline PRÓPRIO ("CRM Vivo — Clínica") em vez de sujar o "Pedidos" da org
* de teste, por três motivos:
* 1. determinismo — "Pedidos" acumulou dezenas de leads órfãos de e2e antigos; um
* board poluído não prova "altura constante" nem passa no teste do metro;
* 2. `expected_duration_hours` — nenhum estágio de "Pedidos" tem o campo preenchido,
* e o estado "Esfriando" (CORE 5) depende dele;
* 3. promessa multi-nicho — estágios de clínica exercitam o mapa configurável
* enum→estágio que a ponte (Wave 8) precisa provar.
*
* Idempotente: upsert por (organization_id, title). Rodar N vezes dá o mesmo board.
* Incremental: ganha um bloco a cada wave que adiciona coluna (owner_agent_id na
* Wave 1, ai_probability na Wave 5, …). Aqui só entra o que o schema de hoje aceita.
*
* Depende de .e2e-creds.json (rode scripts/seed-e2e-credentials.ts antes).
* Não colide com scripts/seed-e2e-kanban.ts — títulos e pipeline são outros, e
* kanban-owner-filter.spec.ts continua enxergando os leads dele.
*
* Run: npx tsx scripts/seed-crm-vivo.ts
*/
import { createClient } from "@supabase/supabase-js";
import * as fs from "node:fs";
import * as path from "node:path";
import { carregarEnvLocal } from "../scripts/lib/env-de-teste";
const env = carregarEnvLocal();
const SUPABASE_URL = env.NEXT_PUBLIC_SUPABASE_URL!;
const SERVICE_ROLE = env.SUPABASE_SERVICE_ROLE_KEY!;
if (!SUPABASE_URL || !SERVICE_ROLE) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY in .env.local");
}
const admin = createClient(SUPABASE_URL, SERVICE_ROLE, {
auth: { autoRefreshToken: false, persistSession: false },
});
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
const PIPELINE_SLUG = "crm-vivo-clinica";
const HOUR = 3_600_000;
interface Creds {
org_id: string;
users: Record<string, { id: string }>;
crm_vivo?: unknown;
}
/** Estágios da clínica. `expected_duration_hours` é o prazo que o CORE 5 vigia. */
const STAGES = [
{ slug: "primeiro_contato", name: "Primeiro contato", position: 1000, hours: 24 },
{ slug: "avaliacao", name: "Avaliação", position: 2000, hours: 48 },
{ slug: "proposta", name: "Proposta enviada", position: 3000, hours: 72 },
{ slug: "negociacao", name: "Negociação", position: 4000, hours: 96 },
{ slug: "fechado", name: "Tratamento fechado", position: 5000, hours: null, isWon: true },
{ slug: "perdido", name: "Perdido", position: 6000, hours: null, isLost: true },
] as const;
const LONG_TITLE =
"Clínica Vitalis — pacote completo de implantes dentários com enxerto ósseo, " +
"protocolo superior e acompanhamento de 24 meses";
/**
* O board de demonstração. Cada linha existe para provar um caso da régua visual
* (§5 do briefing) — nenhuma é decorativa.
*/
const LEADS = [
{
key: "dono_humano",
title: "Clínica Vitalis — implantes",
stage: "negociacao",
owner: "manager" as const,
valueCents: 1_240_000,
tags: ["implante"],
staleHours: 2,
why: "dono humano, valor cheio, dentro do prazo — o card 'normal'",
},
{
key: "sem_dono",
title: "Marina Costa — clareamento",
stage: "primeiro_contato",
owner: null,
valueCents: 180_000,
tags: [],
staleHours: 3,
why: "sem responsável — prova o estado vazio do slot de dono",
},
{
key: "valor_nulo",
title: "Rogério Paiva — avaliação inicial",
stage: "avaliacao",
// Wave 1 (0070): dono AGENTE. Resolvido por nome, nunca por uuid fixo —
// um clone tem outros ids. Se o agente não existir, cai para sem dono.
agentOwner: "Lia — AgendaPlus",
owner: null,
valueCents: null,
tags: ["primeira-consulta"],
staleHours: 5,
why: "valor nulo + dono agente — faixa ② não pode virar 'R$ NaN'; avatar vazado com anel",
},
{
key: "titulo_longo",
title: LONG_TITLE,
stage: "proposta",
owner: "manager" as const,
valueCents: 3_890_000,
tags: ["implante", "enxerto"],
staleHours: 8,
why: "título de 120+ caracteres — altura constante sob texto longo",
},
{
key: "muitas_tags",
title: "Família Andrade — 4 tratamentos",
stage: "avaliacao",
owner: "agent" as const,
valueCents: 2_150_000,
tags: [
"implante",
"ortodontia",
"clareamento",
"canal",
"protese",
"familia",
"convenio",
"indicacao",
],
staleHours: 12,
why: "8 tags — provam que tags saíram do card (§5) sem quebrar layout",
},
{
key: "esfriando",
title: "Bruno Tavares — protocolo superior",
stage: "proposta",
owner: "manager" as const,
valueCents: 4_500_000,
tags: ["protocolo"],
staleHours: 150, // estágio "proposta" espera 72h → estourado por 2x
why: "estourou expected_duration_hours do estágio — estado Esfriando (CORE 5)",
},
{
key: "esfriando_critico",
title: "Helena Marques — ortodontia adulto",
stage: "negociacao",
owner: null,
valueCents: 960_000,
tags: ["ortodontia"],
staleHours: 480, // 20 dias
why: "frio há 20 dias E sem dono — o pior caso, tem que gritar no board",
},
{
key: "fresco_alto_valor",
title: "Grupo Odonto Sul — contrato corporativo",
stage: "negociacao",
owner: "admin" as const,
valueCents: 12_400_000,
tags: ["corporativo"],
staleHours: 1,
why: "maior valor do board — tabular-nums alinhado com os demais",
},
{
key: "primeiro_contato_novo",
title: "Caio Ribeiro — dor de dente",
stage: "primeiro_contato",
agentOwner: "Bot Padrão E2E",
owner: null,
valueCents: 45_000,
tags: ["urgencia"],
staleHours: 1,
why: "menor valor + segundo dono agente, em outra coluna — prova o filtro por agente",
},
{
key: "avaliacao_media",
title: "Patrícia Nunes — prótese fixa",
stage: "avaliacao",
owner: "manager" as const,
valueCents: 780_000,
tags: ["protese"],
staleHours: 60, // estágio "avaliacao" espera 48h → levemente estourado
why: "estouro leve do prazo — a fronteira do Esfriando, não o caso extremo",
},
{
// O caso que faltava e é o mais importante do CORE 5: o agente É O DONO e o
// lead ESFRIOU MESMO ASSIM. Não é "ninguém está olhando" — é "quem está
// olhando não conseguiu destravar". É aqui que o humano precisa decidir se
// assume, e é o único estado em que o Radar tem de mostrar um dono-agente.
key: "agente_esfriando",
title: "Sônia Vasconcelos — implante unitário",
stage: "proposta", // janela de 72h
agentOwner: "Lia — AgendaPlus",
owner: null,
valueCents: 690_000,
tags: ["implante"],
staleHours: 200, // ~8 dias: estoura a janela de 72h com folga
why: "dono agente E esfriando — o humano decide se assume; o Radar tem de nomear o agente",
},
{
// O ESPÉCIME. Este contato existe de verdade no banco e o agente já
// conversou 33 turnos com ele (lead_checkpoints) e decidiu 87 vezes se
// enviava (before_send_traces), com lead_state.stage='qualified' — e o CRM
// NÃO tinha negócio nenhum para ele. É a doença dos dois funis paralelos em
// estado puro. Ligar um crm_lead a este contato é o que dá à Wave 3 um caso
// com histórico REAL para traduzir contato→negócio, em vez de dado sintético.
key: "ponte_real",
title: "Carlos — Clínica Vida Odonto · manutenção de protocolo",
stage: "negociacao",
contactRef: "Carlos — Clínica Vida Odonto",
agentOwner: "Lia — AgendaPlus",
owner: null,
valueCents: 2_800_000,
tags: ["protocolo", "manutencao"],
staleHours: 30,
why: "contato com histórico real do harness (33 checkpoints, 87 traces) — o caso da ponte",
},
] as const;
async function ensurePipeline(orgId: string): Promise<string> {
const { data: existing } = await admin
.from("crm_pipelines")
.select("id")
.eq("organization_id", orgId)
.eq("slug", PIPELINE_SLUG)
.maybeSingle();
const vocabulary = {
lead: "Paciente",
lead_plural: "Pacientes",
deal: "Tratamento",
deal_plural: "Tratamentos",
won: "Fechado",
lost: "Perdido",
stage: "Etapa",
stage_plural: "Etapas",
};
// canonical_tags: a UMA tag que sobrevive no card como ponto de 6px (§5).
const settings = {
fields: [],
canonical_tags: ["implante"],
lost_reasons: [],
identity_resolution: { fields_in_priority_order: ["cpf", "phone_e164", "email"] },
};
if (existing) {
const id = (existing as { id: string }).id;
await admin
.from("crm_pipelines")
.update({ vocabulary, settings } as never)
.eq("id", id);
console.log(`[seed] pipeline existente: ${id}`);
return id;
}
const { data, error } = await admin
.from("crm_pipelines")
.insert({
organization_id: orgId,
name: "CRM Vivo — Clínica",
slug: PIPELINE_SLUG,
description: "Board de demonstração do CRM Vivo. Determinístico e re-executável.",
is_default: false,
position: 9000,
vocabulary,
settings,
} as never)
.select("id")
.single();
if (error || !data) throw new Error(`insert pipeline: ${error?.message}`);
const id = (data as { id: string }).id;
console.log(`[seed] pipeline criado: ${id}`);
return id;
}
async function ensureStages(
orgId: string,
pipelineId: string,
): Promise<Record<string, string>> {
const ids: Record<string, string> = {};
for (const s of STAGES) {
const row = {
organization_id: orgId,
pipeline_id: pipelineId,
name: s.name,
slug: s.slug,
position: s.position,
expected_duration_hours: s.hours,
is_won: "isWon" in s ? s.isWon : false,
is_lost: "isLost" in s ? s.isLost : false,
is_archived: false,
};
const { data: existing } = await admin
.from("crm_stages")
.select("id")
.eq("pipeline_id", pipelineId)
.eq("slug", s.slug)
.maybeSingle();
if (existing) {
const id = (existing as { id: string }).id;
await admin.from("crm_stages").update(row as never).eq("id", id);
ids[s.slug] = id;
continue;
}
const { data, error } = await admin
.from("crm_stages")
.insert(row as never)
.select("id")
.single();
if (error || !data) throw new Error(`insert stage ${s.slug}: ${error?.message}`);
ids[s.slug] = (data as { id: string }).id;
}
console.log(`[seed] ${STAGES.length} estágios garantidos`);
return ids;
}
/**
* Agentes resolvidos por NOME, nunca por uuid fixo: um clone tem outros ids.
* Devolve mapa nome→id só dos que existirem — o seed continua válido numa org
* sem nenhum agente (os leads correspondentes ficam sem dono).
*/
async function resolveAgents(orgId: string, names: string[]): Promise<Map<string, string>> {
const found = new Map<string, string>();
if (names.length === 0) return found;
const { data, error } = await admin
.from("ai_agents")
.select("id,name")
.eq("organization_id", orgId)
.in("name", names);
if (error) throw new Error(`resolver agentes: ${error.message}`);
for (const a of (data ?? []) as { id: string; name: string }[]) found.set(a.name, a.id);
for (const n of names) {
if (!found.has(n)) {
console.warn(`[seed] agente "${n}" não existe nesta org — lead ficará sem dono`);
}
}
return found;
}
/**
* Contatos resolvidos por NOME — mesma razão dos agentes: um clone tem outros
* ids. Um contato que o harness já conhece (tem `lead_state`/`lead_checkpoints`)
* é o que dá à ponte um caso com histórico real em vez de dado sintético.
*/
async function resolveContacts(orgId: string, names: string[]): Promise<Map<string, string>> {
const found = new Map<string, string>();
if (names.length === 0) return found;
const { data, error } = await admin
.from("contacts")
.select("id,name")
.eq("organization_id", orgId)
.in("name", names);
if (error) throw new Error(`resolver contatos: ${error.message}`);
for (const c of (data ?? []) as { id: string; name: string }[]) found.set(c.name, c.id);
for (const n of names) {
if (!found.has(n)) console.warn(`[seed] contato "${n}" não existe — lead ficará sem contato`);
}
return found;
}
async function upsertLead(
orgId: string,
pipelineId: string,
stageIds: Record<string, string>,
spec: (typeof LEADS)[number],
ownerIds: Record<string, string>,
agentIds: Map<string, string>,
contactIds: Map<string, string>,
position: number,
): Promise<string> {
const stageId = stageIds[spec.stage];
if (!stageId) throw new Error(`estágio desconhecido: ${spec.stage}`);
const lastActivityAt = new Date(Date.now() - spec.staleHours * HOUR).toISOString();
const agentName = "agentOwner" in spec ? spec.agentOwner : undefined;
const ownerAgentId = agentName ? (agentIds.get(agentName) ?? null) : null;
// A constraint crm_leads_owner_kind_coherence exige exatamente UM dono.
const ownerUserId = ownerAgentId ? null : spec.owner ? ownerIds[spec.owner]! : null;
const ownerKind = ownerAgentId ? "ai" : ownerUserId ? "user" : null;
const contactRef = "contactRef" in spec ? spec.contactRef : undefined;
const contactId = contactRef ? (contactIds.get(contactRef) ?? null) : null;
const mutable = {
stage_id: stageId,
contact_id: contactId,
owner_user_id: ownerUserId,
owner_agent_id: ownerAgentId,
owner_kind: ownerKind,
assigned_at: ownerKind ? lastActivityAt : null,
value_cents: spec.valueCents,
currency: spec.valueCents === null ? null : "BRL",
tags: spec.tags,
last_activity_at: lastActivityAt,
position_in_stage: position,
description: spec.why,
};
const { data: existing } = await admin
.from("crm_leads")
.select("id")
.eq("organization_id", orgId)
.eq("pipeline_id", pipelineId)
.eq("title", spec.title)
.maybeSingle();
if (existing) {
const id = (existing as { id: string }).id;
const { error } = await admin.from("crm_leads").update(mutable as never).eq("id", id);
if (error) throw new Error(`update lead "${spec.key}": ${error.message}`);
console.log(`[seed] lead atualizado ${spec.key}: ${id}`);
return id;
}
const { data, error } = await admin
.from("crm_leads")
.insert({
organization_id: orgId,
pipeline_id: pipelineId,
title: spec.title,
status: "open",
source: "manual",
...mutable,
} as never)
.select("id")
.single();
if (error || !data) throw new Error(`insert lead "${spec.key}": ${error?.message}`);
const id = (data as { id: string }).id;
console.log(`[seed] lead criado ${spec.key}: ${id}`);
return id;
}
/**
* Garante que o board da demo TENHA o que a Wave 4 mostra.
*
* O `@QAVivo` mediu a interseção "contato com `next_action`" × "negócio aberto" e
* ela oscilou 0 → 1 → 1 → 0 entre rodadas: os testes movem e fecham leads, e o
* caso da demo evaporava. **Feature que passa no teste e some do board não está
* entregue** — quem abrir o Kanban precisa ver a próxima ação, não só o CI.
*
* REGRA: só preenche quando está VAZIO. Nunca sobrescreve o que o agente
* realmente decidiu — o Carlos tem 33 turnos e 87 decisões de envio reais, e
* apagar a proposta dele para pôr uma de semente seria trocar dado por cenário,
* que é o oposto do que esta entrega defende.
*/
async function garantirProximaAcao(
orgId: string,
contactIds: Map<string, string>,
): Promise<void> {
const PROPOSTAS: Record<string, string> = {
"Carlos — Clínica Vida Odonto":
"Confirmar a data da manutenção do protocolo e enviar o orçamento revisado",
"P A N A R O": "Retomar contato — sem resposta desde a última proposta",
};
let postos = 0;
const naoResolvidos: string[] = [];
for (const [nome, acao] of Object.entries(PROPOSTAS)) {
const contactId = contactIds.get(nome) ?? (await resolverPorExibicao(orgId, nome));
if (!contactId) {
// GRITA. A versão anterior fazia `continue` calado, e o @QAVivo achou:
// o alvo "Sônia Vasconcelos" não existe como contato (o lead dela está
// com contact_id nulo), então metade desta garantia era INERTE — e o
// board parecia protegido com 3 propostas quando só UMA era garantida.
// A função vizinha (`resolveContacts`) avisa quando um alvo não existe;
// este laço pulava em silêncio, no arquivo ao lado do que faz certo.
naoResolvidos.push(nome);
continue;
}
const { data: atual } = await admin
.from("lead_state")
.select("id,next_action")
.eq("organization_id", orgId)
.eq("contact_id", contactId)
.maybeSingle();
const jaTem = typeof atual?.next_action === "string" && atual.next_action.trim() !== "";
if (jaTem) continue;
const { error } = atual
? await admin.from("lead_state").update({ next_action: acao }).eq("id", atual.id)
: await admin
.from("lead_state")
.insert({ organization_id: orgId, contact_id: contactId, next_action: acao });
if (error) throw new Error(`próxima ação de ${nome}: ${error.message}`);
postos += 1;
}
if (postos > 0) console.log(` próxima ação semeada em ${postos} contato(s) que estavam sem`);
if (naoResolvidos.length > 0) {
console.warn(
` ⚠ próxima ação NÃO garantida para: ${naoResolvidos.join(", ")} — ` +
`contato inexistente na org. O board pode mostrar a proposta destes por dado VIVO do ` +
`agente, que o seed não protege: se sair, a demo encolhe sem ninguém saber.`,
);
}
}
/**
* Segunda tentativa de resolver o alvo: por `display_name`.
*
* Contato do WhatsApp costuma chegar com `name` nulo e só o `display_name`
* preenchido — foi o caso do "P A N A R O Especialista", que tem proposta viva
* do agente e ficava fora da garantia por causa da coluna consultada.
*/
async function resolverPorExibicao(orgId: string, nome: string): Promise<string | null> {
const { data } = await admin
.from("contacts")
.select("id")
.eq("organization_id", orgId)
.ilike("display_name", `%${nome}%`)
.limit(1)
.maybeSingle();
return data?.id ?? null;
}
async function main(): Promise<void> {
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
const orgId = creds.org_id;
const ownerIds: Record<string, string> = {
admin: creds.users.admin!.id,
manager: creds.users.manager!.id,
agent: creds.users.agent!.id,
};
const pipelineId = await ensurePipeline(orgId);
const stageIds = await ensureStages(orgId, pipelineId);
const agentNames = [
...new Set(
LEADS.flatMap((l) => ("agentOwner" in l && l.agentOwner ? [l.agentOwner] : [])),
),
];
const agentIds = await resolveAgents(orgId, agentNames);
const contactNames = [
...new Set(
LEADS.flatMap((l) => ("contactRef" in l && l.contactRef ? [l.contactRef] : [])),
),
];
const contactIds = await resolveContacts(orgId, contactNames);
const leadIds: Record<string, string> = {};
let position = 1000;
for (const spec of LEADS) {
leadIds[spec.key] = await upsertLead(
orgId,
pipelineId,
stageIds,
spec,
ownerIds,
agentIds,
contactIds,
position,
);
position += 1000;
}
await garantirProximaAcao(orgId, contactIds);
creds.crm_vivo = {
pipeline_id: pipelineId,
pipeline_slug: PIPELINE_SLUG,
stage_ids: stageIds,
lead_ids: leadIds,
agent_ids: Object.fromEntries(agentIds),
};
fs.writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2));
console.log(
`\n✅ Seed CRM Vivo completo: ${LEADS.length} leads em ${STAGES.length} estágios.` +
`\n Board: /app/pipelines/${pipelineId}`,
);
}
main().catch((err) => {
console.error("❌ Seed CRM Vivo falhou:", err);
process.exit(1);
});