420 KiB
Changelog de contexto para agentes (fatias do AGENTS.md)
Conteúdo movido do AGENTS.md em 2026-07-30 (fatiamento do paradigma de agentes paralelos): "Known Gaps" e todos os blocos "Recently resolved". Histórico — leia só quando precisar do contexto de uma entrega passada; o sempre-presente fica no AGENTS.md. Desde OPS44 (2026-08-13) cada entrega registra UMA entrada em docs/changelog/<data>-<id>.md e o agregado é regenerado por pnpm changelog:build (insert-only — este arquivo nunca é editado na mão; guard de CI docs-guards e escape changelog-rewrite: documentados em AGENT-OPS).
OPS89 — variantes do provider Vercel AI Gateway movidas do repo para a config global do opencode (2026-08-24): fecha o ciclo aberto pela OPS78/OPS82: as variantes low/high/max dos dois DeepSeek V4 Flash (deepseek-v4-flash e -0731) saíram do opencode.json do repo e voltam a ser preferência de máquina no global ~/.config/opencode/opencode.jsonc (aplicação manual pelo dono — nada commitado; auth continua em ~/.local/share/opencode/auth.json). O repo fica mínimo: só $schema + mcp.penpot — o mesmo princípio "edit the owner, don't twin" da OPS82, agora para o próprio provider.vercel (o anti-goal original da OPS78 de não commitar config de opencode no repo prevalece). Config-only + prosa: -32 linhas de config; docblock de OPENCODE_PRESET_MODEL (scripts/lib/worktree.mjs) e prosa de worktree-next-issue/SKILL.md passam a apontar para a config global; a constante (model ID) e os pins de worktree.unit.spec.ts ficam intactos — o launch de pnpm worktree next não muda. No mesmo diff: "Forgejo" → "GitHub" na linha reescrita da skill (rastro stale pós-cutover); a varredura ampla do resíduo virou a OPS91 (#841). Verificação: pnpm gate:fast verde (256 arquivos/2443 testes), JSON estrito validado, grep sem ref viva a provider.vercel fora do docblock atualizado; aceite final do TUI condicionado ao global aplicado pelo dono (opencode debug config, --variant max, Ctrl+T).
OPS90 — skills e docs: codificar a pirâmide de testes unit→int→e2e-com-benefício (2026-08-24): meta-chore de guias de agente, sem código de produto/schema/CI. A ordem de preferência unit primeiro → int para fronteiras Payload/DB → e2e só com benefício real por fluxo ficou explícita nos donos certos: âncora canônica nova "This repo: the three layers" na skill test-driven-development (mapeia a pirâmide às camadas tests/unit|int|e2e com fixtures e aponta a política OPS72 por referência); linha das 3 camadas do codebase-map.mdc reescrita com a ordem; parágrafo "Level preference (pyramid)" em docs/TESTING.md; preferência de nível enunciada no §Executar da execution-pipeline.md (compartilhada por work-issue/agent-work-issue) e como bullet no Passo 4 do work-issue; revisão ganha o critério de nível em code-review-and-quality (Step 2 + checklist) e improve-code-quality (exit checklist); browser-testing-with-devtools rotula verificação em browser como caso legítimo de "e2e com benefício real". Política OPS72 intacta byte-a-byte (gate local discricionário, CI selected, full só no verify do deploy); sem % de cobertura, sem proibição de e2e, sem guard novo; ci-cd-and-automation/engineering-audit/AGENTS* intocados (sem lacuna real). Débitos: 6 achados de simplify absorvidos no diff; contradição stale do tracker Forgejo já coberta pela Issue #841.
OPS83 — desbloquear deploy: estabilizar os e2e do verify sem reduzir cobertura; AgendaFeed volta a rodar em prod (2026-08-24): o deploy manual (deploy.yml) estava bloqueado pelo job verify (run #15: 1 failed + 7 flaky + 2 skipped + 1 did-not-run). Causas estudadas e corrigidas por reescrita de specs (settle/poll/hermeticidade/escopo de locator) — nenhum test.flaky, nenhuma remoção de cobertura, nenhuma mudança de workers/retries, zero mudança de app/contrato/acesso: (1) kill switch da home — o poll de server-HTML convergia enquanto a seção renderizada ainda mostrava o link do Instagram; passou a pollar o DOM rendered escopado a [data-home-section="contents"] (expectSectionCardCount, usado também no sibling de fallback IG); (2) section-hidden — o revalidate tag=posts era engolido por .catch; agora o POST é assertado e a convergência ganha orçamento explícito (não bump cego); (3) SavedFilters strict-mode — o link 'Municípios' agora é escopado ao container [data-slot="sidebar"] (2º match = bottom nav, #669 absorvida); (4) People — gate S: + settle do stream antes de asserir rows (testes 15, pós-search e C118); (5) Municípios:642 — poll DOM da liderança específica em vez de .first() no stream; (6) Municípios:386 B176 — settleMunicipalityStream entre remove-chip e o próximo clique de opção; (7) Newsletter:222 — toHaveURL(/#novidades/) sem $ (o ?e2e=scroll-… é legítimo); (8) DemandVisibility:62 — .goto com cache-buster para a asserção de 404 do peer (a cache RSC servia a demanda acessível após a troca de cookie). AgendaFeed (Decision B): os 2 test.skip(isProdMode) foram removidos — o build e2e agora inlina NEXT_PUBLIC_SITE_URL=https://feed.e2e.teqo.test (.test TLD, passa o fail-closed requireProductionDNSOrigin), o diálogo gera o link canônico e os testes asserem o path e buscam via campaign.baseURL + path; confirmado que share usa window.location.origin, webauthn é desacoplado via request-host e webhooks usam campaign.baseURL — nenhum outro e2e depende do origin. Escopo config: playwright.config.ts (default do webServer env em prod), deploy.yml e ci-pr.yml (env do build-for-e2e + do passo de testes). Verificação: specs afetadas verdes em dev mode (2×) e AgendaFeed verde em E2E_PROD=1; pnpm lint/tsc/format:check/knip/check:cycles/test:unit (2443) verdes; PR em main com auto-merge.
OPS79 — última migração de dados em produção: plataforma antiga → nova (vertical campanha) (2026-08-23): com a fonte congelada pelo OPS80 (zero escritas no Neon desde 2026-08-23 22:30 UTC), reconciliação e migração do residual. Descoberta-chave: a vertical campanha JÁ estava 100% íntegra no teo_1313 (aceite do OPS51 confirmado por reconciliação repetível — diff 0 em contagens e junta de IDs das 13 coleções; municipality_rels só 3 links de edição de portfólio pós-OPS51, absorvidos como não-dado). O único residual real entre Neon e target era a signature pública 1486 (petition fim-escala-6x1) com seu contato + subscription, cujo contato originário (Juares) não existia no target (o id 2221 do target é outra pessoa). Ferramentas: scripts/reconcile-campaign-vertical.mjs (read-only, 19/19 PASS, nunca imprime PII) + scripts/migrate-signature-orphan.mjs (dry-run/apply, guard OPS79_MIGRATE_CONFIRM=1, transação única, PII mascarada nos logs). Execução 100% no homeserver: aplicado contact novo 2225 + signature 1486 preservada + subscription 1492 + sequences ajustadas; pós-verificação signature 1485=1485, reconcile 19/19, smoke / /campanha /admin 200. Runbook (rollback = DELETE das 3 linhas + setval) em docs/ops/teqo-1313-deploy.md §OPS79. Sem migration de schema, sem access/Consent novo, sem UI. Closes #797.
E2E-FULL-FLAKE-C142MUN — endurecer write-chrome assertions sob 4 workers (2026-08-23): as 3 asserts de campaignPermissionProfile.e2e.spec.ts:85 (carteira FAB visível), :101 (tudo FAB visível) e campaignMunicipalities.e2e.spec.ts:115 (checkbox de advisor no /editar) falhavam consistentemente no deploy.yml → verify (suíte full, 4 workers, prod build) por latência de render/hydration do shell streamed sob carga — a sonda SSR prova que o FAB está correto no HTML; o falha é que networkidle finda antes do chunk S: commitar, e a assert toBeVisible/.check() usa o budget default que é insuficiente no runner hosted. Fix: adicionar page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) (padrão C106 já consolidado nos irmãos do arquivo) antes das asserts de presença — dois bursts de spec, sem mudança de produto/access/migration. Verificado: gate:fast (2439 unit tests, all green); e2e das 2 specs isoladas + repeat-each=3 em 4 workers: 21/21 passed. Sem alteração de access, sem migration, sem redução de workers do full.
OPS82 — default de launch do worktree opencode → DeepSeek V4 Flash 0731 (mais barato) + variantes expostas + limpeza duplicatas Cheapest Inference (2026-08-23): continuação da OPS78: o launch de pnpm worktree next/plan/new passou do preset vercel/deepseek/deepseek-v4-flash (input $0.13/M) para vercel/deepseek/deepseek-v4-flash-0731 (input $0.076/M — mesma família, razão forte, ~41% mais barato). Mudança config-only (mecanismo OPS26 "trocar modelo = editar a constante"): OPENCODE_PRESET_MODEL em scripts/lib/worktree.mjs → deepseek-v4-flash-0731; o repo opencode.json ganhou o provider.vercel.models["deepseek/deepseek-v4-flash-0731"].variants com low/high/max (espelha o sibling OPS78) — o catálogo do opencode traz esse model key com reasoning_options vazio, então o override é o que torna as variantes selecionáveis no TUI. No mesmo repo opencode.json, removido o bloco provider.cheapest-inference (com hífen) inteiro — duplicata do global ~/.config/opencode/opencode.jsonc, que já define cheapestinception (sem hífen) com os mesmos deepseek-v4-flash + mimo-v2.5 e com variantes low/high/max; agora o TUI mostra um único DeepSeek e um único MiMo no Cheapest Inference. 8 literais + pin da constante em tests/unit/worktree.unit.spec.ts atualizados; textos em scripts/worktree.mjs (docblock/help — o help agora interpola OPENCODE_PRESET_MODEL, importado), .agents/shell/worktree.sh e worktree-next-issue/SKILL.md sincronizados; o help deixou de duplicar o literal. Anti-goals preservados: cardápio de execução de Issues intocado (work-issue.md/plan-issue.md + pin opencodeCommands.unit.spec.ts seguem com o provider direto), config global do humano intocada, docs históricos congelados, usos diretos deepSeek('deepseek-v4-flash') no app (Sollinha/campaignDemandTitle.ts) intactos. Verificação: pnpm gate:fast verde; opencode debug config confirma variantes do 0731 ativas e cheapest-inference do repo ausente; PR em main com auto-merge.
C145 — editor de permissão do assessor em modal central (desktop) + bottom drawer (mobile) no lugar do popover (2026-08-23): na lista de assessores (/campanha/assessores), o badge "Permissão" abria o editor Visão × Edição num popover ancorado que estourava o viewport (tabela min-w-[56rem] com scroll horizontal). Troca do invólucro contida em AdvisorPermissionBadge.tsx (ponto único dos dois usos — linha de dados com advisorId+formAction e linha de rascunho com onDraftChange local): o badge agora abre um Dialog central no desktop e um bottom drawer no celular, selecionados por useNarrowMeasured(640) vivendo no próprio badge (não na tabela). O AdvisorPermissionEditor entra com props idênticas às de hoje — mesmos combos Visão/Edição (advisor-permission-visibility/advisor-permission-editing), nota, footer Cancelar/Salvar e regras de coerência intactos; onSaved/onCancel fecham o overlay e o badge reflete o novo perfil; a linha de rascunho segue via onDraftChange local, sem escrita nova no servidor. A11y: DialogTitle/DrawerTitle sr-only "Permissão da conta" cumprem o Radix sem duplicar o heading visível do editor; guards de altura (max-h-[calc(100dvh-2rem)]+scroll no dialog, capa do drawer) garantem o "nunca estoura" do aceite. Editor embutido no detalhe assessores/[id]/page.tsx intocado; import de Popover removido. Débito adiado com gatilho: o padrão "Dialog/Drawer keyed em isNarrow" se repete em >3 overlays — extrair primitivo ResponsiveSheet só quando uma 3ª superfície precisar do mesmo wrapper. Verificação: lint/typecheck/unit + int 830 verdes; gate:push na entrega; PR em main com auto-merge.
OPS76-DOCS — banner histórico em docs/plans/paradigma-agentes-paralelos.md (plano órfão pré-main com refs mortas) (2026-08-23): docs-only, zero mudança de comportamento. Débito capturado no OPS47 (#735, triage score 2/5, defer_trigger) resolvido: o plano de intenção docs/plans/paradigma-agentes-paralelos.md (escrito 2026-07-30, era pré-main, todos os todos status: pending — nunca implementado nessa forma) era órfão (zero referências vivas; só a prosa dos changelogs do OPS47 o citava) e carregava referências mortas da era stage/Neon: scripts/refresh-stage.mjs (deletado 2026-08-01), Neon/stage (STAGE_DATABASE_URL, branch git stage, ci-stage.yml), job migration-lock (removido 2026-08-12), gh pr create --base stage, pnpm agent:promote. Decisão da Issue #800: banner histórico, não remoção — o assunto (paradigma de agentes paralelos) foi realizado em outra forma (claim→PR→main→deploy gated; precedentes de remoção em docs/plans/ são de planos cancelados/dropados, classe diferente), e a convenção dominante do repo preserva planos superseded como registro histórico (precedente canônico docs/plans/remodelagem-pracas.md:3). Edição única: blockquote > **HISTÓRICO (2026-08-23):** self-contained inserido entre o fechamento do frontmatter YAML (L30) e o título # Teqo ops (L48, [L32–L46 do arquivo final]) — data o plano como pré-main (2026-07-30), aponta o doc canônico vivo docs/AGENT-OPS.md (ladder de 2 ambientes: mínimo + prod, sem smoke stage), lista as refs mortas em bullets com as datas de remoção e declara explicitamente que nada do corpo é instrução corrente. Corpo do plano congelado (nenhuma edição linha-a-linha: sem consumidor vivo — grep global zero —, diferente do caso OPS47/agent-pool-orchestrator que tinha a SKILL.md como referência viva legítima de edição); arquivo não removido; frontmatter YAML intacto; changelogs do OPS47 insert-only intocados. Sem migration/access/Consent/UI/schema. Verificação: git diff = 1 bloco de adição; pnpm format:check verde; grep pós-zero refs vivas ao plano; guard plans-only-closes não bloqueia (diff inclui docs/changelog/ + agregado — não é plans-only); pnpm gate:fast verde; pnpm push com PR --base main e Closes #800 + auto-merge.
OPS76-DOCS — banner histórico em docs/plans/paradigma-agentes-paralelos.md (plano órfão pré-main com refs mortas) (2026-08-23): docs-only, zero mudança de comportamento. Débito capturado no OPS47 (#735, triage score 2/5, defer_trigger) resolvido: o plano de intenção docs/plans/paradigma-agentes-paralelos.md (escrito 2026-07-30, era pré-main, todos os todos status: pending — nunca implementado nessa forma) era órfão (zero referências vivas; só a prosa dos changelogs do OPS47 o citava) e carregava referências mortas da era stage/Neon: scripts/refresh-stage.mjs (deletado 2026-08-01), Neon/stage (STAGE_DATABASE_URL, branch git stage, ci-stage.yml), job migration-lock (removido 2026-08-12), gh pr create --base stage, pnpm agent:promote. Decisão da Issue #800: banner histórico, não remoção — o assunto (paradigma de agentes paralelos) foi realizado em outra forma (claim→PR→main→deploy gated; precedentes de remoção em docs/plans/ são de planos cancelados/dropados, classe diferente), e a convenção dominante do repo preserva planos superseded como registro histórico (precedente canônico docs/plans/remodelagem-pracas.md:3). Edição única de ~9 linhas: blockquote > **HISTÓRICO (2026-08-23):** self-contained inserido entre o fechamento do frontmatter YAML (L30) e o título # Teqo ops (L32) — data o plano como pré-main (2026-07-30), aponta o doc canônico vivo docs/AGENT-OPS.md (ladder de 2 ambientes: mínimo + prod, sem smoke stage), lista as refs mortas com as datas de remoção e declara explicitamente que nada do corpo é instrução corrente. Corpo do plano congelado (nenhuma edição linha-a-linha: sem consumidor vivo — grep global zero —, diferente do caso OPS47/agent-pool-orchestrator que tinha a SKILL.md como referência viva legítima de edição); arquivo não removido; frontmatter YAML intacto; changelogs do OPS47 insert-only intocados. Sem migration/access/Consent/UI/schema. Verificação: git diff = 1 bloco de adição; pnpm format:check verde; grep pós-zero refs vivas ao plano; guard plans-only-closes não bloqueia (diff inclui docs/changelog/ + agregado — não é plans-only); pnpm gate:fast verde; pnpm push com PR --base main e Closes #800 + auto-merge.
E2E-ADMIN-LOGIN-LOCK — beforeEach UI login do admin ignora o advisory lock do usuário compartilhado (2026-08-23): o fix do #760 (adminHeaders com pg_advisory_lock(727001)) serializou os logins REST do usuário dev@payloadcms.com, mas o beforeEach de tests/e2e/admin.e2e.spec.ts seguia logando pela UI (form, helper login()) sem lock — mesma fila de sessões read-modify-write do Payload, aberta a colisão worker(UI) × worker(adminHeaders lock) → 403 no e2e full (4 workers). Fix: novo tests/helpers/advisoryLock.ts exporta ADMIN_LOGIN_LOCK_KEY = 727_001 e withAdvisoryLock (guarda assertTestDatabase, lock/unlock com finally defensivo); adminHeaders (adminApi.ts) e login() (login.ts) passam a compartilhar a mesma chave — UI e API serializam entre si — e os dois logins REST inline não-lockados do spec (social-feed seeder e sync) agora usam adminHeaders. Sem swap UI→API, sem serialização de projeto/workers, mudança de schema ou UI. Verificação: typecheck/lint/format verdes, e2e admin (5) + campaignHomePixel/campaignNewsletter (7) locais verdes; gate:push na entrega; PR em main com auto-merge. Débito registrado: #811 (shadow local de adminHeaders em frontend.e2e.spec.ts).
OPS80 — cutoff fase 1: desativar o acesso à URL antiga pt.jorgesolla.com.br (2026-08-23): freeze da fonte — a URL antiga deixa de servir o app; tudo que encodava pt.jorgesolla.com.br como verdade canônica é atualizado para jorgesolla1313.com.br. Operação: scripts/check-push-chain.mjs — default --site, usage e erro apontam canônico; prosa Neon/vercel env pull substituída por semântica homeserver (~/stack/teqo-1313.env). Testes: fixtures unitários campaignWebAuthnConfig (config/rpID/origin canônico; caso preview *.vercel.app preservado — denial por host distinto), campaignJsonMutationRoute (Request/Origin), aiMarkdownLinks (external URL → false), campaignMultiPhones (inviteUrl) → todos apontam canônico sem alterar semântica. Personas: .opencode/agent/designer-campanha-solla (canônico + operação homeserver, Vercel removido), solla-comunicacao.md, solla-comunicacao/SKILL.md → canônico. Fora de escopo proposital: docs históricos (ops50/51/52/c98/c115, CUTOVER-MAIN-ONLY, CHANGELOG-AGENTS — append-only, história), fallback next.config.mjs/apoiadores (base WordPress), refs de conteúdo jorgesolla.com.br (origem WordPress, não a URL antiga do app), hosts *.vercel.app no teste WebAuthn (denial intencional). Sem migration, sem schema, sem access/Consent, sem UI. Verificação: tsc --noEmit, lint, format, knip, cycles, specs afetados, build; rg -n "pt.jorgesolla.com.br" = 0 (excl. decisões preservadas); Closes #796.
C140 — Formulários de campanha não apagam mais o digitado em erro (React 19 form reset) (2026-08-23): fix manual dispatch em 18 formulários de /campanha que usavam <form action={submitAction}>: campos não controlados voltavam ao defaultValue após qualquer action assentada (sucesso ou erro) do React 19. O fix segue o precedente C139 — onSubmit + preventDefault + startTransition — que mantém isPending correto sem reentrar no plumbing de form action. WizardStepFormChrome foi alterado uma vez para cobrir 3 wizard steps; os demais 15 forms receberam dispatch manual arquivo a arquivo. AdvisorPasswordResetButton (2 micro-forms sem campo digitável) ficou como está. Unit spec novo campaignWizardStepFormChrome.unit.spec.tsx cobre o chrome.
S12 — âncora #novidades quebra no mobile — deep link não leva ao formulário (2026-08-23): o deep link jorgesolla1313.com.br/#novidades (usado para direcionar tráfego orgânico/pago/WhatsApp para a captura) abria a home de campanha no topo no celular: a home é dona do container de scroll interno (h-dvh overflow-y-auto, body overflow-hidden), então a navegação nativa por fragmento não aterrissa no alvo, e no mobile o reflow do h-dvh + imagens eager do hero deslocam o layout após o scroll inicial. Fix contido: novo componente cliente CampaignHashScroll (renderiza null, montado no layout da home) que (1) no load direto com hash retenta o scroll até a seção entrar no viewport (rAF + timeouts, parando na primeira interação do usuário — wheel/touch/pointer/teclas de scroll) e (2) ouve hashchange para âncoras in-page (CTA do hero, #bandeiras, back/forward) sem tocar em CampaignHero (continua server component). el.scrollIntoView() sem behavior herda scroll-smooth/motion-reduce:scroll-auto do container — prefers-reduced-motion respeitado com zero JS. Sem mudança de visual, schema, formulário S9 ou modelo de scroll de outras rotas. E2E novos: describe mobile 390×844 (load direto, CTA, reduced-motion via emulateMedia) + load direto desktop (antes sem cobertura), com o teste de load direto desktop em describe próprio para não criar corrida no beforeAll de consent do describe de captura (purge+create não seguro sob workers paralelos). Verificação: lint/typecheck/unit + e2e campaignNewsletter e campaignHomePixel verdes locais; gate:push na entrega; PR em main com auto-merge.
OPS78 — default de launch do worktree opencode → DeepSeek V4 Flash max via Vercel AI Gateway (2026-08-23): o launch de pnpm worktree next/plan/new abria o opencode com o preset deepseek/deepseek-v4-flash (rota direta ao provedor). A política mudou: a sessão de launch deve rotear pelo Vercel AI Gateway (cache/estabilidade/failover) e expor a variante de raciocínio mais forte (max). Mudança config-only (mecanismo OPS26 "trocar modelo = editar a constante"): OPENCODE_PRESET_MODEL em scripts/lib/worktree.mjs → vercel/deepseek/deepseek-v4-flash; o repo opencode.json ganhou o override provider.vercel.models["deepseek/deepseek-v4-flash"].variants com low/high/max (cada uma com reasoningEffort) — o catálogo built-in do opencode não expõe as variantes para esse modelo, e a sobrescreve as adiciona sem quebrar o routing (fix validado ao vivo: opencode debug config + opencode run --model vercel/deepseek/deepseek-v4-flash --variant max). Fato verificado no source do opencode 1.18: o TUI não tem flag --variant (só opencode run) e --model é estritamente provider/model, então a variante é selecionável no TUI e sticky por model (state do opencode) — a verificação do aceite é a linha de launch emitida pelo preset. 8 literais + pin da constante em tests/unit/worktree.unit.spec.ts atualizados; textos em scripts/worktree.mjs (docblock/help), .agents/shell/worktree.sh e worktree-next-issue/SKILL.md sincronizados. Anti-goals preservados: cardápio de execução de Issues intocado (work-issue.md/plan-issue.md + pin opencodeCommands.unit.spec.ts seguem com o provider direto), config global do humano intocada, docs históricos congelados. Verificação: pnpm gate:fast verde; gate:push na entrega; PR em main com auto-merge.
OPS73-FOLLOWUP — re-home do S10 (pixel Meta) no GitHub main, origin do workspace homeserver + race do seedUser no e2e full (2026-08-23): fechamento dos débitos colhidos no OPS73 (#760). Das três fases planejadas, duas já estavam resolvidas por eventos posteriores ao plano de intenção (escrito 2026-08-20): (1) o S10 não precisou de cherry-pick — o merge de convergência OPS75 (c723d6ac, GitHub como fonte única, 2026-08-21) já trouxe o pixel Meta completo para o GitHub main (os 11 arquivos da feature são byte-idênticos à ponta Forgejo 504f6c37; migração 20260819_213947_add_site_settings_facebook_pixel_id, e2e campaignHomePixel, unit campaignNewsletterLead, changelog/plano todos em main); (2) o origin do workspace do homeserver (~/teqo-deploy) já era https://github.com/fsolla/teqo.git — verificado via SSH, e o deploy-homeserver.sh re-aponta idempotentemente a cada deploy. O código real da entrega foi o resíduo da F3: a race do usuário compartilhado dev@payloadcms.com no e2e full estava parcialmente fixa no main (o simplify do S10 trouxe seedTestUser create-if-missing + helper adminHeaders com pg_advisory_lock(727001)), mas restavam 3 pontos de risco: o afterAll de admin.e2e.spec.ts ainda deletava o usuário compartilhado no meio da suíte paralela (cleanupTestUser), e dois specs roteavam logins REST diretos sem o advisory lock (admin.e2e.spec.ts ×2 e a função inline adminHeaders sem lock em frontend.e2e.spec.ts). A entrega removeu o cleanupTestUser (chamada + helper morto, zero call sites) e roteou todos os logins restantes pelo helper com lock — um único ponto de serialização para a suite inteira. Deploy do main (que inclui o S10) via deploy.yml disparado manualmente: o verify full flakou com 3 reds + 5 flaky pré-existentes não relacionados ao seedUser (timeouts de locator.check em campaignMunicipalities:115, campaignPermissionProfile C142, etc. — classe consolidada na casa de flakes #553), então o deploy precisa de re-dispatch pós-merge. Dívida capturada: #805 (E2E-ADMIN-LOGIN-LOCK, depends #760) — o beforeEach UI login do admin (page-based, form) continua sem o lock (resíduo da própria classe que o #760 ataca); correção inline de docs drift em AGENTS-infra.md (tracker já migrado para GitHub no OPS76).
OPS47 — agent-pool/SKILL.md aponta para plano com política pré-main obsoleta (--base stage + auto-merge) (2026-08-23): docs-only, zero mudança de comportamento. O .agents/skills/agent-pool/SKILL.md:18 cita docs/plans/agent-pool-orchestrator.md como referência viva de arquitetura ("Arquitetura e elegibilidade"), mas o plano (escrito 2026-07-30, era pré-main) ainda descrevia a política obsoleta: PR --base stage, issue-done-on-stage-merge.yml, promote stage→main, refresh-stage — realidade removida em 2026-08-01 (promote/refresh) e 2026-08-12 (migration-lock). Edição linha-a-linha do plano (dono do concern), frame histórico do pool dormente (OPS65) preservado: overview e resumo executivo passam a "workers PR direto para main" com nota do promote removido; diagrama mermaid pull_request closed em stage→em main e merge em main → issue-done-on-main-merge.yml; contrato de prompt do worker gh pr create --base stage→--base main (reflete o vivo: POOL_BASE_REF='main' em scripts/agent-pool.mjs:70 e prompt --base main em scripts/lib/agent-pool-prompt.mjs:40-41); gargalo de migrations reescrito de "job migration-lock" para countOpenSchemaPrs (scripts/lib/agent-pool-forgejo.mjs:83, com nota da remoção do job em 2026-08-12); referências à skill deletada model-selection → scripts/lib/agent-pool-models.mjs; scripts/lib/agent-github.mjs (inexistente) → agent-forgejo.mjs; .github/workflows/agent-pool.yml e gh workflow run agent-pool.yml marcados como removidos no OPS65 (dispatch falha 404); startingRef: 'stage'→POOL_BASE_REF (='main'); D7/D8/D1 e rabbit holes alinhados (sem refresh-stage, sem "slot ocupado até merge em stage"). SKILL.md: resíduo L79 "Antes do promote, use dispatch na branch" → nota histórica (promote removido 2026-08-01, workflow removido OPS65). Touch-ups de comentário/label (sem mudança de lógica): tests/unit/agentPoolState.unit.spec.ts:103 "CI flipped on stage merge"→"on main merge"; scripts/lib/agent-pool-state.mjs:131 "merge in stage"→"merge in main". Fora de escopo (órfão/histórico): docs/plans/paradigma-agentes-paralelos.md; código do pool e workflows (dormentes, não tocados). Impl plan: docs/plans/agent-pool-skill-md-aponta-para-plano-com-politica-pre-impl.md.
OPS46-S1 — hook ESM mapeia next/cache incondicionalmente (2026-08-23): débito da revisão estrutural do OPS46 (issue #789): tests/helpers/e2eEsmResolve.mjs remapeava next/cache → next/cache.js sem tentar a resolução bare — quando o next publicar um exports map, o remap podia mascarar erro de resolução real (fail-loud vira fail-silent). Fix no branch next/cache: await nextResolve(specifier, context) primeiro; só em ERR_MODULE_NOT_FOUND remapeia para next/cache.js; qualquer outro erro propaga. Branch react-server intocado (injeção de conditions não mascara erro). Spec unit novo tests/unit/e2eEsmResolve.unit.spec.ts (3 casos com nextResolve mockado: bare-ok sem remap, ERR_MODULE_NOT_FOUND remapeia com context intacto, erro de outro código propaga). Verificação: gate:fast verde (unit 2432), sanity OPS72: playwright test --list coleta 202 testes com o hook carregado. Gatilho de reavaliação: bump major do next (fallback vira código morto e pode ser removido). Impl plan: docs/plans/ops46-s1-hook-esm-bare-first-impl.md.
OPS46 — infra de teste e2e: coleta local consertada (next/cache) + allocator de município sem colisão no wrap (2026-08-23): entrega de dois débitos do OPS45 (capture-review-debts). Fase 1 — e2e local não coleta: pnpm exec playwright test --list falhava em todo spec com Cannot find module 'node_modules/next/cache' (e o diagnóstico correto do CI-vs-máquina: não há delta — o CI só roda pelo script pnpm test:e2e, que carrega --conditions=react-server --import=tsx/esm no NODE_OPTIONS; comando bare não tinha nada). Causa: next@15 não declara exports → ESM nativo não resolve next/cache (o arquivo é cache.js), e server-only precisa da condição react-server. Fix no runner, zero mudança em produção: hook de resolução ESM vitest-free tests/helpers/e2eEsmResolve.mjs (mapeia next/cache→next/cache.js e injeta react-server nas conditions) registrada no processo main via module.register e propagada aos workers via NODE_OPTIONS=--import=e2eEsmLoader.mjs no playwright.config.ts — só quando os flags do script já não estão presentes. Aceite: bare playwright test --list coleta 198 testes em 41 arquivos (antes: 0); runs reais bare admin 5/5 e campaign 18/18; pnpm test:e2e intocado. Fase 2 — allocator de município com wrap destrutivo: a sequência persistente campaign_fixture_municipality_alloc nunca é resetada e um ciclo completo de suíte claima ~505 do catálogo de 435 (medido: 432 no int + 73 no e2e) → o wrap acontece em todo ciclo em DB persistente de worktree, e dois runs concorrentes podiam receber o mesmo índice e purgar as linhas vivas um do outro (purge-on-claim). Fix: registry de claims em módulo compartilhado vitest-free tests/helpers/campaignMunicipalityAllocator.ts — tabela campaign_fixture_municipality_claims (index PK, run_id, claimed_at), claim via INSERT ON CONFLICT DO NOTHING RETURNING (conflito = slot vivo → pula; claim stale >2h de run crashado → rouba; tudo ocupado → erro claro de exaustão), release no cleanup() dos dois fixtures (int + e2e, só quando claimaram). Guard estático novo no testMunicipalityAllocatorConventions (proíbe nextval/sequence fora do módulo) + spec int de stress campaignMunicipalityAllocator.int.spec.ts (exclusividade viva por 3 wraps, exaustão+recuperação, steal, release). Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente de não carregar payload.config, igual no HEAD), unit 2420 + int 819 (92 arquivos) verdes com tabela de claims zerada ao final, e2e local afetado (admin 5/5 + campaign 18/18 bare) verdes; importMap.js restaurado com envs S3_* dummy após o --list bare regenerá-lo sem elas (classe OPS69 — o guard do #87 cobre no CI). Impl plan: docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md.
FIX — desbloqueio do deploy (e2e full vermelho, 2026-08-23): dois bugs do C142 (merge em main no 21-08) que só o e2e full do verify pegou — o PR do C142 rodou só o blast radius selecionado e passou. (1) gate: 'writable' barrava coordinator/candidate: requireCampaignPageActor checava advisorEditingAccess(user) === 'none', mas esse helper retorna 'none' para qualquer papel não-advisor — inclusive coordinator e candidate, que têm escrita plena. Resultado: municipios/[slug]/editar redirecionava o coordinator para a página de detalhe e o e2e campaignMunicipalities (coordinator assigns an advisor) estourava timeout. Fix: o gate exige staff primeiro (isStaffCampaignRole) e só então aplica a regra somente_leitura para advisors (user.role === 'advisor' && advisorEditingAccess(user) === 'none'). (2) FAB sem data-slot: o e2e campaignPermissionProfile loca [data-slot="campaign-quick-actions-fab"], mas CampaignQuickActionsFab nunca renderizou esse atributo — os asserts toBeVisible() dos perfis carteira/tudo falhavam com "element(s) not found" (o toBeHidden() do perfil somente_leitura passava porque ausência conta como hidden). Fix: data-slot="campaign-quick-actions-fab" no div raiz do FAB. Regressões pino: tests/unit/campaignPageActorGate.unit.spec.ts (5 casos do gate writable, incluindo coordinator/candidate passando e advisor somente_leitura bloqueado) + assert do data-slot no campaignQuickActionsDrawer.unit.spec.tsx. Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente), unit 2435 + int 819 verdes, e2e local dos dois specs afetados verdes para os 3 casos que falhavam no deploy (campaignMunicipalities coordinator-assigns-advisor e campaignPermissionProfile carteira/tudo FAB); os dois remanescentes locais (key-warning dev-only do omnibox no campaignPermissionProfile:69 e campaignMunicipalities:638) passam no build de produção do CI — não são bloqueadores.
E1 — extração do hook compartilhado de busca assíncrona com debounce (2026-08-23): a 3ª cópia do efeito de busca async (requestId + setTimeout(250) + loading/failed) — introduzida pela C143 em DemandResponsibleMultiSelect (demand), e presente em ResponsibleMultiSelect (C90) e AsyncSearchCombobox — vira o hook useAsyncSearchOptions({ open, query, search, isQueryReady? }) → { options, loading, failed } (src/hooks/useAsyncSearchOptions.ts, novo; constante ASYNC_SEARCH_DEBOUNCE_MS exportada). Os 3 componentes consomem o hook e mantêm só o que é seu (grupos por typeLabel, avatares, headings de sugestões do município, pinnedOptions, filtro de selecionados, mensagens de vazio); semântica de query vazia preservada por consumidor (Demand busca sempre → sugestões; ResponsibleMultiSelect reseta via isContactSearchQueryReady; combobox com gate configurável — o efeito sync selected←value fica no componente). Fix pontual: server actions intocadas (continuam props — hook client-safe por injeção), sem schema/migration/access/Consent. Testes: spec unit novo tests/unit/useAsyncSearchOptions.unit.spec.ts (renderHook + fake timers, 9 casos: debounce/trim, coalesce, gate reset, closed, failed + retry, in-flight loading, out-of-order, quirk gate→not-ready com resposta stale pinado — comportamento herdado dos 3 originais, registrado na decisão D3 do impl plan). Impl plan docs/plans/extrair-hook-busca-assincrona-debounce-impl.md. Verificação: unit 2428 + int 815 verdes, e2e activity/demand (campaignActivity, campaignRegisterDemand, campaignDemandVisibility) verdes em re-run (1 flake de cold-compile pré-existente isolado), tsc/lint/format/cycles/build verdes; knip com o erro de load pré-existente (exit 0); fora de escopo: WizardMunicipalitySearchStep (4ª variante, AbortController — defer com gatilho no plano).
C88 — deliberação na atualização (responsável, fio, resolvido) (2026-08-23): a coordenação agora delibera na própria atualização de município, sem abrir demanda/atividade/Zap paralelo para cada fato (depende do modelo unificado do C87). O card da atualização ganhou três camadas: (1) responsável — coordinator/candidate atribuem um campaignUser staff do município (assessores da carteira + coordinator/candidate; nunca liderança; reatribuir/limpar livres), salvo server-side com validação de elegibilidade e lock por doc; (2) fio de comentários — qualquer staff que pode criar atualização naquele município comenta (array comments no doc, precedente Activity.updates, hook carimba autor/data), ordem cronológica, composer some quando resolvido; (3) Resolvido/Reabrir — coordinator/candidate marcam (audita resolvedBy+resolvedAt) e reabrem; atualização resolvida permanece legível com badge "Resolvido" + corpo atenuado, e "Em deliberação" aparece quando há fio/responsável. Segurança: canal de escrita novo via context.mutationKind (assignResponsible|appendComment|resolve|reopen) com split canUpdateMunicipalityUpdate/canDeleteMunicipalityUpdate (delete segue admin-only), allowlist de campos no beforeChange por diff contra o doc original (403 fora dela), zero overrideAccess: true novo, leader barrado na raiz (read escopado + ações com overrideAccess: false). Superfícies: tab Atualizações do município, feed global /campanha/atualizacoes e dossiê compartilham a mesma ilha client com capabilities server-side. Migration 20260823_161905_add_municipality_update_deliberation (colunas responsible_id/resolved_by_id/resolved_at + tabela municipality_update_comments, serializada após 20260820_003426; o snapshot .json gerado saneia o snapshot stale da cadeia). Corrigido também o alias canMutateAllocationDecision que herdava o gate de update do MunicipalityUpdate (agora explícito admin-only — o gate deliberativo jamais alcança allocationDecision). Verificação: unit (2423) + int (826, incl. spec novo da deliberação com 11 casos) + build + e2e novos (campaignMunicipalityDeliberation, 2 fluxos) e afetados verdes; um e2e pré-existente (B27, gate writable do C142) falha também no main limpo — não é regressão desta entrega.
C142 — interface do /campanha respeita o perfil de permissão do assessor (2026-08-21): a entrega fechada parte do C141 (que definiu os eixos visibility/editing no campaignUser e os gates server-side). Com o C142, a UI de todos os rascunhos staff do /campanha obedece de ponta a ponta o perfil de edição do assessor: com editing = somente_leitura não há nenhum controle de escrita em lugar nenhum (listas, detalhes, tabs, wizards); com editing = tudo o assessor enxerga/edita tudo o que vê; com carteira mantém o comportamento por carteira. Discriminação das entregas: (1) núcleo puro src/lib/campaignAdvisorProfile.ts com advisorEditingScope()/rowEditingAllowed() (retorna none para combinação incoerente editing=tudo + visibility≠tudo) e gate: 'writable' em requireCampaignPageActor (redireciona para /campanha); (2) todas as células compartilhadas (CampaignInlineEditableCell, MunicipalityRelationEditor, PhonesFieldEditor, DemandDescriptionEditor/Card/WorkflowCard, MunicipalityV2*Cells, MunicipalityGoalAccountCard, ActivityTaskChecklist, SuggestionCard, MunicipalityPledgesPanel, etc.) ganharam o prop readOnly; (3) páginas staff — municípios (lista desktop+mobile, detalhe com tabs Overview/Updates/Leaderships/Suggestions, edição), pessoas (invite e criação gated), lideranças, dobradinhas, demandas, atividades, apoiadores, organizações — escondem os botões de criação (Nova demanda, Nova atividade, Planejar giro, Novo/Importar CSV, Nova organização, Nova liderança, Nova dobradinha) com editingScope !== 'none'; (4) home: homeActionsForRole(role, editingScope) filtra os 4 wizards para somente_leitura (sobra só o atalho de navegação); (5) shouldMountQuickActionsFab não monta o FAB para somente_leitura; (6) registry de quick actions (resolveQuickActionsForPath) recebe editingScope e filtra; (7) e2e novo tests/e2e/campaignPermissionProfile.e2e.spec.ts cobre os 3 perfis (somente_leitura sem controles, carteira com carteira, tudo com tudo) usando createCampaignUser com visibility/editing. Verificação: tsc/lint/format/knip/cycles/units (2419) + gate:fast verdes; PR em main com auto-merge (rebase); nada muda para coordinator/candidate/leader.
S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19): o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global SiteSettings → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em facebookPixel.ts, com o Petition refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (/) carrega o base code + PageView do Meta (injeção no layout do grupo (home) via next/script, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um Lead (content_name "novidades-da-campanha", eventID UUID) no padrão do PetitionForm — nunca no page load. Leitura via getCachedGlobal('site-settings') (ISR sob a tag global_site-settings; a edição do admin já revalida a home). Migração 20260819_213947_add_site_settings_facebook_pixel_id (colunas tracking_facebook_pixel_id + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup eventID, fila/retry de Lead e cookie banner/CMP. E2e novo campaignHomePixel (script #meta-pixel-* presente só com ID configurado, CDN stubada; mode: serial por precedente).
C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19): o coordenador/candidato define por assessor, em /campanha/assessores (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele vê (carteira|tudo) e o que ele edita (carteira|tudo|somente_leitura) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo resolveProfileScopedRead (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (advisorEditingAccess) nos canUpdate*/canCreate* de todos os domínios, no check do giro e no create individual de atividade (ambos contra getWritableMunicipalityIds), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (assertPersonContactWritable — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, municipality.advisors, nível de envolvimento E14, allocationDecision) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook enforceCoherentAdvisorProfile (REST/admin incluídos). Migration 20260819_214755_add_campaign_user_advisor_permission_profile (duas colunas select com default 'carteira', sem backfill).
OPS73 — deploy do fix do admin em produção (2026-08-20): o /admin do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision 8c734ba) já continha o fix do importMap no histórico, mas o commit S9 (8d2352c0, captura de novidades na home) tinha regenerado o importMap sem as envs S3_*, removendo o handler @payloadcms/storage-s3/client#S3ClientUploadHandler e reintroduzindo o erro getFromImportMap: PayloadComponent not found in importMap — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o main do GitHub havia divergido do Forgejo (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo 8d2352c0 + 488dcf7d), mantendo o handler no importMap (prova: generate:importmap com envs S3_* dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do deploy.yml: verify full → job deploy no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou dba75ff8); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision dba75ff8, healthy, log sem nenhum getFromImportMap, /admin renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: docs/plans/ops73-deploy-admin-prod-fixed-evidence.png. Lições registradas: a regeneração do importMap sem envs S3_* re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (localhost:3000) e o S10 (pixel) segue pendente de re-home no GitHub.
OPS73 — deploy do fix do admin em produção (2026-08-20): o /admin do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. O diagnóstico mostrou que a premissa do plano (fix nunca tinha chegado a prod) estava errada em dois pontos: (1) o deploy das 23:09 de 2026-08-19 (revision 8c734ba) já continha o fix do importMap no histórico, mas o commit S9 (8d2352c0) regenerou o importMap sem as envs S3_*, removendo o handler @payloadcms/storage-s3/client#S3ClientUploadHandler e reintroduzindo getFromImportMap: PayloadComponent not found (admin em branco de novo); (2) o main do GitHub havia divergido do Forgejo (pós-cutover OPS71) — o GitHub tinha o fix + OPS71, mas não as features S8 ("Nossa história") e S9 (captura de novidades) que produção já rodava. A entrega (PR #751): re-home do S8+S9 no GitHub main (cherry-pick 8d2352c0+488dcf7d), mantendo o handler no importMap (prova: generate:importmap com envs S3_* dummy → diff vazio vs main); deploy via deploy.yml no runner self-hosted do homeserver (dba75ff8), após 2 "stale run" por merges paralelos no verify; migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: revision dba75ff8, healthy, log sem getFromImportMap, /admin renderizando o login (2 inputs + botão, título "Login - Payload", 0 erros de console), home mantendo S8/S9. Evidência: docs/plans/ops73-deploy-admin-prod-fixed-evidence.png. Lições: regeneração do importMap sem envs S3_* re-órfão o handler (classe do guard OPS70/#87); divergência de plataformas pós-cutover (workspace homeserver ainda aponta Forgejo local; S10 pixel pendente de re-home).
S11 — o feed do Instagram configurado sem aparecer na home fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (instagramSyncStatus jsonb hidden na social-feed-settings) e o porquê de os posts não aparecerem — o painel InstagramSyncStatusPanel (campo ui irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: loadInstagramFeed passou a parsear o body de erro da Graph API e lançar InstagramApiError (status + {message,type}); describeInstagramError mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, TypeError → rede. Estado escrito por 3 caminhos: render da home (getInstagramFeed, dentro do unstable_cache, SQL direto sem hooks — padrão anti-revalidateTag-no-cache da S2), afterChange da global (sync awaited quando a credencial IG muda — instagramCredentialsChanged(doc, previousDoc), gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o payload.config.ts não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota POST /api/social-feed/sync (botão; payload.auth sessão admin exigindo users + isSameOriginRequest CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope /campanha). Bug real descoberto e corrigido na entrega: o afterChange roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão deadlockava no lock da própria linha (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela mesma transação (getPostgresTransactionDatabase — o helper transacional da C6) com um req opcional no syncInstagramFeed; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em pg_stat_activity (UPDATE transactionid Lock vs idle in transaction) e fix verificado (45ms vs 232s). Stub IG ganha estado invalid-token (400 OAuthException) para o e2e da cena de falha; runbook docs/ops/instagram-feed-token-runbook.md (o que o painel mostra, validação via curl /me e refresh_access_token, geração pelo Instagram Login, o que NÃO fazer). Migration 20260820_003426_add_instagram_sync_status (ADD COLUMN jsonb nullable, não destrutiva). /simplify (3 reviewers): absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the user must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (isInvalidUserIdMessage) e adicionei o mapeamento de AbortError (timeout → "demorou demais"). Painel: gate passou a respeitar o master enabled (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), outcome transiente limpo ao editar credencial e aria-live nos boxes interativos. Testes: caminho transacional do syncInstagramFeed (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou afterEach. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, 5 posts hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; pnpm build ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.
C143 — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo campaignDemand.responsibles (hasMany → campaignUser, staff elegível via eligibleCampaignStaffWhere com hook beforeValidate que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável ({ responsibles: { contains } }), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em src/utilities/access/demands.ts e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via overrideAccess: false; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook enforceDemandWorkflow (cobre form, wizard e rascunhos C90); migration 20260819_233503_add_campaign_demand_responsibles adiciona campaign_user_id a campaign_demand_rels (FK cascade) e faz backfill de created_by_id → responsável para demandas pré-C143. UI: picker multi-select DemandResponsibleMultiSelect (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (/demandas/nova em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão DemandResponsiblesCard no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por searchDemandResponsibleOptions (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo campaignDemandVisibility.e2e.spec.ts (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou createCampaignDemand. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com S3_* dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles' = nº de demandas com created_by_id. Plano de implementação: docs/plans/demandas-responsaveis-impl.md.
OPS72 — política de e2e: CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy. Decisão do gate (2026-08-19): (1) local = discricionário — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (pnpm test:e2e:affected é a ferramenta, não um gate mecânico; não está no gate:push); (2) CI de PR = blast radius — ci-pr.yml perdeu o step E2E tests (full suite); o e2e roda só em modo selected (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica full e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o verify do deploy roda full antes de publicar; (3) main = full no deploy — inegociável, já existia no deploy.yml (nada mudou lá). Docs: work-issue e agent-work-issue (checklist + Passo 4), execution-pipeline.md (nova seção "E2E local afetado" com o critério + a limitação da #72: --no-deps + projetos paralelos colide no seedTestUser → --workers=1 ou a cadeia padrão de projetos), AGENTS.md (parágrafo OPS59 → política OPS72), docs/AGENT-OPS.md (fluxo do agente + linha do ci-pr.yml na tabela), .agents/rules/agent-pr-workflow.mdc (descrição do gate:push), mensagens informativas do gate-ci.mjs (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (ci-scope.mjs, manifest, run-e2e-affected.mjs); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte full (o diff toca scripts/gate-ci.mjs, high-risk → modo full — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em E2E_PROD=1 (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo full por design desta mesma política). Impl plan: docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md. Nota: E2E_PROD=1 local regenera o importMap.js sem as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).
OPS72 — política de e2e: CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy. Decisão do gate (2026-08-19): (1) local = discricionário — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (pnpm test:e2e:affected é a ferramenta, não um gate mecânico; não está no gate:push); (2) CI de PR = blast radius — ci-pr.yml perdeu o step E2E tests (full suite); o e2e roda só em modo selected (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica full e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o verify do deploy roda full antes de publicar; (3) main = full no deploy — inegociável, já existia no deploy.yml (nada mudou lá). Docs: work-issue e agent-work-issue (checklist + Passo 4), execution-pipeline.md (nova seção "E2E local afetado" com o critério + a limitação da #72: --no-deps + projetos paralelos colide no seedTestUser → --workers=1 ou a cadeia padrão de projetos), AGENTS.md (parágrafo OPS59 → política OPS72), docs/AGENT-OPS.md (fluxo do agente + linha do ci-pr.yml na tabela), .agents/rules/agent-pr-workflow.mdc (descrição do gate:push), mensagens informativas do gate-ci.mjs (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (ci-scope.mjs, manifest, run-e2e-affected.mjs); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte full (o diff toca scripts/gate-ci.mjs, high-risk → modo full — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em E2E_PROD=1 (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo full por design desta mesma política). Impl plan: docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md. Nota: E2E_PROD=1 local regenera o importMap.js sem as envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
OPS72 — política de e2e: CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy. Decisão do gate (2026-08-19): (1) local = discricionário — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (pnpm test:e2e:affected é a ferramenta, não um gate mecânico; não está no gate:push); (2) CI de PR = blast radius — ci-pr.yml perdeu o step E2E tests (full suite); o e2e roda só em modo selected (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica full e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o verify do deploy roda full antes de publicar; (3) main = full no deploy — inegociável, já existia no deploy.yml (nada mudou lá). Docs: work-issue e agent-work-issue (checklist + Passo 4), execution-pipeline.md (nova seção "E2E local afetado" com o critério + a limitação da #72: --no-deps + projetos paralelos colide no seedTestUser → --workers=1 ou a cadeia padrão de projetos), AGENTS.md (parágrafo OPS59 → política OPS72), docs/AGENT-OPS.md (fluxo do agente + linha do ci-pr.yml na tabela), .agents/rules/agent-pr-workflow.mdc (descrição do gate:push), mensagens informativas do gate-ci.mjs (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (ci-scope.mjs, manifest, run-e2e-affected.mjs); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte full (o diff toca scripts/gate-ci.mjs, high-risk → modo full — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em E2E_PROD=1 (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo full por design desta mesma política). Impl plan: docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md. Nota: E2E_PROD=1 local regenera o importMap.js sem as envs envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
OPS72 — política de e2e: CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy. Decisão do gate (2026-08-19): (1) local = discricionário — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (pnpm test:e2e:affected é a ferramenta, não um gate mecânico; não está no gate:push); (2) CI de PR = blast radius — ci-pr.yml perdeu o step E2E tests (full suite); o e2e roda só em modo selected (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica full e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o verify do deploy roda full antes de publicar; (3) main = full no deploy — inegociável, já existia no deploy.yml (nada mudou lá). Docs: work-issue e agent-work-issue (checklist + Passo 4), execution-pipeline.md (nova seção "E2E local afetado" com o critério + a limitação da #72: --no-deps + projetos paralelos colide no seedTestUser → --workers=1 ou a cadeia padrão de projetos), AGENTS.md (parágrafo OPS59 → política OPS72), docs/AGENT-OPS.md (fluxo do agente + linha do ci-pr.yml na tabela), .agents/rules/agent-pr-workflow.mdc (descrição do gate:push), mensagens informativas do gate-ci.mjs (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (ci-scope.mjs, manifest, run-e2e-affected.mjs); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte full (o diff toca scripts/gate-ci.mjs, HIGH*RISK → modo full — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em E2E_PROD=1 (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo full por design desta mesma política). Impl plan: docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md. Nota: E2E_PROD=1 local regenera o importMap.js sem as envs S3*\* (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
OPS72 — política de e2e: CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy. Decisão do gate (2026-08-19): (1) local = discricionário — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (pnpm test:e2e:affected é a ferramenta, não um gate mecânico; não está no gate:push); (2) CI de PR = blast radius — ci-pr.yml perdeu o step E2E tests (full suite); o e2e roda só em modo selected (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica full e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o verify do deploy roda full antes de publicar; (3) main = full no deploy — inegociável, já existia no deploy.yml (nada mudou lá). Docs: work-issue e agent-work-issue (checklist + Passo 4), execution-pipeline.md (nova seção "E2E local afetado" com o critério + a limitação da #72: --no-deps + projetos paralelos colide no seedTestUser → --workers=1 ou a cadeia padrão de projetos), AGENTS.md (parágrafo OPS59 → política OPS72), docs/AGENT-OPS.md (fluxo do agente + linha do ci-pr.yml na tabela), .agents/rules/agent-pr-workflow.mdc (descrição do gate:push), mensagens informativas do gate-ci.mjs (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (ci-scope.mjs, manifest, run-e2e-affected.mjs); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte full (o diff toca scripts/gate-ci.mjs, HIGH_RISK → modo full — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em E2E_PROD=1 (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo full por design desta mesma política). Impl plan: docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md.
OPS72 — política de e2e: CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy. Decisão do gate (2026-08-19): (1) local = discricionário — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (pnpm test:e2e:affected é a ferramenta, não um gate mecânico; não está no gate:push); (2) CI de PR = blast radius — ci-pr.yml perdeu o step E2E tests (full suite); o e2e roda só em modo selected (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica full e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o verify do deploy roda full antes de publicar; (3) main = full no deploy — inegociável, já existia no deploy.yml (nada mudou lá). Docs: work-issue e agent-work-issue (checklist + Passo 4), execution-pipeline.md (nova seção "E2E local afetado" com o critério + a limitação da #72: --no-deps + projetos paralelos colide no seedTestUser → --workers=1 ou a cadeia padrão de projetos), AGENTS.md (parágrafo OPS59 → política OPS72), docs/AGENT-OPS.md (fluxo do agente + linha do ci-pr.yml na tabela), .agents/rules/agent-pr-workflow.mdc (descrição do gate:push), mensagens informativas do gate-ci.mjs (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (ci-scope.mjs, manifest, run-e2e-affected.mjs); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc/unit verdes; test:e2e:affected com modo none (diff sem blast radius e2e); CI do PR valida (e2e não roda — docs/skills/workflow não são superfície e2e). Impl plan: docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md.
OPS71-INFRA (2026-08-19): runner self-hosted do GitHub instalado no homeserver (Issue #113) — o job deploy do deploy.yml (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: ~/actions-runner v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome teqo-1313-runner, labels self-hosted + homeserver, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como systemd user actions.runner.fsolla-teqo.teqo-1313-runner.service (linger já ativo; svc.sh install exige root — unit user criado manualmente do template oficial), usuário fsolla (docker + ~/stack envs), workdir _work, outbound-only (funciona atrás do Cloudflare tunnel). Verificado online na API do GitHub com as labels corretas. Registrado em infra-solla STATE.md (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace ~/teqo-deploy re-aponta para o GitHub no primeiro deploy via set-url idempotente do script. Pendência registrada: secret CURSOR_API_KEY no GitHub fica dormente (helper archive-cursor-agent.yml; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação.
S9 — captura de novidades da campanha na home pública (2026-08-19): a home de campanha (jorgesolla1313.com.br) ganhou a seção "Receba as novidades da campanha" logo acima do rodapé — nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado "Quero fazer parte do time" (frequência + grupos de WhatsApp vs. comunicações esporádicas) e confirmação in-place com atalho para as bandeiras. O CTA secundário do hero virou o atalho da seção (âncora #novidades, scroll suave no container da home; "Conhecer bandeiras" sai, as bandeiras seguem no rodapé e no estado de sucesso). O registro é Contact + Subscription em transação com consentimento NOVO por chave estável campanha-novidades (fail-closed: sem o texto aprovado no admin, o form recusa a captura — nada é gravado); a escolha do toggle sobrevive como subscription.campaignLevel (time|esporadico, coluna própria na lista do admin). Migração 20260819_192605_add_campaign_newsletter_capture (DROP NOT NULL em contact.state — estado opcional no form, ficha staff via zod segue exigindo — + coluna campaign_level com enum). Precedente submitWhatsapp preservado; action nova usa withPayloadTransaction (o dono do concern de transação, não o hand-roll gêmeo — submitWhatsapp/submitPetitionSignature ficam como débito de alinhamento). S10 (pixel Meta) dispara Lead na costura do sucesso do form. Chave de consentimento pendente de aprovação jurídica antes da ativação.
S7 — polimento dos cards da seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br), look editorial e direto no lugar do "card de app": cantos retos (sem rounded-xl no card e rounded-lg na capa), capa de borda a borda (o p-3 saiu do contêiner do card e foi para o bloco de texto — título/subtítulo/meta continuam respirando p-3 das bordas; mt-auto da meta e overflow-hidden do zoom hover intactos) e, por decisão do humano posterior ao gate, sem borda (border --campaign-line) e sem sombra (shadow-sm/hover:shadow-md) — o card branco lê contra a banda do S5 só pela forma. Chips de badge removidos de todos os cards (decisão B do gate): nem os tipos de post do site (POST_TYPE_BADGE_LABELS deletado de src/utilities/posts.ts, junto com o describe no unit test) nem as origens externas YouTube/Instagram (sinalização externa segue por target="_blank"/rel="noopener"); badgeLabel saiu do tipo CampaignContentCardData. Superfície única no componente dono (CampaignContentCard.tsx) — bento e carrossel herdam; ContentShareButton (S4), banda do S5, carrossel e fail-closed da seção intocados. Zero migration/schema/Consent. e2e do describe da seção atualizados para o novo estado: loop de badges → ausência (toHaveCount(0) para os 4 tipos + YouTube/Instagram), pins CSS do card (border-radius/border-width/box-shadow + raio da capa) e pin geométrico da capa de borda a borda (boundingBox do card × capa). Verificação: gate:fast verde, unit 2415 + int 791 verdes, e2e frontend 19/19 local, format/knip/cycles limpos. Plano de implementação: docs/plans/cards-acompanhe-de-perto-polimento-impl.md.
S8 — seção "Nossa história" na home de campanha (jorgesolla1313.com.br): o vídeo da trajetória do Dep. Jorge Solla (~1:40) ganha lugar próprio na home, após a seção "Bandeiras" e antes do rodapé — eyebrow "Nossa história" + título "Conheça a trajetória de Jorge Solla" + frame vertical 9:16 (desktop 2 colunas, copy à esquerda e frame 300px à direita centralizados; mobile empilhado, frame full-width cap 420px). Divergência aprovada em execução pelo humano: o vídeo é embutido do canal do deputado no YouTube (Short i_fbclWWC5o) em vez do asset self-hosted do plano de intenção — cada play embutido conta view no canal; youtube-nocookie (LGPD), loading="lazy" nativo (zero bytes até o frame se aproximar do viewport), playsinline, allowfullscreen, title a11y, allow com compute-pressure (silencia o console.error do player — o guard do e2e falha em console.error); CTA "Assistir à história" rola até o player (play cross-origin não é disparável). Os encodes one-off H.264/AV1 + poster da foto JOA00327 (14.9 MB, validados por design-vision contra o orçamento de 15 MB da intenção) ficam como artefato local gitignored em data/encode/ — fallback pronto se um dia se voltar a self-hosted. Zero schema/migration/Consent; nenhuma seção existente tocada. e2e novo (posição, copy, atributos do embed, CTA→viewport, layout desktop 2 colunas com centro vertical, 9:16 mobile sem overflow) + abort global do youtube-nocookie no fixture e2eTest.ts (hermeticidade CI). Verificação: unit 2416 + int 791 verdes, tsc/lint/format/cycles/build verdes; e2e frontend 20/20; knip = limitação P3 pré-existente. /simplify: 3 revisores paralelos absorvidos (JSDoc enxuto, e2e sem assert redundante, plano sem conteúdo morto, desktop asserts); 2 débitos deferidos no impl plan (rótulo do CTA se a assessoria reabrir copy; dedupe da classe do CTA com CampaignContentSection quando S5 #33 terminar).
OPS69 — o admin do Payload abria em branco em produção (P0, 2026-08-19): o importMap commitado (src/app/(payload)/admin/importMap.js) ficou sem a entrada @payloadcms/storage-s3/client#S3ClientUploadHandler — o commit OPS52 (dabc00f4) removeu o handler do Vercel Blob e não adicionou o do S3, porque o pnpm generate:importmap rodou sem as envs S3_*: com o plugin s3Storage condicional, sem as envs o importMap sai consistente (dev/test, storage local) e com elas (produção, Garage) o admin precisa do handler e o montava → getFromImportMap: PayloadComponent not found in importMap → login nunca renderizava. Fix mínimo (opção A do plano, aprovado no gate): regenerar o importMap com as 4 S3_* dummy inline (a mecânica do CLI: iterateConfig sem DB; loadEnvConfig do @next/env não sobrescreve envs já no processo) → diff de exatamente +2 linhas (import + entrada do S3ClientUploadHandler), nenhum outro drift. Verificação: smoke headless com S3 ativo no dev local — /admin/login renderiza (6 inputs, formulário visível, zero erros de console) vs. o branco reproduzido no diagnóstico; gates completos verdes (tsc/lint/format/knip/cycles, unit+int 791, build). Sem migration/access/Consent/UI. Docs: linha no AGENTS.md (checklist local: generate:importmap precisa das S3_* setadas — valores dummy ok — enquanto o plugin for condicional; sem elas o importMap fica órfão, classe OPS69) + item sucessor registrado (guard de CI que regenera com envs dummy e falha o gate se o arquivo commitado divergir — depends #77). Verificação pós-deploy: /admin abre o login e o log do container não re-emite o getFromImportMap para o handler (runbook docs/ops/teqo-1313-deploy.md).
OPS62-FOLLOWUP — flake do municipalitiesWithoutUpdateTool.unit.spec.ts (2 ocorrências 2026-08-18: gate local 19:22 e CI run 779 23:53) eliminado pela raiz, com a hipótese original da Issue refutada por reprodução. Causa raiz: o fixture do teste (não o ambiente nem locale/ICU): daysAgo() chamava Date.now() por chamada; as duas construções daysAgo(40) de Beta/Alpha podiam cair em milissegundos diferentes sob carga (GC/preempção do fork do vitest na máquina compartilhada), deixando o timestamp de Alpha estritamente mais novo — em sortStale (getMunicipalitiesWithoutUpdate.ts) o sort por timestamp vencia o tie-break por nome e o teste recebia [Beta, Alpha] no lugar de [Alpha, Beta] (só o par trocado, resto correto — "sort não atuou" estava errado; o sort atuou sobre timestamps acidentalmente desiguais). Repro mecânica (sort de produção verbatim + fixture): fixture atual 0.08% de falhas em 200k runs sem pausa e 100% com pausa de 1 ms injetada; fixture ancorado 0 em 2000 runs. Fix (test-only, nada em produção — o sort real é determinístico sobre timestamps fixos do DB): âncora fixtureNowMs = Date.now() no load do módulo (padrão do prioritiesTool.unit.spec.ts) + hoist do timestamp empatado (const stale40 = daysAgo(40) compartilhado por Beta/Alpha — igualdade estrutural, imune até a regressão da âncora) + comentário explicando a classe de flake. Scan da mesma classe: prioritiesTool (âncora no load) e suggestionCatalog (now fixo) já seguros; nenhum outro spec combina Date.now() por chamada com assunção de igualdade. Verificação: 30/30 runs do spec, unit full (249 arquivos/2405 testes), int (89/791), tsc, lint, format, check:cycles verdes (knip só com o P3 ledgered de sempre). /simplify: 3 reviewers aprovaram; débito absorvido no próprio teste (comentário do porquê). Impl plan: docs/plans/ops62-followup-ci-flake-fixture-timestamps-impl.md.
OPS67 — o cliente scripts/lib/forgejo-api.mjs ganhou retry com backoff nos fetchs: um fetch failed transitório (rede/DNS/reset — o sintoma que matava o run do safety net agent-pr-ready-automerge.yml no meio da espera de checks) agora retenta em qualquer método, e a leitura de body interrompida no meio (mesma classe de falha de rede) retenta em GET; 5xx do Cloudflare (502/503/504) retenta só em GET (protege o poll de 45 min do waitForChecks sem arriscar duplicar efeitos colaterais de escrita — body do 5xx cancelado antes do retry para liberar o socket); 4xx e body-drop em escrita continuam falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, console.warn pt-BR em cada retry (tentativa N/4), validação retries >= 0/backoffMs >= 0. Opções flat no createApi (retries, backoffMs, jitter, sleepImpl) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 12 specs novas em forgejoApi.unit.spec.ts (rede GET/POST → sucesso, body-drop GET retenta + POST fail-closed, exaustão de rede/5xx, retries: 0, backoff exponencial [100, 200, 400], jitter ±20% com Math.random pinado, 4xx sem retry, 5xx-GET retry/exaustão, 5xx-POST sem retry) + pin no spec 403 existente (1 fetch por invocação). Verificação: unit 2403 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: docs/plans/ops67-forgejo-api-retry-backoff-impl.md (aprovado no gate humano; refinamentos do /simplify absorvidos na execução).
S4 — botão discreto de compartilhar no WhatsApp por card da seção "Acompanhe de perto" da home (jorgesolla1313.com.br): cada card (Artigo/YouTube/Instagram, o board S1+S2+S3) ganha um botão redondo no canto superior direito (visual 28px sobre área de toque 44px, aria-label "Compartilhar", sem competir com o CTA do card) que abre um popover de 2 ações: "Compartilhar no WhatsApp" — wa.me sem destinatário (WhatsApp do próprio remetente; o buildWhatsAppTextShareUrl do S6 foi movido para src/lib/phone.ts — dono do wa.me, re-exportado no homeSearchShare.ts — e o contentShare.ts reusa, sem twin) com mensagem pré-configurada por fonte (decisão B do gate: "Olha isso do Solla: {título} — {link}" / "Olha esse vídeo…" / "Olha esse post…"; link = página canônica do artigo resolvida contra window.location.origin no cliente, ou URL da plataforma para YT/IG; nova aba noopener noreferrer") — e **"Copiar link"** (clipboard com "Link copiado" + aria-livepara leitores de tela, fallback honesto em falha). Estrutura: o card inteiro é um anchor — o botão vive como **irmão** absoluto num wrapperrelative(botão dentro do anchor seria HTML inválido); o conteúdo do popover usaui/Popover(Radix, portal — escapa ooverflow-x-autodo carrossel mobile que cliparia um menu absoluto), sem rolesmenu/menuitem(a semântica honesta de popover com 2 ações — Radix já emitearia-expanded). Zero migration/schema/Consent/escrita no servidor — montagem de URL 100% cliente, nada coletado (anti-goal da intenção). Lib pura src/lib/contentShare.ts(prefixos por fonte, resolução de link fail-soft contra permalink malformado, URL wa.me) + 11 units; e2e novo no describe serial da seção (cadeia 19/19 verde local): botão por card do bento, mensagem por fonte com URL absoluta,target/rel, copiar→clipboard real (expect.poll), Escape/clique-fora, mobile ≥44px + 28px visual + portal dentro do viewport, card ativo pinado por identidade. Verificação: unit 2402 + int 791 verdes, tsc/lint/format/cycles/build verdes; knip = limitação P3 pré-existente (não carrega payload.config.ts); CI valida. /simplify: 3 revisores paralelos absorvidos (twin do wa.me resolvido pelo owner move, máquina de feedback de copiar simplificada para effect único, foco/roles honestos, min-h-11 nos itens, aria-live, origin lazy sem guard morto, malformed-href fail-soft, e2e endurecido: pin de identidade do card ativo + unmount do popover antes do próximo trigger + helper de decode +); 1 débito deferido (dedupe do blob de 15 campos do updateSocialFeedSettings` nos 5 testes do describe — quando os testes S2/S3 forem tocados de novo).
S6-FOLLOWUP — CLI Playwright 1.58.2 e filtros e2e locais: pnpm test:e2e:affected rodava a suíte completa silenciosamente em runs selecionados. Causa raiz (medida com o binário do repo, config real): o commander parseia posicionais e -g/--list funcionam — quem arrastava a suíte era o closure de dependências dos projects no dev mode (setup ← campaign ← frontend ← admin em playwright.config.ts): collectProjectsAndTestFiles filtra os arquivos por project, mas buildProjectsClosure repõe todos os arquivos de cada project-dependência — filtrar um spec do frontend puxava campaign (160) + setup (2), 177 no total com o próprio arquivo (15); admin puxava a suíte inteira. Em prod mode (CI=1) os projects não têm deps e o filtro sempre funcionou (por isso o CI selecionado estava correto). Fix: run-e2e-affected.mjs agora gera test:e2e <passthrough verbatim> --no-deps -- <paths> via helpers puros novos em scripts/lib/playwright-e2e-args.mjs (unit-testados, tests/unit/playwrightE2eArgs.unit.spec.ts) — --no-deps desliga o closure em runs filtrados (suíte completa mantém a cadeia OPS34 de prewarm); de quebra, o passthrough do script foi corrigido (pnpm test:e2e:affected -- tests/x.spec.ts descartava o path — pnpm consome o -- e o argv nunca o continha; agora parsePassthroughArgs só remove um -- líder e repassa o resto verbatim, preservando -g grade). Hardening: scripts/run-e2e-affected.mjs entrou em HIGH_RISK_EXACT (era o espelho local do job e2e sem gate — um PR só de runner não rodaria e2e). Docs: receitas de filtro no playwright.config.ts (posicional exige --no-deps; --project=<família> --no-deps; -g/--list funcionam), header do script e nota no AGENTS.md. Sem mudança de specs, CI (--shard) ou gate:ci. Verificação: unit 2343+ verdes; contagem por --list do binário com a forma final (18); CI do PR roda o e2e completo (o diff toca playwright.config.ts e run-e2e-affected.mjs, high-risk). Débito absorvido: hang do pnpm test:e2e via pnpm neste worktree (pré-existente, ambiente — binário direto funciona; não escopo). Impl plan: docs/plans/s6-followup-playwright-filtros-cli-ignorados-impl.md.
S2 — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (@JorgeSollaDep), estendendo o board da S1. Fonte automática servidor-side: global SocialFeedSettings nova (grupo Configurações, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch enabled ("Pausar feed"), toggle youtubeEnabled, chave + channel ID, youtubeMaxItems (1–5, default 3) e excludedItems (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration 20260818_111748_add_social_feed_settings não destrutiva (editada para conter só o DDL — o migrate:create regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader src/utilities/youtubeFeed.ts (irmão de posts.ts, pinado no spec de convenções): parsers/format puros (formatYouTubeViews pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, fetchImpl/YOUTUBE_API_BASE_URL injetáveis), unstable_cache tag social-feed + revalidate: 300 (TTL da entrada, não da página — o bug do S1 era page-level); fail-closed com snapshot persistido por SQL direto (payload.db.drizzle, sem hooks — revalidateTag dentro de unstable_cache LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado CampaignArticleCard → CampaignContentCard (git mv; external?: boolean → <a target="_blank" rel="noopener noreferrer">); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha i.ytimg.com + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (tests/e2e/youtube-stub.mjs, webServer do Playwright vira array, porta = dev+1000; POST /__stub/state alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (E2E_PROD=1 — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. /simplify 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, Promise.all posts+feed no cache frio, comentário do sentinela 0 do merge, helper único tests/helpers/youtubeStub.ts para a porta do stub, waitForHomeSectionState fundido no waitForHomeHTML com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o validate do itemId lê data.platform que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag social-feed fora da convenção global_* (precedente election-tse/municipality-catalog), remotePattern localhost/thumbs incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).
S6 — home de campanha (jorgesolla1313.com.br) sem carrossel no desktop: as seções "Por que essa eleição importa" (3 cards) e "Nossa caminhada" (6 cards) mostram agora todo o conteúdo de uma vez em ≥1024px — grade estática 3 colunas (problem: 3×437px, bandeiras: 3×2 com altura por conteúdo, padding p-4), sem auto-avanço, sem chips/indicadores e sem aria-current. Mobile (<1024px) 100% preservado: carrossel com auto-avanço ~4s, chips sincronizados, swipe — componente e geometria mobile intocados. Padrão S1 (renderização-irmã com toggle CSS hidden lg:grid/lg:hidden): grade server-rendered (zero JS no desktop) + carrossel client embrulhado; os dois cards extraídos do CampaignCarousel para CampaignProblemCard/CampaignFlagCard (presentacionais, fonte única do markup, size='spacious' só na grade) e o contrato da grade (data-grid + aria-label + 3 colunas) vive num CampaignCardGrid local do page. O carrossel ficou byte-equivalente (só re-roteia os cards; unit tests intocados) e ganhou guard de display:none no auto-avanço — o timer nunca roda no desktop (chain morto numa subárvore oculta). Geometria: a seção flags recalibrada no desktop (height: clamp(418px, calc(807.7px - 28.13vw), 520px), âncoras medidas em 1024/1280/1440+, respiro ≥30px, overflow: hidden para zoom de texto ≥125% não pintar sobre o footer); a seção problem manteve a geometria (respiro ~31px). Zero schema/migration/Consent; mesma copy/imagens/ordem; S1 e o resto da home intocados. Verificação: unit 2318 + int 791 verdes, e2e frontend 11/11 (novo teste de grade desktop com invariantes 3×1/3×2, ausência de regiões/chips/aria-current, respiro ≥24 e scrollHeight ≤ clientHeight em 1024/1280/1440/1920; teste de carrossel migrado para viewport mobile 390px com auto-avanço + chips; swipe/mobileSpacing intactos), lint/format/typecheck/knip/cycles/build verdes. /simplify 3 reviewers: lg: órfãos removidos do carrossel, grade extraída em CampaignCardGrid, overflow: hidden (zoom ≥125%), guard do timer, document.fonts.ready + eixo x + pino de clip no e2e, âncoras no comentário do clamp. Débitos: Issue #58 registrada (S6-FOLLOWUP — CLI do Playwright 1.58.2 ignora --grep/posicionais e o test:e2e:affected roda a suíte completa; depends: [S6]); defer com gatilho para os literais de gap da grade (recalibração Penpot do carrossel); descartados contratos de tipo dos cards (armadilha knip), aria-labels duplicados (copy pinada no e2e) e pino de tablet 768–1023. Impl plan: docs/plans/home-campanha-sem-carrossel-desktop-impl.md.
S3 — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os posts do Instagram oficial, completando o board do plano-site §4.2 (artigos S1 + YouTube S2 + Instagram). Fonte automática servidor-side: loader irmão src/utilities/socialFeed/instagramFeed.ts (os feeds externos ganharam o subfolder socialFeed/ — o pin da S2 predizia exatamente isso quando a segunda plataforma entrasse; git mv do youtubeFeed + imports atualizados): Graph API (GET /{ig-user-id}/media com children{media_url,thumbnail_url} p/ carrossel + GET /{ig-user-id}?fields=username), unstable_cache tag social-feed + revalidate: 300, snapshot cru ({ username, posts }, pré-exclusão) persistido via SQL direto (padrão anti-revalidateTag-no-cache da S2), exclusões aplicadas na leitura, refresh de token automático (refresh-on-failure + retry 1x, token renovado persistido por SQL direto; nota honesta no admin: o refresh endpoint só renova tokens de Instagram Login — page tokens business caem no fail-closed + rotação manual). Global SocialFeedSettings ganha instagramEnabled/AccessToken/UserId/MaxItems + instagramFeedSnapshot (hidden) — excludedItems já cobria platform: 'instagram' desde a S2, sem migration no mecanismo; migration 20260818_192948_add_instagram_social_feed_fields não destrutiva (DDL revisado, sem o drift do migrate:create da S2). Picker de exclusão no admin (requisito nº 1 da intenção — posts de grade): primeiro componente custom do repo (src/components/admin/InstagramPostExclusionPicker.tsx, campo ui na global, path string + importmap regenerado — o import direto de @payloadcms/ui na config quebra o generate:importmap no tsx por causa do .css do react-image-crop; com path string o scanner resolve sem carregar o módulo); lista os posts do snapshot (thumbnail + legenda + data + ID) com toggle "Não exibir"/"Reexibir" + motivo opcional que escreve em excludedItems via useField. UI: CampaignContentCardData.coverAspect ('video' default — S1/S2 intactos; 'square' 1:1 do rascunho aprovado para IG), badge "Instagram", título = legenda (fallback "Publicação no Instagram" para post sem legenda — entra no automático para exclusão manual), meta = data relativa, clique no permalink (nova aba, noopener), link do header "Seguir no Instagram →" (copy do rascunho aprovado; username derivado da API — com API fora e sem snapshot o link some junto com os cards, divergência documentada do "YouTube →" que sobrevive por ter o channelId configurado). next.config ganha https://*.cdninstagram.com/**. Testes: 16 units novas (parsers com caption null/carrossel/item sem permalink, thumbnail por media_type, eligibility, fetch + refresh-retry com fetchImpl); e2e: stub irmão tests/e2e/instagram-stub.mjs (porta dev+2000; a derivação da porta virou tests/helpers/socialStub.ts — rename do youtubeStub.ts mantendo UMA spelling), 3 testes novos no describe serial (full-state com exclusão + fallback de legenda + aspect 1:1 + link do perfil + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down) + kill switch estendido com IG configurado + smoke do picker no admin (seed do snapshot via REST, toggle, Salvar, verificação do excludedItems). Verificação: unit 2351 + int 791 verdes; gate:fast/lint/format/tsc/knip (exit 0 — o erro de load do payload.config segue o ledger P3)/cycles/build verdes; e2e local: describe serial 9/9 + smoke admin 1/1 (rodando os projetos em sequência — em paralelo com --no-deps o seedTestUser dos dois beforeAll colide no mesmo usuário e quebra o run, artefato da invocação local, não do produto; o CI roda na cadeia de dependências). Detalhe do debug: o clique via CDP do MCP de browser não dispara eventos de input (ambiente do MCP, não do app — el.click() e o runner real funcionam).
S2 — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (@JorgeSollaDep), estendendo o board da S1. Fonte automática servidor-side: global SocialFeedSettings nova (grupo Configurações, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch enabled ("Pausar feed"), toggle youtubeEnabled, chave + channel ID, youtubeMaxItems (1–5, default 3) e excludedItems (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration 20260818_111748_add_social_feed_settings não destrutiva (editada para conter só o DDL — o migrate:create regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader src/utilities/youtubeFeed.ts (irmão de posts.ts, pinado no spec de convenções): parsers/format puros (formatYouTubeViews pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, fetchImpl/YOUTUBE_API_BASE_URL injetáveis), unstable_cache tag social-feed + revalidate: 300 (TTL da entrada, não da página — o bug do S1 era page-level); fail-closed com snapshot persistido por SQL direto (payload.db.drizzle, sem hooks — revalidateTag dentro de unstable_cache LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado CampaignArticleCard → CampaignContentCard (git mv; external?: boolean → <a target="_blank" rel="noopener noreferrer">); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha i.ytimg.com + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (tests/e2e/youtube-stub.mjs, webServer do Playwright vira array, porta = dev+1000; POST /__stub/state alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (E2E_PROD=1 — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. /simplify 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, Promise.all posts+feed no cache frio, comentário do sentinela 0 do merge, helper único tests/helpers/youtubeStub.ts para a porta do stub, waitForHomeSectionState fundido no waitForHomeHTML com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o validate do itemId lê data.platform que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag social-feed fora da convenção global_* (precedente election-tse/municipality-catalog), remotePattern localhost/thumbs incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).
S5 — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o <section> sai de bg-white (idêntico à faixa de números, seções coladas) para border-y border-(--campaign-line) bg-(--campaign-band) — banda #ebe9e9 da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com border-top/bottom: 1px solid var(--line)) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções toHaveCSS no e2e full-state da seção (fundo #ebe9e9 exato — pega token quebrado em vez de só "≠ branco", hairline top 1px com cor rgb(0 0 0 / 12%) exata, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega payload.config.ts); CI valida.
S5 — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o <section> sai de bg-white (idêntico à faixa de números, seções coladas) para border-y border-(--campaign-line) bg-(--campaign-band) — banda #ebe9e9 da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com border-top/bottom: 1px solid var(--line)) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções de estilo computado no e2e full-state da seção (fundo ≠ branco ≠ prova, hairline top presente, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega payload.config.ts); CI valida.
S1 — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do post collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta "há X · categoria" (relativa < 48h, data absoluta além — nunca mente congelada na ISR); card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (isPostVisible — tag hidden eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via getVisiblePosts sob a tag posts (home estática revalidada pelo mecanismo posts existente). Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 em dev e prod (E2E_PROD=1, .next-e2e), suíte completa prod 169-170 passed com flakes ambientais de campanha (máquina compartilhada; testes diferentes a cada run, nenhum no código tocado); criação via REST com hooks reais; polling positivo do estado da home pós-revalidate (ISR serve stale uma vez sob carga) e navegação para about:blank antes do cleanup (prefetch de links apagados); lint/format/typecheck/cycles/knip/build verdes. /simplify 3 reviewers: 2 P1 de a11y (foco+setas no track, inert em slides inativos) corrigidos; o revalidate = 300 proposto contra o congelamento da label relativa foi TESTADO e REVERTIDO — no Next 15.4 em prod ele trava o bust por tag (revalidateTag('posts') deixava de regenerar a home; medido em probe manual) — a label relativa ganhou threshold de 48h com fallback absoluto; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do formatRelativeAge por contrato, gutter flow-centered, link /artigos sancionado pela intenção).
S1 — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do post collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta relativa "há X · categoria"; card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (isPostVisible — tag hidden eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via getVisiblePosts sob a tag posts; a home ganhou revalidate = 300 para a label relativa não congelar na ISR. Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 (empty-state + full-state desktop/mobile, fail-closed com tag oculta, click-through, teclado/setas, swipe, dots; criação via REST com hooks reais; cleanup convergindo a cache de dev), lint/format/typecheck/cycles/knip/build verdes. /simplify 3 reviewers: 2 P1 de a11y (foco+setas no track, inert em slides inativos) e o congelamento da data relativa na ISR corrigidos; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do formatRelativeAge por contrato, gutter flow-centered, link /artigos sancionado pela intenção).
PUB5 — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com aria-label acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em font-black (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (campaignHero.unit, campaignHome.unit, frontend.e2e). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (E2E_PROD=1, .next-e2e), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). /simplify 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
Recently resolved (2026-08-20): OPS71-FLIP — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). Investigação (GitHub API pública): o closed do PR #746 (merge por github-actions[bot], auto-merge armado com o GITHUB_TOKEN nativo) não criou run nenhum — anti-recursão documentada do GitHub (eventos acionados por ações do GITHUB_TOKEN não geram runs; o closed fica no timeline com 0 runs; comparação com o #742, mergeado por fsolla, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). Endurecimento: (1) fix de raiz — o arm do auto-merge (agent-pr-ready-automerge.yml → scripts/github-pr-automerge.mjs) agora usa o secret novo AUTOMERGE_PAT (PAT real do fsolla; decisão pura automergeArmingToken em github-pr-flow.mjs com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o closed volta a gerar runs); (2) recuperação manual — workflow_dispatch com input pr nos dois flips (issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml): o job busca o PR via gh com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como PR_BODY; (3) close da Issue — forgejo-issue-transition.mjs agora PATCH state=closed via novo closeIssue do forgejo-api.mjs (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava OPEN com done+in-prod); (4) 410 no retry — forgejo-api.mjs retenta 410 em qualquer método (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto corpo não-JSON (proxy/nginx…) no erro. Testes: 8 specs novas (retry 410 GET/write, marker após 410 esgotado, closeIssue PATCH pin, automergeArmingToken ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. Docs: AGENTS.md (frase do auto-merge com o PAT + porquê), docs/AGENT-OPS.md (tabelas de workflows/secrets + runbook de recuperação manual com fallback local PR_BODY). Sem migration/access/Consent/UI. Impl: ops71-flip-pos-merge-falhou-silencioso-impl.md.
Recently resolved (2026-08-19): OPS66 — deadlock de migrations em caminho estático: o deploy buildava o runner antes do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: social_feed_settings na home), o next build falhava para sempre (relation ... does not exist) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em scripts/deploy-homeserver.sh (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): build do migrator (o estágio não roda next build — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → migrate via docker compose run teqo-1313-migrate (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → build do runner contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: payload migrate dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e docker run direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: tests/unit/deployScript.unit.spec.ts com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda next build). Runbook docs/ops/teqo-1313-deploy.md atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.
Recently resolved (2026-08-19): OPS68 — fechamento da parte 2 do OPS50: reescrita dos docs canônicos operacionais que ainda descreviam a era GitHub/Vercel. 12 arquivos, 78+/82−, edições linha-a-linha: docs/AGENT-OPS.md (norte "só Forgejo + Cursor"; fonte canônica = Issues do Forgejo; fluxo do agente e Contrato de PR sem gh — criação via API/MCP + merge pelo safety net agent-pr-ready-automerge.yml, o único que espera o rollup CI (PR) / checks; "Só humano" = envs de ~/stack/teqo-1313.env + branch protection + runbook; tabela de secrets sem POOL_GITHUB_TOKEN (legado — o script o ignora) e com nota de pool dormente; linha archive-cursor-agent.yml na tabela CI; "smoke Neon stage" → sem smoke stage), README.md (cheatsheet de 5 linhas reescrito: claim → push → PR via API/MCP → safety net mergea → ci.yml em janela de 30 min + deploy homeserver; Neon → homeserver Postgres; PROD_DATABASE_URL = DATABASE_URL de ~/stack/teqo-1313.env; migrações no deploy via teqo-1313-migrate; "GitHub mirror" → Forgejo Actions; papéis coordinator/advisor/leader/candidate; "electoral nuclei" → municípios; Issues → link Forgejo), docs/roadmap.md (só o ponteiro da fonte canônica — o stub continua congelado), AGENTS.md (refs Neon → homeserver teqo_1313; job do gate check→gate, key real do YAML; "Vercel preview" → host genérico na rationale do WebAuthn), .agents/rules/agent-pr-workflow.mdc (comandos gh pr create/merge/checks morrem — gh não existe em nenhuma máquina (OPS50); regra descreve API/MCP + safety net; "There is no deploy from CI" corrigido — OPS53 colocou o deploy no ci.yml), e as skills work-issue (SKILL + execution-pipeline), plan-issue (frontmatter, gate "nada no Forgejo", dedup via pnpm issue all), project-status, capture-review-debts, engineering-audit (gh issue list → pnpm issue all/API; issue-done-on-stage-merge.yml→on-main; merge stage→main), worktree-next-issue ("resto do Forgejo") e agent-pool (descrição Forgejo Actions; workflow marcado removido OPS65; estado em pool-state.json, não mais variables). Mantido como registro histórico (exclusões explícitas do plano): menções de Vercel/Neon/POOL_GITHUB que documentam remoções (AGENTS.md L15/L105/L167, AGENT-OPS L94/L98, "Vercel/Git-integration deploy gate" como guardrail no rule). Fonte canônica única: git.solla.dev/fsolla/teqo. Sem migration/access/Consent/UI. Gate: grep de saída zero nas classes gh pr/gh issue/github.com/fsolla/GitHub Issues/GitHub mirror/POOL_GITHUB/Neon-em-uso; docs-only → CI roda os docs-guards.
Recently resolved (2026-08-19): OPS71 — CI/PR/deploy do Forgejo Actions de volta para o GitHub Actions, com o tracker de Issues permanecendo no Forgejo (labels, claims, pnpm issue/agent:* e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (ci-pr.yml, job único checks — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via ci-scope.mjs), com concurrency nativo cancelando o run em push novo e if: same-repo (repo público — fork não queima minutes). Seu check-run CI (PR) / checks é o required check do main e o gate do auto-merge nativo (agent-pr-ready-automerge.yml → scripts/github-pr-automerge.mjs: draft cursor/* → Ready via PATCH, arm enablePullRequestAutoMerge GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). ci.yml (verificador de main) eliminado — publicar é dispatch manual (deploy.yml): verify (hosted, suíte full incl. e2e full) → deploy (needs, runs-on: [self-hosted, homeserver]) executa scripts/deploy-homeserver.sh <sha> localmente no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace ~/teqo-deploy para TEQO_REPO_URL (default https://github.com/fsolla/teqo.git, público). pnpm push passa a empurrar para origin = GitHub (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: scripts/lib/github-api.mjs (REST+GraphQL, retry OPS67), scripts/lib/github-pr-flow.mjs (decisão draft/veto/skip, pura), scripts/lib/github-branch-protection.mjs (regra desejada + drift, enforce_admins: true), scripts/github-pr.mjs (criação de PR Ready — nunca draft), scripts/configure-branch-protection.mjs reescrito para a REST do GitHub; forgejo-issue-transition.mjs aceita PR_BODY (body do PR via evento GitHub; flip continua no Forgejo por FORGEJO_API_TOKEN). Cutover em duas fases (gate): nada do Forgejo foi apagado (.forgejo/workflows/, forgejo-pr-automerge.mjs, forgejo-dispatch.mjs, ci-classify-production.mjs preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: githubApi (10), githubBranchProtection (10), githubPrFlow (9) unit novos. Docs: AGENTS.md, docs/AGENT-OPS.md, .agents/rules/agent-pr-workflow.mdc, work-issue/execution-pipeline.md, agent-work-issue, capture-review-debts, runbook docs/ops/teqo-1313-deploy.md (dispatch manual, runner no homeserver, cutover com ordem, rollback).
Recently resolved (2026-08-19): OPS71 Fase 2 — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). 14 arquivos deletados: .forgejo/workflows/ (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), scripts/forgejo-pr-automerge.mjs, scripts/forgejo-dispatch.mjs, scripts/ci-classify-production.mjs, scripts/lib/dockerignore.mjs, scripts/lib/branch-protection.mjs e as specs órfãs ciClassifyProduction/dockerignoreMatch/branchProtection .unit.spec.ts. Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança: scripts/lib/test-affected-core.mjs perdeu as 2 entries de HIGH_RISK_EXACT dos scripts mortos; tests/unit/ciSkipInvariants.unit.spec.ts tirou os 2 .forgejo/workflows/* do loop .next-e2e, os 2 paths do pin de high-risk e reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub: deploy.yml é workflow_dispatch-only (sem push:/schedule: — deploy nunca automático) e ci-pr.yml não referencia o gate morto. Docs: AGENTS.md (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook docs/ops/teqo-1313-deploy.md (Rollback do cutover idem), README.md (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em scripts/gate-ci.mjs, scripts/github-pr-automerge.mjs e .github/workflows/agent-pr-ready-automerge.yml. Intocados (anti-goal tracker): forgejo-api.mjs, agent-*.mjs, issue.mjs, forgejo-issue-transition.mjs, agent-promote-related-on-merge.mjs, .forgejo/worktree.env (consumido pelo provisionamento de worktree), .dockerignore (o arquivo — docker build do deploy o consome; só o lib de parse morreu). docs/AGENT-OPS.md já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs → só histórico.
Recently resolved (2026-08-18): OPS52 — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket teqo-media + key teqo-key, já existentes no Garage v2.1.0). O plugin @payloadcms/storage-vercel-blob (e o @vercel/blob do seed) foi trocado por @payloadcms/storage-s3 na config, no padrão R2 da doc do Payload (endpoint + forcePathStyle: true, region garage). Decisão de arquitetura (revisão A→B durante a execução, gate com o humano): o Garage v2.1.0 não suporta acesso anônimo na API S3 (sem --public-read; source: "when we support anonymous access in the future"; testado: anonymous/anon → NoSuchAccessKey), então o bucket fica privado e a app serve a media por proxy — sem disablePayloadAccessControl, o staticHandler do plugin streama o objeto do Garage em /api/media/file/<filename> (range + etag/304 nativos) — o mesmo contrato de URL relativa que as 40 rows pré-existentes já têm (a recuperação dos arquivos é a Issue #10/OPS52-media). Fail-closed: resolveS3StorageEnv (src/utilities/mediaStorage.ts, unit-testado) — envs S3_* parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: S3_BUCKET, S3_ENDPOINT, S3_REGION (default garage), S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY (.env.example, .env.test, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet http://100.119.220.31:3900), depois media criada pelo Payload local com S3 ativo → media.url relativa + GET /api/media/file/... → 200 image/png; bucket devolvido a 0 objects e .env.local do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem VercelBlobClientUploadHandler). Runbook de cutover registrado no impl plan: setar as 5 S3_* no ~/stack/teqo-1313.env (endpoint tailnet) antes do próximo deploy (OPS53). Repo: docs/plans/ops52-blob-vercel-para-garage-impl.md; infra-solla STATE.md atualizado à parte.
Recently resolved (2026-08-18): OPS52-media — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa /api/media/file/<filename> mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com pnpm media:recover (scripts/recover-media.mjs, novo): lê as rows de media + post.coverImage (mapeamento exato row→slug; fallback pelo filename determinístico <slug>.<ext>), resolve a capa com a MESMA lógica do seed (featured media do REST _embed, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz PutObject no bucket das envs S3_* com key = filename da row, sem nunca escrever no DB (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para scripts/lib/wpArticles.mjs o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 S3_* completas (resolveS3StorageEnv reusado; recuperação nunca vai pro storage local), ALLOW_REMOTE_DB=true explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (@smithy/node-http-handler, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), --dry-run (plano + headObject), --verify (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: docs/plans/ops52-media-recuperar-arquivos-impl.md.
Recently resolved (2026-08-18): OPS52-media — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa /api/media/file/<filename> mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com pnpm media:recover (scripts/recover-media.mjs, novo): lê as rows de media + post.coverImage (mapeamento exato row→slug; fallback pelo filename determinístico <slug>.<ext>), resolve a capa com a MESMA lógica do seed (featured media do REST _embed, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz PutObject no bucket das envs S3_* com key = filename da row, sem nunca escrever no DB (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para scripts/lib/wpArticles.mjs o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 S3_* completas (resolveS3StorageEnv reusado; recuperação nunca vai pro storage local; --verify dispensa S3 — só HEAD de URLs públicas), ALLOW_REMOTE_DB=true explícito para DB remoto + echo do alvo (DB/bucket/endpoint/origin), timeouts curtos no SDK (@smithy/node-http-handler, 5s/15s) e nos fetches (30s/60s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan), e covers constrangidas a http(s) no host do WordPress com só image/* armazenado (sem SSRF/objeto não-imagem no proxy). Modos: reconcile (padrão, upload idempotente por overwrite), --dry-run (plano + headObject; exit 1 se qualquer checagem falhar), --verify (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: docs/plans/ops52-media-recuperar-arquivos-impl.md.
Recently resolved (2026-08-18): OPS54 — fim do warning do pnpm sobre o campo pnpm ignorado em package.json. A allowlist onlyBuiltDependencies (sharp, esbuild, unrs-resolver) migrou para o home canônico do pnpm 10: novo pnpm-workspace.yaml na raiz (sem packages — repo single-package, default ['.']), e o bloco "pnpm" saiu do package.json (pnpm 10 não o lê mais; sem a remoção o warning persiste). Dockerfile estágio deps agora copia pnpm-workspace.yaml junto (COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./) — sem isso a allowlist não chegaria ao install da imagem e local×Docker divergiriam nos build scripts. Validado com a versão pinada (10.11.0) e no estágio deps real (docker build --target deps): nenhum [WARN] do campo pnpm em install/CI/Docker, pnpm config get onlyBuiltDependencies → os 3 pacotes, --frozen-lockfile limpo (lockfile e grafo intocados). Guardrails respeitados: .npmrc (legacy-peer-deps, include) e packageManager intocados; nada além do que o warning citava foi migrado (o aviso informativo de msw/onnxruntime-node é comportamento padrão do pnpm 10 — fora de escopo por anti-goal). Sem migration, sem UI, sem testes de domínio (config de ferramenta; gates completos verdes). Repo: docs/plans/ops54-pnpm-warning-onlybuiltdependencies-impl.md.
Recently resolved (2026-08-18): OPS56: o forgejo-pr-automerge nunca mais reporta falso positivo "já mergeado" com PR aberto. Raiz confirmada no source do servidor (Forgejo 9.0.3, tag v9.0.3): o endpoint POST /pulls/{n}/merge responde 200 com corpo vazio no sucesso (falhas são 404/405/409/500), e o request() do forgejo-api.mjs convertia isso em null — o autoMerge() devolvia null cru e o CLI imprimia "já mergeado" (exit 0) independente do estado real do PR; o incidente (PR #27 da OPS52, aberto das 01:59 às 02:45) deixou o merge falhar silenciosamente com o PR aberto. Correção: o veredito de autoMerge agora é re-read do PR após a tentativa ({ attempted, merged, pr }) — 200-vazio → confirma no GET /pulls/{n}; POST falhou e o PR segue aberto → re-throw (falha ruidosa, exit 1); raça perdida (outro ator mergeou) → "já mergeado" só com o PR realmente mergeado; PR fechado sem merge → "fechado sem merge — skip" honesto. waitForChecks passou a esperar o mergeable amadurecer (false → throw imediato de conflito; null → segue no poll), mesmo timeout de 30 min. CLI sem shadowing de pr (const redeclarada). Testes: 6 specs novas em forgejoApi.unit.spec.ts (veredito por re-read, POST 405 + PR aberto → reject, raça concorrente, no-op de já-mergeado, poll de mergeable null, fail-fast de mergeable false). Gates: 2318 unit + 791 int + tsc/lint/prettier/knip/check:cycles verdes. Débito deferido: alargar o gatilho do agent-pr-ready-automerge.yml para branches não-cursor/* (decisão de ops separada).
Recently resolved (2026-08-18): OPS52-media-guard — o modo reconcile (escrita) do pnpm media:recover ganhou guard de intenção explícita (achado do /simplify da #10): exige MEDIA_RECOVER_CONFIRM=1 para armar a escrita no bucket — antes, bastava o conjunto completo de S3_* (cópia all-or-nothing de credenciais de prod num worktree + rows locais via db:pull → escrevia covers no bucket de prod; o echo do alvo mostrava, não impedia). --dry-run/--verify seguem sem flag (não escrevem). Sem a flag, reconcile recusa com exit 1 e a mensagem exibe o comando correto. Implementação no dono dos guards: isTruthyEnv novo em scripts/lib/cli.mjs (semântica exata true|1 do ALLOW_REMOTE_DB — sem trim/case; isRemoteDbOverrideSet passou a delegar, sem twin), unit-testado com pin do comportamento preservado (TRUE/yes continuam recusados). Runbook do homeserver atualizado com a flag; nota no AGENTS.md.
Recently resolved (2026-08-18): OPS57 — o safety net do forgejo-pr-automerge agora cobre todas as PRs same-repo para main, não só cursor/*. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, zero eram cursor/* — 9 de plans/*, 11 de worktrees humanos, 5 de refs/pull/N/head (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: if: do job em agent-pr-ready-automerge.yml → state == 'open' && head.repo.full_name == github.repository (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de is_draft do webhook); política de draft movida para o CLI forgejo-pr-automerge.mjs com o head ref do re-read da API — draft cursor/* → Ready (pool inalterado); draft não-cursor/* → skip exit 0 (draft = veto do ator, PR humana nunca é forçada); PRs ready não-cursor/* (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma --auto --rebase) e é o que pega a classe OPS52; ready_for_review em PR draft re-dispara o job e mergea. Docs sincronizadas (docs/AGENT-OPS.md tabela CI por alvo, .agents/rules/agent-pr-workflow.mdc seção Safety net). Rejeitado: !is_draft no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de main (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: nenhum novo (lógica de veredito já coberta pelos specs da OPS56; branch de draft é 3 linhas no CLI); gates unit/int verdes.
Recently resolved (2026-08-18): OPS58 — o sync de posts de produção ficou pronto para executar com runbook documentado: scripts/seed-posts.mjs ganhou o modo --dry-run (plan-only: fetch ao vivo + echo do alvo + relatório do que SERIA criado/saltado — zero escrita, pre-flight padrão OPS52-media antes de tocar o DB de prod; argumento desconhecido morre) e a taxonomy CLASSIFICATION foi atualizada com os 4 slugs novos do jorgesolla.com.br (43 posts no WP vs 39 mapeados): os 3 de campanha/pré-campanha (convenção estadual do PT, plenária "PT tem que disputar o programa de governo", caravana da Chapada) entraram com tags: ['eleitoral'] para ficarem sob o controle de visibilidade eleitoral do repo (isPostVisible fail-closed — critério do tag documentado no seed), e o embate com Bruno Reis em politica. A execução em prod (pós-merge, homeserver, env do stack, ALLOW_REMOTE_DB=true) segue o runbook: dry-run → sync (create-only, idempotente por slug) → bust do cache posts via /api/revalidate → verify (media:recover --verify para capas + curl nas páginas dos novos). Runbook para as próximas sincronizações: docs/plans/ops58-sincronizar-posts-prod-impl.md.
Recently resolved (2026-08-18): OPS57 — o safety net do forgejo-pr-automerge agora cobre todas as PRs same-repo para main, não só cursor/*. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, zero eram cursor/* — 9 de plans/*, 11 de worktrees humanos, 5 de refs/pull/N/head (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: if: do job em agent-pr-ready-automerge.yml → state == 'open' && head.repo.full_name == github.repository (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de is_draft do webhook); política de draft movida para o CLI forgejo-pr-automerge.mjs com o head ref do re-read da API — draft cursor/* → Ready (pool inalterado); draft não-cursor/* → skip exit 0 (draft = veto do ator, PR humana nunca é forçada); PRs ready não-cursor/* (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma --auto --rebase) e é o que pega a classe OPS52; ready_for_review em PR draft re-dispara o job e mergea. Docs sincronizadas (docs/AGENT-OPS.md tabela CI por alvo, .agents/rules/agent-pr-workflow.mdc seção Safety net). Rejeitado: !is_draft no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de main (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: spec nova em forgejoApi.unit.spec.ts (waitForChecks para quando o PR vira draft no meio do wait — veto garantido mesmo em conversão mid-run; autoMerge idempotente); gates unit/int verdes. Validação ao vivo na própria entrega: o Forgejo roda o workflow do head da PR (não o de main), então a PR #48 foi mergeada pelo safety net alargado no primeiro uso real — o if: com head.repo.full_name avaliou true em PR real e o script mergeou por rebase. Fixes pós-review do /simplify: waitForChecks/autoMerge param em PR draft (antes: 405 ruidoso na conversão mid-run, contradizendo o "veto nunca tocado"); CLI valida base.ref === 'main' (invocação manual); comentário do fork guard documenta por que ele não pode descer pro CLI (checkout de fork roda o script do fork = RCE com o token do Actions); docs sem o mecanismo fantasma gh pr (era GitHub, OPS50 trocou por CLI plain-Node).
Recently resolved (2026-08-18): OPS59 — o pre-push local não roda mais e2e: ele roda uma vez, no CI (PR e main — gateia merge e deploy). O espelho scripts/gate-ci.mjs cortou o bloco e2e da cascata (playwright install, build .next-e2e, test:e2e) e o preflight de DB passou a depender só de test/build; o readout do classificador mantém e2e=<mode> e imprime o que o CI rodará ("e2e runs in CI, not in this gate — OPS59"), com o check unmapped duplicado removido (ci-scope.mjs já o imprime no stderr). package.json, .husky/pre-push, git-push.mjs e os workflows .forgejo/* intocados (CI verificado, não alterado: ci-pr.yml e2e → checks; ci.yml e2e → checks → deploy). tests/unit/ciSkipInvariants.unit.spec.ts tirou gate-ci.mjs da lista de arquivos que devem conter .next-e2e (o gate local não builda mais e2e; run-e2e-affected.mjs/playwright.config.ts seguem cobertos — e2e local opcional intacto). Docs sincronizadas: AGENTS.md (checklist local item 3 — test:e2e/test:all saem da obrigatória; "e2e = CI, local opcional pnpm test:e2e:affected"), .agents/rules/engineering-standards.mdc e agent-pr-workflow.mdc (gate descrito como "espelho do ci-pr.yml sem e2e"). Verificado por grep: AGENT-OPS.md e skills (work-issue, agent-work-issue, worktree-next-issue, local-database) não citavam e2e no pre-push — sem mudança. Anti-goals respeitados: sem flag de "pular e2e" (bypass continua só via --no-verify documentado), política "Dono do PR, dono do CI" inalterada. Sem migration/access/Consent/UI. Verificação: pnpm gate:ci local (cascata nova full — lint/format/typecheck/knip/cycles/unit/int/build em teqo_test; e2e vira print), unit 2286+ verdes; o e2e desta PR roda full no CI por HIGH_RISK (gate-ci.mjs).
Recently resolved (2026-08-18): OPS60 — o sync do pnpm db:seed:posts ganhou guard de intenção explícita para escrita no bucket (achado do /simplify da #37): com as envs S3_* ativas, o modo sync exige SEED_MEDIA_CONFIRM=1 (mesmo padrão isTruthyEnv do MEDIA_RECOVER_CONFIRM da OPS52-media-guard) e recusa com exit 1 antes de qualquer escrita sem a flag — o risco fechado: um worktree com cópia de credenciais S3_* de prod (all-or-nothing do worktree-env.mjs) rodando sync contra DB local sobrescrevia covers no bucket de prod (o seed é idempotente por slug e sobrescreve o key determinístico). Storage local (sem S3_* → disk) e --dry-run seguem sem flag (não escrevem); echo do alvo ganhou Bucket/Endpoint quando S3 ativo (paridade com o media:recover). Implementação no dono: isTruthyEnv (já existente da #37) + resolveS3StorageEnv — sem twin. Runbook do OPS58 atualizado com o comando real; nota no AGENTS.md.
Recently resolved (2026-08-18): OPS61 — contratos de merge/flip do Forgejo consertados. (S1) Os workflows pós-merge (issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml) rodavam com o GITHUB_TOKEN nativo, que 403 em escrita de Issues — o flip Closes #N → done+in-prod (PRs #42/#48) quebrava em silêncio (o script engolia o 403 e o job terminava success; flip manual necessário). Agora injetam FORGEJO_API_TOKEN (o PAT que o pool já usa para o mesmo tipo de escrita) via env: no job, e o forgejo-issue-transition.mjs sai exit 1 se qualquer flip falhar — o job vermelha em vez de mentir. Fork guard nos dois workflows (head.repo.full_name == github.repository, a mesma classe do agent-pr-ready-automerge): PR de fork mergeada faria checkout do script do fork rodando com o PAT do repo (RCE). (S3) A branch protection de main não existia no servidor (0 regras no GET /branch_protections — o pnpm configure:branch-protection era passo humano pendente do OPS50 e nunca rodou; as docs diziam "aplicado" e mentiam), e o valor checks do script nunca casaria os contextos reais do Forgejo (casamento por glob contra CI (PR) / checks (pull_request) — o sufixo (evento) faz parte do contexto; literal checks = todo merge bloqueado). Script corrigido: contexto CI (PR) / checks*, idempotente (GET → POST/PATCH/no-op, verifica após aplicar, dry-run honesto) com decisão pura em scripts/lib/branch-protection.mjs + spec nova. (2b — achado ao vivo na própria entrega, PR #67) O safety net tinha dois bloqueios independentes. (1) O gate antigo do waitForChecks (pending.length === 0) era insatisfazível: o Forgejo posta o status do próprio job do safety net como pending durante o run inteiro. (2) O merge POST faz um push do branch rebaseado para main e o hook pre-receive do Forgejo rejeita esse push quando o usuário do token não tem write no repo — o GITHUB_TOKEN nativo age como o usuário sistema do Actions (o mesmo que 403 em Issues), então o merge do CLI morria com 409 "User permission denied for writing" (confirmado na fonte 9.0.3: o hook cai no AssertCanWriteCode antes do caminho de merge). Resultado: o CLI nunca auto-mergeou nenhuma PR — todo merge Forgejo até aqui foi manual (merged_by do dono), inclusive o da PR #52 com o CI vermelho (a narrativa da "raça do snapshot" da intenção estava errada: o merge vermelho foi manual; e runs que "exitaram 0" eram leituras de PR já mergeada). Gate novo: espera o rollup CI (PR) / checks presente+verde (único contexto que só existe quando a cascata inteira do ci-pr assentou) e ignora o próprio contexto do safety net; mergeable === false só conta como conflito após a cascata assentar; timeouts sobem (CLI 30→45 min, workflow 40→55 min); e o workflow do safety net agora injeta FORGEJO_API_TOKEN (mesmo PAT dos pós-merge) — o merge com o PAT (admin) passa no hook. A regra no servidor é o gate real para todo mundo: o merge POST é bloqueado com 405 enquanto o contexto exigido não estiver verde — inclusive merges manuais via API (a PR #52 não teria entrado vermelha). Validação ao vivo na própria entrega: a regra foi criada e verificada no servidor (re-run = no-op), e a PR #67 (Closes #53) mergeou pelo novo gate do rollup com a regra aplicada; o issue-done flipou a #53 para done+in-prod com o PAT. Sem migration/access/Consent/UI. Testes: forgejoApi.unit.spec.ts (rollup não-posto continua poll, rollup failure lança, próprio status do safety net não bloqueia, mergeable pré-settle — fixtures dos waitForChecks/autoMerge existentes atualizados para o contexto real), branchProtection.unit.spec.ts nova (create/update/noop, glob casa o contexto observado).
Recently resolved (2026-08-18): OPS62 — fail-fast estrutural do CI: ci-pr.yml e ci.yml deixaram de ter jobs irmãos. Cada workflow agora tem um único job checks sequencial (ci-pr: guards de conteúdo → lint → format → typecheck → knip → cycles → unit → int → build → e2e, com skips do ci-scope.mjs agora como step com outputs; ci.yml: mesma mecânica full + deploy OPS53 intacto), então step vermelho interrompe o job, o run falha e o slot do runner é liberado imediatamente — sem API de cancel (o Forgejo 9.0.3 não tem), sem watchdog, sem upgrade do Forgejo no homeserver (decisão X1 do gate de execução substitui o watchdog+upgrade da intenção; o aceite "API de cancel/upgrade" e "mesmos jobs, mesma cascata" foi revisado pelo humano — o que o CI verifica permanece idêntico, só o envelope muda). Diagnóstico preservado: steps têm logs próprios. O job id checks mantém o contexto CI (PR) / checks que o automerger/waitForChecks e a branch protection esperam (OPS61). Paralelismo do e2e recuperado por PLAYWRIGHT_WORKERS=4 num único processo (substitui os 2 shards × 2 workers; mesma carga); 2 services Postgres por nome na rede do job (postgres-int/postgres-build, sem publish host — o job único seguraria portas fixas pelo run inteiro e runs concorrentes colidiriam; o Forgejo 9 não expande expressões em services.ports, medido ao vivo no run 730 falhando em 1s com a string literal; o DNS da rede do job mata a colisão de raiz — os guards fail-closed LOCAL_HOSTS/assertTestDatabase admitem os nomes dos services, ainda rejeitando hosts remotos) preservam a topologia de hoje (e2e nunca herda resíduo de fixtures do int); 1 checkout + 1 pnpm install por run (antes ~10). Plumbing morto removido: e2eShardConfig e o output e2e_shards do classifier (sem consumidor). Guards de conteúdo movidos para o início do pipeline (falham rápido e são PR-scoped). /simplify (3 reviewers): P1 aplicados — set -euo pipefail no step scope (crash do classifier com outputs vazios silenciaria skips → false green) e services por nome (acima); P2 aplicados — pins de invariantes ancorados em chaves YAML (comentários não satisfazem needs: [checks]/matrix:/fail-fast:), pin do wiring crítico id: scope + if: e2e_mode == 'full' (perdê-lo pula a suíte em silêncio), comentário morto do sharding em playwright.config.ts e docs/TESTING.md corrigidos (ainda citavam .github/workflows e jobs paralelos); P3 — JSDoc do ci-scope.mjs e redação do impl plan; o P2 do waitForChecks (timeout 30 min vs job 50 min) fica como débito de CI anotado no impl. Docs: tabela CI do AGENT-OPS reescrita + linha morta do styfle/cancel-workflow-action (removida no OPS50) apagada. Sem migration/access/Consent/UI. Testes: invariantes novos em ciSkipInvariants.unit.spec.ts (um único job checks, sem matrix:/fail-fast:/runs-on: host no ci-pr, PLAYWRIGHT_WORKERS: 4, guards antes do lint, deploy gated em needs: [checks], services por nome sem ports:); localHosts.unit.spec.ts com os nomes dos services; testAffected.unit.spec.ts sem o describe do shard config. Validação ao vivo na própria entrega: o ci-pr deste PR já é o envelope novo — o primeiro run (730) pegou a expressão de porta não suportada (achado → corrigido para DNS de service), o run verde prova a suíte inteira no job único; a medição do tempo de parede fica registrada no run. Custo aceito (X1): run verde ~21 min esperados (vs ~15–18 com jobs paralelos) e e2e vermelho é o caso mais lento de veredito; débito registrado no impl plan (matrix fail-fast Y) se a fila do runner apertar. Impl: docs/plans/ops62-ci-fail-fast-forgejo-impl.md (a intenção fica como histórico do gate).
Recently resolved (2026-08-18): OPS63 — os docs-guards (OPS44) rodam também no pre-push local: o espelho scripts/gate-ci.mjs ganhou os três checks na fase 1 (changelog append-only, agregado sync --check, marcadores de conflito em markdown) como chamadas diretas dos mesmos scripts do CI — labels espelhando os steps do job docs-guards; header do arquivo e linha final atualizados. package.json, .husky/pre-push, git-push.mjs e workflows .forgejo/* intocados: gate:push = gate:ci continua a cadeia única, o job docs-guards segue dono do escape changelog-rewrite: (body do PR — CI-only; restauração legítima D8 falha local com a mesma mensagem do CI e usa o bypass documentado git push --no-verify, com o CI como via do escape). Docs sincronizadas: .agents/rules/engineering-standards.mdc e agent-pr-workflow.mdc (gate descrito como "espelho do ci-pr.yml sem e2e, com docs-guards locais"), docs/AGENT-OPS.md (contrato item 4 — OPS44: checks no pre-push + nota do escape CI-only). Verificado por grep: AGENTS.md e skills não descreviam o gate sem os checks de docs — sem mudança. Anti-goals respeitados: sem bypass novo (falha local honesta), sem plans-only-closes local (contrato de PR body). Sem migration/access/Consent/UI/schema. Verificação: três checks green na branch; caminho de falha validado (commit descartável removendo linha do agregado → append-only falha com mensagem acionável); pnpm gate:ci local full; CI roda full nesta PR por HIGH_RISK (gate-ci.mjs).
Recently resolved (2026-08-18): OPS65 — CI do main em janela fixa de 30 min + agent-pool morto. O ci.yml não dispara mais a cada push: roda por schedule */30 + workflow_dispatch, e um gate barato (gate, runs-on host, main-only, sem pnpm — scripts/ci-classify-production.mjs + matcher puro scripts/lib/dockerignore.mjs) lê a revision do container rodando no homeserver (label org.opencontainers.image.revision — o compose pode mentir após rollback falho), diffa contra o HEAD de main e classifica "mudança de produção" usando o .dockerignore como fonte da verdade (o que não entra no build context não justifica build novo; docs//.agents/.env* viram skip; scripts//tests/.forgejo/AGENTS.md contam como produção — conservador, qualquer dúvida roda a suite). Deployed == HEAD → suite e deploy skipped, run verde (o rollup checks já aceita skipped); qualquer outra coisa → suite full + deploy na mesma janela; dispatch sempre roda tudo; o deploy-homeserver.sh ganhou guard de idempotência "already deployed" (revision do container já no SHA → skip verde). Fail-open: revision desconhecida → suite roda (main sempre verificada); gate incapaz de classificar → suite roda e o run fica vermelho (nunca blackout silencioso); deploy duplo-gated (needs: [checks, gate] + if explícito — nunca deploy às cegas nem com suite vermelha). agent-pool.yml removido (o tick */10 custava 144 pnpm install/dia na workstation; scripts/skills do pool ficam dormentes). Testes: dockerignoreMatch + ciClassifyProduction unit novos (matcher contra o .dockerignore real, fail-open, dispatch, caminho git com repo scratch, exit codes), pins do ciSkipInvariants (janela 30, sem push, ref guard, ifs fail-open) e do deployScript (guard), HIGH_RISK_EXACT += classificador. Docs: runbook docs/ops/teqo-1313-deploy.md (janela/gate/"already deployed"/falhas), AGENTS.md, docs/AGENT-OPS.md.
Recently resolved (2026-08-18): OPS64 — o safety net ready-automerge deixa de se auto-bloquear e o gate do merge fica estruturalmente honesto. O sintoma primário da Issue (deadlock: o waitForChecks contava o próprio status pending do job → timeout 30 min → merge nunca acontecia) já tinha sido entregue pelo OPS61 (filtro do próprio contexto + gate do rollup + PAT; provado vivo: runs 710/712 mergearam os PRs #67/#70). O secundário — o rollup checks reportava SUCCESS com um shard de e2e vermelho porque o Forgejo 9.0.3 reporta o resultado do último shard como o resultado do parent de matrix no contexto needs (runs 604/607/610: e2e (1) vermelho + e2e (2) verde → checks verde; o needs.<job>.results do GitHub não existe neste runner) — foi eliminado estruturalmente pelo OPS62 (mergeado em main durante a execução deste item): o ci-pr.yml e o ci.yml viraram jobs únicos sequenciais (sem matrix, sem shards), então o status do job checks já é o veredito honesto da cascata inteira. Este item entregou o que restava de valor: (a) teste-pino no waitForChecks (forgejoApi.unit.spec.ts, cenário run 607) — o CLI nunca confia no rollup sozinho: varre os statuses individuais e lança "Checks falharam" com rollup verde + shard vermelho (invariante de defesa que continua válido e pegaria qualquer matrix que volte); (b) docs honestas (agent-pr-workflow.mdc, AGENT-OPS.md) registrando o veredito do rollup, o pin do CLI e a eliminação estrutural do bug pelo OPS62. O script ci-rollup-check.mjs e os edits de workflow planejados foram descartados na execução (redundantes pós-OPS62 — decisão do gate com o humano; os workflows em main são os do OPS62, intocados). Sem migration/access/Consent/UI/schema. Verificação: unit 2360 verdes; lint/format/typecheck/knip/cycles no gate local; a própria PR prova o ciclo (o ready-automerge mergea no fim). Impl plan: docs/plans/ops64-forgejo-pr-automerge-waitforchecks-impl.md.
Recently resolved (2026-08-17): OPS51 — o banco de produção saiu do Neon para o Postgres do homeserver (teqo_1313, container teqo-1313), sem mudança de config: o ~/stack/teqo-1313.env já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. Divergência deliberada da intenção (questão A/B validada como B): rejeitado criar teqo_prod novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata teqo_1313 como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (vercel env pull, projeto jorgesolla, DATABASE_URL_UNPOOLED → NEON_DATABASE_URL em ~/stack/.env chmod 600), pg_dump -Fc (Neon PG 17.10) → /srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump (5,8 MB, sha256 f12196f2…), pg_restore --no-owner --no-privileges como a role teqo_1313 (drop schema + grant, padrão do db-pull), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (last_value == max(id)), objetos com dono teqo_1313, payload migrate no-op (56 = 56, serviço de maintenance teqo-1313-migrate), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: /, artigo com conteúdo restaurado, /campanha/login, /admin, POST /api/revalidate (posts + global_privacy-policy), WebAuthn login-options, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. Rollback documentado: Neon nunca foi escrito (só lido); voltar = re-apontar DATABASE_URL do teqo-1313.env para o Neon + compose up -d. Débito registrado (→ OPS52): as 40 rows de media têm URLs relativas (/api/media/file/…) sem arquivos em lugar nenhum — capas dão 500 no homeserver e 404 no próprio Vercel/produção atual (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, AGENTS.md e help de db:pull/check-push-chain sem a referência ao Neon; infra-solla STATE.md atualizado à parte.
Recently resolved (2026-08-17): OPS53 — o deploy do site 1313 virou etapa do CI: mergir em main com a suite verde == site atualizado em jorgesolla1313.com.br, sem passo manual. O job deploy em .forgejo/workflows/ci.yml roda no host do runner (workstation, runs-on: host) e streama scripts/deploy-homeserver.sh para o homeserver via SSH (ssh homeserver "bash -s -- <sha>"); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (git ls-remote de main == SHA do job, senão skip "stale"), flock de serialização, workspace ~/teqo-deploy (clone do Forgejo local), docker login localhost:5000 (REGISTRY_USER/PASSWORD de ~/stack/.env), build dos stages runner+migrator no homeserver (--network stack_default para a geração estática alcançar o postgres:5432 do compose; secrets de ~/stack/teqo-1313.env via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup docker-compose.yml.pre-<sha>), migrate pelo serviço de maintenance teqo-1313-migrate antes do rollout, docker compose up -d, espera do healthcheck e smoke (/, /campanha/login, /admin, barreira 307, WebAuthn login-options com body JSON, POST /api/revalidate com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + up -d da imagem anterior) + job vermelho. Divergência deliberada da intenção (fato novo): a intenção assumia o runner no homeserver; verificado (infra-solla/STATE.md + ao vivo) que o runner é a workstation-runner na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job runs-on: host na workstation + todo o deploy no homeserver via SSH", o que manteve zero secrets novos no Forgejo e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): docs/ops/teqo-1313-deploy.md (novo). Guarda estrutural nova: tests/unit/deployScript.unit.spec.ts (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla STATE.md anotado à parte.
Recently resolved (2026-08-16): CAMPAIGN-HOME-RESPONSIVE: a primeira dobra da home pública agora acompanha as referências do Penpot em 393, 768, 1024, 1280, 1366 e 1920 px. Retrato, aliados, marca, texto, CTAs e prova parlamentar interpolam entre os tamanhos com clamp(), vw e dvh, sem saltos por proporção de tela nem overflow horizontal; texto, ações e condecorações permanecem no fluxo e crescem com os ajustes de espaçamento da WCAG 1.4.12. No desktop, a escala e a posição horizontal das fotos dependem somente da largura: Solla, Lula e Wagner ficam ancorados ao rodapé real do hero até quando o conteúdo aumenta a seção, e um palco centralizado de 1920 px preserva a proximidade entre retrato e texto em monitores ultrawide. O E2E fixa a geometria das seis referências, larguras intermediárias, transições de proporção, desktops baixos, ultrawide e os overrides de acessibilidade; o caso de regressão também foi executado no Firefox. Sem migration, schema ou mudança de conteúdo.
Recently resolved (2026-08-16): OPS50 (parte 1): CI e scripts de agentes portados do GitHub para o Forgejo (git.solla.dev, tracker já migrado nas Issues #1–#3), sem nenhuma dependência do GitHub Actions. Os 10 workflows de .github/workflows/ viraram 8 nativos em .forgejo/workflows/ (ci.yml com deploy gated + requeue por forgejo-dispatch.mjs, ci-pr.yml com cascade de jobs e checks agregador, agent-pool.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, vercel-promote.yml, archive-cursor-agent.yml) e .github/workflows/* + .github/actions/ foram removidos (cancel-workflow-on-failure não existe no Forgejo). Novo runner: Forgejo runner v13 na workstation (capacity 4), jobs paralelos em serviços Postgres isolados por porta (int 5433, build 5434, e2e shards 5435/5436) com DB_HOST calculado do gateway do bridge Docker (byte order corrigida via strtonum). Libs de agente: agent-github.mjs/agent-pool-github.mjs substituídas por agent-forgejo.mjs + agent-pool-forgejo.mjs sobre o novo cliente zero-dependência forgejo-api.mjs (token FORGEJO_API_TOKEN/GITHUB_TOKEN, base URL FORGEJO_API_URL/GITHUB_SERVER_URL/default, shapes normalizados ao contrato gh-flavored; novos isDraft/markPullRequestReady); helpers forgejo-dispatch.mjs, forgejo-pr-automerge.mjs (poll de checks + ready de draft), forgejo-issue-transition.mjs; auto-merge do PR de planos já validado em produção (PR #4). Bug de port corrigido em pnpm issue (await em função sync) e validado ao vivo contra o Forgejo. Testes: forgejoApi.unit.spec.ts novo (10 testes), pin do matrix do ci.yml movido para .forgejo/workflows/; tsc/lint/prettier/knip/check:cycles/2283 unit verdes. Pendente (parte 2): migrar as Issues abertas do GitHub, reescrever .agents/, AGENTS.md, docs/AGENT-OPS.md e docs/roadmap.md, e passos humanos (secrets no Forgejo, pnpm configure:branch-protection, arquivar o repo GitHub).
Recently resolved (2026-08-15): IMG-RESIZE — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). pnpm images:resize (scripts/resize-images.mjs) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), redimensiona para no máximo 3840px — 2× a tela desktop de 1920 — sem upscaling (fit: 'inside', withoutEnlargement), aplica EXIF-rotation, remove metadados e converte para AVIF por padrão (formato leve moderno; WebP/JPEG via --format, qualidade por formato via --quality, --out para outra pasta — default public/). Saída mantém o basename (foto.jpg → foto.avif) e recusa colidir com a fonte. Derivação pura em scripts/lib/imageResize.mjs (parse fail-closed, alias jpg→jpeg, default de qualidade por formato), unit-testada em tests/unit/imageResize.unit.spec.ts. Sem migration/access/Consent/UI/schema.
Recently resolved (2026-08-15): IMG-RESIZE — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). pnpm images:resize (scripts/resize-images.mjs) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), redimensiona para no máximo 3840px — 2× a tela desktop de 1920 — sem upscaling (fit: 'inside', withoutEnlargement), aplica EXIF-rotation, remove metadados e gera DUAS versões por padrão: .avif (formato leve moderno, para o site) e .webp (aceito em mais lugares, incluindo PenPot e navegadores antigos); o conjunto muda via --format avif,webp (lista separada por vírgula), qualidade por formato via --quality, pasta via --out (default public/). Saída mantém o basename (foto.jpg → foto.avif + foto.webp) e recusa colidir com a fonte. Derivação pura em scripts/lib/imageResize.mjs (parse fail-closed, alias jpg→jpeg, default de qualidade por formato), unit-testada em tests/unit/imageResize.unit.spec.ts. Sem migration/access/Consent/UI/schema.
Recently resolved (2026-08-15): IMG-RESIZE — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). pnpm images:resize (scripts/resize-images.mjs) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), aplica EXIF-rotation, remove metadados e gera DUAS versões por padrão com largura por formato: .avif em 3840px (2× tela desktop, quality 60) para o site, e .webp em 1920px (1×, quality 75 — ¼ dos pixels) para PenPot/navegadores antigos, sem upscaling (fit: 'inside'); conjunto/qualidade/pasta via --format avif,webp / --quality / --out (default public/), --max-width sobrepõe todos. --remove-bg remove o fundo com IA 100% local (@imgly/background-removal-node, onnxruntime; 1ª execução baixa o modelo ~40 MB, depois cache; intermediário PNG explícito — o pacote 1.4.5 não faz sniffing de magic bytes e morre com blob sem type); jpeg (sem alpha) é achatado em branco. Saída mantém o basename (foto.jpg → foto.avif + foto.webp) e recusa colidir com a fonte. Derivação pura em scripts/lib/imageResize.mjs (parse fail-closed, alias jpg→jpeg, maxWidthFor/qualidade por formato), unit-testada em tests/unit/imageResize.unit.spec.ts. Sem migration/access/Consent/UI/schema.
Recently resolved (2026-08-15): IMG-RESIZE — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). pnpm images:resize (scripts/resize-images.mjs) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), aplica EXIF-rotation, remove metadados e gera DUAS versões por padrão com largura por formato: .avif em 3840px (2× tela desktop, quality 60) para o site, e .webp em 1920px (1×, quality 75 — ¼ dos pixels) para PenPot/navegadores antigos, sem upscaling (fit: 'inside'); conjunto/qualidade/pasta via --format avif,webp / --quality / --out (default public/), --max-width sobrepõe todos. --remove-bg remove o fundo com IA 100% local (@imgly/background-removal-node, onnxruntime; 1ª execução baixa o modelo ~40 MB, depois cache; intermediário PNG explícito — o pacote 1.4.5 não faz sniffing de magic bytes e morre com blob sem type); jpeg (sem alpha) é achatado em branco. pnpm images:fill (scripts/fill-image.mjs) completa/corrige regiões com FLUX.1 Fill [dev] no DeepInfra (DEEPINFRA_API_KEY): --extend <px|%> cresce o canvas pela base, preenche a faixa de branco e gera a máscara dela sozinho (foto de Lula cortada no peito sem espaço abaixo — o caso de uso), ou --mask <png> para inpaint arbitrário (branco = regenerar); prompt default pronto para completar corpo, canvas capado em 2 MP (capToMaxPixels), --steps (default 28), master PNG em data/ai-fill/ (gitignored) e cadeia o resize (avif+webp) no resultado via engine compartilhada runResizeImages (exportada de resize-images.mjs com guard de argv; --no-resize corta a cadeia). Derivação pura em scripts/lib/imageResize.mjs (parse fail-closed, alias jpg→jpeg, maxWidthFor/qualidade por formato) e scripts/lib/imageFill.mjs (parseExtendPx, extendedCanvas, capToMaxPixels, prompts default), unit-testadas em tests/unit/imageResize.unit.spec.ts + tests/unit/imageFill.unit.spec.ts. Sem migration/access/Consent/UI/schema.
Recently resolved (2026-08-15): IMG-RESIZE — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). pnpm images:resize (scripts/resize-images.mjs) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), aplica EXIF-rotation, remove metadados e gera DUAS versões por padrão com largura por formato: .avif em 3840px (2× tela desktop, quality 60) para o site, e .webp em 1920px (1×, quality 75 — ¼ dos pixels) para PenPot/navegadores antigos, sem upscaling (fit: 'inside'); conjunto/qualidade/pasta via --format avif,webp / --quality / --out (default public/), --max-width sobrepõe todos. --remove-bg remove o fundo com IA 100% local (@imgly/background-removal-node, onnxruntime; 1ª execução baixa o modelo ~40 MB, depois cache; intermediário PNG explícito — o pacote 1.4.5 não faz sniffing de magic bytes e morre com blob sem type); jpeg (sem alpha) é achatado em branco. pnpm images:fill (scripts/fill-image.mjs) completa/corrige regiões com FLUX-2-dev no DeepInfra (DEEPINFRA_API_KEY no .env.local): --extend <px|%> cresce o canvas pela base, preenche a faixa de branco e gera a máscara dela sozinho (foto de Lula cortada no peito sem espaço abaixo — o caso de uso), ou --mask <png> para inpaint arbitrário (branco = regenerar, preto = manter); prompt default pronto para completar corpo, --steps (default 28, guidance 30), máscara enviada no canal alpha do PNG (transparente = regenerar — convenção FLUX-2 nativa; descobrimos na marra que o wrapper OpenAI-images/edits do DeepInfra ignora o size e sempre devolve 1024×1024 — o endpoint nativo /v1/inference/black-forest-labs/FLUX-2-dev aceita múltiplos de 64 e devolve na mesma dimensão), canvas com padding opaco até múltiplo de 64 (padToMultipleOf64) recortado após a geração, master PNG em data/ai-fill/ (gitignored) e cadeia o resize (avif+webp) no resultado via engine compartilhada runResizeImages (exportada de resize-images.mjs com guard de argv; --no-resize corta a cadeia). Derivação pura em scripts/lib/imageResize.mjs (parse fail-closed, alias jpg→jpeg, maxWidthFor/qualidade por formato) e scripts/lib/imageFill.mjs (parseExtendPx, extendedCanvas, padToMultipleOf64, prompts default), unit-testadas em tests/unit/imageResize.unit.spec.ts + tests/unit/imageFill.unit.spec.ts. Sem migration/access/Consent/UI/schema.
Recently resolved (2026-08-15): IMG-RESIZE — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). pnpm images:resize (scripts/resize-images.mjs) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), aplica EXIF-rotation, remove metadados e gera DUAS versões por padrão com largura por formato: .avif em 3840px (2× tela desktop, quality 60) para o site, e .webp em 1920px (1×, quality 75 — ¼ dos pixels) para PenPot/navegadores antigos, sem upscaling (fit: 'inside'); conjunto/qualidade/pasta via --format avif,webp / --quality / --out (default public/), --max-width sobrepõe todos. --remove-bg remove o fundo com IA 100% local (@imgly/background-removal-node, onnxruntime; 1ª execução baixa o modelo ~40 MB, depois cache; intermediário PNG explícito — o pacote 1.4.5 não faz sniffing de magic bytes e morre com blob sem type); jpeg (sem alpha) é achatado em branco. pnpm images:fill (scripts/fill-image.mjs) completa/corrige regiões com FLUX-2-dev no DeepInfra (DEEPINFRA_API_KEY no .env.local): --extend <px|%> cresce o canvas pela base, preenche a faixa de branco e gera a máscara dela sozinho (foto de Lula cortada no peito sem espaço abaixo — o caso de uso), ou --mask <png> para inpaint arbitrário (branco = regenerar, preto = manter); prompt default pronto para completar corpo, --steps (default 28, guidance 30), máscara enviada no canal alpha do PNG (transparente = regenerar — convenção FLUX-2 nativa; descobrimos na marra que o wrapper OpenAI-images/edits do DeepInfra ignora o size e sempre devolve 1024×1024 — o endpoint nativo /v1/inference/black-forest-labs/FLUX-2-dev aceita múltiplos de 64 e devolve na mesma dimensão), canvas com padding opaco até múltiplo de 64 (padToMultipleOf64) recortado após a geração, downscale de fotos enormes (27 MP) para orçamento pré-pad de 1.8 MP — o endpoint nativo recusa >~2 MP e cobra por pixel, master PNG em data/ai-fill/ (gitignored) e cadeia o resize (avif+webp) no resultado via engine compartilhada runResizeImages (exportada de resize-images.mjs com guard de argv; --no-resize corta a cadeia). Derivação pura em scripts/lib/imageResize.mjs (parse fail-closed, alias jpg→jpeg, maxWidthFor/qualidade por formato) e scripts/lib/imageFill.mjs (parseExtendPx, extendedCanvas, padToMultipleOf64, prompts default), unit-testadas em tests/unit/imageResize.unit.spec.ts + tests/unit/imageFill.unit.spec.ts. Sem migration/access/Consent/UI/schema.
Recently resolved (2026-08-13): OPS44 (Issue #713) — redução de conflitos de merge em arquivos compartilhados. Changelog por entrega: cada entrega escreve docs/changelog/<data>-<id>.md (uma entrada, imune a conflito por construção) e pnpm changelog:build regenera o agregado docs/CHANGELOG-AGENTS.md de forma insert-only (nunca remove/reescreve entradas; histórico não migrado) — libs puras scripts/lib/changelog.mjs (parse/sort fail-closed, split do header, build idempotente, assertChangelogAppendOnly multiset) e scripts/lib/conflictMarkers.mjs (regex compartilhada com o guard OPS41); CLIs scripts/build-changelog.mjs (com --check), scripts/check-changelog-append-only.mjs e scripts/check-docs-conflict-markers.mjs; job docs-guards no ci-pr.yml (append-only do agregado + additions-only em docs/changelog/ + zero marcadores de conflito em diffs de docs/ — cobre PRs docs-only onde a suíte unit não roda, a classe do incidente OPS41); escape documentado changelog-rewrite: <motivo> no PR body (padrão D8; usado pela própria entrega — header do agregado mudou). Merge por rebase em 7 pontos (--auto --rebase em vez de --auto --merge: execution-pipeline, agent-pool-prompt, plan-issue, agent-pr-workflow ×3, agent-pr-ready-automerge, engineering-audit; AGENT-OPS contrato item 3) — sem isso o safety-net re-arma --merge por cima; allow_rebase_merge: true verificado no repo. serializes nomeia o registro (ex. serializes: 'docs/plans/'). Docs: AGENT-OPS (Contrato item 4 + seção "Registries compartilhados"), AGENTS.md bullet, PR template (2 checkboxes), header do agregado, execution-pipeline (passo de registro). Sem migration/access/Consent/UI/schema.
Recently resolved (2026-08-13): OPS44 (Issue #713) — redução de conflitos de merge em arquivos compartilhados. Changelog por entrega: cada entrega escreve docs/changelog/<data>-<id>.md (uma entrada, imune a conflito por construção) e pnpm changelog:build regenera o agregado docs/CHANGELOG-AGENTS.md de forma insert-only (nunca remove/reescreve entradas; histórico não migrado) — libs puras scripts/lib/changelog.mjs (parse/sort fail-closed, split do header, build idempotente, assertChangelogAppendOnly multiset) e scripts/lib/conflictMarkers.mjs (regex compartilhada com o guard OPS41); CLIs scripts/build-changelog.mjs (com --check), scripts/check-changelog-append-only.mjs e scripts/check-docs-conflict-markers.mjs; job docs-guards no ci-pr.yml (append-only do agregado + additions-only em docs/changelog/ + zero marcadores de conflito em diffs de docs/ — cobre PRs docs-only onde a suíte unit não roda, a classe do incidente OPS41); escape documentado changelog-rewrite: <motivo> no PR body (linha própria; definição canônica em AGENT-OPS, precedente de restauração D8; usado pela própria entrega — header do agregado mudou). Merge por rebase em 7 pontos (--auto --rebase em vez de --auto --merge: execution-pipeline, agent-pool-prompt, plan-issue, agent-pr-workflow ×3, agent-pr-ready-automerge, engineering-audit; AGENT-OPS contrato item 3) — sem isso o safety-net re-arma --merge por cima; allow_rebase_merge: true verificado no repo. serializes nomeia o registro (ex. serializes: 'docs/plans/'). Docs: AGENT-OPS (Contrato item 4 + seção "Registries compartilhados"), AGENTS.md bullet, PR template (2 checkboxes), header do agregado, execution-pipeline (passo de registro). Sem migration/access/Consent/UI/schema.
Recently resolved (2026-08-12): B200 (Issue #704) — encaixes dos cards mobile de /campanha/municipios (pós-B196): o card lê como um bloco só. Barra (chassis omnibox): form py-1 → pt-1 pb-0 + campo min-h-8 → min-h-10 + X size-8 → size-9 → barra 41 → 45px (aceite "≈48" do plano — o draft gateado manda: min-h-10 sem voltar ao campo alto do B184); a causa real do vão entre barra e primeiro card não era o pb do form, e sim o gap-8 da coluna do shell (32px — medido ao vivo: form.bottom 100.67 vs card.top 132.67) → max-md:-mb-8 no campaignListOmniboxFormClassName cancela exatamente esse gap (primeiro card colado; demais seções do shell mantêm o ritmo; desktop md:mb-0 intacto). Card: article gap-2.5 → gap-2; nome/território gap-0 + leading-tight no território (vão ≈0–2px, e2e ≤2); chips gap-y-2 → gap-y-1.5; grupos de relação gap-1.5 → gap-1. Avatares (decisão cara): o modo overlapRow troca as células flex-1 justify-center + overflow-hidden (origem do "um em cada canto" com 2 e das pontas cortadas com 5+) por pilha à esquerda com sobreposição medida — função pura relationAvatarOverlapPx(count, width) (src/lib/relationAvatarOverlap.ts, unit-testada: min 8px garantido até com 2 avatares, fit exato 28+(n−1)(28−overlap) ≤ W para qualquer n/viewport, fallback no mínimo para width não-finita) + ResizeObserver no grupo ('use client' no stack; modo default desktop -space-x-2/cap 3 intacto; overflow-hidden permanece como guarda de último recurso, nunca ativa no fit). E2E: B196 da barra (44–48px + primeiro card colado com limite duplo — nem vão nem card embaixo da barra) e de avatares reescritos (2 avatares levemente sobrepostos à esquerda — a asserção antiga "sem overlap" pinava o bug); novo describe B200 (barra 44–48 + colagem + eixo label/valor nos seis blocos + pilha: mesma linha, distância entre avatares 8–20px — sobreposição mínima E sliver visível, ancorada à esquerda, nada invade o vizinho via fronteira do grupo); endurecimento anti-flake de medição nas specs de geometria — wait S: settle + footer "1 município encontrado" (o settle sozinho passava com o card mid-stream num layout transiente de célula ~15px — medido via debug) + expect.poll 30s de layout único por leitura (o webServer dev sob workers paralelos recarrega página no meio do teste — medições em frames diferentes capturavam estados distintos). Sem migration/access/Consent/schema. Gates: unit 2085 (6 novos), int 748, e2e B196+B200 14/14 (2 repeats) + spec completa 33/34 (única falha B24 — reproduzida sem a mudança, flake de ambiente pré-existente; 5 falhas ambientais do 1º run sumiram), build OK, lint/format/cycles OK (knip: erro pré-existente ledgerado P3 do payload.config). Intenção municipios-mobile-ajustes-cards.md; impl municipios-mobile-ajustes-cards-impl.md.
Recently resolved (2026-08-12): Remoção do migration-lock (CI) — o check de ci-pr.yml que falhava quando >1 PR aberto tocava src/migrations//payload-types.ts foi removido (deadlock de CI: com 2 PRs de schema abertos, AMBOS falhavam o check e nenhum mergeava sem bypass manual — 9 de 13 runs de CI falhos desde 11/08 foram por isso). A ameaça que ele serializava (cadeia de migrations com drift de snapshot) já é coberta pelo CI em todo PR e na main: pnpm migrate + test:int (incl. campaignMigrationReconciliation.int.spec.ts, checksum do schema migrado vs config); o conflito real (index.ts/payload-types.ts) resolve no rebase. Removido: job migration-lock do ci-pr.yml + needs do rollup checks + contexto required da branch protection (pnpm configure:branch-protection agora aplica só checks); pool mantém a serialização de spawn de issues needs:migration (countOpenSchemaPrs em agent-pool-github.mjs — independe do CI). Docs/skills atualizados (AGENT-OPS contrato de PR, GUARDRAILS, engineering-standards, agent-pool, work-issue, agent-pr-workflow). Sem migration/access/Consent/UI/schema.
Recently resolved (2026-08-11): C130 (Issue #697) — /campanha/pessoas desktop ajustada para leitura/edição: Contato completo (coluna min-w-40 + nowrap — o telefone nunca corta; quem sofre com a largura é o resto), coluna Base removida (some do picker B17; hidden no cookie fica inerte) e a cidade vira 2ª linha discreta do Nome (flex-col, truncate text-xs text-muted-foreground, sem label, sem traço quando vazia — a precedência "legenda sobrepõe base" é da C129), Nome com max-w-72 (outliers truncam no link já existente), colunas de município estreitas quando vazias (minWidthClassName opcional no RelationChipCell/MunicipalityPortfolioCell/PeopleMunicipalityCell, default min-w-56 preserva as outras listas; pessoas passa min-w-32 + size={1} no input do combobox — o input HTML tem largura intrínseca ~20ch que esticava a coluna vazia no auto-layout; verificado 224→153px com chips intactos), "Aliada em" → "Dobra em" só nos labels (coluna, cards mobile, menu de ordenação; chave aliada e URL intactas), tooltip em TODAS as ações inclusive desabilitadas ("WhatsApp indisponível — sem celular" / "Convidar indisponível — sem celular"): descoberto que Chromium não dispara pointer events sobre botão disabled → wrapper <span> + pointer-events-none no botão (também no LeadershipInviteRowAction), e que o tooltip "Apagar" nunca funcionou (o componente não repassa o ref do trigger — o span resolve), caret visível ao editar o Nome (troca de papéis no foco: link opacity-0 pointer-events-none tabIndex=-1, input z-20 text-primary font-medium px-0 caret-foreground — o input é o display enquanto focado, caret alinhado; fora do foco volta o mecanismo C116: link z-10 clicável, input z-0 invisível), filtro e ordenação por Partido (facet dinâmico na omnibox — grupo "Partido", valores do recorte unionados com selecionados, precedente município; chave party na URL ≤32 chars — maxLength do campo; sort "Partido (A–Z)" com nulls por último; partido segue como sufixo do nome, sem coluna), e facet "Sem base" + ordenação por Base permanecem (decisão do gate). Bugfix C116 no caminho: Escape discartava mas salvava o draft velho — setDraft não roda antes do blur() síncrono e o blur persistia o closure stale (observado e reproduzido: nome "Seed Lideranç Xa Dois" salvo no DB local); fix pendingDiscardRef consumido no blur + requestId bump (muta um save debounced já despachado) + select() só no name-link (telefone/e-mail precisam posicionar o caret) + span "Salvo." pointer-events-none. Orfandade removida (coluna Base): field: 'city' sai de CampaignInlineEditableField, de contactFieldUpdateSchema e do branch do updatePersonContactRecord/formAction (int campaignPersonCellEditing ajustado). Sem migration/access/Consent/rota nova. Testes: unit 2055 (party parse/serialize/sort/facet + Escape-discard do permanent name cell), int 746 (party facet no recorte), e2e campaignPeople 8 (novo teste C130: Dobra em, sem Base, cidade na 2ª linha, party=PT filtra) + picker/lideranças 6/6; verificação visual no browser (caret, larguras, tooltips, 2ª linha, filtro). Nota ambiente: e2e dev-mode flakeou sob carga da máquina (load ~68, vários worktrees) — suite rodada em E2E_PROD=1; flake pré-existente googleCalendarSync.int (reproduz no main). Intenção pessoas-tabela-desktop-ajustes.md; impl pessoas-tabela-desktop-ajustes-impl.md.
Recently resolved (2026-08-11): C131 (Issue #698) — "Salvador" como opção agregada no dropdown de municípios das células de capacidade (pessoas ×3, assessores, lideranças, dobradinhas): o hit "Salvador — Todas as zonas" entra primeiro na busca (searchMunicipalityPortfolio ganha kind: 'city' derivado do catálogo — constante NORMALIZED_ZONE_CITIES com a contagem de zonas por cidade-zona; um toque adiciona as 19 ZEs num lote via máquina existente do RelationChipCell — delta otimista, cap/floor, undo, colapso "Salvador (19)" B178, e o exact-match C116 faz "salvador"+Enter eleger o agregado). Gate de escopo por contagem: o agregado só aparece quando a cidade inteira está no escopo addableIds do ator (assessor com carteira parcial segue com as opções individuais — a label decidida no gate "Todas as zonas" nunca mente); o matcher de word-start é query-prefixo-de-label, então um query "Salvador — ZE N" só surfacia o hit exato da ZE (o agregado nunca rouba o keyboard contract do teste B34 — verificado, sem guard); sem count no hit (descrição fixa); célula ganhou só um branch em hitDescription. Sem migration/access/Consent/UI shape; superfícies fora da célula não usam essa busca (wizard tem busca própria). Testes: unit busca pura (19 ids, soma com selecionado, supressão por escopo parcial, supressão tudo-atribuído, primeiro da lista), cell render (opção "Salvador"/"Todas as zonas"), e2e novo em campaignPeople (Lidera → agregado → chip "Remover Salvador — 19 municípios" → 19 zonas no DB, assert condicional se o claim caiu em zona). Gates: unit+int 2084/748 verdes, e2e pessoas+lideranças 11/11 (modo prod local E2E_PROD=1 — prewarm dev estourou o orçamento de 180s sob carga, flake de contenção conhecida; C117 flakeou 1× em paralelo e passou isolado), build OK; knip segue na falha conhecida P3 de ambiente (payload.config.ts). Intenção pessoas-salvador-completa-dropdown.md; impl pessoas-salvador-completa-dropdown-impl.md.
Recently resolved (2026-08-11): C115 (Issue #636) — Google Calendar → Teqo: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). Motor único bidirecional no runSyncPass do C114 (estende o dono): para cada evento nosso — conteúdo difere? → regra do relógio: Google mais novo aplica título + horário (all-day incluído; prefixo [Município] só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com registro auditável no updates feed (Google Calendar: …, author: null, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. Baseline do relógio = activity.lastMirroredChangeAt (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam updatedAt sem tocar o espelho — comparar contra updatedAt silenciaria uma edição pendente do Google. Escrita reversa sem usuário (req.user stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. Cancelamento: listEvents com showDeleted=true — evento status: 'cancelled' (lixeira) com atividade confirmado → cancela se o cancelamento for mais novo que o último carimbo (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); realizado é histórico protegido. Remoção permanente distinguida de "nunca criado" por snapshot lastSeenEventIds (JSONB {calendarId, ids}, gravado só em passada bem-sucedida e excluindo ids que o próprio motor deletou — senão o reopen virava re-cancelamento fantasma); troca de calendarId → snapshot stale → recria sem cancelar. Webhook público POST /campanha/agenda/google-webhook/[secret] (fora de (app), precedente ical/[secret], maxDuration=60, force-dynamic, allowlist do campaignJsonMutationRoute com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (isGooglePushNotificationValid — secret da URL + X-Goog-Channel-ID + X-Goog-Resource-ID + X-Goog-Channel-Token, constant-time, 404; not_exists → estado de erro; sync → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). Canal lazy sem cron (Hobby): ensureGoogleCalendarPushChannel em toda passada — watch com UUID novo, TTL 30d, segredo rotaciona a cada criação (URL vazada auto-cura na renovação), novo canal persistido ANTES do stop do antigo (falha do stop não orfana o novo); expira em <48h → watch com id novo + channels.stop do antigo; troca de calendário → re-watch; falha → pushChannelError (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na googleCalendarSync (system-write; identity fields com read: false — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration hand-written (mesmo motivo do C114). Título editável pelo Google exigiu mutationKind: 'googleCalendarSync' dedicado (título muda, slug canônico congelado) + skip do hook de sync na própria escrita reversa (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção c115-google-calendar-edicao-bidirecional.md; impl c115-google-calendar-edicao-bidirecional-impl.md.
Recently resolved (2026-08-11): C129 (Issue #696) — dobradinha ganha nome de legenda (campo + display discreto sob o nome): campo ballotName (texto opcional, maxLength: 30 — limite real do nome de urna do TSE; sem index — nunca é chave de busca/ordenação/facet, o nome real continua sendo a chave) em stateDeputy, ao lado de party. Migration 20260811_234822_add_state_deputy_ballot_name (gerada; o diff do gerador re-emitia um no-op de ADD VALUE 'lembranca' por snapshot stale — limpa à mão para só o ADD COLUMN; nullable, sem backfill). Display "discreto sob o nome" (mesma família, text-xs text-muted-foreground, sem label — a posição é o rótulo) em 3 pontos: 2ª linha da célula de Nome de /campanha/pessoas via seam pura peopleNameSubline em peopleData (C130 estende com o fallback ?? city — regra combinada final legenda-sobrepõe-base registrada nos dois planos; nesta branch, sem legenda nada muda na linha, aceite da intenção), sublinha fora da região click-to-edit da célula de Nome na lista de Dobradinhas (irmã da célula no flex-col — clicar nela nunca abre o editor do nome), e sublinha no Nome da seção Contato da ficha. Edição (onde já se edita dado de dobradinha, B163): coluna "Nome de legenda" na lista de Dobradinhas (mesma máquina de célula do partido, editTrigger="cell", default-hidden em DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas — precedente E-mail B197; a sublinha é o display always-on e a coluna o opt-in de edição) + campo no StateDeputyForm (criar e editar; schemas create/update/inline trimmedOptionalText(30)/trimmedNullableText(30) — vazio limpa o campo). Escrita: updateStateDeputyBallotNameRecord espelhando o partido (runStaffEntityMutation, staff-only, sem transação/locks/Consent novos — escrita single-collection); CampaignInlineEditableField ganhou 'ballotName' (aditivo). Sem migration destrutiva/access/Consent. Testes: unit (merge carrega ballotName; peopleNameSubline legenda-only/null; default de colunas dobradinhas ['email','ballotName']), int novo campaignStateDeputyBallotName (staff seta/limpa, advisor ok, leader negado, criação com legenda); verificado ao vivo em dev (pessoas/dobradinhas/ficha, edição inline persistindo na sublinha). Gates: unit 2062, int 750, build OK; knip = P3 ledgered pré-existente (idêntico no baseline). Intenção pessoas-nome-de-legenda-dobradinha.md; impl pessoas-nome-de-legenda-dobradinha-impl.md. (feat(C129): nome de legenda da dobradinha — campo ballotName + display discreto sob o nome (Closes #696))
Recently resolved (2026-08-11): OPS42 (Issue #691) — o e2e dev não perde mais fill/Enter para a navegação RSC tardia do login: em dev sob carga, ~150–500 ms após o load do /campanha o router do Next dev dispara um history.replaceState sem request (framenav) e remonta o subtree do layout (provider do chat novo, textarea novo com defaultValue='') — interações imediatas caem na janela e são silenciosamente perdidas (input volta a '', envio nunca dispara; CI/prod imune — main verde). Mecanismo medido por sonda (3 runs nesta máquina, load 45–61): o waitForURL do login resolve no commit do redirect (não otimista — a hipótese da intenção caiu), e o commit perigoso é um segundo framenav pós-goto do próprio teste, cuja remontagem do DOM chega 150–250 ms depois; interação dentro da janela ainda induz cascade do overlay de dev (POST /__nextjs_original-stack-frames); fill atrasado ~2 s sobrevive. Fix: helper waitForRouterSettled(page) no fixture (campaignE2EFixtures.ts, padrão checkRadixWhenHydrated) — retorna após janela de 500 ms sem framenavigated (fail-soft em 15 s com console.warn; no-op em prod via CI/E2E_PROD — custo zero no CI), chamado no campaign.login (pós-waitForURL) e nos pontos de interação imediata da família do chat: openChatAndSend/openChat/askViaInput em campaignSollinhaContext, campaignAiChatResize, campaignAiLinks, campaignAiTranscribe, campaignAiChatFollowUps; openMobileDrawer e os 2 cliques de drawer diretos de campaignAiChatFollowUps/campaignAiChatOpeningChips + o "Fechar" desktop do campaignAiChatResize (o run 3 pegou o clique do drawer na mesma janela — a classe é qualquer interação no layout, não só fill/Enter). Rejeitadas: retry de interação (double-send com o mock gated do B199), só-no-login (não enxerga o commit pós-goto), networkidle/wrap global de goto. Evidência: campaignSollinhaContext 10/10 ×3 dev (load 31–61; o B188 "reload na mesma aba" que flakeava 5/5 na sessão B199) + 8/8 prod (E2E_PROD=1, 30,7 s — no-op); irmão campaignAiChatResize 8/8 dev. Gates: tsc/lint/format/cycles verdes (knip falha pré-existente P3 — payload.config). Sem migration/access/Consent/UI/app. Intenção e2e-dev-router-quiescencia.md; impl e2e-dev-router-quiescencia-impl.md.
Recently resolved (2026-08-11): C123 (Issue #667) — a agenda ganha uma superfície única de criação/edição de atividade: o overlay (modal central no desktop, drawer do C103 no mobile) com todas as seções do formulário completo; as páginas /campanha/atividades/nova e /[slug]/editar saem sem redirecionamento (404). ActivityInlineCreate virou ActivityOverlay (ActivityOverlay.tsx): slot click → criação com o intervalo do slot; event click → edição (Google Calendar; o detalhe fica acessível pelo link "Ver detalhes" no rodapé do overlay; middle-click no evento ainda navega ao detalhe via url preservada); Local renderizado uma única vez (o bloco duplicado do popover C91 morreu); Início/Término com data e hora separados e visíveis sem rolar (ActivityDateTimeField reescrito: trigger de data em popover/bottom-sheet + selects Hora/Minuto inline; contrato civil YYYY-MM-DDTHH:mm intacto; mobile [dia][hora][minuto] por linha). Submit por FormData nas actions createActivityOverlay/updateActivityOverlay (reusam os parsers parseActivity*FormData + os *Record transacionais; status só na criação — na edição não envia, evita realizado→confirmado) e loadActivityEditDraft (findByID activityFormSelect, overrideAccess: false, int testado por escopo). Bug latente C14 consertado: o <input name="id"> que o update perdia (o /editar estava quebrado desde adc29c03) volta no modo edição. Quick actions new-activity/edit-activity viraram onAction bridged por contexto (openActivityCreate/openActivityEdit, precedente C94/C114); hosts novos: ActivityCreateOverlayHost (lista /atividades) e ActivityEditOverlayHost (detalhe, botão Editar + FAB). Morrem: páginas/formActions/ActivityForm.tsx, buildActivityCreateHref/parseActivityCreatePrefill/parseActivityAgendaReturnHref (activityUi), atividadesNova (chrome catalog), ACTIVITY_NEW_PATH, getActivityEditPageData, createActivityInline (vira createActivityOverlay), activityInlineErrors (vira activityOverlayErrors, com duplicate-demand + boundary/zod). Sem migration/access/Consent. Testes: unit overlay 14 (create/edit/erro/sheet), unit mappers/Ui/quickActions/agendaInteractions (eventClick→edit), int loadActivityEditDraftRecord por papel, e2e campaignActivity 7/7 + agenda mobile/feed/google 9/9 no modo prod (dev-mode flakeou por cold-compile do dev server sob carga da máquina — prewarm 180s estourou; E2E_PROD=1 é o modo de verificação). Intenção agenda-criacao-modal-central.md; impl agenda-criacao-modal-central-impl.md.
Recently resolved (2026-08-11): C128 (Issue #695) — ciclo de vida das três capacidades pelas células de /campanha/pessoas: qualquer pessoa visível pode ganhar (primeira cidade cria a entidade) e perder (última cidade remove da tabela) as capacidades Assessora/Lidera/Aliada em — sem sair da lista. Server: três record functions person-centric por contactId em actions/person.ts — setPersonAssessoraMembershipRecord estendida (0 contas + add → cria a conta staff: { name, role: 'advisor', contact, email stub @criado.invalid, senha aleatória } — sem credenciais utilizáveis, login provisionado depois; 0 contas + remove → no-op; última cidade → delete da conta com cascata de autorias), setPersonLeadershipMunicipalitiesRecord (cria liderança no primeiro município com canCreateLeadership re-checando a carteira; última cidade → delete de pledges→invites→liderança) e setPersonStateDeputyMunicipalitiesRecord (cria dobradinha com slug auto; última cidade → delete com vínculos limpos por FK). Saída destrutiva por escopo, não por papel (gate): coordenação/candidato sempre; assessor também encerra Lidera/Aliada quando TODOS os municípios atuais da capacidade estão na carteira dele (o piso dele é a visibilidade — chips fora nem renderizam; servidor re-checa assertPersonCapacityExitScope); Assessora segue coordenação/candidato (decisão do gate da intenção). Confirmação explícita com manifest (getPersonCapacityExitManifestAction/loadPersonCapacityExitManifest — votos declarados+convites para liderança, conta+autorias+assessorado para assessora; dobradinha sem diálogo, rabbit hole da intenção). UI: RelationChipCell ganhou opt-ins commitWithNullOwner e commitGuard (antes do apply otimista, fail-closed — falha de manifest aborta), MunicipalityPortfolioCell repassa + extraFormFields (manda contactId); PeopleMunicipalityCell implementa o guard + PeopleCapacityExitDialog (padrão DeletePersonButton); matriz da página: pessoa sem a capacidade fica editável (assessor com addableIds=carteira), minItems removido (o piso da liderança passa ao ciclo de vida). Extrações (edit the owner): applyLeadershipMunicipalitiesDelta (B34) e applyStateDeputyMunicipalitiesBatch (B37) viram helpers transaction-agnostic reusados pelos dois call sites; deleteCampaignUserAccount sai do personDelete; nextFreeStubCampaignEmail sai do municipality (B154); matchers de conflito isUniqueLeadershipConflict/stateDeputyPolicy movidos para utilities (const não sai de módulo 'use server'). Bug latente C116 corrigido: a célula Assessora nunca salvava — o FormData não enviava contactId que a action exigia (FormDataBoundaryError em toda tentativa); os int tests chamavam a record function direto e não pegavam. Sem migration. Testes: int campaignPersonCellEditing 28 (ciclo de vida das 3, manifest, guardas de escopo, no-ops, undo re-cria, conflito de nome da dobradinha), unit peopleMunicipalityCell 6 (guard aborta/confirma/fail-closed, commit com owner null + contactId), e2e campaignPeople +2 (cria liderança na célula + diálogo destrutivo com voto; conta staff criada na Assessora). Intenção pessoas-qualquer-capacidade-para-toda-pessoa.md; impl pessoas-qualquer-capacidade-para-toda-pessoa-impl.md.
Recently resolved (2026-08-11): C126 (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou escopo injetável activityWhere?: Where em CampaignCalendarSyncOptions (src/utilities/googleCalendarSync.ts) — appendado ao and do push set (loadSyncActivities) e do delete-guard set (loadAliveActivityIds); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: 5 specs criam atividades in-window no banco compartilhado (campaignActivity, calendarFeed, homeSearchActivities, municipalityDossierData + campaignSuggestions com realizado no lookback — o 5º que a Issue não nomeou), e outcome.created global contava todas; serialização não era construtível (vitest não serializa par-a-par; fileParallelism:false mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper runSync(client, reason) com activityWhere: { title: { like: 'C114%' } } (mesmo filtro do afterEach) e ganhou dois pins: (1) atividade estrangeira in-window não é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). Conflito com o C122 (#705, mergeado antes): o C122 endereçou a C126 pela abordagem oposta (asserções globais removidas em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada googleCalendarTestKey, cleanup de config por owned id, datas C127 derivadas do agora). Contraprova: fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl c126-espelho-google-int-flake-isolamento-impl.md (Issue = spec, sem intenção própria).
Recently resolved (2026-08-11): OPS41 (Issue #689) — o AGENTS.md de main carregava marcadores de conflito commitados no bullet "Per-worktree environments" ( <<<<<<< HEAD indentado + lado HEAD pré-OPS33 com artefato - # + fechamento corrompido > > > > > > > 9e18bec8; o ======= foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo já conflitado (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução perda zero validada por diff: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, --issue N, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou AGENTS.md como único afetado. Guardrail anti-reincidência (2ª vez que marcador chega a main): novo describe no conflict markers in committed files em tests/unit/codebaseConventions.unit.spec.ts — varre git ls-files, falha em <<<<<<</>>>>>>> (tolerante a indentação) e na forma corrompida commitada ^\s*(> ){7}; roda no CI via pnpm test:unit. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl agents-md-sem-marcadores-conflito-impl.md.
Recently resolved (2026-08-11): Plan-issue — rascunho UI/UX do gate vira HTML+Tailwind commitado no repo (ad-hoc, decisão do usuário). O artefato deixa de ser Cursor canvas (~/.cursor/projects/<workspace>/canvases/plan-<id>-ui-draft.canvas.tsx, fora do repo, só renderizava no IDE de quem planejou e apodrecia) e passa a ser HTML + Tailwind v4 (docs/plans/<slug>-ui-draft.html — @tailwindcss/browser@4 via jsdelivr, mesma major do app 4.1.x; rede só na hora de renderizar) commitado com o plano e renderizado em PNG por pnpm ui-draft:render (scripts/render-ui-draft.mjs, Chromium do Playwright via @playwright/test, fallback load se o CDN não settle — nunca toca banco/app): uma PNG por cena <section data-shot="<rótulo>"> (ex. mobile, desktop-vazio) → docs/plans/<slug>-ui-draft-<rótulo>.png, ou full-page sem data-shot; PNGs embutidos no plano (visíveis no chat do gate E no PR — o aceite vira registro permanente; o HTML é a fonte iterável). Contrato de fidelidade explícito em ui-draft-html.md (substitui ui-draft-canvas.md, deletado; refs atualizadas em SKILL.md/frontmatter, intention-template.md — campo renomeado Canvas UI: → Rascunho UI: + secção de embed, skills-map.md, shaping.md): o rascunho DEVE ter layout real, espaçamentos/tamanhos na escala do app (p-4, gap-3, h-11, text-sm), breakpoints reais (390/1280) e estados críticos como cenas estáticas; é PROIBIDO ser funcional (zero JS de comportamento — cada estado é cena separada), usar código de app (src/), brand/polish (gradientes/emojis/sombras/logo/paleta do produto — cinza neutro + um acento), mock de dados realistas ou decisão de engenharia. Gate: iterar HTML → re-render → embutir até confirmação explícita. Verificado: render real do skeleton (classes v4 aplicadas — oklch zinc, rounded-lg 8px, text-lg 18px). Sem migration/access/Consent/UI de produto/schema. Sem issue — ad-hoc no worktree work/5.
Recently resolved (2026-08-11): OPS40 (Issue #682) — pnpm dev honra o PORT do .env.local: next dev resolve a porta antes de @next/env carregar env-files (commander .env('PORT') no bin/next — causa confirmada no fonte 15.4.11), então o PORT=3100+slot do provisionador de worktree era ignorado e todo dev server subia em 3000 (colisão entre worktrees paralelos; o e2e escapava porque o webServer injeta PORT no env real). pnpm dev/devsafe agora passam por scripts/dev.mjs (wrapper): importa o guard-dev-db.mjs (preflight fail-closed inline, mesma saída), loadCliEnv() (.env.local → .env, override:false) e entrega a porta ao next dev como flag CLI -p — a única alavanca que o repo controla. Precedência: env real do processo (webServer do Playwright manda) → .env.local (worktree) → .env → sem -p (default 3000 + allowRetry do Next intactos no main repo). Derivação pura unit-testada em scripts/lib/cli.mjs (resolveDevPort — null quando ausente, throw fail-closed em PORT inválida; nextDevArgs); spawn node next/dist/bin/next com NODE_OPTIONS=--no-deprecation (paridade cross-env), stdio inherit, forward SIGINT/SIGTERM/SIGHUP, propagação de exit code; extras (--turbopack) repassados após o -p derivado. Sem migration/access/Consent/UI. Verificação: boot manual no worktree → "Local: http://localhost:3140" (antes 3000), unit 8/8 novos, e2e dev-mode campaignAuth 3/3 (2 runs de prewarm estouraram o orçamento sob load ~50+ da máquina — flake de contenção fichado B17/B27/B32/B42, terceira run verde com cache quente). Nota fora de escopo: AGENTS.md em main carrega marcadores de conflito commitados (OPS33/OPS37) — área de main-mess da OPS38; o novo bullet do Database section foi adicionado fora da região conflitada. Intenção worktree-dev-port-nao-honrado.md; impl worktree-dev-port-nao-honrado-impl.md.
Recently resolved (2026-08-11): C124 (Issue #668) — tarefa de atividade fica só com texto, responsável e concluída: o campo due (Prazo, datetime) sai do modelo e da UI. Remoção em cadeia num único commit simétrico: campo do array tasks da activity (o guardrail de liderança perde a comparação de due no mesmo item — continua fail-closed sobre título/responsável/contagem), taskSchema zod, parseTasksFormData/ParsedActivityTask (formData), ActivityFormTaskViewModel/ActivityTaskViewModel, input datetime-local do ActivityTaskFields (grid 2fr_1fr) e linha "Prazo: …" do ActivityTaskChecklist (subtítulo condicional + break-words no título — P2 dos reviewers). doneAt ("concluída em") permanece como derivado invisível; taskTotal/taskDoneCount intocados. Migration 20260811_183016_drop_activity_task_due (gerada; diff do gerador tinha no-op de google_calendar_sync por snapshot stale — limpa à mão para só o DROP COLUMN "due" de activity_tasks; dados descartados conforme decidido no gate). Grep pós: zero due fora das migrations (histórico frozen); nenhum consumidor fora da vertical (notificações, Google sync, quick actions). Gates: unit+int 736/736, e2e atividade 9/9 (1 flake ECONNRESET no run paralelo — rerun limpo), build OK. Intenção atividades-tarefas-sem-prazo.md; impl atividades-tarefas-sem-prazo-impl.md.
Recently resolved (2026-08-11): C118 (Issue #657) — página de detalhe da pessoa /campanha/pessoas/[id] (staff), montada pelas capacidades da ficha: a mesma rota serve liderança, dobradinha, assessora, apoiador — e só mostra o que a pessoa tem. Loader irmão loadPersonDetail (src/utilities/people/personDetail.ts) reusa o merge da lista (mergePeopleSources + scopePeopleRows) com os mesmos selects por fonte — o escopo do assessor é literalmente o da lista (pessoa só aparece se alguma capacidade municipal estiver na carteira; null → notFound()), staff com bypass justificado (identity-gated, prova pelo merge); seção Apoiador (dado novo vs. view model da lista) lê supporter por contact com o access da própria collection (overrideAccess: false) — camada dupla, nunca alarga (int testado: apoiador de município fora da carteira é cortado). [id] é contactID (a chave de linha da C100 — e o link do Nome na lista, que a C116 nasceu apontando para /campanha/liderancas/[id], agora aponta para cá). UI: PersonDetailSections em components/campaign/people/ — Ficha (partido/telefone/e-mail/base + pills de capacidade), Liderança (status, acesso ao app, municípios, link "Abrir detalhe de liderança" — convivência v1 com /campanha/liderancas/[id], votos continuam lá), Dobradinha, Assessora (por conta, campaignRoleLabels), Assessorado, Apoiador (fonte · município · intenção quando consentida · data), Ações reusando os botões da lista (WhatsApp, LeadershipInviteRowAction só com liderança, DeletePersonButton ganhou deletedHref opcional → volta à lista pós-delete). Sem migration/access novo/Consent; sem votos/KPI (intenção corta; assimetria intacta). Testes: int personDetail (matriz de escopo: unrestricted completo, assessor na carteira vê, fora → null, corte de supporter, só-staff só para unrestricted, conta leader não acende Assessora, contato solto/leader → null) e e2e novos em campaignPeople (coordinator vê seções por capacidade + link de liderança; dobradinha+apoiador sem bloco de liderança; leader → /campanha/contatos). Verificado ao vivo em dev (coordenador/assessor/leader, 404 de id inválido, mobile). Intenção pessoas-detalhe-por-capacidades.md; impl pessoas-detalhe-por-capacidades-impl.md.
Recently resolved (2026-08-11): B197+ (Issue #642) — o menu do picker de colunas de assessores nasce da mesma fonte que os <th> do AdvisorsTable (fim do espelho): o módulo client declara advisorTableColumns (id/label/mandatory + headerClassName + visuallyHiddenLabel para o sr-only de Ações) e exporta advisorPickerColumns = toCampaignColumnPickerColumns(...) (precedente supporterPickerColumns); o header renderiza por resolveVisibleColumns(advisorTableColumns, hiddenColumnIds) — mesmo helper B17 das outras listas — e emailHidden deriva do mesmo visibleColumns. Descoberta do /simplify incorporada: o corpo só condiciona a coluna email (células à mão + exceção do draft), então phone/municipalities/actions são mandatory: true — esconder um deles (cookie velho do toggle inerte do B197) derrubaria o <th> sobre um corpo de 5 células; os checkboxes viram "sempre visível" (honestos). assessores/page.tsx consome a fonte exportada e perdeu o array espelho + imports órfãos. Comportamento da tabela idêntico (ids/labels/ordem, exceção do draft intacta); e2e campaignColumnPicker ganhou pin dos mandatory. Sem migration/access/Consent/UI/cache. Observações de ambiente (não da mudança): o e2e do picker falha no harness dev sob carga desta máquina (painel do popover não monta; falha idêntica na base e em lideranças — intocado; conjunto falho deriva com a carga; verificado funcional em Chromium real); warning React de key pré-existente no assessores (CampaignListOmnibox — base também). Intenção escala-dry-pos-b197.md; impl escala-dry-pos-b197-impl.md.
Recently resolved (2026-08-11): C116 (Issue #655) — /campanha/pessoas desktop vira edit-where-you-see: toda célula (exceto Ações) é um input permanente sem destaque — salva no blur/Enter, Esc descarta, vazio mostra "—" apagado. Nome mantém o valor como link para a ficha (/campanha/liderancas/[id]) com o mecanismo travado no gate: input invisível (text-transparent, z-0) sobre o link (z-10, pointer-events-auto, w-auto — cobre só o texto; o resto da célula é a área clicável do input), draft renderizado no texto do link (o valor real do input nunca é o display), clique no link navega, teclado/área fora do texto edita. Células de municípios (Assessora, Lidera, Aliada em) e Assessorado usam chips internos com: grupo novo "Salvador (19)" (buildMunicipalityPortfolioChips ganha kind city, checado antes do território — senão as 19 ZEs colapsariam como "Metropolitano de Salvador"), chip batch clicável expande in loco (estado de apresentação do wrapper PeopleMunicipalityCell — clique expande, mouse-leave colapsa se todos os membros seguem atribuídos; X num membro remove e impede o recolapso), "+X" no toggle de overflow do B170, quiet (sem tint de hover/foco; indicador de foco discreto só no input), commit por match exato no Enter/espaço (digitar "salvador-ze-2"+Enter adiciona a ZE certa, não a pre-selecionada — regra da intenção). Escrita: updatePersonContactRecord (escopo "edita o que vê" — assessor só com entidade da pessoa na carteira; contactFieldUpdateSchema ganhou variante city, convivendo com phones do C112), setPersonAssessoraMembershipRecord (unrestricted-only, single-account — multi-conta nega com mensagem segura e célula read-only; toggle extraído para toggleMunicipalityAdvisorMembership em municipality.ts, reusado pelo record B27) e setPersonAdvisorMembershipRecord (union person-centric: delta de assessor nas DUAS entidades quando ambas existem). Matriz por papel na página: assessor edita Lidera/Aliada com addableIds=carteira e read-only em Assessora/Assessorado (regras das collections donas); tooltips de label (WhatsApp/Convidar/Apagar) via CampaignHoverTooltip; CampaignListSheetProvider na tabela (guard miss #52 — wrapper de composição registrado no sharedSheetHostConventions). Mobile intacto. Sem migration. Testes: unit (grupo Salvador + expand + match exato; guard do sheet host), int novo campaignPersonCellEditing (11 — escopo por papel, assessora single/multi/sem-conta, union nas duas entidades), e2e campaignPeople 4/4. Intenção pessoas-edicao-inplace-lista.md; impl pessoas-edicao-inplace-lista-impl.md.
Recently resolved (2026-08-11): B198 (Issue #660) — o drawer mobile do Sollinha fecha ao navegar por link interno: tocar num link /campanha… da resposta fecha o drawer no toque (antes de a página carregar) e a navegação segue no mesmo tab; desktop inalterado (painel fica aberto); link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre (o close persiste open: false). Engrenagem: a decisão pura mora no owner src/lib/ai/markdownLinks.ts — regex ^\/campanha(?:\/|$) promovido a isCampaignInternalLink (era APP_INTERNAL_LINK no renderer) + shouldCloseDrawerOnLinkClick(href, isMobile, event) (fecha ⇔ interno && mobile && clique simples — ignora ctrl/meta/shift/alt, middle-click e defaultPrevented, pois abrem nova aba e a origem mantém o chat); o markdownComponents estático do CampaignAIChat virou useMemo no corpo (acesso ao ctx.isMobile/setOpen; identidade estável, recomputa só no crossing de breakpoint). Sessão/restauração intactas (fechar com requestOpen(false) marca openBy:'user' com open:false — inerte, o restore só reabre com open:true). Testes: unit (12 — interno/externo, viewport, modifiers, middle, prevented) + e2e no campaignSollinhaContext (interno fecha+navega sem load+reload não reabre; externo mantém drawer e abre popup interceptado). Nota: dev-mode local flakeou no prewarm/compile cold — e2e rodado no modo CI (E2E_PROD=1 com NEXT_DIST_DIR=.next/e2e, 7/7). Débito registrado no gate (B199/#672, depends #660): race pré-existente do persist effect (só grava com status==='ready') — fechar o drawer mid-stream e recarregar antes do settle reabre o drawer "sozinho" (classe OPS22); o tap-no-link do B198 amplia a superfície. Intenção sollinha-drawer-fecha-link-mobile.md; impl sollinha-drawer-fecha-link-mobile-impl.md.
Recently resolved (2026-08-11): C114 (Issue #635) — Agenda da Campanha espelhada no Google Calendar com push Teqo→Google (API oficial, service account). Motor: reconciliação completa por passada (runCampaignCalendarSync) com id determinístico de evento (teqo + base32hex do id — charset do Google) → upsert idempotente sem estado por evento; janela 90d passado/365d futuro (precedente do feed iCal), espelho cheio do escopo do staff (sem intersecção de advisor — agenda oficial), cancelado/deletado no Teqo apaga o evento no Google (sem estado cancelado visível a quem segue); conteúdo comparado por instante absoluto (imune a fuso do calendário) → passadas repetidas não tocam o Google; eventos estrangeiros do calendário nunca são tocados (decode completo — um teqo3 manual não é nosso). Triggers (sem cron — Hobby): hook afterChange/afterDelete na activity (só campos espelhados, com normalização de shapes populados vs depth-0; tarefas não disparam; nunca lança — Teqo independe do Google; timeout 15s por request), auto-retry na view da agenda quando paused (uma vez por carregamento), botão "Sincronizar agora", e auto-resync por config (D7): trocar calendarId/reativar na collection googleCalendarSync re-conciliar sozinho (guard por comparação de campo quebra o loop de re-entrada); scan de delete com lookback de 730d (cancelado antigo não vira fantasma; atividade viva fora da janela mantém o evento). Estado: collection googleCalendarSync (uma linha; calendarId admin-only, disabledAt staff, timestamps system-write; status derivado not-configured | disabled | synced | paused em lib pura) + chave da service account só em env (GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY, base64 do JSON do GCP — sem credencial no DB); sem env → not-configured com runbook de ops (criar calendário na conta da campanha, público no link, SA editor, calendarId no Painel). Transporte: jose (novo dep) + fetch → Calendar API v3 REST (token JWT assertion, cache em módulo, 401 re-minta uma vez, 409 de insert idempotente; end obrigatório → atividade sem endAt vira 1h). UI (Impeccable B, padrão C94): pill no header da agenda (sincronizado/pausado/não configurado/desativado) + FAB mobile + diálogo com os 4 estados do canvas (link público de adição calendar/r?cid=webcal… + instruções + sync agora + desativar; falha de action visível). Sem participantes no v1 (decisão de produto do gate: aviso segue as configs de quem segue; service account exigiria domain-wide delegation — segue para o C115/Google→Teqo). DRY com o feed: janela/where/nomes de município/description compartilhados (calendarFeed.ts como dono da janela). Migration hand-written (o gerador re-emitia no-ops pré-existentes de campaign_user.contact_id). Testes: unit (mapping/derivação/guards com normalização/client com fetch fake/hook wiring via vi.mock), int (engine com stub in-memory: cria/converge/atualiza só drift/cancela deleta/delete limpa fantasma/falha→pausado→recupera/troca de calendarId), e2e (pill não-configurado + runbook, sem dependência externa). Débito de cobertura (estados disabled/paused/synced + ação) registrado como #666 (depends #635). Intenção c114-google-calendar-push-agenda-oficial.md; impl c114-google-calendar-push-agenda-oficial-impl.md.
Recently resolved (2026-08-11): D10 (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). F1 (self-heal): purgeOrphanedConsentRenames (3 nomes explícitos de renames temporários — temporarily-renamed-consent, chave-renomeada, chave-temporariamente-ausente) roda sob lease exclusivo, em autocommit (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (withMutableConsentFixture antes do snapshot, setup do withMissingInviteConsentFixture, ensureLeasedConsent) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE consent_key_idx auto-sustentado). F1b: purgeMunicipalityResidue também deleta os contatos que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do removeSupporterData, reimplementado no helper). F2 (refutação por medição + design final): a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK consent_id (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era shared), down-test da Onda0 deletando as 4 keys não-atomicamente, e provision re-textando no meio de asserções de preview. Fix: janela do withMissingInviteConsentFixture → exclusiva (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 atômico (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases exclusivos aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. Review /simplify (2 reviewers): P1-1 — os renames vivos de campaignInvite/campaignFixtures migraram para chaves privadas (consent-vacated-<id>/owned-consent-vacated-<id>) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; withExclusiveTestDatabaseLease colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. Contraprova: 7 arquivos da key × 6 workers → 12/12 verdes (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/src/. Intenção escala-dry-pos-d9.md; impl escala-dry-pos-d9-impl.md.
Recently resolved (2026-08-11): C113 (Issue #631) — feed iCal da agenda vivo: eventos criados depois da criação do link aparecem no mesmo link. Spike diagnóstico primeiro (e2e novo com o aceite de dois GETs via fixture — roda em dev E no build de produção): o teste passou no modo prod ANTES do fix, provando que o Next 15 não congela route handlers GET (docs: "not cached by default" desde 15.0; build compila a rota como route.js dinâmica, sem .body) — o freeze é o contrato de resposta: Cache-Control: public, max-age=3600 sem validadores permitia a caches compartilhados (CDN da Vercel, infra do Google, proxies) reter o snapshot do primeiro fetch. Fix (mínimo): export const dynamic = 'force-dynamic' na rota ical/[secret] (explícito, convenção do repo) + novo buildICalFeedResponse em src/utilities/calendarFeed.ts (helper puro): Cache-Control: public, no-cache (armazenar só com revalidação obrigatória — fecha também a janela de 1h servindo feed revogado), ETag forte (sha256 do corpo, hash sempre muda com conteúdo), Last-Modified informacional, X-PUBLISHED-TTL: PT1H (RFC 7986) e 304 só por If-None-Match — nunca por If-Modified-Since, porque exclusão de atividade não move Last-Modified (linha some) e um 304 por data esconderia "evento fantasma". Testes: unit do helper (12/12 — headers, ETag estável/mutável, 304 sem corpo, */lista/weak, ETag velho → 200, IMS nunca 304); int na rota real com payload (route.dynamic === 'force-dynamic' + A → GET → B → GET ambos → edição de horário → DTSTART novo → cancelamento → some → GET condicional → 304, 18/18); e2e do aceite completo no spec existente campaignAgendaFeed (dev 5/5, prod build 1/1). Aceite: edição/cancelamento refletem, revogação/escopo do criador fail-closed intactos, sem migration/schema/access/UI/Consent. Verificação pós-deploy pendente no elo CDN externo (link real de produção). Intenção c113-feed-ical-agenda-nao-atualiza.md; impl c113-feed-ical-agenda-nao-atualiza-impl.md.
Recently resolved (2026-08-10): OPS36 (Issue #601) — journeys com sessão compartilhada nos 3 arquivos e2e pesados (campaignHomeActions 17, campaignMunicipalities 20, campaignBottomNav 9 testes): o overhead repetido por teste amortizado de verdade, não o que a intenção supunha. Premissa da intenção corrigida por medição: getPayload NÃO é pago por teste — Payload 3.82 memoiza a instância em global._payload keyed por options.key (medido: 364 ms na 1ª chamada do worker, 0 ms nas seguintes). O custo real era o login de browser + a criação de usuário por teste (bcrypt): login isolado medido em 3.6 s (quente) / 30.3 s (1º hit) vs 112–134 ms do payload.login de seed — ~32×. Mecanismo: beforeAll por arquivo cria UM coordinator + UM leader (instância CampaignE2EOwnership própria, cleanup em afterAll) e minta o token via payload.login (a MESMA via do login real — session row + JWT que o app valida); beforeEach injeta o cookie campaign-token (path /campanha, httpOnly, sameSite lax) no context fresh de cada teste — helpers mintCampaignSession/seedCampaignSession/createCampaignOwnership no fixture. Divergências declaradas da intenção: (1) sem mode: 'serial' — serial existia para reusar o contexto do browser; com seed por cookie o reuso é desnecessário e o serial skipa N+1..k em falha (perde diagnóstico — anti-goal "mega-teste" da própria intenção); (2) storageState rejeitado (file race entre workers + test.use em tempo de definição); (3) login UI sai dos 46 testes — caminho permanece coberto por campaignAuth.e2e.spec.ts + journey mista t9 (advisor→leader, mantém browser login); advisors nunca compartilhados (OPS29 exige portfólio vazio; B176 atribui portfólio). Flake classe exposta e endurecida: sem o warm-up incidental do login, clicks de estado client-side (drawer/sheet/overlay) passaram a correr contra a hidratação — a classe B13/B17 da casa; 7 interações ganharam o padrão toPass (B70 add-liderança, Mais drawer, B42 trend/C109, B193 scenario, FAB×3) e 7 lookups do strip de ações/omnibox ganharam .first() (duplicado transitório RSC do shell — classe .nth(0) já documentada), sem enfraquecer asserção. Medição antes/depois: baseline 29/49 passou em 10.0 m a load ~55 (20 falhas = classe de carga/restart de Postgres por outro worktree — OPS37; A/B no código original: B97 falha identicamente na mesma carga, e o conjunto falhante casa o do baseline); com o seed, runs limpas: homeActions 18/19 (2.6 m), interações endurecidas 8/10 (falhas residuais = render lento sob load, classe pré-existente comprovada). Sem migration/access/Consent/UI. Intenção e2e-journeys-seriais-arquivos-pesados.md; impl e2e-journeys-seriais-arquivos-pesados-impl.md.
Recently resolved (2026-08-10): B197 (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do /campanha (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie campaign_columns, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em DEFAULT_HIDDEN_COLUMN_IDS (email nas 5) e assessores entrou em CAMPAIGN_LIST_IDS (primeira lista sem CampaignTable no mecanismo). Pessoas (C100 reaberto): o e-mail saiu da célula "Contato" (fica só telefone, — sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). Apoiadores: coluna "E-mail" nova no SupporterList (dado já existia via contact; SupporterListItemViewModel ganhou email), nascendo oculta. Assessores: o encaixe — picker B17 na lista (trailing no omnibox, advisorPickerColumns espelhando os <th>) e AdvisorsTable (client) passou a receber columnVisibility com <th>/<td> condicionais; exceção única: a linha-draft de criação mantém o input de e-mail sempre (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar assessores; defaults + stored-wins) e e2e novo no campaignColumnPicker (default oculto, religar, persistência no reload, draft com input). Intenção ocultar-email-por-padrao-listas.md; impl ocultar-email-por-padrao-listas-impl.md.
Recently resolved (2026-08-10): Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc). O requestPermission do card de perfil rodava dentro de startTransition(async …) — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome auto-nega silenciosamente (resolve 'denied' sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e Notification.requestPermission() passa a ser chamado sincronamente no clique (antes de qualquer await/transição); mensagens distintas para denied (permissão bloqueada — reativar nas configurações do site; um denied não pode ser re-perguntado por nenhuma API) vs default (prompt não exibido). Opt-in automático (pedido do usuário): novo CampaignPushOptInToast no shell (app) (padrão InstallPwaToast/BiometricEnrollmentToast), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando Notification.permission === 'default' (nunca denied), sem inscrição ativa no device, consentimento campanha-notificacoes-push publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em localStorage (chave campaign-push-opt-in-dismissed); ativação/desativação/leitura de inscrição extraídas para src/utilities/campaignPushClient.ts (compartilhado com o card — don't twin; registrado no pin de top-level com razão). Diagnóstico de prod (read-only, db:check:push-chain): consentimento campanha-notificacoes-push já publicado (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — único elo quebrado: 0 inscrições push (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree work/3.
Recently resolved (2026-08-10): OPS37 (Issue #605) — o container Postgres compartilhado não é mais recriado por um worktree vizinho: pnpm db:start virou scripts/db-start.mjs (CLI) sobre scripts/lib/db-start.mjs — preflight "container teqo-postgres-1 Up + healthy/starting → no-op (compose nem roda); senão, compose -p teqo up -d --wait --wait-timeout 120 postgres como antes. Causa raiz provada por medição: o bind mount relativo ./docker/postgres/init do compose resolve um path absoluto por worktree → config_hash diferente por worktree (OPS37 39e89956… / B195 cf3cb522… / C101 917e11bb…; diff do config = só o source: do bind) → qualquer compose up cru de outro worktree fazia replace e matava dev+e2e de todos (terminating connection due to administrator command) — o incidente se repetiu ao vivo durante o gate: o worktree work/4 recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). Verificação do aceite: StartedAt inalterado após db:start repetido, inclusive rodado de outro worktree (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por pnpm db:start --force-recreate (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). worktree.mjs provisionamento delega para a mesma lib (mesmo risco no worktree next; dockerComposeUp/SHARED_COMPOSE_PROJECT órfãos removidos). Skip deliberado em starting (container que um peer acabou de criar): espera o boot ficar healthy (60s, polling 2s) em vez de recriar — e cai no compose up se travou; não skip em unhealthy/sem healthcheck (volta à config atual exatamente uma vez); db:stop/db:doctor/compose file/CI intactos. Unit novos (dbStart.unit.spec.ts, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de starting). Sem migration/access/Consent/UI. Intenção container-postgres-compartilhado-compose-replace.md; impl container-postgres-compartilhado-compose-replace-impl.md.
Recently resolved (2026-08-10): D9 (Issue #596) — flake do consent restore do testDatabaseLease sob carga eliminado. Caracterização com reprodução (3 falhas em 8 runs da suíte int completa; depois isolada a 2 arquivos): o assertion "restores the exact configured consent after deletion and recreation" via leituras voláteis de uma linha canônica que escritores legítimos de outras specs deletavam/recriavam no meio do teste. Causa raiz em três camadas: (1) a janela de operação de withMissingInviteConsentFixture era a única transição de estado sem lease da suíte (outras specs criavam linha efêmera que o restore deletava sob elas); (2) removeOnda0ConsentAndPrivacyDb rodava sob leases shared e deletava as 4 keys estáveis no meio de testes alheios (o id restaurado sumia irreversivelmente); (3) asserções de restore comparavam contra leituras before que o provision Onda0 podia re-textar legitimamente. Fix (só testes, zero src/): janela de operação sob lease shared (serializeWindow, default true; o teste de ensures concorrentes migrou para key/lease privada — único caso que deliberadamente mantém janela sem lease, com re-verificação/re-delete do gap setup→janela; exclusivo descartado: alargava o ABBA com os escritores de invite sob carga); spec Onda0 com leases shared + down-migration restaura as linhas com os ids originais dentro da janela (restoreConsentRows compartilhado; no-op para o resto da suíte); asserções de restore referenciam o snapshot do fixture (contrato real), rollback asserta id+key; ORDER BY id/sort: 'id' nas leituras limit-1; core withDatabaseLease; ensureInviteConsent órfão removido (knip). Contraprova: suíte completa ×5 + par mínimo ×8 + 6 arquivos da key ×13/14 verdes (runs com restart externo do Postgres descartados por uptime). Intenção test-database-lease-consent-flake.md; impl test-database-lease-consent-flake-impl.md.
(fix(C113): feed iCal da agenda vivo — contrato de frescor no lugar do snapshot congelado)
Recently resolved (2026-08-10): B195 (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome municipalityLabel), sem título grande de passo (stepTitle={null} + contentFocus="none"), Tipo → Atividade (opcional) → campo único "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (returnPath). Mudança de modelo da demanda: o cadastro (wizard e /demandas/nova, sem divergência) tem UM campo de texto (description required, min 2, max 4000 — constante CAMPAIGN_DEMAND_BODY_MAX_LENGTH); o título virou derivado por IA (src/utilities/ai/campaignDemandTitle.ts — generateText DeepSeek deepseek-v4-flash como o Sollinha, timeout 4s, sem key → null, prompt com rótulo do tipo) com fallback = texto truncado (src/lib/demandTitle.ts, puro: colapsa whitespace, ≤160) no create e keep-previous na edição (novo updateCampaignDemandRecord + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); campaignDemandCreateSchema perdeu title (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). Unicidade de slug defensiva só no create das forms (ensureUniqueDemandSlug, opt-in context.campaignDemandUniqueSlug fail-closed em transação ativa — precedente skipContactPhoneInvariant): probe único da janela -2…-20 + advisory lock campaign-demand-slug:<base> contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch register-demand no router + staff gate ANTES do load do escopo (gate: 'staff' → líder vai a /campanha/contatos, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub WizardMunicipalitySelectedStub removido, placeholder "em breve" apagado). Bug latente corrigido no hook compartilhado useCampaignFormSuccessToast (dependência [state.message, state.status] → [state]: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em schemas/campaignDemand.ts; parser único parseCampaignDemandCreateFormData (utilities/demand/). Sem migration (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão -2/-3, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem DEEPSEEK_API_KEY" no beforeAll); e2e novo campaignRegisterDemand (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção registrar-pedido-wizard.md; impl registrar-pedido-wizard-impl.md.
Recently resolved (2026-08-10): OPS33 (Issue #595) — worktree next claima deterministicamente antes de criar o worktree: mesma fila/ordem e lock otimista do pnpm agent:claim, com o claim extraído para scripts/lib/agent-github.mjs (claimIssue — lock + flip + comentário; claimBriefLines — brief compartilhado; claimQueueEntry — shape para o reopen; claimTargetVerdict — decisão pura testada) e o agent-claim.mjs virou CLI fino com a mesma saída; claim falhou (fila vazia/race/--issue inválido) → motivo e saída sem worktree órfão (branch validada antes do claim; hint de falha pós-claim). --issue <N> claima a Issue direcionada (ready) ou reabre uma já claimada (in-progress — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar pnpm agent:claim". O launch do terminal agora envia /work-issue --issue <N> (prompt citado na diretiva) e a função worktree() de .agents/shell/worktree.sh passou a tokenizar a diretiva via xargs (quote-aware, nunca eval) — o valor do --prompt carrega espaço. --stay suprime cd+launch mas o claim acontece; o comando /worktree roda o mesmo script (claim incluso, sem launch). Guard -B contra reset de commits de sessão no reopen. Pool intacto (claimIssueForPool coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, .opencode/commands/worktree.md, skill worktree-next-issue (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção worktree-next-claima-issue-deterministicamente.md; impl worktree-next-claima-issue-deterministicamente-impl.md.
Recently resolved (2026-08-10): OPS35 (Issue #600) — modo de spec e2e full-stack sem browser (paradigma HTTP): mesma suíte (pnpm test:e2e), mesmo job/CI, mesmo servidor + DB reais, sem lançar navegador. Base nova tests/e2e/fixtures/campaignHttpTest.ts estende o test cru de @playwright/test (nunca e2eTest.ts — o guard e2eFailureGuard é auto e puxaria page/context), reusa o fixture campaign do browser (exportado como campaignFixture — ownership/cleanup in-process, zero caminho de seed paralelo) e adiciona o fixture campaignRequest(user, senha): login pelo REST real do Payload (POST /api/campaignUser/login, loginWithUsername aceita email/username) → request.newContext com o cookie canônico campaign-token (path /campanha, flags espelhando setCampaignAuthCookie; secure pelo scheme do baseURL, não pelo NODE_ENV do runner). Nomenclatura ajustada contra a letra da intenção (descoberta de execução): *.http.e2e.spec.ts não casa o testMatch /campaign.*\.e2e\.spec\.ts/ do projeto → campaign<Family>Http.e2e.spec.ts no mesmo projeto (dependência setup de prewarm já coberta). Primeira família migrada: campaignTerritories (4 specs campaignTerritoriesHttp.e2e.spec.ts, browser removido; manifesto e2e-affected atualizado — unit-pinned). Equivalências: clicks viravam GETs de URL (?sort=votes2022 → caption "Ordenado por 2022 (maior primeiro)"; ?region=Irecê → "1 território encontrado"); DOM virou HTML renderizado — com duas realidades de render registradas no header do spec: separadores RSC <!-- --> (asserções rodam sobre rendered(), comentários são transporte, não conteúdo) e o label "Cobertura" presente no flight payload (menu do picker é client component serializado mesmo fechado) — o pin "oculta por default" é a ausência do controle sortable, que resolveVisibleColumns não renderiza. RBAC leader: o redirect noLeader é redirect() pós-stream → o documento responde 200 com <meta http-equiv="refresh" content="1;url=/campanha/contatos"> (Next next-route-redirect), não 3xx — o meta é o contrato assertado (dev 307/prod 308 quando o redirect vence o stream; nunca pinar status numérico). Medição (dev server, worktree 3135, load ~68, prewarm incluído nas duas versões): família browser 56,0 s (22,9/17,5/7,9/7,7) vs HTTP 33,7 s (10,8/8,4/2,4/12,1) — −40%; o ganho é estrutural (sem login de browser, sem hidratação, sem waitForURL), a carga da máquina domina a variância. No prod build local (E2E_PROD=1, o modo do CI) a família HTTP fecha 4/4 em 3,1 s (1,4/0,9/0,5/0,3). Guard de falhas client permanece exclusivo do browser (aceite). Sem migration/access/Consent/UI/schema. Intenção e2e-full-stack-sem-browser.md; impl e2e-full-stack-sem-browser-impl.md.
Recently resolved (2026-08-10): OPS28 (Issue #584) — worktree next/plan passam a aplicar o seed mínimo além das migrations em cada banco provisionado (migrate + db:seed:minimal por banco, dev e test) — paridade com a CI, que roda o mesmo par antes do e2e: o e2e de worktree deixa de divergir do de CI (specs dependentes dos pins priority: 'alta' de salvador-ze-1/camacari — FAB suggest e campaignSavedFilters — falhavam só localmente com banco migrado cru 435×normal, o padrão que o ledger TECH-DEBT row 19 ficha como "masks real failures"). --no-migrate pula migrate e seed (o seed depende do catálogo migrado); re-provisionamento continua idempotente (upserts). Fallback sem Docker (Cursor Cloud) também roda migrate+seed nos teqo/teqo_test compartilhados (decisão de produto do gate: paridade total). Guard de regressão tests/e2e/seedParity.setup.e2e.spec.ts no projeto setup do Playwright: falha cedo com mensagem clara ("rode pnpm migrate && pnpm db:seed:minimal") se o banco de teste não estiver seedado — dependentes do setup pulam, nada de spec vermelho confuso; a evidência checada são os users do seed (MINIMAL_CAMPAIGN_USERS), deliberadamente não os pins priority: 'alta' — o cleanup dos fixtures e2e reseta priority dos municípios que toca (round-robin por sequência persistente), então pins morrem legitimamente após runs repetidas no mesmo banco de worktree e só o pnpm worktree next re-aplica (guard nunca checaria pins); scripts/worktree.mjs entrou em HIGH_RISK_EXACT (mudança no provisionador roda a suíte cheia na CI). Sem migration/access/Consent/UI. Intenção worktree-seed-minimal-paridade-e2e-ci.md; impl worktree-seed-minimal-paridade-e2e-ci-impl.md.
Recently resolved (2026-08-10): OPS30 (Issue #586) — hardening do e2e em dev contra o compile-frio do Next dev (a classe que faz pnpm test:e2e local acusar vermelho com main verde). Prewarm completo no setup (setup.e2e.spec.ts): lista GET 13→26 rotas (o audit por grep achou /campanha/municipios/<slug>/editar — o flake ERR_ABORTED de :78 —, alvos de redirect invisíveis a grep de goto como /campanha/demandas/[slug], rotas de wizard acoes/*, agenda/atividades/nova/contatos/atualizacoes/offline e os artefatos PWA sw.js/manifest.webmanifest que toda página busca no load) e POST ganhou /campanha/municipios/next-steps, /campanha/home-search (suggest do overlay FAB) e as rotas do chat Sollinha /campanha/api/ai-chat+ai-transcribe (montadas em toda página autenticada — 1º hit compila frio no meio da suíte e o reload aborta a navegação de um teste irmão). Prewarm GET com retry-único (prewarmGet): o próprio compile pode abortar a conexão do prewarm (socket hang up medido na lista original sob load ~50); asserção só no retry. Budget test.slow()→setTimeout(420s) (26 módulos × ~60s frios sob carga; CI quente em segundos). Login resiliente (campaign.login): retry-único do submit inteiro (re-fill+re-click) com grace-wait de 5s do destino exato antes do re-submit (guard de prefixo casava /campanha/login — corrigido); budget sozinho não recupera submit abortado. Refactor mínimo do spec campaignMunicipalities (evidência de execução): :518 (B24) — botão de tendência só existe no estágio largo B158 e o locator pegava a cópia mobile escondida → ensureWideMunicipalityList+escopo container+.filter(visible); re-clique pós-reload era pré-hydration (P3-C) e o trigger toggles (toPass com clique cego fecha o popover para sempre) → networkidle pós-reload + clique só com popover fechado; :713 (demanda) — server action de criação compila fria no 1º POST (não prewarmável) → retry do submit em toPass; :1063 (FAB suggest) — root cause = paridade de seed: o CI roda db:seed:minimal (pinna priority:'alta' em salvador-ze-1/camacari) e o suggest do coordinator é vazio sem isso → achado registrado para o OPS28 (worktrees provisionam DBs e2e sem o seed mínimo, em progresso); retry blur+focus do suggest em toPass como defesa do stream truncado. P4-L fechado com evidência (TECH-DEBT row 59): campaignLeaderships ×3 verde em dev a 2 workers (load 4–10) + ×1 E2E_PROD=1; TESTING.md caveat atualizado. Aceite: campaignMunicipalities 17/17 ×3 consecutivos em dev a 2 workers (16:00–16:03, load 5–18) — antes: :78 3/4 vermelho e :518 1/2. Sem migration/access/Consent/UI. Intenção hardening-e2e-dev-compile-frio.md; impl hardening-e2e-dev-compile-frio-impl.md.
Recently resolved (2026-08-10): OPS34 (Issue #599) — o job e2e do CI (o mais longo da cascata, 435 s medidos no run 31412425553: ~67 s fixos + build 172 s + testes 196 s) roda agora em 2 shards paralelos (--shard=N/2, matrix fail-fast: false, build por shard — opção A da intenção validada pela medição: por shard ≈ 5–5,5 min; artefato de build compartilhado rejeitado porque os shards só começam após o build → wall maior). Cadeia de projetos do Playwright (setup → campaign → frontend → admin) + prewarm do setup viraram dev-only: playwright.config.ts ganhou isProdMode = CI || E2E_PROD=1 (mesma condição do webServer.command, agora reusada) — em prod o projeto setup sai do array (arquivo não casado = não coletado) e as dependencies zeram (famílias em paralelo contra o build de produção, onde não há compile a preaquecer); dev intocado (provado: --list mostra [setup] só sem CI; smoke e2e dev rodou setup→campaignAuth). ci-pr.yml: o job scope emite e2e_matrix/e2e_shards via novo e2eShardConfig(mode) em scripts/lib/test-affected-core.mjs (fonte única, unit-pinada: full → [1,2]/2, selected/none → [1]/1 — pagar 2 builds por 3 specs é desperdício); ci.yml hardcodeia [1,2]/2 com comentário apontando o single-source. PLAYWRIGHT_WORKERS: 2 explícito no env dos jobs e2e (aceite: explícito, default local intocado). Armadilha registrada nos workflows: flags do playwright vão sem o separador -- — pnpm repassa o -- literal ao playwright e a flag vira file pattern ("No tests found"). Specs intactas (o setup não foi removido — virou dev-only, já documentado como prewarm dev-only); sem migration/access/Consent/UI. Medição pós-merge (main, run 31418206512): job e2e 435 s → 326 s (shard 1) / 369 s (shard 2) — shard 2 = fixo 68 s + build 176 s + testes 124 s (a fase de testes caiu de 196 s seriais para 124 s — metade da suite por runner + cadeia removida; o shard 2 ainda pagou retry da flake conhecida de campaignMunicipalities; shard 1: testes ~82 s); gate completo do pipeline (checks) ~7,5 → ~6,4 min. Build passou a dominar o shard — gatilho da intenção (opção B, artefato compartilhado) reavaliável quando os testes encolherem mais. Intenção ci-e2e-paralelizar-job.md; impl ci-e2e-paralelizar-job-impl.md.
Recently resolved (2026-08-10): OPS22 (Issue #562) — CI de main vermelho determinístico desde o merge do B188 (05:54): os 3 e2e da agenda mobile (campaignAgendaMobile :96/:242/:295) falhavam com todas as retries → deploy bloqueado. Root cause (reproduzida no prod build local + error-contexts com o drawer aberto): o login e2e roda em viewport desktop → o settle de B167 abre o chat (open=true) → o efeito persist do B188 (status ready já nasce) grava {open:true} no sessionStorage → o spec muda para 390px na mesma aba → o restore do B188 reabre o drawer mobile sozinho → o modal Radix aria-hide + inert a página e cobre o viewport (combobox fora da a11y tree no :242, swipe cai no drawer no :96, clique do seletor bloqueado no :295; teclado/:173 passam por não usarem hit-testing). Defeito real de produto (aba desktop → mobile na mesma aba faz o chat "saltar"), não só timing de teste. Fix na origem: SollinhaChatSession ganhou openBy: 'user' | 'settle' (opcional — sessão legada sem o campo = 'settle', fail-closed; sem bump de versão) e o provider agora distingue intenção do usuário (setOpen/toggle do context value marcam userToggledOpenRef; FAB/header/drawer já passam por eles) de verdade de layout (settle B167 usa o setter cru): o restore espera o measured (viewport real) e honra open:true em mobile só quando openBy==='user'; o persist grava openBy conforme a intenção. Todos os contratos B188/resize mantidos (reload restaura conversa; drawer aberto por usuário volta aberto; visita mobile nova fechada; crossing ao vivo B167 intocado). Testes: unit (round-trip/invalidez/legado de openBy) + e2e pin OPS22 no spec B188 (login desktop → poll do persist open:true → viewport 390 → /campanha/agenda → sem dialog + botão do Sollinha visível); spec da agenda intacto (4/4 no prod build). Sem migration/access/Consent/schema. Intenção ci-e2e-agenda-mobile-regressao-b188.md; impl ci-e2e-agenda-mobile-regressao-b188-impl.md.
Recently resolved (2026-08-10): OPS31 (Issue #593) — worktree plan no terminal agora abre o TUI do opencode com /plan-issue já enviado (a revisão da proposição do opencode mostrou que a necessidade real do fluxo era auto-envio, não pre-fill: --prompt sempre auto-submete, e o next já provava com /work-issue — o plan só ficou sem prompt por uma premissa equivocada da OPS26). Mudança de valor no mapa existente: OPENCODE_SKILL_COMMAND_BY_PURPOSE.plan: null → '/plan-issue' (scripts/lib/worktree.mjs); next (/work-issue) e new (prompt-less, "apenas conversar") intocados; o mecanismo OPS26 já injetava --prompt quando o valor existia — zero código novo. Textos correlatos atualizados (docblock/uso do script, worktree.sh, AGENTS.md, skill worktree-next-issue); o tom do gap mudou: prefill-without-submit segue como feature request upstream (proposição ~/Code/propositions/opencode/prefill-prompt-sem-submit.md anotada com o desfecho — edição local, fora do repo), não mais fallback do Teqo; entradas OPS26/OPS27 do changelog permanecem como histórico. Unit: teste "plan launches with /plan-issue sent" + pin de constantes { next: '/work-issue', plan: '/plan-issue', new: null }. Sem migration/dados/access/UI. Intenção worktree-plan-abre-opencode-com-plan-issue.md; impl worktree-plan-abre-opencode-com-plan-issue-impl.md.
Recently resolved (2026-08-10): B193 (Issue #542) — card de município mobile denso + edit-where-you-see + última atualização expandível. O card de /campanha/municipios no mobile deixou o grid de labels e virou composição do wireframe: cabeçalho nome+território com posição de voto 2022 à direita (percentual em cima; colocação · votos embaixo), barra de cenários (pessimista·central·otimista + marcador do ativo — VoteEstimateScenarioStrip como trigger da sheet de votos existente), chips classe/tendência/nível (tendência e nível com label à esquerda, dentro do trigger — tocar no label abre a sheet; classe com razão via tooltip que abre no toque), 3 grupos de avatares com label (Assessores/Lideranças/Dobradinhas — um avatar por entidade, wrap, sem cap; leitura de assessores para não-coordenador), rodapé "Última atualização há X dias" + chevron que expande o limite inferior revelando o último card de atualização (badges + corpo + autor · data, sem moldura) + CTA "Registrar atualização" (sheet existente); sem atualização → o rodapé vira CTA direto (sem chevron/expansão); prioritário = borda lateral direita 6px border-primary no lugar do flag; Salvador read-only; "Cobertura" (conta da cadeira) sai do card (fica na tabela/detalhe — gate humano); rodapé mede a idade da atualização (não o frescor update∨pledge — coluna desktop intacta). C109 corrigida no caminho (a "sheet quebra ao abrir" era real): os callbacks dos controles (autosave onOpenChange, handleOpenChange do nível) são recriados por render — qualquer re-render com a sheet aberta (digitar no sheet!) fazia o host descartar a sessão e o corpo sumia; sessões do host agora são keyed por id de sessão (imune a identidade) e o footer customizado (submit + DialogClose) passou a ser registrado e renderizado pelo host dentro da árvore do Drawer (portal perdia o Dialog context — o e2e antigo só abria a sheet de tendência, sem footer custom, por isso a C109 não reproduzia). Controles ganharam prop trigger/triggerClassName (precedente RelationChipCell.trigger); MunicipalityRelationAvatarStack ganhou modo wrap. Dado: MunicipalityListViewModel.lastUpdate (batch por página via loadMunicipalityLastUpdates, scoped por portfólio). E2E novos (390×844): barra abre sheet e reflete valores salvos, chip Nível abre a sheet, borda de prioridade (6px + cor), expansão/CTA vazio. Sem migration/access/Consent. Intenção municipios-card-mobile-edit-where-you-see.md; impl municipios-card-mobile-edit-where-you-see-impl.md.
Recently resolved (2026-08-10): OPS26 (Issue #571) — worktree next/plan no terminal terminam com o TUI do opencode aberto no worktree: a função worktree() de .agents/shell/worktree.sh marca TEQO_WORKTREE_TERMINAL=1 e o script imprime a diretiva launch opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt /work-issue] antes do cd (que segue sendo a última linha — contrato OPS24 intacto); a função aplica o cd e executa a linha (sem exec: sair do TUI volta ao shell no worktree; sem auto-kill). next envia /work-issue (o comando OPS25 roda o ciclo completo); plan abre sem prompt — a CLI do opencode não tem pre-fill sem submit (--prompt sempre auto-envia; fallback acordado: /plan- + autocomplete; gap registrado para o repo do opencode). Presets são constantes em scripts/lib/worktree.mjs (OPENCODE_PRESET_MODEL, OPENCODE_SKILL_COMMAND_BY_PURPOSE, opencodeLaunchDirective — pura, unit-testada; trocar de modelo é editar a constante). Sem o marcador (comando /worktree do opencode, automação) a diretiva não é impressa — nunca abre TUI aninhado; --stay suprime cd e launch; kill intocado. Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, .opencode/commands/worktree.md, skill worktree-next-issue. Sem migration/dados/access/UI. Intenção worktree-go-abre-opencode-com-presets.md; impl worktree-go-abre-opencode-com-presets-impl.md.
Recently resolved (2026-08-10): OPS24 (Issue #569) — --go virou o padrão do worktree CLI e kill volta ao main: worktree next/plan imprimem cd <dir> na última linha por padrão (antes exigiam --go); --stay suprime; --go explícito continua aceito como no-op (não quebra hábito/automação). kill troca a mensagem "Volte ao main: cd …" (que nunca casava com o sed s/^cd //p da função shell) pela linha máquina cd <main> como última linha — depois do drop dos DBs, que rodava antes — e não aceita --stay (ficar em cwd destruído é exatamente o bug corrigido; --go no kill é no-op). Vale nas duas superfícies que aplicam o cd: função worktree() de .agents/shell/worktree.sh (agora sempre captura/aplica, --stay retorna cedo) e comando /worktree do opencode (agente aplica o cd da última linha sempre, exceto --stay). Docs: header/uso do script, AGENTS.md ("Per-worktree environments"), skills worktree-next-issue/local-database. Sem migration/dados/access; derivação pura (worktree-env.mjs/worktree.mjs libs) intocada. Intenção worktree-go-default-e-kill-volta-ao-main.md; impl worktree-go-default-e-kill-volta-ao-main-impl.md.
Recently resolved (2026-08-10): OPS25 (Issue #570) — atalhos diretos no TUI do opencode: .opencode/commands/work-issue.md e plan-issue.md (mecanismo nativo de commands, mesmo padrão de issue.md/worktree.md). O frontmatter leva description pt-BR (autocomplete do TUI) + model: deepseek/deepseek-v4-flash (pin do modelo de preferência, alinhado ao launch da OPS26 com --model); o corpo instrui carregar a skill de mesmo nome pela ferramenta de skills e segui-la ponta a ponta, apontando a fonte canônica .agents/skills/<nome>/SKILL.md — sem transcrever (anti-goal da intenção) — com $ARGUMENTS repassado no fim. Guard de acoplamento tests/unit/opencodeCommands.unit.spec.ts: comando↔skill presos por nome exato (renome de skill → build falha em vez de comando quebrado silencioso). Sem migration/access/Consent/UI. Intenção opencode-comandos-work-issue-e-plan-issue.md; impl opencode-comandos-work-issue-e-plan-issue-impl.md.
Recently resolved (2026-08-10): B192 (Issue #533) — o Sollinha encerra respostas factuais com follow-ups sugeridos como chips (2–3, no mesmo slot dos chips de abertura do B191, acima do input; tocar envia como mensagem do usuário; substituídos a cada resposta; somem durante o streaming; mobile limita a 2). Uma única chamada ao modelo: o prompt (systemPrompt.ts) instrui encerrar com o bloco estável **Sugestões de continuação:** + lista markdown (só perguntas respondíveis pelas próprias tools, leader sem sugestão eleitoral — B180; bloco = instrução de interface, nunca citar em prosa) e o cliente extrai. Contrato em fonte única: src/lib/sollinhaFollowUpSuggestions.ts — SOLLINHA_FOLLOW_UP_MARKER (interpolado no prompt) + splitSollinhaFollowUpBlock (última ocorrência vence; body sempre mascarado do marcador em diante; itens -/*/1./1) aceitos, bold e duplicados limpos, teto 3; fail-closed: <2 itens ou linha não-lista → sem chips, resposta normal). UI: strip no render de todo text part assistant antes do markdown/links (bloco nunca pisca como prosa; mensagens antigas restauradas via B188 continuam mascaradas e os chips renascem por derivação); slot B191 estendido (catálogo vazio → follow-ups); tipo compartilhado renomeado SollinhaOpeningQuestion → SollinhaChatChip. Strip defensivo no servidor: route.ts remove o bloco das mensagens assistant ao reconverter para o modelo (próximo turno sem eco nem custo de contexto). Unit (16 casos do split) + e2e novo campaignAiChatFollowUps.e2e.spec.ts (wire format v7 como o mock do B187; bolha sem o bloco, chips, substituição, fail-closed, cap mobile). Sem migration/access/Consent/schema/endpoint novo. Intenção sollinha-follow-ups-respostas.md; impl sollinha-follow-ups-respostas-impl.md.
Recently resolved (2026-08-10): OPS23 (Issue #566) — a Action OPS18 (plan-issue-ready-on-main-merge.yml) falhava sempre no primeiro disparo real (PR #563, run 31381977772): o job roda actions/setup-node@v5 sem pnpm/action-setup e o script é plain Node — com package-manager-cache: true (default do v5), o setup-node resolve o packageManager do package.json (pnpm@10.11.0) e procura o executável pnpm → Unable to locate executable file: pnpm, promote nunca rodava. Correção: package-manager-cache: false no setup-node (precedente exato dos jobs plain-Node: cooldown do ci.yml, vercel-promote.yml, archive-cursor-agent.yml; pnpm/action-setup@v6 rejeitado — só existe onde há pnpm install). Varredura dos 21 usos de setup-node: plan-issue-ready era o único com o bug latente. Validação funcional = próximo merge de PR com Related #N (trigger closed+merged→main só dispara em merge real). Impl ops23-action-promote-related-setup-node-impl.md.
Recently resolved (2026-08-10): B189 (Issue #526) — o Sollinha ganhou a tool getMunicipalitiesWithoutUpdate (src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts): "quais municípios estão sem atualização há mais de X dias" (cobertura do acompanhamento) no escopo do usuário, com contagem total exaustiva (sem top N — cobertura é "tudo além do limiar"), limiar declarado na resposta (limiarDias + criterio interpolado; default 30, ajustável por pergunta via days 1–365, estritamente maior), lista ordenada do mais velho ao mais recente com "nunca atualizado" no topo (estagnação máxima, sempre incluídos), itens com diasSemAtualizacao/cidade/regiao/tipo (ZE vs município inteiro)/assessores (nomes em query própria campaignUser — name não é identity-gated; nunca depth). Fonte de recência = municipality.lastUpdateAt (campo derivado pelos hooks de municipalityUpdate, admin read-only — a semântica compartilhada da família B185 "última atualização de campo", nunca updatedAt do município; sanity local provou create→coluna→delete→null via hooks); filtro de recência em JS com municipalitySignalAgeInDays (helper E9 reusado — coerência rótulo↔limiar sem boundary de instante no where). Gate staff inline (leader → { error: 'Leitura de municípios negada.' } antes de qualquer query, shape da B180); leituras overrideAccess: false, user: ctx.user (assessor = portfólio automático); guard fail-closed de escopo vazio (assessor fora do portfólio → shape vazio escopado sem leitura). Resolver de escopo extraído da B185 para src/utilities/ai/tools/aiToolScope.ts (resolveAIToolScope — Salvador primeiro, município por alias, território acento-tolerante; 2º call site da família; nomes de tipos alinhados à extração em voo da B186 para o merge dela vir trivial; contrato da B185 inalterado, spec existente cobre a regressão) e loader de nomes compartilhado aiToolQueries.ts (fundiu loadNamesByIds da B185 com o lookup local — don't twin). Seção nova no system prompt: declarar sempre limiar+critério, resumir Salvador ("7 das 19 zonas sem atualização há mais de 30 dias") e agrupar por assessor quando pedido (mesmo precedente "o modelo agrupa pela lista" da B185). Unit tests novos (municipalitiesWithoutUpdateTool.unit.spec.ts, 15): gate fail-closed com payload intocado, critério + borda estrita (31→incluído/30→excluído/days:15), ordem (nunca por nome, depois mais velho→recente), escopos acento/Salvador/município/erro, ids do escopo no where, escopo vazio fail-closed, nomes de assessores, tipo ZE. Sem migration/access/Consent/UI. Intenção sollinha-municipios-sem-atualizacao-recente.md; impl sollinha-municipios-sem-atualizacao-recente-impl.md.
Recently resolved (2026-08-10): OPS21 (Issue #538) — REVALIDATE_SECRET adicionada na Vercel production (projeto solla/jorgesolla): diagnóstico D6 confirmado (env ausente em vercel env ls production; POST /api/revalidate respondia 500 "not configured" em prod, quebrando o runbook pós-seed/mudança direta de DB). Segredo gerado com openssl rand -hex 32 (valor via stdin, nunca argv), vercel env add REVALIDATE_SECRET production; fallback necessário: o deploy existente congelava as envs em runtime → vercel redeploy do último deploy de produção (2×, pois o primeiro env add via pipe herdou o \n do openssl e gravou valor de 66 chars — removido e re-adicionado limpo). Validação real em prod: sem secret → 401; secret errado → 401; secret certo → 200 { revalidated: true, tag: 'posts' }; tag desconhecida → 400. Preview não adicionada (projeto sem Git conectado — vercel env add ... preview exige branch, precedente D6). Sem código/migration/access/UI. Impl revalidate-secret-vercel-production-impl.md.
Recently resolved (2026-08-10): C108 (Issue #522) — sino de notificações: abrir já lê — openCampaignNotifications (action única) lista + marca tudo como lido em 1 round trip, substituindo listCampaignNotifications + markAllCampaignNotificationsRead; o badge zera na abertura sem clique e o botão "Marcar todas como lidas" deixa de existir. Painel limpo em qualquer tamanho: sem título "Notificações" visível (sr-only para a11y), sem contador, sem botão "Fechar"; desktop vira modal central (Dialog shadcn, X no canto superior direito, clique fora fecha — par responsivo Dialog/Drawer do C94 via useIsMobile); mobile segue bottom sheet com handle, safe-area e skeleton de loading (o painel não abre mais em branco durante o round trip). markAllNotificationsRead extraída para src/utilities/notification/notificationList.ts (owner-scoped com bypass documentado, idempotente, cap 200 mantido; int testado: marca só as próprias, deixa as do outro intactas); readAt saiu do contrato NotificationListItem (sem leitores após a remoção do destaque de não lida); copy de erro unificada em CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE; router.refresh() re-sincroniza a instância irmã do outro header só quando algo foi marcado (race cross-tab documentado); foco visível nos itens e role="alert" no erro. P4-D atualizado no ledger — o batch sem transação agora dispara a cada abertura (self-healing). Testes: int (owner-scope + idempotência) e unit novos do bell (dialog limpo, refresh condicional, empty/error, sheet mobile). Sem migration, access, Consent ou schema. Intenção sino-notificacoes-leitura-auto-modal-desktop.md; impl sino-notificacoes-leitura-auto-modal-desktop-impl.md.
Recently resolved (2026-08-10): B186 (Issue #525) — o Sollinha ganhou a tool getMunicipalityPriorities (src/utilities/ai/tools/getMunicipalityPriorities.ts): responde "quais devem ser minhas prioridades neste momento?" com top N municípios do escopo (coordenador = estado; assessor = portfólio; recorte por território/cidade/município), cada item com uma linha de evidência e o critério de ordenação declarado na resposta (criterio + janelaDias). Ranking derivado na hora, 3 baldes mutuamente exclusivos por gravidade — (1) sinal_desfavoravel: última atualização na janela com ruim/urgente/alerta de adversário (trecho do body na evidência; "última palavra decide"); (2) estagnacao: sem sinal há 30+ dias (default, ajustável 7–90) ou nunca; (3) potencial: nível N0/N1/sem nível + top 5 por potencial do escopo (expectedVotes.central 2026 com fallback nos válidos 2022 do artefato commitado, fonte rotulada na evidência). Exclusão literal do aceite: atualização recente boa e sem sinais negativos tira o município de todos os baldes; municipality.priority: 'alta' é fator (menção "prioritário" + tiebreak), nunca o ranking inteiro; tendência política desfavorável entra como cláusula secundária. Frescor = E9 (max(lastUpdateAt, lastPledgeAt), municipalitySignal.ts — divergência da hipótese B185 de só-municipalityUpdate, aprovada no gate: a intenção lista recência de votePledge como fonte). Refinamentos: reason filtra por balde ("só as sem atualização"), sortBy: 'potencial' reordena, days/limit ajustam. Arquitetura: ranking em módulo puro e client-safe (src/utilities/municipality/municipalityPriorities.ts, 28 unit tests sem DB) + resolver de escopo da B185 extraído para src/utilities/ai/tools/aiToolScope.ts (2º consumidor; getPendingLeaderships refatorado — 18 testes verdes, sem mudança de comportamento); gate staff inline (leader → { error: 'Leitura de prioridades de municípios negada.' } antes de qualquer query), leituras overrideAccess: false, user: ctx.user, escopo vazio (assessor fora do portfólio) short-circuita antes da leitura. Seção nova no system prompt; allowlist P3-D estendida (mesma forma distinta da B185). Tool tests novos (prioritiesTool.unit.spec.ts, 10) — gate fail-closed, where da janela, escopo região, fallback 2022 (slugs reais do artefato), truncamento. Sem migration/access change/Consent/UI. Intenção sollinha-prioridades-do-momento.md; impl sollinha-prioridades-do-momento-impl.md.
Recently resolved (2026-08-10): B184 (Issue #514) — no mobile (<768px), o filtro de listas /campanha virou o padrão sem moldura do chassis CampaignListOmnibox (B127) — todas as listas: label oculta (input carrega o nome via aria-label), campo sem borda/sombra (md: restaura o desktop), form do filtro sticky sob a top bar com linha separadora edge-to-edge, "Limpar" vira X circular dentro do input (aparece com chips OU busca digitada; slot sempre renderizado com invisible para a largura não pular; hit target 44px + círculo visual 28px; live region própria "Busca e filtros limpos." — limpar só busca não navega e o pending region ficaria mudo para AT), botão texto "Limpar" desktop-only. Por que sticky no <form>: sticky não escapa do containing block (o pai) — a coluna do input dentro do wrapper curto do omnibox "grudava" ~12px e rolava junto (medido); o form é o único wrapper filho do CampaignPageShell (altura total). Exceção agenda (C101, merge anterior): a /campanha/agenda já tinha o strip sticky próprio (activity-agenda-filter-strip) — mantido; o chassis ainda a cobre no campo/X/Limpar. Em municípios, adicionalmente: "Salvar filtro" vira icon button no header mobile (slot C94/C95, SaveMunicipalityFilterControl presentation='icon'; popover de nomear intacto; nome de rename é o contrato 2.5.3) e cards edge-to-edge sem borda com uma linha separando (border-b last:border-b-0, -mx-4, gate md:). "Nova atualização" (atualizações) — único trailing visível no mobile — foi para o header (mesmo padrão); campaignMobileHeaderIconClassName extraída (3º consumidor). E2E novo em campaignMunicipalities.e2e.spec.ts (390×844): label oculta, borda 0, sticky, X (limpa chips+busca, foco volta sem reabrir popover), salvar→renomear no header, cards com separador, desktop invertido (1280). Débito registrado no caminho: D7 (sticky dos headers da tabela desktop morto — wrapper overflow-x-auto é o scroll container; medido). Sem migration, access, Consent. Intenção municipios-mobile-sem-moldura.md; impl municipios-mobile-sem-moldura-impl.md.
Recently resolved (2026-08-09): C103 (Issue #504) — o formulário de criação rápida da agenda ficou usável de ponta a ponta no celular: no narrow (mesmo breakpoint 640px do calendário), o sheet de criação abre do topo (swipeDirection="up" na primitiva Drawer — que já suportava top sheet; zero mudança em Drawer.tsx) preenchendo 100dvh-1rem, o conteúdo rola num scroll container e o rodapé ("Mais detalhes" + "Salvar") é fixo — verificado em smoke com teclado aberto (viewport 280px: sheet encolhe via dvh, conteúdo rola 187/187px e o Salvar permanece visível). O form ganhou variant: 'popover' | 'sheet': no sheet, labels viram sr-only (a11y e getByLabelText intactos), placeholders dão contexto ("Adicionar título _", "Município _", "Local (opcional)", "Adicionar responsáveis"), obrigatórios marcados com asterisco (título/município no placeholder, Início no trigger), campos empilhados sem gaps com divide-y e inputs/triggers sem borda (lista iOS do canvas). O seletor de data/hora abre como bottom sheet aninhado no mobile (ActivityDateTimeField ganhou isNarrow/label/required: conteúdo compartilhado Calendar+selects renderizado em Drawer com header "Início — " + "Pronto"; desktop continua popover). Props backward-compatible: placeholder/className no StrictCombobox, labelClassName/emptyText/triggerClassName no ResponsibleMultiSelect. Decisão do smoke: swipe handle removido do top sheet (primitiva o posiciona no rodapé, contradizendo o gesto de swipe-up; continua nos bottom sheets). E2E mobile novo (viewport 390×844): sheet do topo (y ≤ 1), Salvar visível sem scroll, picker em bottom sheet com Pronto, evento aparece após salvar; unit narrow cobre placeholders/asterisco/Pronto. Desktop visualmente inalterado (B181 segue na fila para o clip do popover ≤720px). Sem migration/access/Consent/schema. Intenção agenda-mobile-form-criacao-usavel.md; impl agenda-mobile-form-criacao-usavel-impl.md.
Recently resolved (2026-08-09): D6 (Issue #521) — push no device fechado de ponta a ponta. Diagnóstico com evidência por elo (leitura read-only de prod): (1) envs VAPID ausentes no Vercel production (verificado com vercel env ls production) — elo raiz: sem elas o servidor nunca chama o web-push e o card de perfil desabilita "Ativar"; (2) consentimento campanha-notificacoes-push já publicado (Onda 0, 2026-07-19); (3) 0 inscrições pushSubscription (bloqueado pelo elo 1); (4) 74 Notification geradas no sino (eventos fluem) mas zero envios; (5) banner no OS = passo humano (Android). Dois defeitos de código corrigidos com evidência: (a) o SW /campanha/sw.js servia CACHE_NAME "campanha-dev" em prod — deploys são CLI vercel build --prebuilt (git builds ignorados), e a rota force-static congelava o buildId no build onde VERCEL_GIT_COMMIT_SHA/VERCEL_DEPLOYMENT_ID não existem; a rota virou force-dynamic (lê VERCEL_DEPLOYMENT_ID, garantido em runtime) → cache versionado por deploy (unit test pina dynamic); (b) subscribe não idempotente — segundo toque em "Ativar neste dispositivo" no mesmo device devolvia a mesma subscription do browser e estourava o UNIQUE do endpoint (erro falso com o device já ativo); subscribeCampaignPush agora faz find-before-create e responde sucesso. Ferramenta nova: pnpm db:check:push-chain (scripts/check-push-chain.mjs, read-only — PROD_DATABASE_URL obrigatória, conexões com default_transaction_read_only) fecha um veredito por elo (SW servido/versionado, envs, consentimento, inscrições por usuário, log de eventos) com exit code. Ops: par VAPID gerado e adicionado em Production (VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT/NEXT_PUBLIC_VAPID_PUBLIC_KEY); preview ficou de fora — o projeto não tem Git repo conectado (deploys CLI) e vercel env add ... preview exige branch (branch_not_found); NEXT_PUBLIC_* precisa do build do próximo deploy para inliner. Pendência humana: teste em device real (Android Chrome) após o deploy — ativar em /campanha/perfil, disparar evento, conferir banner + toque; db:check:push-chain antes/depois. Sem migration, access ou Consent (fail-closed intacto). Intenção push-notificacoes-dispositivo-producao.md; impl push-notificacoes-dispositivo-producao-impl.md.
Recently resolved (2026-08-10): C106 (Issue #517) — feed de atualizações /campanha/atualizacoes sem moldura no mobile: cards edge-to-edge (-mx-4 no ul) sem moldura/arredondamento com uma linha separadora (border-b last:border-b-0 — padrão exato dos cards do B184) + empty state com copy única "Ajuste o filtro ou registre um novo fato de campo." (descreve a ação, vale nas duas resoluções; o texto antigo citava o botão "+ Nova atualização"). O filtro sticky/sem label/moldura e o icon "Nova atualização" no header foram entregues pelo irmão B184/C107 em main (campaignListOmniboxFormClassName + chassis borderless < md + campaignMobileHeaderIconClassName, id campaign-updates-new) — no rebase, a versão do irmão venceu o conflito em CampaignUpdatesFilters.tsx (edit the owner, não twin; a hipótese original de CSS de página no padrão C101 foi descartada na execução). Bug pré-existente corrigido no caminho: o @layer base editorial aplica my-6 ml-6 list-disc [&>li]:mt-2 a todo ul — o feed era a única lista campaign em <ul> (indent de 24px + 8px por li; precedente do patch: [data-theme='campaign'] p { margin-top: 0 }); a lista neutraliza na própria classe. Achado registrado: páginas dinâmicas (searchParams awaitado) têm um nó #S:0 hidden transitório durante o streaming (cópia da página; some em ~1–5s; só no build de produção) — os e2e aguardam o stream assentar (settleStream). E2E novo tests/e2e/campaignUpdatesMobile.e2e.spec.ts (mobile: label oculta, form sticky + linha, icon abre o bottom sheet, cards edge-to-edge; empty state; regressão desktop). Sem migration/access/Consent. Intenção atualizacoes-mobile-sem-moldura.md; impl atualizacoes-mobile-sem-moldura-impl.md.
Recently resolved (2026-08-09): B183 (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA /campanha (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela causa: uma regra CSS única em styles.css — @media (pointer: coarse) and (hover: none) + [data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem } — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por max-width). Unlayered vence as utilities text-sm sem !important; desktop (pointer fine) fica intocado; Input/Textarea do ui já eram text-base md:text-sm. Sem migration, access, Consent ou mudança de componente. E2E novo tests/e2e/campaignIosInputZoom.e2e.spec.ts pina o contrato: emulação devices['iPhone 13'] → omnibox de /campanha/municipios computa 16px; control desktop → permanece 14px. Intenção pwa-ios-autozoom-input-foco.md; impl pwa-ios-autozoom-input-foco-impl.md.
Recently resolved (2026-08-10): B185 (Issue #524) — o Sollinha ganhou a tool getPendingLeaderships (src/utilities/ai/tools/getPendingLeaderships.ts): "quais lideranças ainda precisam ser abordadas" por território de identidade (grafia tolerante a acento — "Vale do Jiquiriça" resolve para a região canônica), cidade ("Salvador" = as 19 ZE juntas) ou município (aliases canônicos), com o critério de pendência sempre declarado na resposta ('A abordar'/'Em disputa', ou 'Engajado' sem compromisso de votos no escopo consultado — eixo do compromisso = presença de votePledge liderança×município no escopo; negativo é excluído na query, fail-closed), lista com nome/municípios/status/última atualização/assessores responsáveis (nomes resolvidos em query própria — nunca depth populando campaignUser), top N (default 20, max 50) com total e truncado, escopo omitido = escopo de acesso do usuário. Filtro C3 sem_assessor (advisors: { exists: false }, precedente B176) e modo C4 municipios_sem_lideranca (municípios do escopo sem nenhuma liderança). Gate staff inline no topo do execute (leader → { error: 'Leitura de dados de lideranças negada.' } antes de qualquer query, mesmo shape da B180; 1 call site — sem helper); leituras todas overrideAccess: false, user: ctx.user (RBAC atual). Links via buildCampaignLinks existente (nada mudou lá); seção nova no system prompt manda declarar o critério. Unit tests novos (pendingLeadershipsTool.unit.spec.ts, 14) — gate fail-closed com payload intocado, critério por status+pledge, pledge fora do escopo não satisfaz (where municipality in), escopos com acento/Salvador/município/erro, sem_assessor no where, C4, truncamento, nomes de assessores; allowlist na convenção P3-D justificada (filtro de domínio sobre set resolvido ≠ escopo de actor). Sem migration, access change ou Consent. Intenção sollinha-liderancas-pendentes-abordagem.md; impl sollinha-liderancas-pendentes-abordagem-impl.md.
Recently resolved (2026-08-09): C104 (Issue #505) — compromissos "Todo o dia" na agenda: o sheet de criação rápida (C91) e o formulário completo ganharam o toggle Todo o dia (checkbox; no sheet entre o título e Início/Término, no form no topo do card "Data e horário") — ligado, Início e Término viram só datas (término pode ser outro dia = faixa de vários dias inteiros; dia único = startAt === endAt). Representação: campo novo allDay (migration 20260810_010844_add_activity_all_day) + convenção de instantes fim inclusivo (startAt = 1º dia 00:00 America/Bahia, endAt = último dia 00:00; Bahia sem DST); conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) concentradas no módulo puro lib/activityAllDay.ts (unit-pinado: dia único, multi-dia, virada de mês/ano, rótulo "10/08/2026 a 12/08/2026"). Calendário: allDaySlot ligado (faixa no topo de semana/dia), toEventInput com allDay, reschedule converte os startStr/endStr exclusivos e envia allDay; guard de drop: arrastar entre a lane all-day e a grade de horário é rejeitado (revert + toast — converter por drag é feature fora do escopo). "Mais detalhes" preserva a escolha (allDay=1 + datas civis nos params; form nasce com toggle ligado). Rótulos all-day em card, overview, dossiê do município, cabeçalho de detalhe e home search; feed iCal exporta DTSTART;VALUE=DATE/DTEND;VALUE=DATE com fim exclusivo. Liderança não consegue flipar allDay (snapshot staff). Sem access/Consent. Intenção agenda-toggle-todo-o-dia.md; impl agenda-toggle-todo-o-dia-impl.md.
Recently resolved (2026-08-10): C100 (Issue #495) — nova rota staff /campanha/pessoas: lista unificada de pessoas da campanha (lideranças, dobradinhas e staff) com merge por Contact — uma linha por pessoa física, capacidades como colunas (Nome com partido da dobradinha, Contato, Assessora, Lidera, Aliada em, Assessorado, Base, Ações). Merge em memória de 3 fontes com o where/access existente de cada domínio (src/utilities/people/peopleData.ts — fontes leadership/dobradinha/staff; staff source com overrideAccess justificado: campo contact identity-gated; escopo do assessor = linha com ≥1 município de capacidade na carteira; todo staff com ficha entra, mesmo sem carteira — gate humano 2026-08-09). Filtros na omnibox (capacidade multi OR, município, apoio, q) com URL canônica própria (peopleListUrl.ts, sem sort v1); filtros salvos = 2º call site do B18 via factory compartilhada createSavedFilterStore (municipalitySavedFilters refatorado como delegado, API + storage key + eventos idênticos, suite unit existente guarda) + submenu no sidebar e no drawer "Mais". Apagar pessoa (src/utilities/people/personDelete.ts + actions/person.ts): manifest read-only → diálogo lista tudo (liderança c/ municípios, dobradinha c/ partido, votos, convites, apoiadores, atualizações de município, feeds de agenda, contas); cascata transacional com advisory lock person-delete:<contact> — ordem por FKs NOT NULL reais (pledges → invites → supporters → leadership → dobradinha → contas (hooks de passkeys/notificações) → ficha deletada ou anonimizada se signature/subscription a referenciarem); contas coordenação/candidato bloqueiam a pessoa inteira; advisor joins cascadeiam no delete da conta. UI: omnibox edgeToEdge (novo variant no chassis B127 — label sr-only, sem bordas, Limpar = círculo-X interno), cards mobile sem bordas com divisórias, "Salvar filtro" como icon button no header via SetCampaignHeaderAction (slot C94), colunas com column picker B17 (novo CampaignListId 'pessoas'), FABs globais intactos. Mobile: convite reusa LeadershipInviteRowAction; WhatsApp whatsAppHrefForPhone. Sem migration. Testes: unit (URL contract, merge/escopo/facets, storage factory), int (escopo assessor/unrestricted/leader, cascata completa + anonimização + conta protegida), e2e novo campaignPeople (smoke staff + leader redirect; pegou e corrigiu um div dentro de p no card mobile — erro de hidratação). Intenção pessoas-lista-unificada.md; impl pessoas-lista-unificada-impl.md.
Recently resolved (2026-08-09): C102 (Issue #498) — no mobile (<768px), nenhuma tela /campanha de staff mostra mais hamburger nem sheet de sidebar: a navegação staff mora na barra inferior + drawer "Mais". CampaignMobileTopBar esconde o SidebarTrigger via useCampaignPageChromeRole + isStaffCampaignRole (leader mantém — lockdown sem barra inferior); CampaignSidebar retorna null quando isMobile && staff, desmontando o Sheet; B18 (filtros salvos de Municípios) migrou para o drawer "Mais" — MunicipalityNavSavedFilters ganhou variant: 'overflow' (mesma store useMunicipalitySavedFilters, mesma lógica apagar/desfazer/sucessão de foco; no drawer o fallback de foco vira o link Perfil do footer) e CampaignBottomNav passou a renderizá-la entre destinos e footer (container com overflow-y-auto). Bug pré-existente corrigido no caminho (D4): o <Toaster> vivia dentro do wrapper [contain:layout_paint] do painel da AI — contain: paint vira containing block para position: fixed e cria stacking context próprio, prendendo o toast (z 999999999 do sonner) ABAIXO dos portais z-50 de drawer/sheet: qualquer toast com ação (ex. "Desfazer") era inalcançável com modal aberto; o Toaster foi movido para o root layout (campaign)/layout.tsx (irmão do {children}, direto no <body>). Testes: 2 unit novos na top bar (staff sem trigger / leader com trigger), 3 e2e novos (staff mobile sem trigger e sem dialog Sidebar; leader abre o sheet com "Meus contatos"; fluxo completo salvar→navegar→apagar→desfazer no drawer). Sem migration, access ou Consent. Intenção c102-mobile-sem-botao-sidebar.md; impl c102-mobile-sem-botao-sidebar-impl.md.
Recently resolved (2026-08-09): B179 (Issue #462) — o Sollinha passou a falar de si no masculino: o system prompt (src/utilities/ai/systemPrompt.ts) abre com "Você é o Sollinha" e ganhou regra explícita (auto-referência masculina em primeira pessoa, nunca flexões femininas sobre si) — o welcome da UI já era masculino; o placeholder do chat virou "Pergunte para o Sollinha..." (CampaignAIChat.tsx) com os seletores e2e acompanhando (2 specs). Sem migration, access, Consent ou guarda automatizada (decisão do gate: só édito de texto). Os planos entregues da família (B162/B166/B167/B173/B174/B177) permanecem frozen com "a Sollinha" histórico — o que vale é a voz no runtime e o que se escreve daqui pra frente. Intenção sollinha-genero-masculino.md; impl sollinha-genero-masculino-impl.md.
Recently resolved (2026-08-09): B181 (Issue #480) — a /campanha/agenda não exibe mais o bloco "Nenhum compromisso nesta janela e neste filtro" + botão "Criar atividade" acima do calendário em janela vazia (só ocupava espaço, redundante com slot-click C91, botão "Nova atividade" dos filtros e FAB C94). Remoção cirúrgica em ActivityAgenda.tsx (bloco condicional + órfãos emptyHref/imports Link e buildActivityCreateHref); buildActivityCreateHref permanece com 2 consumidores ("Mais detalhes" no inline create, "Nova atividade" nos filtros); loading/erro e noEventsText do FullCalendar (vista "Lista") intactos. Teste unitário de regressão: janela vazia não renderiza aviso nem link de criação. Sem migration, access, Consent ou schema. Intenção agenda-remover-aviso-vazio.md; impl agenda-remover-aviso-vazio-impl.md.
Recently resolved (2026-08-09): C98 (Issue #483) — link de import da agenda: a geração em produção falhava por infra, não por código — a env NEXT_PUBLIC_SITE_URL não existia na Vercel production (verificado com vercel env ls production; só o preview tinha a de stage), e getCampaignInviteBaseURL() lança em NODE_ENV=production sem ela (fail-closed intencional) → o catch da action devolvia "Não foi possível criar o feed de calendário." e nada era criado. Verificação ponta a ponta no fluxo real (browser local, dev 3198): unlock C93 + criação C92 funcionam — login → agenda sem filtros → ícone "Link de import" → nomear → gerar → link /campanha/agenda/ical/<secret> sem erro → GET responde 200 text/calendar com a atividade. Correção: NEXT_PUBLIC_SITE_URL=https://pt.jorgesolla.com.br adicionada em Production (e conferida em Preview) + vercel redeploy do deploy de produção. E2E novo tests/e2e/campaignAgendaFeed.e2e.spec.ts pina o fluxo real: desktop (header → dialog → nomear → gerar → link responde iCal com a atividade) e mobile (FAB "Ações rápidas" → tile "Link de import" → sheet → gerar); a fixture e2e passou a conhecer calendarFeed no ownership/cleanup (antes deletava o campaignUser com feed createdBy NOT NULL → transação abortada). Sem migration, schema, access ou Consent. Intenção c98-corrigir-geracao-link-import-agenda.md; impl c98-corrigir-geracao-link-import-agenda-impl.md.
Recently resolved (2026-08-09): C93 (Issue #437) — botão "Link de import" da agenda destravado sem filtros (sucessor direto do C16): com zero filtros ativos, staff gera o link do feed completo dentro do escopo de leitura do criador — coordinator/candidate = tudo; advisor = portfólio dos municípios que administra — com fail-closed/revogação de C16 intactos. O servidor já cobria o caso (vazio = escopo do criador em buildFeedWhere/resolveFeedCreatorAccess; descrição do campo filterMunicipality); a entrega removeu o gate hasFilters → disabled de CalendarFeedButton (prop morto state incluído), manteve hasFilters só no "Limpar filtros" e neutralizou a copy do diálogo ("este recorte" → "a agenda"). Testes: 3 int de conteúdo sem filtro (coordinator = A+B; advisor = só o portfólio, nada fora vaza; advisor sem município → feed vazio fail-closed) + unit component (botão habilitado com zero filtros e fluxo nomear→criar). Sem migration, access, Consent ou schema. Intenção c93-gerar-link-import-sem-filtros.md; impl c93-gerar-link-import-sem-filtros-impl.md.
Recently resolved (2026-08-09): B180 (Issue #474) — as tools eleitorais legadas do Sollinha (getTopDeputies, getMunicipalityVotes) ganharam o gate fail-closed de leader lockdown que a B177 introduziu só na tool nova: assertCanReadElectionData(ctx.user) no topo do execute → { error: 'Leitura de dados eleitorais negada.' } antes de qualquer query (o route api/ai-chat expõe todas as tools a todos os papéis). O padrão chegou a 3 call sites → extraído um helper compartilhado electionDataGate(ctx) (src/utilities/ai/tools/electionDataGate.ts, true | { error }), e getLeadingMunicipalities refatorado para usá-lo (mensagem com fonte única). Unit tests novos (electionToolsLockdown.unit.spec.ts, 11): helper (leader nega/staff passa), as 3 tools com leader + payload stub intocado (fail-closed antes de query, inclusive no path SQL de terceiro candidato) e staff-proceeds por papel (coordinator/advisor/candidate). Sem migration/Consent/UI/prompt. Intenção sollinha-tools-eleitorais-leader-lockdown.md; impl sollinha-tools-eleitorais-leader-lockdown-impl.md.
Recently resolved (2026-08-09): C97 (Issue #482) — Início/Término do overlay de criação rápida da agenda (C91) trocaram o datetime-local nativo (que renderiza AM/PM no idioma do browser e só deixava escolher o dia de forma confiável) por um seletor shadcn de dia+horário 24h por construção: ActivityDateTimeField (trigger com rótulo civil dd/mm/aaaa às hh:mm + popover com Calendar react-day-picker 9.7 timeZone="America/Bahia" locale pt-BR e selects nativos Hora 00–23 / Minuto em passos de 15). Contrato de valor inalterado (YYYY-MM-DDTHH:mm civil → parseBahiaDateTimeInput no submit), prefill do slot mantido, validações intactas; dia trocado no calendário preserva hora e vice-versa; popover com max-h-[22rem] + scroll (clippava no topo em viewports baixos — nav do calendário ficava inalcançável). Componente shadcn canônico src/components/ui/Calendar.tsx (variante radix-nova, lucide icons; --cell-size via .cn-calendar no styles.css) + helpers puros em campaignTime.ts (formatBahiaCivilDateTimeLabel, hourOptions/minuteOptionsForStep/timeStepMinutes, floorToMinuteStep — prefill de slot é múltiplo de 30, floor é defensivo). Única dep nova: react-day-picker@^9.7.0 (date-fns transitivo). E2E do fluxo inline agora muda o horário pelo seletor e prova o chip "15:00 – 16:30" (24h) na grade. Sem migration/access/Consent. Fatia mínima aprovada no gate: eixo/chips do FullCalendar continuam meridiem: 'short' ("2 PM") e formatBahiaDateTimeLabel sem hourCycle explícito — follow-ups registrados no impl plan. Intenção c97-agenda-popover-seletor-data-hora.md; impl c97-agenda-popover-seletor-data-hora-impl.md.
Recently resolved (2026-08-09): B176 (Issue #458) — /campanha/municipios ganhou três dimensões de filtro na omnibox — Dobradinha (nome do contato), Liderança (nome do contato) e Partido (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos stateDeputy/leadership/party na URL congelada (sentinelas sem_dobradinha/sem_lideranca, reuso de sem_partido); buildMunicipalityListWhere ganhou um catálogo request-scoped fail-closed (municipalityRelationSets.ts, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (leadership.municipalities) e cruzada (stateDeputy.party) em where — sem migration, paginação nativa mantida; facets estendidas (stateDeputyIDs/leadershipOptions/parties) e mapa compartilha o catálogo. Coluna Dobradinhas liberada ao Assessor (staff-wide): servidor já estava scoped (B37/B157 reloadStaffActor + canUpdateMunicipality), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + exists:false" em campaignListUrl (NO_PARTY_FILTER_VALUE, splitAbsenceFilterValues, collapseListWhereOrBranches) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção filtros-municipios-dobradinha-lideranca.md; impl filtros-municipios-dobradinha-lideranca-impl.md.
Recently resolved (2026-08-09): B174 reverso (sem Issue — pedido direto) — a Sollinha (/campanha/api/ai-chat) voltou do @ai-sdk/deepinfra para o @ai-sdk/deepseek (API oficial api.deepseek.com): o cache-hit do V4-Flash direto custa $0.0028/M contra $0.018/M na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (deepseek-v4-flash), mesmas tools; @ai-sdk/deepseek pinado em 3.0.19 (não ^3.0.19, que flutuaria a 3.0.26 e puxaria a ferroviária provider@4.0.7 duplicada — o lock ficou na 4.0.4/5.0.18 do ai@7.0.47, verificado no lockfile). STT fica na Deep Infra (B173): whisper-large-v3 a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); .env.example agora documenta as duas chaves — DEEPSEEK_API_KEY (LLM) e DEEPINFRA_API_KEY (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.
Recently resolved (2026-08-09): OPS20 (Issue #464) — o e2e de cada worktree agora usa a porta do slot próprio (3100+slot), nunca o fallback 3000: o .env.test.local (que o playwright.config.ts lê em playwright.config.ts:14) passou a carregar PLAYWRIGHT_BASE_URL — antes ele só ia para o .env.local (que o config não lê), então dois pnpm test:e2e em worktrees paralelos subiam dois dev servers na porta 3000. Causa-raiz tratada além do sintoma: os dois blocos env inline do provision (que já tinham derivado uma vez) viraram um builder puro único worktreeEnvFileContents em scripts/lib/worktree-env.mjs que deriva NEXT_PUBLIC_SITE_URL + PLAYWRIGHT_BASE_URL do mesmo env.devPort para ambos .env.local/.env.test.local (paridade estrutural, drift impossível) + unit tests de paridade em tests/unit/worktree.unit.spec.ts (falham se o drift voltar). playwright.config.ts intocado (a variável certa já existia; só faltava chegar). Sem Docker (fallback) continua degradando ao compartilhado — fora de escopo. Sem migration. Intenção porta-e2e-isolada-por-worktree.md; impl porta-e2e-isolada-por-worktree-impl.md.
Recently resolved (2026-08-08): B170 (Issue #441, follow-up B169) — o contrato do colapso de chips virou spec unitário (tests/unit/relationChipCellCollapse.unit.spec.tsx): RelationChipCell renderiza 16 chips com getBoundingClientRect stubado (4/linha, 20px, gap 6 — a matemática real de COLLAPSED_CHIP_ROWS/CHIP_GAP_PX) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam measureOverflow: false ou colapsavam para 1 chip — a mudança de timing do B169 (medição em useLayoutEffect) não tinha regressão do comportamento de colapso. F2 medido e deferido em vez de eliminado: PerformanceObserver('layout-shift') no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos 0.139/0.043/0.257 (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo 97px/42chips → 57px/0chips numa única transição; ordenação client-side (RSC) → zero shifts novos. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção estabilizar-lista-dobradinhas-followup.md; impl estabilizar-lista-dobradinhas-followup-impl.md.
Recently resolved (2026-08-09): C94 (Issue #438) — barra da agenda consolidada: os 3 seletores (Município/Tag/Deputado presente) viraram um combobox omnibox (activityAgendaOmnibox.ts + chassis B127) com chips removíveis/“Limpar” e sugestões agrupadas; desktop toolbar [combobox][carrinho giro][+ nova] (ícones; mobile sem botão de criação no corpo — tudo no FAB); “Link de import” saiu do card de filtros para o header do app (CampaignDesktopHeader, agenda-contextual, ordem [link][sino][IA] do gate) via registro chaveado SetCampaignHeaderAction no CampaignPageChromeContext (slot compartilhado com C95), e virou ação de drawer no FAB mobile (CampaignQuickAction.onAction + openCalendarFeed no CampaignQuickActionContext) abrindo o mesmo diálogo como sheet responsivo (CalendarFeedDialog Dialog/Drawer; sem recorte mostra aviso “Aplique filtros…”, C93 cobre gerar sem filtro). O overlay do FAB fecha ao disparar ação de dialog. Sem migration/access/Consent. Intenção c94-agenda-combobox-header-acoes-rapidas.md; impl c94-agenda-combobox-header-acoes-rapidas-impl.md.
Recently resolved (2026-08-08): B172 (Issue #446) — na lista staff /campanha/municipios, abaixo da faixa C2 (< 60rem do container) a coluna Tendência deixa de colapsar para ícone no próprio lugar (inverte a decisão B158 "container query nunca duplica" só para Tendência): a coluna inteira some (hidden @min-60:table-cell no th+td) e o mesmo editor de tendência (status+justificativa, autosave) passa a morar como ícone compacto na coluna Ações, ao lado do de atualização; acima de 60rem volta o badge completo e Ações é gated fechado (@min-60:hidden) quando nada está oculto manualmente. Ações tornou-se sempre montada para staff (antes só por ocultação manual) e pinada quando trend/lastSignal está oculto no B17. Uma instância visível do editor por linha por faixa (dual-render CSS-gated, sem JS/resize — a leitura "uma instância" do B158 virou "uma visível por faixa"). Atualização/Sinal fica de fora (opção A). Side effect documentado: sort/filtro de tendência via header some abaixo de 60rem (seguem no omnibox). Sem migration/dados/Consent. Bug de produção corrigido no caminho (React 19.2.4 flight): o className novo na coluna destravou um bug do serializer do RSC — com votos oculta o thead virou uma tarefa de ~4,7 KB (acima do MAX_ROW_SIZE=3200) e o span do cabeçalho Dobradinha (com description) foi deferido para chunk posterior; no SSR o CampaignHoverTooltip recebia o placeholder não-resolvido como children → cloneElement(undefined) → React #130 derrubando o painel (e2e campaignColumnPicker vermelho 3/3 em prod, verde em dev). Correção: CampaignTableHead passou a enviar o rótulo como prop de dados (explanationLabel) em vez de elemento children — string nunca é deferida; o tooltip reconstrói o trigger. Sem mudança visual; os demais call sites do tooltip inalterados. Intenção tendencia-icone-coluna-acoes.md; impl tendencia-icone-coluna-acoes-impl.md.
Recently resolved (2026-08-08): pnpm worktree plan [bag] [--go] [--no-migrate] — worktree de planejamento para rodar a skill /plan-issue sem ocupar o main: cada invocação cria UM DIFERENTE, para sessões de planejamento paralelas — com bag, branch plans/plan-issue-<bag> (sufixo -2/-3 se o nome já estiver vivo); sem bag, o próximo sequencial plans/plan-issue-<n> livre (PLAN_BRANCH_PREFIX/planBranchName, scripts/lib/worktree.mjs). Prefixo minúsculo plans/… nunca colide com um next posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do next (porta 3100+slot, bancos teqo_wt<slot>/teqo_wt<slot>_test, migrations; sem Docker degrada ao compartilhado). kill cobre; comment das envs distingue gerado por pnpm worktree plan. Sem migration. Naming unit-testada.
Recently resolved (2026-08-08): B168 (Issue #422) — desfeito o encadeamento automático das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar qualquer ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (from allowlist B110 mantido; Início sem from) com toast — nunca avança para outro wizard. Removidos a fila/pipeline wizardActionChain.ts (fundido wizardReturnHref + wizardPreviousHref só com passos internos em campaignActionRoutes.ts), o param entry, os skip resolvers/Pular (WizardSkipTrailing/WizardTrendSkipTrailing deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (notePrefill/voteFrom/voteTo). Tendência: pós-save o RSC refresha com status já aplicado e o client é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para “Concluir” (retorna à origem). Sem migration. Intenção desfazer-encadeamento-acoes-wizard.md; impl desfazer-encadeamento-acoes-wizard-impl.md.
Recently resolved (2026-08-08): C90 (Issue #426) — os três campos de responsável da Atividade (responsible Contact, advisors staff, leadership) viraram um campo responsible polimórfico multi-valor (campaignUser staff · leadership · stateDeputy), removendo os dois antigos do modelo/UI; assessor continua vendo/curando o compromisso onde está listado como responsável via notação-objeto equals {relationTo,value} no access (provado por int test com payload.find real); seletor ResponsibleMultiSelect (chips tipados) + busca única searchActivityResponsibleOptions (escopo de leitura do ator, liderança engajada) — reusado pelo C91 na agenda; migração esquema+dados (advisors/leadership → responsible, Contact antigo descartado, contagem via NOTICE; SQL de reconciliação ensaiado contra cenário misto); busca q da lista virou título-only (regressão assumida no gate); notificação de atenção notifica só staff em responsible. Intenção remodelar-responsaveis-atividade.md; impl remodelar-responsaveis-atividade-impl.md.
Recently resolved (2026-08-08): B169 (Issue #425) — fim do flicker de linhas/colunas na lista de Dobradinhas (e nas demais listas com células de chips: Municípios, Lideranças, Assessores): o ciclo "medir → recolher" de RelationChipCell passou de useEffect (pós-pintura) para useLayoutEffect (antes da paint). Antes, cada remount (navegação RSC, paginação, ordenação, filtro) pintava o clamp max-h-18 (72px) e só então colapsava para o slice de 3 linhas — a tabela ia 1122→682px numa onda de LayoutShift por célula (medido 0.021×6 por navegação). Agora a primeira pintura já é a altura final; medido durante ordenação: tabela estável em 682px, zero LayoutShift das células. Resíduo documentado: o load SSR inicial ainda pinta o estado medindo (pré-hidratação) — useLayoutEffect não alcança essa paint; estimar largura no servidor foi rejeitado (reintroduziria a estimativa por label que o código evita). Sem migration, dados, access ou mudança visual. Impl estabilizar-lista-dobradinhas-flicker-impl.md.
Recently resolved (2026-08-08): worktree next provisiona ambiente isolado por worktree — agentes em paralelo não disputam mais porta 3000, o teqo_test compartilhado nem um segundo Postgres no 5432: pnpm worktree next [--go] [--no-migrate] deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server 3100+slot, bancos teqo_wt<slot>/teqo_wt<slot>_test criados no container compartilhado com migrations aplicadas; escreve .env.local + .env.test.local (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. kill dropa os bancos gerados (best-effort, WITH FORCE). Suites leem .env.test.local depois de .env.test (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); assertTestDatabase aceita teqo_test ou teqo*<worktree>\_testem localhost só (fail-closed preservado).db:start/db:stopfixam-p teqo(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação purascripts/lib/worktree-env.mjs (unit-tested).
Recently resolved (2026-08-08): B164 (Issue #400) — barra de navegação inferior fixa no mobile para staff, com 5 itens (Início, Municípios, Atualizações, Agenda, Mais) e drawer overflow "Mais" para destinos secundários (Quadro, Territórios, Organizações, Líderanças, etc.) + rodapé Perfil/Sair. Leader e desktop mantêm sidebar-only (lockdown intacto). CampaignContentScroll e CampaignQuickActionsHost ajustados para safe-area e z-index do FAB sobre a barra. /campanha/atualizacoes é placeholder honesto (âncora C89). Impl barra-navegacao-inferior-mobile-impl.md.
Recently resolved (2026-08-07): C15 (Issue #390) — /campanha/agenda virou a entrada operacional staff com FullCalendar v7 route-local (semana/mês/lista), filtros compartilháveis por município/deputado/tag, criação pelo slot, detalhe por URL e remarcação drag/resize sob RBAC, transação e lock por atividade; /campanha/atividades e giros permanecem como lista/fluxos legados. Sem migration. Impl fullcalendar-agenda-campanha-impl.md.
Recently resolved (2026-08-06): B163 (Issue #391) — /campanha/dobradinhas agora edita Nome, Partido, E-mail e Telefone inline; Contact virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, preserva nomes legados com preflight de limite, mantém a invariável de nome/telefone e o importador da projeção também reencontra dobradinhas pelo nome atual do Contact quando o alias legado diverge; os links de relações/busca apontam para ID. Intenção editar-campos-lista-dobradinhas.md; impl editar-campos-lista-dobradinhas-impl.md.
Recently resolved (2026-08-05): B158 (Issue #364) — /campanha/municipios troca cards↔tabela e revela colunas pela largura do painel de conteúdo com named container queries (48–84rem), sem rolagem horizontal; Território virou sublinha de Município, Tendência/Sinal preservam um único editor na tabela quando ocultos manualmente, e campaign_columns=municipios:__none__ diferencia “todas visíveis” dos defaults. E2E mede cada limiar em Chromium e mantém um caso iPad-sized no projeto WebKit focado. Sem migration ou mudança de RBAC. Intenção colunas-responsivas-municipios.md; impl colunas-responsivas-municipios-impl.md.
Recently resolved (2026-08-05): B159 (Issue #374) — as colunas Assessores, Lideranças e Dobradinhas de /campanha/municipios agora compartilham MunicipalityRelationEditor (CampaignCellEditOverlay + Command + chips + optimistic reconcile) e MunicipalityRelationAvatarStack; Dobradinhas deixa RelationChipCell só nesta superfície, preserva busca por partido e links para a ficha, e Lideranças cria inline somente com nome (Contact.phone = null, sem stub; telefone real depois na ficha). Sem migration, rota nova ou mudança de access. Intenção generalizar-colunas-relacao-municipios.md; impl generalizar-colunas-relacao-municipios-impl.md.
Recently resolved (2026-08-04): B155 (Issue #359) — coluna de lideranças na lista de municípios (espelho de Assessores): coluna "Lideranças" após "Assessores" (staff; leader fora), chips com nome do contato, popover/sheet com Command + delta otimista (seq/pending/rollback) e criação inline (mini-form Nome + Celular). Sem migration: leitura por query reversa de leadership.municipalities (batch no bundle, loadMunicipalityLeadershipSummaries) e escrita reusando B34 (setLeadershipMunicipalitiesMembershipRecord) + wizard create (createLeadershipWizardRecord) — fonte única de verdade, consistência automática com /campanha/liderancas. Rota nova POST /campanha/municipios/leaderships (campaignJsonMutationRoute, union toggle|create, safe-messages allowlist); provider bridge espelho do B154. Regra travada com o humano: célula → rota, form → server action (débito de migração das 3 anomalias — sinal da lista e colunas B31/B34 de /campanha/liderancas). Intenção liderancas-coluna-municipios.md; impl liderancas-coluna-municipios-impl.md.
Recently resolved (2026-08-03): B154 (Issue #352) — criar assessor inline no popover da lista de municípios: opção "+ Criar assessor ''" quando a busca não casa ninguém; cria campaignUser advisor (e-mail stub <slug>@criado.invalid, senha aleatória) e atribui ao município na MESMA transação (cap 10 com rollback, lock municipality-advisors:{id}); provider client compartilha o assessor criado entre linhas; chip otimista com id temporário negativo; advisorNameSchema unificado em primitives.ts; predicado de placeholder estende a @criado.invalid. Bônus pré-existentes destravando o e2e do spec municipios: key warning do fragment trailing de MunicipalityFilters (array com keys) e settle-wait da animação do Drawer no teste B136. Intenção criar-assessor-inline-popover-municipios.md; impl criar-assessor-inline-popover-municipios-impl.md.
Recently resolved (2026-08-03): B86 (Issue #22) — ações rápidas de apoiadores (staff), último catálogo do lote B80–B90: módulo supporterQuickActions.ts (espelha B87) com register-supporter → /apoiadores/novo (todo staff) e import-supporters → /apoiadores/importar (coordinator-only, espelha isCampaignCoordinator); superfície lista+detalhe numérico; novo/importar só-busca; leader fora. Sem migration/Consent. Impl acoes-rapidas-apoiadores-impl.md; débito S1 (normalização de pathname de lista, 6+ call sites) → #353 escala-dry-pos-acoes-rapidas.md.
Recently resolved (2026-08-03): OPS18 (Issue #296) — promote dual plan-issue: Action plan-issue-ready-on-main-merge.yml no merge lê Related #N e promove Issues ainda aguardando plano (canPromotePlanIssue); idempotente com pnpm agent:ready. Planos: plan-issue-promote-dual.md, impl plan-issue-promote-dual-impl.md.
Recently resolved (2026-08-02): OPS17 (Issue #292) — ciclo plan-issue: gate pós-overview antes de Issue/PR; agent:register --plan nasce blocked; após plano em main, pnpm agent:ready promove a ready. Planos: plan-issue-lifecycle-ready.md, impl plan-issue-lifecycle-ready-impl.md. Action dual no merge = OPS18 (#296).
Recently resolved (2026-08-02): B147 (Issue #330) — rota paralela /campanha/municipio/<slug>/v2 com faixa de status (nível · tendência · sinal · classe · frescor) e modal de motivo opcional; soft-dep B134 no write de nível (motivo opcional, sem “voltar atrás”). Intenção municipio-v2-shell-status.md; impl municipio-v2-shell-status-impl.md. Filhos B148–B152.
Recently resolved (2026-08-02): B127 (Issue #264) — barra omnibox de filtros no piloto Municípios + chassis CampaignListOmnibox (chips + sugestões; URL como fonte de verdade; cenário/sort como chips de apresentação). Planos: barra-filtros-omnibox-listas.md, impl barra-filtros-omnibox-listas-impl.md. Adoção nas demais listas = B128.
Recently resolved (2026-08-02): Skills plan/work reescritas — plan-issue agora só gera planos de intenção (persona/fluxo/aceite + direção suave no código; sem decisões duras de engenharia). Execução: agent-work-issue (pool — Plan mode → *-impl.md → executa → /simplify → capture-review-debts autônomo → PR Ready + auto-merge) e work-issue (humano — claim + pausa após o impl plan). Prompt do pool aponta para agent-work-issue.
Recently resolved (2026-08-02): WhatsApp interno D3–D5 descartado — Issues #30–#32 wontfix (bridge Baileys/Evolution/WAHA / linked device): risco de ban do número pessoal do staff. Canal no produto = só wa.me. Planos whatsapp-interno-campanha.md, whatsapp-canal-fundacao.md, whatsapp-envio-liderancas.md, whatsapp-sugestao-atualizacoes.md removidos; refs em notifications.md / convite·email listas / fluxos-ação / regra nucleos-eleitorais atualizadas.
Recently resolved (2026-08-02): Agent pool hard max → 12 — POOL_HARD_MAX_SLOTS de 8 → 12 (scripts/lib/agent-pool-state.mjs); default continua 5. Skill agent-pool alinhada.
Recently resolved (2026-08-01): B102 (Issue #129) — crash na busca do bottom drawer (focus/digitação): TooltipProvider do layout (app) envolvia só {children} e o drawer (irmão do scrollport) renderizava hits priority:alta via CampaignHoverTooltip sem provider. Fix: provider envolve CampaignAppScrollChrome; pins unit focus→suggest e type→search. Plano: crash-busca-bottom-drawer.md.
Recently resolved (2026-08-01): C8 F1–F2 (Issue #37) — freshness audit confirmou entrega em código desde 2026-07-19: locks advisory em batch (acquireTextAdvisoryLocks), bulk import com .returning() + leituras drizzle, overview derivando total de totalDocs, migration pg_trgm, guard contactSearchQuery (mín. 2 chars). Plano escala-dry-pos-c6.md fechado; follow-ups de filtros/forms permanecem no C9.
Recently resolved (2026-08-01): Cutover residue cleanup — removed dead pnpm db:refresh:stage / scripts/refresh-stage.mjs and pnpm agent:promote / scripts/agent-promote.mjs; closed docs/CUTOVER-MAIN-ONLY.md; scrubbed PR template STAGE_DATABASE_URL checklist item. Branch + Environment stage already deleted.
Recently resolved (2026-08-01): PRs de agente = Ready + auto-merge (audit incluso) — removida a exceção do engineering-audit (artefatos também armam gh pr merge --auto --merge). Safety net GHA agent-pr-ready-automerge.yml: PRs cursor/* → main Draft→Ready + auto-merge. Rule always-on agent-pr-workflow.mdc.
Recently resolved (2026-07-31): CI build skip para diffs fora da árvore buildável — scripts/build-affected.mjs + classifyBuildScope (núcleo em test-affected-core.mjs) decidem run|none; job build-scope barato no ci-pr.yml e o job build (Postgres + pnpm build) só roda em mode=run. Docs/tests/tooling/workflows pulam o build; src/, public/ e configs do Next (next.config/tsconfig/tailwind/postcss/components.json/lockfile) forçam. gate:ci espelha. Push em main (ci.yml) continua sempre full.
Known Gaps (as of 2026-07-24 — resolve before relying on this file for onboarding new devs)
Backlog consolidado (bloqueadores, site, campanha, white-label): docs/roadmap.md.
- RESOLVED 2026-07-27 (
users.roles): camporoles(admin|editor) emusers— migration20260727_032523_add_users_rolescom backfilladmin.isPayloadAdminexige papeladmin; editor entra em/admine escrevepost/tag/media; PII/petition/campanha seguem admin-only. Unificação comcampaignUserrejeitada (ver roles-admin-payload.md). Matriz fina por collection e editor empetitionficam fora. - RESOLVED 2026-07-25 (Pass 2 D3): the last hardcoded consent document id is gone —
submitWhatsapp.tsnow resolvesConsent.key = 'whatsapp-inscricao'fail-closed (migration20260725_170000_whatsapp_subscription_consent_keytags the live document; keys live insrc/lib/campaignConsentKeys.ts). Post/Tagship the news system, but there is still noPagescollection for institutional content. Partly resolved: thepost/tagcollections back the news/publications system, and the home "Últimas notícias" list plus the/[type],/[type]/[category], and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (HomePageglobal still exposes only a singleimagefield — the text lives insrc/app/(frontend)/(home)/page.tsx), and there is noPagescollection yet for institutional content such as the bio and propostas.- Campaign LGPD consent texts are not yet configured in production. Invites and self-service leadership flows fail closed until an admin creates
Consent.key = 'lideranca-autopreenchimento'with counsel-approved copy. The C2 supporter registry (merged tomain, production-blocked) additionally requiresConsent.key = 'apoiador-cadastro'andConsent.key = 'apoiador-intencao-voto'(vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (see deploy checklist).
Recently resolved (2026-07-31): A6 Insight de dobradinhas 2026 (Issue #27) — card "Dobradinhas potenciais para 2026" na aba Eleições do detalhe do município: candidatos proporcionais com runningAgain2026 = sim ranqueados por score 0,6 alinhamento + 0,4 força local (tiers compostos de campoParties 2026 + electionPartySpectrum — sem terceiro mapa; votos 2022 normalizados pelo máximo da geografia). Leitura derivada, sem migration/Consent; loader municipalityTicketPartnerData cacheado sob election-tse com a política dos irmãos; estado pending2026 ("indisponível até as candidaturas de 2026") até a Fase 5 reconciliar pós-15/08 — o runbook da conciliação termina com POST /api/revalidate?tag=election-tse. Solla excluído (referência); link para /campanha/dobradinhas como caminho de registro (não cria registro paralelo). Int specs eleitorais agora se serializam pelo lease election-collections (testDatabaseLease.ts). Overview da lista ficou fora de escopo (strip de métricas + varredura estadual por request — follow-up possível pós-Fase 5). Plano: docs/plans/insight-dobradinha-2026.md.
Recently resolved (2026-07-18): the /campanha nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration 20260718_010733_consolidate_campaign_schema. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration 20260718_190559_territorio_multi_municipio_bairro), TSE 2022 election baseline collections + pnpm db:seed:tse (migration 20260718_195854_add_election_results), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable /campanha PWA. Still on 2026-07-18, A2 shipped: static Bahia municipality↔TSE zone map (src/lib/bahiaTseZones.ts from TSE 2024 detalhe_votacao_munzona BA) plus opt-in territory/zone suggestion chips in the nucleus form (territorySuggestions, NucleusTerritoryAndZonesFields) — no migration, no forced equality on save. Also the same day: C2 Cadastro nominal de apoiadores (supporter join Contact↔campanha with optional nucleus, migration 20260718_222656_add_supporter with UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id), consent keys apoiador-cadastro / apoiador-intencao-voto resolved via a generalized campaignConsent.ts that fails closed, /campanha/apoiadores vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, lideranca has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (actionPlan + /campanha/planos, migration 20260718_222832_add_action_plan, blocos "Próximos eventos") — renamed to activity / /campanha/atividades by C13 on 2026-07-25; the old names survive only in frozen migration history. Also the same day: B2 map foundation — static TopoJSON for Bahia municipalities + identity territories, bahiaMunicipalityCodes, helpers in bahiaGeometries, re-runnable pnpm build:geometries (no migration, no UI; Leaflet is B3). Post-/simplify scale follow-ups for that delivery are registered as B5 (docs/plans/escala-dry-pos-b2.md). Earlier (2026-07-15): local Postgres via docker-compose.yml (Postgres 17, teqo_test); Payload migrations baselined; dev/test database guards; post/tag news routes deployed (see "Posts & Tags" below).
Recently resolved (2026-07-18 evening / 2026-07-19): A4 Baseline no produto + Gap vs 2022 shipped and merged to main — getNucleusElectoralBaseline / loadNucleusBaseline2022Overview aggregate TSE 2022 by nucleus geography; computeGapVs2022 + NucleusInsights on the overview tab; NucleusElectoralBaseline card (candidate / president / governor via BASELINE_TICKET_2022); "Baseline 2022" card on the filtered nuclei-list overview. No migration (reads existing A3 collections). A /simplify pass applied targeted cleanup (role-generic ticket config, per-city zoneNumber in […], shared geography helpers). Scale/DRY debits larger than cleanup (aggregate federal ranking on detail instead of loading all nominal rows, filter by TSE cityCode, Alert confirmed + Progress reuse) are registered as A7 (docs/plans/escala-dry-pos-a4.md).
Recently resolved (2026-07-19): C6 Escala e DRY pós-C2 shipped and merged to main — bulk supporter import via payload.db.drizzle inside the existing Payload transaction (supporterImportBulk.ts, ON CONFLICT DO NOTHING on (contact_id, nucleus_id), 500-row chunks), skipContactPhoneInvariant opt-in on Contact.enforceUniqueContactPhone (fail-closed: only honored inside an active req.transactionID), single-SQL KPI aggregate (supporterListOverviewAggregate.ts, COUNT(*) FILTER mirroring buildSupporterListWhere + access constraint), HMAC-SHA256 single-use import token + ephemeral supporterImportBatch staging collection (migration 20260719_011015_add_supporter_import_batch, TTL 10 min, actor-bound, consumed on confirm), and shared shells (campaignListUrl.ts, CampaignListPagination, campaignFormFields.ts, mapCampaignFormActionError.ts) replacing the three per-list pagination components. A /simplify pass applied targeted cleanup (identity maps, dead branches, relationshipId/canManageCampaignUsers reuse, native base64url, rowCount ?? 0 bug fix). Scale/DRY debits the simplify reviewers flagged as larger than cleanup (locks in 1 round-trip, .returning(), pg_trgm, shared drizzleBulk.ts, column-name assertion, migrating the remaining formActions) are registered as C8 (docs/plans/escala-dry-pos-c6.md). Production use with real supporter data still waits on the Onda 0 legal batch (Consent keys apoiador-cadastro / apoiador-intencao-voto).
Recently resolved (2026-07-21): B6 BahiaMap setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; pathByKeyRef + restyle O(2) no hover/select; fitBounds só em mode/highlightKeys; helpers em src/lib/bahiaMapStyle.ts. Fecha débitos de perf de B10/B11. Plano: docs/plans/escala-dry-pos-b3.md.
Recently resolved (2026-07-21): B11 Escala % dos válidos no Mapa das Praças — validVotesByYear in MunicipalityMapBundle, scaleMode selector (absolute | percentValid, default %), computeValidVoteShares, readout em % com votos absolutos secundários; /simplify fix scaleMax (legenda 0–100% alinhada ao fill). Plano: docs/plans/escala-percentual-mapa-pracas.md.
Recently resolved (2026-07-21): B12 Aproximar mapa ao footprint filtrado + correção hover — fitToKeys / interactiveKeys em BahiaMap (municipalitiesByIbgeCode); canonicalMapKeysKey; hover stroke-only; clear síncrono no mouseout; fit só quando footprint muda. Plano: docs/plans/aproximar-mapa-pracas.md.
Recently resolved (2026-07-21): Discovery "Inteligência de campanha" — ciclo literatura→persona→entrevista sintética aprovado. Embasamento canônico de produto/domínio em docs/research/ (compêndio de ~67 fontes sobre campanha de DF na Bahia; relatório com kernel, playbook de 25 padrões dado→decisão, anti-goals): agentes devem consultá-lo antes de propor métricas/análises territoriais novas. Programa registrado no roadmap: E8–E16 + B13 + C12 (+ adjacentes A11/E17; sessão real com o CG em 2026-07-23 validou/calibrou o programa — docs/CUSTOMER.md; plano-mestre docs/plans/inteligencia-campanha.md) — conta da cadeira (metas derivadas sobre válidos projetados), fila de alocação, registro-fundação (versions em votePledge, sinais tipados, allocationDecision), classificação relativa (substitui limiares 35/20/10 p/ DF), escala relativa no mapa (quantis/LQ/rank + símbolo proporcional), níveis N0–N4 staff-only, motor de sugestões com humano no loop, camada TI (salvaguardas MAUP), planejador de giros, backtest pós-eleição. Anti-metas travadas: % estadual absoluto como KPI, contagem bruta de cadastros, cartograma, "caça ao alienado", sugestão automática sem decisão humana.
Recently resolved (2026-07-23): Export CSV de assinaturas e contatos no admin Payload — @payloadcms/plugin-import-export@3.82.0 habilitado em signature e contact (import: false, export CSV síncrono); flatten de contact/petition via toCSV em src/utilities/signatureExport.ts (só assinaturas); consent excluído do export de assinaturas; collections exports/imports com access isPayloadAdmin; migration 20260723_025513_add_import_export_plugin. Plano: docs/plans/exportar-csv-assinaturas.md.
Recently resolved (2026-07-24): A11 Posição em votos do município — rank denso sobre as 435 unidades do catálogo + % da própria votação estadual (artefato bahiaElectionAggregates); helper src/lib/municipalityVoteRank.ts; UI no MunicipalityBaselineCard (por ano) e coluna/ordenação ?sort=votos na lista (sort/dir mínimo name|votos; B15 amplia as demais keys). Plano: docs/plans/ranking-votos-municipio.md.
Recently resolved (2026-07-24): E4R Import único da planilha de projeção — pnpm db:seed:projecao (scripts/import-projecao.mjs) lê o xlsx canônico em docs/sheets/, faz match via canonicalizeMunicipalityName + municipalityCatalog, e sempre sobrescreve municipality.expectedVotes + priority (re-seed quando a mesa manda planilha nova); Salvador pulado; --dry-run + guard ALLOW_REMOTE_DB; parser puro em src/lib/projectionSheetParse.ts (Bom→optimistic, Regular→central, Mínimo→pessimistic). Fase 2 (mesmo dia, decisão do produto — não é mais zero PII): o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→politicalTrend (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→stateDeputy+stateDeputies (união; célula crua em dobradinhaNotes), LIDERANÇAS→contact name-only sem telefone + leadership (dedup estadual por slug do nome; Contact.phone virou opcional via migration 20260724_175500_contact_phone_optional, UI continua exigindo), ASSESSOR RESPONSÁVEL→campaignUser+advisors (união; Edizio=coordinator, Solla=candidate — candidate agora é elegível como assessor em eligibleCampaignStaffWhere; novos usuários com email <slug>@planilha.invalid + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→nextSteps, OBSERVAÇÃO→strengths/risks (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado municipality.voteGoals ("Meta Bom/Regular/Mínimo") foi removido — collection, zod, view models, form/card e utilitário voteGoals.ts — via migration 20260724_133600_drop_municipality_vote_goals (backfill metas→estimativas onde a estimativa estava vazia): expectedVotes (pessimista/média/otimista) é a única série por cenário do município. Plano: docs/plans/import-planilha-projecao.md.
Recently resolved (2026-07-25): B21 Página dos Territórios de Identidade — nova rota staff-only /campanha/territorios com os 27 TIs no sistema de listas: sort/filtros/busca canônicos na URL, Metropolitano decomposto em sub-linhas, primeira coluna sticky no mobile e linha → /campanha/municipios?region=<TI>. O chrome rico agora é compartilhado por CampaignSortableHead/CampaignHeaderFilterPopover; wrappers de municípios mantêm a política de domínio. O painel de TI e territorySlot foram removidos do Início. Sem migration/collection/action. Plano: docs/plans/pagina-territorios-identidade.md.
Recently resolved (2026-07-25): Engineering Consolidation Pass 2 (tracker: docs/IMPROVE-CODE-QUALITY-PLAN.md § Pass 2; architecture: docs/ARCHITECTURE.md; agent map: .agents/rules/codebase-map.mdc). Highlights: W1 sistema de listas — colunas como dado em CampaignTable (seams B17/B18), URL state compartilhado (campaignListUrl + módulos por domínio; o god module municipalityUi.ts virou municipalityListUrl/municipalityListFilters/municipalityLabels/municipalitySignal), shells (CampaignListFooter, CampaignListEmptyState), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (useOptimistic, hrefs por serializador canônico, facet por slugs, facets no mesmo Promise.all); W2 — fronteiras: slug/phone/wordStartFilter/voteEstimate movidos para lib/, 21 loaders server-only, campaignRoles.ts client-safe (sidebar sem o barrel de access), votePledgeViews (contract) vs votePledgeData (loaders), ciclo supporter quebrado, components/campaign em 15 subpastas de domínio; decisão D1: sem src/domains/, sem ports-and-adapters; W4a — electionInsights.ts (941 linhas, 2 exports vivos) deletado; sobreviventes em electionFormat.ts/voteTrend.ts; E10 nasce limpo; W4b — knip exports/types/enumMembers em ERROR no CI (0 findings; kit ui sem peças mortas); W4c — tabs das páginas de detalhe extraídas (MunicipalityDetailTabs, ActivityOverviewTab) + factory detailTabUi; W4d — runCampaignFormAction fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); W4e — tipos de fonte única; W4f — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); D3 — consent do WhatsApp por chave estável (whatsapp-inscricao, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
Recently resolved (2026-07-30): Unificação das skills do fluxo (OPS1/OPS2) — as 7 skills do ciclo roadmap foram fundidas em três: plan-issue (fundiu roadmap-item/roadmap-items + profundidade de implement-roadmap-item; GATE de confirmação com overview + wireframes ASCII antes de criar qualquer Issue; sugestão de modelo vira propriedade model: no frontmatter), work-issue (claim → verificação assimétrica de modelo → freshness audit → execução → /simplify → PR --base stage com CI acompanhado até o merge; promote continua humano) e project-status (pnpm agent:status — overview/fila com model:/mermaid/bloqueios/sugestões de consolidação; read-only, nunca toca in-progress). Suporte: agent:register --model; brief do agent:claim imprime model:; workflow issue-done-on-stage-merge.yml faz o flip in-progress→done deterministicamente no merge em stage. Conversão: as 38 Issues do seed ganharam model: (tabela ID→modelo aprovada pelo usuário); 6 itens abertos do roadmap legacy viraram Issues (C11 #44, PUB1–PUB4 #45–#48, ADM1 #49); guarda-chuvas ("UX-1", "Higiene PascalCase", smoke/onboarding, white-label, ledger TECH-DEBT) citados, não registrados; docs/roadmap.md virou stub legado congelado. Deletadas: roadmap-item, roadmap-items, implement-roadmap-item, suggest-next-roadmap-items, ship-to-main, close-delivery, compile-roadmap; referências vivas varridas (model-selection, rebase-on-main, notebooks de projeto, IMPROVE-CODE-QUALITY-PLAN). capture-review-debts registra via agent:register/agent:file-miss e nunca edita Issue in-progress (débito do mesmo pai = Issue nova com depends: [pai]); engineering-audit declarado solitário (pausa o paralelismo; executa remediações P0/P1 na sessão) com harvest kind:agent-miss → docs/GUARDRAILS.md. Docs: docs/AGENT-OPS.md, README.md.
Recently resolved (2026-07-30): B77 Simplificar wizard ajuste de votos — WizardExpectedVotesStep reescrito: três cenários numa tela (VoteEstimateScenarioInputs compact + strip + atalhos no foco); validação pessimistic ≤ central ≤ optimistic só no confirm com Alert + highlight do par conflitante (getWizardVoteViolationHighlights); POST batch inalterado (MUNICIPALITY_EXPECTED_VOTES_ENDPOINT); ?cenario= legado redireciona para URL canônica sem param (hasWizardScenarioParam + wizardActionHref sem 3º arg); órfãos do ritual B61 removidos de wizardVoteEstimate/campaignWizardCopy. VoteEstimateScenarioInputs compact ganhou activeScenario/onFocusScenario/errorScenarios/disabled (lista inalterada). Sem migration. Plano: docs/plans/simplificar-ajuste-votos-wizard.md.
Recently resolved (2026-07-30): B70 Wizard atualizar liderança — /campanha/acoes/atualizar-lideranca substitui o stub B60 por WizardLeadershipStep (grid 2/3/4 + tile Adicionar + info Drawer + Tooltip de notes no hover fine) e WizardLeadershipForm (6 campos incl. exclusive B69); loadWizardLeadershipTiles no RSC; writes updateLeadershipWizard/createLeadershipWizard em transação Contact+Leadership (não updateLeadershipInternal); WIZARD_ENTRY_ACTION_QUERY_KEY + skip via trailingAction quando entryAction !== 'update-leadership'; Continuar após Salvar dirty → Início + toast; truncateNameAtWordBoundary em lib/. Sem migration. Plano: docs/plans/wizard-atualizar-lideranca.md. Desbloqueia wiring A1 embutido.
Recently resolved (2026-07-30): B71 Polimento visual dos resultados de busca no Início — src/lib/homeSearchUi.ts centraliza bleed (-mx-4 px-4 md:-mx-6 md:px-6), heading discreto (text-xs sentence-case) e ul list-none; HomeSearchHitRow pinta hover/focus na faixa full-bleed; todos os HomeSearch*Group alinhados. Sem migration/action/contrato JSON. Plano: docs/plans/polimento-resultados-busca-inicio.md.
Recently resolved (2026-07-30): B69 Simplificar modelo de liderança — leadership.exclusive boolean NOT NULL DEFAULT true ("Apoio exclusivo") via migration 20260730_043306_simplify_leadership_fields + DROP sector/sectorNotes/consentNote (collection, zod, forms, filtros/sort B29 da lista, invite autofill/redemption, dossiê; ?sector= legado canonicaliza fora). Decisão de produto 2026-07-30 reverteu o rascunho: organization FICA — mapeia ramificações políticas (SindMed com várias lideranças); vertical /campanha/organizacoes e todos os vínculos intactos. Lista ganha coluna "Apoio exclusivo" (badge outline "Não exclusivo" só quando false); forms com checkbox default-on + hidden "false" na criação (último valor vence; ausência cai no default do zod). Nota operacional: o prompt interativo de rename do migrate:create trava sem TTY — gerar drops e adds em passadas separadas (CI=1) e fundir à mão foi o caminho; o NOT NULL foi emendado sobre o SQL gerado. Gate: tsc/lint/format/cycles/build; int 518/518; unit com as mesmas 16 falhas pré-existentes do clean tree (drift B66/B71 na base); knip idêntico à base (P3 + drift); Aikido 0 findings. Plano: docs/plans/simplificar-modelo-lideranca.md. Desbloqueia B70.
Recently resolved (2026-07-29): B62 Simplificar modelo de sinal — municipalityUpdate kind sinal is now signalType + body only: migration 20260730_010601_simplify_municipality_signal_fields drops signal_source and triangulated (the generator re-emitted ADD VALUE 'adiada' from snapshot drift — removed by hand; this migration's committed .json heals the chain), and the five signal-type labels/descriptions were rewritten for the field team (Rede esfriou, Adversário apareceu, Alguém pediu algo — no "broker" in the signal model; activity.origin's Pedido de broker and suggestionCatalog copy were explicitly left out by session decision). MunicipalitySignalFields (B26 list control) and the inline block in MunicipalityUpdateForm lost Fonte/Triangulado; municipalityUpdatePageData + MunicipalityUpdateFeed (detail + dossier) render only the type badge. E11 changed semantics: any adversary signal in the window reads as confirmed — P1 triage 1 (session decision, replacing the triangulated/single-source split), MunicipalityTriggerInput.adversarySignal: { present: true } | null, single factor copy, E18 glossary aligned. Bonus gate fixes: the campaignMunicipalities "advisor scopes…" e2e was stale since B43 (leader home → /campanha/contatos) and was updated, and the municipalityElectoralBaseline bypass pin dropped 9→8 (pre-existing main drift). Gate: 876 unit / 489 int / targeted e2e green / build; Aikido 0 findings. Plano: docs/plans/simplificar-modelo-sinal.md.
Recently resolved (2026-07-29): B59 Chassis do wizard de campanha — rotas staff em /campanha/acoes/<slug> (campaignActionRoutes.ts, campaignActionEntryHref), layout acoes/layout.tsx (gate staff, coluna max-w-lg), CampaignWizardShell (voltar mobile, caption de município sticky, trailingAction, foco no h1 ao trocar passo), cinco páginas-ponte até B60 e strip do Início com href nos fluxos wizard. Sem migration. Plano: docs/plans/chassis-wizard-campanha.md.
Recently resolved (2026-07-29): B49 Busca global — resultados Lideranças no Início staff: o mesmo POST /campanha/home-search agora inclui searchHomeLeaderships em paralelo com municípios e assessores; word-start no contact.name com pré-filtro buildLeadershipListWhere (contains) refinado em memória, cap 25, municipalitiesSummary via truncatedNamesLabel, desempate por updatedAt; assessor vê só a carteira (overrideAccess: false). Client: HomeSearchLeadershipGroup entre Municípios e Assessores, HomeSearchHitRow compartilhado, empty state via homeSearchHasAnyHits; linha → /campanha/liderancas/[id]. Sem migration. Plano: docs/plans/busca-global-resultados-liderancas.md.
Recently resolved (2026-07-29): B48 Busca global — resultados Municípios (+ TIs) no Início staff: POST /campanha/home-search (campaignJsonMutationRoute + homeSearchBodySchema) chama searchHomeMunicipalities — municípios via loadMunicipalityScope + matchesAtWordStart + readout A11; territórios no mesmo grupo via loadTerritoryOverview (contexto regional E17, não carteira do assessor). Client: HomeSearchResultsContext (fetch único, extensível B49+), HomeSearchMunicipalityGroup, empty “Nenhum resultado.”; MunicipalityVotePositionReadout extraído de MunicipalityList. Sem migration. Plano: docs/plans/busca-global-resultados-municipios.md.
Recently resolved (2026-07-28): E11 Motor de sugestões v1 — the 8 highest-confidence data→decision patterns of the research report §6.1 (P1 reduto dormente/ameaçado, P2 ataque, P3 correligionário na frente, P5 expansão, P6 grande sem rede, P7 pledges estagnados, K-A não responde, K-B perdida vestida) now run as a human-in-the-loop decision queue: a "Sugestões" panel on the staff dashboard (top-5 by triage + "pauta do silêncio") and a card on the municipality Overview, with aceitar/adiar/descartar writing allocationDecision. Three layers: src/lib/suggestionCatalog.ts (client-safe — pure predicates over MunicipalityTriggerInput, a numbers-only input type, plus the exact card copy: probable reading, the "descartes" that become the dismiss form's quick picks, cheap-before-expensive menu, contraindication, statute; anchors are illustrative and private, calibration = E15); src/utilities/municipalityTriggers.ts (server-only, on the E13 planner's mold — own municipality read because the shared scope select lacks engagementLevel/lastUpdateAt, six reads in one Promise.all, and per-year LQ using E10's exact formula rather than a third LQ writing); resolveSuggestionRecord which RE-EVALUATES the pattern server-side before writing — the snapshot records decision-time factors, never render-time ones, and a pattern that left the queue refuses with the safe SUGGESTION_STALE_MESSAGE; advisory lock municipality-suggestion:{id}:{patternId} is taken before re-reading the latest decision. The audit killed the plan's "sem migration": the real outcome enum was aceita|descarta|movimento (the plan's descartada|adiada values never existed), so "Adiar" cost migration 20260728_041547_add_allocation_decision_adiada_outcome (E14's movimento precedent), with postponeDays derived from the recomputed triage (7d; 14d on levels 4–5) and suppressUntil in the snapshot instead of a column. Suppression is pure and unit-pinned (isSuggestionSuppressedByDecision): aceita 14d, descarta 30d, adiada until snooze, malformed snapshot falls back to the shortest postpone — and "nível 1 fura a fila": a trigger returning as confirmed risk pierces a suppression recorded at lower urgency. Structural differential is code (§6.3): P2 yields to P3/K-B, P7 yields to K-A when effort was recorded. P3 fires from the artifact alone (runningAgain2026 is 100% desconhecido with no read path — "concorre de novo?" is the menu's first item, not a trigger). Silence review = alta/N2+ with no FIRED pattern (a suppressed one was answered, not silence) and no signal ≥30d; includes priority: alta because E14 shipped without backfill. UI: explicit submit on all three outcomes (the campanha-edit-where-you-see exception for note/confirmation flows), native radios (accent-primary — first radio precedent in the repo), managed focus across the trigger↔form swap, level 1 is the only red. Two facts worth keeping: the public-site base ul { my-6 ml-6 list-disc } leaks into campaign surfaces (the factors list needed B20's reset), and artifact-anchored patterns cannot be staged from DB rows — int tests that need an artifact property claim municípios through the fixture allocator until one qualifies (measured: 109/435 top-quartile, 205/435 quiet, and reduto+coreBlock is only 13/435, which moved P1's trigger coverage to the 45-case unit spec; explicit slugs would race the allocator's wrapping sequence). E18 concepts triagem-de-sugestoes + pauta-do-silencio; /campanha First Load JS 268 → 277 kB. Adjacent follow-ups live in the plan: wiring E13's "não vá" override to resolveSuggestion; catalog fase 2 (T/I/J, P4/P8–P12, K-C) after 2 weeks of real use + 20 decisions; a dedicated route if >15 recurring active suggestions. Plano: docs/plans/motor-de-sugestoes.md.
Recently resolved (2026-07-28): B32+ Escala e DRY pós-B32 — and one line of it is a convention every agent needs: every JSON POST route under /campanha is now created by campaignJsonMutationRoute(config, handler) (src/utilities/campaignJsonMutationRoute.ts), which exports the POST already same-origin-checked, body-parsed and error-mapped, and a convention guard in tests/unit/codebaseConventions.unit.spec.ts fails the build on a route.ts that exports POST without it — the guard sweeps all of src/app, not just (campaign), because a cookie-authenticated endpoint written under api/ would be exactly as exposed and exactly as invisible; the three exceptions (Payload's two re-exports, the secret-header revalidate) are declared with their reason. That wrapper replaced the helper the plan asked for, because the helper would still have been a line someone could forget: isSameOriginRequest was copied into five routes, so the CSRF policy it claims to own was failing open by omission. Two details not to undo: bodySchema.parse runs inside the same try as the handler (that is what keeps a malformed body answering with the route's existing genericMessage instead of a new one), and engagement-level keeps its 409 inside its handler so the wrapper never grows a "blocked" concept. parseCampaignJsonRequestBody and campaignJsonMutationErrorResponse are no longer exported. Client side: postCampaignJson (src/lib/campaignJsonRequest.ts) is the only place that spells credentials: 'same-origin' + JSON headers, and useCampaignCellAutosave (src/components/campaign/shared/) is the auto-save machine of the quick-edit cells (B24 tendência, B27 votos estimados, B32 status de apoio). Three audit findings worth keeping: display and draft never diverged — they were set together on every path, one value under two names, so the hook has one value and only the trend's raw noteDraft (raw text vs. normalized note) stays in the caller; reportFailure had not been born in all four controls as the plan claimed (MunicipalityListAdvisorsControl never imported sonner, so a failure after closing was mute); and the trend's inFlightRef was a workaround for having two code paths into one save, so it became an idempotent flush() that protects all three. The hook owns open because closing is the commit, and valueRef advances synchronously — the same "state captured by the render that scheduled the work" bug B33+ and B34 both paid for. Shell: CampaignCellEditOverlay took a statusMessage prop and renders the aria-live region outside the Popover/Drawer, because closing is what triggers the save and it was unmounting the node that would announce it; and variant="sheet" took a footer slot, which lifted "Registrar movimento" out of a scrollable body (E14) and let MunicipalityListSignalControl (B26) drop the Popover/Drawer it only kept for its footer — the planned formId prop turned out unnecessary, since <form id> in the caller plus <button form={id}> in the footer is plain HTML, and campaignCellEditTriggerClassName became module-private for lack of anyone re-spelling the trigger. /simplify found two P1s the gate could not see, both verified red against the pre-fix hook: a revert issued while a different payload was in flight was dropped silently — "nothing to do" compared against the last confirmation, so with B unanswered going back to A looked settled and B's success then repainted the cell as B; "settled" now means equal to what the server will end up with (the in-flight payload when there is one). And change() did not clear errorMessage, which outranks everything in statusMessage, so after a failure every later keystroke was announced as the old error. change also takes an updater now, because a select and a keystroke batched into one tick each built their payload from the render that scheduled them. The two reviewers disagreed on one thing, and the performance side won on counts: the live region is no longer mounted unconditionally (~250 polite regions on a 25-row município page — 5 controls × the card and table trees) but from the first open, which is early enough for "Salvando…" and outlives the close that commits; the consequence is that MunicipalityPortfolioCell keeps its own region, since on a fine pointer its editor IS the cell and the Drawer never opens. Also: engagement-level echoed updated.engagementLevel ?? movement.level right under a comment forbidding exactly that, and a município born without a level would have reported a dropped write as saved — it now fails. /simplify then closed three duplications the gate could not see: the auth throw is now CAMPAIGN_AUTH_REQUIRED_MESSAGE (it was a bare literal in five places, and safeMessages matches on exact string, so a reworded throw would silently collapse a 401 into the generic message); useCampaignCellFailureChannel owns the closing unmounts the Alert, so the failure changes channel policy that had been written out three times; and the live region was still hand-rolled in two cells — LeadershipStateDeputyRelationCell had it inside its Popover, the exact F4 bug, and MunicipalityPortfolioCell adopted statusMessage, whose role="status" the shell took from it. Reusing useCampaignCellAutosave in the set-membership editors was rejected with measurement: they fire one request per delta with no debounce, several in flight, reconciled by sequence number, and the hook aborts the superseded request — which for independent deltas drops a save. New specs: campaignJsonMutationRoute.unit.spec.ts and campaignCellAutosave.unit.spec.ts (jsdom + fake timers) — that machine had only e2e coverage before. Worth knowing for jsdom: form={id} does submit there, but native required validation runs too, so a footer-submit test must fill the required fields or it silently never submits. No migration, no collection, no network-contract change. Plano: docs/plans/escala-dry-pos-b32.md.
Recently resolved (2026-07-27): E13 Planejador de presença/giros — visit eligibility as five conditions ✓/— (volume, headroom, rede, janela, encaixe) plus three advisory contraindications, each carrying a counter-offer and never blocking; the calendar phase (construcao|consolidacao|ativacao) changes the suggested "produto da visita". Pure math in src/utilities/visitEligibility.ts, cut points in src/lib/visitPlannerAnchors.ts (illustrative, on the territorialClassAnchors.ts precedent; the phase turns at midnight in Bahia via the new formatBahiaCivilDate), candidates grouped by identity territory in src/utilities/visitPlannerData.ts (server-only). Surfaces: an "Elegibilidade para visita" card on the municipality OverviewTab, and a composer at /campanha/atividades/giros (staff-only, reached from a "Planejar giro" button — no new sidebar entry) that turns TI → âncora/satélites/semente into draft activity rows in one transaction. Three plan premises died in the pre-implementation audit and should not come back: kind: 'visita' never shipped with C12 and is not wanted — activity.deputyPresent already IS the candidate-presence marker, so a giro is derivable from deputyPresent + território + date window and each stop keeps its real kind; allocationDecision still has no application write path, so the "não vá" is advisory text and recording an override is E11's first job; and "satélites contíguos" was never possible — bahiaTerritories.ts carries membership only (polygon adjacency exists solely inside the build scripts), so encaixe = same TI, said in those words in the UI. Two implementation facts worth keeping: the uncaptured field ceiling and the catalog medians moved out of E10's classifier into municipalityPotential.ts (memoized), so the card and the "Classe" column cannot compute the same number twice; and createTourDraftActivitiesRecord re-reads every stop's município id with overrideAccess: false inside the transaction and aborts if one is missing, because activity create access is a plain staff boolean and Payload cannot express a per-município constraint on create — without it an advisor could seed drafts statewide by posting ids the composer never offered. Caps: 3 satellites, 8 stops. No migration, no collection, no Consent, no persisted tour entity. Plano: docs/plans/planejador-de-giros.md.
Recently resolved (2026-07-27): B34 Chips editáveis de municípios na lista de lideranças — the "Municípios" column of /campanha/liderancas was municipalityNames.join(', '), dead text with no link; it is now chips that navigate to /campanha/municipios/[slug], with a territory chip (MapPinIcon + count) when the leadership covers a whole TI, and it is editable in place. The decision that generalizes beyond this item: what picks inline vs. drawer is the pointer: media query, not viewport width. On pointer-fine the editor is always mounted — the "×" sits at the chip's corner revealed on hover/focus (absolutely positioned, so revealing it moves nothing) and a search input trails the chip row; on pointer-coarse a tap anywhere in the cell opens a Drawer with 44 px targets, because a thumb scrolling a table must not be asked to distinguish "open the município" from "delete the link". A tablet with a mouse gets the inline editor; a touch laptop gets the Drawer. This amends the anti-goal "always-mounted inputs on every list row" (PRODUCT.md principle 3 + .agents/rules/campanha-edit-where-you-see.mdc) for chip-based relations on fine pointers — the input IS the readout there, and a per-row Popover would add a click to a target already on screen. It does not reopen B28/B31/B32: nothing here is a table mode, and AdvisorsTable's toggle was renamed "Editar nome e contato", which is all it still unlocks. Engineering, all divergences from the plan measured: AdvisorMunicipalityCell was promoted to src/components/campaign/shared/MunicipalityPortfolioCell.tsx (the plan rejected it — but the ResizeObserver + "Ver mais…" machinery it called dead weight became the requirement the moment the cell was always visible), and /campanha/assessores migrated onto the shared piece in the same PR, so the 2nd call site was born with the 1st; no context provider (the catalog index and addableIds travel as props of the column factory the RSC builds once per table); one batch action only — setLeadershipMunicipalitiesMembership (repeated municipalityIds + assigned), the single-id twin never existed, and the advisor twin (setAdvisorMunicipalityMembershipRecord + its schema) was deleted with its int tests migrated to the batch path; LeadershipRowViewModel.municipalityNames → municipalityIDs: number[] (names/slugs resolve client-side against the catalog the cell already holds); the floor of 1 uses aria-disabled + the reason in a toast rather than disabled, because disabled drops the button from the tab order and the reason lived only in its aria-label; the cap of 30 is refused client-side before the optimistic apply, so a TI batch does not bounce back whole with a message that never says which limit blew. /impeccable closed two real P0/P1s: suggestions were mouse-only (now a full ARIA 1.2 combobox — arrows/Home/End/Enter, aria-activedescendant, portaled listbox with its own name, "Nenhum resultado." kept outside the listbox) and auto-save was mute (aria-busy + dimmed row + sr-only live region, rollback scoped to the failed delta so a neighbouring saved toggle is not reverted, plus undo on batch removal); target size of the "×" went 16 → 24 px via before:-inset-1 (SC 2.5.8 has no mouse exemption, and the 44 px Drawer equivalent is pointer-fine:hidden), the "×" border got real contrast (the token's is 1.17:1 on a secondary chip), and the chips are links inside the Drawer again. /simplify caught two real bugs the gate could not see, both in the same shape — state captured by the render that scheduled the work. Every optimistic delta is now applied functionally, because the success path computed from the assignedIds of its own render: the toast's "Desfazer", pressed after a later add, dropped that add from the UI while the server kept it, and the reconcile effect only clears the optimistic value when both id lists match exactly, so the divergence outlived every later revalidation. That fix was half a fix, and the second half is the thing to remember: making the updater functional is not enough if its fallback is not. withDelta(current ?? municipalityIds, …) still read the render that created the closure whenever current was null — which is the normal state by the time an undo is pressed, since the server reconcile clears it — so the identical bug survived under a shape that reads as correct. The baseline is now a ref (latestIds) tracking effectiveIds, advanced synchronously in the draft branch so two toggles batched into one tick cannot drop each other, and the whole sequence is pinned in municipalityPortfolioCell.unit.spec.ts (verified red against the old code). Worth knowing that the two reviewers disagreed here — the performance pass read the functional withDelta and cleared it; only tracing the fallback branch finds it. And the saved/errorMessage pair was sticky, so from the first save onward the live region said "Municípios salvos." forever and never announced the result count again — the one thing an ARIA combobox needs; they collapsed into one feedback, cleared on typing, with an explicit priority (pending > error > searching > saved). Also: the pointer policy had been encoded twice (a matchMedia('(pointer: fine)') in the root onClick next to the pointer-* variants — the coarse overlay now stops propagation and the policy lives only in CSS); MunicipalityPortfolioIndexEntry.city/zoneNumber were read by nothing (TI/ZE resolution runs against the static catalog by slug) and were shipping in 435 entries of two routes' RSC payload; the advisor route's local parseMunicipalityIds (which swallowed invalid ids silently) gave way to the shared repeatedRelationshipFormValues; the advisor-scope refusal became LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE (a bare literal in 4 files, matched by safeMessages on exact string); the membership helper returns { next, added } instead of letting the caller re-derive added for the scope check; and onPersisted died because setAdvisorMunicipalitiesBatch already revalidates. One fix was created by the simplification itself: unifying the suggestion gate would have opened the inline list — which is portaled, so pointer-coarse:hidden cannot reach it — on top of the Drawer, hence separate inlineSuggesting / drawerSuggesting, which also closes the pre-existing case of opening the Drawer with the inline popover already up. Post-rebase /simplify, where a premise of this delivery died: the rebase brought B42's CampaignCellEditOverlay, judged inapplicable because "it switches on viewport and B34 switches on pointer" — false in the code. The container switches on nothing: variant is a prop, there is no matchMedia in it, and LeadershipListSupportStatusControl in this same table already hardcodes "popover" because a caller that knows its own policy just states it. B34's Drawer half now passes variant="sheet" unconditionally with pointer-fine:hidden on triggerClassName, so the pointer policy stays in CSS — and the point is not the ~25 lines (14 of them verbatim DrawerHeader/DrawerFooter) but initialFocus={titleRef}: the hand-rolled Drawer handed focus to the first chip, a link that navigates away from the row being edited, or to the search field on a draft row, which is the virtual-keyboard-over-the-sheet failure B42 had already fixed once. The trigger also gained the aria-haspopup="dialog"/aria-expanded/aria-busy every other cell trigger in both lists has, and the cell became the 5th case of the shared table-driven spec — the Drawer half had no coverage at all, since e2e runs on a fine pointer. Adopting cost one real bug, worth knowing before adopting the shell elsewhere: an always-mounted Drawer reports onOpenChange(false) when a dismissal anywhere on the page sweeps the layer stack, not only when it closes — so the new "clear the search when the sheet closes" handler was erasing what the fine pointer had just typed inline (caught by e2e, which passes only in file order; guard is if (!next && drawerOpen)). Fixed in the same pass, and it is behaviour rather than style: setAdvisorMunicipalitiesBatch revalidated only the last município of a batch (lastSlug) — pre-existing from B19, but B34 made the batch the only advisor write path and put a território chip within thumb reach, so dropping a 30-município TI left 29 detail pages stale; it now returns slugs: string[]. Rejected with reason: the container's Popover branch must not be adopted — it yields role="dialog" behind a PopoverTrigger, and a dialog cannot be the popup of an ARIA 1.2 combobox.
Latent bug fixed for free: CampaignTableHead's description seam (B22) broke any RSC caller — campaignHoverTooltipAlign/campaignHoverExplanationClassName lived in the 'use client' module of CampaignHoverTooltip, so the server was calling a client reference; they moved to src/lib/campaignHoverTooltip.ts, and the leaderships list is the first RSC header with an explanation. Post-merge /simplify follow-up (same day), all measured: the perf pass cut real CPU — buildMunicipalityPortfolioChips 4.2 → 0.78 ms cold per 25-row table and 0.043 ms on every later render (a WeakMap keyed on the index array, which one RSC builds once and all rows share, so it survives re-renders where a per-row useMemo would not), and search 4.59 → 0.077 ms per keystroke (normalize the query once instead of inside the matcher for each of ~660 candidates; matchesNormalizedAtWordStart is the new shared entry point and matchesAtWordStart delegates to it). Also: sameIdSet replaced a hand-rolled sorted-key comparison (3rd call site of a single-purpose helper), scrollIntoView is keyboard-only (the mouse is already where it points — it was a forced layout per option the pointer crossed), and revalidatePath now uses the real delta (changed), so removing a município that was never linked no longer busts its route. One reviewer suggestion was rejected by measurement, and it looked obvious: dropping sort: 'name' from the loader ("nothing depends on the DB ordering") broke the suggestion list — searchMunicipalityPortfolio walks the index in arrival order and stops at limit, so the alphabetical suggestions were alphabetical only because of that ORDER BY. The B34 e2e caught it; the sort is back with the reason written beside it. The finding that did NOT get fixed, because both fixes oppose each other: the delivery costs +58 kB First Load JS on /campanha/liderancas (246 → 304) and +42 kB on /campanha/assessores (253 → 295), measured with two full builds against a stashed clean main. Assessores already imported the static Bahia catalog, so its +42 kB is the cell, not the catalog — only ~16 kB of lideranças' delta is the catalog reaching a new route. Pulling the catalog out of the browser fattens the payload of every chip toggle; shrinking the payload to {id,slug} keeps the bundle. Registered as B34+ with the recommendation (payload-minimal) and its one real caveat. No migration, collection, Consent or URL change. Debt with a trigger: LeadershipStateDeputyRelationCell (B31) keeps its own Popover and measuring machinery in the same table — it, not B37, is the extraction trigger for a relation-agnostic chip editor (B37 plugs the existing shared cell into a third relation without new machinery). Measured, and now P1 in the ledger: two e2e tests fail deterministically at one worker on a clean main tree (verified by stashing this whole delivery) — campaignNearestMunicipality:156 and campaignMunicipalities:394. That is not the fichado 2-worker flake; do not dismiss a red pnpm test:e2e as flakiness without re-checking those two by name. Plano: docs/plans/chips-municipios-lista-liderancas.md.
Recently resolved (2026-07-27): users.roles — Payload admin RBAC. Field roles (admin | editor) on users (migration 20260727_032523_add_users_roles, backfill admin); isPayloadAdmin requires the admin role; editors open /admin and write post/tag/media; PII/petition/campaign collections stay admin-only. Unifying users + campaignUser was rejected (25 FKs; structural isolation). Plano: docs/plans/roles-admin-payload.md.
Recently resolved (2026-07-29): B51 Busca global — grupo Atividades no Início staff: searchHomeActivities (staff-only; word-start no title; access activity com overrideAccess: false; rascunhos incluídos; cap 25; desempate startAt desc); campo activities no POST /campanha/home-search; HomeSearchActivityGroup após Assessores; secundário "{município} · {quando}"; empty via homeSearchHasAnyHits. Int homeSearchActivities.int.spec.ts. WA = B55. Plano: docs/plans/busca-global-resultados-atividades.md.
Recently resolved (2026-07-29): B50 Busca global — grupo Assessores no Início staff: searchHomeAdvisors (isUnrestrictedCampaignRole — assessor recebe []; word-start no nome; municipalityIdsByAdvisorIds exportado de advisorData); campo advisors no POST /campanha/home-search; HomeSearchAdvisorGroup + HomeSearchHitRow compartilhado; empty via homeSearchHasAnyHits; secundário = contagem de municípios (sem e-mail). Int homeSearchAdvisors.int.spec.ts. WA = B55. Plano: docs/plans/busca-global-resultados-assessores.md.
Recently resolved (2026-07-28): B17 Seletor de colunas — a "Colunas" picker shipped as a capability of CampaignTable, so it landed on all seven list surfaces (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios) rather than only on /campanha/municipios: pass columnVisibility (one value, from readCampaignColumnVisibility(listId)) and the table mounts CampaignColumnPicker and filters through resolveVisibleColumns. /campanha/assessores stays out — AdvisorsTable is still a raw ui/Table with table-fixed, and migrating it to the list system is separate work. Two premises of the 2026-07-24 plan died, and the first one died on a type, not a preference: CampaignTableColumn.cell is a function, so the column array never crosses the server/client boundary and the client cannot rebuild the table without it — hiding client-side would mean tagging every cell with data-column and injecting a <style> per list. Persistence is therefore the cookie campaign_columns (path /campanha, SameSite=Lax, one year, client-readable because the client writes it), carrying a listId → hidden ids map in a compact form (municipios:kind~trend|demandas:requester) with no JSON and no percent-encoding — document.cookie and Next's cookies() disagree about decoding, and every character used is legal in a cookie-value under RFC 6265. The server filters, so a hidden column does not reach the RSC payload either; the price is one router.refresh() per editing session inside the shared transition, dimming the results like every other filter on those screens. Closing the menu is the commit, not a debounce: the first cut used 400 ms, which batches a burst rather than a reading pace, so unchecking four columns while reading their labels was four full-route refreshes — and on /campanha/liderancas each one re-pays the ~229 ms / 371-statement render already ledgered P1. The timer survives at 3 s, but a /simplify pass split what it does: it writes the cookie only, without refreshing, because any timer that also refreshed just recreates the per-toggle cost at a slower pace — three seconds is an ordinary gap between clicks when the labels are being read, which is the same failure the 400 ms cut was rejected for. Durability and repaint are separate questions, so they get separate triggers, and the accepted cost is stated because it is real: the table behind the menu no longer updates per checkbox. Two guards came with the split — a session that ends where it started refreshes nothing (sameIdSet against the rendered set), and a menu still open when the route unmounts writes the cookie from the cleanup, since Radix reports no close event for a sidebar navigation and that choice used to vanish silently. The second casualty is the defaultVisible seam, which is gone: it was true on 10 columns and false on none, and storing only the hidden set is what makes a newly shipped column visible to everyone who never touched the picker — the contract E9 and E14 already assumed. In exchange, label: string is now required on CampaignTableColumn (the picker needs a serializable label), filled in across ~55 column definitions — and since 24 of those columns then had a head that only restated the new label, head became optional, defaulting to <CampaignTableHead>{label}</CampaignTableHead>; declare it only for an alignment, a B22 description, or a head island of its own. Each surface declares one mandatory column, listed in the menu disabled with its reason and re-filtered on the server, so a stale cookie cannot leave a row without its only linked cell. The real bug review caught is this codebase's recurring one in new clothing: the RSC payload of toggle A arrives while B is still inside the debounce window, and adopting the server there undid B and then committed the set without it, losing it silently — reconciliation now runs only when no write is pending, pinned in tests/unit/campaignColumnPicker.unit.spec.ts (verified red against the previous code). Worth knowing operationally: two e2e rounds during the session suggested "router.refresh() does not apply the cookie". That was the machine at load average 40 with another worktree compiling — the picker's own spec passes at one and at two workers, and the lesson is that expect's 5 s default does not cover a dev-mode RSC round-trip on a heavy list route with two workers, so the refresh assertions carry their own budget. The P1 red-e2e ledger row also got wider and the way to read it changed: campaignMunicipalities fails five tests at one worker on a stashed clean tree (:56, :125, :292, :394, :446) against four with this delivery applied, and the failing set differs between runs — so "check those two by name" no longer settles anything; compare the count against a stashed tree. No migration, collection, server action or Consent. Plano: docs/plans/seletor-colunas-lista-municipios.md.
Recently resolved (2026-07-27): E14 Níveis de envolvimento N0–N4 — municipality.engagementLevel (staff-only select n0…n4) records what the campaign decided to invest in a município, next to the E8/E10 metrics that describe what it knows. It is also the first production writer of allocationDecision (C12 shipped 2026-07-24 with access and tests but no caller). Migration 20260727_161752_add_municipality_engagement_level adds engagementLevel (indexed, no default), levelNote (textarea 2000) and levelChangedAt (indexed date, readOnly, stamped by a beforeChange hook only when the level actually changes — and cleared when the level is), plus the value movimento on the allocationDecision.outcome enum, because a level move is neither aceita nor descarta and pretending otherwise would poison E11's triage. No backfill: the field is born null on all 435 municípios — stamping N2 would assert a decision nobody made — so null renders "Sem nível" and filtering by it is the triage queue. Moving a município is a reallocation decision, so writes are isCampaignUnrestricted only (canManageMunicipalityEngagementLevel in utilities/access/municipalities.ts); the advisor reads it and proposes through a signal. setMunicipalityEngagementLevelRecord takes the advisory lock municipality-engagement-level:{id} before reading the current level (otherwise the snapshot's from is a guess under concurrency) and writes both documents in one transaction, with snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt } — the values the decision was made on, never the document. The hysteresis rules from research §6.8 live in the pure, client-safe src/lib/engagementLevel.ts (two-level jump needs a triangulated shock; three-week protection window after a promotion, against demotion only; one move per calendar month, derivable from levelChangedAt alone — no extra query), so the Popover shows the reason before submitting and the server re-runs them anyway. The route has three states, not two: blocked (409) returns the typed violations instead of collapsing into "could not save", because a blocked move is a decision the coordination may still take — the Popover keeps the draft and offers the override, which is recorded with exactly the rules it contradicted. Explicit submit, not auto-save — the exception campanha-edit-where-you-see already carves out for flows needing a note/confirmation, which also keeps this control out of the debounce machine B32+ will audit. On the list: an 11th staff column with a numbered outline badge, plus filter ?level=, which — unlike E10's derived classe — is native, because the field is stored and reaches the where; "sem nível" cannot ride inside an in and becomes an or branch with { exists: false }. The sort is not native, though n0…n4 does order lexicographically: Postgres puts NULLs FIRST on DESC, so with no backfill "N4 primeiro" would open on a page of "Sem nível". nivel joins applyDerivedMunicipalitySort and reuses sortByNullableValue, which sinks the absent level in both directions — the same treatment sem_base gets in classe. New E18 concept nivel-de-envolvimento. Worth knowing: with overrideAccess: false Payload silently drops a field the actor cannot write instead of rejecting the update, so the advisor int test asserts the level did not change rather than expecting a throw. Plano: docs/plans/niveis-de-envolvimento.md.
Recently resolved (2026-07-27): Build resiliente ao global metadata ausente — resolveSiteMetadata in src/utilities/seo.ts (chain global.URL → NEXT_PUBLIC_SITE_URL → omit canonical/OG/JSON-LD url; textual defaults for empty global). All public SEO consumers (layout, /abaixo-assinado/[id], /[type] tree) go through it; convention guard in codebaseConventions.unit.spec.ts. Plano: docs/plans/build-resiliente-metadata-ausente.md.
Recently resolved (2026-07-26): B8 F2 Polígonos dos Municípios-zona de Salvador (ZE 1–19) — Salvador used to be one polygon carrying the whole city's votes, so the choropleth could not answer "which piece of Salvador is weak, or mine"; the only per-zone view was the textual zoneBreakdown. Two halves shipped. (1) The mesh: a new committed artifact src/lib/geometries/bahia-municipality-zones.topo.json (72,310 bytes, ~20 KB gzip, 19 features keyed by municipalitySlug) built by pnpm build:municipality-zone-geometries — see "Campaign map geometries" for the source, the parser and the three curated reconciliation tables. The interesting part is not the dissolve, it is that the curation lives in the script, declared line by line with its evidence, and the build fails closed: an orphan polygon or a TRE neighbourhood with no polygon aborts it, so "170/170 → 19/19" is a tested invariant. Periperi, which the resolution splits mid-street between two ZE, goes whole to ZE 17 — the neighbourhood is the smallest geometry available and cutting a polygon from a prose description of a street was the rabbit hole the plan already refused. The 5 polygons the resolution never names are assigned by longest shared boundary, and that rule was validated against the 4 it names under another label before being trusted. (2) The key: the choropleth is no longer keyed by codarea but by a map key (mapKeyForMunicipality: slug for kind === 'zona', ibgeCode otherwise) — see the "Map" bullet in the Município model for the contract, the non-interactive Salvador base and the per-city rank limitation. Because each map key now maps to exactly one catalog entry, the rollups stopped accumulating with += and computeAggregateTerritorialClass gave way to computeMunicipalityTerritorialClass; resolveMunicipalityMapNavigation became 1:1, and with it the kind: 'zones' branch, its "toque de novo" readout copy and the panel's scroll-anchor all died. buildMunicipalitiesByIbgeCode survives untouched: B14's list href is per city on purpose. Two measurements, so nobody re-derives them: Salvador's municipal polygon is ~690 km² against ~324 km² for the 19 zones summed — the difference is the bay water the municipal limit encloses and a neighbourhood mesh never draws, which is why the int test asserts a land-area range instead of a ratio against the município (the first version demanded ≥95% and was comparing land to land+water); and centroid-inside-another-zone is not a valid overlap check here, because ZE 16 is concave and wraps ZE 14, so 16's centroid legitimately falls in 14 — non-overlap is guaranteed structurally at build time plus the area sum. New e2e campaignZoneMap.e2e.spec.ts pins 436 paths / 435 interactive (the one non-interactive path is Salvador's base) and that a zone opens /campanha/municipios/salvador-ze-N. No migration, no collection, no server action, no Consent. Open debts with triggers (in the plan, not silent): the B14 "Onde estou" card still routes Salvador to the filtered list even though resolving the exact ZE is now possible, and per-ZE competitive rank needs a new dimension in the TSE artifact. Plano: docs/plans/poligonos-pracas-zona.md.
Recently resolved (2026-07-27): B8+ Escala e DRY pós-B8 F2 — the four /simplify debts of the ZE polygons, and the headline finding changed owner. The plan called the target "ground resolution implemented via quantization digits"; measuring the committed artifact re-snapped to coarser grids showed quantization alone barely cuts (~25 m → 90% of the original vertices, and even on the state mesh's ~103 m grid the zones stayed at 203 points/feature, ~9× the sibling). The lever is the absolute Visvalingam threshold, so the policy was inverted: absolute weight in m² of ground is the policy, digits are derived per topology. See the ground-scale policy bullet under "Campaign map geometries" for the numbers that matter; the outcome is the zone mesh at 574 arc points / 30.2 per feature / 7,687 bytes (from 9,120 / 480 / 72,310), bahia-municipalities byte-identical, and bahia-identity-territories off by one byte because its grid is now derived from its bbox (102.730 → 102.822 m). zoneNumber left the artifact and the type. The other three phases: featureMapKey is now exercised on real features of all three committed meshes, each inside the loop that already sweeps it whole (417 codarea + 27 code + 19 slug) — the /simplify caught the first version asserting the city inside the zone loop, which covered one municipal feature nineteen times because the 19 ZE share Salvador's code — and the synthetic precedence test died (the invariant is real — see the Map bullet); a zone-chunk failure degrades instead of killing the map (with the reason logged, since a ChunkLoadError and a corrupt artifact are not the same event), and the approximation caveat left the !comparisonActive gate for a paragraph conditioned on the new bundle field hasZoneMunicipalities, published by the loader from kind === 'zona' — the /simplify rejected deriving it on the client from the key's shape (a second encoding of mapKeyForMunicipality, in negative space) and rejected gating on zoneBreakdown, which the zone-list fill-in deletes; and the delivery's orphans are gone — two accessors deleted, MunicipalityMapNavigation collapsed to string | undefined, MunicipalitiesByMapKey to Record<string, string> (435 dead strings out of every Início RSC payload), polygonRingsOf exported, geometry helpers retyped on PolygonalFeature. One planned deletion was rejected by measurement: city/zoneNumber on MunicipalityZoneNeighborhoodEntry stay — no runtime reads them, but both feed the rows hashed by canonicalEvidenceRows and are columns of the RA 02/2017 provenance fixture (an independent transcription), so deleting them would weaken the verification for nothing. No migration, collection, action or Consent; e2e still 436 paths / 435 interactive. Plano: docs/plans/escala-dry-pos-b8f2.md.
Recently resolved (2026-07-28): B37 Chips editáveis de municípios na lista de dobradinhas — the "Municípios" column of /campanha/dobradinhas was row.municipalityCount, a dead number; it now uses the same MunicipalityPortfolioCell (B34 ✓) unchanged, writing the inverted side of the relation (municipality.stateDeputies) through a single batch action (setStateDeputyMunicipalitiesBatch, transactional, per-município advisory lock, all-or-nothing on a partial-scope batch), with no floor (a dobradinha can hold zero municípios) and no assertMunicipalitiesWithinScope (canUpdateMunicipality already scopes the advisor). stateDeputiesArraySchema's cap moved 20 → 435 to fit a whole território batch from the município side. The post-implementation critique found two P1s, and the answer was to generalize immediately instead of registering a fourth "deferred with trigger": MunicipalityPortfolioCell and LeadershipStateDeputyRelationCell (B31 ✓/B36 ✓) had grown the same interaction machine (optimistic delta, undo, ARIA 1.2 combobox, floor/ceiling, pointer-fine/coarse split) twice, with incompatible interfaces — this item was the 3rd call site B34+ was waiting for, so src/components/campaign/shared/RelationChipCell.tsx (813 lines) became the single engine and both cells shrank to thin, domain-specific wrappers (chip/search building, FormData, copy — nothing interaction-specific left in either). This closes B34+'s useChipRowOverflow extraction trigger ahead of schedule: the ~105 duplicated lines it named are gone, not just deduplicated into a helper. LeadershipStateDeputyRelationCell also gained an ownerName prop (for the Drawer description and aria-labels, matching the município cell) and its first dedicated test suite — it shipped with zero coverage of its own before this. The second P1 — an advisor could see, and attempt to remove, a município chip outside the portfolio they administer, and the removal silently no-oped at the document level with no way for the cell to explain why — was closed by hiding the chip instead of only explaining the failure: MunicipalityPortfolioCell now filters municipalityIds by addableIds when present (visibleIds), so an advisor sees and can only touch municípios they administer; staff without addableIds still sees and can remove every link. Accepted trade-off: in a rare portfolio-reassignment race, the client-side floor could refuse a removal the server would actually accept — acceptable because the server remains the source of truth and the explicit ask was to hide, not merely explain. Gate: tsc/lint/format:check/check:cycles clean, unit 715/715, int 452/452 (full suites, not just touched files), Aikido 0 findings on edited first-party files; knip blocked by the pre-existing, already-ledgered payload.config.ts load failure (P3 in TECH-DEBT.md), not a regression of this delivery. The closing /simplify found a latent bug rather than a deduction, and it is the reason one convention here is now load-bearing: mapCampaignFormActionError matches a thrown message against safeMessages by exact string, so a cap message spelled as a bare literal in the throw and in two independent allowlists is three chances to reword one of them and silently collapse a real refusal into "tente novamente". Those messages are constants next to their caps now (LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, MUNICIPALITY_ADVISORS_CAP_MESSAGE, plus STATE_DEPUTY_STAFF_MESSAGE — renamed, since it guards create/update too, and actions/stateDeputy.ts was importing the constant while restating it as a literal one line below). What surfaced them: B37's delta helper was the third byte-for-byte copy of the same single-id calculation, differing only in which cap it reads, so the algorithm lives once in src/lib/relationMembershipDelta.ts (nextIdsAfterMembership, cap as data) with the three domain names as one-line wrappers, and the three cloned specs became one describe.each with more coverage than before (every relation now gets the "can go to zero" and no-mutation cases only B37's clone had). The set-with-floor form (nextMunicipalityIdsAfterLeadershipMembership) stayed out on purpose: B34+ had already inverted that recommendation on a second reading, and seeing the collapsed code confirmed it — a generic covering both forms needs four callbacks to save ~20 lines, while the new helper is 12 lines with no optional parameter. Two memoization gaps closed, both restoring invariants the codebase had elsewhere: the advisor's scoped .filter() returned a fresh array per row, defeating the very WeakMap that exists to share derivations across rows (now scopedPortfolioIndex, memoized on (index, addableIds) in the lib — this closes B34+ F9), and the leadership cell's search called matchesAtWordStart per candidate, paying an NFD pass plus three Unicode regexes on both sides per keystroke, the cost wordStartFilter's own doc comment warns about. Also: the batch action stopped resolving the dobradinha slug before its loop (the slug exists only to target the revalidate, which the caller already skips on a no-op — the guard its leadership twin already had), its redundant second dedup is gone, and the bare 435 shared with the advisor batch is MAX_MUNICIPALITIES_PER_BATCH. Rejected with reason: maxItems on the dobradinhas cell "for parity" — a dobradinha has no product ceiling on municípios (which is why the bound is the catalog), so a "generous 30" would invent a rule that refuses a legitimate statewide dobradinha. Two debts absorbed into B34+ as F10 rather than opening an ID: both município-side batch actions write one município at a time (findByID + update each — ~870 round trips at the 435 ceiling this delivery raised, but ~52 at the largest real TI, and no test above a batch of 2), and they are twins at 2 call sites; the common trigger is a "select all"/multi-TI affordance or a third batch on that side. No migration, collection, or Consent. Plano: docs/plans/chips-municipios-lista-dobradinhas.md.
Recently resolved (2026-07-28): B18 Filtros salvos na lista de Municípios — a "Salvar filtro" button beside "Limpar" names the current URL recorte, and the shortcut becomes a second-level submenu under Municípios in the sidebar (chevron, persisted disclosure, active item, delete with undo). Three layers: src/utilities/municipalitySavedFilters.ts (localStorage, snapshot cache for useSyncExternalStore, cap 12, alphabetical order on purpose — a named shortcut that reorders itself under the cursor every time it is re-saved is a navigation bug; recency is Visitados' job), the pure src/lib/listQueryMatch.ts, and two islands synchronized by useMunicipalitySavedFilters. The identity is the href, not a UUID: the entry is { href, name } — a UUID would exist only to allow two shortcuts navigating to the same place, which is exactly what the upsert prevents, and that is what makes "renomear" (deferred in the plan) cost a string instead of a phase, since the button just reads "Renomear" when the current recorte is already saved. Five premises of the 2026-07-24 plan died in the audit, and the first cost work outside the appetite: SidebarMenuSub/SidebarMenuSubItem/SidebarMenuSubButton/SidebarMenuAction did not exist (Pass 2 W4b put knip exports in ERROR and the kit was pruned), so they were re-added from upstream — and doing so exposed a latent kit bug worth knowing before touching that file: React renders data-active={false} as data-active="false" and Tailwind's data-active: selector matches [data-active], so every non-active item was getting the active style; the attribute is now emitted only when true (data-active={isActive || undefined}) on both SidebarMenuButton and SidebarMenuSubButton. The others: municipalityUi.ts no longer exists (W1 split it, so buildMunicipalitySavedFilterHref lives in municipalityListFilters.ts); the URL state grew to 14 params, absorbed by serializing the whole state minus page/compare rather than by a hand-written param list; B17 persists in a cookie, not localStorage; and hover disclosure died with B38 ✓ — collapsible="offcanvas" takes the whole sidebar off screen when collapsed, so there is no rail to hover. One seam came free: formatMunicipalityActiveFiltersSummary was already the "has a filter?" gate the Limpar button uses and already produced a readable label of every filter, so it is simultaneously the mount condition and the suggested default name. The central risk was bundle, and it was measured against a stashed clean tree: the sidebar lives in the (app) layout, so importing buildMunicipalityListHref there would cost the 21 kB AGENTS.md already records (it drags bahiaTerritories + municipalityCatalog) across every /campanha route — with the pure comparator instead, /campanha stayed unchanged at 277 kB and only /campanha/municipios moved (328 → 330 kB); ignoring page also settled a case the plan never considered, that being on page 3 of a saved filter is still being in it. Two real bugs only the e2e caught. The auto-open re-opened the group on every mount, which made the persisted preference unhonorable for exactly the person who uses saved filters most (collapse it, reload, it is back) — and the cause is not obvious: the trigger was activeHref appearing, but the store answers empty until the client has read localStorage, so a match surfacing out of hydration read as a navigation; the trigger is now the URL changing. And deleting a row dropped focus to <body>, because emptying the group unmounts the whole component, chevron included — the successor is resolved before the removal. The critique also found an SC 2.5.3 (Label in Name) failure: visible label "Salvar filtro" with aria-label "Salvar este filtro", which voice control cannot match, so the accessible name has to contain the visible one. Gate: importmap unchanged, tsc/lint/format:check/check:cycles clean, unit 787/787, int 478/478, e2e 3/3, local pnpm build green, Aikido 0 findings; knip blocked by the pre-existing ledgered payload.config.ts load failure (P3), so every new export was verified by hand. Worth knowing operationally: pnpm build cleans .next and the e2e dev server uses NEXT_DIST_DIR=.next/e2e, so running a build between two e2e rounds destroys the dev compile cache and makes the setup project blow its 90 s budget — that is not a test flake. No migration, collection, server action or Consent. Plano: docs/plans/filtros-salvos-municipios.md.
Recently resolved (2026-07-26): E12 Camada TI — extends the E17/B21 rollup (territoryOverview.ts + loadTerritoryOverview.ts) with E8 goal coverage (central), MAUP-safe regional capture (aggregate ratio Σ own votes ÷ Σ field ceiling — never the mean of municipal ratios; median, min–max, named critical municipality and beacon), and aggregated territorial class via computeAggregateTerritorialClass (same rule as B13 for TI groups). Staff surface: three new columns on /campanha/territorios (Cobertura, Captura, Classe) with B22 header descriptions and sorts cobertura/captura/classe; municipality detail gets intra-TI benchmark T4 on MunicipalityGoalAccountCard (territoryIntraCaptureBenchmark.ts — Salvador zone slugs compare among the 19 zones, other Metropolitano peers among RMS municipalities). Two E18 concepts (captura-regional, benchmark-intra-ti). v1 cuts kept: no ?group=territorio on the municipality list, no TI map mode. No migration. Plano: docs/plans/camada-territorios-identidade.md.
Recently resolved (2026-07-26): B24 Auto-save + justificativa no popover de Tendência — MunicipalityListTrendControl grava sozinho via POST /campanha/municipios/political-trend (status ~150 ms, nota 600 ms, flush blur/fechamento), Textarea de justificativa no Popover, sem revalidatePath da lista; /editar mantém submit explícito. Plano: docs/plans/autosave-tendencia-lista-municipios.md.
Recently resolved (2026-07-26): B14 Município mais próximo (atalho geo no Início staff) — a new client island NearestMunicipalityCard ("Onde estou") answers "open the município I am standing in", and the answer is point-in-polygon, not a nearest-centroid guess. The source plan had rejected polygons as a bundle rabbit hole; the premise was false by the time it shipped, because MunicipalityMapPanel always mounts in mode="municipality" and BahiaMap calls loadMunicipalityGeometryModule() on mount, so the 417 polygons (132 KB raw, 41 KB gzip, own memoized chunk) are already in the browser of anyone on /campanha — the card reuses that promise and adds no geometry. So no centroid artifact, no build script, no migration, no collection, no server action and no Consent key: the position is read by navigator.geolocation and matched in the browser, and never leaves the device. Pure math in src/lib/municipalityProximity.ts (haversine, ray casting with holes, area-weighted centroid, discriminated union inScope | zoneCity | outOfScope | outsideBahia; measured, so nobody re-derives it: the mesh decodes to 417 plain Polygons with zero holes and zero MultiPolygons, a full containment scan costs 0.106 ms and the whole fallback 0.32 ms — 81% of it centroids — so there is no hot path here and a centroid artifact would still not pay for itself; building a fresh Map per call measured slower, and the module's existing getMunicipalityFeature index is passed in only because the map already built it); browser plumbing in src/utilities/campaignGeolocation.ts (sibling of recentVisits.ts: session flag teqo:campaign:geo-prompted-session so the automatic prompt fires at most once per tab, guarded permission read where Safari's missing descriptor legitimately answers unknown, typed failures). Centroid distance survives only to rank the fallback: an advisor standing outside his portfolio is told where he is and offered the closest município he can open, up to NEAREST_IN_SCOPE_MAX_KM = 150 km. Salvador still gets no guessed ZE — it links to the filtered list, with the zone count limited to what the actor can read. StaffDashboardView gained accessibleMunicipalities (~6.6 KB gzip of RSC payload for all 435), and the zone-list href reaches the island as a zoneCityHrefs prop serialized by CampaignDashboard (the RSC that already builds list hrefs) rather than by the Payload loader: the first version imported buildMunicipalityListHref into the island and cost 21 kB of First Load JS for a single link, because the canonical serializer drags bahiaTerritories + municipalityCatalog in — the "client components must not import server data modules for values" rule applies to URL serializers too, so the href is serialized on the server. /campanha First Load JS: 265 → 268 kB (measured against a stashed baseline, not against the number in a previous note). A coarse fix (accuracy > 10 km, typical of a desk) renders the result with an explicit "localização aproximada" caveat rather than silently naming the wrong município. The shortcuts row hides itself in CSS (hidden … has-[>*]:grid, written inline in CampaignDashboard): having the children report visibility upward cost two state slots, a callback prop, an effect and a layout shift on every load, because the server rendered the row hidden and the effect only unhid it after first paint — and with that state gone, the DashboardShortcuts wrapper was a named div, so it is gone too. loadMunicipalityGeometryModule memoizes the dynamic import's rejection, so the card catches it and falls into an actionable failure state — otherwise a ChunkLoadError left a spinner with a retry button that could never recover. Plano: docs/plans/municipio-mais-proximo.md.
Recently resolved (2026-07-26): B13 Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales relative to the candidate himself, all as discrete classes: quantis (the new default), padrão próprio (LQ) and posição no município, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in src/lib/mapScaleClasses.ts (new file — bahiaMapStyle.ts already owned path styling); resolvePathStyle gained a fillByKey path that replaces the continuous ramp when present, where a missing key is "no data", which is not a value of zero. LQ is 5 named classes cut on the E10 anchors, not diverging — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. TERRITORIAL_CLASS_ANCHORS / AT_STANDARD_LQ moved to src/lib/territorialClassAnchors.ts because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (E15) stays a one-line diff in one file. Quantiles are computed over the user's access scope (435 for coordinator/candidate, the portfolio for an advisor) and degrade to 3 classes below 10 municípios, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the committed artifact (v3) rather than a request-time query — see "Election baseline data" above. New in E10: computeAggregateTerritorialClass(slugs) classes a GROUP as one territory by summing inputs and running the same classifyMunicipalityTerritory, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — E12's TI rollup must inherit this helper instead of growing a second one. The map loader fills statewideShareByYear, territorialClassByCode, competitiveRankByYear and projectedValidVotesByCode, all pure over the artifact: no new query per request. (B8 F2 later re-keyed the last two to territorialClassByMapKey / projectedValidVotesByMapKey.) UI: discrete MapScaleLegend with real ranges tied to the selector via aria-describedby, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, interactive: false so the bubble does not stack a second hover target on its own município) — 417 markers were measured before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in /campanha/conceitos. No migration, no collection, no server action; /campanha's First Load JS is byte-identical (9.46 kB / 265 kB) because the map panel already ships in its own ssr: false chunk — that chunk itself grew, and the RSC payload grew ~64 KB uncompressed (~10 KB gzipped), dominated by competitiveRankByYear. Known flake (pre-existing, reproduced on a clean tree): campaignMunicipalities.e2e.spec.ts fails intermittently with 2 workers — the LeaderContactsPanel consent checkbox clicked before hydration, and a getByRole regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: docs/plans/escala-relativa-mapa.md.
Recently resolved (2026-07-25): C13 "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection actionPlan → activity (src/collections/Activity.ts, labels "Atividade"/"Atividades"), campaignDemand.actionPlan → activity, route /campanha/planos → /campanha/atividades (create at /campanha/atividades/nova), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum values (kind/status/origin) are unchanged. Schema moved by a hand-written data-preserving migration (20260725_213000_rename_action_plan_to_activity): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric down(). Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index action_plan_upcoming_start_at_idx, so the object list came from pg_indexes/pg_constraint/pg_type/pg_sequences and the .json snapshot was written by transforming its predecessor — validated by pnpm migrate:create __probe reporting no schema changes. The same migration aligned the 8 fossil %plaza% index names the Município remodel left behind (it renamed the plaza_id columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the codebaseConventions.unit.spec.ts "banned campaign terminology" describe is now table-driven and sweeps src + tests + scripts (migrations stay out — frozen history), banning the actionPlan compounds, the pt-BR copy and the old route, but not bare plan/plano (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, scripts/generate-remodel-municipalities-migration.mjs and the historical comment in campaignMigrationReconciliation.int.spec.ts. Plano: docs/plans/renomear-plano-acao-para-atividade.md.
Recently resolved (2026-07-25): E10 Classificação territorial relativa — src/utilities/municipalityTerritorialClass.ts classifies each of the 435 catalog municipalities as reduto | expansao | manutencao | marginal | sem_base (labels "Reduto · Expansão · Manutenção · Marginal · Sem base"). This vocabulary comes from the discovery research and replaces the majoritarian-era Defesa/Ataque/Indecisa/Perdida — don't reintroduce it. The classifier is a pure function over the committed TSE artifact (no DB, no campaignGoals, no user, no migration): location quotient against the candidate's own statewide standard (TERRITORIAL_CLASS_ANCHORS.strongLq 2 / weakLq 0.5), share of his own statewide vote, uncaptured field ceiling (E8's projectedFieldCeiling) compared against the catalog median, plus membership in the block holding 50% of his vote (which is what keeps a big low-penetration município out of "Marginal") — that block is built by walking A11's already-memoized computeVoteRankByYear ordering, not by re-sorting the catalog. Cuts are ILLUSTRATIVE and versioned in one object — calibration is E15. getStatewideFederalTotals(year) was extracted and memoized into bahiaElectionAggregates.ts; municipalityVoteRank.ts reuses it instead of summing the state itself. The UI never renders the label alone (research §6.4 — a bare verdict buys false confidence): the class always travels with its two dominant factors as real text — never a title attribute, which reaches neither keyboard nor touch. In the list that text is sr-only behind the badge and shown on hover/focus/tap through the B23 cellTooltip seam (spelling it out under the pill made Classe the widest cell in a table that cannot scroll horizontally); on the detail card and the dossier it is a visible paragraph. Surfaces: MunicipalityGoalAccountCard (E8 — the class opens the diagnostics block it summarizes, no new card), the MunicipalityDossier cover (self-labeling badge "Classe reduto" + factors; print CSS forces print-color-adjust: exact on [data-slot='badge'], otherwise the printer flattens priority/trend/class into identical grey), and a new "Classe" column in /campanha/municipios right after "2022" — the 10th staff column, accepted without B17 by explicit product decision. Badge palette is documented in DESIGN.md § Status Badge; sem_base deliberately renders as an em dash in the list (matching the neighboring "2022" column) and keeps the flattest pill on the detail card and the dossier, where it sits in a labeled "Classe …" field. List filtering is the one derived filter in the system: state.classes is NOT part of buildMunicipalityListWhere — municipalityPageData.ts forces the limit: 0, pagination: false path whenever a class is selected (otherwise a 25-row page would drop rows and totalDocs would lie) and applies the same predicate to staffScope.municipalities before the overview rollup and to all three column facets. URL: repeatable ?class= (all selected canonicalizes to absent, same as trend) and sort key classe (ordinal via territorialClassSortWeight, desc default = reduto first, sem_base last either way). Two new diagnostico concepts in /campanha/conceitos (E18). NEC/competition stayed out of v1 (would need a new SQL aggregate; trigger is E11 needing the competition axis beyond the intra-field proxy). Unblocks B13, which should consume computeMunicipalityTerritorialClass as-is rather than reclassify. Plano: docs/plans/classificacao-territorial-relativa.md.
Recently resolved (2026-07-25): E16 Dossiê do município (pré-agenda, field request O6) — staff-only dossie tab on the municipality detail page (6th value in municipalityDetailTabs, same query-param pattern): src/utilities/municipalityDossierData.ts composes the existing loaders in one pass (TSE baseline + A11 rank via municipalityElectionGeographyForSlug, E8 goal account, pledges, leaderships sorted by a new LeadershipRowViewModel.updatedAt freshness field, updates feed, two scoped actionPlan reads with overrideAccess: false, and A8 demographics — the previously consumer-less bahiaMunicipalityDemographics.ts re-enters through the "Perfil" section, with an explicit whole-city caveat for Salvador zona municipalities). Section caps 8 lideranças / 5 sinais / 3 upcoming + 2 realizado plans, each with a "ver tudo" link to its tab. New staff-only municipality.budgetNotes textarea ("Emendas aportadas", G11 manual-first) via migration 20260725_022155_add_municipality_budget_notes — its committed .json snapshot also heals the drizzle snapshot chain after the snapshot-less campaign_goals migration; strategy form/zod/action extended; the section renders only when filled. Print view is CSS-only: app chrome gets print:hidden, the h-svh/overflow-hidden shells get print:h-auto print:overflow-visible (otherwise only page 1 prints), and styles.css gains A4 print typography; the dossier cover is self-contained (name, TI, badges, "Gerado em") so the printed page keeps its identity. Int test mocks loadMunicipalityElectoralBaseline (its unstable_cache wrapper needs the Next runtime). Plano: docs/plans/dossie-municipio.md.
Recently resolved (2026-07-24): E8 Conta da cadeira (metas derivadas, potencial por município, cobertura) — committed TSE artifact extended to v2 (pnpm build:election-aggregates now writes campoFederalVotesByYear, federalTallyByYear, and 2022 majoritarian2022 presidente/governador T1 per municipality, alongside the existing votesByYear/validVotesByYear; byte budget raised 400→700 KB in the unit test); src/lib/campoParties.ts (curated field-party siglas per election year, unit-tested against electionPartySpectrum.ts's esquerda bucket — not a second party classifier); global campaignGoals (src/globals/CampaignGoals.ts, migration 20260724_180000_add_campaign_goals_global, stateGoal default 150,000 + margin/baseYear/note, read = admin/staff, update = admin/isCampaignUnrestricted, no afterChange hook — /campanha is dynamic with auth so nothing caches it); pure derived utilities src/utilities/municipalityPotential.ts (projected valid votes, field ceiling, capture rate, intra-field share, 2022-only roll-off, per-scenario suggested goal + TI sanity check) and src/utilities/goalCoverage.ts — locked semantics: meta = expectedVotes[scenario] ?? suggestedGoal[scenario], comprometido = aggregate.effectiveByScenario[scenario] (pledges only — the mesa's own expectation never substitutes for the pledge sum in the coverage denominator, unlike resolveMunicipalityStaffVoteTotal); orchestration in src/utilities/municipalityGoalAccount.ts (cache()-deduplicated across dashboard/list/detail). UI (Impeccable class B): "Cobertura da meta" row on the dashboard and the list overview CampaignMetricStrip, a new list column (renaming the old "Cobertura" column to "Assessoria" to remove ambiguity) via a shared MunicipalityListGoalCoverageCell (compact/default layouts), and a new "Conta da cadeira" card on the municipality detail page (fixed to the central scenario, replacing the "Votos estimados" block that used to live in MunicipalityStrategyCard). Unblocks roadmap items E9/E10/E12/E13. Plano: docs/plans/conta-da-cadeira.md. As-built correction (same day, during E9): the suggested goal originally split the state goal proportionally to the field ceiling alone, which ignored the candidate's own vote and produced absurd targets (2,911 for Vitória da Conquista, which gave him 5,005 votes in 2022; 813 for Campo Formoso, which gave him 47). decomposeStateGoal was deleted in favour of deriveSuggestedGoalsByScenario, anchored on the municipality's own 2022 vote — pessimistic = base × (1 − margin), central = base, optimistic = base × (stateGoal ÷ Σ base), clamped at max(central, optimistic) — so stateGoal now defines the optimistic scenario and margin the pessimistic haircut (10% fallback). Descriptions only, no migration.
Recently resolved (2026-07-24): E9 Fila de alocação — the municipality list became the allocation queue, with no new route, no new column and no migration (the column picker is B17 and is not shipped, so every new signal folds into an existing cell). Ordering: two new sort keys in municipalityUi.ts — deficit (the new staff default, descending) and frescor — both derived in memory in applyDerivedMunicipalitySort over the already-loaded bundle, fixed to the central scenario server-side (moving the scenario into the URL is the "Cenário junto aos filtros" fill-in). Freshness: aggregatePledgesByMunicipality now selects declaredAt/estimatedAt and exposes lastPledgeAt; resolveMunicipalityLastSignalAt (max of municipality.lastUpdateAt and lastPledgeAt) feeds both the comparator and the view model's lastSignalAt, so server ordering and rendered copy cannot drift; cold threshold MUNICIPALITY_COLD_SIGNAL_DAYS = 21. UI: "há N dias" in the existing "Última atualização" column (amber text-estimate-pending-foreground, deliberately not destructive — the priority and "sem responsável" badges already compete for red on the same row), a "sem responsável" badge when priority === 'alta' with zero advisors, the "coluna da vergonha" as the advisor-coverage metric's detail linking ?priority=alta&coverage=sem_assessor, a scenario-named coverage tooltip, and the leftover "Praças" copy fixed to "Municípios". Cut and re-homed: votes at stake → B13, LQ/capture → E10, a dedicated deficit column → unnecessary (the E8 cell already shows % and signed deficit). Plano: docs/plans/fila-de-alocacao.md.
Recently resolved (2026-07-24): E18 Documentação dos conceitos de inteligência — staff-only route /campanha/conceitos (isCampaignStaff; leader redirected to /campanha) rendering src/lib/campaignIntelligenceConcepts.ts. The glossary is curated content in code, not a Payload collection: each entry (what it measures, the formula in plain text, a hand-written example, why it matters, where it appears) sits next to the formulas it documents (municipalityPotential.ts / goalCoverage.ts) and is reviewed in the same PR that changes them; a unit test guards anchor-safe unique IDs and content completeness. Seven E8 concepts in three groups — base do cálculo (válidos projetados, teto do campo), diagnóstico (captura, share intracampo, roll-off), meta e cobertura (meta/meta sugerida, cobertura da meta). Reading surface, not cards: one article per concept with hairline dividers, max-w-prose column, sticky index at lg only, article:target tint so anchor arrivals land oriented. Two discovery paths from MunicipalityGoalAccountCard: per-metric "Saiba mais" links inside the hover tooltips (MetricExplanation takes a conceptID) and a whole-page link in the title CampaignInfoHint Popover — the Popover is the keyboard path because Radix Tooltip content is not tabbable; for touch, MunicipalityHoverTooltip now treats its own content as "inside" in the pointerdown dismiss handler (the link used to unmount before the tap landed). Sidebar entry lives in staffSecondaryNav (nav.ts) rendered in its own SidebarGroup with mt-auto at the foot of the navigation — deliberately outside staffNav (the seven work destinations) and outside the mobile bottom bar, since it is reference material; getCampaignSecondaryNav returns nothing for leader. Fixed an E8 defect found while writing the docs: formatElectionNumber rendered a fractional suggested goal as "100,968" (read as a hundred thousand in pt-BR) — now maximumFractionDigits: 0. No migration, no collection, no server action; later slices of the intelligence program (E9/E10/B13/E11/E12/E13/E14) each append their section to the array. Plano: docs/plans/documentacao-conceitos-campanha.md.
Recently resolved (2026-07-24): B19 Gerenciar assessores — /campanha/assessores (lista + novo + detalhe) for coordinator/candidate only (isCampaignUnrestricted); create/edit advisor accounts, municipality portfolio (per-municipality auto-save + advisory lock), password-reset email (blocks @planilha.invalid); aligned canUpdateCampaignUser / phone field access / canAssignMunicipalityAdvisors / assignMunicipalityAdvisorsRecord to unrestricted; privileged email read in advisorData loaders after the route gate; reloadUnrestrictedActor. No migration. Plano: docs/plans/gerenciar-assessores.md.
Recently resolved (2026-07-23): A10 Cenários de estimativa (pessimista/média/otimista) — votePledge.estimatedVotes e municipality.expectedVotes viram groups (pessimistic|central|optimistic); migration 20260723_124200_add_vote_estimate_scenarios (backfill scalar→central); agregação por cenário em votePledgeData com default central; mapa 2026 com values2026ByScenario + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um declaredVotes sem ver estimativas. Plano: docs/plans/cenarios-estimativa-votos.md. Desbloqueia E8.