From cb5854d17e7c09eb7951e388d27da66ef1aac6f4 Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Thu, 17 Sep 2026 18:54:49 -0300
Subject: [PATCH] =?UTF-8?q?feat(C187):=20dossi=C3=AA=20Solla=20por=20insti?=
=?UTF-8?q?tui=C3=A7=C3=A3o=20+=20boletim=20modelo=20(skill=20+=20builder)?=
=?UTF-8?q?=20(Closes=20#1135)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Generaliza aditivamente o pipeline C186 (default município; specs verdes) via o
descritor scripts/lib/dossieUnit.mjs (municipality|institution), extrai o emit
A4/fit-guard para scripts/lib/buildPdf.mjs e a receita read-only para
scripts/lib/readOnlyExtract.mjs.
- Catálogo fail-closed src/lib/institutionCatalog.ts + institutionNameAliases.ts
(seed UFBA/Correios/Enfermagem; escape one-off --slug/--name; dedup).
- Skill/command/subagente /dossie-solla-instituicao; eras A/B/C reusadas.
- Abrangência instituição × setor × rede nunca somada; era sem fonte vira página
de lacuna explícita; lista capada declara "e mais N" (regra C188 no dono).
- Boletim modelo 1×A4 com fase junto ao valor, sem fontes, sem CTA; herda apenas
fatos com fonte do dossiê (incl. falas do acervo read-only).
- Sem schema/migration/DB write; artefatos gitignored.
---
.../skills/dossie-solla-instituicao/SKILL.md | 271 ++++++
.gitignore | 4 +
.opencode/agent/dossie-solla-instituicao.md | 42 +
.../commands/dossie-solla-instituicao.md | 9 +
docs/changelog/2026-09-17-c187.md | 1 +
docs/plans/dossie-solla-instituicao-impl.md | 178 ++++
scripts/build-dossie-solla-cidade.mjs | 75 +-
scripts/build-dossie-solla-instituicao.mjs | 171 ++++
scripts/extract-city-report-snapshot.mjs | 53 +-
scripts/extract-institution-snapshot.mjs | 157 ++++
scripts/institutionSnapshot.mjs | 145 ++++
scripts/lib/buildPdf.mjs | 95 ++
scripts/lib/dossieBlocks.mjs | 354 +++++++-
scripts/lib/dossieBulletin.mjs | 45 +-
scripts/lib/dossieBulletinRender.mjs | 179 +++-
scripts/lib/dossieRender.mjs | 813 +++++++++++++++++-
scripts/lib/dossieResearch.mjs | 239 ++++-
scripts/lib/dossieUnit.mjs | 67 ++
scripts/lib/readOnlyExtract.mjs | 67 ++
scripts/lib/test-affected-core.mjs | 4 +
src/lib/institutionCatalog.ts | 126 +++
src/lib/institutionNameAliases.ts | 49 ++
tests/int/institutionSnapshot.int.spec.ts | 119 +++
tests/unit/buildPdf.unit.spec.ts | 45 +
tests/unit/dossieInstitution.unit.spec.ts | 312 +++++++
.../dossieInstitutionBatchSkill.unit.spec.ts | 95 ++
.../dossieInstitutionResearch.unit.spec.ts | 128 +++
tests/unit/institutionCatalog.unit.spec.ts | 62 ++
tests/unit/opencodeCommands.unit.spec.ts | 1 +
tests/unit/readOnlyExtract.unit.spec.ts | 64 ++
30 files changed, 3791 insertions(+), 179 deletions(-)
create mode 100644 .agents/skills/dossie-solla-instituicao/SKILL.md
create mode 100644 .opencode/agent/dossie-solla-instituicao.md
create mode 100644 .opencode/commands/dossie-solla-instituicao.md
create mode 100644 docs/changelog/2026-09-17-c187.md
create mode 100644 docs/plans/dossie-solla-instituicao-impl.md
create mode 100644 scripts/build-dossie-solla-instituicao.mjs
create mode 100644 scripts/extract-institution-snapshot.mjs
create mode 100644 scripts/institutionSnapshot.mjs
create mode 100644 scripts/lib/buildPdf.mjs
create mode 100644 scripts/lib/dossieUnit.mjs
create mode 100644 scripts/lib/readOnlyExtract.mjs
create mode 100644 src/lib/institutionCatalog.ts
create mode 100644 src/lib/institutionNameAliases.ts
create mode 100644 tests/int/institutionSnapshot.int.spec.ts
create mode 100644 tests/unit/buildPdf.unit.spec.ts
create mode 100644 tests/unit/dossieInstitution.unit.spec.ts
create mode 100644 tests/unit/dossieInstitutionBatchSkill.unit.spec.ts
create mode 100644 tests/unit/dossieInstitutionResearch.unit.spec.ts
create mode 100644 tests/unit/institutionCatalog.unit.spec.ts
create mode 100644 tests/unit/readOnlyExtract.unit.spec.ts
diff --git a/.agents/skills/dossie-solla-instituicao/SKILL.md b/.agents/skills/dossie-solla-instituicao/SKILL.md
new file mode 100644
index 00000000..77b2cc31
--- /dev/null
+++ b/.agents/skills/dossie-solla-instituicao/SKILL.md
@@ -0,0 +1,271 @@
+---
+name: dossie-solla-instituicao
+description: 'Gera o dossiê Solla por instituição (PDF A4 + .md, tudo o que Solla fez pela/na/com a instituição por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita uma instituição ou um lote separado por vírgula.'
+---
+
+# Dossiê Solla por instituição + boletim modelo (C187)
+
+Entrega, por instituição (universidade, instituto, empresa pública, autarquia,
+órgão, conselho/entidade de classe, categoria profissional, movimento, rede), um
+**par PDF A4 datado + companion `.md`** com tudo o que **Jorge Solla fez por, na
+e com a instituição ao longo da carreira** — toda afirmação não trivial com
+**URL + data**, tudo sem fonte vira **lacuna explícita** — **e** o **Boletim
+Informativo modelo** (1 página A4, linguagem de eleitor, sem declaração de
+fontes). Skill irmã da `dossie-solla-cidade` (C186): mesmo pipeline, recorte
+institucional. Os dois layouts vêm dos artefatos hi-fi aprovados pelo `designer`.
+
+## Quando usar
+
+- A comunicação pede "o dossiê da " e/ou o "boletim modelo da
+ " — ou várias numa invocação (`/dossie-solla-instituicao UFBA,
+ Correios`).
+- Quem executa são o **orquestrador** (agente principal) + um sub-agente
+ **researcher por era, por instituição**, em paralelo + os scripts (`extract` no
+ homeserver, `build` local). O detalhe está em "Pipeline (etapas)".
+
+## Lote (várias instituições)
+
+A invocação aceita **uma** instituição ou **várias**, separadas por **vírgula**:
+
+```text
+/dossie-solla-instituicao UFBA # N=1
+/dossie-solla-instituicao UFBA, Correios # lote
+```
+
+Parsing (orquestrador, **antes** de qualquer pesquisa):
+
+- separa por `,`; aplica `trim`; descarta vazio;
+- cada token é aceito como **slug canônico** (`isInstitutionSlug`) **ou** como
+ **nome** (fold acento-insensível `resolveInstitutionName`; `null` = nome
+ desconhecido → token inválido) → entrada do catálogo;
+- **0 entradas** = token inválido; **>1 entrada** = ambíguo (peça o slug
+ canônico explícito) — o token **falha fechado** com mensagem acionável
+ (adicionar alias/entrada em `src/lib/institutionCatalog.ts` ou usar o escape
+ one-off `--slug= --name=""`), **nunca** inventa slug;
+- **dedupe após a resolução**, preservando a ordem;
+- token inválido/ambíguo vira **falha isolada** com o motivo no summary final —
+ **nunca** aborta o lote.
+
+Uma instituição = um dossiê (PDF+MD) + **um** boletim PDF; **sem** índice/PDF
+agregado. Falha de uma instituição/era **não** cancela as demais (**sucesso
+parcial** explícito).
+
+## Pipeline (etapas)
+
+1. **Orquestrador (agente principal).** Parseia a lista (seção "Lote"), resolve
+ as entradas no catálogo (`src/lib/institutionCatalog.ts`) e dispara **um
+ researcher por era (A/B/C), por instituição** (Task), em paralelo, com guarda
+ de concorrência (`MAX_RESEARCHERS_IN_FLIGHT = 6`). Não invente slug: confirme
+ no catálogo ou use o escape one-off. Nunca retém pesquisa web nem o corpo de
+ um `research.json` — só os recibos.
+2. **Researcher (sub-agente, ×3 por instituição, em paralelo).**
+ `.opencode/agent/dossie-solla-instituicao.md` — o **único** passo pesado de
+ contexto. Faz a pesquisa web datada da sua era e escreve
+ `data/dossie-solla-instituicao/..research.json` (cada item com
+ `sourceUrl` + `sourceDate`; sem fonte, **não escreva o item** — ele vira
+ lacuna). Devolve **apenas o recibo curto** (seção "Recibo do researcher").
+ Não roda ssh nem build.
+3. **Extração read-only no homeserver — serializada, 1 instituição por vez.**
+ `scripts/extract-institution-snapshot.mjs` (`INSTITUTION_REPORT_CONFIRM=1`,
+ sessão read-only) lê o acervo interno de falas por **tema→instituição**
+ (`topics` da entrada do catálogo; o acervo cobre 2011+ e não tem campo
+ instituição) e grava
+ `data/dossie-solla-instituicao/.institution.snapshot.json`.
+4. **Fontes oficiais em tempo de build.** O Portal da Transparência **não filtra
+ por instituição** e a Câmara tampouco; emendas/proposições institucionais
+ entram como **itens de pesquisa** (com fase e fonte). Sem atribuição → lacuna,
+ **nunca zero silencioso**. O builder **não** busca emendas/Câmara/IBGE.
+5. **Render local (por instituição):**
+ ```bash
+ NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-instituicao.mjs \
+ --snapshot=data/dossie-solla-instituicao/.institution.snapshot.json \
+ --research-dir=data/dossie-solla-instituicao \
+ --out-dir=docs/research/dossie-solla-instituicao
+ ```
+ Saídas: `docs/research/dossie-solla-instituicao/--dossie.pdf`
+ + `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em
+ `data/dossie-solla-instituicao/` (HTML, JSONs, logs). Flags úteis:
+ `--generated-at=`, `DOSSIER_STRICT=1` (falha o run se houver lacuna —
+ conferência, não entrega).
+6. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status
+ (ok|failed) · dossiê/boletim (quando ok) · motivo (quando falha)`.
+
+## Briefing por era (A/B/C)
+
+O researcher recebe **uma era** e usa o checklist da era (ver "Contrato dos
+JSONs"). As eras são as mesmas da C186 (não criar eras novas):
+
+- **Era A (até 2006)** — formação/residência/pesquisa com vínculo nominal;
+ SESAB; consultoria no MS; gestão municipal; Secretaria de Atenção à Saúde do
+ MS; vínculo institucional documentado. O acervo interno de falas cobre 2011+:
+ **pré-2011 é lacuna explícita**, não invenção.
+- **Era B (2007–2014)** — gestão da SESAB com a instituição; equipamentos;
+ programas; obras; convênios/termos. Fontes: DOE-BA (DOOL), SESAB/instituição,
+ Transparência Bahia.
+- **Era C (2015–2027)** — pronunciamentos; proposições/relatorias de interesse;
+ emendas; títulos/honrarias; atuação; parcerias/audiências. Fontes: API Câmara
+ (deputy id `178857`; `/discursos` **default 7 dias** — sempre passe a janela;
+ relatorias saem das tramitações), Portal da Transparência, Siga Brasil.
+
+Templates de busca: `"Jorge Solla" ` + ``; recorte por data
+`after:YYYY-MM-DD before:YYYY-MM-DD`; `site:` por fonte institucional/oficial
+(ufba.br, correios.com.br, cofen.gov.br, camara.leg.br, ptbahia.org.br,
+jorgesolla.com.br); excluir ruído de campanha 2026 (`-eleição -candidato -voto
+-coligação -pesquisa`) e preferir verbos de entrega (emenda, recurso, convênio,
+parceria, projeto, relatoria, audiência, homenagem, repassado).
+
+## Recibo do researcher
+
+O sub-agente devolve **só** este recibo curto (≤ ~15 linhas) — **nunca** o corpo
+do `research.json` (`items[].answer/details`):
+
+```jsonc
+{
+ "slug": "ufba",
+ "era": "C",
+ "status": "ok", // ou "failed"
+ "researchPath": "data/dossie-solla-instituicao/ufba.c.research.json",
+ "researchedAt": "2026-09-17T10:00:00.000Z",
+ "itemCount": 5, // itens da era com fonte
+ "gapCount": 2, // == gaps.length
+ "newsCount": 3,
+ "gaps": ["era_c_parcerias", "…"],
+ "failureReason": "…" // opcional (só quando status = failed)
+}
+```
+
+`status: "failed"` carrega o motivo e **não** escreve corpo parcial; o
+orquestrador agrega os recibos no summary final — é o único dado de pesquisa que
+cruza para ele.
+
+## Contrato dos JSONs
+
+`..research.json` (um arquivo por era; `era` ∈ `A|B|C`):
+
+```jsonc
+{
+ "institutionSlug": "ufba", // obrigatório
+ "era": "C", // obrigatório: A | B | C
+ "researchedAt": "2026-09-17T10:00:00.000Z", // obrigatório
+ "items": [
+ {
+ "id": "era_c_emendas", // id do checklist da era
+ "answer": "R$ 5,0 milhões para estrutura de ensino e pesquisa",
+ "details": "opcional; aceita {{fonte}} / {{fonte:N}}",
+ "sphere": "instituicao", // instituicao | setor | rede (default instituicao)
+ "numbers": [ // opcional
+ { "label": "Emenda", "value": "R$ 5,0 mi", "year": "2024", "phase": "empenhado" }
+ ],
+ "sourceUrl": "https://…", // obrigatório no item publicado
+ "sourceDate": "2026-09-15", // obrigatório
+ "extraSources": [{ "label": "…", "url": "https://…", "date": "2026-09-01" }],
+ "consultedAt": "2026-09-17T09:00:00.000Z"
+ }
+ ],
+ "news": [
+ { "title": "…", "outlet": "…", "publishedAt": "2024-06-01", "url": "https://…", "summary": "…" }
+ ],
+ "gaps": [{ "id": "…", "label": "…", "reason": "…" }]
+}
+```
+
+Checklist por era (ids):
+
+- **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`,
+ `era_a_sas_ms`, `era_a_vinculo`.
+- **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`,
+ `era_b_convenios`.
+- **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_emendas`,
+ `era_c_titulos`, `era_c_atuacao`, `era_c_parcerias`.
+
+A **abrangência** (`sphere`) é `instituicao` | `setor` | `rede`; `setor` e `rede`
+não são a instituição e **nunca são somados** a ela (cada linha informa sua
+abrangência). Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra
+era vira **lacuna** (`Não pesquisado.` / `Sem fonte: …`); abrangência inválida
+vira lacuna. `phase` ∈ `autorizado|empenhado|liquidado|pago|restos` (default
+`nao_informado`; empenho **não** é pagamento). O `bulletinFacts` (ledger do
+boletim) só é populado por item **com fonte** — o boletim não introduz fato novo.
+
+## Conteúdo do dossiê
+
+- **Capa** — série institucional, "INSUMO INTERNO — defeso 2026", identificação
+ (tipo/esfera/alcance em badges), data, "como usar", escopo/versão.
+- **Resumo de uma olhada** — identificação + linha do tempo documentada do
+ vínculo; principais entregas localizadas (badge de abrangência + valor + badge
+ de fase + fonte); gancho para a agenda; o que falta. Guarda: **autorizado ≠
+ empenhado ≠ liquidado ≠ pago**.
+- **Seção por era (A/B/C)** — recorte e método + trilha de recuperação; tabela
+ **Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis com evidência";
+ títulos/honrarias. Uma era **sem evidência** vira **página de lacuna
+ explícita** (nunca seção em branco nem "zero").
+- **Abrangência: instituição × setor × rede** — painel "Setor/rede não é a
+ instituição. Não some os recortes."; três listas com contagem por recorte
+ (**nenhum total combinado**); tabela de evidência de alcance; gancho e lacuna
+ prioritária.
+- **Títulos, honrarias e vínculos** — tabela reconhecimento/natureza/fonte.
+- **Fontes e limites** — tabela de lacunas; notícias/documentos consultados
+ (larguras fixas 12/13/47/28%); limites de cobertura; regras para uso editorial;
+ nota de defeso eleitoral 2026.
+- **Nada some por não caber:** listas capadas declaram o resto ("e mais N") e a
+ reformulação fica no `summary` do researcher — nunca "…" silencioso (regra
+ C188 aplicada no dono).
+
+## Conteúdo do Boletim modelo (1 página A4)
+
+- Cabeçalho com o nome da instituição + identity pills + rótulo **"Modelo —
+ insumo interno"**; faixa de **defeso** ("sem CTA · sem propaganda"); abertura
+ "O que Jorge Solla fez pela e na "; **≤6 destaques** (eyebrow
+ "Área · abrangência", número em destaque, título curto, nota); timeline de 4
+ passos da trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com
+ controle editorial e defeso. Com **poucos fatos**, usa a variação de lacuna
+ (não cria cards vazios para chegar a seis).
+- **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem
+ CTA de campanha; herda **apenas** fatos com fonte do dossiê.
+
+## Guardrails de produto (não negociáveis)
+
+- **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna.
+- **Empenho ≠ pagamento**: cada valor acompanha sua fase; nunca consolidar.
+- **Abrangência explícita** (`instituição`/`setor`/`rede`): setor e rede
+ **nunca** são somados como se fossem a instituição.
+- **Leitura relativa**: nunca % estadual absoluto.
+- **Atribuição de emenda** de bancada/relator só com autoria checada; sem
+ atribuição → lacuna (o Portal não filtra por instituição).
+- **Lacuna explícita** em vez de inferência; o acervo interno cobre 2011+.
+- **PII mínima**: telefone/e-mail nunca entram.
+- **Falha isolada** por era/instituição não cancela o lote (**sucesso parcial**).
+- **Artefato gitignored** (repo público): PDF/MD/JSON nunca são commitados.
+- **Defeso**: dossiê e boletim saem como insumo interno/modelo, sem CTA; a peça
+ final publicável é da comunicação/campanha. **Sem schema/migration/DB write.**
+
+## Troubleshooting
+
+- **Página do dossiê / boletim estourou**: o builder aborta com a altura medida.
+ **Aperte o teto de conteúdo/corte copy — nunca mexa no layout para espremer.**
+- **Era sem pesquisa**: o builder sintetiza o arquivo da era com lacunas
+ explícitas — e a era sai como página de lacuna, não inventada.
+- **Câmara 429/500**: `getJsonWithBackoff` + paginação com teto; falha degrada
+ para lacuna datada. Os discursos sempre passam janela (`dataInicio`/`dataFim`).
+- **Sem chave do Portal da Transparência**: emendas institucionais viram lacuna
+ (o dossiê sai).
+- **Slug de pesquisa ≠ snapshot**: o builder recusa o par — regenere, nunca
+ edite o JSON à mão para casar.
+- **Token desconhecido no lote**: vira linha `failed` no summary com o motivo.
+ Use `--slug= --name=""` para uma instituição fora do catálogo.
+- **Chromium**: vem do `@playwright/test`; se faltar binário,
+ `pnpm exec playwright install chromium`.
+
+## Referências
+
+- Intenção: `docs/plans/dossie-solla-instituicao.md`; impl:
+ `docs/plans/dossie-solla-instituicao-impl.md`.
+- Designs hi-fi (fonte de verdade do port):
+ `docs/plans/dossie-solla-instituicao-ui-design.html` e
+ `docs/plans/dossie-solla-instituicao-boletim-ui-design.html`.
+- Skill irmã (dona do pipeline): `.agents/skills/dossie-solla-cidade/SKILL.md`.
+- Scripts: `scripts/build-dossie-solla-instituicao.mjs`,
+ `scripts/extract-institution-snapshot.mjs`, `scripts/institutionSnapshot.mjs`,
+ `scripts/lib/dossie*.mjs`, `scripts/lib/buildPdf.mjs`,
+ `scripts/lib/readOnlyExtract.mjs`.
+- Catálogo: `src/lib/institutionCatalog.ts`, `src/lib/institutionNameAliases.ts`.
+- Runbook de extração read-only: `docs/ops/teqo-1313-deploy.md` §C163.
diff --git a/.gitignore b/.gitignore
index aa0e3ae3..639b17b0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -88,6 +88,10 @@ next-env.d.ts
/data/dossie-solla-cidade/
/docs/research/dossie-solla-cidade/
+# C187 — dossiê Solla por instituição + boletim modelo: same rule (C186).
+/data/dossie-solla-instituicao/
+/docs/research/dossie-solla-instituicao/
+
# Local-only sensitive material (agents may read; never commit content)
/private/**
!/private/README.md
diff --git a/.opencode/agent/dossie-solla-instituicao.md b/.opencode/agent/dossie-solla-instituicao.md
new file mode 100644
index 00000000..9788706d
--- /dev/null
+++ b/.opencode/agent/dossie-solla-instituicao.md
@@ -0,0 +1,42 @@
+---
+description: Researcher por era — pesquisa web datada e escreve o research.json de uma era (A/B/C) do dossiê Solla por instituição; devolve só o recibo curto (etapa do lote /dossie-solla-instituicao)
+mode: subagent
+---
+
+# Dossiê Solla por instituição — researcher (por era)
+
+Você é a etapa **researcher** da skill
+`.agents/skills/dossie-solla-instituicao/SKILL.md` (fonte canônica: contrato dos
+JSONs, checklist por era, guardrails, recibo). Leia-a antes de rodar qualquer
+comando. Você é invocado **uma vez por era** (o orquestrador diz qual: `A`, `B`
+ou `C`) pelo agente principal, que já resolveu o slug e coordena as demais
+eras/instituições em paralelo.
+
+## Papel
+
+1. Confirmar o slug canônico da instituição (catálogo
+ `src/lib/institutionCatalog.ts`) e a era atribuída.
+2. Fazer a **pesquisa web datada** da era e escrever
+ `data/dossie-solla-instituicao/..research.json` no contrato da
+ skill (ex.: `ufba.c.research.json`) — item sem `sourceUrl`/`sourceDate` não
+ vai para o arquivo (vira lacuna explícita). Use o checklist da era e marque
+ `sphere` (`instituicao`/`setor`/`rede`); setor e rede **nunca** são somados à
+ instituição.
+3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill):
+ `slug`, `era`, `status`, `researchPath`, `researchedAt`, `itemCount`,
+ `gapCount`, `newsCount`, `gaps`, `failureReason?`. **Nunca** devolva o corpo
+ do `research.json` (`items[].answer/details`, …).
+
+## Limites
+
+- **Não** conduza a extração nem o build — são etapas determinísticas do
+ orquestrador (`scripts/extract-institution-snapshot.mjs` no homeserver,
+ `scripts/build-dossie-solla-instituicao.mjs` local). Não faça `ssh`, `scp` nem
+ render.
+- **Nunca** escreva na base de produção: não use `db:pull`/snapshot de banco.
+- **Nunca** commite o `research.json`/PDF/MD/snapshot (dado interno; repo público
+ — artefato gitignored).
+- Não invente fato sem fonte; não trate empenho como pagamento (informe a fase);
+ não inclua telefone/e-mail de liderança; não reconstrua a carreira pré-2011
+ fora do acervo (é lacuna).
+- Não edite os scripts nem o layout para "caber".
diff --git a/.opencode/commands/dossie-solla-instituicao.md b/.opencode/commands/dossie-solla-instituicao.md
new file mode 100644
index 00000000..faceea1c
--- /dev/null
+++ b/.opencode/commands/dossie-solla-instituicao.md
@@ -0,0 +1,9 @@
+---
+description: Gera o dossiê Solla por instituição (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para uma instituição ou um lote separado por vírgula
+---
+
+Carregue a skill `dossie-solla-instituicao` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos no catálogo de instituições, dispara um researcher por era (A/B/C) por instituição, roda a extração read-only do acervo (por tema→instituição) e o build local (dossiê + boletim) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-instituicao/SKILL.md` — não a transcreva nem recrie o fluxo.
+
+Uso: `/dossie-solla-instituicao [, ...]` — ex.: `/dossie-solla-instituicao UFBA, Correios`. Uma instituição = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora do catálogo falha fechado; use o escape one-off `--slug= --name=""`.
+
+$ARGUMENTS
diff --git a/docs/changelog/2026-09-17-c187.md b/docs/changelog/2026-09-17-c187.md
new file mode 100644
index 00000000..f8ee7687
--- /dev/null
+++ b/docs/changelog/2026-09-17-c187.md
@@ -0,0 +1 @@
+**C187 — Dossiê Solla por instituição + Boletim modelo (2026-09-17):** nova skill `/dossie-solla-instituicao` (command + subagente researcher por era A/B/C), irmã da C186, que reúne com fonte e data tudo o que Jorge Solla fez por/na/com uma **instituição** (universidade, empresa pública, autarquia, órgão, conselho/entidade de classe, categoria profissional, movimento, rede) e entrega o **dossiê** (PDF A4 + companion `.md`: capa com identificação por tipo/esfera/alcance, resumo com linha do tempo do vínculo, seções por era com tabela Objeto/Valor/Ano/Fase/**Abrangência**/Fonte, painel `instituição × setor × rede` sem total combinado, títulos/honrarias, lacunas, fontes, defeso — era sem evidência vira **página de lacuna explícita**, nunca "zero") e o **Boletim modelo** (1 página A4, linguagem de eleitor, ≤6 destaques + ≤14 "E mais", fase junto ao valor, sem fontes, sem CTA). Arquitetura: **edita o dono, não gema um irmão** — o descritor `scripts/lib/dossieUnit.mjs` (`municipality|institution`) generaliza **aditivamente** o pipeline C186 (`dossieResearch`/`dossieBlocks`/`dossieRender`/`dossieBulletin`/`dossieBulletinRender` seguem com default município; specs C186 verdes) e o emit/fit-guard virou dono único em `scripts/lib/buildPdf.mjs` (C186 migrado). Catálogo novo e **fail-closed** (`src/lib/institutionCatalog.ts` + `institutionNameAliases.ts`; seed UFBA/Correios/Enfermagem; token desconhecido → mensagem acionável + escape one-off `--slug/--name`; dedup; falha isolada no lote). Extração read-only nova (`scripts/institutionSnapshot.mjs` + `extract-institution-snapshot.mjs`, recorte **tema→instituição** — o acervo cobre 2011+ e não tem campo instituição), reusando `scripts/lib/readOnlyExtract.mjs` (dono da receita fail-closed, compartilhado com o extrator C163). O builder **não** busca emendas/Câmara/IBGE (o Portal não filtra por instituição; emenda institucional sem atribuição vira lacuna). O contrato sem-truncamento do C188 vale no dono (lista capada declara "e mais N"). Sem schema, migration, escrita no DB, Consent ou URL pública — artefatos gitignored (repo público). Novos specs: `institutionCatalog`, `dossieInstitutionResearch`, `dossieInstitution`, `dossieInstitutionBatchSkill`, `readOnlyExtract`, `buildPdf` (+ int `institutionSnapshot`; `opencodeCommands` e `test-affected-core` atualizados). `pnpm gate:fast` verde (353 arquivos / 3658 testes) + int 92/824, `knip`/`cycles` limpos, smoke local dos PDFs (UFBA 9 págs + boletim 1 pág; Enfermagem esparsa com era vazia). Issue: #1135.
diff --git a/docs/plans/dossie-solla-instituicao-impl.md b/docs/plans/dossie-solla-instituicao-impl.md
new file mode 100644
index 00000000..861715b1
--- /dev/null
+++ b/docs/plans/dossie-solla-instituicao-impl.md
@@ -0,0 +1,178 @@
+# Impl: Dossiê Solla por instituição + Boletim modelo (skill → dossiê + boletim)
+
+Status: aprovado
+Atualizado em: 2026-09-17
+Issue: #1135
+Intenção: docs/plans/dossie-solla-instituicao.md
+Appetite restante: ~3–4 dias eng (herdado)
+
+> Aprovado no modo `--auto` (`/work-issue --issue 1135 --auto`): a pausa do GATE foi convertida em apresentação-no-chat. Literais e cortes de escopo do plano de intenção foram assumidos; nada de schema/Consent/URL público entra aqui.
+
+## Leitura da intenção (Outcome / o que NÃO negociar / o que reavaliar)
+
+- **Outcome:** `/dossie-solla-instituicao ` entrega, numa invocação, um par **PDF A4 datado + companion `.md`** (identificação por `kind`/`sphere`/`scope`; seções por era A/B/C; tabelas **Objeto/Valor/Ano/Fase/Abrangência/Fonte**; painel de abrangência `instituição × setor × rede`; lacunas; fontes; defeso) **e** o **Boletim modelo** (1 página A4, linguagem de eleitor, ≤6 destaques + ≤14 "E mais", sem fontes, sem CTA, rótulo "Modelo — insumo interno" + defeso, `NEEDS ASSET`). Lote de 1+N; falha isolada não cancela o lote.
+- **O que NÃO negociar:** catálogo **fail-closed** (token desconhecido → mensagem acionável + escape `--slug= --name=""`, nunca slug inventado) e dedup após resolução; eras A/B/C **reusadas** do C186 (não criar eras novas); abrangência `instituicao | setor | rede` **nunca somada**; fases `autorizado|empenhado|liquidado|pago|restos` (default `nao_informado`, empenho ≠ pago); "sem fonte, não publica" + lacuna explícita; PII mínima; leitura relativa (nunca % estadual absoluto); artefato **gitignored** (repo público); **sem schema/migration/DB write/segundo cadastro**; **não alterar** `/dossie-solla-cidade` nem `/relatorio-cidade` (só generalização aditiva do dono, com as specs C186/C163 verdes); **sem truncamento mecânico/descarte silencioso** (reformular no `summary` ou declarar "e mais N" — regra C188, aplicada no dono).
+- **O que reavaliar (hipóteses da "Direção no codebase"):**
+ - "reusar o extrator C186/C163 sem mudanças" — falso: `--municipality=`, `resolveAccessibleMunicipalityContext` e `loadSpeeches(municipalities:[id])` prendem o snapshot a município. O recorte institucional é **tema→instituição**; exige um composer novo que reusa a plumbing read-only, não o composer.
+ - "reusar emendas/Câmara/saúde do C186 em tempo de build" — falso: o Portal **não filtra por instituição** e `fetchAuthorEmendas` exige `municipalityCode/Name`; a Câmara não filtra por instituição. Em `build`, emenda/proposição institucional sem autoria/fonte → **lacuna**; os números vêm dos itens de pesquisa (com fase).
+ - "build reusa `build-dossie-solla-cidade.mjs`" — só a **técnica** (Chromium/`page.pdf`/fit-guard); a plumbing vira dono próprio (`buildPdf.mjs`) porque este é o 3º builder CLI (gatilho já registrado na review do C186).
+ - **Design (fonte de verdade do port — portar classe a classe, sem redesenhar):** dossiê `docs/plans/dossie-solla-instituicao-ui-design.html`, **9 cenas**: 01 Capa; 02 Resumo; 03/04/05 Eras A/B/C; 06 Abrangência `instituição × setor × rede`; 07 Títulos/honrarias/vínculos; 08 Fontes/lacunas/defeso; 09 **empty-state** "era sem fonte". Boletim `docs/plans/dossie-solla-instituicao-boletim-ui-design.html`, **2 cenas** (composição completa + variação de poucos fatos). A estrutura visual é do `designer`; o implementer **porta classe a classe**.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ A["/dossie-solla-instituicao UFBA, Correios"] --> B["orquestrador: resolve token
isInstitutionSlug / resolveInstitutionName
fail-closed + escape --slug/--name + dedup"]
+ B --> C["N researchers por era (A|B|C × instituição)
paralelo, MAX_RESEARCHERS_IN_FLIGHT=6"]
+ C --> R["..research.json
+ recibo curto (status/paths/gaps)"]
+ B --> E["extração read-only, 1 instituição por vez
extract-institution-snapshot.mjs
composeInstitutionSnapshot (por tema)"]
+ E --> S[".institution.snapshot.json"]
+ R --> G["build-dossie-solla-instituicao.mjs"]
+ S --> G
+ G --> I["dossieBlocks(unit=INSTITUTION):
capa+resumo+eras+abrangência+fontes
→ bulletinFacts ledger (só fato com fonte)"]
+ I --> J["dossieRender → PDF A4 multi-página + .md
(buildPdf.mjs: fit-guard + emit)"]
+ I --> K["dossieBulletin(unit=INSTITUTION) → dossieBulletinRender"]
+ K --> L["PDF 1×A4 boletim modelo"]
+ J --> M["docs/research/dossie-solla-instituicao/--*.pdf/.md"]
+ L --> M
+```
+
+**Opções consideradas:** A) generalizar os libs do C186 in-place com `if` de unidade espalhado; B) novo conjunto de libs institucionais reusando só helpers puros; C) **hibrido**: seam explícito de unidade (`dossieUnit.mjs`) + generalização **aditiva** do dono (default município) + módulo fino de conteúdo institucional.
+**Recomendação:** **C** — mantém **um dono** do pipeline (research/render/ledger/PDF) e uma só cópia das mecânicas compartilhadas, ao mesmo tempo em que dá à instituição seu vocabulário (checklist, abrangência 3-listas, copy) sem forçar a seção `região/polo` do C186 a virar uma união estruturalmente estranha.
+**Rejeitadas:** **B** — twin do normalizador de pesquisa, do shell de render e do ledger do boletim (exatamente a drift que o repo proíbe); **A** — a seção de abrangência (3 listas) e a de região/polo (2 listas + "não some") são **estruturalmente** diferentes; um `if` por seção em ~5 libs deixa o C186 mais frágil e o diff maior.
+
+## Componentes / mudanças
+
+**Skill / command / subagente**
+
+- **`.agents/skills/dossie-solla-instituicao/SKILL.md`** (novo) — fonte canônica; seções `Lote`, `Catálogo de instituições` (fail-closed/escape/dedup), `Pipeline (etapas)`, `Briefing por era (A/B/C)`, `Recibo do researcher`, `Contrato dos JSONs` (`institutionSlug`, `sphere: instituicao|setor|rede`), `Conteúdo` (dossiê + boletim), `Guardrails`, `Troubleshooting`, `Referências` (aponta os dois HTMLs hi-fi).
+- **`.opencode/commands/dossie-solla-instituicao.md`** (novo) — frontmatter só `description:`; corpo carga a skill pelo **nome exato** e termina em `$ARGUMENTS`.
+- **`.opencode/agent/dossie-solla-instituicao.md`** (novo) — `description:` + `mode: subagent`; researcher **por era**; escreve `data/dossie-solla-instituicao/..research.json`; devolve só o recibo; proibido `ssh`/`scp`/build/`--snapshot=`/commitar.
+
+**Catálogo (novo, TS em `src/lib` pelo precedente `municipalityCatalog`)**
+
+- **`src/lib/institutionCatalog.ts`** — `InstitutionKind` (`universidade|instituto_federal|empresa_publica|autarquia|orgao_publico|conselho_classe|entidade_classe|categoria_profissional|movimento|rede|outro`), `InstitutionSphere` (`federal|estadual|municipal|nao_governamental`), `InstitutionScope` (`nacional|BA`), `InstitutionCatalogEntry { slug, name, aliases, kind, sphere, scope, topics }`, `institutionCatalog` (seed **ufba** universidade/federal/BA; **correios** empresa_publica/federal/nacional; **enfermagem** categoria_profissional/federal/nacional com âncoras ABEn-BA/COFEN/Coren-BA), `getInstitutionCatalogEntry`, `isInstitutionSlug`, `institutionCatalogEntriesForName`.
+- **`src/lib/institutionNameAliases.ts`** — `INSTITUTION_NAME_ALIASES`, `resolveInstitutionName` (fold `normalizeSearchPhrase`, espelhando `municipalityNameAliases.ts`).
+
+**Seam + libs generalizados (aditivos; C186 verde)**
+
+- **`scripts/lib/dossieUnit.mjs`** (novo) — `MUNICIPALITY_UNIT`, `INSTITUTION_UNIT`, `DOSSIER_UNITS`; campos `id`, `slugField` (`municipalitySlug`/`institutionSlug`), `sphereValues`+`defaultSphere`+`sphereLabels`+`sphereBadgeClass`, `breadthRank`, `researchDir`, `outDir`, `title`, `coverCopy`, `limits`, `checklist`, `identity(snapshot)`. Checklist institucional: A `era_a_formacao|sesab|consultor_ms|conquista|sas_ms|vinculo_institucional`; B `era_b_sesab|equipamentos|programas|obras|convenios`; C `era_c_discursos|proposicoes|emendas|titulos|atuacao|parcerias`.
+- **`scripts/lib/dossieResearch.mjs`** (editar) — `normalizeDossierResearchInput(raw, { unit = MUNICIPALITY_UNIT })`, `mergeDossierResearch(researches, { unit })`, `dossierResearchReceipt(research, { unit })`, `dossierChecklistForEra(era, unit)`. Sem opções → comportamento C186 idêntico (mensagens `/municipalitySlug/`, `/researchedAt/`, `/era/` preservadas).
+- **`scripts/lib/dossieBlocks.mjs`** (editar) — `buildDossierReport({ snapshot, research, emendas=null, camara=null, health=null, generatedAt, unit = MUNICIPALITY_UNIT })`; mecânicas compartilhadas (number rows, `bulletinFacts`, limites, `phaseLabel`) ficam; o shape de seção ramifica em `regiao` (município) vs `abrangencia` (3 listas `instituicao/setor/rede`, sem total combinado); adiciona contagem `omitted` por lista capada.
+- **`scripts/lib/dossieRender.mjs`** (editar) — `renderDossierHtml/Md(report)` lê `report.unit`; shell/`document-table`/`asset-box` compartilhados; classes novas `identity-badge`, `scope-sector`, `scope-network`, `phase-paid`, `phase-pending`, `timeline-item`, `empty-panel`; `` da capa e `data-page` dirigidos pelo descritor.
+- **`scripts/lib/dossieBulletin.mjs`** (editar) — `buildBulletin({ facts, municipality, region, identity = null, unit = MUNICIPALITY_UNIT, generatedAt })`; ranking por `unit.breadthRank` (`instituicao` antes de `setor`/`rede`); tetos 6/14 preservados.
+- **`scripts/lib/dossieBulletinRender.mjs`** (editar) — `renderBulletinHtml(bulletin)` + `identity-pill`, `defeso-band`, `lacuna-panel`; reusa `highlight-card/-number/-title/-note/-eyebrow`, `more-item`, `timeline-step`, `model-label`, `asset-box`; copy de `unit.bulletinCopy`.
+- **`scripts/lib/buildPdf.mjs`** (novo) — `A4_PAGE_BUDGET_PX`, `launchPdfBrowser`, `assertPageFits(page, { selector, budgetPx, label })`, `printPdf(page, outPath)`. Consumido por `build-dossie-solla-cidade.mjs` (editado p/ importar) e pelo builder novo. C163 intocado.
+
+**Extração read-only + builder**
+
+- **`scripts/lib/readOnlyExtract.mjs`** (novo) — `resolveCampaignActor(payload)` + `enterReadOnlySession()` (receita fail-closed do ator real + sessão read-only); consumido pelo extrator C163 (editar, sem mudança de comportamento) e pelo novo.
+- **`scripts/institutionSnapshot.mjs`** (novo) — `composeInstitutionSnapshot({ payload, actor, slug, readAt, codeSha, database })` → `{ meta, institution:{ slug,name,kind,sphere,scope,aliases,topics }, speeches:{ topics, totalCount, rows }, gaps }`; reusa `buildSpeechListWhere` + `loadSegmentsForSpeeches` + `overrideAccess:false`, recorte por `topics` do catálogo (mapa tema→instituição).
+- **`scripts/extract-institution-snapshot.mjs`** (novo) — CLI `--institution= --out=`; `INSTITUTION_REPORT_CONFIRM=1`; sessão read-only; serializado 1 instituição por vez.
+- **`scripts/build-dossie-solla-instituicao.mjs`** (novo) — CLI `--snapshot= --research-dir= --out-dir= [--generated-at=]`; exige `snapshot.institution.slug`; lê `.{a,b,c}.research.json` (recusa slug divergente); **não** chama `fetchAuthorEmendas`/`fetchCamaraActivity`/`fetchHealthData`; `buildDossierReport({ ..., unit: INSTITUTION_UNIT })` → dossiê + `buildBulletin({ facts, identity, unit })`; HTML cache + `.md` + dois PDFs (guardas `[data-page="resumo"]` e `[data-page="boletim"]`); `DOSSIER_STRICT=1` falha em lacuna.
+
+**Testes / CI / docs**
+
+- Novos: `tests/unit/institutionCatalog.unit.spec.ts`, `dossieInstitutionResearch.unit.spec.ts`, `dossieInstitutionBlocks.unit.spec.ts`, `dossieInstitutionRender.unit.spec.ts`, `dossieInstitutionBulletin.unit.spec.ts`, `dossieInstitutionBatchSkill.unit.spec.ts`, `tests/int/institutionSnapshot.int.spec.ts`.
+- Editar: `tests/unit/opencodeCommands.unit.spec.ts` (incluir `'dossie-solla-instituicao'`); `scripts/lib/test-affected-core.mjs` (`SCRIPTS_SPEC_PINNED` += `dossieUnit.mjs`, `buildPdf.mjs`, `readOnlyExtract.mjs`, `institutionSnapshot.mjs`; `ciSkipInvariants` recalcula o closure e falha até casar); `.gitignore` (`/data/dossie-solla-instituicao/`, `/docs/research/dossie-solla-instituicao/`); `docs/changelog/2026-09-17-c187.md`.
+- **Manter verdes:** specs C186 (`dossie*`) e C163 (`cityReport*`), provando os defaults aditivos.
+
+### Dados → forma (pergunta 3 — data-presentation)
+
+- **Dossiê — forma escolhida:** **tabelas/listas com fonte por linha** + badge de abrangência + badge de fase (tabela Objeto/Valor/Ano/Fase/Abrangência/Fonte; painel de abrangência com **3 listas e nenhum total combinado**; timeline de vínculo na página 1 + timeline de carreira reusada em página própria; tabela de lacunas). É a forma mais pobre que ainda desbloqueia a decisão (escolher ângulo/entrega e saber o que falta), com a abrangência visível em cada linha. **Rejeitadas:** dashboard/KPIs agregados (perdem a fonte item a item e convidam a somar); gráfico de série (não há série comparável entre eras/entes); mapa (sem geografia no escopo); % estadual absoluto (anti-goal).
+- **Boletim — forma escolhida:** **número destacado + rótulo curto** (≤6 `.highlight-card`) + **lista densa ✓** (≤14 "E mais") + timeline de 4 passos; densidade > destaque grande. **Rejeitadas:** tabela/planilha (documento de gestor, não de eleitor); parágrafo longo (estoura 1 página); fontes no boletim (vivem só no dossiê); CTA de campanha (defeso).
+
+## Decisões de engenharia (A–H)
+
+**A. Seam de unidade.** Opções: A) `if` de unidade espalhado nos libs do C186; B) libs institucionais novas; C) descritor `dossieUnit.mjs` + generalização aditiva + módulo fino de conteúdo. **Recomendação: C** — um só dono por concern, C186 verde por default, vocabulário institucional isolado. **Rejeitadas:** B (twin do normalizador/render/ledger); A (união estrutural região×abrangência em 5 libs).
+
+**B. Local do catálogo.** Opções: A) `src/lib/institutionCatalog.ts` + `src/lib/institutionNameAliases.ts`; B) `scripts/lib/*.mjs`; C) coleção Payload `Institution` + migration/seed. **Recomendação: A** — segue o precedente (`municipalityCatalog`/`municipalityNameAliases`), é importável pelo tsx dos scripts e testável nos specs TS. **Rejeitadas:** B (segundo lugar/idioma para o mesmo conceito); C (DB/migration/segundo cadastro para insumo interno sem URL pública — appetite e invariante).
+
+**C. Extração read-only do acervo.** Opções: A) modo instituição em `composeCityReportSnapshot`/`extract-city-report-snapshot.mjs`; B) `institutionSnapshot.mjs` + `extract-institution-snapshot.mjs` reusando a plumbing read-only e os loaders de fala com recorte por tema; C) sem extração DB. **Recomendação: B** — o acervo + mapa tema→instituição é fonte declarada do produto, e o recorte não é município. **Rejeitadas:** A (reabre o contrato do snapshot C163 pinado no int spec); C (descarta fonte-chave e contraria a intenção).
+
+**D. Fontes oficiais em tempo de build.** Opções: A) reusar `fetchAuthorEmendas`/`fetchCamaraActivity`/`fetchHealthData`; B) não buscar em build — emendas/proposições institucionais vêm dos itens de pesquisa com fonte; C) filtrar Câmara por nome da instituição na ementa. **Recomendação: B** — o Portal não filtra por instituição, a Câmara tampouco, e `fetchAuthorEmendas` exige município (imprimiria emenda municipal em dossiê institucional). "Sem atribuição → lacuna, nunca zero silencioso". **Rejeitadas:** A (atribuição errada); C (heurística de match nominal = falso positivo/omissão).
+
+**E. Builders e plumbing de PDF.** Opções: A) builder único genérico com `--unit=`; B) `build-dossie-solla-instituicao.mjs` novo + extrair `buildPdf.mjs` (fit-guard + emit) e migrar o C186; C) copiar a plumbing do builder C186. **Recomendação: B** — este é o 3º builder CLI, exatamente o gatilho registrado na review do C186; um dono do "emitir A4 com guarda". C163 fica intocado (fora do escopo deste item). **Rejeitadas:** A (flag gêmea, duas identidades/saídas num entry só); C (twin da plumbing Chromium/orçamento/fit-guard).
+
+**F. Herança de fatos pelo boletim.** Opções: A) derivar de `bulletinFacts` (ledger de fato com fonte) na mesma execução; B) segunda pesquisa. **Recomendação: A** — garantia estrutural: `buildBulletin` institucional recebe apenas o ledger; o renderer não vê research/snapshot crus. **Rejeitadas:** B (reintroduz síntese sem lastro).
+
+**G. Sem truncamento/descarte silencioso (C188 no dono).** Opções: A) declarar "e mais N" no bloco compartilhado quando um cap corta, e a reformulação fica no `summary`; B) paginador de conteúdo completo além das lacunas; C) truncar. **Recomendação: A** — a lista capada é seleção de página, e o resto é **declarado** (`omitted`), nunca sumido; aplica-se ao dono (ambos os recortes), não só à instituição. **Rejeitadas:** B (fora do appetite; C188 dono da regra); C (viola o aceite).
+
+**H. Campo do item para abrangência.** Opções: A) manter `sphere` genérico com enum da unidade (`municipio|regiao|polo` × `instituicao|setor|rede`); B) novo campo `breadth` só para instituição. **Recomendação: A** — um campo genérico, C186 (`item.sphere === 'municipio'`, pinado) verde; o descritor fornece enum/labels/classe. Ressalva documentada: o `sphere`/`scope` do **catálogo** (esfera/alcance do ente) é outro objeto e não deve ser confundido com `sphere` do **item** (abrangência). **Rejeitadas:** B (dois nomes para o mesmo conceito; ramificação no render/blocks).
+
+## Fases verificáveis (tracer bullet first)
+
+1. **Tracer (seam + contrato + conteúdo/render, sem extractor) — quota de meio dia.** `dossieUnit.mjs`; generalização aditiva de `dossieResearch`/`dossieBlocks`/`dossieRender`; módulo institucional com capa + resumo + Era C + abrangência + fontes; `buildPdf.mjs` extraído e C186 migrado; specs C186 verdes e `dossieInstitution{Research,Blocks,Render}` verdes. Prova o encadeamento mais arriscado cedo.
+2. **Catálogo + pipeline agêntico.** `institutionCatalog.ts`/`institutionNameAliases.ts` + spec; `SKILL.md` + command + subagente; `dossieInstitutionBatchSkill.unit.spec.ts` (lote/resolução/fail-closed/escape/dedup/fan-out/guarda); `opencodeCommands.unit.spec.ts` atualizado; `test-affected-core.mjs` atualizado.
+3. **Extração + builder.** `readOnlyExtract.mjs` (+ edição aditiva do extrator C163), `institutionSnapshot.mjs`, `extract-institution-snapshot.mjs`, `build-dossie-solla-instituicao.mjs`; `tests/int/institutionSnapshot.int.spec.ts`; decisão D exercida (sem emendas/Câmara/saúde em build).
+4. **Boletim + port visual + docs.** `buildBulletin`/`dossieBulletinRender` institucionais (completo + variação de poucos fatos); port classe a classe das 9 cenas do dossiê e das 2 do boletim; guardas de fit (`[data-page="resumo"]`, `[data-page="boletim"]`); `.gitignore`; changelog.
+5. **Gates.** `pnpm gate:fast` (lint 0 warnings, `tsc --noEmit`, unit) → build local de **UFBA** e de uma instituição esparsa (Enfermagem) conferindo PDF/MD/boletim (aceite manual) → `pnpm push` (PR CI roda o resto). Sem e2e (não há runtime no app); `tests/int` só o snapshot novo.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Forkar o pipeline institucional (research/render/ledger/PDF) — twin proibido; tudo passa pelo dono + descritor.
+- Estender o snapshot C163/`assertSnapshotShape` ou editar `build-city-report.mjs` (só a extração aditiva de `readOnlyExtract` no extrator, sem mudança de comportamento).
+- Puxar emendas por município / Câmara / IBGE–DATASUS para instituição (decisão D); "completar" com % estadual absoluto; somar `setor`/`rede` à instituição.
+- Coleção Payload/migration/segundo cadastro; UI in-app no `/campanha`; índice/PDF agregado de lote.
+- Reconstruir a carreira pré-2011 fora do acervo — vira **lacuna**, não invenção.
+- Reabrir o design: os hi-fi estão aprovados — só port; se faltar superfície, aciona-se o `designer` (não se redesenha na implementação).
+- `--unit` num builder único; migrar o builder C163 para `buildPdf.mjs` agora (fora do escopo).
+
+## Riscos e mitigação
+
+- **Regressão C186/C163 pela generalização:** defaults preservam assinaturas/erros; specs `dossie*`/`cityReport*` no `gate:fast`; build local do C186 conferido após migrar `buildPdf.mjs`.
+- **`SCRIPTS_SPEC_PINNED` fora de sincronia:** `ciSkipInvariants` recalcula o closure e falha fechado; atualizar o mapa na mesma fase que cria o módulo.
+- **Fit/overflow de página:** orçamentos explícitos e caps; se estourar, **aperta o teto/reformula copy — nunca espreme o layout**; "e mais N" onde cortar.
+- **Recorte por tema impreciso (acervo):** `topics` por entrada do catálogo (revisável), cap por tema + "e mais N", e o SKILL declara o limite (acervo cobre 2011+).
+- **Autoria de emenda ambígua:** sem atribuição/fonte → lacuna; nunca imprimir emenda municipal como institucional.
+- **Fan-out/gitignore/PII:** `MAX_RESEARCHERS_IN_FLIGHT = 6` + extração serializada; artefatos gitignored; subagente proibido de commitar; telefone/e-mail nunca entram.
+- **Appetite:** cortes já embutidos — sem APIs oficiais em build (D), sem health, sem agregado de lote, C163 intocado.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção coberto: dossiê PDF A4 + `.md` (identificação, eras A/B/C, abrangência 3-listas, títulos/vínculos, lacunas, fontes, defeso) **e** boletim modelo 1×A4 (≤6 destaques + ≤14 "E mais", sem fontes, sem CTA, rótulo "Modelo — insumo interno" + defeso, `NEEDS ASSET`).
+- [ ] Catálogo fail-closed acionável + escape `--slug/--name` + dedup; eras A/B/C reusadas; abrangência `instituicao|setor|rede` **nunca somada**; fases por valor (empenho ≠ pago); "sem fonte, não publica"; PII mínima; artefato gitignored.
+- [ ] Boletim herda **apenas** `bulletinFacts` (fato com fonte do dossiê); sem linha de fontes; sem CTA.
+- [ ] Sem truncamento mecânico/descarte silencioso: lista capada declara "e mais N"; reformulação no `summary`.
+- [ ] Generalização **aditiva** do dono; specs C186 e C163 verdes; `/dossie-solla-cidade` e `/relatorio-cidade` sem mudança de comportamento; `buildPdf.mjs` é dono único do emit A4 (C186 migrado).
+- [ ] `test-affected-core.mjs` sincronizado (closure exato) e `opencodeCommands.unit.spec.ts` com `dossie-solla-instituicao`; command/subagente acoplados pelo nome exato.
+- [ ] Unit novos verdes: `institutionCatalog`, `dossieInstitutionResearch`, `dossieInstitutionBlocks`, `dossieInstitutionRender`, `dossieInstitutionBulletin`, `dossieInstitutionBatchSkill`; int `institutionSnapshot`.
+- [ ] `.gitignore` + changelog `docs/changelog/2026-09-17-c187.md`; `pnpm gate:fast` verde; build local aceito em UFBA + Enfermagem; `pnpm push`.
+
+---
+
+## Self-score decision-quality (gate ≥4/5)
+
+1. **Decisões caras têm rejeitadas?** Sim — A–H com Opções/Recomendação/Rejeitadas explícitas (seam, catálogo, extração, APIs, builders/PDF, herança, truncamento, campo de abrangência). — **5/5**
+2. **Abordagem cabe no appetite (3–4 dias)?** Sim — reuso máximo do dono C186, sem schema/DB, e cortes declarados (sem emendas/Câmara/IBGE em build, sem agregado, C163 intocado). — **4/5** (a generalização aditiva de 5 libs + o novo composer é o trecho que mais consome quota; por isso o tracer cedo e o corte de APIs).
+3. **Rabbit holes nomeados?** Sim — fork do pipeline, extensão do snapshot C163, emenda municipal em dossiê institucional, match nominal da Câmara, coleção Payload, UI in-app, agregado, pré-2011 inventado, redesign. — **5/5**
+4. **Depth check: reusa shells/helpers existentes?** Sim — `dossieCareer`, `dossieResearch/Blocks/Render/Bulletin/Render`, `cityReportFormat`, `reportText`, `cli`, `cityReportDatabase`/`buildSpeechListWhere`/`loadSegmentsForSpeeches`, `normalizeSearchPhrase`, e extrai `buildPdf.mjs`/`readOnlyExtract.mjs` onde há ≥2 consumidores. — **4/5** (a generalização toca módulos em uso; mitigada por defaults + specs).
+5. **Intenção (aceite de produto) permanece satisfeita?** Sim — os dois artefatos, o catálogo fail-closed, os literais (1 página, ≤6/≤14, sem fontes, rótulo de modelo, abrangência não somável) e a regra C188 estão no aceite; a engenharia não reescreveu o outcome. — **5/5**
+
+**Total 23/25 (média 4,6/5) — passa o gate ≥4/5.**
+
+## Simplificação e débitos (Passo 5–6 do `work-issue`)
+
+**Já resolvido no simplify (não reabrir):** bug de string `"iten"` no `moreNote`
+(mapa de singular + testes de era vazia e `omitted > 0`); `abrangencia` → `reach`
+(valor `data-page="abrangencia"` mantido); campos mortos do descritor removidos;
+`resolveDossierUnit` fail-closed; exports órfãos desexportados e
+`emitHtmlPairPdf` como dono único do emit (C186 migrado); `speeches.rows`
+consumido como evidência com fonte (`acervo` no dossiê + ledger do boletim);
+`isInstitutionUnit` centralizando o dispatch; caps em constantes nomeadas.
+
+**Adiado com gatilho (não é Issue — DRY/polish em `scripts/**`, 2 call sites):\*\*
+
+- Boilerplate gêmeo dos dois builders (`readJson`, `readEraResearch`, mkdir/write,
+ `DOSSIER_STRICT`) — gatilho: 4º builder CLI ou edição simultânea; a parte
+ valiosa (emit/fit-guard) já saiu para `buildPdf.mjs`.
+- Mecânica copiada no branch institucional de `dossieBlocks.mjs`
+ (`researchActionsByEra`, comparador, `bulletinFacts`, `gaps`/`pending`) —
+ gatilho: 3º unit/recorte ou próxima edição dessas mecânicas.
+- `instHeader`/`instFooter` × `sheetHeader`/`sheetFooter`; fase→classe CSS em dois
+ renderers; typedef `DossierReport` frouxo (`checkJs` off); specs institucionais
+ ~90% cópia do C186; guarda de PII do int spec por `not.toContain('@')`.
+
+**Explicitamente fora (descartado):** renomear `sphere` do item para `breadth`
+(opção B rejeitada na decisão H; documentado no `dossieUnit.mjs`); simetria
+`unit` no report municipal; `meta.municipality`→`subjectName` no boletim.
diff --git a/scripts/build-dossie-solla-cidade.mjs b/scripts/build-dossie-solla-cidade.mjs
index 3bfe3917..8b8cb349 100644
--- a/scripts/build-dossie-solla-cidade.mjs
+++ b/scripts/build-dossie-solla-cidade.mjs
@@ -13,12 +13,12 @@
* --out-dir=docs/research/dossie-solla-cidade
*/
-import { chromium } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
+import { emitHtmlPairPdf, launchPdfBrowser } from './lib/buildPdf.mjs'
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import { buildDossierReport } from './lib/dossieBlocks.mjs'
import { buildBulletin } from './lib/dossieBulletin.mjs'
@@ -38,13 +38,6 @@ const CACHE_DIR = 'data/dossie-solla-cidade'
/** The whole federal mandate, not just the C163 default window. */
const DOSSIER_EMENDAS_YEARS = Array.from({ length: 12 }, (_value, index) => 2015 + index)
-const MM_TO_PX = 96 / 25.4
-const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX)
-const A4_WIDTH_PX = Math.round(210 * MM_TO_PX)
-/** Rounding slack of the mm→px conversion; content must still fit the page. */
-const PAGE_FIT_TOLERANCE_PX = 10
-const PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
-
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
@@ -193,66 +186,16 @@ const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`)
await writeFile(resolve(ROOT, dossierMdFile), dossierMd)
console.log(`[${LABEL}] companion → ${dossierMdFile}`)
-const measureOverflows = async (page, budget) =>
- page.evaluate(
- (max) =>
- [...document.querySelectorAll('[data-page]')]
- .map((element) => ({
- page: element.getAttribute('data-page'),
- height: element.scrollHeight,
- }))
- .filter((row) => row.height > max),
- budget,
- )
-
-const browser = await chromium.launch()
+const browser = await launchPdfBrowser()
try {
- const page = await browser.newPage()
- await page.setViewportSize({ width: A4_WIDTH_PX, height: A4_HEIGHT_PX })
-
- await page.setContent(dossierHtml, { waitUntil: 'load' })
- await page.emulateMedia({ media: 'print' })
- const hasSummary = await page.evaluate(() =>
- Boolean(document.querySelector('[data-page="resumo"]')),
- )
- if (!hasSummary) die('Página de resumo ausente no HTML do dossiê — renderer quebrado.')
- const dossierOverflows = await measureOverflows(page, PAGE_BUDGET_PX)
- if (dossierOverflows.length > 0) {
- die(
- `Página(s) do dossiê estouraram o A4 (${dossierOverflows
- .map((row) => `${row.page}: ${row.height}px`)
- .join(', ')} > ${PAGE_BUDGET_PX}px úteis). ` +
- 'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
- )
- }
- await page.pdf({
- path: resolve(ROOT, dossierPdfFile),
- printBackground: true,
- preferCSSPageSize: true,
- margin: { top: '0', bottom: '0', left: '0', right: '0' },
- })
-
- await page.setContent(bulletinHtml, { waitUntil: 'load' })
- await page.emulateMedia({ media: 'print' })
- const hasBulletin = await page.evaluate(() =>
- Boolean(document.querySelector('[data-page="boletim"]')),
- )
- if (!hasBulletin) die('Bloco do boletim ausente no HTML — renderer quebrado.')
- const bulletinOverflows = await measureOverflows(page, PAGE_BUDGET_PX)
- if (bulletinOverflows.length > 0) {
- die(
- `O boletim estourou (${bulletinOverflows
- .map((row) => `${row.height}px`)
- .join(', ')} > ${PAGE_BUDGET_PX}px úteis). ` +
- 'Corte itens/caps — o boletim TEM de caber em uma página.',
- )
- }
- await page.pdf({
- path: resolve(ROOT, bulletinPdfFile),
- printBackground: true,
- preferCSSPageSize: true,
- margin: { top: '0', bottom: '0', left: '0', right: '0' },
+ await emitHtmlPairPdf(browser, {
+ dossierHtml,
+ bulletinHtml,
+ dossierPdf: resolve(ROOT, dossierPdfFile),
+ bulletinPdf: resolve(ROOT, bulletinPdfFile),
})
+} catch (error) {
+ die(error instanceof Error ? error.message : String(error))
} finally {
await browser.close()
}
diff --git a/scripts/build-dossie-solla-instituicao.mjs b/scripts/build-dossie-solla-instituicao.mjs
new file mode 100644
index 00000000..8b92b781
--- /dev/null
+++ b/scripts/build-dossie-solla-instituicao.mjs
@@ -0,0 +1,171 @@
+/**
+ * Dossiê + boletim builder (C187, institution unit): institution snapshot +
+ * per-era research → dossiê PDF A4 + `.md` and the one-page boletim PDF.
+ *
+ * Runs on the workstation: no database access, no persistence. Official
+ * sources are NOT fetched here — the Transparency Portal does not filter by
+ * institution and the Câmara does not either, so institutional emendas and
+ * propositions come from the per-era research items (with phase + source);
+ * anything unattributed becomes an explicit gap, never a silent zero.
+ *
+ * Usage:
+ * NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-instituicao.mjs \
+ * --snapshot=data/dossie-solla-instituicao/.institution.snapshot.json \
+ * --research-dir=data/dossie-solla-instituicao \
+ * --out-dir=docs/research/dossie-solla-instituicao
+ */
+
+import { execFileSync } from 'node:child_process'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import { emitHtmlPairPdf, launchPdfBrowser } from './lib/buildPdf.mjs'
+import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
+import { buildDossierReport } from './lib/dossieBlocks.mjs'
+import { buildBulletin } from './lib/dossieBulletin.mjs'
+import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs'
+import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
+import { renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs'
+import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs'
+import { INSTITUTION_UNIT } from './lib/dossieUnit.mjs'
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
+const LABEL = 'build-dossie-solla-instituicao'
+const die = dieWithLabel(LABEL)
+const DEFAULT_OUT_DIR = 'docs/research/dossie-solla-instituicao'
+const CACHE_DIR = 'data/dossie-solla-instituicao'
+
+loadCliEnv()
+
+const { flags } = parseEqualsFlags(process.argv.slice(2))
+const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null
+const researchDir = typeof flags['research-dir'] === 'string' ? flags['research-dir'] : CACHE_DIR
+const outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : DEFAULT_OUT_DIR
+const generatedAt =
+ typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date()
+
+if (!snapshotPath) {
+ die('Uso: --snapshot= [--research-dir=] [--out-dir=] [--generated-at=]')
+}
+if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`)
+
+const readJson = async (relativePath, label) => {
+ try {
+ return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8'))
+ } catch (error) {
+ die(
+ `Falha ao ler ${label} (${relativePath}): ${error instanceof Error ? error.message : error}`,
+ )
+ }
+}
+
+const snapshot = await readJson(snapshotPath, 'snapshot')
+const slug = snapshot.institution?.slug
+if (!slug) die('Snapshot sem "institution.slug" — extração inválida.')
+
+const readEraResearch = async (era) => {
+ const path = join(researchDir, `${slug}.${era.toLowerCase()}.research.json`)
+ try {
+ const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
+ if (raw.institutionSlug !== slug) {
+ die(
+ `Pesquisa da Era ${era} é de "${raw.institutionSlug}", não de "${slug}" (${path}) — pare e regenere.`,
+ )
+ }
+ if (raw.era !== era) {
+ die(`Pesquisa em ${path} declara Era ${raw.era}, não Era ${era} — pare e regenere.`)
+ }
+ return normalizeDossierResearchInput(raw, { unit: INSTITUTION_UNIT })
+ } catch (error) {
+ if (error?.code === 'ENOENT') {
+ console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`)
+ return normalizeDossierResearchInput(
+ {
+ institutionSlug: slug,
+ era,
+ researchedAt: generatedAt.toISOString(),
+ items: [],
+ },
+ { unit: INSTITUTION_UNIT },
+ )
+ }
+ die(
+ `Pesquisa da Era ${era} inválida (${path}): ${error instanceof Error ? error.message : error}`,
+ )
+ }
+}
+
+const researches = []
+for (const era of DOSSIER_ERA_IDS) researches.push(await readEraResearch(era))
+const research = mergeDossierResearch(researches, { unit: INSTITUTION_UNIT })
+
+const currentCodeSha = (() => {
+ try {
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
+ } catch {
+ return null
+ }
+})()
+if (snapshot.meta?.codeSha && currentCodeSha && snapshot.meta.codeSha !== currentCodeSha) {
+ console.warn(
+ `[${LABEL}] AVISO: snapshot extraído no SHA ${snapshot.meta.codeSha}, builder no ${currentCodeSha} — regenere se o extrator mudou.`,
+ )
+}
+
+const report = buildDossierReport({
+ snapshot,
+ research,
+ generatedAt,
+ unit: INSTITUTION_UNIT,
+})
+const bulletin = buildBulletin({
+ facts: report.bulletinFacts,
+ identity: { name: report.meta.subjectName, badges: report.meta.identityBadges },
+ unit: INSTITUTION_UNIT,
+ generatedAt,
+})
+
+const dossierHtml = renderDossierHtml(report)
+const dossierMd = renderDossierMd(report)
+const bulletinHtml = renderBulletinHtml(bulletin)
+
+const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}`
+
+await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
+await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
+await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
+
+await mkdir(resolve(ROOT, outDir), { recursive: true })
+const dossierMdFile = join(outDir, `${baseName}-dossie.md`)
+const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`)
+const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`)
+await writeFile(resolve(ROOT, dossierMdFile), dossierMd)
+console.log(`[${LABEL}] companion → ${dossierMdFile}`)
+
+const browser = await launchPdfBrowser()
+try {
+ await emitHtmlPairPdf(browser, {
+ dossierHtml,
+ bulletinHtml,
+ dossierPdf: resolve(ROOT, dossierPdfFile),
+ bulletinPdf: resolve(ROOT, bulletinPdfFile),
+ })
+} catch (error) {
+ die(error instanceof Error ? error.message : String(error))
+} finally {
+ await browser.close()
+}
+
+console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
+console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
+console.log(
+ `[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
+ `highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} html_bytes=${Buffer.byteLength(dossierHtml)}`,
+)
+
+if (isTruthyEnv(process.env.DOSSIER_STRICT) && research.gaps.length > 0) {
+ die(
+ `DOSSIER_STRICT=1 e ${research.gaps.length} lacuna(s) de pesquisa — o dossiê saiu, mas o modo estrito falha.`,
+ )
+}
diff --git a/scripts/extract-city-report-snapshot.mjs b/scripts/extract-city-report-snapshot.mjs
index 7924f14b..f445c90a 100644
--- a/scripts/extract-city-report-snapshot.mjs
+++ b/scripts/extract-city-report-snapshot.mjs
@@ -15,18 +15,9 @@ import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
-import {
- databaseLabel,
- READ_ONLY_PG_OPTIONS,
- withReadOnlyDatabaseUrl,
-} from './lib/cityReportDatabase.mjs'
-import {
- databaseHostname,
- dieWithLabel,
- isTruthyEnv,
- loadCliEnv,
- parseEqualsFlags,
-} from './lib/cli.mjs'
+import { databaseLabel } from './lib/cityReportDatabase.mjs'
+import { dieWithLabel, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
+import { enterReadOnlySession, resolveCampaignActor } from './lib/readOnlyExtract.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'extract-city-report-snapshot'
@@ -40,24 +31,6 @@ const codeSha = () => {
}
}
-const resolveActor = async (payload) => {
- for (const role of ['candidate', 'coordinator']) {
- const result = await payload.find({
- collection: 'campaignUser',
- where: { role: { equals: role } },
- depth: 0,
- limit: 1,
- pagination: false,
- overrideAccess: true,
- })
- const doc = result.docs[0]
- if (doc) return { ...doc, collection: 'campaignUser' }
- }
- die(
- 'Nenhum campaignUser com papel candidate/coordinator encontrado — o relatório exige um ator real que enxergue tudo.',
- )
-}
-
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
@@ -70,18 +43,12 @@ if (!slug || !out) {
' Env: CITY_REPORT_CONFIRM=1 (obrigatório), DATABASE_URL (read-only é forçado), PORTAL da vida real.',
)
}
-if (!isTruthyEnv(process.env.CITY_REPORT_CONFIRM)) {
- die(
- 'Opt-in explícito ausente: rode com CITY_REPORT_CONFIRM=1.\n' +
- ` Alvo: ${databaseHostname(process.env.DATABASE_URL) ?? 'DATABASE_URL ausente'} — a leitura é read-only, mas o comando não adivinha o alvo.`,
- )
-}
-
-const rawDatabaseUrl = process.env.DATABASE_URL
-process.env.DATABASE_URL = withReadOnlyDatabaseUrl(rawDatabaseUrl, READ_ONLY_PG_OPTIONS)
-console.log(
- `[${LABEL}] alvo ${databaseLabel(rawDatabaseUrl)} · sessão read-only · município ${slug}`,
-)
+const rawDatabaseUrl = enterReadOnlySession({
+ confirmEnv: 'CITY_REPORT_CONFIRM',
+ label: LABEL,
+ die,
+ context: `município ${slug}`,
+})
const { default: configPromise } = await import('../src/payload.config.ts')
const { getPayload } = await import('payload')
@@ -90,7 +57,7 @@ const { composeCityReportSnapshot } = await import('./cityReportSnapshot.mjs')
const payload = await getPayload({ config: configPromise })
try {
- const actor = await resolveActor(payload)
+ const actor = await resolveCampaignActor(payload, { die })
console.log(`[${LABEL}] ator: ${actor.name ?? actor.id} (${actor.role})`)
const snapshot = await composeCityReportSnapshot({
diff --git a/scripts/extract-institution-snapshot.mjs b/scripts/extract-institution-snapshot.mjs
new file mode 100644
index 00000000..bd8211e2
--- /dev/null
+++ b/scripts/extract-institution-snapshot.mjs
@@ -0,0 +1,157 @@
+/**
+ * Snapshot extractor for the institutional dossiê (C187).
+ *
+ * Runs where production lives (homeserver), read-only by connection options,
+ * and writes a JSON snapshot consumed by
+ * `scripts/build-dossie-solla-instituicao.mjs`. Nothing is persisted.
+ *
+ * The token is resolved fail-closed against the catalog; a one-off institution
+ * uses the explicit escape `--slug= --name=""`.
+ *
+ * Usage (from the homeserver, with `~/stack/teqo-1313.env` loaded):
+ * INSTITUTION_REPORT_CONFIRM=1 NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
+ * node scripts/extract-institution-snapshot.mjs --institution=ufba --out=data/dossie-solla-instituicao/ufba.institution.snapshot.json
+ */
+
+import { execFileSync } from 'node:child_process'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import {
+ getInstitutionCatalogEntry,
+ INSTITUTION_KIND_LABELS,
+ INSTITUTION_KINDS,
+ INSTITUTION_SCOPE_LABELS,
+ INSTITUTION_SCOPES,
+ INSTITUTION_SPHERE_LABELS,
+ INSTITUTION_SPHERES,
+ institutionSlugs,
+} from '../src/lib/institutionCatalog.ts'
+import { databaseLabel } from './lib/cityReportDatabase.mjs'
+import { dieWithLabel, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
+import { enterReadOnlySession, resolveCampaignActor } from './lib/readOnlyExtract.mjs'
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url))
+const LABEL = 'extract-institution-snapshot'
+const die = dieWithLabel(LABEL)
+
+const codeSha = () => {
+ try {
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
+ } catch {
+ return null
+ }
+}
+
+const oneOf = (value, allowed, fallback) => {
+ if (value === undefined || value === null || value === '') return fallback
+ const normalized = String(value).trim()
+ return allowed.includes(normalized) ? normalized : fallback
+}
+
+/**
+ * Resolve the identity: catalog entry, or the explicit one-off escape. Unknown
+ * token fails closed with an actionable message — never an invented slug.
+ */
+const resolveIdentity = (flags) => {
+ if (typeof flags.institution === 'string' && flags.institution.trim()) {
+ const entry = getInstitutionCatalogEntry(flags.institution.trim())
+ if (!entry) {
+ die(
+ `Instituição desconhecida no catálogo: "${flags.institution}".\n` +
+ ` Conhecidas: ${institutionSlugs().join(', ')}.\n` +
+ ' Adicione alias/entrada em src/lib/institutionCatalog.ts ou use o escape one-off: --slug= --name="".',
+ )
+ }
+ return {
+ slug: entry.slug,
+ name: entry.name,
+ kind: entry.kind,
+ sphere: entry.sphere,
+ scope: entry.scope,
+ aliases: [...entry.aliases],
+ topics: [...entry.topics],
+ }
+ }
+
+ const slug = typeof flags.slug === 'string' ? flags.slug.trim() : ''
+ const name = typeof flags.name === 'string' ? flags.name.trim() : ''
+ if (!slug || !name) {
+ die(
+ 'Uso: --institution= --out=\n' +
+ ' Ou escape one-off: --slug= --name="" [--kind= --sphere= --scope= --topics=a,b] --out=\n' +
+ ' Env: INSTITUTION_REPORT_CONFIRM=1 (obrigatório), DATABASE_URL (read-only é forçado).',
+ )
+ }
+ const topics =
+ typeof flags.topics === 'string'
+ ? flags.topics
+ .split(',')
+ .map((topic) => topic.trim())
+ .filter(Boolean)
+ : []
+ return {
+ slug,
+ name,
+ kind: oneOf(flags.kind, INSTITUTION_KINDS, 'outro'),
+ sphere: oneOf(flags.sphere, INSTITUTION_SPHERES, 'nao_governamental'),
+ scope: oneOf(flags.scope, INSTITUTION_SCOPES, 'nacional'),
+ aliases: [],
+ topics,
+ }
+}
+
+loadCliEnv()
+
+const { flags } = parseEqualsFlags(process.argv.slice(2))
+const out = typeof flags.out === 'string' ? flags.out : null
+const identity = resolveIdentity(flags)
+
+if (!out) {
+ die('Uso: --institution= --out= (ou o escape --slug/--name).')
+}
+
+const rawDatabaseUrl = enterReadOnlySession({
+ confirmEnv: 'INSTITUTION_REPORT_CONFIRM',
+ label: LABEL,
+ die,
+ context: `instituição ${identity.slug}`,
+})
+
+const { default: configPromise } = await import('../src/payload.config.ts')
+const { getPayload } = await import('payload')
+const { composeInstitutionSnapshot } = await import('./institutionSnapshot.mjs')
+
+const payload = await getPayload({ config: configPromise })
+
+try {
+ const actor = await resolveCampaignActor(payload, { die })
+ console.log(`[${LABEL}] ator: ${actor.name ?? actor.id} (${actor.role})`)
+
+ const snapshot = await composeInstitutionSnapshot({
+ payload,
+ actor,
+ identity: {
+ ...identity,
+ kindLabel: INSTITUTION_KIND_LABELS[identity.kind],
+ sphereLabel: INSTITUTION_SPHERE_LABELS[identity.sphere],
+ scopeLabel: INSTITUTION_SCOPE_LABELS[identity.scope],
+ },
+ readAt: new Date().toISOString(),
+ codeSha: codeSha(),
+ database: databaseLabel(rawDatabaseUrl),
+ })
+
+ const outPath = resolve(ROOT, out)
+ await mkdir(dirname(outPath), { recursive: true })
+ await writeFile(outPath, `${JSON.stringify(snapshot, null, 2)}\n`)
+ console.log(
+ `[${LABEL}] snapshot de ${snapshot.institution.name} → ${out} ` +
+ `(falas=${snapshot.speeches.totalCount} temas=${snapshot.speeches.topics.join(',') || '—'} lacunas=${snapshot.gaps.length})`,
+ )
+} catch (error) {
+ die(error instanceof Error ? error.message : String(error))
+}
+
+process.exit(0)
diff --git a/scripts/institutionSnapshot.mjs b/scripts/institutionSnapshot.mjs
new file mode 100644
index 00000000..4250bb22
--- /dev/null
+++ b/scripts/institutionSnapshot.mjs
@@ -0,0 +1,145 @@
+/**
+ * Snapshot composer for the institutional dossiê (C187).
+ *
+ * Runs on the homeserver against production, read-only: the institutional
+ * recorte does not exist as a relationship on `speech`, so the bridge is
+ * theme→institution (`identity.topics`, from the catalog). Reuses the C163
+ * speech loaders (no parallel source of truth), projects out PII and returns
+ * plain JSON. The render step never touches the database again.
+ *
+ * Integration layer, not a pure lib: it imports Payload-coupled utilities and
+ * is exercised by `tests/int/institutionSnapshot.int.spec.ts`.
+ */
+
+import { normalizeForSearch } from '../src/lib/speechSearch.ts'
+import { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
+import { buildSpeechListWhere } from '../src/utilities/speech/speechListFilters.ts'
+import { loadSegmentsForSpeeches } from '../src/utilities/speech/speechPageData.ts'
+
+const SPEECH_LIMIT = 15
+
+const SPEECH_SELECT = {
+ speechAt: true,
+ type: true,
+ phase: true,
+ summary: true,
+ officialTextUrl: true,
+ youtubeUrl: true,
+ vodPlaybackUrl: true,
+ excerptTMs: true,
+ eventStartAt: true,
+ topics: true,
+}
+
+/**
+ * Projects one speech: the official summary says what it is; `mentionExcerpt`
+ * is the passage that literally names the institution (null when the acervo tag
+ * has no literal mention in the ASR segments).
+ */
+const projectSpeechRow = (doc, segments, matchNames) => {
+ const candidates = matchNames
+ .map((name) => ({ name, key: normalizeForSearch(name) }))
+ .filter((candidate) => candidate.key)
+ const matched = candidates.find(({ key }) =>
+ segments.some((segment) => normalizeForSearch(segment.text).includes(key)),
+ )
+ const mentionSegment = matched
+ ? segments.find((segment) => normalizeForSearch(segment.text).includes(matched.key))
+ : undefined
+ return {
+ id: doc.id,
+ speechAt: doc.speechAt ?? null,
+ type: doc.type ?? null,
+ phase: doc.phase ?? null,
+ summary: doc.summary ?? null,
+ officialTextUrl: doc.officialTextUrl ?? null,
+ youtubeUrl: doc.youtubeUrl ?? null,
+ vodPlaybackUrl: doc.vodPlaybackUrl ?? null,
+ youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt),
+ mentionExcerpt: mentionSegment?.text ?? null,
+ matchedName: matched?.name ?? null,
+ topics: Array.isArray(doc.topics) ? doc.topics : [],
+ }
+}
+
+/**
+ * @param {{
+ * payload: any,
+ * actor: any,
+ * identity: { slug: string, name: string, kind: string, kindLabel: string, sphere: string, sphereLabel: string, scope: string, scopeLabel: string, aliases: string[], topics: string[] },
+ * readAt: string,
+ * codeSha?: string | null,
+ * database?: string | null,
+ * }} params
+ * @returns {Promise}
+ */
+export const composeInstitutionSnapshot = async ({
+ payload,
+ actor,
+ identity,
+ readAt,
+ codeSha = null,
+ database = null,
+}) => {
+ const topics = Array.isArray(identity.topics) ? identity.topics : []
+ const matchNames = [identity.name, ...(identity.aliases ?? [])]
+
+ let docs = []
+ let totalCount = 0
+ const gaps = []
+ if (topics.length === 0) {
+ gaps.push({
+ id: 'acervo_sem_tema',
+ reason: 'A instituição não declara temas no catálogo — o recorte do acervo fica vazio.',
+ })
+ } else {
+ const result = await payload.find({
+ collection: 'speech',
+ where: buildSpeechListWhere({ page: 1, topics }),
+ depth: 0,
+ limit: SPEECH_LIMIT,
+ sort: '-speechAt',
+ select: SPEECH_SELECT,
+ user: actor,
+ overrideAccess: false,
+ })
+ docs = result.docs
+ totalCount = result.totalDocs
+ if (docs.length === 0) {
+ gaps.push({
+ id: 'acervo_sem_falas',
+ reason: `Nenhuma fala do acervo nos temas ${topics.join(', ')} — o acervo cobre 2011+.`,
+ })
+ }
+ }
+
+ const segmentsBySpeech = await loadSegmentsForSpeeches(
+ payload,
+ actor,
+ docs.map((doc) => doc.id),
+ )
+
+ return {
+ meta: { readAt, codeSha, database, kind: 'institution' },
+ institution: {
+ slug: identity.slug,
+ name: identity.name,
+ kind: identity.kind,
+ kindLabel: identity.kindLabel,
+ sphere: identity.sphere,
+ sphereLabel: identity.sphereLabel,
+ scope: identity.scope,
+ scopeLabel: identity.scopeLabel,
+ aliases: identity.aliases ?? [],
+ topics,
+ },
+ speeches: {
+ topics,
+ totalCount,
+ rows: docs.map((doc) =>
+ projectSpeechRow(doc, segmentsBySpeech.get(doc.id) ?? [], matchNames),
+ ),
+ },
+ gaps,
+ }
+}
diff --git a/scripts/lib/buildPdf.mjs b/scripts/lib/buildPdf.mjs
new file mode 100644
index 00000000..c2b81719
--- /dev/null
+++ b/scripts/lib/buildPdf.mjs
@@ -0,0 +1,95 @@
+/**
+ * A4 print plumbing for the report builders (owner: the C163/C186/C187
+ * pipeline). Chromium comes from `@playwright/test`; every document is one
+ * `.sheet` per page with a `data-page` anchor, and no page may be cut by the
+ * overflow — if it does not fit, the content budget is reduced, the layout is
+ * never squeezed.
+ *
+ * Extracted from `build-dossie-solla-cidade.mjs` when the institution builder
+ * (C187) became the third consumer, so there is one owner of the mm→px budget,
+ * the launch and the emit.
+ */
+
+import { chromium } from '@playwright/test'
+
+const MM_TO_PX = 96 / 25.4
+export const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX)
+export const A4_WIDTH_PX = Math.round(210 * MM_TO_PX)
+/** Rounding slack of the mm→px conversion; content must still fit the page. */
+export const PAGE_FIT_TOLERANCE_PX = 10
+export const A4_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
+
+export const launchPdfBrowser = () => chromium.launch()
+
+/** Every `[data-page]` taller than the budget, in document order. */
+const measurePageOverflows = (page, budgetPx = A4_PAGE_BUDGET_PX) =>
+ page.evaluate(
+ (max) =>
+ [...document.querySelectorAll('[data-page]')]
+ .map((element) => ({
+ page: element.getAttribute('data-page'),
+ height: element.scrollHeight,
+ }))
+ .filter((row) => row.height > max),
+ budgetPx,
+ )
+
+/** Throws with the list of overflowing pages — callers turn it into `die`. */
+export const assertPageFits = (overflows, label, hint) => {
+ if (overflows.length === 0) return
+ throw new Error(
+ `${label} estourou o A4 (${overflows
+ .map((row) => `${row.page}: ${row.height}px`)
+ .join(', ')} > ${A4_PAGE_BUDGET_PX}px úteis). ${hint}`,
+ )
+}
+
+const printA4Pdf = (page, outPath) =>
+ page.pdf({
+ path: outPath,
+ printBackground: true,
+ preferCSSPageSize: true,
+ margin: { top: '0', bottom: '0', left: '0', right: '0' },
+ })
+
+/**
+ * One browser run, two HTML documents: sets each document, asserts its anchor
+ * exists, guards the fit and emits the PDF. Shared by the C186 (city) and C187
+ * (institution) builders — the pages differ, the emit contract does not.
+ */
+export const emitHtmlPairPdf = async (
+ browser,
+ { dossierHtml, bulletinHtml, dossierPdf, bulletinPdf },
+) => {
+ const page = await browser.newPage()
+ await page.setViewportSize({ width: A4_WIDTH_PX, height: A4_HEIGHT_PX })
+
+ await page.setContent(dossierHtml, { waitUntil: 'load' })
+ await page.emulateMedia({ media: 'print' })
+ const hasSummary = await page.evaluate(() =>
+ Boolean(document.querySelector('[data-page="resumo"]')),
+ )
+ if (!hasSummary)
+ throw new Error('Página de resumo ausente no HTML do dossiê — renderer quebrado.')
+ assertPageFits(
+ await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
+ 'Página(s) do dossiê',
+ 'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
+ )
+ await printA4Pdf(page, dossierPdf)
+
+ await page.setContent(bulletinHtml, { waitUntil: 'load' })
+ await page.emulateMedia({ media: 'print' })
+ const hasBulletin = await page.evaluate(() =>
+ Boolean(document.querySelector('[data-page="boletim"]')),
+ )
+ if (!hasBulletin) throw new Error('Bloco do boletim ausente no HTML — renderer quebrado.')
+ assertPageFits(
+ await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
+ 'O boletim',
+ 'Corte itens/caps — o boletim TEM de caber em uma página.',
+ )
+ await printA4Pdf(page, bulletinPdf)
+
+ await page.close()
+}
diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs
index 4bc51303..4acf0994 100644
--- a/scripts/lib/dossieBlocks.mjs
+++ b/scripts/lib/dossieBlocks.mjs
@@ -15,6 +15,12 @@ import {
DOSSIER_SCOPE,
careerTimelineHighlights,
} from './dossieCareer.mjs'
+import {
+ INSTITUTION_UNIT,
+ MUNICIPALITY_UNIT,
+ isInstitutionUnit,
+ resolveDossierUnit,
+} from './dossieUnit.mjs'
import { stripInlineSources } from './reportText.mjs'
const MAX_DELIVERIES_PAGE_ONE = 3
@@ -26,8 +32,13 @@ const MAX_ERA_CAMARA_ACTIONS = 4
const MAX_REGION_ITEMS = 3
const MAX_SCOPE_LIST = 3
-const sphereLabels = { municipio: 'município', regiao: 'região', polo: 'polo' }
-export const dossierSphereLabel = (sphere) => sphereLabels[sphere] ?? sphere
+/** @param {string} sphere @param {any} [unit] @returns {string} */
+export const dossierSphereLabel = (sphere, unit = MUNICIPALITY_UNIT) =>
+ resolveDossierUnit(unit).sphereLabels?.[sphere] ?? sphere
+
+/** @param {string} sphere @param {any} [unit] @returns {string} */
+export const dossierSphereBadgeClass = (sphere, unit = MUNICIPALITY_UNIT) =>
+ resolveDossierUnit(unit).sphereBadgeClass?.[sphere] ?? ''
const phaseLabels = {
autorizado: 'autorizado',
empenhado: 'empenhado',
@@ -130,8 +141,42 @@ const camaraItemsAsActions = (camara, era) =>
}))
/**
- * Builds the dossiê report model. `research` is the merged per-era research
- * (see `mergeDossierResearch`); `emendas`/`camara`/`health` may be gaps.
+ * @typedef {Object} DossierReport
+ * @property {any} meta
+ * @property {any} cover
+ * @property {any} page1
+ * @property {any[]} trajectory
+ * @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, method: string, recovery: string, numbers: any[], actions: any[], honors: any[] }>} eras
+ * @property {{
+ * ruleTitle: string,
+ * ruleBody: string,
+ * municipal: { label: string, items: any[], total: number },
+ * regional: { label: string, items: any[], total: number },
+ * items: any[],
+ * context: any[],
+ * hook: any,
+ * priorityGap: any,
+ * }} region
+ * @property {{
+ * ruleTitle: string,
+ * ruleBody: string,
+ * lists: Array<{ key: string, label: string, sphere: string, items: any[], total: number, omitted: number }>,
+ * evidence: { items: any[], total: number, omitted: number },
+ * hook: any,
+ * priorityGap: any,
+ * }} reach
+ * @property {{ items: any[], total: number, omitted: number }} acervo
+ * @property {any[]} gaps
+ * @property {any[]} news
+ * @property {any} limits
+ * @property {any[]} bulletinFacts
+ * @property {any} [unit]
+ */
+
+/**
+ * Builds the dossiê report model. Dispatches on the unit descriptor: the
+ * municipality (C186) branch remains the default and byte-identical; the
+ * institution branch (C187) shares helpers but has its own sections.
*
* @param {{
* snapshot: any,
@@ -140,9 +185,14 @@ const camaraItemsAsActions = (camara, era) =>
* camara?: any,
* health?: any,
* generatedAt?: Date,
+ * unit?: any,
* }} params
+ * @returns {DossierReport}
*/
-export const buildDossierReport = ({
+export const buildDossierReport = (params) =>
+ isInstitutionUnit(params?.unit) ? buildInstitutionReport(params) : buildMunicipalityReport(params)
+
+const buildMunicipalityReport = ({
snapshot,
research,
emendas = null,
@@ -189,7 +239,7 @@ export const buildDossierReport = ({
const honors =
era.id === 'C'
? items
- .filter((item) => item.id === 'era_c_titulos')
+ .filter((item) => item.era === era.id && item.id === 'era_c_titulos')
.map((item) => ({
text: item.answer,
brief: item.brief ?? null,
@@ -385,4 +435,296 @@ export const buildDossierReport = ({
}
}
+/* ------------------------------------------------------------------ *
+ * Institution branch (C187) — same owner, institution-shaped sections. *
+ * ------------------------------------------------------------------ */
+
+const MAX_INSTITUTION_DELIVERIES = 3
+const MAX_INSTITUTION_TIMELINE = 3
+const MAX_INSTITUTION_HOOKS = 2
+const MAX_INSTITUTION_PENDING = 4
+const MAX_INSTITUTION_SCOPE_LIST = 5
+const MAX_INSTITUTION_EVIDENCE = MAX_INSTITUTION_SCOPE_LIST * 2
+const MAX_INSTITUTION_ACERVO = 6
+
+const INSTITUTION_SCOPE =
+ 'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Instituição, setor e rede separados; sem fonte, não entra como entrega.'
+
+const institutionEraMethod = {
+ A: 'Registros anteriores a 2007 que citem nominalmente a instituição ou comprovem vínculo. Cargo geral não prova ação institucional.',
+ B: 'A gestão estadual é lida por equipamento, programa, convênio e obra; cada linha informa abrangência e fonte. Setor e rede não somam à instituição.',
+ C: 'Mandato, relatorias, parcerias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
+}
+
+const institutionEraRecovery = {
+ A: 'biografias oficiais · atos e diários · acervos institucionais · busca web datada',
+ B: 'DOE-BA (DOOL) · notícias SESAB/instituição · Transparência Bahia · busca web datada',
+ C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
+}
+
+/**
+ * Wraps a capped list so the renderer can declare what stayed out ("e mais N")
+ * instead of dropping it silently — the C188 no-truncation rule, enforced at
+ * the owner for both recortes.
+ */
+const capped = (list, max) => ({
+ items: limit(list, max),
+ total: list.length,
+ omitted: Math.max(0, list.length - max),
+})
+
+const identityBadges = (identity) =>
+ [identity.kindLabel, identity.sphereLabel, identity.scope ? identity.scopeLabel : null].filter(
+ Boolean,
+ )
+
+/**
+ * Speeches from the internal acervo (read-only snapshot) that the research did
+ * not already carry: they are sourced evidence for the institution recorte, so
+ * they feed the bulletin ledger too — never a second, unsourced fact.
+ */
+const institutionSpeechFacts = (snapshot) =>
+ (snapshot.speeches?.rows ?? [])
+ .filter((row) => Boolean(row.officialTextUrl || row.youtubeUrl || row.vodPlaybackUrl))
+ .map((row) => ({
+ id: `fala-${row.id}`,
+ era: 'C',
+ sphere: 'instituicao',
+ area: 'Acervo de falas',
+ headline:
+ row.summary ??
+ row.mentionExcerpt ??
+ `Pronunciamento na Câmara (${String(row.speechAt ?? '').slice(0, 4)})`,
+ detail: row.mentionExcerpt ?? null,
+ value: null,
+ numberLabel: null,
+ year: row.speechAt ? String(row.speechAt).slice(0, 4) : null,
+ phase: null,
+ sourceUrl: row.officialTextUrl ?? row.youtubeUrl ?? row.vodPlaybackUrl,
+ sourceDate: row.speechAt ?? null,
+ }))
+
+const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }) => {
+ const identity = snapshot.institution ?? {}
+ const subjectName = identity.name ?? identity.slug ?? 'Instituição'
+ const unit = INSTITUTION_UNIT
+ const speechFacts = institutionSpeechFacts(snapshot)
+
+ const items = research.items ?? []
+ const inScope = (sphere) => items.filter((item) => item.sphere === sphere)
+ const directItems = inScope('instituicao')
+ const sectorItems = inScope('setor')
+ const networkItems = inScope('rede')
+
+ const researchActionsByEra = (era) =>
+ items
+ .filter((item) => item.era === era)
+ .map((item) => ({
+ sphere: item.sphere,
+ year: item.numbers?.[0]?.year ?? null,
+ title: item.answer,
+ detail: item.details,
+ brief: item.brief ?? null,
+ sourceUrl: item.sourceUrl,
+ sourceDate: item.sourceDate,
+ }))
+
+ const eraSections = DOSSIER_ERAS.map((era) => {
+ const numbers = limit(
+ itemNumberRows(items.filter((item) => item.era === era.id)),
+ MAX_ERA_NUMBERS,
+ )
+ const actions = limit(researchActionsByEra(era.id), MAX_ERA_CAMARA_ACTIONS)
+ const honors = items
+ .filter((item) => item.era === era.id && item.id === 'era_c_titulos')
+ .map((item) => ({
+ text: item.answer,
+ detail: item.details ?? null,
+ brief: item.brief ?? null,
+ date: item.sourceDate ?? null,
+ year: item.numbers?.[0]?.year ?? null,
+ sourceUrl: item.sourceUrl,
+ }))
+ return {
+ ...era,
+ method: institutionEraMethod[era.id],
+ recovery: institutionEraRecovery[era.id],
+ numbers,
+ actions,
+ honors,
+ empty: numbers.length === 0 && actions.length === 0,
+ }
+ })
+
+ const deliveryList = [...directItems]
+ .sort((left, right) => {
+ const leftEra = left.era === 'C' ? 0 : 1
+ const rightEra = right.era === 'C' ? 0 : 1
+ if (leftEra !== rightEra) return leftEra - rightEra
+ const leftNumber = left.numbers?.length ? 0 : 1
+ const rightNumber = right.numbers?.length ? 0 : 1
+ return leftNumber - rightNumber
+ })
+ .map((item) => ({
+ sphere: item.sphere,
+ era: item.era,
+ year: item.numbers?.[0]?.year ?? null,
+ title: item.answer,
+ detail: item.details,
+ brief: item.brief ?? null,
+ value: item.numbers?.[0]?.value ?? null,
+ phase: item.numbers?.[0]?.phase ?? null,
+ sourceUrl: item.sourceUrl,
+ sourceDate: item.sourceDate,
+ }))
+ const deliveries = capped(deliveryList, MAX_INSTITUTION_DELIVERIES)
+
+ const hookList = items.map((item) => ({
+ topic: item.area,
+ angle: item.answer,
+ brief: item.brief ?? null,
+ sourceUrl: item.sourceUrl,
+ }))
+ const hooks = capped(hookList, MAX_INSTITUTION_HOOKS)
+
+ const acervo = capped(
+ speechFacts.map((fact) => ({
+ id: fact.id,
+ period: fact.year ?? '—',
+ text: fact.headline,
+ excerpt: fact.detail,
+ sourceUrl: fact.sourceUrl,
+ })),
+ MAX_INSTITUTION_ACERVO,
+ )
+
+ const timelineList = items
+ .filter((item) => item.sourceUrl)
+ .map((item) => ({
+ period: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? '—',
+ role: item.answer,
+ brief: item.brief ?? null,
+ source: item.label,
+ url: item.sourceUrl,
+ }))
+ .sort((left, right) => String(left.period).localeCompare(String(right.period)))
+ const timeline = capped(timelineList, MAX_INSTITUTION_TIMELINE)
+
+ const gaps = (research.gaps ?? []).map((gap) => ({
+ label: gap.label ?? gap.id,
+ reason: gap.reason,
+ nextStep: 'Apurar em fonte primária.',
+ }))
+ const pendingList = (research.gaps ?? []).map((gap) => gap.label ?? gap.id)
+ const pending = capped(pendingList, MAX_INSTITUTION_PENDING)
+
+ const scopeItem = (sphere, key, label) => {
+ const sphereItems = items.filter((item) => item.sphere === sphere)
+ return { key, label, sphere, ...capped(sphereItems, MAX_INSTITUTION_SCOPE_LIST) }
+ }
+ const scopeLists = [
+ scopeItem('instituicao', 'institution', unit.sphereLabels.instituicao),
+ scopeItem('setor', 'sector', unit.sphereLabels.setor),
+ scopeItem('rede', 'network', unit.sphereLabels.rede),
+ ]
+ const scopeEvidence = capped(
+ [...sectorItems, ...networkItems].map((item) => ({
+ item: item.answer,
+ sphere: item.sphere,
+ evidence: item.details ?? item.label,
+ brief: item.brief ?? null,
+ sourceUrl: item.sourceUrl,
+ })),
+ MAX_INSTITUTION_EVIDENCE,
+ )
+
+ const bulletinFacts = items
+ .filter((item) => Boolean(item.sourceUrl))
+ .map((item) => ({
+ id: item.id,
+ era: item.era,
+ sphere: item.sphere,
+ area: item.area,
+ headline: stripInlineSources(item.answer),
+ detail: item.details ? stripInlineSources(item.details) : null,
+ brief: item.brief
+ ? {
+ title: stripInlineSources(item.brief.title),
+ note: item.brief.note ? stripInlineSources(item.brief.note) : null,
+ }
+ : null,
+ value: item.numbers?.[0]?.value ?? null,
+ numberLabel: item.numbers?.[0]?.label ?? null,
+ year: item.numbers?.[0]?.year ?? null,
+ phase: item.numbers?.[0]?.phase ?? null,
+ sourceUrl: item.sourceUrl,
+ sourceDate: item.sourceDate,
+ }))
+ .concat(speechFacts.map((fact) => ({ ...fact, headline: stripInlineSources(fact.headline) })))
+
+ const generatedAtLabel = formatDateTimeBr(generatedAt)
+ const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—'
+
+ return {
+ unit,
+ meta: {
+ title: unit.title,
+ subjectName,
+ identity,
+ identityBadges: identityBadges(identity),
+ generatedAt,
+ generatedAtLabel,
+ readAtLabel,
+ },
+ cover: {
+ kicker: 'Comunicação · pesquisa documental',
+ series: unit.series,
+ internalNote: 'INSUMO INTERNO — defeso 2026',
+ internalNoteSub: 'Não circular · não publicar',
+ eyebrow: 'Dossiê de atuação pública',
+ title: subjectName,
+ subtitle: 'O que Jorge Solla fez por, na e com a instituição ao longo da carreira',
+ howToUse:
+ 'Localize o vínculo ou a entrega, confira fonte, data, fase e abrangência. Transforme toda lacuna em tarefa de apuração. Este arquivo não é fala pronta nem peça pública.',
+ scope: INSTITUTION_SCOPE,
+ version: 'Documento de trabalho · versão 01',
+ },
+ page1: { timeline, deliveries, hooks, pending },
+ trajectory: CAREER_TIMELINE,
+ eras: eraSections,
+ acervo,
+ reach: {
+ ruleTitle: 'Setor/rede não é a instituição. Não some os recortes.',
+ ruleBody:
+ 'Uma política para uma categoria ou uma articulação com entidades correlatas pode alcançar a instituição sem constituir entrega exclusiva para ela.',
+ lists: scopeLists,
+ evidence: scopeEvidence,
+ hook: hooks.items[0] ?? null,
+ priorityGap: pending.items[0] ?? null,
+ },
+ gaps,
+ news: (research.news ?? []).map((row) => ({
+ date: row.publishedAt,
+ outlet: row.outlet ?? '—',
+ title: row.title,
+ url: row.url,
+ })),
+ limits: {
+ coverage: [
+ 'O acervo interno de falas cobre 2011+; períodos anteriores dependem de fontes externas.',
+ 'Resultado de busca não prova ausência histórica.',
+ 'Emenda de bancada ou relator só recebe autoria quando a fonte a confirma.',
+ 'Menção a entidade correlata (setor/rede) não é atribuição à instituição.',
+ ],
+ editorial: [
+ 'Sem fonte, não publica.',
+ 'URL e data acompanham cada afirmação não trivial.',
+ 'Instituição, setor e rede permanecem separados.',
+ 'Valor sempre informa a fase de execução.',
+ ],
+ },
+ bulletinFacts,
+ }
+}
+
export const dossierPhaseLabel = (phase) => phaseLabels[phase] ?? phaseLabels.nao_informado
diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs
index c1ee9588..49540c38 100644
--- a/scripts/lib/dossieBulletin.mjs
+++ b/scripts/lib/dossieBulletin.mjs
@@ -1,22 +1,23 @@
/**
- * Boletim model (C186): one A4 page, voter-facing. It reads ONLY the
- * `bulletinFacts` ledger produced by `buildDossierReport` — every fact there
- * already carries a source, so the boletim cannot introduce a new fact. No
- * sources are printed (they live in the dossiê).
+ * Boletim model (owner: C186; unit seam C187): one A4 page, voter-facing. It
+ * reads ONLY the `bulletinFacts` ledger produced by `buildDossierReport` — every
+ * fact there already carries a source, so the boletim cannot introduce a new
+ * fact. No sources are printed (they live in the dossiê).
*/
import { formatDateTimeBr } from './cityReportFormat.mjs'
-import { dossierSphereLabel } from './dossieBlocks.mjs'
+import { dossierPhaseLabel, dossierSphereLabel } from './dossieBlocks.mjs'
import { BULLETIN_TIMELINE_STEPS } from './dossieCareer.mjs'
+import { resolveDossierUnit } from './dossieUnit.mjs'
export const BULLETIN_HIGHLIGHT_LIMIT = 6
export const BULLETIN_MORE_LIMIT = 14
-const sphereRank = { municipio: 0, regiao: 1, polo: 2 }
const eraRank = { C: 0, B: 1, A: 2 }
-const rankFacts = (facts) =>
- [...facts].sort((left, right) => {
+const rankFacts = (facts, unit) => {
+ const sphereRank = resolveDossierUnit(unit).breadthRank ?? {}
+ return [...facts].sort((left, right) => {
const sphere = (sphereRank[left.sphere] ?? 9) - (sphereRank[right.sphere] ?? 9)
if (sphere !== 0) return sphere
const hasValue = (left.value ? 0 : 1) - (right.value ? 0 : 1)
@@ -25,27 +26,36 @@ const rankFacts = (facts) =>
if (era !== 0) return era
return String(left.id).localeCompare(String(right.id))
})
+}
/**
* @param {{
* facts?: Array<{ id: string, era: string, sphere: string, area: string, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string }>,
- * municipality: string,
+ * municipality?: string,
* region?: string | null,
+ * identity?: { name: string, badges?: string[] } | null,
+ * unit?: any,
* generatedAt?: Date,
* }} params
*/
export const buildBulletin = ({
facts = [],
- municipality,
+ municipality = null,
region = null,
+ identity = null,
+ unit,
generatedAt = new Date(),
}) => {
- const ordered = rankFacts(facts)
+ const resolvedUnit = resolveDossierUnit(unit)
+ const ordered = rankFacts(facts, resolvedUnit)
+ const subjectName = municipality ?? identity?.name ?? '—'
const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({
- eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere)}`,
+ eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere, resolvedUnit)}`,
number: fact.value,
title: fact.brief?.title ?? fact.headline,
- note: fact.brief?.note ?? fact.detail,
+ note: fact.brief?.note ?? fact.detail ?? null,
+ phase: fact.value ? (fact.phase ?? null) : null,
+ phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null,
}))
const moreItems = ordered
.slice(BULLETIN_HIGHLIGHT_LIMIT, BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT)
@@ -55,11 +65,16 @@ export const buildBulletin = ({
}))
return {
+ unit: resolvedUnit,
+ sparse: ordered.length < 3,
meta: {
- title: 'Boletim informativo modelo',
+ title: resolvedUnit.bulletinTitle,
kicker: 'Boletim informativo · atuação pública',
- municipality,
+ subjectName,
+ municipality: subjectName,
region,
+ identity,
+ identityBadges: identity?.badges ?? [],
generatedAt,
generatedAtLabel: formatDateTimeBr(generatedAt),
modelLabel: 'Modelo — insumo interno',
diff --git a/scripts/lib/dossieBulletinRender.mjs b/scripts/lib/dossieBulletinRender.mjs
index 2c3aa1ef..78fbab51 100644
--- a/scripts/lib/dossieBulletinRender.mjs
+++ b/scripts/lib/dossieBulletinRender.mjs
@@ -5,6 +5,7 @@
* as an internal model.
*/
+import { isInstitutionUnit } from './dossieUnit.mjs'
import { htmlEscape } from './reportText.mjs'
const ASSET = (title, lines) =>
@@ -12,10 +13,22 @@ const ASSET = (title, lines) =>
.map((line) => htmlEscape(line))
.join('
')}
`
-const renderHighlight = (highlight) => `
+const phaseModifier = (phase) =>
+ phase === 'pago' || phase === 'liquidado'
+ ? 'phase-paid'
+ : phase === 'empenhado' || phase === 'autorizado'
+ ? 'phase-pending'
+ : ''
+
+const renderHighlight = (highlight, { showPhase = false } = {}) => `
${htmlEscape(highlight.eyebrow)}
${highlight.number ? `${htmlEscape(highlight.number)}
` : ''}
+ ${
+ showPhase && highlight.number && highlight.phaseLabel
+ ? `${htmlEscape(highlight.phaseLabel)}
`
+ : ''
+ }
${htmlEscape(highlight.title)}
${highlight.note ? `${htmlEscape(highlight.note)}
` : ''}
`
@@ -101,7 +114,7 @@ const PRINT_CSS = `
.bulletin-foot .stamp { margin: 0; text-align: right; font-size: 7.2pt; font-weight: 600; line-height: 1.3; color: #435264; }
`
-export const renderBulletinHtml = (bulletin) => `
+const renderMunicipalityBulletinHtml = (bulletin) => `
${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.municipality)}
@@ -192,3 +205,165 @@ export const renderBulletinHtml = (bulletin) => `